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