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 name: None,
164 max_session_cost: None,
165 })
166 .await?;
167 let (transport, snapshot) =
168 LocalTransport::attach_with(handle, ClientMeta::new(ClientKind::Tui), AttachMode::Mirror)
169 .await
170 .map_err(|e| synaps_cli::RuntimeError::Config(format!("attach: {e}")))?;
171
172 let config: synaps_cli::SynapsConfig = (**host.config()).clone();
173 let registry = Arc::clone(host.command_registry());
174 let keybind_registry = Arc::clone(host.keybind_registry());
175 let mcp_server_count = host.mcp_server_count();
176 let system_prompt_path = synaps_cli::config::resolve_read_path("system.md");
177 let http = LazyHttp::from(host.parts().client.clone());
178 let ext_mgr_shared = Arc::clone(host.ext_manager());
179
180 let mut app = app_from_snapshot(&snapshot);
181 app.keybinds = Some(keybind_registry.clone());
182
183 for n in take_boot_notices() {
185 app.push_msg(ChatMessage::System(n));
186 }
187 for w in &config.warnings {
189 app.push_msg(ChatMessage::System(format!("⚠ config: {}", w)));
190 }
191
192 {
195 let has_anthropic = synaps_cli::auth::broker::anthropic_credential_available();
198 let has_provider_key =
199 !synaps_cli::auth::broker::configured_static_provider_keys().is_empty();
200 if !has_anthropic && !has_provider_key {
201 app.push_msg(ChatMessage::System(
202 "👋 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(),
203 ));
204 }
205 }
206
207 if mcp_server_count > 0 {
208 tracing::info!(
209 "{} MCP tools available from configured servers",
210 mcp_server_count
211 );
212 }
213
214 let mut ctx = finish_setup(
215 app,
216 Box::new(transport),
217 http,
218 TransportMode::Local { host },
219 config,
220 registry,
221 keybind_registry,
222 system_prompt_path,
223 Some(ext_mgr_shared),
224 )
225 .await?;
226
227 loop_arms::migrate_sidecar_toggle_key_to_claimed_plugins(&ctx.registry.lifecycle_claims());
229
230 if !no_extensions {
231 let session_id_for_hook = ctx.app.session.id.clone();
232 ctx.app.extension_loader_running = true;
233 ctx.app.toasts.upsert(
234 toast::Toast::new("extension-loader", "Discovering extensions…")
235 .titled("Extensions")
236 .at(toast::ToastPosition::TOP_CENTER)
237 .ttl(None),
238 );
239 synaps_cli::extensions::loader::spawn_discover_and_load(
244 std::sync::Arc::clone(ctx.ext_mgr_shared.as_ref().expect("local mode")),
245 ctx.app.extension_loader_tx.clone(),
246 Some(session_id_for_hook),
247 );
248 }
249 Ok(ctx)
250}
251
252pub(crate) fn app_from_snapshot(snapshot: &AttachSnapshot) -> App {
255 let conv = &snapshot.conversation;
256 let session = app::session_from_header(&conv.header);
257 let has_history = conv.messages_len > 0
261 || !conv.api_messages.is_empty()
262 || snapshot
263 .display_tail
264 .as_ref()
265 .is_some_and(|t| !t.items.is_empty());
266 let mut app = App::new_with_clock(session, clock::TuiClock::real());
267 if has_history {
268 app.apply_conversation(conv);
269 rebuild_display_from_snapshot(&mut app, snapshot);
270 }
271 if snapshot.meta.continued {
272 app.push_msg(ChatMessage::System(format!(
273 "resumed session {}",
274 conv.header.id
275 )));
276 if let Some(ref info) = snapshot.meta.continue_info {
277 if let Some(ref via) = info.resolved_via {
278 app.push_msg(ChatMessage::System(format!(
279 " ↳ resolved via {} '{}'",
280 via, info.query
281 )));
282 }
283 if let Some(ref notice) = info.compaction_notice {
284 app.push_msg(ChatMessage::System(format!(" ↳ {}", notice)));
285 }
286 }
287 }
288 app.last_turn_context_window = snapshot.view.context_window;
289 app
290}
291
292#[allow(clippy::too_many_arguments)]
295pub(crate) async fn finish_setup(
296 mut app: App,
297 transport: Box<dyn ClientTransport>,
298 http: LazyHttp,
299 mode: TransportMode,
300 config: synaps_cli::SynapsConfig,
301 registry: std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
302 keybind_registry: std::sync::Arc<
303 std::sync::RwLock<synaps_cli::skills::keybinds::KeybindRegistry>,
304 >,
305 system_prompt_path: std::path::PathBuf,
306 ext_mgr_shared: Option<
307 std::sync::Arc<tokio::sync::RwLock<synaps_cli::extensions::manager::ExtensionManager>>,
308 >,
309) -> Result<RunContext> {
310 let link = SessionLink::new(transport);
311 app.keybinds = Some(keybind_registry.clone());
312 let terminal = setup_terminal(None)?;
326 ladder_stage("terminal", &{
327 let (c, r) = crossterm::terminal::size().unwrap_or((0, 0));
328 format!("cols={c} rows={r}")
329 });
330
331 let t_caps = Instant::now();
350 let term_caps =
351 termcaps::negotiate(termcaps::TermCaps::detect(), termcaps::BURST_TIMEOUT).await;
352 ladder_stage("termcaps", &format_args!("burst_ms={}", t_caps.elapsed().as_millis()));
353
354 let (render_handle, boot_done, exit_done) = spawn_render_thread(terminal, term_caps.clone());
358 ladder_stage("render_thread", &"");
359 if std::env::var("SYNAPS_NO_BOOT_FX").map_or(true, |v| v != "1") {
362 render_handle.send_boot_fx(boot_effect());
363 }
364
365 let event_reader = EventStream::new();
366 let (shutdown_signal_tx, shutdown_signal_rx) = tokio::sync::mpsc::unbounded_channel();
367 let shutdown_signal_task = signals::spawn_shutdown_signal_task_with(
368 shutdown_signal_tx,
369 signals::SignalBackend::for_socket(matches!(mode, TransportMode::Socket)),
370 );
371 ladder_stage("event_stream", &"");
372 let (secret_prompt_tx, secret_prompt_rx) = tokio::sync::mpsc::unbounded_channel();
373 let prompt_bridge = PromptBridge::new(secret_prompt_tx);
374 let secret_prompt_rx = std::sync::Arc::new(std::sync::Mutex::new(secret_prompt_rx));
375 let boot_fx_sent = std::env::var("SYNAPS_NO_BOOT_FX").map_or(true, |v| v != "1");
383 let exit_fx_sent = false;
384 let last_draw = Instant::now() - std::time::Duration::from_secs(1);
385
386 Ok(RunContext {
387 app,
388 link,
389 http,
390 prompt_bridge,
391 mode,
392 config,
393 registry,
394 keybind_registry,
395 system_prompt_path,
396 render_handle,
397 boot_done,
398 exit_done,
399 term_caps,
400 event_reader,
401 shutdown_signal_rx,
402 shutdown_signal_task,
403 secret_prompt_rx,
404 ext_mgr_shared,
405 boot_fx_sent,
406 exit_fx_sent,
407 last_draw,
408 })
409}
410
411pub(crate) async fn try_reconnect(
419 app: &mut App,
420 link: &mut SessionLink,
421 mode: &TransportMode,
422 render_handle: &super::render_thread::RenderHandle,
423 registry: &std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
424) -> bool {
425 match mode {
426 TransportMode::Local { .. } => false,
427 TransportMode::Socket => {
428 use agent_engine::session::socket_transport::SocketTransport;
429 use std::time::Duration;
430
431 let attach_mode = link.mode();
432 let is_reload = link.transport_mut().is_reload_pending();
435 let daemon_pid = link.transport_mut().daemon_pid();
436 let session_id = link.transport_mut().session_id().clone();
437 let budget = SocketTransport::reconnect_budget();
438 let budget_secs = budget.as_secs();
439
440 if app.streaming {
442 app.streaming = false;
443 }
444 app.compacting = false;
445
446 if !is_reload {
448 app.push_msg(ChatMessage::System(format!(
449 "daemon connection lost — reconnecting ({budget_secs}s left)…"
450 )));
451 app.request_redraw();
452 force_publish(app, link, registry, render_handle);
453 }
454
455 let deadline = tokio::time::Instant::now() + budget;
456 let mut backoff = Duration::from_millis(100);
457 let mut attempts = 0u32;
458
459 loop {
460 match link.transport_mut().reconnect_once(attach_mode).await {
461 Ok(snapshot) => {
462 link.refresh_view();
463 let new_pid = link.transport_mut().daemon_pid();
464 let lifecycle = &snapshot.meta.lifecycle;
465 let status = format!("{lifecycle:?}");
466 remirror(app, &snapshot);
467 app.push_msg(ChatMessage::System(format!(
468 "reconnected to daemon (pid {new_pid}) — session {} {status}",
469 session_id
470 )));
471 app.toasts.upsert(
472 toast::Toast::new("reload", "reconnected").titled("Daemon"),
473 );
474 app.request_redraw();
475 return true;
476 }
477 Err(agent_engine::session::TransportError::Refused(_))
478 | Err(agent_engine::session::TransportError::Version { .. }) => {
479 break;
481 }
482 Err(_e) => {
483 attempts += 1;
484 }
485 }
486
487 if tokio::time::Instant::now() + backoff > deadline {
488 break;
489 }
490
491 if !is_reload {
493 let left = deadline.duration_since(tokio::time::Instant::now());
494 let secs_left = left.as_secs();
495 app.status_text = Some(format!(
496 "reconnecting… ({secs_left}s left, attempt {attempts})"
497 ));
498 app.request_redraw();
499 force_publish(app, link, registry, render_handle);
500 }
501
502 tokio::time::sleep(backoff).await;
503 backoff = (backoff * 2).min(Duration::from_secs(5));
504 }
505
506 app.status_text = None;
508 if !is_reload {
509 app.daemon_lost = Some(super::app::DaemonLostInfo {
510 pid: daemon_pid,
511 session_id: session_id.to_string(),
512 budget_secs,
513 });
514 }
515 app.push_msg(ChatMessage::Error(format!(
516 "lost the daemon (pid {daemon_pid}) — could not reconnect within {budget_secs}s"
517 )));
518 app.request_redraw();
519 false
520 }
521 }
522}
523
524fn force_publish(
527 app: &mut App,
528 link: &mut SessionLink,
529 registry: &std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
530 render_handle: &super::render_thread::RenderHandle,
531) {
532 if let Ok((w, h)) = crossterm::terminal::size() {
533 if w > 0 && h > 0 {
534 let size = ratatui::layout::Size { width: w, height: h };
535 let built = super::draw::build_render_model(
536 &mut super::view_model::ViewInputs::from_app(app),
537 &**link.view(),
538 registry,
539 size,
540 );
541 if let Some((model, patch)) = built {
542 patch.apply(app);
543 render_handle.publish(model);
544 }
545 }
546 }
547}
548
549fn rebuild_display_from_snapshot(app: &mut App, snapshot: &AttachSnapshot) {
552 match &snapshot.display_tail {
553 Some(tail) => apply_display_tail(tail, app),
554 None => rebuild_display_messages(&snapshot.conversation.api_messages, app),
555 }
556}
557
558pub(crate) fn remirror(app: &mut App, snapshot: &AttachSnapshot) {
561 app.transcript.clear();
562 app.invalidate();
563 app.apply_conversation(&snapshot.conversation);
564 rebuild_display_from_snapshot(app, snapshot);
565 app.streaming = snapshot.streaming;
566 app.last_turn_context_window = snapshot.view.context_window;
567}