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