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
53pub 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
99pub struct LiveSession {
101 pub id: SessionId,
102 pub model: String,
103 pub clients: usize,
104}
105
106pub 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
177pub 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
196pub 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 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 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 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 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 eprintln!("{n}");
288 }
289
290 let (transport, snapshot) = SocketTransport::attach(conn, attach)
291 .await
292 .map_err(|e| cfg_err(format!("attach: {e}")))?;
293 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 super::client_diet::purge_arenas("purge:attached");
307
308 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 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_owner {
349 Some(owner) if owner != transport.client_id() =>
350 format!(" — input is owned by client #{}", owner.0),
351 _ => String::new(),
352 }
353 )));
354 let replay = snapshot.replay.clone();
357 let pending = snapshot.pending_prompts.clone();
358 app.streaming = snapshot.streaming;
359
360 let mut ctx = finish_setup(
361 app,
362 Box::new(transport),
363 http,
364 TransportMode::Socket,
365 config,
366 registry,
367 keybind_registry,
368 system_prompt_path,
369 None,
370 )
371 .await?;
372 ctx.app.extension_loader_running = false;
374 for env in replay {
375 let _ = super::stream_handler::handle_session_event_arm(
377 env,
378 &mut ctx.app,
379 &mut ctx.link,
380 &ctx.registry,
381 &ctx.render_handle,
382 &mut ctx.prompt_bridge,
383 None,
384 )
385 .await;
386 }
387 for pr in pending {
388 ctx.prompt_bridge.on_prompt(pr);
389 }
390 ladder("replay", &"");
391 if opts.keep_warm {
392 let _ = ctx.link.send(SessionCommand::KeepWarm { on: true }).await;
393 }
394 super::run_loop(ctx).await
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn not_running_message_says_so_and_how_to_start() {
403 let m = daemon_not_running_message(None, None);
404 assert!(m.starts_with("no daemon is running"), "{m}");
405 assert!(m.contains("synaps daemon --detach"), "{m}");
406 assert!(m.contains("synaps daemon --foreground"), "{m}");
407 let m = daemon_not_running_message(Some("work"), Some("connection refused"));
408 assert!(m.contains("(connection refused)"), "{m}");
409 assert!(m.contains("synaps --profile work daemon --detach"), "{m}");
410 }
411
412 fn opts(id: Option<&str>, new_session: bool) -> AttachOpts {
413 AttachOpts {
414 profile: None,
415 id: id.map(str::to_string),
416 continue_session: None,
417 system: None,
418 prompt_manifest: None,
419 mode: AttachMode::Mirror,
420 keep_warm: false,
421 new_session,
422 name: None,
423 adopted: false,
424 }
425 }
426
427 fn meta(id: &str, lifecycle: agent_engine::session::SessionLifecycle, clients: usize, mins_ago: i64) -> agent_engine::session::SessionMeta {
428 agent_engine::session::SessionMeta {
429 id: SessionId::from(id.to_string()),
430 name: None,
431 model: "m".into(),
432 cwd: Some(std::path::PathBuf::from("/w")),
433 created_at: chrono::Utc::now() - chrono::Duration::minutes(mins_ago),
434 continued: false,
435 continue_info: None,
436 host_pid: 1,
437 lifecycle,
438 clients,
439 input_owner: None,
440 awaiting_input: 0,
441 journal_id: String::new(),
442 locked_by: None,
443 }
444 }
445
446 #[test]
447 fn await_extensions_flows_from_quick_start() {
448 let (a, _) = choose_attach(&opts(None, true), &[], None, true).unwrap();
451 match a {
452 Attach::Create { config, .. } => assert!(config.await_extensions),
453 other => panic!("expected Create, got {other:?}"),
454 }
455 let (a, _) = choose_attach(&opts(None, true), &[], None, false).unwrap();
457 match a {
458 Attach::Create { config, .. } => assert!(!config.await_extensions),
459 other => panic!("expected Create, got {other:?}"),
460 }
461 }
462
463 #[test]
464 fn bare_continue_means_most_recent_not_fresh() {
465 let mut o = opts(None, true);
469 o.continue_session = Some(None);
470 let (a, _) = choose_attach(&o, &[live("abc")], None, true).unwrap();
471 match a {
472 Attach::Create { config, .. } => assert_eq!(config.continue_session, Some(None)),
473 other => panic!("expected Create, got {other:?}"),
474 }
475 let mut o = opts(None, false);
476 o.continue_session = Some(Some("xyz".into()));
477 let (a, _) = choose_attach(&o, &[], None, true).unwrap();
478 match a {
479 Attach::Create { config, .. } => assert_eq!(config.continue_session, Some(Some("xyz".into()))),
480 other => panic!("expected Create, got {other:?}"),
481 }
482 }
483
484 #[test]
485 fn adopt_banner_lists_other_sessions_only() {
486 use agent_engine::session::SessionLifecycle as L;
487 let ours = SessionId::from("20260919-000000-ours".to_string());
488 let now = chrono::Utc::now();
489 assert!(adopt_banner(&[meta("20260919-000000-ours", L::Live, 1, 0)], &ours, now).is_none());
491 let sessions = vec![
492 meta("20260919-000000-ours", L::Live, 1, 0),
493 meta("20260919-010000-aaaa", L::Parked, 0, 125),
494 meta("20260919-020000-bbbb", L::Live, 1, 3),
495 ];
496 let b = adopt_banner(&sessions, &ours, now).unwrap();
497 assert!(b.starts_with("2 other sessions in this daemon (1 live, 1 parked)"), "{b}");
498 assert!(!b.contains("ours"), "{b}");
499 let rows: Vec<&str> = b.lines().skip(1).collect();
501 assert!(rows[0].contains("20260919-020000-bbbb") && rows[0].contains("live, attached") && rows[0].contains("m ago") && rows[0].contains("/w"), "{b}");
502 assert!(rows[1].contains("20260919-010000-aaaa") && rows[1].contains("parked") && rows[1].contains("2h ago"), "{b}");
503 let many: Vec<_> = (0..8).map(|i| meta(&format!("20260919-00000{i}-xxxx"), L::Parked, 0, i)).collect();
505 let b = adopt_banner(&many, &ours, now).unwrap();
506 assert_eq!(b.lines().count(), 1 + 5 + 1, "{b}");
507 assert!(b.contains("and 3 more"), "{b}");
508 }
509
510 fn live(id: &str) -> LiveSession {
511 LiveSession {
512 id: SessionId::from(id),
513 model: "m".into(),
514 clients: 1,
515 }
516 }
517
518 #[test]
519 fn new_creates_even_with_live_sessions() {
520 let (a, notice) = choose_attach(&opts(None, true), &[live("abc")], None, true).unwrap();
521 assert!(matches!(a, Attach::Create { .. }), "{a:?}");
522 assert!(notice.is_none());
523 let (a, _) = choose_attach(&opts(None, true), &[live("a"), live("b")], None, true).unwrap();
524 assert!(matches!(a, Attach::Create { .. }), "{a:?}");
525 }
526
527 #[test]
528 fn new_with_explicit_id_is_rejected() {
529 let e = choose_attach(&opts(Some("abc"), true), &[live("abc")], None, true).unwrap_err();
530 assert!(e.contains("cannot combine"), "{e}");
531 }
532
533 #[test]
534 fn sole_live_session_is_picked_with_notice() {
535 let (a, notice) = choose_attach(&opts(None, false), &[live("abc")], None, true).unwrap();
536 assert!(
537 matches!(&a, Attach::Existing { session_id, .. } if session_id.as_str() == "abc"),
538 "{a:?}"
539 );
540 let n = notice.unwrap();
541 assert!(n.contains("abc") && n.contains("--new") && n.contains("daemon sessions"), "{n}");
542 }
543
544 #[test]
545 fn no_live_sessions_creates_and_many_errors_with_hint() {
546 let (a, _) = choose_attach(&opts(None, false), &[], None, true).unwrap();
547 assert!(matches!(a, Attach::Create { .. }), "{a:?}");
548 let e = choose_attach(&opts(None, false), &[live("a"), live("b")], None, true).unwrap_err();
549 assert!(e.contains("--new") && e.contains(" a ") && e.contains(" b "), "{e}");
550 }
551
552 #[test]
553 fn attach_mode_flags_override_env() {
554 assert_eq!(attach_mode(false, true), AttachMode::Takeover);
555 assert_eq!(attach_mode(true, false), AttachMode::Observe);
556 }
557}