Skip to main content

agent_engine/session/
actor.rs

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