1use super::*;
11
12use std::sync::Arc;
13use std::time::Instant;
14use synaps_cli::Result;
15
16use agent_engine::host::{EngineHost, HostOpts};
17use agent_engine::session::{
18 AttachMode, AttachSnapshot, ClientKind, ClientMeta, ClientTransport, CompactionPolicyWire,
19 LocalTransport, SessionConfig,
20};
21use helpers::apply_display_tail;
22use session_link::{PromptBridge, SessionLink};
23
24pub(crate) enum TransportMode {
26 Local {
29 #[allow(dead_code)]
30 host: Arc<EngineHost>,
31 },
32 Socket,
34}
35
36pub(crate) struct LazyHttp(std::cell::OnceCell<reqwest::Client>);
42
43use agent_core::core::memstat::ladder as ladder_stage;
44
45impl LazyHttp {
46 pub(crate) fn new() -> Self {
48 Self(std::cell::OnceCell::new())
49 }
50
51 pub(crate) fn get(&self) -> Result<&reqwest::Client> {
53 if let Some(c) = self.0.get() {
54 return Ok(c);
55 }
56 let t0 = Instant::now();
57 let client = agent_engine::runtime::build_host_http_client()?;
58 agent_core::core::memstat::ladder(
59 "http",
60 &format_args!("build_ms={}", t0.elapsed().as_millis()),
61 );
62 Ok(self.0.get_or_init(|| client))
63 }
64
65 #[allow(dead_code)]
67 pub(crate) fn is_built(&self) -> bool {
68 self.0.get().is_some()
69 }
70}
71
72impl From<reqwest::Client> for LazyHttp {
73 fn from(client: reqwest::Client) -> Self {
74 Self(std::cell::OnceCell::from(client))
75 }
76}
77
78pub(crate) struct RunContext {
82 pub app: App,
83 pub link: SessionLink,
86 pub http: LazyHttp,
89 pub prompt_bridge: PromptBridge,
90 pub mode: TransportMode,
91 pub config: synaps_cli::SynapsConfig,
92 pub registry: std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
93 pub keybind_registry:
94 std::sync::Arc<std::sync::RwLock<synaps_cli::skills::keybinds::KeybindRegistry>>,
95 pub system_prompt_path: std::path::PathBuf,
96 pub render_handle: render_thread::RenderHandle,
97 pub boot_done: std::sync::Arc<std::sync::atomic::AtomicBool>,
98 pub exit_done: std::sync::Arc<std::sync::atomic::AtomicBool>,
99 pub term_caps: termcaps::TermCaps,
102 pub event_reader: EventStream,
103 pub shutdown_signal_rx: tokio::sync::mpsc::UnboundedReceiver<signals::ShutdownSignal>,
104 pub shutdown_signal_task: signals::SignalHandle,
105 pub secret_prompt_rx: std::sync::Arc<
106 std::sync::Mutex<
107 tokio::sync::mpsc::UnboundedReceiver<synaps_cli::tools::SecretPromptRequest>,
108 >,
109 >,
110 pub ext_mgr_shared: Option<
113 std::sync::Arc<tokio::sync::RwLock<synaps_cli::extensions::manager::ExtensionManager>>,
114 >,
115 pub boot_fx_sent: bool,
116 pub exit_fx_sent: bool,
117 pub last_draw: Instant,
118}
119
120static BOOT_NOTICES: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
122
123pub fn push_boot_notice(text: impl Into<String>) {
127 BOOT_NOTICES.lock().unwrap_or_else(|e| e.into_inner()).push(text.into());
128}
129
130pub(crate) fn take_boot_notices() -> Vec<String> {
131 std::mem::take(&mut *BOOT_NOTICES.lock().unwrap_or_else(|e| e.into_inner()))
132}
133
134pub(crate) async fn run_setup(
135 continue_session: Option<Option<String>>,
136 system: Option<String>,
137 prompt_manifest: Option<std::path::PathBuf>,
138 profile: Option<String>,
139 no_extensions: bool,
140) -> Result<RunContext> {
141 let host = EngineHost::boot_and_install(HostOpts {
143 profile,
144 no_extensions,
145 mcp_discovery: agent_engine::McpDiscovery::Inline,
146 })
147 .await?;
148 let handle = host
149 .create_session(SessionConfig {
150 continue_session,
151 system,
152 prompt_manifest,
153 cwd: None,
154 env: None,
155 env_stripped: Vec::new(),
156 auto_approve_confirms: false,
157 model_override: None,
158 persist: true,
159 auto_compact: false,
160 compaction_policy: CompactionPolicyWire::LinkedSuccessor,
161 await_extensions: false,
162 keep_warm: false,
163 client_input: None,
164 name: None,
165 max_session_cost: None,
166 memory_mode: None,
167 restore_parked: false,
168 })
169 .await?;
170 let (transport, snapshot) =
171 LocalTransport::attach_with(handle, ClientMeta::new(ClientKind::Tui), AttachMode::Mirror)
172 .await
173 .map_err(|e| synaps_cli::RuntimeError::Config(format!("attach: {e}")))?;
174
175 let config: synaps_cli::SynapsConfig = (**host.config()).clone();
176 let registry = Arc::clone(host.command_registry());
177 let keybind_registry = Arc::clone(host.keybind_registry());
178 let mcp_server_count = host.mcp_server_count();
179 let system_prompt_path = synaps_cli::config::resolve_read_path("system.md");
180 let http = LazyHttp::from(host.parts().client.clone());
181 let ext_mgr_shared = Arc::clone(host.ext_manager());
182
183 let mut app = app_from_snapshot(&snapshot);
184 app.keybinds = Some(keybind_registry.clone());
185
186 for n in take_boot_notices() {
188 app.push_msg(ChatMessage::System(n));
189 }
190 for w in &config.warnings {
192 app.push_msg(ChatMessage::System(format!("⚠ config: {}", w)));
193 }
194
195 {
198 let has_anthropic = synaps_cli::auth::broker::anthropic_credential_available();
201 let has_provider_key =
202 !synaps_cli::auth::broker::configured_static_provider_keys().is_empty();
203 if !has_anthropic && !has_provider_key {
204 app.push_msg(ChatMessage::System(
205 "👋 No credentials found. To get started:\n • `synaps login` — sign in with Claude Pro/Max (OAuth)\n • or `synaps login --provider <name>` — store a provider API key with the credential broker (groq, openrouter, …), then pick with /model".to_string(),
206 ));
207 }
208 }
209
210 if mcp_server_count > 0 {
211 tracing::info!(
212 "{} MCP tools available from configured servers",
213 mcp_server_count
214 );
215 }
216
217 let mut ctx = finish_setup(
218 app,
219 Box::new(transport),
220 http,
221 TransportMode::Local { host },
222 config,
223 registry,
224 keybind_registry,
225 system_prompt_path,
226 Some(ext_mgr_shared),
227 )
228 .await?;
229
230 loop_arms::migrate_sidecar_toggle_key_to_claimed_plugins(&ctx.registry.lifecycle_claims());
232
233 if !no_extensions {
234 let session_id_for_hook = ctx.app.session.id.clone();
235 ctx.app.extension_loader_running = true;
236 ctx.app.toasts.upsert(
237 toast::Toast::new("extension-loader", "Discovering extensions…")
238 .titled("Extensions")
239 .at(toast::ToastPosition::TOP_CENTER)
240 .ttl(None),
241 );
242 synaps_cli::extensions::loader::spawn_discover_and_load(
247 std::sync::Arc::clone(ctx.ext_mgr_shared.as_ref().expect("local mode")),
248 ctx.app.extension_loader_tx.clone(),
249 Some(session_id_for_hook),
250 );
251 }
252 Ok(ctx)
253}
254
255pub(crate) fn app_from_snapshot(snapshot: &AttachSnapshot) -> App {
258 let conv = &snapshot.conversation;
259 let session = app::session_from_header(&conv.header);
260 let has_history = conv.messages_len > 0
264 || !conv.api_messages.is_empty()
265 || snapshot
266 .display_tail
267 .as_ref()
268 .is_some_and(|t| !t.items.is_empty());
269 let mut app = App::new_with_clock(session, clock::TuiClock::real());
270 if has_history {
271 app.apply_conversation(conv);
272 rebuild_display_from_snapshot(&mut app, snapshot);
273 }
274 if snapshot.meta.continued {
275 app.push_msg(ChatMessage::System(format!(
276 "resumed session {}",
277 conv.header.id
278 )));
279 if let Some(ref info) = snapshot.meta.continue_info {
280 if let Some(ref via) = info.resolved_via {
281 app.push_msg(ChatMessage::System(format!(
282 " ↳ resolved via {} '{}'",
283 via, info.query
284 )));
285 }
286 if let Some(ref notice) = info.compaction_notice {
287 app.push_msg(ChatMessage::System(format!(" ↳ {}", notice)));
288 }
289 }
290 }
291 app.last_turn_context_window = snapshot.view.context_window;
292 app
293}
294
295#[allow(clippy::too_many_arguments)]
298pub(crate) async fn finish_setup(
299 mut app: App,
300 transport: Box<dyn ClientTransport>,
301 http: LazyHttp,
302 mode: TransportMode,
303 config: synaps_cli::SynapsConfig,
304 registry: std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
305 keybind_registry: std::sync::Arc<
306 std::sync::RwLock<synaps_cli::skills::keybinds::KeybindRegistry>,
307 >,
308 system_prompt_path: std::path::PathBuf,
309 ext_mgr_shared: Option<
310 std::sync::Arc<tokio::sync::RwLock<synaps_cli::extensions::manager::ExtensionManager>>,
311 >,
312) -> Result<RunContext> {
313 let link = SessionLink::new(transport);
314 app.keybinds = Some(keybind_registry.clone());
315 let terminal = setup_terminal(None)?;
329 ladder_stage("terminal", &{
330 let (c, r) = crossterm::terminal::size().unwrap_or((0, 0));
331 format!("cols={c} rows={r}")
332 });
333
334 let t_caps = Instant::now();
353 let term_caps =
354 termcaps::negotiate(termcaps::TermCaps::detect(), termcaps::BURST_TIMEOUT).await;
355 ladder_stage("termcaps", &format_args!("burst_ms={}", t_caps.elapsed().as_millis()));
356 super::theme::set_terminal_default_bg(term_caps.terminal_bg);
360
361 let (render_handle, boot_done, exit_done) = spawn_render_thread(terminal, term_caps.clone());
365 ladder_stage("render_thread", &"");
366 if std::env::var("SYNAPS_NO_BOOT_FX").map_or(true, |v| v != "1") {
369 render_handle.send_boot_fx(boot_effect());
370 }
371
372 let event_reader = EventStream::new();
373 let (shutdown_signal_tx, shutdown_signal_rx) = tokio::sync::mpsc::unbounded_channel();
374 let shutdown_signal_task = signals::spawn_shutdown_signal_task_with(
375 shutdown_signal_tx,
376 signals::SignalBackend::for_socket(matches!(mode, TransportMode::Socket)),
377 );
378 ladder_stage("event_stream", &"");
379 let (secret_prompt_tx, secret_prompt_rx) = tokio::sync::mpsc::unbounded_channel();
380 let prompt_bridge = PromptBridge::new(secret_prompt_tx);
381 let secret_prompt_rx = std::sync::Arc::new(std::sync::Mutex::new(secret_prompt_rx));
382 let boot_fx_sent = std::env::var("SYNAPS_NO_BOOT_FX").map_or(true, |v| v != "1");
390 let exit_fx_sent = false;
391 let last_draw = Instant::now() - std::time::Duration::from_secs(1);
392
393 Ok(RunContext {
394 app,
395 link,
396 http,
397 prompt_bridge,
398 mode,
399 config,
400 registry,
401 keybind_registry,
402 system_prompt_path,
403 render_handle,
404 boot_done,
405 exit_done,
406 term_caps,
407 event_reader,
408 shutdown_signal_rx,
409 shutdown_signal_task,
410 secret_prompt_rx,
411 ext_mgr_shared,
412 boot_fx_sent,
413 exit_fx_sent,
414 last_draw,
415 })
416}
417
418pub(crate) async fn try_reconnect(
426 app: &mut App,
427 link: &mut SessionLink,
428 mode: &TransportMode,
429 render_handle: &super::render_thread::RenderHandle,
430 registry: &std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
431) -> bool {
432 match mode {
433 TransportMode::Local { .. } => false,
434 TransportMode::Socket => {
435 use agent_engine::session::socket_transport::SocketTransport;
436 use std::time::Duration;
437
438 let attach_mode = link.mode();
439 let is_reload = link.transport_mut().is_reload_pending();
442 let daemon_pid = link.transport_mut().daemon_pid();
443 let session_id = link.transport_mut().session_id().clone();
444 let budget = SocketTransport::reconnect_budget();
445 let budget_secs = budget.as_secs();
446
447 if app.streaming {
449 app.header_turn_ended(None);
450 app.streaming = false;
451 }
452 app.compacting = false;
453
454 if !is_reload {
456 app.push_msg(ChatMessage::System(format!(
457 "daemon connection lost — reconnecting ({budget_secs}s left)…"
458 )));
459 app.request_redraw();
460 force_publish(app, link, registry, render_handle);
461 }
462
463 let deadline = tokio::time::Instant::now() + budget;
464 let mut backoff = Duration::from_millis(100);
465 let mut attempts = 0u32;
466
467 loop {
468 match link.transport_mut().reconnect_once(attach_mode).await {
469 Ok(snapshot) => {
470 link.refresh_view();
471 let new_pid = link.transport_mut().daemon_pid();
472 let lifecycle = &snapshot.meta.lifecycle;
473 let status = format!("{lifecycle:?}");
474 remirror(app, &snapshot);
475 app.attached_clients = link.transport().roster().clients().len();
478 super::watch::on_reconnect(app, std::time::Instant::now());
479 app.push_msg(ChatMessage::System(format!(
480 "reconnected to daemon (pid {new_pid}) — session {} {status}",
481 session_id
482 )));
483 let skew = link.transport_mut().daemon_binary().and_then(|d| {
487 super::attach::skew_notice_once(app, d, snapshot.meta.id.as_str())
488 });
489 if let Some(skew) = skew {
490 app.push_msg(ChatMessage::System(format!("⚠ {skew}")));
491 }
492 app.toasts.upsert(
493 toast::Toast::new("reload", "reconnected").titled("Daemon"),
494 );
495 app.request_redraw();
496 return true;
497 }
498 Err(agent_engine::session::TransportError::Refused(_))
499 | Err(agent_engine::session::TransportError::Version { .. }) => {
500 break;
502 }
503 Err(_e) => {
504 attempts += 1;
505 }
506 }
507
508 if tokio::time::Instant::now() + backoff > deadline {
509 break;
510 }
511
512 if !is_reload {
514 let left = deadline.duration_since(tokio::time::Instant::now());
515 let secs_left = left.as_secs();
516 app.status_text = Some(format!(
517 "reconnecting… ({secs_left}s left, attempt {attempts})"
518 ));
519 app.request_redraw();
520 force_publish(app, link, registry, render_handle);
521 }
522
523 tokio::time::sleep(backoff).await;
524 backoff = (backoff * 2).min(Duration::from_secs(5));
525 }
526
527 app.status_text = None;
529 if !is_reload {
530 app.daemon_lost = Some(super::app::DaemonLostInfo {
531 pid: daemon_pid,
532 session_id: session_id.to_string(),
533 budget_secs,
534 });
535 }
536 app.push_msg(ChatMessage::Error(format!(
537 "lost the daemon (pid {daemon_pid}) — could not reconnect within {budget_secs}s"
538 )));
539 app.request_redraw();
540 false
541 }
542 }
543}
544
545fn force_publish(
548 app: &mut App,
549 link: &mut SessionLink,
550 registry: &std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
551 render_handle: &super::render_thread::RenderHandle,
552) {
553 if let Ok((w, h)) = crossterm::terminal::size() {
554 if w > 0 && h > 0 {
555 let size = ratatui::layout::Size { width: w, height: h };
556 let built = super::draw::build_render_model(
557 &mut super::view_model::ViewInputs::from_app(app),
558 &**link.view(),
559 registry,
560 size,
561 );
562 if let Some((model, patch)) = built {
563 patch.apply(app);
564 render_handle.publish(model);
565 }
566 }
567 }
568}
569
570fn rebuild_display_from_snapshot(app: &mut App, snapshot: &AttachSnapshot) {
573 match &snapshot.display_tail {
574 Some(tail) => apply_display_tail(tail, app),
575 None => rebuild_display_messages(&snapshot.conversation.api_messages, app),
576 }
577}
578
579pub(crate) fn remirror(app: &mut App, snapshot: &AttachSnapshot) {
582 app.transcript.clear();
583 app.invalidate();
584 app.apply_conversation(&snapshot.conversation);
585 rebuild_display_from_snapshot(app, snapshot);
586 app.streaming = snapshot.streaming;
587 app.last_turn_context_window = snapshot.view.context_window;
588}