1use 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
26pub struct AttachOpts {
28 pub profile: Option<String>,
29 pub id: Option<String>,
31 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 pub new_session: bool,
44 pub name: Option<String>,
47 pub adopted: bool,
51}
52
53const ATTACH_TOAST_ID: &str = "attach";
55
56struct AttachNoticeInputs<'a> {
58 session_id: &'a SessionId,
59 client_id: u64,
60 mode: AttachMode,
61 owned_elsewhere: Option<String>,
63 adopted: Option<bool>,
65 others_banner: Option<String>,
67 config_warnings: &'a [String],
68}
69
70#[derive(Debug, Default, PartialEq, Eq)]
72struct AttachNotices {
73 transcript: Vec<String>,
77 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
102pub 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
148pub struct LiveSession {
150 pub id: SessionId,
151 pub model: String,
152 pub clients: usize,
153}
154
155pub 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
226pub 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
245pub 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 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 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 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 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 eprintln!("{n}");
337 }
338
339 let (transport, snapshot) = SocketTransport::attach(conn, attach)
340 .await
341 .map_err(|e| cfg_err(format!("attach: {e}")))?;
342 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 super::client_diet::purge_arenas("purge:attached");
356
357 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 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 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 ctx.app.extension_loader_running = false;
423 for env in replay {
424 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 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 let n = attach_notices(AttachNoticeInputs { adopted: Some(true), ..notice_inputs(&id, &[]) });
473 assert_eq!(n.toast[1], "adopted the running daemon (continued session)");
474 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 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 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 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 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 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 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 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}