Skip to main content

agent_tui/tui/
attach.rs

1//! `synaps --attach [ID] [--observe|--takeover] [--keep-warm]` — the TUI
2//! over `SocketTransport` (PLAN-phase3 A4). Client diet: no `EngineHost`,
3//! no `Runtime`, no `ExtensionManager`, no MCP, no skills index — config,
4//! builtin commands, keybinds, the render thread and the event stream only.
5//! Plugin commands / interactive plugin commands are not available over the
6//! socket yet (`Query{Commands}` is phase 4).
7
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use agent_engine::session::socket_transport::SocketTransport;
12use agent_engine::session::wire::{Attach, Hello};
13use agent_engine::session::{
14    AttachMode, ClientKind, ClientTransport, CompactionPolicyWire, HistoryMode, SessionCommand,
15    SessionConfig, SessionId, TransportError,
16};
17use synaps_cli::skills::registry::CommandRegistry;
18use synaps_cli::skills::BUILTIN_COMMANDS;
19use synaps_cli::Result;
20
21use agent_core::core::memstat::ladder;
22
23use super::app::ChatMessage;
24use super::run_setup::{app_from_snapshot, finish_setup, LazyHttp, TransportMode};
25
26/// What to attach to.
27pub struct AttachOpts {
28    pub profile: Option<String>,
29    /// Session id (prefix ok); `None` = the only live session, else create.
30    pub id: Option<String>,
31    /// Create by continuing a saved session (name or id).
32    /// `--continue [NAME_OR_ID]`: `Some(None)` = most recent journal (bare
33    /// `--continue`), `Some(Some(x))` = that one. Must stay two-level: the
34    /// attach path used to flatten it, which turned a bare `--continue`
35    /// into a fresh session under adopt / `--new` (soak F21).
36    pub continue_session: Option<Option<String>>,
37    pub system: Option<String>,
38    pub prompt_manifest: Option<PathBuf>,
39    pub mode: AttachMode,
40    pub keep_warm: bool,
41    /// `--new`/`--create`: always create a fresh session even if one is
42    /// live. Rejected together with an explicit `id`.
43    pub new_session: bool,
44    /// `--name`: name the created session (`synaps send --session <name>`
45    /// resolves it at once).
46    pub name: Option<String>,
47    /// A plain `synaps` adopted a running daemon (no explicit `--attach`):
48    /// after attaching, show what else lives in this daemon so parked
49    /// work stays discoverable (F1).
50    pub adopted: bool,
51}
52
53/// Toast id for the routine attach notes ([`attach_notices`]).
54const ATTACH_TOAST_ID: &str = "attach";
55
56/// What [`attach_notices`] needs to know about the attach that just happened.
57struct AttachNoticeInputs<'a> {
58    session_id: &'a SessionId,
59    client_id: u64,
60    mode: AttachMode,
61    /// `Some(notice)` when another client owns input, so this one is read-only.
62    owned_elsewhere: Option<String>,
63    /// `Some(continued)` when a plain `synaps` adopted the daemon.
64    adopted: Option<bool>,
65    /// The daemon's other sessions ([`adopt_banner`]), adopt only.
66    others_banner: Option<String>,
67    config_warnings: &'a [String],
68}
69
70/// Attach notes, split by whether they need the user.
71#[derive(Debug, Default, PartialEq, Eq)]
72struct AttachNotices {
73    /// Stays in the transcript: the other-sessions list, config warnings,
74    /// and the attach line when input is owned elsewhere (this client is
75    /// read-only until it takes over).
76    transcript: Vec<String>,
77    /// Routine notes ("adopted the running daemon", "attached to … as
78    /// client #N") for a short toast. Kept out of the transcript so a fresh
79    /// session starts empty and shows the boot logo, like in-process.
80    toast: Vec<String>,
81}
82
83fn attach_notices(i: AttachNoticeInputs<'_>) -> AttachNotices {
84    let mut out = AttachNotices::default();
85    if let Some(continued) = i.adopted {
86        let how = if continued { "continued session" } else { "fresh session" };
87        out.toast.push(format!("adopted the running daemon ({how})"));
88        out.toast.push("SYNAPS_DAEMON_ADOPT=0 for in-process".to_string());
89        out.transcript.extend(i.others_banner);
90    }
91    for w in i.config_warnings {
92        out.transcript.push(format!("⚠ config: {w}"));
93    }
94    let attached = format!("attached to {} as client #{} ({:?})", i.session_id, i.client_id, i.mode);
95    match i.owned_elsewhere {
96        Some(owned) => out.transcript.push(format!("{attached} — {owned}")),
97        None => out.toast.insert(0, attached),
98    }
99    out
100}
101
102/// One-line inventory of the daemon's other sessions, shown when a plain
103/// `synaps` adopts the daemon (F1): the lid-close user must be able to
104/// *see* their parked task, without being surprise-attached to it.
105/// `None` when nothing else is there.
106pub fn adopt_banner(
107    sessions: &[agent_engine::session::SessionMeta],
108    ours: &SessionId,
109    now: chrono::DateTime<chrono::Utc>,
110) -> Option<String> {
111    use agent_engine::session::SessionLifecycle as L;
112    let mut others: Vec<&agent_engine::session::SessionMeta> =
113        sessions.iter().filter(|m| &m.id != ours).collect();
114    if others.is_empty() {
115        return None;
116    }
117    others.sort_by_key(|m| std::cmp::Reverse(m.created_at));
118    let n = others.len();
119    let parked = others.iter().filter(|m| matches!(m.lifecycle, L::Parked)).count();
120    let live = n - parked;
121    let mut line = format!(
122        "{n} other session{} in this daemon ({live} live, {parked} parked) — `synaps --attach <ID>` joins a live one, `synaps --continue <ID>` resumes a parked one:",
123        if n == 1 { "" } else { "s" }
124    );
125    for m in others.iter().take(5) {
126        let age = now.signed_duration_since(m.created_at);
127        let age = if age.num_hours() >= 1 {
128            format!("{}h ago", age.num_hours())
129        } else {
130            format!("{}m ago", age.num_minutes().max(0))
131        };
132        let state = match m.lifecycle {
133            L::Parked => "parked",
134            _ if m.clients > 0 => "live, attached",
135            _ => "live",
136        };
137        let short = m.id.as_str();
138        let name = m.name.as_deref().map(|n| format!(" \"{n}\"")).unwrap_or_default();
139        let cwd = m.cwd.as_ref().map(|c| format!(" {}", c.display())).unwrap_or_default();
140        line.push_str(&format!("\n  {short}{name}  {state}  {age}{cwd}"));
141    }
142    if n > 5 {
143        line.push_str(&format!("\n  … and {} more (`synaps daemon sessions`)", n - 5));
144    }
145    Some(line)
146}
147
148/// The slice of `Welcome.sessions` the attach decision needs.
149pub struct LiveSession {
150    pub id: SessionId,
151    pub model: String,
152    pub clients: usize,
153}
154
155/// What to send: `Existing` for an explicit or sole live session, `Create`
156/// for `--new`, `--continue`, or no live sessions. Errors are user-facing.
157/// `notice` is a one-line stderr message when a live session was picked
158/// implicitly (no id given).
159pub fn choose_attach(
160    opts: &AttachOpts,
161    sessions: &[LiveSession],
162    cwd: Option<PathBuf>,
163    await_extensions: bool,
164) -> std::result::Result<(Attach, Option<String>), String> {
165    let create = |continue_session: Option<Option<String>>| Attach::Create {
166        config: SessionConfig {
167            continue_session,
168            system: opts.system.clone(),
169            prompt_manifest: opts.prompt_manifest.clone(),
170            cwd: cwd.clone(),
171            env: None,
172            compaction_policy: CompactionPolicyWire::LinkedSuccessor,
173            await_extensions,
174            keep_warm: opts.keep_warm,
175            name: opts.name.clone(),
176            ..Default::default()
177        },
178        mode: opts.mode,
179    };
180    if opts.new_session && opts.id.is_some() {
181        return Err("cannot combine --attach <ID> with --new: pick one".to_string());
182    }
183    if let Some(c) = &opts.continue_session {
184        return Ok((create(Some(c.clone())), None));
185    }
186    if opts.new_session {
187        return Ok((create(None), None));
188    }
189    if let Some(id) = &opts.id {
190        let sid = sessions
191            .iter()
192            .find(|m| m.id.as_str() == id || m.id.as_str().starts_with(id.as_str()))
193            .map(|m| m.id.clone())
194            .unwrap_or_else(|| SessionId::from(id.as_str()));
195        return Ok((
196            Attach::Existing {
197                session_id: sid,
198                mode: opts.mode,
199            },
200            None,
201        ));
202    }
203    match sessions {
204        [] => Ok((create(None), None)),
205        [only] => Ok((
206            Attach::Existing {
207                session_id: only.id.clone(),
208                mode: opts.mode,
209            },
210            Some(format!(
211                "attaching to live session {} (--new for a fresh one; `synaps daemon sessions` lists all)",
212                only.id
213            )),
214        )),
215        many => {
216            let mut lines =
217                vec!["several sessions; pick one with --attach <ID> (or --new for a fresh one):".to_string()];
218            for m in many {
219                lines.push(format!("  {}  model={}  clients={}", m.id, m.model, m.clients));
220            }
221            Err(lines.join("\n"))
222        }
223    }
224}
225
226/// `SYNAPS_ATTACH_MODE=mirror|observe|takeover` default, overridden by flags.
227pub fn attach_mode(observe: bool, takeover: bool) -> AttachMode {
228    if takeover {
229        return AttachMode::Takeover;
230    }
231    if observe {
232        return AttachMode::Observe;
233    }
234    match std::env::var("SYNAPS_ATTACH_MODE").ok().as_deref() {
235        Some("observe") => AttachMode::Observe,
236        Some("takeover") => AttachMode::Takeover,
237        _ => AttachMode::Mirror,
238    }
239}
240
241fn cfg_err(e: impl std::fmt::Display) -> synaps_cli::RuntimeError {
242    synaps_cli::RuntimeError::Config(e.to_string())
243}
244
245/// What `--attach` prints when there is no daemon to attach to: says so,
246/// and how to start one (profile-aware).
247pub fn daemon_not_running_message(profile: Option<&str>, detail: Option<&str>) -> String {
248    let start = match profile {
249        Some(p) => format!("synaps --profile {p} daemon --detach"),
250        None => "synaps daemon --detach".to_string(),
251    };
252    let why = detail.map(|d| format!(" ({d})")).unwrap_or_default();
253    format!(
254        "no daemon is running{why} — nothing to attach to.\n\
255         start one with:  {start}\n\
256         or run in the foreground:  {}\n\
257         then re-run `synaps --attach` (or unset SYNAPS_DAEMON_AUTOSPAWN=0 to auto-start).",
258        start.replace("--detach", "--foreground")
259    )
260}
261
262pub async fn run_attached(mut opts: AttachOpts) -> Result<()> {
263    ladder("attach:enter", &"");
264    let paths = agent_engine::daemon::registry::daemon_paths(opts.profile.as_deref());
265    if !agent_engine::daemon::registry::is_alive(&paths) {
266        return Err(cfg_err(daemon_not_running_message(
267            opts.profile.as_deref(),
268            None,
269        )));
270    }
271    let hello = Hello::new(ClientKind::Tui)
272        .with_history(HistoryMode::from_env_or(HistoryMode::attach_client_default()));
273    let conn = match SocketTransport::connect(&paths.sock, hello).await {
274        Ok(c) => c,
275        Err(TransportError::Version { client, daemon }) => {
276            return Err(cfg_err(format!(
277                "protocol version mismatch (client {client}, daemon {daemon}); restart or reload the daemon with this binary"
278            )))
279        }
280        Err(TransportError::Io(e)) => {
281            // Lock held but the socket is gone/refusing: the daemon is dead
282            // or still booting — same advice, with the cause.
283            return Err(cfg_err(daemon_not_running_message(
284                opts.profile.as_deref(),
285                Some(&format!("{}: {e}", paths.sock.display())),
286            )));
287        }
288        Err(e) => return Err(cfg_err(format!("connect: {e}"))),
289    };
290    ladder(
291        "connect",
292        &format_args!("sessions={}", conn.welcome.sessions.len()),
293    );
294
295    let cwd = std::env::current_dir().ok();
296
297    // T4: resolve path-like args (--system, --prompt-manifest) against the
298    // client's cwd before shipping them to the daemon.
299    if let Some(ref cwd_path) = cwd {
300        let mut probe_cfg = SessionConfig {
301            system: opts.system.clone(),
302            prompt_manifest: opts.prompt_manifest.clone(),
303            ..Default::default()
304        };
305        if let Err(e) = agent_engine::session::client_args::resolve_client_session_args(
306            &mut probe_cfg,
307            cwd_path,
308        ) {
309            // Print to stderr before entering raw mode so it survives.
310            eprintln!("synaps: {e}");
311            return Err(cfg_err(e));
312        }
313        opts.system = probe_cfg.system;
314        opts.prompt_manifest = probe_cfg.prompt_manifest;
315    }
316
317    let live: Vec<LiveSession> = conn
318        .welcome
319        .sessions
320        .iter()
321        .map(|m| LiveSession {
322            id: m.id.clone(),
323            model: m.model.clone(),
324            clients: m.clients,
325        })
326        .collect();
327    let welcome_sessions = conn.welcome.sessions.clone();
328    // Quick Start (default on) => await_extensions=false: the daemon TUI
329    // stops blocking on extension discovery before turn 1 (fast cold start),
330    // matching the in-process TUI. Off => block like the historic daemon path.
331    let await_extensions = !synaps_cli::load_config().startup.quick_start;
332    let (attach, notice) =
333        choose_attach(&opts, &live, cwd, await_extensions).map_err(cfg_err)?;
334    if let Some(n) = notice {
335        // Before the TUI takes the terminal, so it survives on the scrollback.
336        eprintln!("{n}");
337    }
338
339    let (transport, snapshot) = SocketTransport::attach(conn, attach)
340        .await
341        .map_err(|e| cfg_err(format!("attach: {e}")))?;
342    // `frame_bytes` is filled once the transport records its last frame
343    // length (B4 `last_frame_bytes()`); until then the wire size is not known here.
344    ladder(
345        "attached",
346        &format_args!(
347            "frame_bytes=n/a messages_len={} api_messages={} replay={} tail_items={}",
348            snapshot.conversation.messages_len,
349            snapshot.conversation.api_messages.len(),
350            snapshot.replay.len(),
351            agent_engine::session::DEFAULT_TAIL_ITEMS
352        ),
353    );
354    // The decode transient of `Attached` is garbage now — give it back (§4.4).
355    super::client_diet::purge_arenas("purge:attached");
356
357    // ── Client diet: config + builtin commands + keybinds, nothing else ──
358    let config = synaps_cli::load_config();
359    let registry = Arc::new(CommandRegistry::new(BUILTIN_COMMANDS, Vec::new()));
360    let mut keybinds = synaps_cli::skills::keybinds::KeybindRegistry::new();
361    if !config.keybinds.is_empty() {
362        keybinds.register_user(&config.keybinds);
363    }
364    let keybind_registry = Arc::new(std::sync::RwLock::new(keybinds));
365    let system_prompt_path = synaps_cli::config::resolve_read_path("system.md");
366    ladder("config", &"");
367    let http = LazyHttp::new();
368    if std::env::var("SYNAPS_CLIENT_HTTP").is_ok_and(|v| v == "eager") {
369        http.get()?;
370    }
371
372    let mut app = app_from_snapshot(&snapshot);
373    let (msgs, bytes) = super::app::scrollback_from_env(&TransportMode::Socket);
374    app.transcript.set_scrollback(msgs, bytes);
375    ladder("app", &"");
376    // Notices queued before boot go first.
377    for n in super::run_setup::take_boot_notices() {
378        app.push_msg(ChatMessage::System(n));
379    }
380    let notices = attach_notices(AttachNoticeInputs {
381        session_id: &snapshot.meta.id,
382        client_id: transport.client_id().0,
383        mode: transport.mode(),
384        owned_elsewhere: snapshot.input_owned_elsewhere(transport.client_id()),
385        adopted: opts.adopted.then_some(opts.continue_session.is_some()),
386        others_banner: if opts.adopted {
387            adopt_banner(&welcome_sessions, &snapshot.meta.id, chrono::Utc::now())
388        } else {
389            None
390        },
391        config_warnings: &config.warnings,
392    });
393    for n in notices.transcript {
394        app.push_msg(ChatMessage::System(n));
395    }
396    if !notices.toast.is_empty() {
397        app.toasts.upsert(
398            super::toast::Toast::new(ATTACH_TOAST_ID, String::new())
399                .titled("daemon")
400                .lines(notices.toast),
401        );
402    }
403    // Mid-turn attach: rebuild the partial turn from the replay ring, then
404    // the actor's pending prompts.
405    let replay = snapshot.replay.clone();
406    let pending = snapshot.pending_prompts.clone();
407    app.streaming = snapshot.streaming;
408
409    let mut ctx = finish_setup(
410        app,
411        Box::new(transport),
412        http,
413        TransportMode::Socket,
414        config,
415        registry,
416        keybind_registry,
417        system_prompt_path,
418        None,
419    )
420    .await?;
421    // No extension host in this process: the loader arm never runs.
422    ctx.app.extension_loader_running = false;
423    for env in replay {
424        // Presentation only; a render handle is not needed for correctness.
425        let _ = super::stream_handler::handle_session_event_arm(
426            env,
427            &mut ctx.app,
428            &mut ctx.link,
429            &ctx.registry,
430            &ctx.render_handle,
431            &mut ctx.prompt_bridge,
432            None,
433        )
434        .await;
435    }
436    for pr in pending {
437        ctx.prompt_bridge.on_prompt(pr);
438    }
439    ladder("replay", &"");
440    if opts.keep_warm {
441        let _ = ctx.link.send(SessionCommand::KeepWarm { on: true }).await;
442    }
443    super::run_loop(ctx).await
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    fn notice_inputs<'a>(id: &'a SessionId, warnings: &'a [String]) -> AttachNoticeInputs<'a> {
451        AttachNoticeInputs {
452            session_id: id,
453            client_id: 1,
454            mode: AttachMode::Mirror,
455            owned_elsewhere: None,
456            adopted: None,
457            others_banner: None,
458            config_warnings: warnings,
459        }
460    }
461
462    #[test]
463    fn routine_attach_notes_go_to_the_toast_so_the_logo_shows() {
464        let id = SessionId::from("20261001-000000-ours".to_string());
465        // plain `synaps` adopting the daemon, nothing else going on
466        let n = attach_notices(AttachNoticeInputs { adopted: Some(false), ..notice_inputs(&id, &[]) });
467        assert!(n.transcript.is_empty(), "a fresh session's transcript stays empty: {n:?}");
468        assert_eq!(n.toast[0], "attached to 20261001-000000-ours as client #1 (Mirror)");
469        assert_eq!(n.toast[1], "adopted the running daemon (fresh session)");
470        assert!(n.toast[2].contains("SYNAPS_DAEMON_ADOPT=0"), "{n:?}");
471        // `--continue` says so
472        let n = attach_notices(AttachNoticeInputs { adopted: Some(true), ..notice_inputs(&id, &[]) });
473        assert_eq!(n.toast[1], "adopted the running daemon (continued session)");
474        // explicit `--attach`: only the attach line
475        let n = attach_notices(notice_inputs(&id, &[]));
476        assert_eq!(n, AttachNotices { transcript: vec![], toast: vec![
477            "attached to 20261001-000000-ours as client #1 (Mirror)".to_string(),
478        ] });
479    }
480
481    #[test]
482    fn notes_that_need_the_user_stay_in_the_transcript() {
483        let id = SessionId::from("20261001-000000-ours".to_string());
484        let warnings = vec!["unknown key `fooo`".to_string()];
485        let n = attach_notices(AttachNoticeInputs {
486            adopted: Some(false),
487            others_banner: Some("2 other sessions in this daemon".to_string()),
488            owned_elsewhere: Some("input is owned by client #3 (tui)".to_string()),
489            ..notice_inputs(&id, &warnings)
490        });
491        assert_eq!(n.transcript, vec![
492            "2 other sessions in this daemon".to_string(),
493            "⚠ config: unknown key `fooo`".to_string(),
494            "attached to 20261001-000000-ours as client #1 (Mirror) — input is owned by client #3 (tui)"
495                .to_string(),
496        ]);
497        // read-only attach: the attach line is in the transcript, not the toast
498        assert!(n.toast.iter().all(|l| !l.starts_with("attached to")), "{n:?}");
499        assert_eq!(n.toast[0], "adopted the running daemon (fresh session)");
500    }
501
502    #[test]
503    fn not_running_message_says_so_and_how_to_start() {
504        let m = daemon_not_running_message(None, None);
505        assert!(m.starts_with("no daemon is running"), "{m}");
506        assert!(m.contains("synaps daemon --detach"), "{m}");
507        assert!(m.contains("synaps daemon --foreground"), "{m}");
508        let m = daemon_not_running_message(Some("work"), Some("connection refused"));
509        assert!(m.contains("(connection refused)"), "{m}");
510        assert!(m.contains("synaps --profile work daemon --detach"), "{m}");
511    }
512
513    fn opts(id: Option<&str>, new_session: bool) -> AttachOpts {
514        AttachOpts {
515            profile: None,
516            id: id.map(str::to_string),
517            continue_session: None,
518            system: None,
519            prompt_manifest: None,
520            mode: AttachMode::Mirror,
521            keep_warm: false,
522            new_session,
523            name: None,
524            adopted: false,
525        }
526    }
527
528    fn meta(id: &str, lifecycle: agent_engine::session::SessionLifecycle, clients: usize, mins_ago: i64) -> agent_engine::session::SessionMeta {
529        agent_engine::session::SessionMeta {
530            id: SessionId::from(id.to_string()),
531            name: None,
532            model: "m".into(),
533            cwd: Some(std::path::PathBuf::from("/w")),
534            created_at: chrono::Utc::now() - chrono::Duration::minutes(mins_ago),
535            continued: false,
536            continue_info: None,
537            host_pid: 1,
538            lifecycle,
539            clients,
540            input_owner: None,
541            awaiting_input: 0,
542            journal_id: String::new(),
543            locked_by: None,
544        }
545    }
546
547    #[test]
548    fn await_extensions_flows_from_quick_start() {
549        // quick_start=false => caller passes await_extensions=true => the
550        // created SessionConfig blocks on extensions (historic daemon path).
551        let (a, _) = choose_attach(&opts(None, true), &[], None, true).unwrap();
552        match a {
553            Attach::Create { config, .. } => assert!(config.await_extensions),
554            other => panic!("expected Create, got {other:?}"),
555        }
556        // quick_start=true (default) => await_extensions=false => fast start.
557        let (a, _) = choose_attach(&opts(None, true), &[], None, false).unwrap();
558        match a {
559            Attach::Create { config, .. } => assert!(!config.await_extensions),
560            other => panic!("expected Create, got {other:?}"),
561        }
562    }
563
564    #[test]
565    fn bare_continue_means_most_recent_not_fresh() {
566        // F21: `synaps --continue` (no id) over the attach path must reach the
567        // daemon as `continue_session: Some(None)` (= most recent journal),
568        // even when --new is also set (adopt sets it).
569        let mut o = opts(None, true);
570        o.continue_session = Some(None);
571        let (a, _) = choose_attach(&o, &[live("abc")], None, true).unwrap();
572        match a {
573            Attach::Create { config, .. } => assert_eq!(config.continue_session, Some(None)),
574            other => panic!("expected Create, got {other:?}"),
575        }
576        let mut o = opts(None, false);
577        o.continue_session = Some(Some("xyz".into()));
578        let (a, _) = choose_attach(&o, &[], None, true).unwrap();
579        match a {
580            Attach::Create { config, .. } => assert_eq!(config.continue_session, Some(Some("xyz".into()))),
581            other => panic!("expected Create, got {other:?}"),
582        }
583    }
584
585    #[test]
586    fn adopt_banner_lists_other_sessions_only() {
587        use agent_engine::session::SessionLifecycle as L;
588        let ours = SessionId::from("20260919-000000-ours".to_string());
589        let now = chrono::Utc::now();
590        // alone in the daemon → nothing to say
591        assert!(adopt_banner(&[meta("20260919-000000-ours", L::Live, 1, 0)], &ours, now).is_none());
592        let sessions = vec![
593            meta("20260919-000000-ours", L::Live, 1, 0),
594            meta("20260919-010000-aaaa", L::Parked, 0, 125),
595            meta("20260919-020000-bbbb", L::Live, 1, 3),
596        ];
597        let b = adopt_banner(&sessions, &ours, now).unwrap();
598        assert!(b.starts_with("2 other sessions in this daemon (1 live, 1 parked)"), "{b}");
599        assert!(!b.contains("ours"), "{b}");
600        // newest first, state + age + cwd per row
601        let rows: Vec<&str> = b.lines().skip(1).collect();
602        assert!(rows[0].contains("20260919-020000-bbbb") && rows[0].contains("live, attached") && rows[0].contains("m ago") && rows[0].contains("/w"), "{b}");
603        assert!(rows[1].contains("20260919-010000-aaaa") && rows[1].contains("parked") && rows[1].contains("2h ago"), "{b}");
604        // capped at 5 rows + overflow line
605        let many: Vec<_> = (0..8).map(|i| meta(&format!("20260919-00000{i}-xxxx"), L::Parked, 0, i)).collect();
606        let b = adopt_banner(&many, &ours, now).unwrap();
607        assert_eq!(b.lines().count(), 1 + 5 + 1, "{b}");
608        assert!(b.contains("and 3 more"), "{b}");
609    }
610
611    fn live(id: &str) -> LiveSession {
612        LiveSession {
613            id: SessionId::from(id),
614            model: "m".into(),
615            clients: 1,
616        }
617    }
618
619    #[test]
620    fn new_creates_even_with_live_sessions() {
621        let (a, notice) = choose_attach(&opts(None, true), &[live("abc")], None, true).unwrap();
622        assert!(matches!(a, Attach::Create { .. }), "{a:?}");
623        assert!(notice.is_none());
624        let (a, _) = choose_attach(&opts(None, true), &[live("a"), live("b")], None, true).unwrap();
625        assert!(matches!(a, Attach::Create { .. }), "{a:?}");
626    }
627
628    #[test]
629    fn new_with_explicit_id_is_rejected() {
630        let e = choose_attach(&opts(Some("abc"), true), &[live("abc")], None, true).unwrap_err();
631        assert!(e.contains("cannot combine"), "{e}");
632    }
633
634    #[test]
635    fn sole_live_session_is_picked_with_notice() {
636        let (a, notice) = choose_attach(&opts(None, false), &[live("abc")], None, true).unwrap();
637        assert!(
638            matches!(&a, Attach::Existing { session_id, .. } if session_id.as_str() == "abc"),
639            "{a:?}"
640        );
641        let n = notice.unwrap();
642        assert!(n.contains("abc") && n.contains("--new") && n.contains("daemon sessions"), "{n}");
643    }
644
645    #[test]
646    fn no_live_sessions_creates_and_many_errors_with_hint() {
647        let (a, _) = choose_attach(&opts(None, false), &[], None, true).unwrap();
648        assert!(matches!(a, Attach::Create { .. }), "{a:?}");
649        let e = choose_attach(&opts(None, false), &[live("a"), live("b")], None, true).unwrap_err();
650        assert!(e.contains("--new") && e.contains("  a  ") && e.contains("  b  "), "{e}");
651    }
652
653    #[test]
654    fn attach_mode_flags_override_env() {
655        assert_eq!(attach_mode(false, true), AttachMode::Takeover);
656        assert_eq!(attach_mode(true, false), AttachMode::Observe);
657    }
658}