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 lives under; `None` = no turn open.
64    open: Option<String>,
65    /// History length the in-flight response continues from.
66    base_len: usize,
67    /// Text streamed in the in-flight response so far.
68    text: String,
69    /// Changed since the last flush.
70    dirty: bool,
71}
72
73/// What `SessionActor::drain_cancelled_stream` recovered from a cancelled
74/// turn's stream.
75#[derive(Default)]
76pub(crate) struct CancelDrain {
77    /// The engine's final history for the turn (last `MessageHistory` seen).
78    pub(crate) history: Option<Vec<crate::SharedMessage>>,
79    /// The stream ended (`Done` / EOF) inside the drain budget.
80    pub(crate) closed: bool,
81}
82
83/// A field that is `None` exactly while the session is `Parked` (B3).
84/// Derefs to the value so the turn machine reads `self.runtime` / `self.conv`
85/// unchanged; every command that needs them goes through `ensure_live()`
86/// first, so the panic path is a programming error, not a runtime state.
87pub(crate) struct Live<T>(Option<T>);
88
89impl<T> Live<T> {
90    pub(crate) fn new(v: T) -> Self {
91        Self(Some(v))
92    }
93    pub(crate) fn is_live(&self) -> bool {
94        self.0.is_some()
95    }
96    pub(crate) fn park_take(&mut self) -> Option<T> {
97        self.0.take()
98    }
99    pub(crate) fn unpark_set(&mut self, v: T) {
100        self.0 = Some(v);
101    }
102}
103
104impl<T> std::ops::Deref for Live<T> {
105    type Target = T;
106    fn deref(&self) -> &T {
107        self.0.as_ref().expect("session is parked: runtime/conv unavailable")
108    }
109}
110
111impl<T> std::ops::DerefMut for Live<T> {
112    fn deref_mut(&mut self) -> &mut T {
113        self.0.as_mut().expect("session is parked: runtime/conv unavailable")
114    }
115}
116
117/// `SYNAPS_DAEMON_PARKED_EVICT_SECS`: how long a PARKED session stays in the
118/// daemon's map before it is evicted (`EndReason::Evicted`). Its state is on
119/// disk; `--continue <id>` rebuilds it exactly as after a daemon restart. The
120/// row only exists so `--attach <id>` / the adopt banner can find it quickly.
121/// Default 1 h; `never` → keep forever.
122pub fn parked_evict_after() -> Option<std::time::Duration> {
123    // Precedence: env > config (`daemon.parked_evict_secs`) > builtin default.
124    let cfg_secs = crate::config::load_config().daemon.parked_evict_secs;
125    parked_evict_after_from(
126        std::env::var("SYNAPS_DAEMON_PARKED_EVICT_SECS").ok().as_deref(),
127        cfg_secs,
128    )
129}
130
131fn parked_evict_after_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
132    let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
133    match v.map(str::trim) {
134        Some("never" | "0" | "off") => None,
135        Some(n) => n
136            .parse::<u64>()
137            .ok()
138            .map(std::time::Duration::from_secs)
139            .or_else(from_cfg),
140        None => from_cfg(),
141    }
142}
143
144/// `SYNAPS_DAEMON_IDLE_END_GRACE_SECS`: how long a ZERO-turn session with no
145/// clients lingers before it ends (F18). Default 5 s — long enough for a
146/// client that disconnected mid-reconnect, short enough that open-and-close
147/// does not pin a Runtime for a minute.
148pub fn idle_end_grace() -> std::time::Duration {
149    std::env::var("SYNAPS_DAEMON_IDLE_END_GRACE_SECS")
150        .ok()
151        .and_then(|v| v.trim().parse::<u64>().ok())
152        .map(std::time::Duration::from_secs)
153        .unwrap_or(std::time::Duration::from_secs(5))
154}
155
156/// `SYNAPS_DAEMON_PARK_GRACE_SECS`: `never` → `None` (Parked disabled);
157/// `n` → n seconds after the last detach once idle; default 60.
158pub fn park_grace() -> Option<std::time::Duration> {
159    match std::env::var("SYNAPS_DAEMON_PARK_GRACE_SECS") {
160        Ok(v) if v.trim().eq_ignore_ascii_case("never") => None,
161        Ok(v) => v
162            .trim()
163            .parse::<u64>()
164            .ok()
165            .map(std::time::Duration::from_secs)
166            .or(Some(DEFAULT_PARK_GRACE)),
167        Err(_) => Some(DEFAULT_PARK_GRACE),
168    }
169}
170
171pub const DEFAULT_PARK_GRACE: std::time::Duration = std::time::Duration::from_secs(60);
172
173/// `SYNAPS_DAEMON_PROMPT_ABANDON_SECS`: how long a pending host confirmation on
174/// a NON-driver session survives after the LAST client detaches before it is
175/// fail-closed answered `None` (deny) so `can_park()` can proceed. This bounds
176/// the resource pin from an ABANDONED prompt WITHOUT punishing a transient
177/// disconnect (SSH drop, sleep, wifi): a re-attach before expiry cancels it and
178/// the user answers the prompt normally. Default 1 h — generous enough that a
179/// human coming back from lunch still answers; `never`/`0`/`off` → `None` =
180/// disabled = pure pre-#112 behaviour (a prompt survives detach forever).
181///
182/// (The driver-armed case fail-closes immediately on last detach; this deadline
183/// is only for a plain interactive session — see `detach` / `rearm_prompt_abandon`.)
184pub fn prompt_abandon_timeout() -> Option<std::time::Duration> {
185    // Precedence: env > config (`daemon.prompt_abandon_secs`) > builtin default.
186    let cfg_secs = crate::config::load_config().daemon.prompt_abandon_secs;
187    prompt_abandon_timeout_from(
188        std::env::var("SYNAPS_DAEMON_PROMPT_ABANDON_SECS").ok().as_deref(),
189        cfg_secs,
190    )
191}
192
193fn prompt_abandon_timeout_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
194    let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
195    match v.map(str::trim) {
196        Some("never" | "0" | "off") => None,
197        Some(n) => n
198            .parse::<u64>()
199            .ok()
200            .map(std::time::Duration::from_secs)
201            .or_else(from_cfg),
202        None => from_cfg(),
203    }
204}
205
206/// Non-persisted runtime knobs replayed after unpark (last-wins per
207/// variant). Model/reasoning live in the journal already; `/system` is
208/// runtime-only (never journaled) so it replays too (M7).
209fn replayable(setting: &SessionSetting) -> bool {
210    matches!(
211        setting,
212        SessionSetting::SystemPrompt { .. }
213            | SessionSetting::ContextWindow { .. }
214            | SessionSetting::CompactionModel { .. }
215            | SessionSetting::ApiRetries { .. }
216            | SessionSetting::SubagentTimeout { .. }
217            | SessionSetting::MaxToolOutput { .. }
218            | SessionSetting::BashTimeout { .. }
219            | SessionSetting::BashMaxTimeout { .. }
220            | SessionSetting::GrantWorkerModel { .. }
221    )
222}
223
224fn replay_setting(rt: &mut Runtime, setting: &SessionSetting) {
225    match setting {
226        SessionSetting::SystemPrompt { text } => rt.set_system_prompt(text.clone()),
227        SessionSetting::ContextWindow { tokens } => rt.set_context_window(*tokens),
228        SessionSetting::CompactionModel { model } => rt.set_compaction_model(model.clone()),
229        SessionSetting::ApiRetries { n } => rt.set_api_retries(*n),
230        SessionSetting::SubagentTimeout { secs } => rt.set_subagent_timeout(*secs),
231        SessionSetting::MaxToolOutput { bytes } => rt.set_max_tool_output(*bytes),
232        SessionSetting::BashTimeout { secs } => rt.set_bash_timeout(*secs),
233        SessionSetting::BashMaxTimeout { secs } => rt.set_bash_max_timeout(*secs),
234        SessionSetting::GrantWorkerModel { model } => {
235            let _ = rt.grant_worker_model(model);
236        }
237        _ => {}
238    }
239}
240
241/// One attached client: its meta + the mode it attached with (B1 ownership
242/// needs the mode to pick the next owner when the owner detaches).
243pub(crate) struct AttachedClient {
244    pub(crate) meta: ClientMeta,
245    pub(crate) mode: AttachMode,
246}
247
248/// B2: a spawned `compact_conversation` (§2.5).
249pub(crate) struct CompactionJob {
250    pub(crate) task: tokio::task::JoinHandle<Result<crate::runtime::compaction::CompactionOutcome>>,
251    pub(crate) source: String,
252    #[allow(dead_code)]
253    pub(crate) started: tokio::time::Instant,
254}
255
256/// Commands that only the input owner may send (B1). Everything else
257/// (`Answer`, `Query`, `Save`, `Detach`, `Attach`, `Resync`, `HostEvent`)
258/// is open to every attached client. `Checkpoint` cancels the owner's turn
259/// and kills its PTYs, so it is owner-only from a client (M1); the daemon
260/// sends it `from: None`.
261fn is_input_command(cmd: &SessionCommand) -> bool {
262    matches!(
263        cmd,
264        SessionCommand::Checkpoint { .. }
265            | SessionCommand::Submit { .. }
266            | SessionCommand::SubmitPrepared { .. }
267            | SessionCommand::Steer { .. }
268            | SessionCommand::Cancel
269            | SessionCommand::Set { .. }
270            | SessionCommand::Compact { .. }
271            | SessionCommand::NewSession
272            | SessionCommand::EngineCommand { .. }
273            | SessionCommand::PluginCommand { .. }
274            | SessionCommand::Resume { .. }
275            | SessionCommand::KeepWarm { .. }
276            | SessionCommand::DriverStart { .. }
277            | SessionCommand::End {
278                reason: EndReason::ClientQuit
279            }
280    )
281}
282
283fn command_name(cmd: &SessionCommand) -> &'static str {
284    match cmd {
285        SessionCommand::Submit { .. } => "submit",
286        SessionCommand::SubmitPrepared { .. } => "submit_prepared",
287        SessionCommand::Steer { .. } => "steer",
288        SessionCommand::Cancel => "cancel",
289        SessionCommand::Answer { .. } => "answer",
290        SessionCommand::Set { .. } => "set",
291        SessionCommand::Compact { .. } => "compact",
292        SessionCommand::NewSession => "new_session",
293        SessionCommand::Save => "save",
294        SessionCommand::Query { .. } => "query",
295        SessionCommand::Attach { .. } => "attach",
296        SessionCommand::Detach { .. } => "detach",
297        SessionCommand::End { .. } => "end",
298        SessionCommand::Resync { .. } => "resync",
299        SessionCommand::EngineCommand { .. } => "engine_command",
300        SessionCommand::PluginCommand { .. } => "plugin_command",
301        SessionCommand::Resume { .. } => "resume",
302        SessionCommand::Checkpoint { .. } => "checkpoint",
303        SessionCommand::KeepWarm { .. } => "keep_warm",
304        SessionCommand::Park => "park",
305        SessionCommand::HostEvent(_) => "host_event",
306        SessionCommand::DriverStart { .. } => "driver_start",
307    }
308}
309
310pub struct SessionActor {
311    pub(crate) id: SessionId,
312    pub(crate) meta: SessionMeta,
313    pub(crate) config: SessionConfig,
314    /// THE runtime; `session_id` + `cwd` set by `create`. `None` ⇔ Parked.
315    pub(crate) runtime: Live<Runtime>,
316    /// `None` ⇔ Parked.
317    pub(crate) conv: Live<ConversationState>,
318    /// B3: unpark needs a fresh `foreground_runtime()`.
319    pub(crate) host: Arc<EngineHost>,
320    /// Kept across Park (`finish()` needs it without a Runtime).
321    pub(crate) hook_bus: Arc<crate::extensions::hooks::HookBus>,
322    /// Session-lifetime queue; `background` pushes into it, every Runtime
323    /// this actor builds is pointed at it (`Runtime::set_event_queue`).
324    pub(crate) event_queue: Arc<crate::events::EventQueue>,
325    /// Last-wins per variant; replayed after unpark (non-persisted knobs).
326    pub(crate) settings_replay: Vec<SessionSetting>,
327    pub(crate) keep_warm: bool,
328    /// Armed on last-detach-while-idle / idle-while-detached.
329    pub(crate) park_deadline: Option<tokio::time::Instant>,
330    /// (P11) Armed when the last client detaches while a pending host prompt is
331    /// held on a NON-driver session; a re-attach cancels it, expiry denies the
332    /// prompt(s) so `can_park()` can proceed. `None` = no abandoned prompt (or
333    /// the deadline is disabled by config).
334    pub(crate) prompt_abandon_deadline: Option<tokio::time::Instant>,
335    // ── turn machine: the run() loop locals + App fields ──
336    pub(crate) stream: Option<ActiveStream>,
337    pub(crate) cancel: Option<CancellationToken>,
338    pub(crate) steer_tx: Option<mpsc::UnboundedSender<String>>,
339    pub(crate) streaming: bool,
340    pub(crate) turn_baseline: usize,
341    pub(crate) consecutive_auto_turns: u32,
342    /// Formatted events `on_queue_wake` steered into this turn's stream
343    /// that the engine has not acknowledged (`SteeringDelivered`) yet. On
344    /// cancel they would die with the steering channel; `cancel_turn` moves
345    /// them into history after the interruption marker instead.
346    pub(crate) turn_steered_events: Vec<String>,
347    /// Set by the engine when the running turn reaches its normal end
348    /// (`TurnCompletion`); read when a cancel races that end.
349    pub(crate) turn_completion: Option<crate::runtime::TurnCompletion>,
350    /// A turn cut from outside `cancel_turn` (driver revocation cancels the
351    /// driver turn's token): the stream is given until this instant to
352    /// deliver its terminal event, then dropped and the turn finished as
353    /// interrupted (`finish_revoked_turn`).
354    pub(crate) revoked_turn_deadline: Option<tokio::time::Instant>,
355    /// In-flight turn draft for crash recovery (persisting sessions only).
356    pub(crate) turn_draft: TurnDraftState,
357    /// Session saves and turn-draft writes, off the turn machine
358    /// (`session::persister`).
359    pub(crate) persister: super::persister::Persister,
360    /// The snapshot last handed to the persister: an identical one is not
361    /// queued again (a turn's end asks several times for the same state).
362    pub(crate) last_queued_save: Option<super::persister::SnapshotKey>,
363    // ── prompts ──
364    pub(crate) secret_prompt_handle: SecretPromptHandle,
365    pub(crate) secret_prompt_rx: mpsc::UnboundedReceiver<SecretPromptRequest>,
366    /// Held across detach; replayed in `AttachSnapshot.pending_prompts`.
367    pub(crate) pending_prompts: VecDeque<(PromptRequest, oneshot::Sender<Option<String>>)>,
368    pub(crate) next_prompt_id: u64,
369    // ── clients ──
370    pub(crate) cmd_rx: mpsc::Receiver<Addressed>,
371    pub(crate) events: broadcast::Sender<Envelope>,
372    pub(crate) view: Arc<arc_swap::ArcSwap<RuntimeView>>,
373    pub(crate) attached: HashMap<ClientId, AttachedClient>,
374    /// B1: the one client whose input commands are honoured (`None` = any
375    /// host-originated sender only).
376    pub(crate) input_owner: Option<ClientId>,
377    pub(crate) next_client_id: u64,
378    pub(crate) seq: u64,
379    pub(crate) turn_replay: VecDeque<Envelope>,
380    pub(crate) state: AttachState,
381    pub(crate) background: BackgroundTasks,
382    /// B2: spawned compaction in flight (`busy` while `Some`).
383    pub(crate) compact: Option<CompactionJob>,
384    /// `await_extensions=false`: fires when process-level discovery is done;
385    /// the actor then emits `on_session_start` without having blocked boot.
386    pub(crate) ext_ready: Option<oneshot::Receiver<()>>,
387    /// 1 Hz `SubagentRows` cadence while a turn runs (`None` when idle).
388    pub(crate) subagent_tick: Option<tokio::time::Interval>,
389    /// Mirrors `handle.lifecycle()` (B3 writes Parking/Parked).
390    pub(crate) lifecycle: Arc<std::sync::atomic::AtomicU8>,
391    /// Mirrors `handle.journal_id()` (B2 stores the successor id).
392    pub(crate) journal_id: Arc<arc_swap::ArcSwap<String>>,
393    /// Mirrors `handle.presence()` (clients / owner / pending prompts).
394    pub(crate) presence: Arc<arc_swap::ArcSwap<super::handle::Presence>>,
395    /// Mirrors `handle.name()`; `sync_name` after any rename.
396    pub(crate) name: Arc<arc_swap::ArcSwap<Option<String>>>,
397    /// F10: per-session journal ownership lock. Held while Live, released on Park.
398    pub(crate) session_lock: Option<agent_core::session_lock::SessionLock>,
399    // ── E: session driver (actor-side, behind `session.drive` permission) ──
400    pub(crate) driver: Option<super::driver::DriverState>,
401    pub(crate) driver_pending: Option<super::driver::DriverPending>,
402    /// Generation counter for invalidating stale pending results.
403    pub(crate) driver_generation: u64,
404    /// Interrupted owner (for generic stop commands across revocation).
405    pub(crate) driver_interrupted_owner: Option<String>,
406    /// 200 ms cadence for `driver_tick()`, always present.
407    pub(crate) driver_tick_interval: tokio::time::Interval,
408}
409
410impl SessionActor {
411    /// = today's `setup::boot()` per session (foreground_runtime →
412    /// resolve_session_and_prompt → set_cwd → model override →
413    /// spawn_session_background → finish_session_setup) then
414    /// `on_session_start` (keyed injection).
415    pub(crate) async fn create(
416        host: &Arc<EngineHost>,
417        mut cfg: SessionConfig,
418    ) -> Result<(SessionHandle, SessionTask)> {
419        // cwd goes in BEFORE apply_config (inside foreground_runtime_for) so the
420        // one-shot memory binding scopes to the session's project (§4).
421        let mut runtime = host.foreground_runtime_for(cfg.cwd.clone()).await?;
422        let config: crate::SynapsConfig = (**host.config()).clone();
423
424        let mut sb = crate::engine::setup::resolve_session_and_prompt(
425            &mut runtime,
426            &cfg.continue_session,
427            cfg.system.as_deref(),
428            cfg.prompt_manifest.as_deref(),
429        )?;
430        // `--name` at create: apply BEFORE the registry entry is written so
431        // `synaps send --session <name>` and `--continue <name>` resolve
432        // from the first second — not only after a later `--continue`.
433        if let Some(name) = cfg.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
434            sb.session
435                .set_name(name)
436                .map_err(|e| crate::RuntimeError::Session(format!("--name {name:?}: {e}")))?;
437            if cfg.persist {
438                // Persist the name now so disk resolution (chain/name → id)
439                // agrees with the live map even before the first turn.
440                if let Err(e) = sb.session.save().await {
441                    tracing::warn!(session = %sb.session.id, "failed to save named session at create: {e}");
442                }
443            }
444        }
445        runtime.set_cwd(cfg.cwd.clone());
446        // T5: rehydrate env from journal when the client sent none.
447        // Precedence: explicit Hello.env from the continuing client > journal > None.
448        // Never fall back to the daemon's own process env.
449        if cfg.env.is_none() {
450            if let Some(ref journal_env) = sb.session.env {
451                cfg.env = Some(journal_env.clone());
452            }
453            if cfg.env_stripped.is_empty() && !sb.session.env_stripped.is_empty() {
454                cfg.env_stripped = sb.session.env_stripped.clone();
455            }
456        }
457        // Always persist the latest env on the session so the next
458        // restart/continue sees it — a fresh client env supersedes
459        // whatever was journaled previously.
460        if cfg.env.is_some() {
461            sb.session.env = cfg.env.clone();
462            sb.session.env_stripped = cfg.env_stripped.clone();
463        }
464        runtime.set_env(cfg.env.clone());
465        runtime.set_env_stripped(cfg.env_stripped.clone());
466        // CLI `--model` overrides whatever was persisted (rpc.rs precedent).
467        if let Some(ref m) = cfg.model_override {
468            runtime.set_model(m.clone());
469        }
470
471        let background =
472            crate::engine::setup::spawn_session_background(&runtime, &sb.session)?;
473        crate::engine::setup::finish_session_setup(
474            &mut runtime,
475            &config,
476            &sb.session,
477            cfg.cwd.clone(),
478            crate::engine::setup::IndexRecord::Start,
479        );
480
481        // C2: per-session on_session_start (keyed injection) once the
482        // process-level discovery is known-finished. Never re-runs discovery.
483        // `await_extensions=false` (TUI): a spawned waiter fires the
484        // `ext_ready` arm instead, so boot never blocks on discovery.
485        // `startup.extensions_ready_timeout_secs` (config, default 30) bounds
486        // the wait on extension discovery; the const remains the fallback.
487        let ext_ready_timeout = {
488            let secs = config.startup.extensions_ready_timeout_secs;
489            if secs == 0 {
490                budgets::EXTENSIONS_READY_TIMEOUT
491            } else {
492                std::time::Duration::from_secs(secs)
493            }
494        };
495        let mut ext_ready = None;
496        if cfg.await_extensions {
497            if tokio::time::timeout(ext_ready_timeout, host.extensions_ready())
498                .await
499                .is_err()
500            {
501                tracing::warn!(
502                    budget_secs = ext_ready_timeout.as_secs(),
503                    "extensions_ready timed out — on_session_start may miss late extensions"
504                );
505            }
506            crate::extensions::loader::emit_session_start(runtime.hook_bus(), &sb.session.id)
507                .await;
508        } else {
509            let (tx, rx) = oneshot::channel();
510            let waiter_host = Arc::clone(host);
511            tokio::spawn(async move {
512                let _ = tokio::time::timeout(ext_ready_timeout, waiter_host.extensions_ready())
513                    .await;
514                let _ = tx.send(());
515            });
516            ext_ready = Some(rx);
517        }
518
519        let id = SessionId::from(sb.session.id.clone());
520
521        // F10: acquire the per-session journal ownership lock.
522        let lock_kind = if cfg.await_extensions { "daemon" } else { "tui" };
523        let session_lock = if sb.continued {
524            // Continuing an existing session — the lock MUST succeed.
525            // If another process holds it, refuse with an actionable error.
526            let dir = agent_core::session_lock::sessions_dir();
527            let holder = agent_core::session_lock::LockHolder {
528                pid: std::process::id(),
529                kind: lock_kind.to_string(),
530            };
531            match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
532                Ok(lock) => Some(lock),
533                Err(agent_core::session_lock::SessionLockError::Held { session_id, holder }) => {
534                    let msg = agent_core::session_lock::SessionLockError::Held { session_id, holder };
535                    return Err(crate::RuntimeError::Session(msg.to_string()));
536                }
537                Err(e) => {
538                    tracing::warn!(session = %sb.session.id, "session lock: {e}");
539                    None
540                }
541            }
542        } else {
543            // Fresh session — best-effort lock.
544            let dir = agent_core::session_lock::sessions_dir();
545            let holder = agent_core::session_lock::LockHolder {
546                pid: std::process::id(),
547                kind: lock_kind.to_string(),
548            };
549            match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
550                Ok(lock) => Some(lock),
551                Err(e) => {
552                    tracing::warn!(session = %sb.session.id, "session lock: {e}");
553                    None
554                }
555            }
556        };
557
558        let meta = SessionMeta {
559            id: id.clone(),
560            name: sb.session.name.clone(),
561            model: runtime.model().to_string(),
562            cwd: cfg.cwd.clone(),
563            created_at: sb.session.created_at,
564            continued: sb.continued,
565            continue_info: sb.continue_info.as_ref().map(ContinueInfoWire::from),
566            host_pid: std::process::id(),
567            lifecycle: SessionLifecycle::Live,
568            clients: 0,
569            input_owner: None,
570            awaiting_input: 0,
571            journal_id: sb.session.id.clone(),
572            locked_by: None,
573        };
574        let mut conv = if sb.continued {
575            ConversationState::from_resumed(sb.session)
576        } else {
577            ConversationState::new(sb.session)
578        };
579        conv.api_messages = sb.api_messages;
580        conv.total_input_tokens = sb.total_input_tokens;
581        conv.total_output_tokens = sb.total_output_tokens;
582        conv.session_cost = sb.session_cost;
583        // Crash recovery (the last process died mid-turn): only the lock
584        // holder may fold the turn draft in, persist it and remove it.
585        if sb.continued && cfg.persist && session_lock.is_some() {
586            crate::engine::setup::recover_turn_draft(&mut conv).await;
587        }
588
589        let view = RuntimeView::from_runtime(&runtime).await;
590        let hook_bus = Arc::clone(runtime.hook_bus());
591        let event_queue = Arc::clone(runtime.event_queue());
592        let keep_warm = cfg.keep_warm;
593        let (
594            handle,
595            SessionEndpoints {
596                cmd_rx,
597                events,
598                view,
599                lifecycle,
600                journal_id,
601                presence,
602                name,
603            },
604        ) = SessionHandle::new(meta.clone(), view);
605        let (sp_tx, secret_prompt_rx) = mpsc::unbounded_channel();
606
607        let actor = SessionActor {
608            id,
609            meta,
610            config: cfg,
611            runtime: Live::new(runtime),
612            conv: Live::new(conv),
613            host: Arc::clone(host),
614            hook_bus,
615            event_queue,
616            settings_replay: Vec::new(),
617            keep_warm,
618            park_deadline: None,
619            prompt_abandon_deadline: None,
620            stream: None,
621            cancel: None,
622            steer_tx: None,
623            streaming: false,
624            turn_baseline: 0,
625            consecutive_auto_turns: 0,
626            turn_steered_events: Vec::new(),
627            turn_completion: None,
628            revoked_turn_deadline: None,
629            turn_draft: TurnDraftState::default(),
630            persister: super::persister::Persister::new(agent_core::session_lock::sessions_dir()),
631            last_queued_save: None,
632            secret_prompt_handle: SecretPromptHandle::new(sp_tx),
633            secret_prompt_rx,
634            pending_prompts: VecDeque::new(),
635            next_prompt_id: 1,
636            cmd_rx,
637            events,
638            view,
639            attached: HashMap::new(),
640            input_owner: None,
641            next_client_id: 1,
642            seq: 0,
643            turn_replay: VecDeque::new(),
644            state: AttachState::Detached { running: false },
645            background,
646            compact: None,
647            ext_ready,
648            subagent_tick: None,
649            lifecycle,
650            journal_id,
651            presence,
652            name,
653            session_lock,
654            driver: None,
655            driver_pending: None,
656            driver_generation: 0,
657            driver_interrupted_owner: None,
658            driver_tick_interval: {
659                let mut interval = tokio::time::interval(Duration::from_millis(200));
660                interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
661                interval
662            },
663        };
664        Ok((handle, SessionTask(actor)))
665    }
666
667    /// Publish `conv.session.name` to the handle (listings, `--continue
668    /// <name>`) and rewrite this session's registry entry so `synaps send
669    /// --session <name>` resolves the new name. Call after any rename.
670    pub(crate) fn sync_name(&mut self) {
671        let name = self.conv.session.name.clone();
672        self.meta.name = name.clone();
673        self.name.store(Arc::new(name.clone()));
674        if let Err(e) = crate::events::registry::update_session_name(self.id.as_str(), name.as_deref()) {
675            tracing::warn!(session = %self.id, "registry name update failed: {e}");
676        }
677    }
678
679    // ── emit ─────────────────────────────────────────────────────────────
680
681    /// The ONLY seq++ site. Pushes to `turn_replay` while streaming, except
682    /// prompt traffic (never replayed), per-client replies, and full-history
683    /// envelopes (`MessageHistory`, `Conversation`): the engine publishes one
684    /// per round, and an attaching client already gets the LATEST history in
685    /// its snapshot's `conversation` — replaying older ones would ship the
686    /// whole history once per round and roll its mirror back to a stale
687    /// state. The ring's `TurnStarted` drops `user_text`: that prompt is
688    /// already in the snapshot's history (a replayed copy would show it
689    /// twice). Completed rounds leave the ring when their checkpoint is
690    /// adopted (`trim_replay_to_checkpoint`).
691    pub(crate) fn emit(&mut self, event: SessionEventWire) {
692        let replay = self.streaming
693            && !matches!(
694                event,
695                SessionEventWire::Prompt(_)
696                    | SessionEventWire::PromptResolved { .. }
697                    | SessionEventWire::Attached { .. }
698                    | SessionEventWire::QueryResult { .. }
699                    | SessionEventWire::Conversation(_)
700                    | SessionEventWire::Stream(StreamEvent::Session(
701                        SessionEvent::MessageHistory(_)
702                    ))
703            );
704        let env = Envelope {
705            session_id: self.id.clone(),
706            seq: self.seq,
707            ts: chrono::Utc::now(),
708            event,
709        };
710        self.seq += 1;
711        if replay {
712            if self.turn_replay.len() >= TURN_REPLAY_CAP {
713                self.turn_replay.pop_front();
714            }
715            let mut ring = env.clone();
716            if let SessionEventWire::TurnStarted { user_text, .. } = &mut ring.event {
717                *user_text = None;
718            }
719            self.turn_replay.push_back(ring);
720        }
721        // No receivers is not an error: streams are not tied to clients.
722        let _ = self.events.send(env);
723    }
724
725    /// A round checkpoint was adopted: every round in it is now in the
726    /// attach snapshot's `conversation`, so the replay ring drops those
727    /// rounds' display events — a mid-turn attach would otherwise render
728    /// them twice (once from history, once from the ring). The engine
729    /// publishes the checkpoint at a round boundary, so a tool call and its
730    /// result are always on the same side of the cut. Kept: the turn's
731    /// `TurnStarted`, notices, subagent progress, `Usage`.
732    fn trim_replay_to_checkpoint(&mut self) {
733        self.turn_replay.retain(|env| {
734            !matches!(
735                env.event,
736                SessionEventWire::Stream(StreamEvent::Llm(_))
737                    | SessionEventWire::Stream(StreamEvent::Agent(
738                        AgentEvent::SteeringDelivered { .. }
739                    ))
740            )
741        });
742    }
743
744    pub(crate) fn emit_conversation(&mut self) {
745        let snap = self.conv.snapshot(self.consecutive_auto_turns);
746        self.emit(SessionEventWire::Conversation(snap));
747    }
748
749    pub(crate) async fn publish_view(&mut self) {
750        let v = RuntimeView::from_runtime(&self.runtime).await;
751        self.view.store(Arc::new(v));
752    }
753
754    /// Queue a save of the current conversation. Never waits: the write
755    /// runs on the actor's persister (latest wins), so a slow disk cannot
756    /// stall the turn machine.
757    pub(crate) fn request_save(&mut self) {
758        if !self.config.persist || !self.conv.is_live() {
759            return;
760        }
761        let Some(session) = self.conv.prepare_save() else {
762            return;
763        };
764        let key = super::persister::SnapshotKey::of(&session);
765        // This exact state is already queued or on disk — unless the last
766        // write failed, in which case it is queued again.
767        if self.last_queued_save.as_ref() == Some(&key) && self.persister.saves_ok() {
768            return;
769        }
770        self.last_queued_save = Some(key);
771        self.persister.save(session);
772    }
773
774    /// Wait, at most `SAVE_TIMEOUT`, until every queued save and draft
775    /// operation has been applied. `false` on a failed save or the timeout
776    /// (the queued writes still land, in order, when the disk catches up).
777    pub(crate) async fn flush_saves(&mut self) -> bool {
778        let ok = match tokio::time::timeout(budgets::SAVE_TIMEOUT, self.persister.flush()).await {
779            Ok(ok) => ok,
780            Err(_) => {
781                tracing::warn!(session = %self.id, "session save still pending after the save budget");
782                false
783            }
784        };
785        if !ok {
786            // Whatever failed is not known to be on disk: queue it again next
787            // time instead of skipping it as a duplicate.
788            self.last_queued_save = None;
789        }
790        ok
791    }
792
793    /// Save now: queue the current conversation and wait for it (bounded,
794    /// see `flush_saves`).
795    pub(crate) async fn save(&mut self) -> bool {
796        self.request_save();
797        self.flush_saves().await
798    }
799
800    /// A turn is over: announce `Idle` only once everything it queued is on
801    /// disk (bounded, `flush_saves`). `Idle` has always meant "the session
802    /// is saved": a client or script that reads the session, quits or
803    /// hands off on `Idle` must never see the previous state. Per-round
804    /// saves stay in the background; this waits once per turn.
805    pub(crate) async fn announce_idle(&mut self) {
806        self.flush_saves().await;
807        self.emit(SessionEventWire::Idle);
808    }
809
810    /// Wall 1 — actor-owned context-head checkpoint persistence.
811    ///
812    /// The receipt stays in-process (actor task → runtime stream task).
813    /// It never crosses the wire — `SessionEventWire` maps this to `Done`.
814    ///
815    /// Park ordering: `can_park()` requires `!self.streaming`, and streaming
816    /// is only cleared on stream completion/error. The checkpoint event
817    /// arrives mid-stream, so park cannot race with a pending checkpoint.
818    /// No explicit drain is needed.
819    async fn handle_context_head_checkpoint(
820        &mut self,
821        session_id: String,
822        messages: Vec<crate::SharedMessage>,
823        receipt: agent_core::core::context_head::ContextHeadReceipt,
824    ) {
825        // Stale session id guard (compaction may have changed it).
826        if session_id != self.conv.session.id {
827            tracing::warn!(
828                event_id = %session_id,
829                actor_id = %self.conv.session.id,
830                "context head checkpoint: stale session id — ignoring"
831            );
832            receipt.complete(Err(std::io::Error::new(
833                std::io::ErrorKind::InvalidInput,
834                "stale session id after compaction",
835            )));
836            return;
837        }
838
839        // Non-persistent sessions (tests / --no-persist): complete without I/O.
840        if !self.config.persist {
841            receipt.complete(Ok(()));
842            return;
843        }
844
845        // A queued ordinary save must not land after (and over) the durable
846        // head: drain the persister first.
847        if !self.persister.flush().await {
848            tracing::warn!(session = %self.id, "context head checkpoint: an earlier save failed");
849        }
850        let result = self.conv.persist_context_head(&session_id, messages).await;
851        if let Err(ref e) = result {
852            tracing::error!(session = %session_id, "context head checkpoint save failed: {e}");
853        }
854        let succeeded = result.is_ok();
855        receipt.complete(result);
856
857        // P5: observe_checkpoint — revoke driver on failure or mismatch.
858        if let Some(driver) = &self.driver {
859            if !succeeded
860                || driver.grant.session_id != session_id
861                || session_id != self.conv.session.id
862            {
863                self.driver_revoke("context checkpoint failed or session replaced");
864            }
865        }
866    }
867
868
869    /// C3: the checkpoint reply payload (`SessionReloadRecord`).
870    pub(crate) fn reload_record(&self) -> SessionReloadRecord {
871        let view = self.view.load();
872        SessionReloadRecord {
873            config: self.config.clone(),
874            keep_warm: self.keep_warm,
875            lifecycle: SessionLifecycle::from_u8(
876                self.lifecycle.load(std::sync::atomic::Ordering::Acquire),
877            ),
878            settings_replay: self.settings_replay.clone(),
879            model: view.model.clone(),
880            thinking_level: view.thinking_level.clone(),
881        }
882    }
883
884    pub(crate) fn publish_presence(&self) {
885        self.presence.store(Arc::new(super::handle::Presence {
886            clients: self.attached.len(),
887            input_owner: self.input_owner,
888            awaiting_input: self.pending_prompts.len(),
889        }));
890    }
891
892    /// Recomputes `state` from clients/streaming and (re)arms or disarms the
893    /// park grace timer. A no-op while Parking/Parked.
894    pub(crate) fn update_attach_state(&mut self) {
895        self.publish_presence();
896        if matches!(self.state, AttachState::Parking | AttachState::Parked) {
897            return;
898        }
899        self.state = if self.attached.is_empty() {
900            AttachState::Detached {
901                running: self.streaming,
902            }
903        } else {
904            AttachState::Attached(self.attached.len())
905        };
906        self.rearm_park();
907        self.rearm_prompt_abandon();
908    }
909
910    // ── Parked (B3) ──────────────────────────────────────────────────────
911
912    pub(crate) fn is_parked(&self) -> bool {
913        matches!(self.state, AttachState::Parked)
914    }
915
916    /// A session with nothing to save (`ConversationState::save` skips an
917    /// empty `api_messages`, so no journal ever exists) never parks: it
918    /// costs nothing warm and could not be unparked from disk (H2).
919    pub(crate) fn can_park(&self) -> bool {
920        self.attached.is_empty()
921            && !self.streaming
922            && self.compact.is_none()
923            && self.pending_prompts.is_empty()
924            && self.conv.is_live()
925            && !self.conv.api_messages.is_empty()
926            && self.conv.queued_message.is_none()
927            && !self.keep_warm
928            && self.config.persist
929            && !self.is_parked()
930            && self.driver.is_none()
931    }
932
933    /// (E-P7, §S3) Return the first breached spend ceiling, if any, as
934    /// `(scope, cost_observed, cap)`. Checks the host-owned per-session cap
935    /// (`config.max_session_cost`) and, when a driver is armed, the effective
936    /// per-run cap (`min(plugin grant, host)`) measured from `cost_at_arm`.
937    /// `None` means every configured ceiling still has headroom.
938    fn cost_cap_breach(&self) -> Option<(&'static str, f64, f64)> {
939        let total = self.conv.session_cost;
940        if let Some(cap) = self.config.max_session_cost {
941            if cap.is_finite() && total >= cap {
942                return Some(("session", total, cap));
943            }
944        }
945        if let Some(driver) = &self.driver {
946            // Host cap for the run is not yet a config field (S3 should-have
947            // #4, daemon-level); pass `None` so the plugin's proposal stands
948            // alone until the daemon cap lands.
949            if let Some(cap) = driver.grant.effective_cost_cap(None) {
950                let run_cost = total - driver.cost_at_arm;
951                if cap.is_finite() && run_cost >= cap {
952                    return Some(("run", run_cost, cap));
953                }
954            }
955        }
956        None
957    }
958
959    /// `<sessions>/<id>.json` — written by both persistence modes.
960    fn journal_exists(&self) -> bool {        let id = (**self.journal_id.load()).clone();
961        crate::config::resolve_write_path("sessions")
962            .join(format!("{id}.json"))
963            .is_file()
964    }
965
966    // ── E: driver arm/revoke ─────────────────────────────────────────────
967
968    /// Invalidate the driver: drop `DriverState`, cancel pending work, emit
969    /// `DriverRevoked` with any undelivered steering, and release the host grant.
970    pub(crate) fn driver_revoke(&mut self, reason: &str) {
971        let undelivered = self
972            .driver
973            .as_mut()
974            .map(|d| d.steering.drain(..).collect::<Vec<_>>())
975            .unwrap_or_default();
976        let was_active = self.driver.is_some() || self.driver_pending.is_some();
977        // Dropping the driver below cancels the driver turn's token.
978        if self.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
979            self.note_cancel_cause(crate::engine::interrupt::InterruptReason::Driver);
980        }
981        if let Some(driver) = &self.driver {
982            self.driver_interrupted_owner = Some(driver.grant.plugin_id.clone());
983            self.host
984                .release_driver(&driver.grant.plugin_id, &self.id);
985        }
986        self.driver_generation = self.driver_generation.wrapping_add(1);
987        self.driver_pending = None;
988        self.driver = None;
989        if was_active {
990            self.emit(SessionEventWire::DriverRevoked {
991                reason: reason.to_string(),
992                undelivered_steering: undelivered,
993            });
994        }
995        // Dropping `DriverState` cancelled the driver turn's token (a child
996        // of the driver's): that turn is over, but not through `cancel_turn`.
997        // Unwind prompts now (as `cancel_turn` does), then give its stream
998        // the drain budget to deliver its cancel-path history and terminal
999        // event (`finish_revoked_turn`); a stream that never started (its
1000        // start task was just aborted) ends at the next loop turn. A caller
1001        // that cancels the turn itself right after (`finish`, `checkpoint`,
1002        // the cost cap) supersedes this: `cancel_turn` clears the deadline.
1003        if self.turn_cancelled() && self.revoked_turn_deadline.is_none() {
1004            self.resolve_pending_prompts();
1005            let wait = if self.stream.is_some() {
1006                budgets::CANCEL_DRAIN_TIMEOUT
1007            } else {
1008                std::time::Duration::ZERO
1009            };
1010            self.revoked_turn_deadline = Some(tokio::time::Instant::now() + wait);
1011        }
1012        self.rearm_park();
1013    }
1014
1015    /// Spawn the plugin's start command as an async task; the result is
1016    /// processed in `driver_tick()` (P4). Invokes the command via the
1017    /// extension manager, just like the TUI's `start_command`.
1018    pub(crate) fn driver_start(&mut self, plugin: String, command: String, arg: String) {
1019        // Revoke any existing driver first (TUI :369).
1020        if self.driver.is_some() || self.driver_pending.is_some() {
1021            self.driver_revoke("explicit command");
1022        }
1023
1024        let manager = self.host.ext_manager().clone();
1025        let session_id = self.id.0.clone();
1026
1027        // Resolve handler + check permissions synchronously.
1028        let (handler, timeout) = match manager.try_read() {
1029            Ok(mgr) => {
1030                let handler = match mgr.user_action_handler(&plugin) {
1031                    Ok(h) => h,
1032                    Err(error) => {
1033                        self.emit(SessionEventWire::SystemNotice(error));
1034                        return;
1035                    }
1036                };
1037                let timeout = if mgr.session_driver_handler(&plugin).is_ok() {
1038                    5
1039                } else {
1040                    self.emit(SessionEventWire::SystemNotice(
1041                        "session driver extension lacks validated session.drive permission".into(),
1042                    ));
1043                    return;
1044                };
1045                (handler, timeout)
1046            }
1047            Err(_) => {
1048                self.emit(SessionEventWire::SystemNotice(
1049                    "extensions are loading or busy — try again shortly".into(),
1050                ));
1051                return;
1052            }
1053        };
1054
1055        let handler_generation = super::driver::live_generation(&handler).ok();
1056
1057        let owner = plugin.clone();
1058        let cmd = command.clone();
1059        let args: Vec<String> = arg.split_whitespace().map(str::to_owned).collect();
1060
1061        let generation = self.driver_generation;
1062        let task_handler = handler.clone();
1063        self.driver_pending = Some(super::driver::DriverPending {
1064            generation,
1065            session_id: session_id.clone(),
1066            task: super::driver::Task(tokio::spawn(async move {
1067                let (sink, collector) =
1068                    invoke_event_channel(InvokeOutputBudget::default());
1069                let request_id = uuid::Uuid::new_v4().to_string();
1070                let (result, report) =
1071                    tokio::time::timeout(Duration::from_secs(timeout), async {
1072                        tokio::join!(
1073                            task_handler.invoke_command(&cmd, args, &request_id, sink),
1074                            collector.collect()
1075                        )
1076                    })
1077                    .await
1078                    .unwrap_or_else(|_| {
1079                        (
1080                            Err(format!("interactive command timed out ({timeout}s)")),
1081                            crate::extensions::invoke_output::InvokeOutputReport {
1082                                events: Vec::new(),
1083                                counters: Default::default(),
1084                            },
1085                        )
1086                    });
1087                super::driver::TaskResult::Command {
1088                    owner,
1089                    command,
1090                    handler: task_handler,
1091                    handler_generation,
1092                    result,
1093                    report,
1094                }
1095            })),
1096        });
1097    }
1098
1099    /// Arm the driver after a successful start command result. Mirrors
1100    /// the TUI's `arm()` (:538-602). Called from P4's `driver_tick()`.
1101    pub(crate) fn driver_arm(
1102        &mut self,
1103        owner: String,
1104        handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
1105        handler_generation: u64,
1106        reply: Reply,
1107    ) -> std::result::Result<(), String> {
1108        let manager = self.host.ext_manager().clone();
1109        super::driver::same_handler(&manager, &owner, &handler, handler_generation)?;
1110
1111        if self.driver.is_some() {
1112            return Err("cannot replace an armed grant".into());
1113        }
1114
1115        let session_id = self.id.0.clone();
1116        let (grant, proposal) = Grant::from_start(&owner, &session_id, reply)?;
1117
1118        // Claim the host-level single-tenancy grant.
1119        self.host
1120            .claim_driver(&owner, &self.id)
1121            .map_err(|e| e.to_string())?;
1122
1123        let cancel = CancellationToken::new();
1124        let workers = self.runtime.subagent_registry().clone();
1125        let worker_epoch = workers
1126            .lock()
1127            .unwrap_or_else(|p| p.into_inner())
1128            .set_spawn_cancellation(Some(cancel.clone()));
1129        let deadline_task = grant.deadline().map(|deadline| {
1130            let c = cancel.clone();
1131            let w = workers.clone();
1132            super::driver::Task(tokio::spawn(async move {
1133                tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
1134                c.cancel();
1135                super::driver::cancel_workers(&w, worker_epoch);
1136            }))
1137        });
1138
1139        let models = grant.models().to_vec();
1140        let proposal_notice = proposal.notice.clone();
1141        let selection = proposal.selection.clone();
1142        let deadline_ms = super::driver::deadline_ms(&grant);
1143        let run_id = grant.run_id.clone();
1144        let cost_at_arm = self.conv.session_cost;
1145
1146        let mut state = super::driver::DriverState {
1147            selection: proposal.selection.clone(),
1148            grant,
1149            handler,
1150            handler_generation,
1151            cancel,
1152            workers,
1153            worker_epoch,
1154            deadline_task,
1155            proposal: None,
1156            awaiting_terminal: false,
1157            outcome: None,
1158            feedback: crate::extensions::feedback::Tracker::default(),
1159            completed_feedback: "unknown",
1160            steering: VecDeque::new(),
1161            auto_wakes_blocked: false,
1162            cost_at_arm,
1163        };
1164
1165        super::driver::schedule(&mut state, proposal)?;
1166
1167        // Apply context mode (session-only, like /context auto/off).
1168        let context_notice = state.grant.apply_context_mode(&self.runtime)?;
1169        if let Some(text) = context_notice {
1170            self.emit(SessionEventWire::SystemNotice(text));
1171        }
1172
1173        self.driver_interrupted_owner = None;
1174        self.driver = Some(state);
1175
1176        self.emit(SessionEventWire::DriverArmed {
1177            plugin_id: owner,
1178            run_id,
1179            models,
1180            selection,
1181            deadline_ms,
1182            notice: proposal_notice,
1183        });
1184
1185        Ok(())
1186    }
1187
1188    // ── E-P4: driver_tick ────────────────────────────────────────────────
1189
1190    /// Actor-equivalent of the TUI's `idle_conflict()` (TUI :437-463).
1191    /// Returns `Some(reason)` when the driver must defer or revoke.
1192    fn driver_idle_conflict(&self) -> Option<&'static str> {
1193        if self.conv.queued_message.is_some() || !self.conv.pending_events.is_empty() {
1194            return Some("other queued work");
1195        }
1196        if self.compact.is_some() {
1197            return Some("compaction");
1198        }
1199        if self.ext_ready.is_some() {
1200            return Some("extensions loading or reloading");
1201        }
1202        if self.conv.context_head.is_blocked(&self.conv.session) {
1203            return Some("unverified context head");
1204        }
1205        if self.driver_completion_blocked() {
1206            return Some("outstanding workers require collection/reconciliation");
1207        }
1208        None
1209    }
1210
1211    /// Actor-equivalent of the TUI's `completion_blocked()` (TUI :465-480).
1212    fn driver_completion_blocked(&self) -> bool {
1213        use agent_core::orchestration::CompletionGate;
1214        self.runtime.orchestration().is_some_and(|o| {
1215            !matches!(o.completion_gate(), CompletionGate::Allowed)
1216        }) || self
1217            .runtime
1218            .subagent_registry()
1219            .lock()
1220            .map(|r| {
1221                r.list_active().iter().any(|(_, _, status)| {
1222                    *status == crate::runtime::subagent::SubagentStatus::Running
1223                })
1224            })
1225            .unwrap_or(true)
1226    }
1227
1228    /// 200 ms timer arm — the core driver loop. Every invocation performs only
1229    /// bounded state transitions; all plugin and provider IO is in abort-on-drop
1230    /// spawned tasks. Mirrors the TUI's `tick()` (TUI :695-1027).
1231    pub(crate) async fn driver_tick(&mut self) {
1232        use crate::extensions::session_driver::Outcome;
1233
1234        // ── 1. Cleanup cancelled stream setup ────────────────────────────
1235        // (e.g. the grant's deadline cancelled the driver token before the
1236        // turn's stream started): an interrupted turn, ended like any other.
1237        if self.stream.is_none() && self.turn_cancelled() {
1238            self.finish_revoked_turn(false).await;
1239        }
1240
1241        // ── 2. Validate lifecycle ────────────────────────────────────────
1242        if let Some(driver) = &self.driver {
1243            let invalid = if driver.grant.session_id != self.id.0 {
1244                Some("session replaced".to_string())
1245            } else if driver.grant.expired() {
1246                Some("grant deadline reached".into())
1247            } else if driver.cancel.is_cancelled() {
1248                Some("canceled".into())
1249            } else {
1250                let manager = self.host.ext_manager().clone();
1251                super::driver::same_handler(
1252                    &manager,
1253                    &driver.grant.plugin_id,
1254                    &driver.handler,
1255                    driver.handler_generation,
1256                )
1257                .err()
1258            };
1259            if let Some(reason) = invalid {
1260                self.driver_revoke(&reason);
1261                return;
1262            }
1263        }
1264
1265        // ── 3. Idle conflict (only when driver is armed) ─────────────────
1266        if self.driver.is_some() {
1267            // (E-P7, §S1) No clients → no headless spend. `detach()` revokes on
1268            // the last-client transition; this is the belt-and-suspenders check
1269            // for any path that leaves a grant armed with zero clients.
1270            if self.attached.is_empty() {
1271                self.driver_revoke("no clients attached");
1272                return;
1273            }
1274            // F10/F14: use the full driver_idle_conflict() instead of an
1275            // inline subset that was missing `completion_blocked`.
1276            if let Some(reason) = self.driver_idle_conflict() {
1277                self.driver_revoke(reason);
1278                return;
1279            }
1280
1281            // Let the reactor classify queued events before deciding whether they
1282            // are competing work or steering within this owned turn.
1283            if !self.runtime.event_queue().is_empty() {
1284                return;
1285            }
1286        }
1287
1288        // ── 4. Process finished pending task ─────────────────────────────
1289        if self
1290            .driver_pending
1291            .as_ref()
1292            .is_some_and(|p| p.task.0.is_finished())
1293        {
1294            let mut pending = self.driver_pending.take().expect("checked pending");
1295            if pending.generation != self.driver_generation
1296                || pending.session_id != self.id.0
1297            {
1298                return;
1299            }
1300            let result = match (&mut pending.task.0).await {
1301                Ok(result) => result,
1302                Err(error) => {
1303                    self.driver_revoke("driver task failed");
1304                    self.emit(SessionEventWire::SystemNotice(format!(
1305                        "Session driver task failed: {error}"
1306                    )));
1307                    return;
1308                }
1309            };
1310            match result {
1311                super::driver::TaskResult::Command {
1312                    owner,
1313                    command: _,
1314                    handler,
1315                    handler_generation,
1316                    result,
1317                    report,
1318                } => {
1319                    let current = self
1320                        .host
1321                        .ext_manager()
1322                        .try_read()
1323                        .ok()
1324                        .and_then(|m| m.user_action_handler(&owner).ok());
1325                    if !current
1326                        .as_ref()
1327                        .is_some_and(|current| Arc::ptr_eq(current, &handler))
1328                    {
1329                        self.driver_revoke(
1330                            "command owner unloaded, replaced, or unavailable",
1331                        );
1332                        return;
1333                    }
1334                    let output_failed = report.is_limited()
1335                        || report.events.iter().any(|event| {
1336                            matches!(
1337                                event,
1338                                crate::extensions::runtime::InvokeCommandEvent::Output(
1339                                    crate::extensions::commands::CommandOutputEvent::Error { .. }
1340                                )
1341                            )
1342                        });
1343                    // F7 (shady should-fix): plugin command output is visible
1344                    // to the user, but BATCHED into one notice (cap 20 lines) —
1345                    // a chatty `/auto start` must not flood the client with one
1346                    // wire event per line.
1347                    let mut lines: Vec<String> = Vec::new();
1348                    for event in &report.events {
1349                        if let crate::extensions::runtime::InvokeCommandEvent::Output(out) = event {
1350                            let text = match out {
1351                                crate::extensions::commands::CommandOutputEvent::Text { content }
1352                                | crate::extensions::commands::CommandOutputEvent::System { content } => {
1353                                    Some(content.clone())
1354                                }
1355                                crate::extensions::commands::CommandOutputEvent::Error { content } => {
1356                                    Some(format!("Error: {content}"))
1357                                }
1358                                _ => None,
1359                            };
1360                            if let Some(text) = text {
1361                                lines.push(text);
1362                            }
1363                        }
1364                    }
1365                    if !lines.is_empty() {
1366                        const MAX_LINES: usize = 20;
1367                        let truncated = lines.len() > MAX_LINES;
1368                        if truncated {
1369                            lines.truncate(MAX_LINES);
1370                            lines.push("…output truncated".into());
1371                        }
1372                        self.emit(SessionEventWire::SystemNotice(lines.join("\n")));
1373                    }
1374                    if let Some(notice) = report.limit_notice() {
1375                        self.emit(SessionEventWire::SystemNotice(format!(
1376                            "command output truncated: {}",
1377                            notice.message,
1378                        )));
1379                    }
1380                    if let Ok(value) = result {
1381                        let result = match protocol::parse_reply(&value) {
1382                            Ok(Some(reply @ Reply::Start { .. })) => {
1383                                if output_failed {
1384                                    Err("command reported an error or exceeded its output budget"
1385                                        .into())
1386                                } else if self.streaming || self.stream.is_some() {
1387                                    Err("foreground turn still active".into())
1388                                } else if let Some(reason) = self.driver_idle_conflict() {
1389                                    Err(reason.into())
1390                                } else {
1391                                    match handler_generation {
1392                                        Some(generation) => {
1393                                            self.driver_arm(
1394                                                owner, handler, generation, reply,
1395                                            )
1396                                        }
1397                                        None => Err(
1398                                            "command owner did not expose a live lifecycle at dispatch"
1399                                                .into(),
1400                                        ),
1401                                    }
1402                                }
1403                            }
1404                            Ok(Some(
1405                                Reply::Stop { notice: text }
1406                                | Reply::Status { notice: text },
1407                            )) => {
1408                                self.emit(SessionEventWire::SystemNotice(text));
1409                                Ok(())
1410                            }
1411                            Ok(Some(Reply::Next { .. })) => Err(
1412                                "next requires an armed poll, not an interactive command"
1413                                    .into(),
1414                            ),
1415                            Ok(None) => Ok(()),
1416                            Err(error) => Err(error),
1417                        };
1418                        if let Err(error) = result {
1419                            self.emit(SessionEventWire::SystemNotice(format!(
1420                                "Session driver rejected: {error}"
1421                            )));
1422                        }
1423                    }
1424                }
1425                super::driver::TaskResult::Poll(result) => {
1426                    let Some(driver) = self.driver.as_mut() else {
1427                        return;
1428                    };
1429                    let stop_notice = match &result {
1430                        Ok(Reply::Stop { notice }) => Some(notice.clone()),
1431                        _ => None,
1432                    };
1433                    match result.and_then(|reply| driver.grant.accept(reply)) {
1434                        Ok(Some(proposal)) => {
1435                            let text = proposal.notice.clone();
1436                            if let Err(error) =
1437                                super::driver::schedule(driver, proposal)
1438                            {
1439                                self.driver_revoke(&error);
1440                            } else {
1441                                self.emit(SessionEventWire::SystemNotice(text));
1442                            }
1443                        }
1444                        Ok(None) => {
1445                            self.driver_revoke("owner requested stop");
1446                            if let Some(text) = stop_notice {
1447                                self.emit(SessionEventWire::SystemNotice(text));
1448                            }
1449                        }
1450                        Err(error) => {
1451                            self.driver_revoke(&format!("invalid poll response: {error}"))
1452                        }
1453                    }
1454                }
1455                super::driver::TaskResult::Prepared { proposal, result } => {
1456                    if self.streaming || self.stream.is_some() {
1457                        self.driver_revoke("foreground work took priority");
1458                        return;
1459                    }
1460                    if let Some(reason) = self.driver_idle_conflict() {
1461                        self.driver_revoke(reason);
1462                        return;
1463                    }
1464                    match *result {
1465                        Err(protocol::PrepareError::Selection(error)) => {
1466                            self.emit(SessionEventWire::SystemNotice(format!(
1467                                "Session driver selection rejected (nothing sent): {error}"
1468                            )));
1469                            if let Some(driver) = self.driver.as_mut() {
1470                                driver.outcome = Some((
1471                                    Outcome::SelectionRejected,
1472                                    "unknown".into(),
1473                                ));
1474                            }
1475                        }
1476                        Err(protocol::PrepareError::Blocked(error)) => {
1477                            self.driver_revoke(&format!("preflight blocked: {error}"));
1478                        }
1479                        Ok(candidate) => {
1480                            let validated = proposal.clone();
1481                            let history = super::driver::history_with_steering(
1482                                &self.conv.api_messages,
1483                                &self
1484                                    .driver
1485                                    .as_ref()
1486                                    .map(|d| &d.steering)
1487                                    .cloned()
1488                                    .unwrap_or_default(),
1489                            );
1490                            if let Err(error) = protocol::validate_prepared(
1491                                &candidate, &validated, &history,
1492                            ) {
1493                                self.driver_revoke(&format!(
1494                                    "prepared selection changed before commit: {error}"
1495                                ));
1496                                return;
1497                            }
1498                            // F3: commit steering + prompt via the
1499                            // driver.rs helper (single call site).
1500                            // Destructure to split borrows across conv fields.
1501                            {
1502                                let driver = self.driver.as_mut().expect("driver present");
1503                                let conv = &mut *self.conv;
1504                                super::driver::commit_submission(
1505                                    &mut conv.api_messages,
1506                                    &mut driver.steering,
1507                                    &proposal.prompt,
1508                                );
1509                            }
1510                            // Apply the prepared runtime.
1511                            *self.runtime = candidate;
1512                            self.conv.session.model = self.runtime.model().to_owned();
1513                            self.conv.session.thinking_level =
1514                                self.runtime.thinking_level().to_owned();
1515                            self.consecutive_auto_turns = 0;
1516                            self.turn_baseline = self.conv.api_messages.len();
1517                            self.turn_steered_events.clear();
1518                            self.turn_replay.clear();
1519                            self.streaming = true;
1520                            self.open_turn_draft();
1521                            self.update_attach_state();
1522                            self.emit(SessionEventWire::TurnStarted {
1523                                turn_baseline: self.turn_baseline,
1524                                trigger: TurnTrigger::DriverAuto,
1525                                user_text: Some(proposal.prompt.clone()),
1526                            });
1527
1528                            // Set up the driver's awaiting_terminal.
1529                            if let Some(driver) = self.driver.as_mut() {
1530                                driver.awaiting_terminal = true;
1531                                driver.completed_feedback = "unknown";
1532                                if driver.grant.feedback_enabled() {
1533                                    driver.feedback.begin_turn(
1534                                        &driver.selection.model,
1535                                        &driver.selection.effort,
1536                                    );
1537                                }
1538                            }
1539
1540                            // Spawn stream start as a task (never block tick).
1541                            let handler = self
1542                                .driver
1543                                .as_ref()
1544                                .expect("driver")
1545                                .handler
1546                                .clone();
1547                            let handler_generation = self
1548                                .driver
1549                                .as_ref()
1550                                .expect("driver")
1551                                .handler_generation;
1552                            let ct = self
1553                                .driver
1554                                .as_ref()
1555                                .expect("driver")
1556                                .cancel
1557                                .child_token();
1558                            self.cancel = Some(ct.clone());
1559                            // Cancelled by anything that notes no cause (the
1560                            // grant's deadline task): the driver ending.
1561                            let completion = crate::runtime::TurnCompletion::with_default_cause(
1562                                crate::runtime::CancelCause::Driver,
1563                            );
1564                            self.turn_completion = Some(completion.clone());
1565                            let (tx, rx) = mpsc::unbounded_channel();
1566                            self.steer_tx = Some(tx);
1567                            let mut runtime = self.runtime.clone();
1568                            // F2: share the parent's TTL latches so the
1569                            // driver turn doesn't fire a redundant 1h→5m
1570                            // downgrade notice.
1571                            runtime.share_ttl_latches(&self.runtime);
1572                            let history = self.conv.api_messages.clone();
1573                            let secret = self.secret_prompt_handle.clone();
1574                            let session_id = self.id.0.clone();
1575                            let generation = self.driver_generation;
1576                            self.driver_pending = Some(super::driver::DriverPending {
1577                                generation,
1578                                session_id,
1579                                task: super::driver::Task(tokio::spawn(async move {
1580                                    if ct.is_cancelled() {
1581                                        return super::driver::TaskResult::Started(
1582                                            Err("canceled before stream setup".into()),
1583                                        );
1584                                    }
1585                                    if let Err(error) =
1586                                        super::driver::same_lifecycle(
1587                                            &handler,
1588                                            handler_generation,
1589                                        )
1590                                    {
1591                                        return super::driver::TaskResult::Started(
1592                                            Err(error),
1593                                        );
1594                                    }
1595                                    tokio::select! {
1596                                        biased;
1597                                        _ = ct.cancelled() => super::driver::TaskResult::Started(Err("canceled during stream setup".into())),
1598                                        started = runtime.run_stream_tracked(history, ct.clone(), Some(rx), Some(secret), false, completion) => super::driver::TaskResult::Started(Ok(started)),
1599                                    }
1600                                })),
1601                            });
1602
1603                            // Start subagent tick (same as normal start_turn).
1604                            let mut tick = tokio::time::interval(
1605                                std::time::Duration::from_secs(1),
1606                            );
1607                            tick.set_missed_tick_behavior(
1608                                tokio::time::MissedTickBehavior::Delay,
1609                            );
1610                            tick.reset();
1611                            self.subagent_tick = Some(tick);
1612                            self.request_save();
1613                        }
1614                    }
1615                }
1616                super::driver::TaskResult::Started(started) => match started {
1617                    Ok(started) if self.driver.is_some() => {
1618                        self.stream = Some(started);
1619                    }
1620                    Err(error) => {
1621                        self.driver_revoke(&error);
1622                    }
1623                    _ => {}
1624                },
1625            }
1626        }
1627
1628        // ── 5. Idle scheduling ───────────────────────────────────────────
1629        if self.streaming || self.stream.is_some() || self.driver_pending.is_some() {
1630            return;
1631        }
1632        if let Some(reason) = self.driver_idle_conflict() {
1633            self.driver_revoke(reason);
1634            return;
1635        }
1636        let session_cost = self.conv.session_cost;
1637        let Some(driver) = self.driver.as_mut() else {
1638            return;
1639        };
1640        if let Some((outcome, error_kind)) = driver.outcome.take() {
1641            let request = super::driver::poll_request(driver, outcome, error_kind, session_cost);
1642            let handler = driver.handler.clone();
1643            let session_id = self.id.0.clone();
1644            let generation = self.driver_generation;
1645            self.driver_pending = Some(super::driver::DriverPending {
1646                generation,
1647                session_id,
1648                task: super::driver::Task(tokio::spawn(async move {
1649                    // F4: poll must not hang forever — 30 s timeout like
1650                    // the prepare path's 5 s.
1651                    let result = tokio::time::timeout(
1652                        Duration::from_secs(30),
1653                        protocol::poll(handler, request),
1654                    )
1655                    .await
1656                    .unwrap_or_else(|_| Err("poll timed out (30s)".into()));
1657                    super::driver::TaskResult::Poll(result)
1658                })),
1659            });
1660        } else if driver
1661            .proposal
1662            .as_ref()
1663            .is_some_and(|p| Instant::now() >= p.due)
1664        {
1665            let proposal =
1666                driver.proposal.take().expect("checked proposal").proposal;
1667            // Clone is read-only (prepare never sends a request) and
1668            // discarded — no TTL latch sharing needed.
1669            let runtime = self.runtime.clone();
1670            let history = super::driver::history_with_steering(
1671                &self.conv.api_messages,
1672                &driver.steering,
1673            );
1674            let session_id = self.id.0.clone();
1675            let generation = self.driver_generation;
1676            self.driver_pending = Some(super::driver::DriverPending {
1677                generation,
1678                session_id,
1679                task: super::driver::Task(tokio::spawn(async move {
1680                    let result = tokio::time::timeout(
1681                        Duration::from_secs(5),
1682                        protocol::prepare(&runtime, &proposal, &history),
1683                    )
1684                    .await
1685                    .unwrap_or_else(|_| {
1686                        Err(protocol::PrepareError::Blocked(
1687                            "preparation timed out (5s)".into(),
1688                        ))
1689                    });
1690                    super::driver::TaskResult::Prepared {
1691                        proposal,
1692                        result: Box::new(result),
1693                    }
1694                })),
1695            });
1696        }
1697    }
1698
1699    /// F10: the session lock follows the conversation to `new_id` (a fresh
1700    /// id from NewSession or compaction). Best-effort: on failure the old
1701    /// lock is still released (the conversation left it) and the failure
1702    /// logged. The new lock is taken BEFORE the old one is dropped.
1703    pub(crate) fn reacquire_session_lock(&mut self, new_id: &str) {
1704        match Self::try_lock_session(new_id) {
1705            Ok(lock) => self.session_lock = Some(lock),
1706            Err(e) => {
1707                tracing::warn!(session = %new_id, "reacquire session lock: {e}");
1708                self.session_lock = None;
1709            }
1710        }
1711    }
1712
1713    /// Try to take the journal lock on `id` for this daemon process.
1714    pub(crate) fn try_lock_session(
1715        id: &str,
1716    ) -> std::result::Result<agent_core::session_lock::SessionLock, agent_core::session_lock::SessionLockError>
1717    {
1718        let dir = agent_core::session_lock::sessions_dir();
1719        let holder = agent_core::session_lock::LockHolder {
1720            pid: std::process::id(),
1721            kind: "daemon".to_string(),
1722        };
1723        agent_core::session_lock::SessionLock::try_acquire(&dir, id, holder)
1724    }
1725
1726    /// F18: a session with NO history can never park (nothing to journal),
1727    /// so with no clients it would stay Live — Runtime resident — forever.
1728    /// Same gates as `can_park` minus the history requirement: at the park
1729    /// deadline such a session ends instead.
1730    pub(crate) fn can_end_idle(&self) -> bool {
1731        self.attached.is_empty()
1732            && !self.streaming
1733            && self.compact.is_none()
1734            && self.pending_prompts.is_empty()
1735            && self.conv.is_live()
1736            && self.conv.api_messages.is_empty()
1737            && self.conv.queued_message.is_none()
1738            && !self.keep_warm
1739            && !self.is_parked()
1740            // An armed driver keeps the session alive between turns exactly
1741            // like an attached client would (shady P3/P4 F6: without this, a
1742            // zero-turn armed session is idle-ended 5 s after the last client
1743            // detaches, killing the run mid-arm). `can_park` already guards.
1744            && self.driver.is_none()
1745            && self.driver_pending.is_none()
1746    }
1747
1748    fn rearm_park(&mut self) {
1749        if self.is_parked() {
1750            // Eviction deadline is owned by `park()`; the attach path unparks
1751            // (which rebuilds the runtime and clears the deadline).
1752            return;
1753        }
1754        if self.can_end_idle() {
1755            // F18: nothing to keep warm — a zero-turn session with no
1756            // clients ends after a short grace, not the full park grace
1757            // (which exists to keep a runtime WITH history warm for a
1758            // quick reconnect).
1759            if self.park_deadline.is_none() {
1760                self.park_deadline =
1761                    Some(tokio::time::Instant::now() + idle_end_grace());
1762            }
1763            return;
1764        }
1765        if !self.can_park() {
1766            self.park_deadline = None;
1767            return;
1768        }
1769        match park_grace() {
1770            Some(grace) => {
1771                if self.park_deadline.is_none() {
1772                    self.park_deadline = Some(tokio::time::Instant::now() + grace);
1773                }
1774            }
1775            None => self.park_deadline = None,
1776        }
1777    }
1778
1779    /// (P11) Bound the resource pin from an ABANDONED prompt. A pending host
1780    /// confirmation on a plain interactive session survives detach so the user
1781    /// reattaches and answers it — but if NObody ever comes back it pins the
1782    /// session forever (`can_park()` requires `pending_prompts` empty). Arm a
1783    /// deadline only in that abandoned state (zero clients, prompt pending, no
1784    /// driver); a re-attach clears it (prompt survives, user answers); expiry
1785    /// denies the prompt(s). The driver-armed case fail-closes immediately in
1786    /// `detach` and never reaches here. Idempotent: safe to call on every
1787    /// attach/detach/prompt transition.
1788    fn rearm_prompt_abandon(&mut self) {
1789        let armed_driver = self.driver.is_some() || self.driver_pending.is_some();
1790        let abandoned = self.attached.is_empty()
1791            && !self.pending_prompts.is_empty()
1792            && !armed_driver
1793            && !self.is_parked();
1794        if !abandoned {
1795            // Someone is attached, the prompt was answered, or a driver took
1796            // over the fail-closed path — never auto-deny while any of these.
1797            self.prompt_abandon_deadline = None;
1798            return;
1799        }
1800        if self.prompt_abandon_deadline.is_none() {
1801            // `None` config ⇒ deadline stays `None` ⇒ disabled (pre-#112).
1802            self.prompt_abandon_deadline =
1803                prompt_abandon_timeout().map(|d| tokio::time::Instant::now() + d);
1804        }
1805    }
1806
1807    /// (P11) The prompt-abandonment deadline expired with still zero clients:
1808    /// deny every pending prompt (send `None` + emit `PromptResolved`) so the
1809    /// session stops zombie-blocking and `can_park()` becomes true. A re-attach
1810    /// would have cleared the deadline first; re-check defensively.
1811    fn on_prompt_abandon_deadline(&mut self) {
1812        self.prompt_abandon_deadline = None;
1813        if !self.attached.is_empty() {
1814            return;
1815        }
1816        let had_prompts = !self.pending_prompts.is_empty();
1817        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
1818            let _ = tx.send(None);
1819            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
1820        }
1821        if had_prompts {
1822            tracing::info!(session = %self.id, "pending prompt abandoned (no clients before deadline) — denied");
1823            self.publish_presence();
1824            // `can_park()` may now be true — re-evaluate the park deadline.
1825            self.rearm_park();
1826        }
1827    }
1828
1829    fn set_lifecycle(&mut self, l: SessionLifecycle) {
1830        self.lifecycle
1831            .store(l as u8, std::sync::atomic::Ordering::Release);
1832        self.emit(SessionEventWire::Lifecycle(l));
1833    }
1834
1835    /// Save, close PTYs, drop `conv` THEN `runtime`. `background` (inbox
1836    /// watcher + per-session UDS + registry) stays: `synaps send` keeps
1837    /// resolving and its push into `event_queue` is the wake-up.
1838    pub(crate) async fn park(&mut self) -> std::ops::ControlFlow<EndReason> {
1839        self.park_deadline = None;
1840        if self.is_parked() {
1841            if self.attached.is_empty() {
1842                tracing::info!(session = %self.id, "parked past the eviction age — leaving the map (journal kept)");
1843                return std::ops::ControlFlow::Break(EndReason::Evicted);
1844            }
1845            return std::ops::ControlFlow::Continue(());
1846        }
1847        if self.can_end_idle() {
1848            tracing::info!(session = %self.id, "idle with no history — ending instead of parking (F18)");
1849            // No journal will ever exist for this id: take the lock file
1850            // with us instead of leaving an orphan `.lock` (RC soak F-NEW-1).
1851            if let Some(lock) = self.session_lock.take() {
1852                lock.release_and_remove();
1853            }
1854            return std::ops::ControlFlow::Break(EndReason::Idle);
1855        }
1856        if !self.can_park() {
1857            return std::ops::ControlFlow::Continue(());
1858        }
1859        self.state = AttachState::Parking;
1860        self.set_lifecycle(SessionLifecycle::Parking);
1861        // Parking drops the conversation from memory: everything queued must
1862        // be on disk first, and a failed save keeps the session live.
1863        if !self.save().await {
1864            tracing::warn!(session = %self.id, "park: save failed or timed out — staying live");
1865            self.state = AttachState::Detached { running: false };
1866            self.set_lifecycle(SessionLifecycle::Live);
1867            return std::ops::ControlFlow::Continue(());
1868        }
1869        if !self.journal_exists() {
1870            // Never park what cannot be restored (H2).
1871            tracing::warn!(session = %self.id, "park: no journal on disk — staying live");
1872            self.state = AttachState::Detached { running: false };
1873            self.set_lifecycle(SessionLifecycle::Live);
1874            return std::ops::ControlFlow::Continue(());
1875        }
1876        if self.runtime.session_manager().active_count() > 0 {
1877            self.emit(SessionEventWire::SystemNotice(
1878                "parked: background shells closed".into(),
1879            ));
1880        }
1881        self.runtime.session_manager().shutdown_all();
1882        self.turn_replay.clear();
1883        // Drop order: conv first, then runtime — a panic between them can
1884        // never leave a Runtime without state to save.
1885        let conv = self.conv.park_take();
1886        drop(conv);
1887        let runtime = self.runtime.park_take();
1888        drop(runtime);
1889        // Hand the freed pages back: jemalloc otherwise keeps them as
1890        // dirty/muzzy for its decay window, and a parked session that
1891        // still shows in RssAnon buys nothing.
1892        crate::core::memstat::purge_arenas();
1893        // F10: release the journal lock so an in-process --continue can
1894        // pick up the parked session (the daemon will fail to re-acquire
1895        // on unpark and surface a clear error instead of a zombie).
1896        self.session_lock = None;
1897        self.state = AttachState::Parked;
1898        self.set_lifecycle(SessionLifecycle::Parked);
1899        tracing::info!(session = %self.id, "session parked");
1900        // The park timer is reused as the eviction timer: a session nobody
1901        // re-attaches to within `parked_evict_after` leaves the map (its
1902        // journal stays; `--continue` brings it back).
1903        self.park_deadline = parked_evict_after().map(|d| tokio::time::Instant::now() + d);
1904        std::ops::ControlFlow::Continue(())
1905    }
1906
1907    /// Rebuild `runtime` + `conv` from the journal (`load_session_in_dir`
1908    /// via `resolve_session_and_prompt`), replay non-persisted settings,
1909    /// `finish_session_setup(IndexRecord::Skip)`. On error the session
1910    /// stays Parked (nothing is half-built).
1911    pub(crate) async fn unpark(&mut self) -> Result<()> {
1912        let started = std::time::Instant::now();
1913        // Somebody came back: the eviction deadline armed by `park()` is void.
1914        self.park_deadline = None;
1915        let journal_id = (**self.journal_id.load()).clone();
1916
1917        // F10: re-acquire the journal lock before rebuilding the runtime.
1918        // If an in-process --continue took over while we were parked, fail
1919        // loudly so the attach gets a clear error instead of a zombie.
1920        {
1921            let dir = agent_core::session_lock::sessions_dir();
1922            let holder = agent_core::session_lock::LockHolder {
1923                pid: std::process::id(),
1924                kind: "daemon".to_string(),
1925            };
1926            match agent_core::session_lock::SessionLock::try_acquire(&dir, &journal_id, holder) {
1927                Ok(lock) => self.session_lock = Some(lock),
1928                Err(agent_core::session_lock::SessionLockError::Held { .. }) => {
1929                    return Err(crate::RuntimeError::Session(format!(
1930                        "cannot unpark session {}: journal locked by another process",
1931                        journal_id
1932                    )));
1933                }
1934                Err(e) => {
1935                    tracing::warn!(session = %journal_id, "unpark: session lock: {e}");
1936                    // Non-fatal: proceed without the lock (e.g. read-only fs).
1937                }
1938            }
1939        }
1940
1941        let host = Arc::clone(&self.host);
1942        let cfg = self.config.clone();
1943        let queue = Arc::clone(&self.event_queue);
1944        let replay = self.settings_replay.clone();
1945        let journal_present = self.journal_exists();
1946        let view = self.view.load_full();
1947        let build = async move {
1948            let config: crate::SynapsConfig = (**host.config()).clone();
1949            // §4: cwd before apply_config — see `create`.
1950            let mut runtime = host.foreground_runtime_for(cfg.cwd.clone()).await?;
1951            runtime.set_event_queue(queue);
1952            let mut sb = if journal_present {
1953                crate::engine::setup::resolve_session_and_prompt(
1954                    &mut runtime,
1955                    &Some(Some(journal_id)),
1956                    cfg.system.as_deref(),
1957                    cfg.prompt_manifest.as_deref(),
1958                )?
1959            } else {
1960                // The journal vanished under us (H2): rebuild an empty
1961                // conversation under the SAME id with the last-published
1962                // model/thinking rather than leaving a zombie Parked session.
1963                tracing::warn!(
1964                    session = %journal_id,
1965                    "unpark: journal missing — restoring as a fresh conversation"
1966                );
1967                let mut sb = crate::engine::setup::resolve_session_and_prompt(
1968                    &mut runtime,
1969                    &None,
1970                    cfg.system.as_deref(),
1971                    cfg.prompt_manifest.as_deref(),
1972                )?;
1973                sb.session.id = journal_id.clone();
1974                runtime.set_session_id(Some(journal_id));
1975                sb
1976            };
1977            runtime.set_cwd(cfg.cwd.clone());
1978            runtime.set_env(cfg.env.clone());
1979            runtime.set_env_stripped(cfg.env_stripped.clone());
1980            // The CURRENT model/thinking (the last published view), not
1981            // `cfg.model_override` frozen at create: `/model` survives park.
1982            runtime.set_model(view.model.clone());
1983            let _ = runtime.restore_session_reasoning(&view.thinking_level);
1984            sb.session.model = view.model.clone();
1985            sb.session.thinking_level = runtime.thinking_level().to_string();
1986            crate::engine::setup::finish_session_setup(
1987                &mut runtime,
1988                &config,
1989                &sb.session,
1990                cfg.cwd.clone(),
1991                crate::engine::setup::IndexRecord::Skip,
1992            );
1993            for s in &replay {
1994                replay_setting(&mut runtime, s);
1995            }
1996            Ok::<_, crate::RuntimeError>((runtime, sb))
1997        };
1998        let (runtime, sb) = match tokio::time::timeout(budgets::UNPARK_TIMEOUT, build).await {
1999            Ok(Ok(v)) => v,
2000            Ok(Err(e)) => return Err(e),
2001            Err(_) => {
2002                return Err(crate::RuntimeError::Session(format!(
2003                    "unpark timed out after {}s",
2004                    budgets::UNPARK_TIMEOUT_SECS
2005                )))
2006            }
2007        };
2008        let mut conv = ConversationState::from_resumed(sb.session);
2009        conv.api_messages = sb.api_messages;
2010        conv.total_input_tokens = sb.total_input_tokens;
2011        conv.total_output_tokens = sb.total_output_tokens;
2012        conv.session_cost = sb.session_cost;
2013        if journal_present && self.config.persist && self.session_lock.is_some() {
2014            crate::engine::setup::recover_turn_draft(&mut conv).await;
2015        }
2016        self.runtime.unpark_set(runtime);
2017        self.conv.unpark_set(conv);
2018        self.publish_view().await;
2019        self.state = AttachState::Detached { running: false };
2020        self.set_lifecycle(SessionLifecycle::Live);
2021        self.update_attach_state();
2022        tracing::info!(session = %self.id, ms = started.elapsed().as_millis() as u64, "session unparked");
2023        Ok(())
2024    }
2025
2026    /// Unpark if Parked. `Err` = still Parked; the caller reports it
2027    /// (`Refused` to a client, `AttachRefused` to an attacher, a warning
2028    /// for host wakes).
2029    pub(crate) async fn ensure_live(&mut self) -> std::result::Result<(), String> {
2030        if !self.is_parked() {
2031            return Ok(());
2032        }
2033        self.unpark()
2034            .await
2035            .map_err(|e| format!("session parked and could not be restored: {e}"))
2036    }
2037
2038    /// `stream_handler.rs:264`: a turn OR a compaction blocks auto-turns.
2039    pub(crate) fn busy(&self) -> bool {
2040        self.streaming || self.compact.is_some()
2041    }
2042
2043    /// `runtime.subagent_registry().display_rows()` → `SubagentRows`, only
2044    /// when there is something to show (the TUI's reconcile is a no-op on
2045    /// an empty registry).
2046    ///
2047    /// The 1 Hz tick that drives this starts with a turn and outlives it
2048    /// while a background worker (`subagent_start`) is still running: those
2049    /// workers' progress events rode the ended turn's stream, so these rows
2050    /// (which carry each worker's step and tool count) are how clients keep
2051    /// up. The tick stops once no worker is running after the turn, having
2052    /// published the terminal rows.
2053    pub(crate) fn publish_subagent_rows(&mut self) {
2054        let rows = self
2055            .runtime
2056            .subagent_registry()
2057            .lock()
2058            .unwrap_or_else(|p| p.into_inner())
2059            .display_rows();
2060        let running = rows
2061            .iter()
2062            .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running));
2063        if !rows.is_empty() {
2064            self.emit(SessionEventWire::SubagentRows(rows));
2065        }
2066        if !keep_subagent_tick(self.streaming, running) {
2067            self.subagent_tick = None;
2068        }
2069    }
2070
2071    /// Whether any worker in the registry is still running.
2072    fn subagents_running(&self) -> bool {
2073        self.runtime
2074            .subagent_registry()
2075            .lock()
2076            .unwrap_or_else(|p| p.into_inner())
2077            .display_rows()
2078            .iter()
2079            .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running))
2080    }
2081
2082    // ── turn start (dispatch.rs Submit tail / stream_handler.rs RunTurn) ──
2083
2084    pub(crate) async fn start_turn(&mut self, trigger: TurnTrigger, user_text: Option<String>) {
2085        let ct = CancellationToken::new();
2086        let (s_tx, s_rx) = mpsc::unbounded_channel::<String>();
2087        self.streaming = true;
2088        self.turn_baseline = self.conv.api_messages.len();
2089        self.open_turn_draft();
2090        self.turn_steered_events.clear();
2091        self.turn_replay.clear();
2092        self.update_attach_state();
2093        self.emit(SessionEventWire::TurnStarted {
2094            turn_baseline: self.turn_baseline,
2095            trigger,
2096            user_text,
2097        });
2098        // Blocks command processing during setup exactly like the TUI loop.
2099        // (E-P7, §S9) An armed driver MUST NOT auto-approve tool activation —
2100        // the session-driver contract keeps ordinary tool-approval gates in
2101        // force. Override the session config to false whenever a grant is
2102        // armed, regardless of what the client requested. (Driver-initiated
2103        // turns already pass `false` from the tick's Prepared arm; this guards
2104        // any foreground turn that starts while armed.)
2105        let auto_approve = self.config.auto_approve_confirms && self.driver.is_none();
2106        let completion = crate::runtime::TurnCompletion::new();
2107        self.turn_completion = Some(completion.clone());
2108        let stream = self
2109            .runtime
2110            .run_stream_tracked(
2111                self.conv.api_messages.clone(),
2112                ct.clone(),
2113                Some(s_rx),
2114                Some(self.secret_prompt_handle.clone()),
2115                auto_approve,
2116                completion,
2117            )
2118            .await;
2119        self.stream = Some(stream);
2120        self.cancel = Some(ct);
2121        self.steer_tx = Some(s_tx);
2122        let mut tick = tokio::time::interval(std::time::Duration::from_secs(1));
2123        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
2124        tick.reset();
2125        self.subagent_tick = Some(tick);
2126    }
2127
2128    /// Every turn-end path (Done/Error/Cancel/stream EOF). `streaming=false`
2129    /// makes a `Cancel` racing a `Done` the idle no-op. Events steered into
2130    /// the stream that the engine never took (`turn_steered_events`) go back
2131    /// to `pending_events`, first: they are flushed into history like any
2132    /// event that arrived during the turn (the interrupted path takes them
2133    /// earlier, to place them after the marker). Queues the save of the
2134    /// turn's final history, then the draft removal (`close_turn_draft`).
2135    pub(crate) fn clear_stream(&mut self) {
2136        self.stream = None;
2137        self.cancel = None;
2138        self.steer_tx = None;
2139        self.streaming = false;
2140        self.turn_completion = None;
2141        self.revoked_turn_deadline = None;
2142        // Background workers outlive the turn: keep publishing their rows
2143        // until the last one finishes (see `publish_subagent_rows`).
2144        if !keep_subagent_tick(false, self.subagents_running()) {
2145            self.subagent_tick = None;
2146        }
2147        if !self.turn_steered_events.is_empty() && self.conv.is_live() {
2148            let undelivered = std::mem::take(&mut self.turn_steered_events);
2149            self.conv.pending_events.splice(0..0, undelivered);
2150        }
2151        // The history that ends the turn — whether or not a draft is open
2152        // (no draft without the session lock) — then the draft removal.
2153        self.request_save();
2154        self.close_turn_draft();
2155        self.update_attach_state();
2156    }
2157
2158    // ── in-flight turn draft (crash recovery) ──────────────────────────
2159
2160    /// Turn start: the draft's existence is the "turn open" signal a
2161    /// loader uses to detect a process that died mid-turn. Only the session
2162    /// lock holder writes one: without the lock another process may own
2163    /// this session, and a draft is a claim that a turn is running HERE.
2164    fn open_turn_draft(&mut self) {
2165        if !self.config.persist || !self.conv.is_live() || self.session_lock.is_none() {
2166            return;
2167        }
2168        self.turn_draft = TurnDraftState {
2169            open: Some(self.conv.session.id.clone()),
2170            base_len: self.conv.api_messages.len(),
2171            text: String::new(),
2172            dirty: true,
2173        };
2174        self.flush_turn_draft();
2175    }
2176
2177    fn turn_draft_text(&mut self, delta: &str) {
2178        let d = &mut self.turn_draft;
2179        if d.open.is_some()
2180            && d.text.len() < agent_core::core::session_draft::TURN_DRAFT_MAX_TEXT_BYTES
2181        {
2182            d.text.push_str(delta);
2183            d.dirty = true;
2184        }
2185    }
2186
2187    fn turn_draft_reset_text(&mut self) {
2188        let d = &mut self.turn_draft;
2189        if d.open.is_some() && !d.text.is_empty() {
2190            d.text.clear();
2191            d.dirty = true;
2192        }
2193    }
2194
2195    /// A round checkpoint was adopted and saved: later text continues from it.
2196    fn turn_draft_committed(&mut self) {
2197        if self.turn_draft.open.is_none() {
2198            return;
2199        }
2200        self.turn_draft.base_len = self.conv.api_messages.len();
2201        self.turn_draft.text.clear();
2202        self.turn_draft.dirty = true;
2203        self.flush_turn_draft();
2204    }
2205
2206    /// Write the draft if it changed (1 Hz turn tick; round checkpoints).
2207    /// Non-blocking and ordered (`persister`).
2208    pub(crate) fn flush_turn_draft(&mut self) {
2209        let Some(open) = self.turn_draft.open.clone() else {
2210            return;
2211        };
2212        if !self.turn_draft.dirty {
2213            return;
2214        }
2215        // Defensive: follow the conversation if its id changed mid-turn.
2216        let id = self.conv.session.id.clone();
2217        if open != id {
2218            self.persister.remove_draft(&open);
2219            self.turn_draft.open = Some(id.clone());
2220        }
2221        self.turn_draft.dirty = false;
2222        self.persister.write_draft(
2223            &id,
2224            agent_core::core::session_draft::TurnDraft {
2225                base_len: self.turn_draft.base_len,
2226                partial_text: self.turn_draft.text.clone(),
2227            },
2228        );
2229    }
2230
2231    /// Turn end (every path goes through `clear_stream`, which has just
2232    /// queued the save of the history that ends the turn): remove the draft.
2233    /// Queued after that save, and the persister never removes a draft while
2234    /// its session's latest save has failed, so the draft cannot disappear
2235    /// before the history that concludes its turn is on disk.
2236    fn close_turn_draft(&mut self) {
2237        if let Some(id) = self.turn_draft.open.take() {
2238            self.persister.remove_draft(&id);
2239        }
2240        self.turn_draft = TurnDraftState::default();
2241    }
2242
2243    /// dispatch.rs Submit (:1231-1288) minus presentation.
2244    pub(crate) async fn submit(
2245        &mut self,
2246        text: String,
2247        attachments: Vec<serde_json::Value>,
2248        from: Option<ClientId>,
2249    ) {
2250        // Wall 1 defense-in-depth: a latched (unverified) context head must
2251        // not accept new inference. The stream would refuse via
2252        // `durability_blocked` anyway, but that leaves the user message
2253        // orphaned in history; refuse here, before it is pushed.
2254        if self.conv.context_head.is_blocked(&self.conv.session) {
2255            let reason = "context head is unverified after a failed checkpoint; reload the \
2256                          session (`--continue`) or start a new one before continuing"
2257                .to_string();
2258            // Refused to the submitter (its editor text comes back and it
2259            // stops expecting this turn), a notice for everyone else.
2260            match from {
2261                Some(client) => self.emit(SessionEventWire::Refused {
2262                    client,
2263                    command: "submit".into(),
2264                    reason,
2265                }),
2266                None => self.emit(SessionEventWire::SystemNotice(reason)),
2267            }
2268            return;
2269        }
2270        if self.streaming {
2271            // A Submit while streaming is what the TUI calls StreamingInput.
2272            // Attachments during streaming are rejected by the client; if they
2273            // arrive anyway, ignore them (text-only steer).
2274            // P6: if the driver is armed and streaming, steer AND update
2275            // the driver's steering FIFO.
2276            if let Some(driver) = self.driver.as_mut() {
2277                let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
2278                if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
2279                    self.emit(SessionEventWire::SystemNotice(
2280                        "steering queue full (16 messages) — message dropped".into(),
2281                    ));
2282                    return;
2283                }
2284                if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
2285                    self.emit(SessionEventWire::SystemNotice(
2286                        "steering queue full (256 KiB) — message dropped".into(),
2287                    ));
2288                    return;
2289                }
2290                driver.steering.push_back(text.clone());
2291            }
2292            self.steer(text);
2293            return;
2294        }
2295        // P6: while the driver is armed and idle, route to steering FIFO
2296        // instead of starting a normal turn.
2297        if self.driver.is_some() {
2298            let driver = self.driver.as_mut().unwrap();
2299            let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
2300            if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
2301                self.emit(SessionEventWire::SystemNotice(
2302                    "steering queue full (16 messages) — message dropped".into(),
2303                ));
2304                return;
2305            }
2306            if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
2307                self.emit(SessionEventWire::SystemNotice(
2308                    "steering queue full (256 KiB) — message dropped".into(),
2309                ));
2310                return;
2311            }
2312            driver.steering.push_back(text.clone());
2313            // Clear auto_wakes_blocked on explicit user submit (user takeover).
2314            driver.auto_wakes_blocked = false;
2315            self.emit(SessionEventWire::Steered {
2316                text,
2317                delivered: false,
2318            });
2319            return;
2320        }
2321        if self.compact.is_some() {
2322            // dispatch.rs:1233-1237: queued until the compaction lands.
2323            self.emit(SessionEventWire::Steered {
2324                text: text.clone(),
2325                delivered: false,
2326            });
2327            self.conv.queued_message = Some(text);
2328            return;
2329        }
2330        // Real user send — reset auto-turn counter.
2331        self.consecutive_auto_turns = 0;
2332        // For every OTHER attached client: the submitter drew its own card.
2333        let user_text = text.clone();
2334        let api_content = text;
2335
2336        if attachments.is_empty() {
2337            // Text-only: existing path.
2338            self.conv.api_messages.push(std::sync::Arc::new(
2339                serde_json::json!({"role": "user", "content": api_content}),
2340            ));
2341        } else {
2342            // Build multipart content: text block (if non-empty) + attachment blocks.
2343            let mut blocks = Vec::with_capacity(attachments.len() + 1);
2344            if !api_content.is_empty() {
2345                blocks.push(serde_json::json!({"type": "text", "text": api_content}));
2346            }
2347            blocks.extend(attachments);
2348            let candidate = std::sync::Arc::new(
2349                serde_json::json!({"role": "user", "content": blocks}),
2350            );
2351            // Validate the complete history (including this message) for the
2352            // session's current model before accepting.
2353            let mut proposed = self.conv.api_messages.clone();
2354            proposed.push(candidate.clone());
2355            let model = self.runtime.model().to_string();
2356            if let Err(e) = crate::runtime::attachments::validate_messages(&model, &proposed) {
2357                // Typed refusal: the submitting client gets its editor text
2358                // back (stream_handler restores `last_submitted` on `Refused`)
2359                // and keeps its attachment drafts; mirrors see a notice.
2360                match from {
2361                    Some(client) => self.emit(SessionEventWire::Refused {
2362                        client,
2363                        command: "submit".into(),
2364                        reason: format!("attachments rejected: {e}"),
2365                    }),
2366                    None => self.emit(SessionEventWire::SystemNotice(format!(
2367                        "attachments rejected: {e}"
2368                    ))),
2369                }
2370                return;
2371            }
2372            self.conv.api_messages.push(candidate);
2373        }
2374        self.start_turn(TurnTrigger::User, Some(user_text)).await;
2375    }
2376
2377    /// dispatch.rs StreamingInput plain-text branch (:1369-1378).
2378    pub(crate) fn steer(&mut self, text: String) {
2379        let delivered = self
2380            .steer_tx
2381            .as_ref()
2382            .map(|tx| tx.send(text.clone()).is_ok())
2383            .unwrap_or(false);
2384        self.emit(SessionEventWire::Steered {
2385            text: text.clone(),
2386            delivered,
2387        });
2388        self.conv.queued_message = Some(text);
2389    }
2390
2391    /// Every cancel path — `Cancel` (Esc), quit mid-turn (`finish`), the cost
2392    /// cap, a reload/host checkpoint — behind the TUI's `if streaming` guard
2393    /// (input.rs:350): a `Cancel` while idle is a no-op that only re-announces
2394    /// `Idle` — it must never touch history, save, or emit `Aborted`.
2395    ///
2396    /// The interrupted turn is recorded as REAL history, never as a recap:
2397    /// 1. the token is cancelled, then pending host prompts are answered
2398    ///    `None` (a tool or `before_tool_call` hook blocked on one must unwind);
2399    /// 2. the still-live stream is drained for a
2400    ///    bounded time (`drain_cancelled_stream`) so the engine's cancel-path
2401    ///    history — partial assistant message, completed tool rounds,
2402    ///    delivered steering, canceled `tool_result`s — plus its final Usage
2403    ///    and any in-flight context-head checkpoint reach the actor;
2404    /// 3. that history is adopted verbatim (else the last adopted history is
2405    ///    kept — every history the engine publishes is valid), then
2406    ///    `finish_interrupted_turn` APPENDS one interruption marker
2407    ///    (`engine::interrupt`). Nothing already sent is edited, so the
2408    ///    provider's cached prefix survives.
2409    ///
2410    /// A turn the engine had already finished when the cancel reached it
2411    /// (Esc a moment after the answer ended) is not interrupted: no marker.
2412    pub(crate) async fn cancel_turn(&mut self, reason: crate::engine::interrupt::InterruptReason) {
2413        if !self.streaming {
2414            if self.compact.is_some() {
2415                self.abort_compaction();
2416                if let Some(q) = self.conv.queued_message.take() {
2417                    self.emit(SessionEventWire::Dequeued { text: q });
2418                }
2419                self.update_attach_state();
2420            }
2421            self.emit(SessionEventWire::Idle);
2422            return;
2423        }
2424        self.note_cancel_cause(reason);
2425        if let Some(ref ct) = self.cancel {
2426            ct.cancel();
2427        }
2428        // After the cancel, so a tool awaiting a prompt observes the cancel
2429        // (not a `None` it could read as "declined"); a `before_tool_call`
2430        // hook awaiting a confirm prompt is not cancel-aware and needs this
2431        // answer to unwind at all.
2432        self.resolve_pending_prompts();
2433        let drain = self.drain_cancelled_stream().await;
2434        match drain.history {
2435            Some(history) => self.conv.api_messages = history,
2436            None if !drain.closed => tracing::warn!(
2437                session = %self.id,
2438                budget_ms = budgets::CANCEL_DRAIN_TIMEOUT_MS,
2439                "cancelled turn did not publish its history within the drain budget; \
2440                 keeping the last adopted history"
2441            ),
2442            None => {}
2443        }
2444        // Complete only if the drain consumed the whole stream: the final
2445        // history is published right after the completion is recorded.
2446        let completed = drain.closed && self.turn_completed();
2447        self.finish_interrupted_turn(reason, completed).await;
2448    }
2449
2450    /// The engine recorded the running turn's normal end (`TurnCompletion`).
2451    fn turn_completed(&self) -> bool {
2452        self.turn_completion.as_ref().is_some_and(|c| c.completed())
2453    }
2454
2455    /// Tell the engine why the running turn is being cancelled, BEFORE its
2456    /// token is: its canceled tool results say so ("Canceled by user" only
2457    /// for the user). First cause wins, so a teardown that revokes a driver
2458    /// and then cancels notes its own cause first.
2459    fn note_cancel_cause(&self, reason: crate::engine::interrupt::InterruptReason) {
2460        if !self.streaming {
2461            return;
2462        }
2463        if let (Some(turn), Some(cause)) = (&self.turn_completion, reason.cancel_cause()) {
2464            turn.note_cancel_cause(cause);
2465        }
2466    }
2467
2468    /// The ONE tail of every interrupted turn, once its stream has been
2469    /// drained or dropped (`cancel_turn`, a revoked driver turn): record the
2470    /// interruption in history, end the turn, tell clients. Never runs the
2471    /// post-turn machinery of a normal end (queued auto-send, event
2472    /// auto-turns, auto-compaction).
2473    ///
2474    /// `completed`: the engine had finished the turn normally before the
2475    /// cancel reached it — the history is a complete answer, so no marker is
2476    /// appended and clients get the `Done` they would have had.
2477    async fn finish_interrupted_turn(
2478        &mut self,
2479        reason: crate::engine::interrupt::InterruptReason,
2480        completed: bool,
2481    ) {
2482        // Defensive: only trailing invalid messages this turn appended.
2483        crate::engine::stream::repair_history_after_failure(
2484            &mut self.conv.api_messages,
2485            self.turn_baseline,
2486        );
2487        let kept_partial = self.conv.api_messages.len() > self.turn_baseline;
2488        if !completed {
2489            crate::engine::interrupt::append_marker(&mut self.conv.api_messages, reason);
2490        }
2491        // A user steer the engine never picked up was not delivered.
2492        if let Some(q) = self.conv.queued_message.take() {
2493            self.emit(SessionEventWire::Dequeued { text: q });
2494        }
2495        // Events that arrived during the turn go in after the marker: first
2496        // those steered into the stream but never drained by the engine
2497        // (otherwise lost with the channel), then those buffered.
2498        {
2499            let undelivered = std::mem::take(&mut self.turn_steered_events);
2500            let conv: &mut ConversationState = &mut self.conv;
2501            for formatted in undelivered.into_iter().chain(conv.pending_events.drain(..)) {
2502                conv.api_messages.push(std::sync::Arc::new(serde_json::json!({
2503                    "role": "user",
2504                    "content": formatted
2505                })));
2506            }
2507        }
2508        // Queues the save of this history, then the draft removal.
2509        self.clear_stream();
2510        // Cancel all running reactive subagents; recover a poisoned guard
2511        // rather than skip cancellation.
2512        {
2513            let mut registry = match self.runtime.subagent_registry().lock() {
2514                Ok(g) => g,
2515                Err(poisoned) => {
2516                    tracing::warn!(
2517                        "subagent registry mutex poisoned during abort; recovering to cancel running handles"
2518                    );
2519                    poisoned.into_inner()
2520                }
2521            };
2522            for handle in registry.iter_mut_handles() {
2523                if handle.status() == crate::runtime::subagent::SubagentStatus::Running {
2524                    handle.cancel();
2525                }
2526            }
2527        }
2528        if completed {
2529            self.emit(SessionEventWire::Stream(StreamEvent::Session(SessionEvent::Done)));
2530        } else {
2531            // Typed event. `context_saved` (wire name kept for compatibility)
2532            // now means "the turn's partial work is kept in history".
2533            self.emit(SessionEventWire::Aborted {
2534                context_saved: kept_partial,
2535            });
2536        }
2537        self.emit_conversation();
2538        self.announce_idle().await;
2539    }
2540
2541    /// A turn whose token was cancelled from OUTSIDE `cancel_turn` — a
2542    /// driver revocation drops `DriverState`, cancelling the driver turn's
2543    /// child token (the grant's deadline task cancels that token too; the
2544    /// next `driver_tick` then revokes and arms the deadline) — ends here: on its `Done`, its stream's end, or when
2545    /// `revoked_turn_deadline` passes (a tool that ignores the cancel must
2546    /// not keep the session busy forever; the same budget as
2547    /// `drain_cancelled_stream`). What is left of the stream is dropped.
2548    async fn finish_revoked_turn(&mut self, stream_closed: bool) {
2549        if !self.streaming {
2550            return;
2551        }
2552        let completed = stream_closed && self.turn_completed();
2553        self.stream = None;
2554        self.finish_interrupted_turn(crate::engine::interrupt::InterruptReason::Driver, completed)
2555            .await;
2556    }
2557
2558    /// Whether the running turn's token has been cancelled (by `cancel_turn`
2559    /// or a driver revocation).
2560    fn turn_cancelled(&self) -> bool {
2561        self.streaming && self.cancel.as_ref().is_some_and(|ct| ct.is_cancelled())
2562    }
2563
2564    /// Answer every pending host prompt `None` (same as `checkpoint`/`finish`).
2565    fn resolve_pending_prompts(&mut self) {
2566        if self.pending_prompts.is_empty() {
2567            return;
2568        }
2569        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
2570            let _ = tx.send(None);
2571            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
2572        }
2573        self.publish_presence();
2574        self.rearm_prompt_abandon();
2575    }
2576
2577    /// Consume what a CANCELLED turn's stream still carries, for at most
2578    /// `budgets::CANCEL_DRAIN_TIMEOUT`, then drop it.
2579    ///
2580    /// Deliberately NOT `on_stream_event`: that handler would re-check the
2581    /// cost cap on the final `Usage` (recursive `cancel_turn`), run the
2582    /// failure path on the engine's typed `Canceled` error, and treat `Done`
2583    /// as a normal completion (queued auto-send, auto-compaction).
2584    ///
2585    /// Forwarded to clients: display events (partial text, tool results —
2586    /// the history being adopted contains them), `Usage`, agent events.
2587    /// Handled: `MessageHistory` (kept, last wins), `ContextHeadCheckpoint`
2588    /// (serviced — dropping its receipt would latch `durability_blocked`;
2589    /// the head it installs replaces any history kept before it),
2590    /// `SteeringDelivered`. Swallowed: `Done`, the `Canceled` error. A typed
2591    /// `InterruptedAfterSideEffect` becomes a notice (it is not a failure of
2592    /// the turn; the canceled `tool_result` already tells the model). A tool
2593    /// raising a host prompt before it observed the cancel is answered
2594    /// `None` at once, not at the deadline.
2595    async fn drain_cancelled_stream(&mut self) -> CancelDrain {
2596        let mut out = CancelDrain::default();
2597        let Some(mut stream) = self.stream.take() else {
2598            out.closed = true;
2599            return out;
2600        };
2601        let deadline = tokio::time::Instant::now() + budgets::CANCEL_DRAIN_TIMEOUT;
2602        loop {
2603            let event = tokio::select! {
2604                biased;
2605                Some(req) = self.secret_prompt_rx.recv() => {
2606                    let _ = req.response_tx.send(None);
2607                    continue;
2608                }
2609                next = tokio::time::timeout_at(deadline, stream.next()) => match next {
2610                    Err(_) => break,
2611                    Ok(None) => {
2612                        out.closed = true;
2613                        break;
2614                    }
2615                    Ok(Some(event)) => event,
2616                },
2617            };
2618            match event {
2619                StreamEvent::Session(SessionEvent::Done) => {
2620                    out.closed = true;
2621                    break;
2622                }
2623                StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
2624                    out.history = Some(history);
2625                }
2626                StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
2627                    session_id,
2628                    messages,
2629                    receipt,
2630                }) => {
2631                    self.handle_context_head_checkpoint(session_id, messages, receipt)
2632                        .await;
2633                    // The adopted head is the actor's history now: a history
2634                    // kept from BEFORE it is stale and must not override it
2635                    // (a later `MessageHistory`, if any, still wins).
2636                    out.history = None;
2637                }
2638                StreamEvent::Session(SessionEvent::Error(err)) => match err.outcome {
2639                    crate::TurnOutcome::Canceled => {}
2640                    crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
2641                        self.emit(SessionEventWire::SystemNotice(err.message));
2642                    }
2643                    _ => {
2644                        tracing::warn!(
2645                            session = %self.id,
2646                            category = err.category_label(),
2647                            "cancelled turn reported an error while draining"
2648                        );
2649                    }
2650                },
2651                StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
2652                    self.note_steering_delivered(message);
2653                    self.emit(SessionEventWire::Stream(event));
2654                }
2655                StreamEvent::Session(SessionEvent::Usage { .. }) => {
2656                    self.record_usage(&event);
2657                    self.emit(SessionEventWire::Stream(event));
2658                }
2659                other => self.emit(SessionEventWire::Stream(other)),
2660            }
2661        }
2662        drop(stream);
2663        out
2664    }
2665
2666    /// `SteeringDelivered`: the engine injected `message` into history.
2667    fn note_steering_delivered(&mut self, message: &str) {
2668        if self.conv.queued_message.as_deref() == Some(message) {
2669            self.conv.queued_message = None;
2670            self.emit_conversation();
2671        }
2672        // P5/P6: pop the driver's steering FIFO on delivery ack.
2673        if let Some(driver) = self.driver.as_mut() {
2674            if driver.steering.front().map(String::as_str) == Some(message) {
2675                driver.steering.pop_front();
2676            }
2677        }
2678        if let Some(pos) = self.turn_steered_events.iter().position(|e| e == message) {
2679            self.turn_steered_events.remove(pos);
2680        }
2681    }
2682
2683    /// Accumulate one `SessionEvent::Usage` into the conversation totals.
2684    fn record_usage(&mut self, event: &StreamEvent) {
2685        if let StreamEvent::Session(SessionEvent::Usage {
2686            input_tokens,
2687            output_tokens,
2688            cache_read_input_tokens,
2689            cache_creation_input_tokens,
2690            cache_creation_5m,
2691            cache_creation_1h,
2692            model: usage_model,
2693        }) = event
2694        {
2695            let model_for_pricing = usage_model
2696                .as_deref()
2697                .unwrap_or(self.runtime.model())
2698                .to_string();
2699            self.conv.add_usage(
2700                *input_tokens,
2701                *output_tokens,
2702                *cache_read_input_tokens,
2703                *cache_creation_input_tokens,
2704                *cache_creation_5m,
2705                *cache_creation_1h,
2706                &model_for_pricing,
2707            );
2708        }
2709    }
2710
2711    // ── event-queue wake (stream_handler.rs handle_event_queue_arm) ──────
2712
2713    async fn on_queue_wake(&mut self) {
2714        if self.is_parked() {
2715            if self.event_queue.is_empty() {
2716                return;
2717            }
2718            if let Err(e) = self.ensure_live().await {
2719                tracing::warn!(session = %self.id, error = %e, "event wake could not unpark; events stay queued");
2720                return;
2721            }
2722        }
2723        let busy = self.busy();
2724        let conv: &mut ConversationState = &mut self.conv;
2725        let drained = drain_event_queue(
2726            &self.event_queue,
2727            &mut conv.api_messages,
2728            &mut conv.pending_events,
2729            busy,
2730            self.steer_tx.as_ref(),
2731        );
2732        if drained.is_empty() {
2733            return;
2734        }
2735        for de in &drained {
2736            if de.disposition == EventDisposition::Steered {
2737                self.turn_steered_events.push(de.formatted.clone());
2738            }
2739            self.emit(SessionEventWire::External(de.event.clone()));
2740        }
2741        let injected = drained
2742            .iter()
2743            .any(|d| d.disposition == EventDisposition::Injected);
2744        if injected || busy {
2745            self.emit_conversation();
2746        }
2747
2748        // P5: observe_events — if non-steering events arrive during a
2749        // driver-OWNED turn, revoke (competing work). Coordinates with F8.
2750        if self.streaming
2751            && self
2752                .driver
2753                .as_ref()
2754                .is_some_and(|d| d.awaiting_terminal)
2755        {
2756            let competing = drained
2757                .iter()
2758                .any(|d| !matches!(d.disposition, EventDisposition::Steered | EventDisposition::DisplayOnly));
2759            if competing {
2760                self.driver_revoke("event-bus work took priority");
2761            }
2762        }
2763
2764        // `events.auto_turn = false` opts the session out of event-driven
2765        // turns (events are still injected/forwarded; the spend governor
2766        // for an ambient session). Read live from host config, like the
2767        // RPC/server hosts do — it used to be hardcoded `true` here.
2768        let auto_turn_enabled = self.host.config().events.auto_turn;
2769        let auto_turn_cap = self.host.config().events.auto_turn_cap;
2770        let action = wake_action_with_cap(
2771            &drained,
2772            &self.conv.api_messages,
2773            busy,
2774            auto_turn_enabled,
2775            self.consecutive_auto_turns,
2776            auto_turn_cap,
2777        );
2778        match action {
2779            WakeAction::RunTurn => {
2780                // F8: while a driver is armed, its tick owns turn scheduling.
2781                // Starting a competing turn here would race the driver.
2782                if self.driver.is_some() {
2783                    tracing::debug!("on_queue_wake: RunTurn inhibited — driver armed");
2784                } else if self.stream.is_some() {
2785                    tracing::warn!("handle_event_arm: RunTurn with active stream — skipping");
2786                } else {
2787                    self.consecutive_auto_turns += 1;
2788                    self.start_turn(TurnTrigger::EventAuto, None).await;
2789                }
2790            }
2791            WakeAction::Forward => {
2792                let hit_cap = injected
2793                    && !busy
2794                    && auto_turn_enabled
2795                    && auto_turn_cap_reached(self.consecutive_auto_turns, auto_turn_cap);
2796                if hit_cap {
2797                    self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
2798                }
2799            }
2800            WakeAction::Nothing => {}
2801        }
2802    }
2803
2804    // ── stream events (stream_handler.rs handle_stream_event + arm tail) ──
2805
2806    async fn on_stream_event(&mut self, event: StreamEvent) {
2807        // A turn cancelled from outside `cancel_turn` (a driver revocation):
2808        // its terminal events are not a normal end — the turn is finished as
2809        // interrupted, never through the post-turn machinery below.
2810        if self.turn_cancelled() {
2811            match event {
2812                StreamEvent::Session(SessionEvent::Done) => {
2813                    self.finish_revoked_turn(true).await;
2814                    return;
2815                }
2816                // Swallowed as in `drain_cancelled_stream`; `Done` follows.
2817                StreamEvent::Session(SessionEvent::Error(err)) => {
2818                    match err.outcome {
2819                        crate::TurnOutcome::Canceled => {}
2820                        crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
2821                            self.emit(SessionEventWire::SystemNotice(err.message));
2822                        }
2823                        _ => tracing::warn!(
2824                            session = %self.id,
2825                            category = err.category_label(),
2826                            "revoked turn reported an error"
2827                        ),
2828                    }
2829                    return;
2830                }
2831                _ => {}
2832            }
2833        }
2834        // Forward first: clients see the same order they see today. A
2835        // context-head checkpoint is actor-internal (its receipt is ours to
2836        // complete) and must not reach clients: the wire has no such event
2837        // and maps it to `Done`, which ended the turn on socket clients.
2838        if !matches!(
2839            event,
2840            StreamEvent::Session(SessionEvent::ContextHeadCheckpoint { .. })
2841        ) {
2842            self.emit(SessionEventWire::Stream(event.clone()));
2843        }
2844
2845        // P5: observe_feedback — feed opted-in driver turns.
2846        if let Some(driver) = self.driver.as_mut() {
2847            if driver.awaiting_terminal && driver.grant.feedback_enabled() {
2848                driver.feedback.observe(&event);
2849            }
2850        }
2851
2852        // P5: capture_terminal BEFORE Done/Error handlers modify state.
2853        let is_canceled = self
2854            .cancel
2855            .as_ref()
2856            .is_some_and(|ct| ct.is_cancelled());
2857        let terminal = if self
2858            .driver
2859            .as_ref()
2860            .is_some_and(|d| d.awaiting_terminal)
2861        {
2862            super::driver::capture_terminal(Some(&event), is_canceled)
2863        } else {
2864            None
2865        };
2866
2867        enum After {
2868            Continue,
2869            AutoSendQueued(String),
2870            AutoTriggerEvents,
2871            Failed,
2872        }
2873        let mut after = After::Continue;
2874
2875        match event {
2876            StreamEvent::Llm(crate::LlmEvent::Text(text)) => self.turn_draft_text(&text),
2877            // A new provider response (or a retry's reset): the in-flight
2878            // text starts over.
2879            StreamEvent::Llm(crate::LlmEvent::ResponseStart | crate::LlmEvent::ResponseReset) => {
2880                self.turn_draft_reset_text()
2881            }
2882            StreamEvent::Llm(_) => {}
2883            StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
2884                // Published at every round boundary as well as at the end of
2885                // the turn (`runtime/stream.rs` ROUND CHECKPOINT): the session
2886                // on disk follows the turn as it progresses. Queued, never
2887                // awaited — the persister writes it (latest wins), so a slow
2888                // disk can never stall the turn machine.
2889                self.conv.api_messages = history;
2890                self.request_save();
2891                // The round is committed: the draft now continues from here.
2892                self.turn_draft_committed();
2893                self.trim_replay_to_checkpoint();
2894                self.emit_conversation();
2895            }
2896            StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
2897                self.note_steering_delivered(message);
2898            }
2899            StreamEvent::Agent(_) => {}
2900            StreamEvent::Session(SessionEvent::Usage { .. }) => {
2901                self.record_usage(&event);
2902                // (E-P7, §S3) Host-owned spend circuit breaker. Checked after
2903                // every Usage event — a breach cancels the in-flight turn,
2904                // revokes any armed driver (so it cannot re-poll and keep
2905                // spending), and tells clients why.
2906                if let Some((scope, cost, cap)) = self.cost_cap_breach() {
2907                    self.emit(SessionEventWire::CostCapReached {
2908                        scope: scope.to_string(),
2909                        cost,
2910                        cap,
2911                    });
2912                    self.note_cancel_cause(crate::engine::interrupt::InterruptReason::CostCap);
2913                    if self.driver.is_some() || self.driver_pending.is_some() {
2914                        self.driver_revoke(&format!("{scope} cost cap reached (${cost:.4} ≥ ${cap:.4})"));
2915                    }
2916                    self.cancel_turn(crate::engine::interrupt::InterruptReason::CostCap)
2917                        .await;
2918                    return;
2919                }
2920            }
2921            StreamEvent::Session(SessionEvent::Notice(_)) => {}
2922            StreamEvent::Session(SessionEvent::Done) => {
2923                self.clear_stream();
2924                self.publish_subagent_rows();
2925                // Flush events that arrived during streaming into api_messages
2926                let had_pending = !self.conv.pending_events.is_empty();
2927                {
2928                    let conv: &mut ConversationState = &mut self.conv;
2929                    for formatted in conv.pending_events.drain(..) {
2930                        conv.api_messages
2931                            .push(std::sync::Arc::new(serde_json::json!({
2932                                "role": "user",
2933                                "content": formatted
2934                            })));
2935                    }
2936                }
2937                if let Some(queued) = self.conv.queued_message.take() {
2938                    after = After::AutoSendQueued(queued);
2939                } else if had_pending {
2940                    self.request_save();
2941                    after = After::AutoTriggerEvents;
2942                }
2943                self.emit_conversation();
2944                if matches!(after, After::Continue) {
2945                    if self.config.auto_compact {
2946                        self.post_turn_chat().await;
2947                    }
2948                    // A spawned compaction emits Idle when it lands.
2949                    if self.compact.is_none() {
2950                        self.announce_idle().await;
2951                    }
2952                }
2953            }
2954            StreamEvent::Session(SessionEvent::Error(_)) => {
2955                // Remove only invalid messages appended by the ACTIVE turn —
2956                // before `clear_stream` queues the turn's final save.
2957                crate::engine::stream::repair_history_after_failure(
2958                    &mut self.conv.api_messages,
2959                    self.turn_baseline,
2960                );
2961                self.clear_stream();
2962                self.publish_subagent_rows();
2963                self.emit_conversation();
2964                after = After::Failed;
2965            }
2966            StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
2967                session_id,
2968                messages,
2969                receipt,
2970            }) => {
2971                self.handle_context_head_checkpoint(session_id, messages, receipt)
2972                    .await;
2973            }
2974        }
2975
2976        match after {
2977            After::Continue => {}
2978            After::Failed => {
2979                if self.config.auto_compact {
2980                    self.post_turn_chat().await;
2981                }
2982                if self.compact.is_none() {
2983                    self.announce_idle().await;
2984                }
2985            }
2986            After::AutoSendQueued(queued) => {
2987                if self.config.auto_compact {
2988                    self.post_turn_chat().await;
2989                }
2990                if self.compact.is_some() {
2991                    // Rides the compaction transition (queued_message
2992                    // restored last); the TUI reports it, never re-sends.
2993                    self.conv.queued_message = Some(queued);
2994                    return;
2995                }
2996                // Auto-send the queued message (user-authored — reset counter)
2997                self.consecutive_auto_turns = 0;
2998                let user_text = queued.clone();
2999                self.conv.api_messages.push(std::sync::Arc::new(
3000                    serde_json::json!({"role": "user", "content": queued}),
3001                ));
3002                self.start_turn(TurnTrigger::QueuedAuto, Some(user_text)).await;
3003            }
3004            After::AutoTriggerEvents => {
3005                if self.config.auto_compact {
3006                    self.post_turn_chat().await;
3007                }
3008                if self.compact.is_some() {
3009                    return;
3010                }
3011                // Central claim gate: allows turns while under the configured
3012                // cap (events.auto_turn_cap; 0 = unlimited), denies once reached.
3013                let auto_turn_cap = self.host.config().events.auto_turn_cap;
3014                if claim_auto_turn_with_cap(&mut self.consecutive_auto_turns, auto_turn_cap) {
3015                    self.start_turn(TurnTrigger::EventAuto, None).await;
3016                } else {
3017                    self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
3018                    self.announce_idle().await;
3019                }
3020            }
3021        }
3022
3023        // P5: observe_terminal — set driver outcome or revoke.
3024        if let Some(terminal) = terminal {
3025            let revoke_reason = if let Some(driver) = self.driver.as_mut() {
3026                super::driver::observe_terminal(driver, &self.runtime, terminal)
3027            } else {
3028                None
3029            };
3030            // Emit DriverTurnOutcome if an outcome was just set.
3031            if let Some(driver) = self.driver.as_ref() {
3032                if let Some((outcome, _)) = &driver.outcome {
3033                    self.emit(SessionEventWire::DriverTurnOutcome {
3034                        outcome: *outcome,
3035                        selection: driver.selection.clone(),
3036                        feedback: driver.grant.feedback_enabled().then(|| {
3037                            driver.completed_feedback.to_string()
3038                        }),
3039                    });
3040                }
3041            }
3042            if let Some(reason) = revoke_reason {
3043                self.driver_revoke(&reason);
3044            }
3045        }
3046    }
3047
3048    /// chat.rs post-turn block: save + engine-budget auto-compaction.
3049    async fn post_turn_chat(&mut self) {
3050        self.request_save();
3051        let assessment = self.runtime.assess_context(&self.conv.api_messages).await;
3052        if assessment.should_compact() {
3053            self.emit(SessionEventWire::SystemNotice(format!(
3054                "[auto-compacting ~{} tokens...]",
3055                assessment.used_tokens()
3056            )));
3057            self.compact(None, "auto").await;
3058        }
3059    }
3060
3061    /// `SYNAPS_SESSION_COMPACT_INLINE=1`: one-release kill-switch back to the
3062    /// #107 inline body (deleted in phase 4).
3063    fn compact_inline_env() -> bool {
3064        matches!(
3065            std::env::var("SYNAPS_SESSION_COMPACT_INLINE").as_deref(),
3066            Ok("1") | Ok("true")
3067        )
3068    }
3069
3070    /// `/compact` + auto-compaction entry (B2): spawns `compact_conversation`
3071    /// on a `Runtime::clone()` (the same clone the TUI makes at
3072    /// dispatch.rs:450 — the clone never runs turns) and returns; the
3073    /// `select!` arm `on_compaction_done` applies the outcome. While a job
3074    /// is in flight Attach/Detach/Cancel/Query are serviced, `Submit` is
3075    /// queued (`Steered{delivered:false}`), auto-turns see `busy`.
3076    pub(crate) async fn compact(&mut self, instructions: Option<String>, source: &str) {
3077        if Self::compact_inline_env() {
3078            return self.compact_inline(instructions, source).await;
3079        }
3080        if self.compact.is_some() {
3081            self.emit(SessionEventWire::SystemNotice(
3082                "compaction already in progress".into(),
3083            ));
3084            return;
3085        }
3086        if self.streaming {
3087            self.emit(SessionEventWire::SystemNotice(
3088                "cannot compact while a turn is running".into(),
3089            ));
3090            return;
3091        }
3092        let disclosure =
3093            preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line();
3094        self.emit(SessionEventWire::CompactionStarted {
3095            source: source.to_string(),
3096            disclosure,
3097        });
3098        let msgs = self.conv.api_messages.clone();
3099        let rt: Runtime = (*self.runtime).clone();
3100        let task = tokio::spawn(async move {
3101            compact_conversation(&msgs, &rt, instructions.as_deref()).await
3102        });
3103        self.compact = Some(CompactionJob {
3104            task,
3105            source: source.to_string(),
3106            started: tokio::time::Instant::now(),
3107        });
3108        self.update_attach_state();
3109    }
3110
3111    /// `select!` arm: the spawned job finished (or was aborted). Applies via
3112    /// the ONE engine transition with the configured policy; pending events
3113    /// and the queued message ride the transition (loop_arms.rs:761-836).
3114    async fn on_compaction_done(
3115        &mut self,
3116        res: std::result::Result<
3117            Result<crate::runtime::compaction::CompactionOutcome>,
3118            tokio::task::JoinError,
3119        >,
3120    ) {
3121        let Some(job) = self.compact.take() else {
3122            return;
3123        };
3124        let msg_count = self.conv.api_messages.len();
3125        match res {
3126            Err(join) => {
3127                if !join.is_cancelled() {
3128                    self.emit(SessionEventWire::CompactionFailed {
3129                        message: join.to_string(),
3130                        panicked: true,
3131                    });
3132                }
3133            }
3134            Ok(Err(e)) => self.emit(SessionEventWire::CompactionFailed {
3135                message: e.to_string(),
3136                panicked: false,
3137            }),
3138            Ok(Ok(outcome)) => {
3139                // The transition rewrites the predecessor on disk (its
3140                // `compacted_into` link): a queued save of it must land
3141                // first, never after.
3142                if !self.persister.flush().await {
3143                    tracing::warn!(session = %self.id, "compaction: an earlier save failed");
3144                }
3145                let policy: CompactionPolicy = self.config.compaction_policy.into();
3146                let queued = self.conv.queued_message.clone();
3147                let applied = apply_compaction(
3148                    &self.runtime,
3149                    &self.conv.session,
3150                    &self.conv.api_messages,
3151                    &outcome,
3152                    CompactionTransition {
3153                        policy,
3154                        pending_events: self.conv.pending_events.clone(),
3155                        queued_message: queued.clone(),
3156                        hook_source: job.source.clone(),
3157                    },
3158                )
3159                .await;
3160                match applied {
3161                    Ok(applied) => {
3162                        let previous = applied.previous_session_id.clone();
3163                        self.conv.session = applied.session;
3164                        self.conv.api_messages = applied.api_messages;
3165                        self.conv.pending_events.clear();
3166                        self.conv.queued_message = None;
3167                        if policy == CompactionPolicy::LinkedSuccessor {
3168                            self.conv.total_input_tokens = 0;
3169                            self.conv.total_output_tokens = 0;
3170                            self.conv.session_cost = 0.0;
3171                        }
3172                        let new_id = self.conv.session.id.clone();
3173                        self.runtime.set_session_id(Some(new_id.clone()));
3174                        self.journal_id.store(Arc::new(new_id.clone()));
3175                        // F10: lock follows the new journal id.
3176                        self.reacquire_session_lock(&new_id);
3177                        // Wall 1: reset continuation state for the new session id
3178                        // so stale epoch checks in persist_head() don't reject
3179                        // future checkpoints.
3180                        self.conv.context_head = crate::engine::session::ContextHeadPersistence::default();
3181                        self.runtime
3182                            .reset_context_continuation(&new_id, &self.conv.api_messages);
3183                        self.emit(SessionEventWire::CompactionApplied {
3184                            previous_session_id: previous,
3185                            session_id: new_id,
3186                            chains_advanced: applied.chains_advanced,
3187                            queued_restored: queued,
3188                            msg_count,
3189                        });
3190                    }
3191                    Err(e) => self.emit(SessionEventWire::CompactionFailed {
3192                        message: e.to_string(),
3193                        panicked: false,
3194                    }),
3195                }
3196            }
3197        }
3198        self.emit_conversation();
3199        self.update_attach_state();
3200        self.announce_idle().await;
3201    }
3202
3203    /// #107 inline body (kill-switch only).
3204    async fn compact_inline(&mut self, instructions: Option<String>, source: &str) {
3205        self.emit(SessionEventWire::SystemNotice(format!(
3206            "[{}]",
3207            preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line()
3208        )));
3209        let outcome =
3210            compact_conversation(&self.conv.api_messages, &self.runtime, instructions.as_deref())
3211                .await;
3212        let applied = match outcome {
3213            Ok(outcome) => {
3214                // Queued saves land before the transition rewrites the file.
3215                if !self.persister.flush().await {
3216                    tracing::warn!(session = %self.id, "compaction: an earlier save failed");
3217                }
3218                apply_compaction(
3219                    &self.runtime,
3220                    &self.conv.session,
3221                    &self.conv.api_messages,
3222                    &outcome,
3223                    CompactionTransition {
3224                        policy: CompactionPolicy::InPlace,
3225                        pending_events: Vec::new(),
3226                        queued_message: None,
3227                        hook_source: source.to_string(),
3228                    },
3229                )
3230                .await
3231            }
3232            Err(e) => Err(e),
3233        };
3234        match applied {
3235            Ok(applied) => {
3236                self.conv.session = applied.session;
3237                self.conv.api_messages = applied.api_messages;
3238                let after = self.runtime.assess_context(&self.conv.api_messages).await;
3239                self.emit(SessionEventWire::SystemNotice(format!(
3240                    "[compacted → ~{} tokens]",
3241                    after.used_tokens()
3242                )));
3243            }
3244            Err(e) => self.emit(SessionEventWire::SystemNotice(format!(
3245                "[compaction failed: {}]",
3246                e
3247            ))),
3248        }
3249        self.emit_conversation();
3250    }
3251
3252    // ── prompts ──────────────────────────────────────────────────────────
3253
3254    fn on_prompt_request(&mut self, req: SecretPromptRequest) {
3255        let id = self.next_prompt_id;
3256        self.next_prompt_id += 1;
3257        let pr = PromptRequest {
3258            id,
3259            kind: PromptKind::from_title(&req.title),
3260            title: req.title,
3261            prompt: req.prompt,
3262            raised_at: chrono::Utc::now(),
3263        };
3264        self.pending_prompts.push_back((pr.clone(), req.response_tx));
3265        self.publish_presence();
3266        self.emit(SessionEventWire::Prompt(pr));
3267        // A prompt can be raised while already detached (a turn running with no
3268        // client). Arm the abandonment deadline if this left us pinned.
3269        self.rearm_prompt_abandon();
3270    }
3271
3272    fn answer(&mut self, prompt_id: u64, value: Option<String>) {
3273        let Some(pos) = self.pending_prompts.iter().position(|(p, _)| p.id == prompt_id) else {
3274            return; // unknown or already answered — dedup on id
3275        };
3276        let (_, tx) = self.pending_prompts.remove(pos).expect("position");
3277        let _ = tx.send(value);
3278        self.publish_presence();
3279        self.emit(SessionEventWire::PromptResolved { prompt_id });
3280        // The last pending prompt may have just cleared — drop the deadline.
3281        self.rearm_prompt_abandon();
3282    }
3283
3284    // ── settings / queries / engine commands ─────────────────────────────
3285
3286    pub(crate) async fn apply_setting(&mut self, id: u64, setting: SessionSetting) {
3287        if replayable(&setting) {
3288            let disc = std::mem::discriminant(&setting);
3289            self.settings_replay
3290                .retain(|s| std::mem::discriminant(s) != disc);
3291            self.settings_replay.push(setting.clone());
3292        }
3293        let name = match &setting {
3294            SessionSetting::Model { .. } => "model",
3295            SessionSetting::ReasoningLevel { .. } => "reasoning_level",
3296            SessionSetting::ContextWindow { .. } => "context_window",
3297            SessionSetting::CompactionModel { .. } => "compaction_model",
3298            SessionSetting::ApiRetries { .. } => "api_retries",
3299            SessionSetting::SubagentTimeout { .. } => "subagent_timeout",
3300            SessionSetting::MaxToolOutput { .. } => "max_tool_output",
3301            SessionSetting::BashTimeout { .. } => "bash_timeout",
3302            SessionSetting::BashMaxTimeout { .. } => "bash_max_timeout",
3303            SessionSetting::SystemPrompt { .. } => "system_prompt",
3304            SessionSetting::ReloadPrompt => "reload_prompt",
3305            SessionSetting::GrantWorkerModel { .. } => "grant_worker_model",
3306        };
3307        let rt = &mut self.runtime;
3308        let mut clamp_wire = None;
3309        let result: std::result::Result<Option<String>, String> = match setting {
3310            SessionSetting::Model { model } => rt.try_set_model(model).map(|clamp| {
3311                self.conv.session.model = rt.model().to_string();
3312                clamp.map(|c| {
3313                    self.conv.session.thinking_level = rt.thinking_level().to_string();
3314                    clamp_wire = Some(ReasoningClampWire {
3315                        from: c.from.as_str().to_string(),
3316                        to: c.to.as_str().to_string(),
3317                    });
3318                    format!(
3319                        "thinking → {} (clamped from {}: not supported by {})",
3320                        c.to.as_str(),
3321                        c.from.as_str(),
3322                        rt.model()
3323                    )
3324                })
3325            }),
3326            SessionSetting::ReasoningLevel { level } => {
3327                rt.set_reasoning_level_checked(level).map(|_| {
3328                    self.conv.session.thinking_level = rt.thinking_level().to_string();
3329                    None
3330                })
3331            }
3332            SessionSetting::ContextWindow { tokens } => {
3333                rt.set_context_window(tokens);
3334                Ok(None)
3335            }
3336            SessionSetting::CompactionModel { model } => {
3337                rt.set_compaction_model(model);
3338                Ok(None)
3339            }
3340            SessionSetting::ApiRetries { n } => {
3341                rt.set_api_retries(n);
3342                Ok(None)
3343            }
3344            SessionSetting::SubagentTimeout { secs } => {
3345                rt.set_subagent_timeout(secs);
3346                Ok(None)
3347            }
3348            SessionSetting::MaxToolOutput { bytes } => {
3349                rt.set_max_tool_output(bytes);
3350                Ok(None)
3351            }
3352            SessionSetting::BashTimeout { secs } => {
3353                rt.set_bash_timeout(secs);
3354                Ok(None)
3355            }
3356            SessionSetting::BashMaxTimeout { secs } => {
3357                rt.set_bash_max_timeout(secs);
3358                Ok(None)
3359            }
3360            SessionSetting::SystemPrompt { text } => {
3361                rt.set_system_prompt(text);
3362                Ok(None)
3363            }
3364            SessionSetting::ReloadPrompt => rt
3365                .reload_prompt()
3366                .map(|generation| Some(format!("prompt reloaded (generation {generation})")))
3367                .map_err(|e| e.to_string()),
3368            SessionSetting::GrantWorkerModel { model } => {
3369                rt.grant_worker_model(&model).map(|_| None)
3370            }
3371        };
3372        self.publish_view().await;
3373        let view = (**self.view.load()).clone();
3374        let (ok, message) = match result {
3375            Ok(m) => (true, m),
3376            Err(e) => (false, Some(e)),
3377        };
3378        self.emit(SessionEventWire::SettingChanged(SettingApplied {
3379            id,
3380            setting: name.to_string(),
3381            ok,
3382            message,
3383            view,
3384            clamp: clamp_wire,
3385        }));
3386    }
3387
3388    pub(crate) async fn query(&mut self, id: u64, query: SessionQuery) {
3389        // Status/View never wake a parked session (the idle probe must not).
3390        if self.is_parked() {
3391            let value = match query {
3392                SessionQuery::Status => serde_json::json!({
3393                    "session": (**self.journal_id.load()).clone(),
3394                    "model": self.view.load().model,
3395                    "lifecycle": SessionLifecycle::Parked,
3396                    "streaming": false,
3397                    "auto_turns": self.consecutive_auto_turns,
3398                    "attached": 0,
3399                    "pending_prompts": 0,
3400                }),
3401                SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
3402                _ => match self.ensure_live().await {
3403                    Ok(()) => return Box::pin(self.query(id, query)).await,
3404                    Err(e) => serde_json::json!({ "error": e }),
3405                },
3406            };
3407            self.emit(SessionEventWire::QueryResult { id, value });
3408            return;
3409        }
3410        let value = match query {
3411            SessionQuery::Status => serde_json::json!({
3412                "session": self.conv.session.id,
3413                "model": self.runtime.model(),
3414                "lifecycle": SessionLifecycle::from_u8(self.lifecycle.load(std::sync::atomic::Ordering::Acquire)),
3415                "tokens": { "input": self.conv.total_input_tokens, "output": self.conv.total_output_tokens },
3416                "cost": self.conv.session_cost,
3417                "messages": self.conv.api_messages.len(),
3418                "streaming": self.streaming,
3419                "auto_turns": self.consecutive_auto_turns,
3420                "attached": self.attached.len(),
3421                "pending_prompts": self.pending_prompts.len(),
3422            }),
3423            SessionQuery::Messages => {
3424                serde_json::to_value(&self.conv.api_messages).unwrap_or_default()
3425            }
3426            SessionQuery::DisplayTail { items } => {
3427                serde_json::to_value(super::display::display_tail(&self.conv.api_messages, items))
3428                    .unwrap_or_default()
3429            }
3430            SessionQuery::SubagentRows => {
3431                let rows = self
3432                    .runtime
3433                    .subagent_registry()
3434                    .lock()
3435                    .unwrap_or_else(|p| p.into_inner())
3436                    .display_rows();
3437                serde_json::Value::Array(
3438                    rows.iter()
3439                        .map(|r| {
3440                            serde_json::json!({
3441                                "subagent_id": r.subagent_id,
3442                                "agent_name": r.agent_name,
3443                                "status": format!("{:?}", r.status),
3444                                "cancel_requested": r.cancel_requested,
3445                                "elapsed_secs": r.elapsed_secs,
3446                            })
3447                        })
3448                        .collect(),
3449                )
3450            }
3451            SessionQuery::PromptInspection => {
3452                serde_json::to_value(self.runtime.prompt_inspection_json()).unwrap_or_default()
3453            }
3454            SessionQuery::ToolsSchema => serde_json::json!({ "unsupported": "tools_schema" }),
3455            SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
3456            SessionQuery::ContextAssessment => {
3457                let a = self.runtime.assess_context(&self.conv.api_messages).await;
3458                serde_json::json!({
3459                    "used_tokens": a.used_tokens(),
3460                    "budget_tokens": a.budget_tokens(),
3461                    "provider_window": a.provider_window,
3462                    "should_compact": a.should_compact(),
3463                })
3464            }
3465            SessionQuery::ContextReport => {
3466                use crate::engine::commands::{context_command, CommandResult};
3467                match context_command(&self.runtime, Some(&self.conv.api_messages)) {
3468                    CommandResult::Output(text) => serde_json::json!({ "text": text }),
3469                    other => serde_json::json!({ "unsupported": format!("{other:?}") }),
3470                }
3471            }
3472        };
3473        self.emit(SessionEventWire::QueryResult { id, value });
3474    }
3475
3476    // ── attach / detach ──────────────────────────────────────────────────
3477
3478    pub(crate) fn snapshot(&self) -> AttachSnapshot {
3479        AttachSnapshot {
3480            meta: self.meta.clone(),
3481            view: (**self.view.load()).clone(),
3482            conversation: self.conv.snapshot(self.consecutive_auto_turns),
3483            streaming: self.streaming,
3484            replay: self.turn_replay.iter().cloned().collect(),
3485            pending_prompts: self.pending_prompts.iter().map(|(p, _)| p.clone()).collect(),
3486            clients: self.attached.iter().map(|(c, a)| (*c, a.meta.kind)).collect(),
3487            input_owner: self.input_owner,
3488            display_tail: None,
3489        }
3490    }
3491
3492    /// `snapshot()` shaped for one client (phase 4 §2.3): `Digest` clients
3493    /// get an empty `api_messages` (`messages_len` kept), a daemon-projected
3494    /// `display_tail`, and a replay without the per-round `MessageHistory`
3495    /// envelopes (the trailing `Conversation` digest carries len/hash).
3496    pub(crate) fn snapshot_for(&self, client: &ClientMeta) -> AttachSnapshot {
3497        let mut snap = self.snapshot();
3498        if client.history == HistoryMode::Digest {
3499            snap.display_tail = Some(super::display::display_tail(
3500                &snap.conversation.api_messages,
3501                client.tail_items,
3502            ));
3503            snap.conversation.api_messages = Vec::new();
3504            snap.replay.retain(|e| {
3505                !matches!(
3506                    e.event,
3507                    SessionEventWire::Stream(StreamEvent::Session(SessionEvent::MessageHistory(_)))
3508                )
3509            });
3510        }
3511        snap
3512    }
3513
3514    /// B1 ownership: `Observe` never owns; `Mirror` owns iff nobody does
3515    /// (else read-only + notice); `Takeover` steals with
3516    /// `InputOwnerChanged{reason: Takeover}` to everyone (the old owner's
3517    /// client renders the toast).
3518    async fn attach(&mut self, client: ClientMeta, mode: AttachMode) {
3519        if let Err(e) = self.ensure_live().await {
3520            self.emit(SessionEventWire::AttachRefused { message: e });
3521            return;
3522        }
3523        let cid = ClientId(self.next_client_id);
3524        self.next_client_id += 1;
3525        let kind = client.kind;
3526        let snapshot_meta = client.clone();
3527        self.attached.insert(cid, AttachedClient { meta: client, mode });
3528        self.update_attach_state();
3529        let mut owner_change: Option<(Option<ClientId>, OwnerChangeReason)> = None;
3530        match mode {
3531            AttachMode::Observe => {}
3532            // Input owned elsewhere: the joiner learns it from its snapshot
3533            // (`AttachSnapshot::input_owned_elsewhere`), never from a notice
3534            // broadcast to every client.
3535            AttachMode::Mirror => {
3536                if self.input_owner.is_none() {
3537                    owner_change = Some((None, OwnerChangeReason::Attach));
3538                }
3539            }
3540            AttachMode::Takeover => {
3541                let reason = if self.input_owner.is_some() {
3542                    OwnerChangeReason::Takeover
3543                } else {
3544                    OwnerChangeReason::Attach
3545                };
3546                owner_change = Some((self.input_owner, reason));
3547            }
3548        }
3549        if let Some((from, reason)) = owner_change {
3550            self.input_owner = Some(cid);
3551            self.emit(SessionEventWire::InputOwnerChanged {
3552                from,
3553                to: Some(cid),
3554                reason,
3555            });
3556        }
3557        self.publish_presence();
3558        let snapshot = self.snapshot_for(&snapshot_meta);
3559        self.emit(SessionEventWire::Attached {
3560            client: cid,
3561            snapshot,
3562        });
3563        self.emit(SessionEventWire::ClientJoined { client: cid, kind });
3564    }
3565
3566    /// Never touches `stream`/`cancel`: the turn keeps running and its
3567    /// events buffer in the broadcast (§8 detach-without-abort). The owner
3568    /// leaving passes input to the oldest attached non-`Observe` client.
3569    fn detach(&mut self, client: ClientId) {
3570        if self.attached.remove(&client).is_none() {
3571            return;
3572        }
3573        self.update_attach_state();
3574        self.emit(SessionEventWire::ClientLeft { client });
3575        if self.input_owner == Some(client) {
3576            let next = self
3577                .attached
3578                .iter()
3579                .filter(|(_, a)| a.mode != AttachMode::Observe)
3580                .map(|(c, _)| *c)
3581                .min_by_key(|c| c.0);
3582            self.input_owner = next;
3583            self.emit(SessionEventWire::InputOwnerChanged {
3584                from: Some(client),
3585                to: next,
3586                reason: OwnerChangeReason::OwnerDetached,
3587            });
3588            self.publish_presence();
3589        }
3590        // (E-P7 §S1 / P11) The last client just left. Fail-closed gates apply
3591        // ONLY when a driver is (or was about to be) armed:
3592        //
3593        //  1. An armed driver is revoked: it must never run turns headless with
3594        //     nobody watching and no way to answer a confirmation.
3595        //  2. Its pending host confirmations are answered `None` (deny) — a
3596        //     headless autonomous run must not sit on an unanswerable prompt.
3597        //     `tools/discovery.rs` treats `None` as Unauthorized (deny).
3598        //
3599        // For a PLAIN interactive session (no driver), a pending prompt SURVIVES
3600        // detach: the user reattaches and answers it — the daemon's core
3601        // detach/reattach contract, and detach is often involuntary (SSH drop,
3602        // sleep, wifi). Denying on detach would let a transient disconnect
3603        // silently reject the user's action. The resource pin from an
3604        // *abandoned* prompt is bounded by the pending-prompt deadline instead
3605        // (see the actor select loop), not by punishing every detach.
3606        if self.attached.is_empty() && (self.driver.is_some() || self.driver_pending.is_some()) {
3607            self.driver_revoke("no clients attached");
3608            let had_prompts = !self.pending_prompts.is_empty();
3609            while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3610                let _ = tx.send(None);
3611                self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3612            }
3613            if had_prompts {
3614                self.publish_presence();
3615            }
3616        }
3617    }
3618
3619    /// B1 (used by C3 reload): checkpoint the session without ending it —
3620    /// cancel any turn (partial history kept + interruption marker), abort compaction, answer
3621    /// pending prompts `None`, save, close PTYs. Replies on
3622    /// `CHECKPOINT_QUERY_ID` so `reload.rs` can await it per session.
3623    pub(crate) async fn checkpoint(&mut self, reason: CheckpointReason) {
3624        let interrupt = match reason {
3625            CheckpointReason::Reload => crate::engine::interrupt::InterruptReason::Restart,
3626            CheckpointReason::HostRequest => crate::engine::interrupt::InterruptReason::Host,
3627        };
3628        // Before the revoke below cancels a driver turn.
3629        self.note_cancel_cause(interrupt);
3630        // E-P3: driver does NOT survive reload (§3 S2/S5).
3631        if self.driver.is_some() {
3632            self.driver_revoke("daemon reloaded");
3633        }
3634        if self.streaming {
3635            self.cancel_turn(interrupt).await;
3636        }
3637        self.abort_compaction();
3638        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3639            let _ = tx.send(None);
3640            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3641        }
3642        // One bounded wait covers the cancelled turn's save too (queued, not
3643        // awaited, by `cancel_turn`).
3644        if !self.save().await {
3645            tracing::warn!(session = %self.id, "checkpoint: save failed or timed out");
3646        }
3647        let notice = match reason {
3648            CheckpointReason::Reload => {
3649                "daemon reloading — background shells/PTYs will be closed; the turn was checkpointed"
3650            }
3651            CheckpointReason::HostRequest => {
3652                "checkpoint — background shells/PTYs closed; the turn was checkpointed"
3653            }
3654        };
3655        self.emit(SessionEventWire::SystemNotice(notice.to_string()));
3656        self.runtime.session_manager().shutdown_all();
3657        let record = serde_json::to_value(self.reload_record()).unwrap_or_default();
3658        self.emit(SessionEventWire::QueryResult {
3659            id: super::wire::CHECKPOINT_QUERY_ID,
3660            value: serde_json::json!({ "ok": true, "record": record }),
3661        });
3662    }
3663
3664    /// Abort the spawned job (`CompactionCancelled`); prior state intact.
3665    pub(crate) fn abort_compaction(&mut self) {
3666        if let Some(job) = self.compact.take() {
3667            job.task.abort();
3668            self.emit(SessionEventWire::CompactionCancelled);
3669        }
3670    }
3671
3672    // ── command dispatch ─────────────────────────────────────────────────
3673
3674    /// B1: a client-stamped input command from anyone but the owner is
3675    /// `Refused` with no side effect; `from: None` (host) bypasses.
3676    async fn handle(&mut self, addressed: Addressed) -> std::ops::ControlFlow<EndReason> {
3677        use std::ops::ControlFlow;
3678        let Addressed { from, cmd } = addressed;
3679        if let Some(c) = from {
3680            if is_input_command(&cmd) && self.input_owner != Some(c) {
3681                let reason = match self.input_owner {
3682                    Some(o) => format!("input owned by client #{}", o.0),
3683                    None => "input has no owner; attach with --takeover".to_string(),
3684                };
3685                self.emit(SessionEventWire::Refused {
3686                    client: c,
3687                    command: command_name(&cmd).to_string(),
3688                    reason,
3689                });
3690                return ControlFlow::Continue(());
3691            }
3692        }
3693        // B3: anything that needs the runtime wakes a parked session first.
3694        if self.is_parked() {
3695            let needs_runtime = !matches!(
3696                cmd,
3697                SessionCommand::Attach { .. }
3698                    | SessionCommand::Detach { .. }
3699                    | SessionCommand::End { .. }
3700                    | SessionCommand::Query { .. }
3701                    | SessionCommand::Answer { .. }
3702                    | SessionCommand::Save
3703                    | SessionCommand::Cancel
3704                    | SessionCommand::Checkpoint { .. }
3705                    | SessionCommand::Park
3706                    | SessionCommand::Resync { .. }
3707                    | SessionCommand::HostEvent(_)
3708            );
3709            if matches!(cmd, SessionCommand::HostEvent(_)) {
3710                return ControlFlow::Continue(()); // nobody attached, nothing to render
3711            }
3712            if matches!(
3713                cmd,
3714                SessionCommand::Save
3715                    | SessionCommand::Cancel
3716                    | SessionCommand::Park
3717                    | SessionCommand::Checkpoint { .. }
3718            ) {
3719                if let SessionCommand::Checkpoint { .. } = cmd {
3720                    let record = serde_json::to_value(self.reload_record()).unwrap_or_default();
3721                    self.emit(SessionEventWire::QueryResult {
3722                        id: super::wire::CHECKPOINT_QUERY_ID,
3723                        value: serde_json::json!({ "ok": true, "parked": true, "record": record }),
3724                    });
3725                }
3726                return ControlFlow::Continue(()); // already on disk / idle
3727            }
3728            if needs_runtime {
3729                if let Err(e) = self.ensure_live().await {
3730                    match from {
3731                        Some(c) => self.emit(SessionEventWire::Refused {
3732                            client: c,
3733                            command: command_name(&cmd).to_string(),
3734                            reason: e,
3735                        }),
3736                        None => self.emit(SessionEventWire::SystemNotice(e)),
3737                    }
3738                    return ControlFlow::Continue(());
3739                }
3740            }
3741        }
3742        match cmd {
3743            SessionCommand::Submit { text, attachments } => self.submit(text, attachments, from).await,
3744            SessionCommand::Steer { text } => {
3745                if self.streaming {
3746                    self.steer(text)
3747                } else {
3748                    self.submit(text, vec![], from).await
3749                }
3750            }
3751            SessionCommand::Cancel => {
3752                // The user's cancel, even of a driver's turn: noted before
3753                // the revoke below cancels that turn.
3754                self.note_cancel_cause(crate::engine::interrupt::InterruptReason::User);
3755                if self.driver.is_some() {
3756                    self.driver_revoke("canceled");
3757                }
3758                self.cancel_turn(crate::engine::interrupt::InterruptReason::User)
3759                    .await;
3760            }
3761            SessionCommand::Answer { prompt_id, value } => self.answer(prompt_id, value),
3762            SessionCommand::Set { id, setting } => self.apply_setting(id, setting).await,
3763            // `CompactionStarted` is the contract; no notice before it.
3764            SessionCommand::Compact { instructions } => {
3765                self.compact(instructions, "manual").await;
3766            }
3767            SessionCommand::NewSession => {
3768                if self.driver.is_some() {
3769                    self.driver_revoke("session replaced");
3770                }
3771                // `clear` saves the old session directly: queued saves of it
3772                // must land first, never after.
3773                self.persister.flush().await;
3774                self.conv.clear(&self.runtime).await;
3775                self.runtime
3776                    .set_session_id(Some(self.conv.session.id.clone()));
3777                self.journal_id.store(Arc::new(self.conv.session.id.clone()));
3778                // F10: lock follows the new session id.
3779                self.reacquire_session_lock(&self.conv.session.id.clone());
3780                self.emit(SessionEventWire::Cleared {
3781                    session_id: self.conv.session.id.clone(),
3782                });
3783                self.emit_conversation();
3784            }
3785            SessionCommand::Save => {
3786                self.save().await;
3787            }
3788            SessionCommand::Query { id, query } => self.query(id, query).await,
3789            SessionCommand::EngineCommand { id, name, arg } => {
3790                self.engine_command(id, name, arg).await
3791            }
3792            SessionCommand::Attach { client, mode } => self.attach(client, mode).await,
3793            SessionCommand::Detach { client } => self.detach(client),
3794            SessionCommand::End { reason } => {
3795                // `finish` cancels a running turn for this reason; note it
3796                // before the revoke below cancels a driver turn first.
3797                self.note_cancel_cause(match reason {
3798                    EndReason::ClientQuit => crate::engine::interrupt::InterruptReason::User,
3799                    _ => crate::engine::interrupt::InterruptReason::Host,
3800                });
3801                if self.driver.is_some() {
3802                    self.driver_revoke("session ending");
3803                }
3804                return ControlFlow::Break(reason);
3805            }
3806            SessionCommand::Resync { .. } => self.emit(SessionEventWire::SystemNotice(
3807                "resync not supported yet".into(),
3808            )),
3809            // A3 bodies live in actor_cmds.rs; B1 (Checkpoint), B3 (KeepWarm)
3810            // fill in the rest.
3811            SessionCommand::SubmitPrepared {
3812                messages,
3813                user_text,
3814            } => self.submit_prepared(messages, user_text).await,
3815            SessionCommand::PluginCommand {
3816                id,
3817                plugin,
3818                name,
3819                arg,
3820            } => self.plugin_command(id, plugin, name, arg).await,
3821            SessionCommand::Resume { id, query } => self.resume(id, query).await,
3822            SessionCommand::Checkpoint { reason } => self.checkpoint(reason).await,
3823            SessionCommand::Park => {
3824                if let std::ops::ControlFlow::Break(reason) = self.park().await {
3825                    return ControlFlow::Break(reason);
3826                }
3827            }
3828            SessionCommand::KeepWarm { on } => {
3829                self.keep_warm = on;
3830                self.rearm_park();
3831                self.emit(SessionEventWire::SystemNotice(format!(
3832                    "keep-warm {}",
3833                    if on { "on: this session will not be parked" } else { "off" }
3834                )));
3835            }
3836            SessionCommand::HostEvent(ev) => match ev {
3837                HostEvent::ExtensionNotification {
3838                    extension_id,
3839                    method,
3840                    params,
3841                } => self.emit(SessionEventWire::ExtensionNotification {
3842                    extension_id,
3843                    method,
3844                    params,
3845                }),
3846                HostEvent::LoaderProgress(ev) => self.emit(SessionEventWire::LoaderProgress(ev)),
3847            },
3848            // E-P3: driver start handler.
3849            SessionCommand::DriverStart { plugin, command, arg } => {
3850                self.driver_start(plugin, command, arg);
3851            }
3852        }
3853        ControlFlow::Continue(())
3854    }
3855
3856    // ── teardown (tui/mod.rs:352-432 + chat.rs shutdown) ─────────────────
3857
3858    async fn finish(&mut self, reason: EndReason) {
3859        self.lifecycle.store(
3860            SessionLifecycle::Ending as u8,
3861            std::sync::atomic::Ordering::Release,
3862        );
3863        // Quitting the client mid-turn is the user's interruption; every
3864        // other end reason is the host's.
3865        let interrupt = match reason {
3866            EndReason::ClientQuit => crate::engine::interrupt::InterruptReason::User,
3867            _ => crate::engine::interrupt::InterruptReason::Host,
3868        };
3869        // Before the revoke below cancels a driver turn.
3870        self.note_cancel_cause(interrupt);
3871        // E-P3: clean up driver before teardown.
3872        if self.driver.is_some() {
3873            self.driver_revoke("session ending");
3874        }
3875        if self.streaming {
3876            self.cancel_turn(interrupt).await;
3877        }
3878        self.abort_compaction();
3879        // Outstanding prompts are cancelled (tool sees `None`).
3880        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3881            let _ = tx.send(None);
3882            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3883        }
3884
3885        let parked = !self.conv.is_live();
3886        let session_id = (**self.journal_id.load()).clone();
3887        let api_messages = if parked {
3888            None
3889        } else {
3890            Some(self.conv.api_messages.clone())
3891        };
3892
3893        // STEP 1: save — own bounded budget, highest priority. A parked
3894        // session is already on disk: end record only. The wait covers
3895        // everything queued, including the cancelled turn's save.
3896        let persist = self.config.persist;
3897        if !parked {
3898            self.request_save();
3899        }
3900        let persister = &self.persister;
3901        let save_fut = async {
3902            if persist {
3903                if !persister.flush().await {
3904                    tracing::warn!(session = %session_id, "session end: save failed");
3905                }
3906                let mut index_record =
3907                    crate::core::session_index::SessionIndexRecord::end(&session_id);
3908                index_record.turns = api_messages.as_ref().map(|m| m.len());
3909                if let Err(err) = crate::core::session_index::append_record(&index_record) {
3910                    tracing::warn!("failed to append session end index record: {}", err);
3911                }
3912            }
3913        };
3914        if tokio::time::timeout(budgets::SAVE_TIMEOUT, save_fut)
3915            .await
3916            .is_err()
3917        {
3918            tracing::warn!(
3919                budget_secs = budgets::SAVE_TIMEOUT_SECS,
3920                "session save timed out — data may be incomplete"
3921            );
3922        }
3923
3924        // STEP 2 (C2): on_session_end — per session, concurrent, fail-open,
3925        // own budget; clears this session's keyed injection.
3926        let hook_bus = Arc::clone(&self.hook_bus);
3927        crate::extensions::loader::emit_session_end(
3928            &hook_bus,
3929            &session_id,
3930            api_messages,
3931            budgets::HOOKS_TIMEOUT,
3932        )
3933        .await;
3934
3935        // STEP 3: bounded observability flush (live only).
3936        if self.runtime.is_live() {
3937            if let Some(outcome) = self
3938                .runtime
3939                .shutdown_observability_async(
3940                    crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT,
3941                )
3942                .await
3943            {
3944                if !outcome.is_flushed() {
3945                    tracing::warn!(
3946                        stats = ?outcome.stats(),
3947                        "observability flush timed out — detached worker keeps draining"
3948                    );
3949                }
3950            }
3951        }
3952
3953        // Inbox watcher, per-session UDS, registry entry, keyed injection.
3954        self.background.shutdown();
3955        self.emit(SessionEventWire::Ended { reason });
3956    }
3957
3958    pub fn id(&self) -> &SessionId {
3959        &self.id
3960    }
3961}
3962
3963/// The tokio task. `SessionTask::run` is the reactor loop.
3964pub struct SessionTask(SessionActor);
3965
3966async fn next_stream_event(stream: &mut Option<ActiveStream>) -> Option<StreamEvent> {
3967    match stream {
3968        Some(s) => s.next().await,
3969        None => std::future::pending().await,
3970    }
3971}
3972
3973/// Whether the 1 Hz `SubagentRows` tick keeps running: always during a turn,
3974/// and after it while any worker is still running (a background worker's
3975/// progress events rode the ended turn's stream; the rows carry it now).
3976fn keep_subagent_tick(streaming: bool, any_running: bool) -> bool {
3977    streaming || any_running
3978}
3979
3980async fn next_tick(tick: &mut Option<tokio::time::Interval>) {
3981    match tick {
3982        Some(t) => {
3983            t.tick().await;
3984        }
3985        None => std::future::pending().await,
3986    }
3987}
3988
3989async fn poll_compaction(
3990    job: &mut Option<CompactionJob>,
3991) -> std::result::Result<Result<crate::runtime::compaction::CompactionOutcome>, tokio::task::JoinError> {
3992    match job {
3993        Some(j) => (&mut j.task).await,
3994        None => std::future::pending().await,
3995    }
3996}
3997
3998async fn park_timer(deadline: Option<tokio::time::Instant>) {
3999    match deadline {
4000        Some(t) => tokio::time::sleep_until(t).await,
4001        None => std::future::pending().await,
4002    }
4003}
4004
4005/// `revoked_turn_deadline`: same shape as `park_timer`.
4006async fn revoked_turn_timer(deadline: Option<tokio::time::Instant>) {
4007    match deadline {
4008        Some(t) => tokio::time::sleep_until(t).await,
4009        None => std::future::pending().await,
4010    }
4011}
4012
4013/// (P11) Prompt-abandonment deadline: same shape as `park_timer`. `None` =
4014/// disabled (no abandoned prompt, or the feature is off) → pends forever.
4015async fn prompt_abandon_timer(deadline: Option<tokio::time::Instant>) {
4016    match deadline {
4017        Some(t) => tokio::time::sleep_until(t).await,
4018        None => std::future::pending().await,
4019    }
4020}
4021
4022/// 200 ms cadence, gated on driver state — `None` driver+pending → pending forever.
4023async fn driver_tick_timer(
4024    has_driver: bool,
4025    has_pending: bool,
4026    interval: &mut tokio::time::Interval,
4027) {
4028    if has_driver || has_pending {
4029        interval.tick().await;
4030    } else {
4031        std::future::pending().await
4032    }
4033}
4034
4035async fn ext_ready(rx: &mut Option<oneshot::Receiver<()>>) {
4036    match rx {
4037        Some(r) => {
4038            let _ = r.await;
4039        }
4040        None => std::future::pending().await,
4041    }
4042}
4043
4044impl SessionTask {
4045    pub fn id(&self) -> &SessionId {
4046        self.0.id()
4047    }
4048
4049    /// Unbiased select (the TUI loop is unbiased too).
4050    pub async fn run(mut self) {
4051        let queue = Arc::clone(&self.0.event_queue);
4052        let reason = loop {
4053            let actor = &mut self.0;
4054            tokio::select! {
4055                cmd = actor.cmd_rx.recv() => match cmd {
4056                    Some(cmd) => {
4057                        if let std::ops::ControlFlow::Break(reason) = actor.handle(cmd).await {
4058                            break reason;
4059                        }
4060                    }
4061                    // Every handle dropped: nobody can ever reach us again.
4062                    None => break EndReason::HostShutdown,
4063                },
4064                Some(req) = actor.secret_prompt_rx.recv() => actor.on_prompt_request(req),
4065                _ = queue.notified() => actor.on_queue_wake().await,
4066                _ = next_tick(&mut actor.subagent_tick) => {
4067                    actor.publish_subagent_rows();
4068                    actor.flush_turn_draft();
4069                }
4070                _ = park_timer(actor.park_deadline) => {
4071                    if let std::ops::ControlFlow::Break(reason) = actor.park().await {
4072                        break reason;
4073                    }
4074                }
4075                _ = prompt_abandon_timer(actor.prompt_abandon_deadline) => {
4076                    actor.on_prompt_abandon_deadline();
4077                }
4078                res = poll_compaction(&mut actor.compact) => actor.on_compaction_done(res).await,
4079                _ = ext_ready(&mut actor.ext_ready) => {
4080                    actor.ext_ready = None;
4081                    let id = (**actor.journal_id.load()).clone();
4082                    let bus = Arc::clone(&actor.hook_bus);
4083                    crate::extensions::loader::emit_session_start(&bus, &id).await;
4084                    if actor.runtime.is_live() {
4085                        actor.publish_view().await;
4086                    }
4087                },
4088                _ = driver_tick_timer(actor.driver.is_some(), actor.driver_pending.is_some(), &mut actor.driver_tick_interval) => {
4089                    actor.driver_tick().await;
4090                }
4091                _ = revoked_turn_timer(actor.revoked_turn_deadline) => {
4092                    actor.finish_revoked_turn(false).await;
4093                }
4094                ev = next_stream_event(&mut actor.stream) => match ev {
4095                    Some(ev) => actor.on_stream_event(ev).await,
4096                    // A revoked turn's stream ending is its end.
4097                    None if actor.turn_cancelled() => actor.finish_revoked_turn(true).await,
4098                    None => {
4099                        // Stream ended without a terminal event: defensive reset.
4100                        // P5: capture_terminal(None) = EOF → revoke driver.
4101                        if actor.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
4102                            let terminal = super::driver::capture_terminal(None, false);
4103                            if let Some(terminal) = terminal {
4104                                if let Some(driver) = actor.driver.as_mut() {
4105                                    let reason = super::driver::observe_terminal(driver, &actor.runtime, terminal);
4106                                    // F-NEW-1 (shady): EOF is a Blocked terminal.
4107                                    // Emit a DriverTurnOutcome before revoking so a
4108                                    // client tracking outcomes sees the same
4109                                    // outcome+revoke pair the Error path produces —
4110                                    // not a turn that silently vanishes.
4111                                    let sel = driver.selection.clone();
4112                                    let fb = driver.grant.feedback_enabled()
4113                                        .then(|| driver.completed_feedback.to_string());
4114                                    actor.emit(SessionEventWire::DriverTurnOutcome {
4115                                        outcome: crate::extensions::session_driver::Outcome::Blocked,
4116                                        selection: sel,
4117                                        feedback: fb,
4118                                    });
4119                                    if let Some(reason) = reason {
4120                                        actor.driver_revoke(&reason);
4121                                    }
4122                                }
4123                            }
4124                        }
4125                        actor.clear_stream();
4126                        actor.emit_conversation();
4127                        actor.announce_idle().await;
4128                    }
4129                },
4130            }
4131        };
4132        self.0.finish(reason).await;
4133    }
4134}
4135
4136#[cfg(test)]
4137mod subagent_tick_tests {
4138    use super::keep_subagent_tick;
4139
4140    #[test]
4141    fn rows_keep_flowing_after_the_turn_while_a_worker_runs() {
4142        assert!(keep_subagent_tick(true, false), "during a turn, always");
4143        assert!(keep_subagent_tick(true, true));
4144        assert!(
4145            keep_subagent_tick(false, true),
4146            "after the turn, while a background worker runs"
4147        );
4148        assert!(
4149            !keep_subagent_tick(false, false),
4150            "stops once the turn is over and no worker runs"
4151        );
4152    }
4153}
4154
4155#[cfg(test)]
4156mod parked_evict_tests {
4157    use super::parked_evict_after_from;
4158    use super::prompt_abandon_timeout_from;
4159    use std::time::Duration;
4160
4161    #[test]
4162    fn parked_evict_defaults_to_one_hour_and_honours_never() {
4163        // cfg_secs = 3600 mirrors the DaemonConfig default.
4164        let d = 3600u64;
4165        assert_eq!(parked_evict_after_from(None, d), Some(Duration::from_secs(3600)));
4166        assert_eq!(parked_evict_after_from(Some("120"), d), Some(Duration::from_secs(120)));
4167        for never in ["never", "0", "off"] {
4168            assert_eq!(parked_evict_after_from(Some(never), d), None, "{never:?}");
4169        }
4170        assert_eq!(parked_evict_after_from(Some("junk"), d), Some(Duration::from_secs(3600)));
4171        // env absent → config wins; config 0 disables; env still beats config.
4172        assert_eq!(parked_evict_after_from(None, 42), Some(Duration::from_secs(42)));
4173        assert_eq!(parked_evict_after_from(None, 0), None);
4174        assert_eq!(parked_evict_after_from(Some("7"), 42), Some(Duration::from_secs(7)));
4175    }
4176
4177    #[test]
4178    fn prompt_abandon_defaults_to_one_hour_and_honours_never() {
4179        // cfg_secs = 3600 mirrors the DaemonConfig default.
4180        let d = 3600u64;
4181        assert_eq!(prompt_abandon_timeout_from(None, d), Some(Duration::from_secs(3600)));
4182        assert_eq!(prompt_abandon_timeout_from(Some("30"), d), Some(Duration::from_secs(30)));
4183        // Disabled sentinels ⇒ None ⇒ pure pre-#112 (prompt survives forever).
4184        for never in ["never", "0", "off"] {
4185            assert_eq!(prompt_abandon_timeout_from(Some(never), d), None, "{never:?}");
4186        }
4187        // Garbage falls back to the safe default rather than disabling the guard.
4188        assert_eq!(prompt_abandon_timeout_from(Some("junk"), d), Some(Duration::from_secs(3600)));
4189        // env absent → config wins; config 0 disables; env still beats config.
4190        assert_eq!(prompt_abandon_timeout_from(None, 42), Some(Duration::from_secs(42)));
4191        assert_eq!(prompt_abandon_timeout_from(None, 0), None);
4192        assert_eq!(prompt_abandon_timeout_from(Some("7"), 42), Some(Duration::from_secs(7)));
4193    }
4194}