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