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