use super::{Notice, SwitchTarget};
use crate::tui::app::{App, ChatMessage};
use crate::tui::session_link::{PromptBridge, SessionLink};
use agent_engine::session::wire::{Attach, Hello};
use agent_engine::session::{
AttachMode, AttachSnapshot, ClientKind, ClientTransport, CompactionPolicyWire, HistoryMode,
SessionCommand, SessionConfig, SessionId,
};
use std::path::PathBuf;
pub(crate) struct Attached {
pub(crate) transport: Box<dyn ClientTransport>,
pub(crate) snapshot: AttachSnapshot,
pub(crate) reused_live: bool,
}
#[async_trait::async_trait]
pub(crate) trait Connector: Send + Sync {
async fn connect(&self, target: &SwitchTarget, mode: AttachMode) -> Result<Attached, String>;
}
pub(crate) struct DaemonConnector {
pub(crate) path: PathBuf,
}
#[async_trait::async_trait]
impl Connector for DaemonConnector {
async fn connect(&self, target: &SwitchTarget, mode: AttachMode) -> Result<Attached, String> {
use agent_engine::session::socket_transport::SocketTransport;
let hello = Hello::new(ClientKind::Tui)
.with_history(HistoryMode::from_env_or(HistoryMode::attach_client_default()));
let conn = SocketTransport::connect(&self.path, hello)
.await
.map_err(|e| format!("daemon unreachable: {e}"))?;
let (attach, reused_live) = match target {
SwitchTarget::Attach { id } => {
(Attach::Existing { session_id: SessionId(id.clone()), mode }, false)
}
SwitchTarget::Continue { journal_id, cwd } => {
match conn
.welcome
.sessions
.iter()
.find(|m| m.journal_id == *journal_id || m.id.0 == *journal_id)
{
Some(m) => (Attach::Existing { session_id: m.id.clone(), mode }, true),
None => (continue_attach(journal_id, cwd.clone(), mode), false),
}
}
};
let (transport, snapshot) = SocketTransport::attach(conn, attach)
.await
.map_err(|e| match e {
agent_engine::session::transport::TransportError::Refused(why) => why,
other => other.to_string(),
})?;
Ok(Attached { transport: Box::new(transport), snapshot, reused_live })
}
}
fn continue_attach(journal_id: &str, cwd: Option<PathBuf>, mode: AttachMode) -> Attach {
Attach::Create {
config: SessionConfig {
continue_session: Some(Some(journal_id.to_string())),
cwd: cwd.or_else(|| std::env::current_dir().ok()),
compaction_policy: CompactionPolicyWire::LinkedSuccessor,
await_extensions: !synaps_cli::load_config().startup.quick_start,
..Default::default()
},
mode,
}
}
pub(crate) async fn pin_past(path: &std::path::Path, journal_id: &str, cwd: Option<PathBuf>) -> Result<String, String> {
use agent_engine::session::socket_transport::SocketTransport;
let conn = SocketTransport::connect(path, Hello::new(ClientKind::Tui))
.await
.map_err(|e| format!("daemon unreachable: {e}"))?;
if let Some(m) = conn.welcome.sessions.iter().find(|m| m.journal_id == journal_id || m.id.0 == journal_id) {
let id = m.id.clone();
drop(conn);
return SocketTransport::pin(path, &id, true).await.map(|_| "pinned".into()).map_err(|e| e.to_string());
}
let (cwd, _note) = resolve_cwd(cwd, std::env::current_dir().ok(), None);
let mut attach = continue_attach(journal_id, cwd, AttachMode::Observe);
if let Attach::Create { config, .. } = &mut attach {
config.keep_warm = true;
}
let (t, snapshot) = SocketTransport::attach(conn, attach).await.map_err(|e| match e {
agent_engine::session::transport::TransportError::Refused(why) => why,
other => other.to_string(),
})?;
let pinned = snapshot.meta.pinned;
t.detach().await;
if pinned {
Ok("pinned".into())
} else {
Err("the daemon did not pin it (an older daemon?); pin it from the Live tab".into())
}
}
pub(crate) fn resolve_cwd(
wanted: Option<PathBuf>,
fallback: Option<PathBuf>,
home: Option<&std::path::Path>,
) -> (Option<PathBuf>, Option<String>) {
match wanted {
Some(dir) if dir.is_dir() => (Some(dir), None),
Some(_) => {
let shown = fallback
.as_deref()
.map(|f| super::short_cwd(Some(f), home))
.unwrap_or_else(|| "this folder".to_string());
(fallback, Some(format!("folder gone, using {shown}")))
}
None => (fallback, None),
}
}
pub(crate) fn display_name(name: Option<&str>, id: &str) -> String {
let short = super::short_id(id);
match name.filter(|n| !n.is_empty()) {
Some(n) => format!("{n} ({short})"),
None => format!("\u{2026}{short}"),
}
}
fn refuse(app: &mut App, text: impl Into<String>) {
if let Some(st) = app.sessions.as_mut() {
st.notice = Some(Notice { text: text.into(), error: true });
} else {
app.push_msg(ChatMessage::Error(text.into()));
}
app.request_redraw();
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn perform(
app: &mut App,
link: &mut SessionLink,
bridge: &mut PromptBridge,
target: SwitchTarget,
connector: Option<&dyn Connector>,
registry: &std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
render_handle: &crate::tui::render_thread::RenderHandle,
) {
let own_id = link.transport().session_id().0.clone();
if let SwitchTarget::Attach { id } = &target {
if *id == own_id {
refuse(app, "already on this session");
return;
}
}
let Some(connector) = connector else {
return in_process(app, link, target).await;
};
let home = std::env::var_os("HOME").map(PathBuf::from);
let mut cwd_note = None;
let target = match target {
SwitchTarget::Continue { journal_id, cwd } => {
let (cwd, note) = resolve_cwd(cwd, std::env::current_dir().ok(), home.as_deref());
cwd_note = note;
SwitchTarget::Continue { journal_id, cwd }
}
other => other,
};
let mode = if link.mode() == AttachMode::Observe { AttachMode::Observe } else { AttachMode::Mirror };
if let Some(st) = app.sessions.as_mut() {
st.notice = Some(Notice { text: "switching\u{2026}".into(), error: false });
}
let attached = match connector.connect(&target, mode).await {
Ok(a) => a,
Err(e) => {
refuse(app, format!("switch failed: {e}"));
return;
}
};
if attached.snapshot.meta.id.0 == own_id {
refuse(app, "already on this session");
return;
}
let old_name = display_name(app.session.name.as_deref(), &own_id);
let new_link = SessionLink::new(attached.transport);
let old = std::mem::replace(link, new_link);
let _ = old.send(SessionCommand::Detach { client: old.client_id() }).await;
drop(old);
let snapshot = attached.snapshot;
app.reset_for_session_switch();
*bridge = bridge.fresh();
crate::tui::run_setup::remirror(app, &snapshot);
app.observing = link.mode() == AttachMode::Observe;
if let Some(cwd) = snapshot.meta.cwd.as_deref() {
let home = std::env::var_os("HOME").map(PathBuf::from);
app.header_folder = crate::tui::header::folder_label(cwd, home.as_deref());
}
if snapshot.meta.name.is_some() {
app.session.name = snapshot.meta.name.clone();
}
super::close(app);
crate::tui::input::reconcile_secret_prompt(app);
bridge.set_owns_input(snapshot.input_owner == Some(link.client_id()));
for env in snapshot.replay.clone() {
let _ = crate::tui::stream_handler::handle_session_event_arm(
env, app, link, registry, render_handle, bridge, None,
)
.await;
}
for pr in snapshot.pending_prompts.clone() {
bridge.on_prompt(pr);
}
crate::tui::stream_handler::sync_waiting_confirms(app, bridge);
app.quiet_owner_toast_until =
Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
if let Some(n) = snapshot.input_owned_elsewhere(link.client_id()) {
app.push_msg(ChatMessage::System(n));
}
let new_name = display_name(snapshot.meta.name.as_deref(), &snapshot.meta.id.0);
let mut extras = Vec::new();
if attached.reused_live {
extras.push("already live".to_string());
}
extras.extend(cwd_note);
let extras = if extras.is_empty() { String::new() } else { format!(" ({})", extras.join(", ")) };
app.push_msg(ChatMessage::System(format!(
"switched to {new_name}{extras} \u{2014} {old_name} keeps running"
)));
#[cfg(debug_assertions)]
crate::tui::focus::debug_assert_stack_sync(app);
app.request_immediate_redraw();
}
async fn in_process(app: &mut App, link: &mut SessionLink, target: SwitchTarget) {
match target {
SwitchTarget::Attach { .. } => {
refuse(app, "no daemon \u{2014} switching sessions needs `synaps --attach`");
}
SwitchTarget::Continue { .. } if app.streaming || app.compacting => {
refuse(app, "switching would cancel this turn \u{2014} wait for it to end (or Esc)");
}
SwitchTarget::Continue { journal_id, .. } => match link.resume(&journal_id).await {
Ok(reply) => {
super::close(app);
app.transcript.clear();
app.invalidate();
app.resume_pending = Some(crate::tui::app::ResumePending {
old_id: reply.old_id,
new_id: reply.new_id,
via: reply.via,
clamp_notice: reply.clamp_notice,
});
}
Err(e) => refuse(app, format!("continue failed: {e}")),
},
}
}