Skip to main content

agent_tui/tui/
attach.rs

1//! `synaps --attach [ID] [--observe|--takeover] [--keep-warm]
2//! [--no-confirm-answers]` — the TUI
3//! over `SocketTransport` (PLAN-phase3 A4). Client diet: no `EngineHost`,
4//! no `Runtime`, no `ExtensionManager`, no MCP, no skills index — config,
5//! builtin commands, keybinds, the render thread and the event stream only.
6//! Plugin commands / interactive plugin commands are not available over the
7//! socket yet (`Query{Commands}` is phase 4).
8
9use std::path::PathBuf;
10use std::sync::Arc;
11
12use agent_engine::session::socket_transport::SocketTransport;
13use agent_engine::session::wire::{Attach, Hello};
14use agent_engine::session::{
15    AttachMode, ClientKind, ClientTransport, CompactionPolicyWire, EndReason, HistoryMode,
16    SessionCommand, SessionConfig, SessionId, TransportError,
17};
18use synaps_cli::skills::registry::CommandRegistry;
19use synaps_cli::skills::BUILTIN_COMMANDS;
20use synaps_cli::Result;
21
22use agent_core::core::memstat::ladder;
23
24use super::app::ChatMessage;
25use super::run_setup::{app_from_snapshot, finish_setup, LazyHttp, TransportMode};
26
27/// What to attach to.
28pub struct AttachOpts {
29    pub profile: Option<String>,
30    /// Session id (prefix ok); `None` = the only live session, else create.
31    pub id: Option<String>,
32    /// Create by continuing a saved session (name or id).
33    /// `--continue [NAME_OR_ID]`: `Some(None)` = most recent journal (bare
34    /// `--continue`), `Some(Some(x))` = that one. Must stay two-level: the
35    /// attach path used to flatten it, which turned a bare `--continue`
36    /// into a fresh session under adopt / `--new` (soak F21).
37    pub continue_session: Option<Option<String>>,
38    pub system: Option<String>,
39    pub prompt_manifest: Option<PathBuf>,
40    pub mode: AttachMode,
41    /// `--no-confirm-answers`: never answer confirm prompts (tool-call and
42    /// tool-activation approvals) from this TUI, whatever keys reach it;
43    /// they are only listed as waiting for another client. Secret prompts
44    /// are still typed here.
45    pub no_confirm_answers: bool,
46    pub keep_warm: bool,
47    /// `--new`/`--create`: always create a fresh session even if one is
48    /// live. Rejected together with an explicit `id`.
49    pub new_session: bool,
50    /// `--name`: name the created session (`synaps send --session <name>`
51    /// resolves it at once).
52    pub name: Option<String>,
53    /// `--as`: what this client is called in the session; `None` =
54    /// `SYNAPS_CLIENT_NAME`, else the daemon's default (`tui`, `tui-2`, …).
55    pub client_name: Option<String>,
56    /// A plain `synaps` adopted a running daemon (no explicit `--attach`):
57    /// after attaching, show what else lives in this daemon so parked
58    /// work stays discoverable (F1).
59    pub adopted: bool,
60}
61
62/// Toast id for the routine attach notes ([`attach_notices`]).
63const ATTACH_TOAST_ID: &str = "attach";
64
65/// What [`attach_notices`] needs to know about the attach that just happened.
66struct AttachNoticeInputs<'a> {
67    session_id: &'a SessionId,
68    client_id: u64,
69    mode: AttachMode,
70    /// `Some(notice)` when another client owns input, so this one is read-only.
71    owned_elsewhere: Option<String>,
72    /// `Some(continued)` when a plain `synaps` adopted the daemon.
73    adopted: Option<bool>,
74    /// The daemon's other sessions ([`adopt_banner`]), adopt only.
75    others_banner: Option<String>,
76    config_warnings: &'a [String],
77    /// `Some(notice)` when this client's binary differs from the daemon's
78    /// ([`version_skew_notice`](agent_engine::session::transport::version_skew_notice)).
79    version_skew: Option<String>,
80}
81
82/// Attach notes, split by whether they need the user.
83#[derive(Debug, Default, PartialEq, Eq)]
84struct AttachNotices {
85    /// Stays in the transcript: the other-sessions list, config warnings,
86    /// and the attach line when input is owned elsewhere (this client is
87    /// read-only until it takes over).
88    transcript: Vec<String>,
89    /// Routine notes ("adopted the running daemon", "attached to … as
90    /// client #N") for a short toast. Kept out of the transcript so a fresh
91    /// session starts empty and shows the boot logo, like in-process.
92    toast: Vec<String>,
93}
94
95fn attach_notices(i: AttachNoticeInputs<'_>) -> AttachNotices {
96    let mut out = AttachNotices::default();
97    if let Some(continued) = i.adopted {
98        let how = if continued { "continued session" } else { "fresh session" };
99        out.toast.push(format!("adopted the running daemon ({how})"));
100        out.toast.push("SYNAPS_DAEMON_ADOPT=0 for in-process".to_string());
101        out.transcript.extend(i.others_banner);
102    }
103    for w in i.config_warnings {
104        out.transcript.push(format!("⚠ config: {w}"));
105    }
106    if let Some(skew) = i.version_skew {
107        out.transcript.push(format!("⚠ {skew}"));
108    }
109    let attached = format!("attached to {} as client #{} ({:?})", i.session_id, i.client_id, i.mode);
110    match i.owned_elsewhere {
111        // Read-only until it takes over: `/takeover` does it from here
112        // (never offered to an observer, whose `/takeover` is refused).
113        Some(owned) if i.mode != AttachMode::Observe => {
114            out.transcript.push(format!("{attached} — {owned}, or /takeover here"))
115        }
116        Some(owned) => out.transcript.push(format!("{attached} — {owned}")),
117        None => out.toast.insert(0, attached),
118    }
119    out
120}
121
122/// One-line inventory of the daemon's other sessions, shown when a plain
123/// `synaps` adopts the daemon (F1): the lid-close user must be able to
124/// *see* their parked task, without being surprise-attached to it.
125/// `None` when nothing else is there. Rows read like `/sessions` rows (same
126/// label, same state clock — `state_since`, not `created_at` — and `~`
127/// paths), and the line points there.
128pub fn adopt_banner(
129    sessions: &[agent_engine::session::SessionMeta],
130    ours: &SessionId,
131    now: chrono::DateTime<chrono::Utc>,
132) -> Option<String> {
133    let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
134    adopt_banner_with_home(sessions, ours, now, home.as_deref())
135}
136
137/// [`adopt_banner`] with the home directory given: `~` paths without
138/// reading the environment (Windows does not set `HOME`).
139fn adopt_banner_with_home(
140    sessions: &[agent_engine::session::SessionMeta],
141    ours: &SessionId,
142    now: chrono::DateTime<chrono::Utc>,
143    home: Option<&std::path::Path>,
144) -> Option<String> {
145    use agent_engine::session::SessionLifecycle as L;
146    let mut others: Vec<&agent_engine::session::SessionMeta> =
147        sessions.iter().filter(|m| &m.id != ours).collect();
148    if others.is_empty() {
149        return None;
150    }
151    others.sort_by_key(|m| std::cmp::Reverse(m.created_at));
152    let n = others.len();
153    let parked = others.iter().filter(|m| matches!(m.lifecycle, L::Parked)).count();
154    let live = n - parked;
155    let counts: Vec<String> = [(live, "live"), (parked, "parked")]
156        .into_iter()
157        .filter(|(k, _)| *k > 0)
158        .map(|(k, what)| format!("{k} {what}"))
159        .collect();
160    let mut line = format!(
161        "{n} other session{} in this daemon ({}) \u{2014} /sessions to switch:",
162        if n == 1 { "" } else { "s" },
163        counts.join(", ")
164    );
165    for m in others.iter().take(5) {
166        let attached = if m.clients > 0 { " \u{00b7} attached" } else { "" };
167        let cwd = super::sessions::short_cwd(m.cwd.as_deref(), home);
168        line.push_str(&format!(
169            "\n  {}  {}{attached}  {cwd}",
170            super::sessions::label(m),
171            super::sessions::state_text(m, now)
172        ));
173    }
174    if n > 5 {
175        line.push_str(&format!("\n  \u{2026} and {} more", n - 5));
176    }
177    Some(line)
178}
179
180/// The slice of `Welcome.sessions` the attach decision needs.
181pub struct LiveSession {
182    pub id: SessionId,
183    /// `--name` / `/name`, when set: `--attach <NAME>` resolves it too.
184    pub name: Option<String>,
185    pub model: String,
186    pub clients: usize,
187}
188
189/// `--attach <ARG>`: an exact id, then a session name (`--name`), then an id
190/// prefix. Unknown values pass through as ids so the daemon can still find
191/// sessions the welcome list didn't carry (and answer "unknown session").
192fn resolve_attach_target(
193    sessions: &[LiveSession],
194    arg: &str,
195) -> std::result::Result<SessionId, String> {
196    if let Some(m) = sessions.iter().find(|m| m.id.as_str() == arg) {
197        return Ok(m.id.clone());
198    }
199    let named: Vec<&LiveSession> = sessions
200        .iter()
201        .filter(|m| m.name.as_deref() == Some(arg))
202        .collect();
203    match named.as_slice() {
204        [one] => return Ok(one.id.clone()),
205        [] => {}
206        many => {
207            let mut lines = vec![format!(
208                "several live sessions are named \"{arg}\"; pick one by id:"
209            )];
210            lines.extend(many.iter().map(|m| format!("  {}  model={}", m.id, m.model)));
211            return Err(lines.join("\n"));
212        }
213    }
214    Ok(sessions
215        .iter()
216        .find(|m| m.id.as_str().starts_with(arg))
217        .map(|m| m.id.clone())
218        .unwrap_or_else(|| SessionId::from(arg)))
219}
220
221/// What to send: `Existing` for an explicit or sole live session, `Create`
222/// for `--new`, `--continue`, or no live sessions. Errors are user-facing.
223/// `notice` is a one-line stderr message when a live session was picked
224/// implicitly (no id given).
225pub fn choose_attach(
226    opts: &AttachOpts,
227    sessions: &[LiveSession],
228    cwd: Option<PathBuf>,
229    await_extensions: bool,
230) -> std::result::Result<(Attach, Option<String>), String> {
231    let create = |continue_session: Option<Option<String>>| Attach::Create {
232        config: SessionConfig {
233            continue_session,
234            system: opts.system.clone(),
235            prompt_manifest: opts.prompt_manifest.clone(),
236            cwd: cwd.clone(),
237            env: None,
238            compaction_policy: CompactionPolicyWire::LinkedSuccessor,
239            await_extensions,
240            keep_warm: opts.keep_warm,
241            name: opts.name.clone(),
242            ..Default::default()
243        },
244        mode: opts.mode,
245        owned: None,
246    };
247    if opts.new_session && opts.id.is_some() {
248        return Err("cannot combine --attach <ID> with --new: pick one".to_string());
249    }
250    if let Some(c) = &opts.continue_session {
251        return Ok((create(Some(c.clone())), None));
252    }
253    if opts.new_session {
254        return Ok((create(None), None));
255    }
256    if let Some(id) = &opts.id {
257        let sid = resolve_attach_target(sessions, id)?;
258        return Ok((
259            Attach::Existing {
260                session_id: sid,
261                mode: opts.mode,
262            },
263            None,
264        ));
265    }
266    match sessions {
267        [] => Ok((create(None), None)),
268        [only] => Ok((
269            Attach::Existing {
270                session_id: only.id.clone(),
271                mode: opts.mode,
272            },
273            Some(format!(
274                "attaching to live session {} (--new for a fresh one; `synaps daemon sessions` lists all)",
275                only.id
276            )),
277        )),
278        many => {
279            let mut lines =
280                vec!["several sessions; pick one with --attach <ID or NAME> (or --new for a fresh one):".to_string()];
281            for m in many {
282                let name = m.name.as_deref().map(|n| format!("\"{n}\"  ")).unwrap_or_default();
283                lines.push(format!("  {}  {name}model={}  clients={}", m.id, m.model, m.clients));
284            }
285            Err(lines.join("\n"))
286        }
287    }
288}
289
290/// `SYNAPS_ATTACH_MODE=mirror|observe|takeover` default, overridden by flags.
291pub fn attach_mode(observe: bool, takeover: bool) -> AttachMode {
292    if takeover {
293        return AttachMode::Takeover;
294    }
295    if observe {
296        return AttachMode::Observe;
297    }
298    match std::env::var("SYNAPS_ATTACH_MODE").ok().as_deref() {
299        Some("observe") => AttachMode::Observe,
300        Some("takeover") => AttachMode::Takeover,
301        _ => AttachMode::Mirror,
302    }
303}
304
305/// The version-skew notice for this daemon, once per daemon build: a
306/// reconnect to the same daemon binary (a crash restart, a `daemon reload`
307/// onto the same build) does not post it again (04-tui M8).
308pub(super) fn skew_notice_once(
309    app: &mut super::app::App,
310    daemon: agent_engine::session::transport::BinaryId,
311    session: &str,
312) -> Option<String> {
313    use agent_engine::session::transport::{version_skew_notice, BinaryId};
314    if app.skew_noticed.as_ref() == Some(&daemon) {
315        return None;
316    }
317    let notice = version_skew_notice(&BinaryId::this(), &daemon, session)?;
318    app.skew_noticed = Some(daemon);
319    Some(notice)
320}
321
322/// What a daemon-session TUI prints on stderr after it exits because the
323/// session ended under it (`Ended`): `/close` here or on another client,
324/// `daemon stop`, an eviction. The alternate screen is gone by then, so this
325/// is the only place the user can still read which session it was and how
326/// to bring it back. A session with no history (`has_history` false) has
327/// no journal, so there is nothing to continue; `None` for one the daemon
328/// ended for exactly that reason (`Idle`).
329pub(super) fn session_ended_line(reason: EndReason, session: &str, has_history: bool) -> Option<String> {
330    let what = match reason {
331        EndReason::ClientQuit => "was closed",
332        EndReason::OwnerGone => "ended: the client that owned it disconnected",
333        EndReason::HostShutdown => "ended: the daemon shut down",
334        EndReason::Evicted => "was evicted from the daemon",
335        EndReason::Error => "ended after an error",
336        EndReason::Idle => return None,
337    };
338    Some(if has_history {
339        format!("synaps: session {session} {what}; resume it with: synaps --attach --continue {session}")
340    } else {
341        format!("synaps: session {session} {what} (it never ran a turn, so there is nothing to resume)")
342    })
343}
344
345fn cfg_err(e: impl std::fmt::Display) -> synaps_cli::RuntimeError {
346    synaps_cli::RuntimeError::Config(e.to_string())
347}
348
349/// What `--attach` prints when there is no daemon to attach to: says so,
350/// and how to start one (profile-aware). The `SYNAPS_DAEMON_AUTOSPAWN` hint
351/// only appears when autospawn really is off; otherwise the daemon was there
352/// a moment ago (idle shutdown, crash) and re-running starts a new one.
353pub fn daemon_not_running_message(profile: Option<&str>, detail: Option<&str>) -> String {
354    not_running_message(profile, detail, agent_engine::daemon::autospawn_enabled())
355}
356
357fn not_running_message(profile: Option<&str>, detail: Option<&str>, autospawn: bool) -> String {
358    let start = match profile {
359        Some(p) => format!("synaps --profile {p} daemon --detach"),
360        None => "synaps daemon --detach".to_string(),
361    };
362    let why = detail.map(|d| format!(" ({d})")).unwrap_or_default();
363    if autospawn {
364        return format!(
365            "no daemon is running{why}; it may have just stopped.\n\
366             re-run `synaps --attach` to start a new one, or start it yourself with:  {start}"
367        );
368    }
369    format!(
370        "no daemon is running{why} — nothing to attach to.\n\
371         start one with:  {start}\n\
372         or run in the foreground:  {}\n\
373         then re-run `synaps --attach` (or unset SYNAPS_DAEMON_AUTOSPAWN=0 to auto-start).",
374        start.replace("--detach", "--foreground")
375    )
376}
377
378pub async fn run_attached(mut opts: AttachOpts) -> Result<()> {
379    ladder("attach:enter", &"");
380    let paths = agent_engine::daemon::registry::daemon_paths(opts.profile.as_deref());
381    if !agent_engine::daemon::registry::is_alive(&paths) {
382        return Err(cfg_err(daemon_not_running_message(
383            opts.profile.as_deref(),
384            None,
385        )));
386    }
387    let client_name = opts.client_name.clone().or_else(agent_engine::session::client_name_from_env);
388    if let Some(name) = client_name.as_deref() {
389        agent_engine::session::validate_client_name(name).map_err(cfg_err)?;
390    }
391    let hello = Hello::new(ClientKind::Tui)
392        .with_name(client_name)
393        .with_history(HistoryMode::from_env_or(HistoryMode::attach_client_default()));
394    let conn = match SocketTransport::connect(&paths.sock, hello).await {
395        Ok(c) => c,
396        Err(TransportError::Version { client, daemon }) => {
397            return Err(cfg_err(format!(
398                "protocol version mismatch (client {client}, daemon {daemon}); restart or reload the daemon with this binary"
399            )))
400        }
401        Err(TransportError::Io(e)) => {
402            // Lock held but the socket is gone/refusing: the daemon is dead
403            // or still booting — same advice, with the cause.
404            return Err(cfg_err(daemon_not_running_message(
405                opts.profile.as_deref(),
406                Some(&format!("{}: {e}", paths.sock.display())),
407            )));
408        }
409        Err(e) => return Err(cfg_err(format!("connect: {e}"))),
410    };
411    if let Some(msg) =
412        agent_engine::daemon::profile_mismatch(conn.welcome.profile.as_deref(), opts.profile.as_deref())
413    {
414        return Err(cfg_err(msg));
415    }
416    ladder(
417        "connect",
418        &format_args!("sessions={}", conn.welcome.sessions.len()),
419    );
420
421    let cwd = std::env::current_dir().ok();
422
423    // T4: resolve path-like args (--system, --prompt-manifest) against the
424    // client's cwd before shipping them to the daemon.
425    if let Some(ref cwd_path) = cwd {
426        let mut probe_cfg = SessionConfig {
427            system: opts.system.clone(),
428            prompt_manifest: opts.prompt_manifest.clone(),
429            ..Default::default()
430        };
431        if let Err(e) = agent_engine::session::client_args::resolve_client_session_args(
432            &mut probe_cfg,
433            cwd_path,
434        ) {
435            // Print to stderr before entering raw mode so it survives.
436            eprintln!("synaps: {e}");
437            return Err(cfg_err(e));
438        }
439        opts.system = probe_cfg.system;
440        opts.prompt_manifest = probe_cfg.prompt_manifest;
441    }
442
443    let live: Vec<LiveSession> = conn
444        .welcome
445        .sessions
446        .iter()
447        .map(|m| LiveSession {
448            id: m.id.clone(),
449            name: m.name.clone(),
450            model: m.model.clone(),
451            clients: m.clients,
452        })
453        .collect();
454    let welcome_sessions = conn.welcome.sessions.clone();
455    // Quick Start (default on) => await_extensions=false: the daemon TUI
456    // stops blocking on extension discovery before turn 1 (fast cold start),
457    // matching the in-process TUI. Off => block like the historic daemon path.
458    let await_extensions = !synaps_cli::load_config().startup.quick_start;
459    let (attach, notice) =
460        choose_attach(&opts, &live, cwd, await_extensions).map_err(cfg_err)?;
461    if let Some(n) = notice {
462        // Before the TUI takes the terminal, so it survives on the scrollback.
463        eprintln!("{n}");
464    }
465
466    let (transport, snapshot) = SocketTransport::attach(conn, attach)
467        .await
468        .map_err(|e| cfg_err(format!("attach: {e}")))?;
469    // `frame_bytes` is filled once the transport records its last frame
470    // length (B4 `last_frame_bytes()`); until then the wire size is not known here.
471    ladder(
472        "attached",
473        &format_args!(
474            "frame_bytes=n/a messages_len={} api_messages={} replay={} tail_items={}",
475            snapshot.conversation.messages_len,
476            snapshot.conversation.api_messages.len(),
477            snapshot.replay.len(),
478            agent_engine::session::DEFAULT_TAIL_ITEMS
479        ),
480    );
481    // The decode transient of `Attached` is garbage now — give it back (§4.4).
482    super::client_diet::purge_arenas("purge:attached");
483
484    // ── Client diet: config + builtin commands + keybinds, nothing else ──
485    let config = synaps_cli::load_config();
486    let registry = Arc::new(CommandRegistry::new(BUILTIN_COMMANDS, Vec::new()));
487    let mut keybinds = synaps_cli::skills::keybinds::KeybindRegistry::new();
488    if !config.keybinds.is_empty() {
489        keybinds.register_user(&config.keybinds);
490    }
491    let keybind_registry = Arc::new(std::sync::RwLock::new(keybinds));
492    let system_prompt_path = synaps_cli::config::resolve_read_path("system.md");
493    ladder("config", &"");
494    let http = LazyHttp::new();
495    if std::env::var("SYNAPS_CLIENT_HTTP").is_ok_and(|v| v == "eager") {
496        http.get()?;
497    }
498
499    let mut app = app_from_snapshot(&snapshot);
500    app.observing = transport.mode() == AttachMode::Observe;
501    app.attached_clients = transport.roster().clients().len();
502    let (msgs, bytes) = super::app::scrollback_from_env(&TransportMode::Socket);
503    app.transcript.set_scrollback(msgs, bytes);
504    ladder("app", &"");
505    // Notices queued before boot go first.
506    for n in super::run_setup::take_boot_notices() {
507        app.push_msg(ChatMessage::System(n));
508    }
509    let notices = attach_notices(AttachNoticeInputs {
510        session_id: &snapshot.meta.id,
511        client_id: transport.client_id().0,
512        mode: transport.mode(),
513        owned_elsewhere: snapshot.input_owned_elsewhere(transport.client_id()),
514        adopted: opts.adopted.then_some(opts.continue_session.is_some()),
515        others_banner: if opts.adopted {
516            adopt_banner(&welcome_sessions, &snapshot.meta.id, chrono::Utc::now())
517        } else {
518            None
519        },
520        config_warnings: &config.warnings,
521        version_skew: skew_notice_once(
522            &mut app,
523            agent_engine::session::transport::BinaryId::of(&transport.welcome),
524            snapshot.meta.id.as_str(),
525        ),
526    });
527    for n in notices.transcript {
528        app.push_msg(ChatMessage::System(n));
529    }
530    // Same first-run guidance as the in-process boot: a new user's first
531    // `synaps` lands here (daemon mode is the default), and without it the
532    // first message fails with a token-refresh error.
533    if let Some(n) = super::run_setup::no_credentials_notice() {
534        app.push_msg(ChatMessage::System(n));
535    }
536    if !notices.toast.is_empty() {
537        app.toasts.upsert(
538            super::toast::Toast::new(ATTACH_TOAST_ID, String::new())
539                .titled("daemon")
540                .lines(notices.toast),
541        );
542    }
543    // Mid-turn attach: rebuild the partial turn from the replay ring, then
544    // the actor's pending prompts.
545    let replay = snapshot.replay.clone();
546    let pending = snapshot.pending_prompts.clone();
547    let owns_input = super::session_link::may_drive(&transport);
548    app.streaming = snapshot.streaming;
549
550    let mut ctx = finish_setup(
551        app,
552        Box::new(transport),
553        http,
554        TransportMode::Socket,
555        config,
556        registry,
557        keybind_registry,
558        system_prompt_path,
559        None,
560    )
561    .await?;
562    // No extension host in this process: the loader arm never runs.
563    ctx.app.extension_loader_running = false;
564    // Before any prompt is fed: the replay and the pending prompts below.
565    // An observer's confirm answers are refused by the session (#148 C2),
566    // so it never opens the approval pane either.
567    ctx.prompt_bridge.set_owns_input(owns_input);
568    if opts.no_confirm_answers || opts.mode == AttachMode::Observe {
569        ctx.prompt_bridge
570            .set_confirm_policy(super::session_link::ConfirmPolicy::ViewOnly);
571    }
572    for env in replay {
573        // Presentation only; a render handle is not needed for correctness.
574        let _ = super::stream_handler::handle_session_event_arm(
575            env,
576            &mut ctx.app,
577            &mut ctx.link,
578            &ctx.registry,
579            &ctx.render_handle,
580            &mut ctx.prompt_bridge,
581            None,
582        )
583        .await;
584    }
585    // The replayed partial turn is history: shown whole, not paced in.
586    ctx.app.flush_reveal();
587    for pr in pending {
588        ctx.prompt_bridge.on_prompt(pr);
589    }
590    super::stream_handler::sync_waiting_confirms(&mut ctx.app, &ctx.prompt_bridge);
591    ladder("replay", &"");
592    if opts.keep_warm {
593        let _ = ctx.link.send(SessionCommand::KeepWarm { on: true }).await;
594    }
595    super::run_loop(ctx).await
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    /// The line a TUI leaves on stderr when its daemon session ended under
603    /// it names the session and how to continue it; a session that never
604    /// ran a turn has nothing to continue.
605    #[test]
606    fn an_ended_session_says_how_to_continue_it() {
607        let line = session_ended_line(EndReason::ClientQuit, "20261014-183459-3f61", true).unwrap();
608        assert_eq!(
609            line,
610            "synaps: session 20261014-183459-3f61 was closed; resume it with: \
611             synaps --attach --continue 20261014-183459-3f61"
612        );
613        let stop = session_ended_line(EndReason::HostShutdown, "s1", true).unwrap();
614        assert!(stop.contains("the daemon shut down") && stop.ends_with("--continue s1"), "{stop}");
615        assert!(session_ended_line(EndReason::Evicted, "s1", true).unwrap().contains("evicted"));
616        assert!(session_ended_line(EndReason::Error, "s1", true).unwrap().contains("error"));
617        let empty = session_ended_line(EndReason::ClientQuit, "s1", false).unwrap();
618        assert_eq!(empty, "synaps: session s1 was closed (it never ran a turn, so there is nothing to resume)");
619        assert_eq!(session_ended_line(EndReason::Idle, "s1", true), None);
620    }
621
622    fn notice_inputs<'a>(id: &'a SessionId, warnings: &'a [String]) -> AttachNoticeInputs<'a> {
623        AttachNoticeInputs {
624            session_id: id,
625            client_id: 1,
626            mode: AttachMode::Mirror,
627            owned_elsewhere: None,
628            adopted: None,
629            others_banner: None,
630            config_warnings: warnings,
631            version_skew: None,
632        }
633    }
634
635    #[test]
636    fn routine_attach_notes_go_to_the_toast_so_the_logo_shows() {
637        let id = SessionId::from("20261001-000000-ours".to_string());
638        // plain `synaps` adopting the daemon, nothing else going on
639        let n = attach_notices(AttachNoticeInputs { adopted: Some(false), ..notice_inputs(&id, &[]) });
640        assert!(n.transcript.is_empty(), "a fresh session's transcript stays empty: {n:?}");
641        assert_eq!(n.toast[0], "attached to 20261001-000000-ours as client #1 (Mirror)");
642        assert_eq!(n.toast[1], "adopted the running daemon (fresh session)");
643        assert!(n.toast[2].contains("SYNAPS_DAEMON_ADOPT=0"), "{n:?}");
644        // `--continue` says so
645        let n = attach_notices(AttachNoticeInputs { adopted: Some(true), ..notice_inputs(&id, &[]) });
646        assert_eq!(n.toast[1], "adopted the running daemon (continued session)");
647        // explicit `--attach`: only the attach line
648        let n = attach_notices(notice_inputs(&id, &[]));
649        assert_eq!(n, AttachNotices { transcript: vec![], toast: vec![
650            "attached to 20261001-000000-ours as client #1 (Mirror)".to_string(),
651        ] });
652    }
653
654    #[test]
655    fn notes_that_need_the_user_stay_in_the_transcript() {
656        let id = SessionId::from("20261001-000000-ours".to_string());
657        let warnings = vec!["unknown key `fooo`".to_string()];
658        let n = attach_notices(AttachNoticeInputs {
659            adopted: Some(false),
660            others_banner: Some("2 other sessions in this daemon".to_string()),
661            owned_elsewhere: Some("input is owned by client #3 (tui)".to_string()),
662            ..notice_inputs(&id, &warnings)
663        });
664        assert_eq!(n.transcript, vec![
665            "2 other sessions in this daemon".to_string(),
666            "⚠ config: unknown key `fooo`".to_string(),
667            "attached to 20261001-000000-ours as client #1 (Mirror) — input is owned by client #3 (tui), or /takeover here"
668                .to_string(),
669        ]);
670        // read-only attach: the attach line is in the transcript, not the toast
671        assert!(n.toast.iter().all(|l| !l.starts_with("attached to")), "{n:?}");
672        assert_eq!(n.toast[0], "adopted the running daemon (fresh session)");
673    }
674
675    /// An observer is read-only on purpose: its attach line never offers
676    /// `/takeover` (the session refuses it from an observer).
677    #[test]
678    fn an_observer_is_not_offered_takeover() {
679        let id = SessionId::from("20261001-000000-ours".to_string());
680        let n = attach_notices(AttachNoticeInputs {
681            mode: AttachMode::Observe,
682            owned_elsewhere: Some("input is owned by client #3 (tui)".to_string()),
683            ..notice_inputs(&id, &[])
684        });
685        assert_eq!(n.transcript, vec![
686            "attached to 20261001-000000-ours as client #1 (Observe) — input is owned by client #3 (tui)"
687                .to_string(),
688        ]);
689    }
690
691    #[test]
692    fn a_client_older_than_the_daemon_says_so_in_the_transcript() {
693        let id = SessionId::from("20261001-000000-ours".to_string());
694        use agent_engine::session::transport::{version_skew_notice, BinaryId};
695        let bin = |version: &str| BinaryId { version: version.into(), build: String::new() };
696        let skew = version_skew_notice(&bin("0.10.1"), &bin("0.10.2"), id.as_str());
697        let n = attach_notices(AttachNoticeInputs { version_skew: skew, ..notice_inputs(&id, &[]) });
698        assert_eq!(n.transcript.len(), 1, "{n:?}");
699        assert!(n.transcript[0].starts_with("⚠ this client is synaps 0.10.1 but the daemon is 0.10.2"), "{n:?}");
700        assert!(n.transcript[0].contains("synaps --attach 20261001-000000-ours"), "{n:?}");
701        // same binary: nothing extra
702        let same = version_skew_notice(&bin("0.10.2"), &bin("0.10.2"), id.as_str());
703        let n = attach_notices(AttachNoticeInputs { version_skew: same, ..notice_inputs(&id, &[]) });
704        assert!(n.transcript.is_empty(), "{n:?}");
705    }
706
707    /// 04-tui M8: the skew line was posted again on every reconnect, crash
708    /// reconnects included. It shows once per daemon build.
709    #[test]
710    fn the_skew_notice_shows_once_per_daemon_build() {
711        use agent_engine::session::transport::BinaryId;
712        let mut app = crate::tui::app::App::new(synaps_cli::Session::new("test-model", "low", None));
713        let daemon = |build: &str| BinaryId { version: "0.0.1".into(), build: build.into() };
714        assert!(skew_notice_once(&mut app, daemon("aaaa1111"), "s").is_some());
715        assert!(
716            skew_notice_once(&mut app, daemon("aaaa1111"), "s").is_none(),
717            "a reconnect to the same daemon build says nothing again"
718        );
719        assert!(
720            skew_notice_once(&mut app, daemon("bbbb2222"), "s").is_some(),
721            "a reload onto another daemon build warns again"
722        );
723    }
724
725    #[test]
726    fn not_running_message_says_so_and_how_to_start() {
727        let m = not_running_message(None, None, false);
728        assert!(m.starts_with("no daemon is running"), "{m}");
729        assert!(m.contains("synaps daemon --detach"), "{m}");
730        assert!(m.contains("synaps daemon --foreground"), "{m}");
731        assert!(m.contains("SYNAPS_DAEMON_AUTOSPAWN=0"), "{m}");
732        let m = not_running_message(Some("work"), Some("connection refused"), false);
733        assert!(m.contains("(connection refused)"), "{m}");
734        assert!(m.contains("synaps --profile work daemon --detach"), "{m}");
735    }
736
737    #[test]
738    fn not_running_message_skips_the_autospawn_hint_when_autospawn_is_on() {
739        let m = not_running_message(Some("demo"), Some("connection refused"), true);
740        assert!(m.starts_with("no daemon is running (connection refused)"), "{m}");
741        assert!(m.contains("re-run `synaps --attach`"), "{m}");
742        assert!(m.contains("synaps --profile demo daemon --detach"), "{m}");
743        assert!(!m.contains("SYNAPS_DAEMON_AUTOSPAWN"), "{m}");
744    }
745
746    fn opts(id: Option<&str>, new_session: bool) -> AttachOpts {
747        AttachOpts {
748            profile: None,
749            id: id.map(str::to_string),
750            continue_session: None,
751            system: None,
752            prompt_manifest: None,
753            mode: AttachMode::Mirror,
754            no_confirm_answers: false,
755            keep_warm: false,
756            new_session,
757            name: None,
758            client_name: None,
759            adopted: false,
760        }
761    }
762
763    fn meta(id: &str, lifecycle: agent_engine::session::SessionLifecycle, clients: usize, mins_ago: i64) -> agent_engine::session::SessionMeta {
764        agent_engine::session::SessionMeta {
765            id: SessionId::from(id.to_string()),
766            name: None,
767            model: "m".into(),
768            cwd: Some(std::path::PathBuf::from("/w")),
769            created_at: chrono::Utc::now() - chrono::Duration::minutes(mins_ago),
770            continued: false,
771            continue_info: None,
772            host_pid: 1,
773            lifecycle,
774            clients,
775            input_owner: None,
776            awaiting_input: 0,
777            roster: Vec::new(),
778            client_input: None,
779            journal_id: String::new(),
780            locked_by: None,
781            pinned: false,
782            busy: false,
783            state_since: None,
784            park_due: None,
785            evict_due: None,
786            title: None,
787            cost_usd: 0.0,
788            messages: 0,
789        }
790    }
791
792    #[test]
793    fn await_extensions_flows_from_quick_start() {
794        // quick_start=false => caller passes await_extensions=true => the
795        // created SessionConfig blocks on extensions (historic daemon path).
796        let (a, _) = choose_attach(&opts(None, true), &[], None, true).unwrap();
797        match a {
798            Attach::Create { config, .. } => assert!(config.await_extensions),
799            other => panic!("expected Create, got {other:?}"),
800        }
801        // quick_start=true (default) => await_extensions=false => fast start.
802        let (a, _) = choose_attach(&opts(None, true), &[], None, false).unwrap();
803        match a {
804            Attach::Create { config, .. } => assert!(!config.await_extensions),
805            other => panic!("expected Create, got {other:?}"),
806        }
807    }
808
809    #[test]
810    fn bare_continue_means_most_recent_not_fresh() {
811        // F21: `synaps --continue` (no id) over the attach path must reach the
812        // daemon as `continue_session: Some(None)` (= most recent journal),
813        // even when --new is also set (adopt sets it).
814        let mut o = opts(None, true);
815        o.continue_session = Some(None);
816        let (a, _) = choose_attach(&o, &[live("abc")], None, true).unwrap();
817        match a {
818            Attach::Create { config, .. } => assert_eq!(config.continue_session, Some(None)),
819            other => panic!("expected Create, got {other:?}"),
820        }
821        let mut o = opts(None, false);
822        o.continue_session = Some(Some("xyz".into()));
823        let (a, _) = choose_attach(&o, &[], None, true).unwrap();
824        match a {
825            Attach::Create { config, .. } => assert_eq!(config.continue_session, Some(Some("xyz".into()))),
826            other => panic!("expected Create, got {other:?}"),
827        }
828    }
829
830    #[test]
831    fn adopt_banner_lists_other_sessions_only() {
832        use agent_engine::session::SessionLifecycle as L;
833        let ours = SessionId::from("20260919-000000-ours".to_string());
834        let now = chrono::Utc::now();
835        // alone in the daemon → nothing to say
836        assert!(adopt_banner(&[meta("20260919-000000-ours", L::Live, 1, 0)], &ours, now).is_none());
837        let sessions = vec![
838            meta("20260919-000000-ours", L::Live, 1, 0),
839            meta("20260919-010000-aaaa", L::Parked, 0, 125),
840            meta("20260919-020000-bbbb", L::Live, 1, 3),
841        ];
842        let b = adopt_banner(&sessions, &ours, now).unwrap();
843        assert!(b.starts_with("2 other sessions in this daemon (1 live, 1 parked)"), "{b}");
844        assert!(!b.contains("ours"), "{b}");
845        // newest first, label + state + cwd per row
846        let rows: Vec<&str> = b.lines().skip(1).collect();
847        assert!(rows[0].contains("\u{2026}bbbb") && rows[0].contains("attached") && rows[0].contains("/w"), "{b}");
848        assert!(rows[1].contains("\u{2026}aaaa") && rows[1].contains("parked"), "{b}");
849        // capped at 5 rows + overflow line
850        let many: Vec<_> = (0..8).map(|i| meta(&format!("20260919-00000{i}-xxxx"), L::Parked, 0, i)).collect();
851        let b = adopt_banner(&many, &ours, now).unwrap();
852        assert_eq!(b.lines().count(), 1 + 5 + 1, "{b}");
853        assert!(b.contains("and 3 more"), "{b}");
854    }
855
856    /// C5: the banner dated rows by `created_at` ("parked 4m ago") while
857    /// /sessions dates them by `state_since` ("parked 2m"); it also carried
858    /// backticked CLI how-tos and absolute paths.
859    #[test]
860    fn adopt_banner_matches_the_sessions_modal() {
861        use agent_engine::session::SessionLifecycle as L;
862        let ours = SessionId::from("20260919-000000-ours".to_string());
863        let now = chrono::Utc::now();
864        // A fixed home, not `HOME`: Windows does not set it.
865        let home = std::env::temp_dir();
866        let mut m = meta("20260919-010000-793d", L::Parked, 0, 4);
867        m.name = Some("gallery2".into());
868        m.state_since = Some(now - chrono::Duration::minutes(2));
869        m.evict_due = Some(now + chrono::Duration::minutes(57) + chrono::Duration::seconds(30));
870        m.cwd = Some(home.join("Projects/synaps"));
871        let b = adopt_banner_with_home(
872            &[meta("20260919-000000-ours", L::Live, 1, 0), m.clone()],
873            &ours,
874            now,
875            Some(&home),
876        )
877        .unwrap();
878        assert_eq!(
879            b,
880            format!(
881                "1 other session in this daemon (1 parked) \u{2014} /sessions to switch:\n  gallery2  {}  ~/Projects/synaps",
882                crate::tui::sessions::state_text(&m, now)
883            )
884        );
885        assert!(b.contains("parked 2m"), "{b}");
886        assert!(!b.contains('`') && !b.contains("ago"), "{b}");
887    }
888
889    fn live(id: &str) -> LiveSession {
890        LiveSession {
891            id: SessionId::from(id),
892            name: None,
893            model: "m".into(),
894            clients: 1,
895        }
896    }
897
898    #[test]
899    fn new_creates_even_with_live_sessions() {
900        let (a, notice) = choose_attach(&opts(None, true), &[live("abc")], None, true).unwrap();
901        assert!(matches!(a, Attach::Create { .. }), "{a:?}");
902        assert!(notice.is_none());
903        let (a, _) = choose_attach(&opts(None, true), &[live("a"), live("b")], None, true).unwrap();
904        assert!(matches!(a, Attach::Create { .. }), "{a:?}");
905    }
906
907    fn named(id: &str, name: &str) -> LiveSession {
908        LiveSession { name: Some(name.into()), ..live(id) }
909    }
910
911    #[test]
912    fn attach_resolves_a_session_name() {
913        let sessions = [live("20261026-081319-a97a"), named("20261026-081500-793d", "pair")];
914        let (a, _) = choose_attach(&opts(Some("pair"), false), &sessions, None, true).unwrap();
915        assert!(
916            matches!(&a, Attach::Existing { session_id, .. } if session_id.as_str() == "20261026-081500-793d"),
917            "{a:?}"
918        );
919        // An exact id still wins over a name, and id prefixes still work.
920        let sessions = [named("abc", "x"), named("def", "abc")];
921        let (a, _) = choose_attach(&opts(Some("abc"), false), &sessions, None, true).unwrap();
922        assert!(matches!(&a, Attach::Existing { session_id, .. } if session_id.as_str() == "abc"), "{a:?}");
923        let (a, _) = choose_attach(&opts(Some("de"), false), &sessions, None, true).unwrap();
924        assert!(matches!(&a, Attach::Existing { session_id, .. } if session_id.as_str() == "def"), "{a:?}");
925    }
926
927    #[test]
928    fn attach_by_ambiguous_name_lists_the_ids() {
929        let sessions = [named("aaa", "pair"), named("bbb", "pair")];
930        let e = choose_attach(&opts(Some("pair"), false), &sessions, None, true).unwrap_err();
931        assert!(e.contains("aaa") && e.contains("bbb") && e.contains("by id"), "{e}");
932    }
933
934    #[test]
935    fn new_with_explicit_id_is_rejected() {
936        let e = choose_attach(&opts(Some("abc"), true), &[live("abc")], None, true).unwrap_err();
937        assert!(e.contains("cannot combine"), "{e}");
938    }
939
940    #[test]
941    fn sole_live_session_is_picked_with_notice() {
942        let (a, notice) = choose_attach(&opts(None, false), &[live("abc")], None, true).unwrap();
943        assert!(
944            matches!(&a, Attach::Existing { session_id, .. } if session_id.as_str() == "abc"),
945            "{a:?}"
946        );
947        let n = notice.unwrap();
948        assert!(n.contains("abc") && n.contains("--new") && n.contains("daemon sessions"), "{n}");
949    }
950
951    #[test]
952    fn no_live_sessions_creates_and_many_errors_with_hint() {
953        let (a, _) = choose_attach(&opts(None, false), &[], None, true).unwrap();
954        assert!(matches!(a, Attach::Create { .. }), "{a:?}");
955        let e = choose_attach(&opts(None, false), &[live("a"), live("b")], None, true).unwrap_err();
956        assert!(e.contains("--new") && e.contains("  a  ") && e.contains("  b  "), "{e}");
957    }
958
959    #[test]
960    fn attach_mode_flags_override_env() {
961        assert_eq!(attach_mode(false, true), AttachMode::Takeover);
962        assert_eq!(attach_mode(true, false), AttachMode::Observe);
963    }
964}