Skip to main content

agent_engine/session/
types.rs

1//! Identity, configuration, commands, events and the envelope — the entire
2//! client↔session contract (PLAN-phase2 §2.1–§2.4). Everything a client can
3//! send is serde-able from day one; `SessionEventWire::Stream` deliberately
4//! is not (it carries the un-serialised `StreamEvent` in-process).
5
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10use super::view::RuntimeView;
11
12// ── identity + configuration ──────────────────────────────────────────────────
13
14/// Conversation session id — the `Session.id` string (`{name}-{ts}-{pid}`; see
15/// events/registry.rs sanitize rules). NOT the tools::activation::SessionId
16/// (that is the per-runtime gate identity minted at Runtime::from_parts).
17#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(transparent)]
19pub struct SessionId(pub String);
20
21impl SessionId {
22    pub fn as_str(&self) -> &str {
23        &self.0
24    }
25}
26
27impl std::fmt::Display for SessionId {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.write_str(&self.0)
30    }
31}
32
33impl From<String> for SessionId {
34    fn from(s: String) -> Self {
35        Self(s)
36    }
37}
38
39impl From<&str> for SessionId {
40    fn from(s: &str) -> Self {
41        Self(s.to_string())
42    }
43}
44
45/// Ordered key-value snapshot of a client's environment.
46pub type SessionEnv = Vec<(String, String)>;
47
48/// Secret env vars an OWNED session's creator forwards (`forward_secrets`):
49/// exactly the vars [`capture_client_env`] strips as secrets. Held in the
50/// daemon's memory for the session's lifetime only, applied to its tools'
51/// env and never to `SessionConfig`, the journal, the reload record or any
52/// log. `Debug` prints names only.
53#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(transparent)]
55pub struct SecretEnv(pub SessionEnv);
56
57impl std::fmt::Debug for SecretEnv {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        let names: Vec<&str> = self.0.iter().map(|(k, _)| k.as_str()).collect();
60        write!(f, "SecretEnv({names:?})")
61    }
62}
63
64/// What a session's tools spawn with: the session env snapshot, plus (owned
65/// sessions with `forward_secrets` only) the forwarded secrets, kept apart
66/// so no `Debug` of the runtime, the tool capabilities or a worker's
67/// workspace can print them. Merged into a child's env only at spawn
68/// ([`ToolEnv::spawn_vars`]).
69#[derive(Clone, Default, PartialEq, Eq)]
70pub struct ToolEnv {
71    pub vars: SessionEnv,
72    secrets: Option<std::sync::Arc<SecretEnv>>,
73}
74
75impl ToolEnv {
76    pub fn with_secrets(vars: SessionEnv, secrets: SecretEnv) -> Self {
77        Self { vars, secrets: (!secrets.0.is_empty()).then(|| std::sync::Arc::new(secrets)) }
78    }
79
80    fn secret_vars(&self) -> &[(String, String)] {
81        self.secrets.as_deref().map(|s| s.0.as_slice()).unwrap_or(&[])
82    }
83
84    /// One var as a child would see it.
85    pub fn get(&self, key: &str) -> Option<&str> {
86        self.secret_vars()
87            .iter()
88            .chain(self.vars.iter())
89            .find(|(k, _)| k == key)
90            .map(|(_, v)| v.as_str())
91    }
92
93    /// The child's env, at spawn only: the session vars, each forwarded
94    /// secret in place of a var of the same name.
95    pub fn spawn_vars(&self) -> impl Iterator<Item = (&str, &str)> + '_ {
96        let secrets = self.secret_vars();
97        self.vars
98            .iter()
99            .filter(move |(k, _)| !secrets.iter().any(|(s, _)| s == k))
100            .chain(secrets.iter())
101            .map(|(k, v)| (k.as_str(), v.as_str()))
102    }
103}
104
105impl From<SessionEnv> for ToolEnv {
106    fn from(vars: SessionEnv) -> Self {
107        Self { vars, secrets: None }
108    }
109}
110
111impl std::fmt::Debug for ToolEnv {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.debug_struct("ToolEnv").field("vars", &self.vars).field("secrets", &self.secrets).finish()
114    }
115}
116
117/// The capability that binds an owned session to its owner: minted by the
118/// daemon at create, returned only to the creating connection, presented
119/// again to re-bind after a daemon reload. `Debug` is redacted.
120#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(transparent)]
122pub struct OwnerToken(pub String);
123
124impl OwnerToken {
125    pub fn mint() -> Self {
126        Self(uuid::Uuid::new_v4().simple().to_string())
127    }
128}
129
130impl std::fmt::Debug for OwnerToken {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        f.write_str("OwnerToken(<redacted>)")
133    }
134}
135
136impl OwnerToken {
137    /// Constant-time: how long a wrong token takes to refuse says nothing
138    /// about how much of it was right.
139    pub fn matches(&self, other: &OwnerToken) -> bool {
140        let (a, b) = (self.0.as_bytes(), other.0.as_bytes());
141        a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
142    }
143}
144
145/// Daemon-set (`SessionConfig::owned`): the session is owned, bound to the
146/// connection that presents `token`.
147#[derive(Clone, Debug)]
148pub struct OwnedConfig {
149    pub token: OwnerToken,
150    /// Restored by a reload's rehydrate: the session waits, unbound and
151    /// holding its autonomous work, until the host arms the re-bind grace
152    /// (`SessionCommand::ArmOwnerRebind`, once the new image accepts
153    /// connections) and its owner re-binds. A fresh create arms the grace
154    /// at once: its creator is attaching right now.
155    pub restored: bool,
156}
157
158/// Host-side (never wire): an owner claim plus the claiming connection's
159/// liveness. The connection task holds a drop guard on `alive` for as long
160/// as it serves, so the actor learns its owner is gone when the task ends
161/// for ANY reason (EOF, error, panic, an attach that timed out after this
162/// was queued), even if no `Detach` ever arrives.
163pub struct OwnerBind {
164    pub claim: OwnerClaim,
165    pub alive: tokio_util::sync::CancellationToken,
166}
167
168/// A connection's claim to be an owned session's owner: the token, plus the
169/// secrets it forwards (`None` = forward none).
170#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
171pub struct OwnerClaim {
172    pub token: OwnerToken,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub forward_secrets: Option<SecretEnv>,
175}
176
177/// The secrets [`capture_client_env`] strips from this process's env, with
178/// their values: what an owned create with `forward_secrets` sends.
179pub fn capture_client_secrets() -> SecretEnv {
180    client_secret_env(std::env::vars())
181}
182
183/// The pure part of [`capture_client_secrets`]: the vars `strip_client_env`
184/// records as stripped secrets. Client-only `SYNAPS_*` knobs never go, even
185/// with a secret-looking name.
186pub fn client_secret_env(vars: impl IntoIterator<Item = (String, String)>) -> SecretEnv {
187    let mut out: SessionEnv = vars
188        .into_iter()
189        .filter(|(k, v)| {
190            let upper = k.to_ascii_uppercase();
191            let client_only = ENV_STRIP_PREFIXES.iter().any(|p| upper.starts_with(p));
192            !client_only && (is_secret_key(k) || agent_core::core::config::is_secret_env_value(v))
193        })
194        .collect();
195    out.sort_by(|(a, _), (b, _)| a.cmp(b));
196    SecretEnv(out)
197}
198
199/// Prefixes stripped from the client env before it enters the session.
200/// These are client-only or daemon-only knobs — never session identity.
201const ENV_STRIP_PREFIXES: &[&str] = &[
202    "SYNAPS_CLIENT_",
203    "SYNAPS_TUI_",
204    "SYNAPS_DAEMON_",
205    "SYNAPS_MEM_TRACE",
206];
207
208/// Secret patterns (case-insensitive suffix/infix) stripped at the client.
209/// Secrets never reach the daemon — the broker owns credentials.
210/// Build a [`SessionEnv`] from the current process environment, stripping
211/// client-only prefixes and secrets. Returns `(kept_env, stripped_secret_names)`.
212/// The stripped names are ONLY the secrets (not `SYNAPS_*` prefixes, those are noise).
213/// Used by thin clients before `Hello`.
214pub fn capture_client_env() -> (SessionEnv, Vec<String>) {
215    strip_client_env(std::env::vars())
216}
217
218/// The pure part of [`capture_client_env`]: filter `vars` (unit-testable
219/// without touching the process environment).
220pub fn strip_client_env(
221    vars: impl IntoIterator<Item = (String, String)>,
222) -> (SessionEnv, Vec<String>) {
223    let mut env: SessionEnv = Vec::new();
224    let mut stripped: Vec<String> = Vec::new();
225    for (k, v) in vars {
226        // A credential VALUE is stripped whatever its key is called
227        // (`DATABASE_URL=postgres://u:p@h`): the name denylist alone let it
228        // through to the daemon and the session files.
229        let secret_value = agent_core::core::config::is_secret_env_value(&v);
230        if should_strip_env(&k) || secret_value {
231            // Only record secret names, not SYNAPS_* prefix noise.
232            if is_secret_key(&k) || secret_value {
233                stripped.push(k);
234            }
235        } else {
236            env.push((k, v));
237        }
238    }
239    env.sort_by(|(a, _), (b, _)| a.cmp(b));
240    stripped.sort();
241    (env, stripped)
242}
243
244/// Whether a key should be stripped from the client env snapshot.
245pub fn should_strip_env(key: &str) -> bool {
246    let upper = key.to_ascii_uppercase();
247    for prefix in ENV_STRIP_PREFIXES {
248        if upper.starts_with(prefix) {
249            return true;
250        }
251    }
252    is_secret_key(&upper)
253}
254
255/// Whether a key matches the secret denylist (case-insensitive).
256/// Delegates to the canonical implementation in `agent_core::core::config`.
257pub fn is_secret_key(key: &str) -> bool {
258    agent_core::core::config::is_secret_key(key)
259}
260
261/// Everything `EngineHost::create_session` needs. Serializable: it is the
262/// payload of the wire `Attach::Create`. Mirrors `EngineOpts` minus the
263/// host-level fields (profile, no_extensions), plus the per-session facts a
264/// daemon cannot inherit from its own process (cwd, env overlay).
265#[derive(Clone, Debug, Serialize, Deserialize)]
266pub struct SessionConfig {
267    /// `EngineOpts.continue_session`. `Some(None)` = bare `--continue` (most
268    /// recent journal). Plain serde encodes `Some(None)` as `null` and decodes
269    /// that as `None`, so the daemon could never receive a bare `--continue`
270    /// (soak F21) — `double_option` keeps the two levels apart on the wire.
271    #[serde(default, with = "double_option", skip_serializing_if = "Option::is_none")]
272    pub continue_session: Option<Option<String>>,
273    /// `EngineOpts.system`.
274    #[serde(default)]
275    pub system: Option<String>,
276    /// `EngineOpts.prompt_manifest`.
277    #[serde(default)]
278    pub prompt_manifest: Option<PathBuf>,
279    /// Per-session working directory. `None` = process cwd (in-process hosts
280    /// pass `None` today → byte-identical). The daemon ALWAYS fills it from
281    /// `Hello.cwd`.
282    #[serde(default)]
283    pub cwd: Option<PathBuf>,
284    /// Per-session environment snapshot. `None` = inherit process env
285    /// (in-process hosts). The daemon fills it from `Hello.env` on
286    /// `Attach::Create` only; attaching to an existing session does not
287    /// change the creator's env.
288    #[serde(default)]
289    pub env: Option<SessionEnv>,
290    /// Names of environment variables stripped as secrets at the client
291    /// (e.g. `GH_TOKEN`, `AWS_SECRET_ACCESS_KEY`). NOT the `SYNAPS_*`
292    /// prefixes (those are noise). Used for loud notices when a tool fails
293    /// referencing a stripped secret. Persisted in the journal (names only,
294    /// never values).
295    #[serde(default, skip_serializing_if = "Vec::is_empty")]
296    pub env_stripped: Vec<String>,
297    /// `Confirm` hook results auto-approved (server `--auto-approve-confirms`).
298    /// Interactive hosts: false.
299    #[serde(default)]
300    pub auto_approve_confirms: bool,
301    /// CLI `--model` override applied after session resolution.
302    #[serde(default)]
303    pub model_override: Option<String>,
304    /// Save policy: only "save at all" — the TUI/chat/rpc always save; tests
305    /// may disable. Default `true`.
306    #[serde(default = "default_true")]
307    pub persist: bool,
308    /// Headless-chat policy: after every turn, save + `assess_context` and
309    /// auto-compact when the engine budget says so (chat.rs post-turn block).
310    /// The TUI does its own compaction; default `false`.
311    #[serde(default)]
312    pub auto_compact: bool,
313    /// Compaction transition policy: `InPlace` (default; chat, today's
314    /// actor) or `LinkedSuccessor` (the TUI's successor-session chain).
315    #[serde(default)]
316    pub compaction_policy: CompactionPolicyWire,
317    /// Block `create` on process-level extension discovery before emitting
318    /// `on_session_start` (chat/daemon). The TUI passes `false`: the actor
319    /// emits it from a spawned waiter so boot never blocks on discovery.
320    #[serde(default = "default_true")]
321    pub await_extensions: bool,
322    /// Never `Park` this session (`--keep-warm`). Default `false`.
323    #[serde(default)]
324    pub keep_warm: bool,
325    /// Who may drive this session ([`ClientInput`]); `None` = config
326    /// `client_input`. An older daemon ignores it (and enforces the owner).
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub client_input: Option<ClientInput>,
329    /// `--name`: name the session at create (same rules as `/cmd saveas`;
330    /// must be unique among saved sessions). Applied before the registry
331    /// entry is written so `synaps send --session <name>` resolves at once.
332    #[serde(default)]
333    pub name: Option<String>,
334    /// (E-P7, §S3) Hard per-session spend ceiling in USD. `None` = unbounded.
335    /// Checked after every `Usage` stream event; a breach cancels the turn,
336    /// revokes any armed driver, and emits `CostCapReached`. This is the
337    /// host-owned circuit breaker for an actor-resident autonomous driver.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub max_session_cost: Option<f64>,
340    /// Set by a daemon reload for a session it restores Parked (never on
341    /// the wire): with `await_extensions=false`, `on_session_start` waits
342    /// until the session is unparked instead of running right after create.
343    #[serde(skip)]
344    pub restore_parked: bool,
345    /// Where this session's message times come from (never on the wire).
346    /// `None` = the wall clock; tests inject a clock to advance time.
347    #[serde(skip)]
348    pub message_clock: Option<crate::runtime::message_time::MessageClock>,
349    /// Set by the daemon (never on the wire, never in the reload record's
350    /// config): this session is OWNED, bound to the connection that
351    /// presents its token (see `docs/daemon-mode.md`, "Owned sessions").
352    #[serde(skip)]
353    pub owned: Option<OwnedConfig>,
354}
355
356fn default_true() -> bool {
357    true
358}
359
360/// Serializable mirror of `runtime::compaction::CompactionPolicy`.
361#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
362#[serde(rename_all = "snake_case")]
363pub enum CompactionPolicyWire {
364    #[default]
365    InPlace,
366    LinkedSuccessor,
367}
368
369impl From<CompactionPolicyWire> for crate::runtime::compaction::CompactionPolicy {
370    fn from(p: CompactionPolicyWire) -> Self {
371        match p {
372            CompactionPolicyWire::InPlace => Self::InPlace,
373            CompactionPolicyWire::LinkedSuccessor => Self::LinkedSuccessor,
374        }
375    }
376}
377
378impl Default for SessionConfig {
379    fn default() -> Self {
380        Self {
381            continue_session: None,
382            system: None,
383            prompt_manifest: None,
384            cwd: None,
385            env: None,
386            env_stripped: Vec::new(),
387            auto_approve_confirms: false,
388            model_override: None,
389            persist: true,
390            auto_compact: false,
391            compaction_policy: CompactionPolicyWire::InPlace,
392            await_extensions: true,
393            keep_warm: false,
394            client_input: None,
395            name: None,
396            max_session_cost: None,
397            restore_parked: false,
398            message_clock: None,
399            owned: None,
400        }
401    }
402}
403
404/// Serializable copy of `engine::setup::ContinueInfo`.
405#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
406pub struct ContinueInfoWire {
407    pub session_id: String,
408    /// "chain", "name", "compacted", or None.
409    pub resolved_via: Option<String>,
410    pub query: String,
411    /// F24: notice when the session was followed through a compaction chain.
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub compaction_notice: Option<String>,
414}
415
416impl From<&crate::engine::setup::ContinueInfo> for ContinueInfoWire {
417    fn from(c: &crate::engine::setup::ContinueInfo) -> Self {
418        Self {
419            session_id: c.session_id.clone(),
420            resolved_via: c.resolved_via.clone(),
421            query: c.query.clone(),
422            compaction_notice: c.compaction_notice.clone(),
423        }
424    }
425}
426
427#[derive(Clone, Debug, Serialize, Deserialize)]
428pub struct SessionMeta {
429    pub id: SessionId,
430    pub name: Option<String>,
431    pub model: String,
432    pub cwd: Option<PathBuf>,
433    pub created_at: chrono::DateTime<chrono::Utc>,
434    pub continued: bool,
435    pub continue_info: Option<ContinueInfoWire>,
436    /// Daemon pid (in-process: this process).
437    pub host_pid: u32,
438    // ── filled at list time from the handle's cells (meta itself is frozen) ──
439    #[serde(default)]
440    pub lifecycle: SessionLifecycle,
441    #[serde(default)]
442    pub clients: usize,
443    #[serde(default)]
444    pub input_owner: Option<ClientId>,
445    #[serde(default)]
446    pub awaiting_input: usize,
447    /// Every attached client, named. Empty from an older daemon.
448    #[serde(default, skip_serializing_if = "Vec::is_empty")]
449    pub roster: Vec<ClientInfo>,
450    /// Who may drive ([`ClientInput`]); `None` from an older daemon (which
451    /// enforces the owner: `Exclusive`). With `Shared`, `input_owner` is the
452    /// active client, not the only one that may type.
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub client_input: Option<ClientInput>,
455    /// `conv.session.id` — differs from `id` after a LinkedSuccessor compaction.
456    #[serde(default)]
457    pub journal_id: String,
458    /// Set when the session's journal is locked by another process (F23):
459    /// `"pid N (kind)"`. Shown in `daemon sessions`; cleared on unpark.
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub locked_by: Option<String>,
462    /// Pinned (`KeepWarm{on}`, `/pin`): never parked, never idle-ended, and
463    /// the daemon does not idle-exit while it is set. An older daemon omits
464    /// it (→ false); an older client ignores it.
465    #[serde(default)]
466    pub pinned: bool,
467    // ── state for the `/sessions` modal, published by the actor. An older
468    //    daemon omits them (→ defaults); an older client ignores them. ──
469    /// A turn or a compaction is running.
470    #[serde(default)]
471    pub busy: bool,
472    /// When the session entered its current state (turn/compaction start,
473    /// idle start, park time).
474    #[serde(default)]
475    pub state_since: Option<chrono::DateTime<chrono::Utc>>,
476    /// When the idle park fires (Live, zero clients, grace armed). `None`
477    /// when not armed — a worker re-check is not a park due.
478    #[serde(default)]
479    pub park_due: Option<chrono::DateTime<chrono::Utc>>,
480    /// When a Parked session is evicted; `None` if not parked or never.
481    #[serde(default)]
482    pub evict_due: Option<chrono::DateTime<chrono::Utc>>,
483    /// The live conversation's title, as of the last publish.
484    #[serde(default)]
485    pub title: Option<String>,
486    /// Session cost in USD, as of the last publish.
487    #[serde(default)]
488    pub cost_usd: f64,
489    /// API message count, as of the last publish.
490    #[serde(default)]
491    pub messages: usize,
492}
493
494/// Where a session is in its park/unpark life (`SessionHandle::lifecycle`).
495#[repr(u8)]
496#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
497#[serde(rename_all = "snake_case")]
498pub enum SessionLifecycle {
499    #[default]
500    Live = 0,
501    Parking = 1,
502    Parked = 2,
503    Ending = 3,
504}
505
506impl SessionLifecycle {
507    pub fn from_u8(v: u8) -> Self {
508        match v {
509            1 => Self::Parking,
510            2 => Self::Parked,
511            3 => Self::Ending,
512            _ => Self::Live,
513        }
514    }
515}
516
517pub use crate::core::config::ClientInput;
518
519/// Minted by the actor per Attach.
520#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
521#[serde(transparent)]
522pub struct ClientId(pub u64);
523
524#[derive(Clone, Debug, Serialize, Deserialize)]
525pub struct ClientMeta {
526    pub kind: ClientKind,
527    /// `$TERM`, informational.
528    pub terminal: Option<String>,
529    /// Client-generated uuid; reattach dedup (day 3).
530    pub instance: String,
531    /// How much history this client wants mirrored (phase 4).
532    #[serde(default)]
533    pub history: HistoryMode,
534    /// Display items in `Attached.display_tail` / `DisplayTail` queries (phase 4).
535    #[serde(default = "default_tail_items")]
536    pub tail_items: usize,
537    /// Additive stream events this client can decode. Absent from older
538    /// clients (all `false`), so a newer daemon never sends them one; an
539    /// older daemon ignores the unknown field.
540    #[serde(default, skip_serializing_if = "ClientCapabilities::is_none")]
541    pub capabilities: ClientCapabilities,
542    /// What this client asks to be called (`--as`, `SYNAPS_CLIENT_NAME`):
543    /// a label, not a login — every client is the daemon's own user. The
544    /// session resolves it ([`resolve_client_name`]) and reports the result
545    /// in `ClientJoined`/`roster`. Absent = the kind's name. An older
546    /// daemon ignores it.
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub name: Option<String>,
549}
550
551/// Longest client name ([`validate_client_name`]).
552pub const CLIENT_NAME_MAX: usize = 32;
553
554/// A client name is 1–32 ASCII letters, digits, `-` or `_`, starting with
555/// a letter or digit. The client checks before connecting; the daemon
556/// checks again and refuses the handshake.
557pub fn validate_client_name(name: &str) -> Result<(), String> {
558    let ok_first = name.chars().next().is_some_and(|c| c.is_ascii_alphanumeric());
559    let ok_rest = name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
560    if name.is_empty() || name.len() > CLIENT_NAME_MAX || !ok_first || !ok_rest {
561        return Err(format!(
562            "invalid client name {name:?}: use 1-{CLIENT_NAME_MAX} letters, digits, '-' or '_', starting with a letter or digit"
563        ));
564    }
565    Ok(())
566}
567
568/// `SYNAPS_CLIENT_NAME`, trimmed; `None` when unset or empty. Not
569/// validated here: the caller reports a bad one.
570pub fn client_name_from_env() -> Option<String> {
571    std::env::var("SYNAPS_CLIENT_NAME")
572        .ok()
573        .map(|v| v.trim().to_string())
574        .filter(|v| !v.is_empty())
575}
576
577/// The name a joining client gets in a session: its requested name, or its
578/// kind's name if it asked for none (or an invalid one), with the lowest
579/// free `-N` suffix (N ≥ 2) if another client already has it. Names
580/// compare case-insensitively.
581pub fn resolve_client_name<'a>(
582    requested: Option<&str>,
583    kind: ClientKind,
584    taken: impl IntoIterator<Item = &'a str>,
585) -> String {
586    let base = requested
587        .filter(|n| validate_client_name(n).is_ok())
588        .unwrap_or(kind.default_name());
589    let taken: std::collections::HashSet<String> = taken.into_iter().map(str::to_ascii_lowercase).collect();
590    if !taken.contains(&base.to_ascii_lowercase()) {
591        return base.to_string();
592    }
593    (2..)
594        .map(|n| format!("{base}-{n}"))
595        .find(|c| !taken.contains(&c.to_ascii_lowercase()))
596        .expect("an unbounded range always has a free suffix")
597}
598
599/// One attached client as every client sees it (`AttachSnapshot.roster`,
600/// `ClientJoined`, `/clients`, `synaps daemon sessions`).
601#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
602pub struct ClientInfo {
603    pub id: ClientId,
604    /// Resolved by the session ([`resolve_client_name`]); unique in it.
605    pub name: String,
606    pub kind: ClientKind,
607    pub mode: AttachMode,
608    /// Unix ms.
609    pub attached_at_ms: u64,
610}
611
612impl ClientInfo {
613    /// `discord (#3)`: the name for people, the id for scripts that grep.
614    pub fn label(&self) -> String {
615        format!("{} (#{})", self.name, self.id.0)
616    }
617}
618
619/// A client-side copy of the session's roster, kept current from the
620/// attach snapshot, `ClientJoined` and `ClientLeft` (each transport owns
621/// one).
622#[derive(Clone, Debug, Default, PartialEq, Eq)]
623pub struct Roster(Vec<ClientInfo>);
624
625impl Roster {
626    pub fn from_snapshot(snapshot: &AttachSnapshot) -> Self {
627        let mut r = Self(snapshot.roster.clone());
628        // An older daemon sends no roster: fall back to ids and kinds.
629        if r.0.is_empty() {
630            r.0 = snapshot
631                .clients
632                .iter()
633                .map(|(id, kind)| ClientInfo {
634                    id: *id,
635                    name: kind.default_name().to_string(),
636                    kind: *kind,
637                    mode: AttachMode::Mirror,
638                    attached_at_ms: 0,
639                })
640                .collect();
641        }
642        r
643    }
644
645    pub fn apply(&mut self, event: &SessionEventWire) {
646        match event {
647            SessionEventWire::ClientJoined { client, kind, name, mode } => {
648                self.0.retain(|c| c.id != *client);
649                self.0.push(ClientInfo {
650                    id: *client,
651                    name: if name.is_empty() { kind.default_name().to_string() } else { name.clone() },
652                    kind: *kind,
653                    mode: *mode,
654                    // The event carries no time: it just happened.
655                    attached_at_ms: chrono::Utc::now().timestamp_millis().max(0) as u64,
656                });
657                self.0.sort_by_key(|c| c.id.0);
658            }
659            SessionEventWire::ClientLeft { client } => self.0.retain(|c| c.id != *client),
660            _ => {}
661        }
662    }
663
664    pub fn clients(&self) -> &[ClientInfo] {
665        &self.0
666    }
667
668    pub fn get(&self, id: ClientId) -> Option<&ClientInfo> {
669        self.0.iter().find(|c| c.id == id)
670    }
671
672    /// `discord (#3)`, or `client #3` for an id not in the roster.
673    pub fn label(&self, id: ClientId) -> String {
674        self.get(id).map(ClientInfo::label).unwrap_or_else(|| format!("client #{}", id.0))
675    }
676}
677
678/// Opt-in stream signals (see [`ClientMeta::capabilities`]). The daemon's
679/// event enums nested under `Stream` (`WireLlmEvent`, ...) have no
680/// `#[serde(other)]`: an older client fails the whole frame on an unknown
681/// variant — and a failed frame reads as a dead socket (the TUI drops into
682/// reconnect, whose `Attached` replay would carry the same variant). So
683/// every new nested variant is sent only to a client that declares it here.
684#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
685pub struct ClientCapabilities {
686    /// `LlmEvent::ToolQueued` / `ToolStarted` (scheduler lane waits).
687    #[serde(default)]
688    pub tool_queue: bool,
689    /// Knows `client_input = shared`: `OwnerChangeReason::Input` and
690    /// `Attached.client_input`. Without it, a shared session is presented
691    /// in owner terms (`wire::legacy_shared`).
692    #[serde(default)]
693    pub owner_reason_input: bool,
694    /// Knows `EndReason::OwnerGone`. Without it, an observer of an owned
695    /// session is told `Ended{ClientQuit}` (`wire::ended_for`).
696    #[serde(default)]
697    pub owner_gone: bool,
698}
699
700impl ClientCapabilities {
701    /// What this build decodes.
702    pub fn current() -> Self {
703        Self { tool_queue: true, owner_reason_input: true, owner_gone: true }
704    }
705
706    fn is_none(&self) -> bool {
707        *self == Self::default()
708    }
709}
710
711/// Default for `ClientMeta.tail_items`: matches the TUI's resumed-display cap.
712pub const DEFAULT_TAIL_ITEMS: usize = 120;
713
714fn default_tail_items() -> usize {
715    DEFAULT_TAIL_ITEMS
716}
717
718/// `SYNAPS_ATTACH_TAIL_ITEMS` (client side): display items requested in
719/// `Attached.display_tail`; `DEFAULT_TAIL_ITEMS` when unset/invalid.
720pub fn tail_items_from_env() -> usize {
721    std::env::var("SYNAPS_ATTACH_TAIL_ITEMS")
722        .ok()
723        .and_then(|v| v.trim().parse::<usize>().ok())
724        .unwrap_or(DEFAULT_TAIL_ITEMS)
725}
726
727impl ClientMeta {
728    /// Fresh meta with a random instance id and no terminal.
729    pub fn new(kind: ClientKind) -> Self {
730        Self {
731            kind,
732            terminal: None,
733            instance: uuid::Uuid::new_v4().to_string(),
734            history: HistoryMode::default(),
735            tail_items: tail_items_from_env(),
736            capabilities: ClientCapabilities::current(),
737            name: None,
738        }
739    }
740
741    /// With a requested name (see [`ClientMeta::name`]).
742    pub fn named(mut self, name: Option<String>) -> Self {
743        self.name = name;
744        self
745    }
746}
747
748/// What a client wants in `Attached` / `Conversation`: the full
749/// `api_messages` mirror (`Full`, the 741b6b60 behaviour) or only the
750/// digest + a daemon-side display tail (`Digest`, phase 4).
751#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
752#[serde(rename_all = "snake_case")]
753pub enum HistoryMode {
754    #[default]
755    Full,
756    Digest,
757}
758
759impl HistoryMode {
760    /// `SYNAPS_CLIENT_HISTORY=full|digest`; `default` wins when unset or
761    /// unrecognised.
762    pub fn from_env_or(default: Self) -> Self {
763        match std::env::var("SYNAPS_CLIENT_HISTORY").ok().as_deref().map(str::trim) {
764            Some(v) if v.eq_ignore_ascii_case("full") => Self::Full,
765            Some(v) if v.eq_ignore_ascii_case("digest") => Self::Digest,
766            _ => default,
767        }
768    }
769
770    /// What `synaps --attach` asks for (phase 4 B7: `Digest`).
771    /// `SYNAPS_CLIENT_HISTORY=full` restores the 741b6b60 mirror wholesale.
772    pub fn attach_client_default() -> Self {
773        Self::Digest
774    }
775}
776
777#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
778#[serde(rename_all = "snake_case")]
779pub enum ClientKind {
780    Tui,
781    Chat,
782    Attach,
783    Rpc,
784    Server,
785    Test,
786}
787
788impl ClientKind {
789    /// The name a client of this kind gets when it asks for none.
790    pub fn default_name(self) -> &'static str {
791        match self {
792            Self::Tui => "tui",
793            Self::Chat => "chat",
794            Self::Attach => "attach",
795            Self::Rpc => "rpc",
796            Self::Server => "server",
797            Self::Test => "test",
798        }
799    }
800}
801
802/// Attach mode. Ownership (B1): `Mirror` owns input iff nobody does,
803/// `Takeover` steals it, `Observe` never owns it.
804#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
805#[serde(rename_all = "snake_case")]
806pub enum AttachMode {
807    Mirror,
808    Takeover,
809    Observe,
810}
811
812/// Actor-side client accounting (§8): `Detached` never cancels a stream.
813#[derive(Clone, Copy, Debug, PartialEq, Eq)]
814pub enum AttachState {
815    Attached(usize),
816    Detached { running: bool },
817    /// Save + PTY teardown in progress (B3).
818    Parking,
819    /// `runtime`/`conv` dropped; `background` alive; unpark on Attach/wake (B3).
820    Parked,
821}
822
823// ── prompts ───────────────────────────────────────────────────────────────────
824
825#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
826#[serde(rename_all = "snake_case")]
827pub enum PromptKind {
828    Secret,
829    Confirm,
830}
831
832/// Fixed title `resolve_before_tool_call_result` uses for Confirm prompts
833/// (runtime/mod.rs).
834pub const CONFIRM_PROMPT_TITLE: &str = "Confirm tool call";
835/// Fixed title `confirm_activation_with_host` uses for the `activate_tools`
836/// y/yes gate (tools/discovery.rs). Everything not in the confirm set is
837/// `Secret` (masked input).
838pub const CONFIRM_ACTIVATION_PROMPT_TITLE: &str = "Confirm tool activation";
839
840impl PromptKind {
841    /// Derive the kind from the prompt title (the actor classifies at receipt;
842    /// `SecretPromptHandle::prompt` classifies for the in-process TUI).
843    pub fn from_title(title: &str) -> Self {
844        if title == CONFIRM_PROMPT_TITLE || title == CONFIRM_ACTIVATION_PROMPT_TITLE {
845            Self::Confirm
846        } else {
847            Self::Secret
848        }
849    }
850}
851
852/// Who raised an extension tool-call Confirm and what "Allow all" would
853/// cover. Built host-side from the hook chain (`HookBus::emit` stamps the
854/// extension ids; an extension cannot name itself or another extension), so
855/// the dialog can show the exact scope BEFORE the user answers.
856#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
857pub struct ConfirmScope {
858    /// Extension ids whose `Confirm` this prompt resolves; "Allow all"
859    /// latches exactly these.
860    pub extensions: Vec<String>,
861    /// Tool the call targets (display only; the latch is per extension).
862    #[serde(default, skip_serializing_if = "Option::is_none")]
863    pub tool: Option<String>,
864    /// Extensions whose `Modify` rewrote the input this prompt approves
865    /// (empty = the model's own input). Lets the session record, for every
866    /// client's transcript, that an approved call ran rewritten input.
867    #[serde(default, skip_serializing_if = "Vec::is_empty")]
868    pub rewritten_by: Vec<String>,
869}
870
871impl ConfirmScope {
872    /// `a` or `a, b`.
873    pub fn extensions_label(&self) -> String {
874        self.extensions.join(", ")
875    }
876
877    /// Text of the "Allow all" button (without the key hint).
878    pub fn allow_all_label(&self) -> String {
879        format!("Allow all from {} this session", self.extensions_label())
880    }
881
882    /// The exact reach of "Allow all", shown before the user answers.
883    pub fn allow_all_scope(&self) -> String {
884        format!(
885            "Allow all: later confirms from {} (any tool) are approved without asking until \
886             /new or /confirms reset. Other extensions still ask.",
887            self.extensions_label()
888        )
889    }
890}
891
892/// Make prompt text safe to print in any client (prA I-R4-3, I-R5): drop
893/// C0/C1 control characters except `\n` (a `\t` becomes a space); the
894/// zero-width space and the LRM/RLM marks (U+200B, U+200E, U+200F); bidi
895/// embeddings/overrides/isolates (U+202A–U+202E, U+2066–U+2069) and the
896/// Arabic letter mark (U+061C); and the line/paragraph separators (U+2028,
897/// U+2029), which some terminals treat as line breaks. ZWNJ / ZWJ
898/// (U+200C, U+200D) are KEPT: emoji sequences and several scripts need
899/// them. Prevents an extension's text from moving the cursor (`\x1b[3A`,
900/// `\r`) over the host-built header, or disguising text with direction
901/// overrides.
902pub fn sanitize_prompt_text(text: &str) -> String {
903    text.chars()
904        .filter_map(|c| match c {
905            '\n' => Some('\n'),
906            '\t' => Some(' '),
907            c if c.is_control() => None,
908            '\u{200B}' | '\u{200E}' | '\u{200F}' | '\u{061C}' | '\u{2028}' | '\u{2029}' => None,
909            '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' => None,
910            c => Some(c),
911        })
912        .collect()
913}
914
915#[derive(Clone, Debug, Serialize, Deserialize)]
916pub struct PromptRequest {
917    /// Per-session monotonic; actor dedups answers on it.
918    pub id: u64,
919    pub kind: PromptKind,
920    pub title: String,
921    pub prompt: String,
922    pub raised_at: chrono::DateTime<chrono::Utc>,
923    /// Extension tool-call confirms only: the source extension(s) and tool.
924    /// Absent → "Allow all" is not offered (it could not be scoped).
925    #[serde(default, skip_serializing_if = "Option::is_none")]
926    pub confirm_scope: Option<ConfirmScope>,
927}
928
929// ── commands, settings, queries ───────────────────────────────────────────────
930
931/// A command with its origin. `from: None` = host-originated (router,
932/// lifecycle, idle probe) and bypasses input ownership (B1).
933#[derive(Debug)]
934pub struct Addressed {
935    pub from: Option<ClientId>,
936    pub cmd: SessionCommand,
937}
938
939/// Why a `Checkpoint` was requested.
940#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
941#[serde(rename_all = "snake_case")]
942pub enum CheckpointReason {
943    Reload,
944    HostRequest,
945}
946
947/// The entire client→session control surface (SEAMS §5.2/§5.3).
948///
949/// `Debug` is manual: `Submit`/`Steer`/`SubmitPrepared`/`Answer`/`Resume`
950/// print `<redacted>` (no lengths) so a command can be traced by *type*.
951#[derive(Serialize, Deserialize)]
952#[serde(tag = "cmd", rename_all = "snake_case")]
953pub enum SessionCommand {
954    /// User-authored prompt. Actor: reset auto-turn counter, push user msg
955    /// verbatim, start turn.
956    Submit {
957        text: String,
958        /// Pre-built canonical user content blocks (images, documents)
959        /// produced by `PendingAttachments::build_content` on the client.
960        /// Empty ⇔ text-only (backward compatible).
961        #[serde(default)]
962        attachments: Vec<serde_json::Value>,
963    },
964    /// Text typed while streaming. Actor: steer if a steer_tx is live else
965    /// queue; ALWAYS also sets queued_message.
966    Steer { text: String },
967    /// Steer one of this session's running subagents (`sa_N`) from its watch
968    /// view. Owner-only like `Steer`. The note also enters the MAIN
969    /// conversation as an event (no auto-turn), so the main agent knows the
970    /// user redirected its worker. Failure → `Refused`.
971    SubagentSteer { handle_id: String, text: String },
972    /// Esc. Cancel, adopt the turn's partial history + append the
973    /// interruption marker (`engine::interrupt`), dequeue, flush pending
974    /// events, cancel subagents, save.
975    Cancel,
976    /// Answer to a PromptRequest. `None` = cancelled.
977    /// NEVER journaled, NEVER replayed, NEVER traced.
978    Answer { prompt_id: u64, value: Option<String> },
979    /// `id` is client-chosen and echoed in `SettingChanged.id`.
980    Set { id: u64, setting: SessionSetting },
981    Compact { instructions: Option<String> },
982    /// `/new`: ConversationState::clear.
983    NewSession,
984    Save,
985    /// Reply = `SessionEventWire::QueryResult { id, value }`.
986    Query { id: u64, query: SessionQuery },
987    /// Reply = `Attached { client_id, … }` on THIS client's channel only.
988    Attach {
989        client: ClientMeta,
990        mode: AttachMode,
991        /// Host-set (never wire): the connection claims to own the session
992        /// (an owned create, or a re-bind after a reload).
993        #[serde(skip)]
994        owner: Option<OwnerBind>,
995    },
996    Detach { client: ClientId },
997    /// Host shutdown of this session: save → on_session_end → leases → Ended.
998    End { reason: EndReason },
999    /// Resync after Lagged (B, day 3): actor re-sends history + turn_replay.
1000    Resync { client: ClientId, since_seq: u64 },
1001    /// Runtime-backed slash command (`engine::commands::handle_engine_command`:
1002    /// /model /thinking /context /trace /compact …). Reply =
1003    /// `QueryResult { id, value: {"kind": .., "text": ..} }`.
1004    EngineCommand { id: u64, name: String, arg: String },
1005    /// (A3) dispatch.rs LoadSkill — pre-built tool_use/tool_result pair
1006    /// (+ optional user text) then a turn. Does NOT reset
1007    /// consecutive_auto_turns.
1008    SubmitPrepared {
1009        messages: Vec<crate::SharedMessage>,
1010        #[serde(default)]
1011        user_text: Option<String>,
1012    },
1013    /// (A3) tools-backed (non-interactive) plugin command on THE runtime's
1014    /// tool set. Reply = `QueryResult{id, {kind:"plugin_output", ..}}`.
1015    PluginCommand { id: u64, plugin: String, name: String, arg: String },
1016    /// (A3) `/resume`: save current, load `query`, restore model/reasoning/
1017    /// system prompt, swap conversation. Reply = `Resumed{id, ..}`.
1018    Resume { id: u64, query: String },
1019    /// (B1, used by C3 reload) cancel any turn (partial history + marker),
1020    /// abort compaction, save, close PTYs, emit Notice. Never ends the
1021    /// session. Reply = `QueryResult{id: CHECKPOINT_QUERY_ID, {ok:true}}`.
1022    Checkpoint { reason: CheckpointReason },
1023    /// (B3) pin/unpin (`/pin on|off`, `--keep-warm`, `/keep-warm on|off`,
1024    /// `synaps daemon pin`). Published on `SessionHandle::pinned()`.
1025    KeepWarm { on: bool },
1026    /// (B1) `/takeover`: the sending client takes input ownership in place,
1027    /// like an `--takeover` attach without re-attaching. Not an input
1028    /// command (a read-only client sends it); refused from an `Observe`
1029    /// client, a no-op from the owner. Emits `InputOwnerChanged{Takeover}`
1030    /// (`Attach` when nobody owned input).
1031    TakeInput,
1032    /// Host-only (never wire): park now if `can_park()` (reload rehydrates
1033    /// a Parked session Parked). A no-op otherwise.
1034    #[serde(skip)]
1035    Park,
1036    /// Host-only (never wire): reload's rehydrate hands the new image the
1037    /// workers the old image stopped or still owed a wake
1038    /// (`SessionReloadRecord::workers`). The actor re-registers their ids
1039    /// against their archives and wakes the parent once per worker.
1040    #[serde(skip)]
1041    AdoptWorkers { workers: Vec<crate::runtime::subagent::CarriedWorker> },
1042    /// Host-only (never wire): the daemon accepts connections again after a
1043    /// reload; a restored owned session that still waits for its owner
1044    /// starts the re-bind grace now (a no-op for any other session).
1045    #[serde(skip)]
1046    ArmOwnerRebind,
1047    /// Host-only (never wire): reload's rehydrate hands the new image the
1048    /// exact tool activations the session held in the previous image
1049    /// (`SessionReloadRecord::tool_activations`). The runtime re-validates
1050    /// and restores them at its first turn start; a parked session keeps
1051    /// them for its unpark. Sent before anything can start a turn.
1052    #[serde(skip)]
1053    RestoreToolActivations {
1054        activations: Vec<crate::tools::activation::CarriedActivation>,
1055    },
1056    /// Host-only (never wire): reload's rehydrate, last, for a session that
1057    /// comes back Live. The old image's park timer did not survive the exec
1058    /// and only an attach or detach arms a new one, so a session no client
1059    /// reconnects to stayed Live with zero clients forever. Arms the park
1060    /// grace as a detach does; a client reconnecting within it clears it.
1061    /// A no-op on a parked session.
1062    #[serde(skip)]
1063    ArmPark,
1064    /// Host-only (never wire): the reload this session checkpointed for
1065    /// did not exec (the exec failed, or the reload stopped after the
1066    /// checkpoints). The checkpoint's reload fence lifts, and the workers
1067    /// the reload stopped are announced to this same image.
1068    #[serde(skip)]
1069    ReloadAborted,
1070    /// Host→session (never wire): the actor re-emits as `SessionEventWire`.
1071    #[serde(skip)]
1072    HostEvent(HostEvent),
1073    /// (E-P0) Start or re-arm the session driver for `plugin`.
1074    /// `command` is the interactive command name (e.g. "auto");
1075    /// `arg` is everything after the command ("start -- do X").
1076    /// No `DriverStop` — `Cancel` handles that (§2).
1077    DriverStart {
1078        plugin: String,
1079        command: String,
1080        arg: String,
1081    },
1082}
1083
1084/// What `Checkpoint{Reload}` reports so `daemon reload` can rebuild the
1085/// session as it IS (not as it was created): the actor's config, keep-warm
1086/// pin, lifecycle, the non-persisted knobs (`settings_replay`, incl.
1087/// `/system`), the CURRENT model/thinking, and its tool activations.
1088#[derive(Clone, Debug, Serialize, Deserialize)]
1089pub struct SessionReloadRecord {
1090    pub config: SessionConfig,
1091    pub keep_warm: bool,
1092    pub lifecycle: SessionLifecycle,
1093    #[serde(default)]
1094    pub settings_replay: Vec<SessionSetting>,
1095    /// Per-session `/keepwarm` override (`off`/`auto`/`<N>h`), if any.
1096    #[serde(default)]
1097    pub cache_keepwarm: Option<String>,
1098    pub model: String,
1099    pub thinking_level: String,
1100    /// Workers a reload stopped (or that still owed a completion wake), so
1101    /// the next image can re-register their ids and wake the parent.
1102    #[serde(default)]
1103    pub workers: Vec<crate::runtime::subagent::CarriedWorker>,
1104    /// The session's exact tool activations (`activate_tools` grants), so
1105    /// the next image's runtime keeps the tools array the cached prompt
1106    /// prefix starts with. Pins only, re-validated on restore.
1107    #[serde(default)]
1108    pub tool_activations: Vec<crate::tools::activation::CarriedActivation>,
1109}
1110
1111/// What a session tells a client whose command a daemon reload refused.
1112pub const RELOADING_REFUSAL: &str = "daemon reloading; retry in a moment";
1113
1114impl SessionCommand {
1115    /// Refused while a daemon reload has checkpointed the session (its
1116    /// reload fence is up) until the exec, or until the daemon reports the
1117    /// exec failed: anything that starts a turn (a `Steer` with no turn
1118    /// running is a `Submit`), changes the conversation or journal the
1119    /// record points at, launches or steers work, or changes a setting the
1120    /// record already carries. Cancel, answers, queries, saves and
1121    /// attach/detach stay open.
1122    pub fn refused_while_reloading(&self) -> bool {
1123        matches!(
1124            self,
1125            Self::Submit { .. }
1126                | Self::SubmitPrepared { .. }
1127                | Self::Steer { .. }
1128                | Self::SubagentSteer { .. }
1129                | Self::Compact { .. }
1130                | Self::NewSession
1131                | Self::Resume { .. }
1132                | Self::EngineCommand { .. }
1133                | Self::PluginCommand { .. }
1134                | Self::DriverStart { .. }
1135                | Self::Set { .. }
1136                | Self::KeepWarm { .. }
1137        )
1138    }
1139}
1140
1141impl std::fmt::Debug for SessionCommand {
1142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1143        match self {
1144            Self::Submit { attachments, .. } => {
1145                let kinds: Vec<&str> = attachments
1146                    .iter()
1147                    .filter_map(|b| b.get("type").and_then(|v| v.as_str()))
1148                    .collect();
1149                f.debug_struct("Submit")
1150                    .field("text", &format_args!("<redacted>"))
1151                    .field("attachments", &format_args!("{}:{:?}", attachments.len(), kinds))
1152                    .finish()
1153            }
1154            Self::Steer { .. } => f
1155                .debug_struct("Steer")
1156                .field("text", &format_args!("<redacted>"))
1157                .finish(),
1158            Self::SubagentSteer { handle_id, .. } => f
1159                .debug_struct("SubagentSteer")
1160                .field("handle_id", handle_id)
1161                .field("text", &format_args!("<redacted>"))
1162                .finish(),
1163            Self::SubmitPrepared { .. } => f
1164                .debug_struct("SubmitPrepared")
1165                .field("messages", &format_args!("<redacted>"))
1166                .field("user_text", &format_args!("<redacted>"))
1167                .finish(),
1168            Self::Answer { prompt_id, .. } => f
1169                .debug_struct("Answer")
1170                .field("prompt_id", prompt_id)
1171                .field("value", &format_args!("<redacted>"))
1172                .finish(),
1173            Self::Resume { id, .. } => f
1174                .debug_struct("Resume")
1175                .field("id", id)
1176                .field("query", &format_args!("<redacted>"))
1177                .finish(),
1178            Self::Cancel => f.write_str("Cancel"),
1179            Self::Set { id, setting } => {
1180                f.debug_struct("Set").field("id", id).field("setting", setting).finish()
1181            }
1182            Self::Compact { instructions } => {
1183                f.debug_struct("Compact").field("instructions", instructions).finish()
1184            }
1185            Self::NewSession => f.write_str("NewSession"),
1186            Self::Save => f.write_str("Save"),
1187            Self::TakeInput => f.write_str("TakeInput"),
1188            Self::Park => f.write_str("Park"),
1189            Self::ArmOwnerRebind => f.write_str("ArmOwnerRebind"),
1190            Self::AdoptWorkers { workers } => f
1191                .debug_struct("AdoptWorkers")
1192                .field("workers", &workers.iter().map(|w| w.id.as_str()).collect::<Vec<_>>())
1193                .finish(),
1194            Self::RestoreToolActivations { activations } => f
1195                .debug_struct("RestoreToolActivations")
1196                .field(
1197                    "tools",
1198                    &activations.iter().map(|a| a.tool_id.as_str()).collect::<Vec<_>>(),
1199                )
1200                .finish(),
1201            Self::ReloadAborted => f.write_str("ReloadAborted"),
1202            Self::ArmPark => f.write_str("ArmPark"),
1203            Self::Query { id, query } => {
1204                f.debug_struct("Query").field("id", id).field("query", query).finish()
1205            }
1206            Self::Attach { client, mode, owner } => {
1207                f.debug_struct("Attach")
1208                    .field("client", client)
1209                    .field("mode", mode)
1210                    .field("owner_claim", &owner.is_some())
1211                    .finish()
1212            }
1213            Self::Detach { client } => f.debug_struct("Detach").field("client", client).finish(),
1214            Self::End { reason } => f.debug_struct("End").field("reason", reason).finish(),
1215            Self::Resync { client, since_seq } => f
1216                .debug_struct("Resync")
1217                .field("client", client)
1218                .field("since_seq", since_seq)
1219                .finish(),
1220            Self::EngineCommand { id, name, arg } => f
1221                .debug_struct("EngineCommand")
1222                .field("id", id)
1223                .field("name", name)
1224                .field("arg", arg)
1225                .finish(),
1226            Self::PluginCommand { id, plugin, name, arg } => f
1227                .debug_struct("PluginCommand")
1228                .field("id", id)
1229                .field("plugin", plugin)
1230                .field("name", name)
1231                .field("arg", arg)
1232                .finish(),
1233            Self::Checkpoint { reason } => {
1234                f.debug_struct("Checkpoint").field("reason", reason).finish()
1235            }
1236            Self::KeepWarm { on } => f.debug_struct("KeepWarm").field("on", on).finish(),
1237            Self::HostEvent(ev) => f.debug_tuple("HostEvent").field(ev).finish(),
1238            Self::DriverStart { plugin, command, .. } => f
1239                .debug_struct("DriverStart")
1240                .field("plugin", plugin)
1241                .field("command", command)
1242                .field("arg", &format_args!("<redacted>"))
1243                .finish(),
1244        }
1245    }
1246}
1247
1248/// Host-originated events pushed into a session (C3 router, loader).
1249#[derive(Debug, Clone)]
1250pub enum HostEvent {
1251    ExtensionNotification {
1252        extension_id: String,
1253        method: String,
1254        params: serde_json::Value,
1255    },
1256    LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
1257}
1258
1259/// The `&mut Runtime` setters the TUI/rpc/server call (SEAMS §5.3).
1260#[derive(Debug, Clone, Serialize, Deserialize)]
1261#[serde(tag = "setting", rename_all = "snake_case")]
1262pub enum SessionSetting {
1263    /// try_set_model → SettingApplied::Model{applied, thinking_level}
1264    Model { model: String },
1265    /// set_reasoning_level_checked
1266    ReasoningLevel {
1267        #[serde(with = "reasoning_level_serde")]
1268        level: agent_core::reasoning::ReasoningLevel,
1269    },
1270    /// set_context_window
1271    ContextWindow { tokens: Option<u64> },
1272    CompactionModel { model: Option<String> },
1273    ApiRetries { n: u32 },
1274    SubagentTimeout { secs: u64 },
1275    MaxToolOutput { bytes: usize },
1276    BashTimeout { secs: u64 },
1277    BashMaxTimeout { secs: u64 },
1278    /// set_system_prompt
1279    SystemPrompt { text: String },
1280    /// reload_prompt() → SettingApplied::PromptReloaded{generation, source}
1281    ReloadPrompt,
1282    /// grant_worker_model
1283    GrantWorkerModel { model: String },
1284}
1285
1286#[derive(Debug, Clone, Serialize, Deserialize)]
1287pub struct SettingApplied {
1288    /// Correlates with `Set{id}`.
1289    #[serde(default)]
1290    pub id: u64,
1291    pub setting: String,
1292    pub ok: bool,
1293    pub message: Option<String>,
1294    pub view: RuntimeView,
1295    /// `{from, to}` when a model change clamped the reasoning level, so the
1296    /// TUI renders `reasoning_clamp_notice` byte-for-byte.
1297    #[serde(default)]
1298    pub clamp: Option<ReasoningClampWire>,
1299}
1300
1301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1302pub struct ReasoningClampWire {
1303    pub from: String,
1304    pub to: String,
1305}
1306
1307#[derive(Debug, Clone, Serialize, Deserialize)]
1308#[serde(tag = "query", rename_all = "snake_case")]
1309pub enum SessionQuery {
1310    /// {session, model, tokens, cost, streaming, auto_turns, attached, pending_prompts}
1311    Status,
1312    /// Vec<SharedMessage> (api_messages)
1313    Messages,
1314    /// Vec<SubagentDisplayRow>
1315    SubagentRows,
1316    /// runtime.prompt_inspection_json()
1317    PromptInspection,
1318    /// rpc_dispatch::build_tools_list_body input
1319    ToolsSchema,
1320    /// RuntimeView (cheap; LocalTransport answers without a hop)
1321    View,
1322    /// assess_context
1323    ContextAssessment,
1324    /// `engine::commands::context_command(runtime, Some(api_messages))` text.
1325    ContextReport,
1326    /// `display::DisplayTail` of the last `items` display items (phase 4).
1327    DisplayTail { items: usize },
1328    /// `runtime::subagent_watch::WatchPage`: one of this session's workers,
1329    /// its journal from `since` (the previous page's `next_seq`; 0 = all).
1330    /// Unknown handle → `{"error": ..}`. `tool_queue`: include
1331    /// `WatchEvent::ToolQueued`/`ToolStarted` entries (an older client
1332    /// omits it and would fail to decode a page that had them; an older
1333    /// daemon ignores it and never records them).
1334    SubagentWatch {
1335        handle_id: String,
1336        since: u64,
1337        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1338        tool_queue: bool,
1339    },
1340}
1341
1342#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1343#[serde(rename_all = "snake_case")]
1344pub enum EndReason {
1345    ClientQuit,
1346    HostShutdown,
1347    Evicted,
1348    Error,
1349    /// F18: zero turns, zero clients, park grace elapsed. There is nothing
1350    /// to journal, so the session cannot park; it ends instead of holding a
1351    /// Runtime resident forever.
1352    Idle,
1353    /// An owned session's owner went away (detach, EOF, socket error, its
1354    /// process killed) or did not re-bind within the grace after a daemon
1355    /// reload. Any turn was cancelled as `Cancel` does; the journal stays.
1356    OwnerGone,
1357}
1358
1359// ── events + envelope ─────────────────────────────────────────────────────────
1360
1361/// What a session tells its clients. `Stream` carries the un-serialised
1362/// StreamEvent in-process (LocalTransport) — that IS the byte-identical
1363/// contract. The socket boundary converts to `wire::WireSessionEvent`.
1364#[derive(Debug, Clone)]
1365#[allow(clippy::large_enum_variant)] // Attached carries the full snapshot by design (§2.4)
1366pub enum SessionEventWire {
1367    Stream(crate::StreamEvent),
1368    /// Actor bookkeeping the client needs to mirror `App` fields exactly.
1369    /// `user_text` = the prompt that started the turn: the queued text on
1370    /// `QueuedAuto` (every client pushes the User card + scroll), the
1371    /// submitted text on `User` (clients OTHER than the submitter push the
1372    /// card; the submitter drew it at submit); `None` otherwise. The attach
1373    /// replay ring stores it as `None` (the snapshot's history has it).
1374    TurnStarted {
1375        turn_baseline: usize,
1376        trigger: TurnTrigger,
1377        user_text: Option<String>,
1378    },
1379    /// Emitted after every MessageHistory / Done / Error / Cancel / Compact so
1380    /// mirrors never diverge: the actor's api_messages IS the truth.
1381    Conversation(ConversationSnapshot),
1382    Prompt(PromptRequest),
1383    /// So mirrors dismiss the modal.
1384    PromptResolved { prompt_id: u64 },
1385    /// Drained event card — presentation only.
1386    External(crate::events::types::Event),
1387    AutoTurnCapReached { cap: u32 },
1388    /// The turn machine is idle: a stream ended (Done/Error/Cancel) and NO
1389    /// auto-turn followed. Headless hosts gate stdin on this so a queued
1390    /// auto-turn is never raced by the next line.
1391    Idle,
1392    /// "→ steering:" vs "queued:".
1393    Steered { text: String, delivered: bool },
1394    Dequeued { text: String },
1395    /// Any ChatMessage::System the actor would have pushed.
1396    SystemNotice(String),
1397    LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
1398    ExtensionNotification {
1399        extension_id: String,
1400        method: String,
1401        params: serde_json::Value,
1402    },
1403    SettingChanged(SettingApplied),
1404    QueryResult { id: u64, value: serde_json::Value },
1405    /// Only to the attaching client.
1406    Attached {
1407        client: ClientId,
1408        snapshot: AttachSnapshot,
1409    },
1410    /// `name`: resolved by the session (unique in it).
1411    ClientJoined { client: ClientId, kind: ClientKind, name: String, mode: AttachMode },
1412    ClientLeft { client: ClientId },
1413    Ended { reason: EndReason },
1414    /// Cancel landed. TUI: drop_empty_thinking, push Error(abort_msg),
1415    /// subagents.clear(), streaming=false. `context_saved` (name kept for
1416    /// protocol v3): the interrupted turn's partial work is kept in history.
1417    Aborted { context_saved: bool },
1418    /// `/clear`. TUI: transcript.clear, counters=0, "new session started".
1419    Cleared { session_id: String },
1420    /// (B2) `disclosure` = preview_compaction_disclosure().render_line().
1421    CompactionStarted { source: String, disclosure: String },
1422    CompactionApplied {
1423        previous_session_id: String,
1424        session_id: String,
1425        chains_advanced: Vec<String>,
1426        queued_restored: Option<String>,
1427        msg_count: usize,
1428    },
1429    /// "compaction failed: {e}" vs "compaction task panicked: {e}".
1430    CompactionFailed { message: String, panicked: bool },
1431    CompactionCancelled,
1432    /// `runtime.subagent_registry().display_rows()` — at Done/Error and at
1433    /// 1 Hz while any row is non-terminal.
1434    SubagentRows(Vec<crate::runtime::subagent::SubagentDisplayRow>),
1435    /// `/resume` reply (`Resume{id}`).
1436    Resumed {
1437        id: u64,
1438        old_id: String,
1439        new_id: String,
1440        via: Option<String>,
1441        clamp_notice: Option<String>,
1442    },
1443    /// (B1) sent to the previous owner on takeover, and to everyone on any
1444    /// owner change.
1445    InputOwnerChanged {
1446        from: Option<ClientId>,
1447        to: Option<ClientId>,
1448        reason: OwnerChangeReason,
1449    },
1450    /// (B1) a non-owner sent an input command. Only the sender renders it.
1451    Refused { client: ClientId, command: String, reason: String },
1452    /// (B3) targeted like `Attached`: the attach could not be honoured.
1453    AttachRefused { message: String },
1454    /// (B3) lifecycle transitions for mirrors and `sessions` listings.
1455    Lifecycle(SessionLifecycle),
1456    /// (C3) daemon is about to exec itself; clients reconnect.
1457    Reloading { generation: u64, retry_after_ms: u64 },
1458    /// (E-P0) Driver armed on this session.
1459    DriverArmed {
1460        plugin_id: String,
1461        run_id: String,
1462        models: Vec<crate::extensions::session_driver::Selection>,
1463        selection: crate::extensions::session_driver::Selection,
1464        /// Remaining ms, not absolute Instant.
1465        deadline_ms: Option<u64>,
1466        notice: String,
1467    },
1468    /// (E-P0) Driver revoked.
1469    DriverRevoked {
1470        reason: String,
1471        undelivered_steering: Vec<String>,
1472    },
1473    /// (E-P0) One driver turn completed.
1474    DriverTurnOutcome {
1475        outcome: crate::extensions::session_driver::Outcome,
1476        selection: crate::extensions::session_driver::Selection,
1477        feedback: Option<String>,
1478    },
1479    /// (E-P7, §S3) A spend ceiling was breached: the turn was cancelled and any
1480    /// armed driver revoked. `scope` is "session" (config cap) or "run" (the
1481    /// driver grant's per-run cap). Additive v3 driver event — old clients skip
1482    /// it via `#[serde(other)]`.
1483    CostCapReached {
1484        scope: String,
1485        cost: f64,
1486        cap: f64,
1487    },
1488}
1489
1490#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1491#[serde(rename_all = "snake_case")]
1492pub enum OwnerChangeReason {
1493    Attach,
1494    Takeover,
1495    OwnerDetached,
1496    Reload,
1497    /// `client_input = shared`: this client sent input, so it is now the
1498    /// active one. Sent only to clients declaring
1499    /// `ClientCapabilities::owner_reason_input`: an older one never sees an
1500    /// owner change on a shared session (`wire::legacy_shared`).
1501    Input,
1502}
1503
1504#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1505#[serde(rename_all = "snake_case")]
1506pub enum TurnTrigger {
1507    User,
1508    QueuedAuto,
1509    EventAuto,
1510    PluginCommand,
1511    Compaction,
1512    /// (E-P0) Turn initiated by the session driver.
1513    DriverAuto,
1514}
1515
1516/// Per-session, gapless `seq` assigned at the single emit site
1517/// (`SessionActor::emit`); `ts` from the host clock.
1518#[derive(Debug, Clone)]
1519pub struct Envelope {
1520    pub session_id: SessionId,
1521    pub seq: u64,
1522    pub ts: chrono::DateTime<chrono::Utc>,
1523    pub event: SessionEventWire,
1524}
1525
1526#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1527pub struct ConversationTokens {
1528    pub input: u64,
1529    pub output: u64,
1530    pub cache_read: u64,
1531    pub cache_creation: u64,
1532}
1533
1534/// Memo of `wire::messages_hash(api_messages)` shared by every clone of one
1535/// snapshot: the daemon converts a `Conversation` to its wire digest once
1536/// per socket client, and hashing serialises the whole history — now once
1537/// per event, not once per client. Keyed on the history's length and last
1538/// message identity (histories only grow), so a snapshot whose messages are
1539/// replaced afterwards recomputes instead of reusing a stale hash.
1540#[derive(Clone, Default)]
1541pub struct MessagesHashMemo(pub(crate) std::sync::Arc<std::sync::Mutex<Option<HashedHistory>>>);
1542
1543/// `(len, last message, hash)` of the history a memo was computed for.
1544type HashedHistory = (usize, Option<crate::SharedMessage>, u64);
1545
1546impl MessagesHashMemo {
1547    pub fn hash(&self, messages: &[crate::SharedMessage]) -> u64 {
1548        let mut memo = self
1549            .0
1550            .lock()
1551            .unwrap_or_else(std::sync::PoisonError::into_inner);
1552        if let Some((len, last, hash)) = memo.as_ref() {
1553            let same_last = match (last, messages.last()) {
1554                (Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
1555                (None, None) => true,
1556                _ => false,
1557            };
1558            if *len == messages.len() && same_last {
1559                return *hash;
1560            }
1561        }
1562        let hash = super::wire::messages_hash(messages);
1563        *memo = Some((messages.len(), messages.last().cloned(), hash));
1564        hash
1565    }
1566}
1567
1568impl std::fmt::Debug for MessagesHashMemo {
1569    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1570        f.write_str("MessagesHashMemo")
1571    }
1572}
1573
1574/// Serializable mirror of `ConversationState` (+ the actor's auto-turn
1575/// counter). Clients MUST replace, never merge, on `Conversation(_)`.
1576#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1577pub struct ConversationSnapshot {
1578    /// The TUI's `App.session` mirror (never `api_messages`).
1579    #[serde(default)]
1580    pub header: SessionHeader,
1581    pub api_messages: Vec<crate::SharedMessage>,
1582    /// `api_messages.len()` on the daemon — always filled by the actor;
1583    /// `api_messages` itself may be empty for `HistoryMode::Digest` clients.
1584    #[serde(default)]
1585    pub messages_len: usize,
1586    pub tokens: ConversationTokens,
1587    pub cost: f64,
1588    /// Prompt-cache keep-warm spend, already included in `cost` (task #444).
1589    #[serde(default)]
1590    pub keepwarm_cost: f64,
1591    /// Keep-warm ping tokens, already included in `tokens` (task #444).
1592    #[serde(default)]
1593    pub keepwarm_tokens: ConversationTokens,
1594    /// Legacy (protocol v3 shape kept): always `None` — an interrupted turn
1595    /// is recorded in `api_messages` (`engine::interrupt`), never as a recap.
1596    pub abort_context: Option<String>,
1597    pub queued_message: Option<String>,
1598    pub pending_events_len: usize,
1599    pub consecutive_auto_turns: u32,
1600    /// Never serialised; see `MessagesHashMemo`.
1601    #[serde(skip)]
1602    pub messages_hash_memo: MessagesHashMemo,
1603}
1604
1605/// `Session` minus `api_messages` and accounting: what a client mirrors.
1606#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1607pub struct SessionHeader {
1608    pub id: String,
1609    pub title: String,
1610    pub name: Option<String>,
1611    pub model: String,
1612    pub thinking_level: String,
1613    pub system_prompt: Option<String>,
1614    pub created_at: chrono::DateTime<chrono::Utc>,
1615    pub updated_at: chrono::DateTime<chrono::Utc>,
1616    pub parent_session: Option<String>,
1617}
1618
1619impl From<&crate::Session> for SessionHeader {
1620    fn from(s: &crate::Session) -> Self {
1621        Self {
1622            id: s.id.clone(),
1623            title: s.title.clone(),
1624            name: s.name.clone(),
1625            model: s.model.clone(),
1626            thinking_level: s.thinking_level.clone(),
1627            system_prompt: s.system_prompt.clone(),
1628            created_at: s.created_at,
1629            updated_at: s.updated_at,
1630            parent_session: s.parent_session.clone(),
1631        }
1632    }
1633}
1634
1635#[derive(Debug, Clone)]
1636pub struct AttachSnapshot {
1637    pub meta: SessionMeta,
1638    pub view: RuntimeView,
1639    pub conversation: ConversationSnapshot,
1640    pub streaming: bool,
1641    /// Current-turn events since TurnStarted (bounded ring) so a mid-turn
1642    /// attach can rebuild partial text/tool cards.
1643    pub replay: Vec<Envelope>,
1644    pub pending_prompts: Vec<PromptRequest>,
1645    pub clients: Vec<(ClientId, ClientKind)>,
1646    /// Every attached client with its resolved name (`clients` stays for
1647    /// older clients). Empty from an older daemon.
1648    pub roster: Vec<ClientInfo>,
1649    /// Who may drive ([`ClientInput`]); `Exclusive` from an older daemon.
1650    pub client_input: ClientInput,
1651    /// Who owns input right now (B1) — so a joiner knows immediately
1652    /// whether its keystrokes will be honoured.
1653    pub input_owner: Option<ClientId>,
1654    /// Daemon-projected display tail — `Some` iff the client attached with
1655    /// `HistoryMode::Digest` (`conversation.api_messages` is then empty).
1656    pub display_tail: Option<crate::session::display::DisplayTail>,
1657}
1658
1659impl AttachSnapshot {
1660    /// For the client this snapshot was made for (`me`): who owns input, if
1661    /// someone else does — "input is owned by tui (#1); attach with
1662    /// --takeover to steal it". Rendered by each client from its OWN
1663    /// snapshot; the actor used to broadcast it as a `SystemNotice`, which
1664    /// every attached client (the owner included) and later attach replays
1665    /// showed.
1666    /// `None` with `client_input = shared`: every drive client may type.
1667    pub fn input_owned_elsewhere(&self, me: ClientId) -> Option<String> {
1668        if self.client_input == ClientInput::Shared {
1669            return None;
1670        }
1671        let owner = self.input_owner.filter(|o| *o != me)?;
1672        let who = Roster::from_snapshot(self).label(owner);
1673        Some(format!("input is owned by {who}; attach with --takeover to steal it"))
1674    }
1675}
1676
1677/// `ReasoningLevel` has no serde impls in agent-core; go through its
1678/// canonical string form.
1679pub mod reasoning_level_serde {
1680    use agent_core::reasoning::ReasoningLevel;
1681    use serde::{Deserialize, Deserializer, Serialize, Serializer};
1682
1683    pub fn serialize<S: Serializer>(level: &ReasoningLevel, s: S) -> Result<S::Ok, S::Error> {
1684        level.as_str().serialize(s)
1685    }
1686
1687    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ReasoningLevel, D::Error> {
1688        let s = String::deserialize(d)?;
1689        ReasoningLevel::parse(&s)
1690            .ok_or_else(|| serde::de::Error::custom(format!("unknown reasoning level `{s}`")))
1691    }
1692}
1693
1694#[cfg(test)]
1695mod tests {
1696    use super::*;
1697
1698    #[test]
1699    fn client_names_are_short_plain_labels() {
1700        for ok in ["tui", "discord", "Dash_2", "a", "9lives", &"x".repeat(CLIENT_NAME_MAX)] {
1701            assert_eq!(validate_client_name(ok), Ok(()), "{ok}");
1702        }
1703        for bad in ["", "-lead", "_lead", "has space", "dots.no", "émoji", &"x".repeat(CLIENT_NAME_MAX + 1)] {
1704            let err = validate_client_name(bad).unwrap_err();
1705            assert!(err.starts_with("invalid client name"), "{bad}: {err}");
1706        }
1707    }
1708
1709    #[test]
1710    fn a_session_resolves_names_to_unique_ones() {
1711        // Requested, else the kind's name; an invalid request falls back.
1712        assert_eq!(resolve_client_name(Some("discord"), ClientKind::Rpc, []), "discord");
1713        assert_eq!(resolve_client_name(None, ClientKind::Tui, []), "tui");
1714        assert_eq!(resolve_client_name(Some("no spaces"), ClientKind::Attach, []), "attach");
1715        // Taken (case-insensitively): lowest free suffix from 2.
1716        assert_eq!(resolve_client_name(None, ClientKind::Tui, ["tui"]), "tui-2");
1717        assert_eq!(resolve_client_name(Some("TUI"), ClientKind::Tui, ["tui", "tui-2"]), "TUI-3");
1718        assert_eq!(resolve_client_name(None, ClientKind::Tui, ["tui", "tui-3"]), "tui-2");
1719        // A freed name is reused.
1720        assert_eq!(resolve_client_name(None, ClientKind::Tui, ["tui-2"]), "tui");
1721    }
1722
1723    #[test]
1724    fn a_roster_follows_joins_and_leaves_and_labels_by_name() {
1725        let joined = |id: u64, name: &str, mode: AttachMode| SessionEventWire::ClientJoined {
1726            client: ClientId(id),
1727            kind: ClientKind::Attach,
1728            name: name.to_string(),
1729            mode,
1730        };
1731        let mut r = Roster::default();
1732        r.apply(&joined(2, "dash", AttachMode::Observe));
1733        r.apply(&joined(1, "terminal", AttachMode::Mirror));
1734        // An older daemon's join has no name: the kind's is used.
1735        r.apply(&joined(3, "", AttachMode::Mirror));
1736        let ids: Vec<u64> = r.clients().iter().map(|c| c.id.0).collect();
1737        assert_eq!(ids, [1, 2, 3], "kept in id order");
1738        assert_eq!(r.get(ClientId(2)).unwrap().mode, AttachMode::Observe);
1739        assert_eq!(r.label(ClientId(1)), "terminal (#1)");
1740        assert_eq!(r.label(ClientId(3)), "attach (#3)");
1741        r.apply(&SessionEventWire::ClientLeft { client: ClientId(1) });
1742        assert_eq!(r.label(ClientId(1)), "client #1", "gone: id only");
1743        assert_eq!(r.clients().len(), 2);
1744    }
1745
1746    /// prA I-R5 minor: line/paragraph separators and the Arabic letter mark
1747    /// are stripped with the other format characters; ZWNJ/ZWJ are kept so
1748    /// emoji sequences and scripts that need them render intact.
1749    #[test]
1750    fn sanitize_prompt_text_strips_separators_keeps_zwj_zwnj() {
1751        let input = "a\u{2028}b\u{2029}c\u{061C}d\u{200B}e\u{200E}f\u{200F}g\u{202E}h\u{2067}i\x1bj\tk\nl";
1752        assert_eq!(sanitize_prompt_text(input), "abcdefghij k\nl");
1753        let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
1754        assert_eq!(sanitize_prompt_text(family), family, "ZWJ emoji intact");
1755        let persian = "\u{0645}\u{06CC}\u{200C}\u{062E}\u{0648}\u{0627}\u{0647}\u{0645}";
1756        assert_eq!(sanitize_prompt_text(persian), persian, "ZWNJ intact");
1757    }
1758
1759    #[test]
1760    fn prompt_kind_from_title() {
1761        assert_eq!(PromptKind::from_title("Confirm tool call"), PromptKind::Confirm);
1762        assert_eq!(
1763            PromptKind::from_title("Confirm tool activation"),
1764            PromptKind::Confirm
1765        );
1766        assert_eq!(PromptKind::from_title("API key"), PromptKind::Secret);
1767    }
1768
1769    #[test]
1770    fn session_config_default_persists() {
1771        let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1772        assert!(cfg.persist);
1773        assert!(cfg.cwd.is_none());
1774        assert_eq!(
1775            serde_json::to_value(SessionConfig::default()).unwrap()["persist"],
1776            serde_json::Value::Bool(true)
1777        );
1778    }
1779
1780    #[test]
1781    fn session_config_env_defaults_to_none() {
1782        let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1783        assert!(cfg.env.is_none());
1784    }
1785
1786    #[test]
1787    fn session_config_env_round_trips() {
1788        let env = vec![("FOO".into(), "bar".into()), ("PATH".into(), "/usr/bin".into())];
1789        let cfg = SessionConfig { env: Some(env.clone()), ..Default::default() };
1790        let json = serde_json::to_string(&cfg).unwrap();
1791        let cfg2: SessionConfig = serde_json::from_str(&json).unwrap();
1792        assert_eq!(cfg2.env, Some(env));
1793    }
1794
1795    #[test]
1796    fn should_strip_env_client_prefixes() {
1797        assert!(should_strip_env("SYNAPS_CLIENT_REEXEC"));
1798        assert!(should_strip_env("SYNAPS_TUI_SCROLLBACK"));
1799        assert!(should_strip_env("SYNAPS_DAEMON_ADOPT"));
1800        assert!(should_strip_env("SYNAPS_MEM_TRACE"));
1801        assert!(should_strip_env("SYNAPS_MEM_TRACE_FOO"));
1802        // Session env — not stripped
1803        assert!(!should_strip_env("SYNAPS_ANTHROPIC_BASE_URL"));
1804        assert!(!should_strip_env("HOME"));
1805        assert!(!should_strip_env("PATH"));
1806        assert!(!should_strip_env("VIRTUAL_ENV"));
1807    }
1808
1809    #[test]
1810    fn should_strip_env_secrets() {
1811        assert!(should_strip_env("ANTHROPIC_API_KEY"));
1812        assert!(should_strip_env("OPENAI_API_KEY"));
1813        assert!(should_strip_env("my_secret_token"));
1814        assert!(should_strip_env("AWS_SECRET_ACCESS_KEY"));
1815        assert!(should_strip_env("DB_PASSWORD"));
1816        assert!(should_strip_env("GCP_CREDENTIALS"));
1817        // Case insensitive
1818        assert!(should_strip_env("My_Api_Key"));
1819        // Not a secret
1820        assert!(!should_strip_env("MY_VALUE"));
1821        assert!(!should_strip_env("TOKENBUCKET_SIZE"));
1822        // suffix-anchored: the secret name must END the key
1823        assert!(!should_strip_env("API_TOKEN_TTL_SECS"));
1824        assert!(!should_strip_env("KEYBOARD_LAYOUT"));
1825        assert!(!should_strip_env("SSH_KEY_PATH"));
1826        assert!(should_strip_env("GH_TOKEN"));
1827        assert!(should_strip_env("SSH_KEY"));
1828        assert!(should_strip_env("SECRET_KEY_BASE"));
1829        assert!(should_strip_env("MY_PASSWD"));
1830    }
1831
1832    #[test]
1833    fn strip_client_env_drops_credential_values_whatever_the_key() {
1834        let vars = vec![
1835            ("HOME".to_string(), "/home/u".to_string()),
1836            (
1837                "DATABASE_URL".to_string(),
1838                "postgres://app:hunter2@db/prod".to_string(),
1839            ),
1840            (
1841                "HTTPS_PROXY".to_string(),
1842                "http://user:pass@proxy:3128".to_string(),
1843            ),
1844            (
1845                "SENTRY_DSN".to_string(),
1846                "https://abc123@o1.ingest.sentry.io/42".to_string(),
1847            ),
1848            ("GITHUB_PAT".to_string(), "ghp_x".to_string()),
1849            (
1850                "API_BASE_URL".to_string(),
1851                "https://api.example.com/v1".to_string(),
1852            ),
1853        ];
1854        let (env, stripped) = strip_client_env(vars);
1855        let kept: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
1856        assert_eq!(
1857            kept,
1858            vec!["API_BASE_URL", "HOME"],
1859            "only non-secrets survive"
1860        );
1861        assert!(env.iter().all(|(_, v)| !v.contains("hunter2")
1862            && !v.contains("user:pass")
1863            && !v.contains("abc123")));
1864        assert_eq!(
1865            stripped,
1866            vec!["DATABASE_URL", "GITHUB_PAT", "HTTPS_PROXY", "SENTRY_DSN"],
1867            "stripped names are recorded"
1868        );
1869    }
1870
1871    /// `forward_secrets` sends exactly what `Hello.env` strips as secrets
1872    /// (names AND credential-shaped values), never the client-only knobs.
1873    #[test]
1874    fn client_secret_env_is_the_stripped_secrets_with_values() {
1875        let vars = vec![
1876            ("HOME".to_string(), "/home/u".to_string()),
1877            ("GH_TOKEN".to_string(), "ghp_dummy".to_string()),
1878            ("AWS_SECRET_ACCESS_KEY".to_string(), "aws-dummy".to_string()),
1879            ("DATABASE_URL".to_string(), "postgres://app:hunter2@db/prod".to_string()),
1880            ("SYNAPS_CLIENT_TOKEN".to_string(), "client-only".to_string()),
1881        ];
1882        let (_, stripped) = strip_client_env(vars.clone());
1883        let secrets = client_secret_env(vars);
1884        let names: Vec<&str> = secrets.0.iter().map(|(k, _)| k.as_str()).collect();
1885        assert_eq!(names, vec!["AWS_SECRET_ACCESS_KEY", "DATABASE_URL", "GH_TOKEN"]);
1886        // Every stripped secret, but not the client-only knob (stripped too).
1887        assert!(names.iter().all(|n| stripped.iter().any(|s| s == n)), "{stripped:?}");
1888        assert!(stripped.iter().any(|s| s == "SYNAPS_CLIENT_TOKEN"), "{stripped:?}");
1889        assert!(secrets.0.contains(&("GH_TOKEN".to_string(), "ghp_dummy".to_string())));
1890        // Debug never prints a value; the token never prints at all.
1891        let dbg = format!("{secrets:?} {:?}", OwnerClaim { token: OwnerToken("tok-123".into()), forward_secrets: Some(secrets.clone()) });
1892        assert!(!dbg.contains("ghp_dummy") && !dbg.contains("hunter2") && !dbg.contains("tok-123"), "{dbg}");
1893        assert!(dbg.contains("GH_TOKEN"), "{dbg}");
1894    }
1895
1896    /// Forwarded secrets stay apart in a `ToolEnv`: a child gets them (in
1897    /// place of a same-named session var), a `Debug` never prints them.
1898    #[test]
1899    fn tool_env_merges_secrets_at_spawn_and_never_debugs_them() {
1900        let vars = vec![("GH_TOKEN".to_string(), "session-old".to_string()), ("PATH".into(), "/bin".into())];
1901        let env = ToolEnv::with_secrets(vars, SecretEnv(vec![("GH_TOKEN".into(), "s3cr3t-value".into())]));
1902        let spawn: Vec<(&str, &str)> = env.spawn_vars().collect();
1903        assert_eq!(spawn, [("PATH", "/bin"), ("GH_TOKEN", "s3cr3t-value")]);
1904        assert_eq!(env.get("GH_TOKEN"), Some("s3cr3t-value"));
1905        let dbg = format!("{env:?}");
1906        assert!(!dbg.contains("s3cr3t-value") && dbg.contains("GH_TOKEN"), "{dbg}");
1907        let plain: ToolEnv = vec![("PATH".to_string(), "/bin".to_string())].into();
1908        assert_eq!(plain.spawn_vars().collect::<Vec<_>>(), [("PATH", "/bin")]);
1909    }
1910
1911    #[test]
1912    fn owner_tokens_compare_whole() {
1913        let t = OwnerToken("abcdef".into());
1914        assert!(t.matches(&OwnerToken("abcdef".into())));
1915        assert!(!t.matches(&OwnerToken("abcdeg".into())));
1916        assert!(!t.matches(&OwnerToken("abcde".into())));
1917        assert!(!t.matches(&OwnerToken(String::new())));
1918    }
1919
1920    /// `owned` is daemon-set and never rides the wire or the reload
1921    /// record's config.
1922    #[test]
1923    fn session_config_owned_never_serializes() {
1924        let cfg = SessionConfig {
1925            owned: Some(OwnedConfig { token: OwnerToken::mint(), restored: false }),
1926            ..Default::default()
1927        };
1928        let v = serde_json::to_value(&cfg).unwrap();
1929        assert!(v.get("owned").is_none(), "{v}");
1930        let back: SessionConfig = serde_json::from_value(v).unwrap();
1931        assert!(back.owned.is_none());
1932    }
1933
1934    #[test]
1935    fn capture_client_env_is_sorted_and_stripped() {
1936        // We can't fully control the process env in a unit test, but we can
1937        // verify the output is sorted and that known strip-list vars are absent.
1938        let (env, stripped) = capture_client_env();
1939        // Every stripped name is a secret by our own rule, and none of them
1940        // survived into the kept env.
1941        for name in &stripped {
1942            let by_value = std::env::var(name)
1943                .map(|v| agent_core::core::config::is_secret_env_value(&v))
1944                .unwrap_or(false);
1945            assert!(
1946                is_secret_key(name) || by_value,
1947                "stripped non-secret: {name}"
1948            );
1949            assert!(
1950                env.iter().all(|(k, _)| k != name),
1951                "stripped name leaked: {name}"
1952            );
1953        }
1954        // Sorted
1955        for w in env.windows(2) {
1956            assert!(w[0].0 <= w[1].0, "not sorted: {:?} > {:?}", w[0].0, w[1].0);
1957        }
1958        // No secrets or client-only vars
1959        for (k, v) in &env {
1960            assert!(!should_strip_env(k), "should have been stripped: {k}");
1961            assert!(
1962                !agent_core::core::config::is_secret_env_value(v),
1963                "credential value kept: {k}"
1964            );
1965        }
1966    }
1967
1968    #[test]
1969    fn command_json_round_trip() {
1970        let cmd = SessionCommand::Set {
1971            id: 7,
1972            setting: SessionSetting::ReasoningLevel {
1973                level: agent_core::reasoning::ReasoningLevel::Ultra,
1974            },
1975        };
1976        let json = serde_json::to_string(&cmd).unwrap();
1977        assert_eq!(
1978            json,
1979            r#"{"cmd":"set","id":7,"setting":{"setting":"reasoning_level","level":"ultra"}}"#
1980        );
1981        let back: SessionCommand = serde_json::from_str(&json).unwrap();
1982        assert!(matches!(
1983            back,
1984            SessionCommand::Set {
1985                id: 7,
1986                setting: SessionSetting::ReasoningLevel {
1987                    level: agent_core::reasoning::ReasoningLevel::Ultra
1988                }
1989            }
1990        ));
1991
1992        let submit: SessionCommand = serde_json::from_str(r#"{"cmd":"submit","text":"hi"}"#).unwrap();
1993        match submit {
1994            SessionCommand::Submit { text, attachments } => {
1995                assert_eq!(text, "hi");
1996                assert!(attachments.is_empty());
1997            }
1998            other => panic!("unexpected {other:?}"),
1999        }
2000
2001        // Submit with content blocks round-trips.
2002        let blocks = vec![
2003            serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBOR"}}),
2004            serde_json::json!({"type":"text","text":"hello"}),
2005        ];
2006        let submit_with = SessionCommand::Submit {
2007            text: "describe".into(),
2008            attachments: blocks.clone(),
2009        };
2010        let json = serde_json::to_string(&submit_with).unwrap();
2011        let back: SessionCommand = serde_json::from_str(&json).unwrap();
2012        match back {
2013            SessionCommand::Submit { text, attachments } => {
2014                assert_eq!(text, "describe");
2015                assert_eq!(attachments.len(), 2);
2016                assert_eq!(attachments[0]["type"], "image");
2017                assert_eq!(attachments[1]["type"], "text");
2018            }
2019            other => panic!("unexpected {other:?}"),
2020        }
2021    }
2022
2023    #[test]
2024    fn new_commands_round_trip() {
2025        let cmds = vec![
2026            SessionCommand::SubmitPrepared {
2027                messages: vec![std::sync::Arc::new(serde_json::json!({"role":"user","content":"x"}))],
2028                user_text: Some("t".into()),
2029            },
2030            SessionCommand::PluginCommand { id: 1, plugin: "p".into(), name: "n".into(), arg: "a".into() },
2031            SessionCommand::Resume { id: 2, query: "q".into() },
2032            SessionCommand::Checkpoint { reason: CheckpointReason::Reload },
2033            SessionCommand::KeepWarm { on: true },
2034            SessionCommand::Query { id: 3, query: SessionQuery::ContextReport },
2035            SessionCommand::Query {
2036                id: 4,
2037                query: SessionQuery::SubagentWatch { handle_id: "sa_2".into(), since: 7, tool_queue: true },
2038            },
2039            SessionCommand::SubagentSteer { handle_id: "sa_2".into(), text: "focus on X".into() },
2040            SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: "start -- hi".into() },
2041            SessionCommand::TakeInput,
2042        ];
2043        for cmd in cmds {
2044            let json = serde_json::to_string(&cmd).unwrap();
2045            let back: SessionCommand = serde_json::from_str(&json).unwrap();
2046            assert_eq!(serde_json::to_string(&back).unwrap(), json);
2047        }
2048        let take: SessionCommand = serde_json::from_str(r#"{"cmd":"take_input"}"#).unwrap();
2049        assert!(matches!(take, SessionCommand::TakeInput));
2050        let cp: SessionCommand = serde_json::from_str(r#"{"cmd":"checkpoint","reason":"host_request"}"#).unwrap();
2051        assert!(matches!(cp, SessionCommand::Checkpoint { reason: CheckpointReason::HostRequest }));
2052    }
2053
2054    #[test]
2055    fn command_debug_redacts_bodies_without_lengths() {
2056        let secret = "hunter2-very-secret";
2057        let cmds = vec![
2058            SessionCommand::Submit { text: secret.into(), attachments: vec![] },
2059            SessionCommand::Steer { text: secret.into() },
2060            SessionCommand::SubagentSteer { handle_id: "sa_1".into(), text: secret.into() },
2061            SessionCommand::SubmitPrepared {
2062                messages: vec![std::sync::Arc::new(serde_json::json!({"content": secret}))],
2063                user_text: Some(secret.into()),
2064            },
2065            SessionCommand::Answer { prompt_id: 1, value: Some(secret.into()) },
2066            SessionCommand::Resume { id: 1, query: secret.into() },
2067            SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: secret.into() },
2068        ];
2069        for cmd in cmds {
2070            let d = format!("{cmd:?}");
2071            assert!(!d.contains(secret), "{d}");
2072            assert!(d.contains("<redacted>"), "{d}");
2073            assert!(!d.contains("chars"), "{d}");
2074            assert!(!d.contains(&secret.len().to_string()), "{d}");
2075        }
2076        let d = format!("{:?}", SessionCommand::Set { id: 4, setting: SessionSetting::ReloadPrompt });
2077        assert_eq!(d, "Set { id: 4, setting: ReloadPrompt }");
2078
2079        // Submit debug shows count+kinds, never base64.
2080        let b64_secret = "aHVudGVyMi12ZXJ5LXNlY3JldA==";
2081        let cmd = SessionCommand::Submit {
2082            text: "x".into(),
2083            attachments: vec![
2084                serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":b64_secret}}),
2085                serde_json::json!({"type":"document","title":"x.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBER"}}),
2086            ],
2087        };
2088        let d = format!("{cmd:?}");
2089        assert!(d.contains(r#"2:["image", "document"]"#), "{d}");
2090        assert!(!d.contains(b64_secret), "base64 leaked: {d}");
2091    }
2092
2093    #[test]
2094    fn session_config_new_fields_default() {
2095        let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
2096        assert_eq!(cfg.compaction_policy, CompactionPolicyWire::InPlace);
2097        assert!(cfg.await_extensions);
2098        assert!(!cfg.keep_warm);
2099        let cfg: SessionConfig =
2100            serde_json::from_str(r#"{"compaction_policy":"linked_successor","await_extensions":false}"#).unwrap();
2101        assert_eq!(cfg.compaction_policy, CompactionPolicyWire::LinkedSuccessor);
2102        assert!(!cfg.await_extensions);
2103    }
2104
2105    #[test]
2106    fn session_lifecycle_u8_round_trip() {
2107        for l in [SessionLifecycle::Live, SessionLifecycle::Parking, SessionLifecycle::Parked, SessionLifecycle::Ending] {
2108            assert_eq!(SessionLifecycle::from_u8(l as u8), l);
2109        }
2110        assert_eq!(serde_json::to_string(&SessionLifecycle::Parked).unwrap(), r#""parked""#);
2111    }
2112
2113    #[test]
2114    fn host_event_is_never_serialised() {
2115        let cmd = SessionCommand::HostEvent(HostEvent::LoaderProgress(
2116            crate::extensions::loader::ExtensionLoaderEvent::Started,
2117        ));
2118        assert!(serde_json::to_string(&cmd).is_err());
2119    }
2120}
2121
2122/// `Option<Option<String>>` on the wire without collapsing `Some(None)`:
2123/// absent → `None`; `{"latest": true}` → `Some(None)`; `"id"` → `Some(Some(id))`.
2124/// Older daemons/clients that sent a bare string or `null` still decode.
2125mod double_option {
2126    use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
2127
2128    #[derive(Serialize, Deserialize)]
2129    #[serde(untagged)]
2130    enum Wire {
2131        Latest { latest: bool },
2132        Id(String),
2133    }
2134
2135    pub fn serialize<S: Serializer>(v: &Option<Option<String>>, s: S) -> Result<S::Ok, S::Error> {
2136        match v {
2137            None => s.serialize_none(),
2138            Some(None) => Wire::Latest { latest: true }.serialize(s),
2139            Some(Some(id)) => Wire::Id(id.clone()).serialize(s),
2140        }
2141    }
2142
2143    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<String>>, D::Error> {
2144        match Option::<Wire>::deserialize(d)? {
2145            None => Ok(None),
2146            Some(Wire::Latest { latest: true }) => Ok(Some(None)),
2147            Some(Wire::Latest { latest: false }) => Err(de::Error::custom("continue_session: latest must be true")),
2148            Some(Wire::Id(id)) => Ok(Some(Some(id))),
2149        }
2150    }
2151
2152    #[cfg(test)]
2153    mod tests {
2154        use super::super::SessionConfig;
2155
2156        fn rt(v: Option<Option<String>>) -> Option<Option<String>> {
2157            let cfg = SessionConfig { continue_session: v, ..Default::default() };
2158            let j = serde_json::to_string(&cfg).unwrap();
2159            serde_json::from_str::<SessionConfig>(&j).unwrap().continue_session
2160        }
2161
2162        #[test]
2163        fn continue_session_round_trips_all_three_states() {
2164            assert_eq!(rt(None), None);
2165            assert_eq!(rt(Some(None)), Some(None), "bare --continue must survive the wire");
2166            assert_eq!(rt(Some(Some("abc".into()))), Some(Some("abc".into())));
2167        }
2168
2169        #[test]
2170        fn continue_session_accepts_legacy_encodings() {
2171            let old_null: SessionConfig = serde_json::from_str(r#"{"continue_session":null}"#).unwrap();
2172            assert_eq!(old_null.continue_session, None);
2173            let old_id: SessionConfig = serde_json::from_str(r#"{"continue_session":"xyz"}"#).unwrap();
2174            assert_eq!(old_id.continue_session, Some(Some("xyz".into())));
2175            let absent: SessionConfig = serde_json::from_str(r#"{}"#).unwrap();
2176            assert_eq!(absent.continue_session, None);
2177        }
2178    }
2179}
2180
2181/// Where a daemon session writes its pin (`daemon::pins::PinBook`). The
2182/// actor calls it exactly when the pin changes, when a pinned session moves
2183/// to a new journal or is renamed, and when a pinned session is ended on
2184/// purpose. Implementations do their own I/O and never fail the caller.
2185pub trait PinSink: Send + Sync {
2186    /// The session is pinned: add or refresh its entry (`journal_id` is
2187    /// current; `id` is the actor id, which may name an older journal).
2188    /// `cache_keepwarm`: the session's `/keepwarm` override (`off`/`auto`/`<N>h`).
2189    fn pinned(&self, id: &str, journal_id: &str, name: Option<&str>, cwd: Option<&std::path::Path>, cache_keepwarm: Option<&str>);
2190    /// Unpinned, or ended on purpose while pinned: drop its entry.
2191    fn unpinned(&self, id: &str, journal_id: &str);
2192    /// A pinned session's journal changed (`/clear`, a successor compaction,
2193    /// resume): the entry follows it.
2194    fn moved(&self, from: &str, to: &str);
2195    /// A pinned session was renamed (`/saveas`, `daemon rename`): its entry
2196    /// takes `name` verbatim, so a cleared name stops resolving.
2197    fn renamed(&self, journal_id: &str, name: Option<&str>);
2198}