//! Interactive mode wiring: resolve the daemon socket, ensure a supervisor is //! listening, pick the session from the CLI session flags, and hand off to the //! pa-tui interactive loop; the session keeps running in the worker after exit. use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; use anyhow::{anyhow, Context, Result}; use crate::config; use crate::mode::RunOptions; use pa_core::session::discovery::{resolve_session_path, ResolvedSession}; use pa_tui::interactive::{InteractiveOptions, ModelSelection, SessionSelection, UiMode}; mod daemon; pub use daemon::{ensure_daemon_running, ensure_daemon_running_with}; mod onboarding; use onboarding::onboarding_task; #[cfg(test)] use onboarding::SettingsOnboardingSink; #[cfg(test)] mod tests; /// Run the interactive TUI attached to the daemon. Returns the exit code. pub fn run_interactive_mode(options: &RunOptions) -> Result { let socket_path = resolve_socket_path(options.daemon_socket.as_deref()); let tui_options = build_tui_options( options, socket_path, std::sync::Arc::new(std::sync::Mutex::new( pa_tui::prompt_stash::PromptStashStore::default(), )), )?; let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .context("build the interactive runtime")?; runtime.block_on(async { ensure_daemon_running(&tui_options.socket_path, &tui_options.cwd).await?; // `prime-agent agents` and bare `--resume` open the agents view // (TS `agentsViewRequested`); the view then opens sessions, and a // session exits back into the view until the user exits it. TS gates // the explicit `agents` request on completed onboarding (a fresh // install shows the first-run notice first); bare `--resume` opens // the view regardless. `--continue` joins them when a candidate // session exists: the view opens preselected on the newest saved // session for the cwd (the notice names it) so the user confirms // what continues instead of a blind newest-resume. let continue_view = continue_recent_view(options, tui_options.onboarding.is_some()); let agents_view = should_open_agents_view( options, tui_options.onboarding.is_some(), continue_view.is_some(), ); let run = async { if agents_view { let (anchor, notice) = continue_view.map_or((None, None), |view| { (Some(view.session_id), Some(view.notice)) }); run_agents_view_flow(tui_options, anchor, notice).await } else { let outcome = pa_tui::interactive::run_interactive(tui_options.clone(), UiMode::Terminal) .await?; // TS `main.ts`: a direct session run closes into the agents view // when the exit came through agents-back or `/resume` // (`launchAgentsView` anchored on the session just left); every // other exit (ctrl+c/ctrl+d, `/quit`) ends the process. if outcome.return_to_agents_view { // A startup attach that fell back to the view has no session // identity to anchor on; its notice seeds the view's status // line instead. let anchor = (!outcome.session_id.is_empty()).then(|| outcome.session_id.clone()); run_agents_view_flow(tui_options, anchor, outcome.agents_view_notice).await } else { print_resume_hint(outcome.resume_hint.as_deref()); Ok(()) } } }; run.await })?; // tmux (verified on 3.2a) can drop the pane's final output when the process // dies immediately after writing it; holding briefly lets the terminal apply it. std::thread::sleep(Duration::from_millis(300)); Ok(0) } /// TS `shutdown` prints the dim resume hint (`formatResumeHint`) to stdout /// after the TUI is restored; agents-view returns suppress it. Dim is the /// TS `chalk.dim` styling (`ESC[2m` ... `ESC[22m`). The print is the last /// exit-path write before the fixed pre-exit delay below, and on a slow /// terminal it blocks behind the flush still draining the pty: its /// completion is exit-path progress (the exit guard's watchdog holds its /// force-quit while progress lands — a draining terminal is not a stalled /// shutdown), and the stamp it leaves carries the fixed delay inside the /// guard's grace window. fn print_resume_hint(hint: Option<&str>) { if let Some(hint) = hint { println!("\x1b[2m{hint}\x1b[22m"); } pa_tui::exit_guard::note_exit_progress(); } /// The agents-view loop: open the view, run the session it opens, and return /// to the view on agents-back or bare `/resume`; every other exit ends the app. async fn run_agents_view_flow( base: InteractiveOptions, anchor: Option, notice: Option, ) -> Result<()> { let mut anchor = anchor; // Every view run reuses the roster connection (a chat run hands it // back), so a switch back from a chat skips the handshake. let mut roster_link: Option = None; let mut frames: Vec<( pa_tui::agents_view::AgentsViewScope, Option, )> = Vec::new(); let mut query: Option = None; // A dismissal survives the next view run. let mut incident_notice_state: Option = None; let mut expanded_ancestors: Vec = Vec::new(); let mut selected_row_identity: Option = None; let mut selected_key: Option = None; let mut status_message: Option = notice; loop { let view_options = pa_tui::agents_view::AgentsViewOptions { socket_path: base.socket_path.clone(), cwd: base.cwd.clone(), session_dir: base.session_dir.clone(), theme: base.theme.clone(), version: base.version.clone(), anchor_session_id: anchor.clone(), scope: frames.last().map(|(scope, _)| scope.clone()), query: query.clone(), expanded_ancestors: expanded_ancestors.clone(), selected_row_identity: selected_row_identity.clone(), selected_key: selected_key.clone(), status_message: status_message.take(), keybindings: base.keybindings.clone(), show_hardware_cursor: base .client_settings .as_ref() .is_some_and(|settings| settings.show_hardware_cursor()), incident_notice_state: incident_notice_state.take(), // The base a saved reply's resume derives from. create_config: base.create_config(), }; let view_run = pa_tui::agents_view::run_agents_view( view_options, pa_tui::agents_view::AgentsViewUiMode::Terminal, roster_link.take(), ) .await?; let view = view_run.outcome; roster_link = view_run.link; // A dropped scope root or the view's parent key pops the frame; // both clear the query. let scope_frame_popped = view.scope_dropped || view.scope_popped; if scope_frame_popped { frames.pop(); } let Some(selection) = view.selection else { return Ok(()); }; expanded_ancestors = view.expanded_ancestors.clone(); selected_row_identity = view.selected_row_identity.clone(); selected_key = view.selected_key.clone(); status_message = view.status_message.clone(); incident_notice_state = Some(view.incident_notice_state); query = if scope_frame_popped { None } else { view.query }; let mut session_options = base.clone(); session_options.session = selection; session_options.session_rlm_depth = view.opened_rlm_depth; session_options.session_has_children = view.opened_has_children; // A scoped panel's own exit reopens the chat with the dock focused // on the Subagents item, not the prompt bar. session_options.restore_dock_focus = view.scope_back; // The session run anchors its cwd (and the file-completion base) // on the attached session's directory, not the launch directory. if let Some(cwd) = view.opened_cwd { session_options.cwd = cwd; } let outcome = pa_tui::interactive::run_interactive_agents_view_open( session_options, UiMode::Terminal, ) .await?; if !outcome.session_id.is_empty() { anchor = Some(outcome.session_id.clone()); } if let Some(notice) = &outcome.agents_view_notice { status_message = Some(notice.clone()); } if !outcome.return_to_agents_view { print_resume_hint(outcome.resume_hint.as_deref()); if let Some(link) = roster_link.take() { link.close(); } return Ok(()); } if let Some(scope) = outcome.agents_view_scope { // Push a frame with the session as the return chat and clear the // query — a filter typed to find the session would hide the subtree. frames.retain(|(frame, _)| frame.session_id != scope.session_id); frames.push(( scope, Some(SessionSelection::Attach(outcome.active_session_id.clone())), )); query = None; } let mut pending = outcome.selection_request; while let Some(selection) = pending.take() { let mut next = base.clone(); next.session = selection; let outcome = pa_tui::interactive::run_interactive(next, UiMode::Terminal).await?; if !outcome.session_id.is_empty() { anchor = Some(outcome.session_id.clone()); } if let Some(notice) = &outcome.agents_view_notice { status_message = Some(notice.clone()); } if !outcome.return_to_agents_view { print_resume_hint(outcome.resume_hint.as_deref()); if let Some(link) = roster_link.take() { link.close(); } return Ok(()); } if let Some(scope) = outcome.agents_view_scope { frames.retain(|(frame, _)| frame.session_id != scope.session_id); frames.push(( scope, Some(SessionSelection::Attach(outcome.active_session_id.clone())), )); query = None; } pending = outcome.selection_request; } } } /// `--daemon-socket` value, the `PRIME_AGENT_DAEMON_SOCKET` environment, /// or the per-user default socket path (precedence in that order). #[must_use] pub fn resolve_socket_path(daemon_socket: Option<&str>) -> PathBuf { config::resolve_daemon_socket_path(daemon_socket) } fn build_tui_options( options: &RunOptions, socket_path: PathBuf, prompt_stash: std::sync::Arc>, ) -> Result { let config = &options.config; let session_dir = options .session .session_dir .clone() .or_else(|| Some(config.agent_dir.join("sessions"))); if let Err(error) = pa_tui::keybindings::migrate_keybindings_file(&config.agent_dir) { // A failed migration never blocks startup: the manager below // falls back to the previous file contents (or the defaults). eprintln!("Warning: could not migrate keybindings: {error:#}"); } let keybindings = pa_tui::keybindings::KeybindingsManager::create(&config.agent_dir); // Test seam: a scripted faux daemon session; never set by the product. let script_path = std::env::var_os("PRIME_AGENT_FAUX_SCRIPT").map(PathBuf::from); // Flag order (fork -> resume -> create): the daemon opens the fork — // never the source — through the create `sessionPath`. let mut session = match &options.session.fork { Some(selector) => fork_startup_selection(selector, &config.cwd, session_dir.as_deref())?, None => session_selection(&options.session, session_dir.as_deref()), }; // A plain resume follows the saved project. Resolve its file before // changing cwd, and use the same project for client settings and the // daemon create config. Explicit --cwd and --fork retain their directory. let mut resolved_options = options.clone(); if !options.session.cwd_from_flag && options.session.fork.is_none() { if let SessionSelection::Resume(path) = &mut session { *path = std::path::absolute(&*path)?; if let Some(header) = pa_core::session::manager::read_session_header(path) .filter(|header| !header.cwd.is_empty()) { if !Path::new(&header.cwd).is_dir() { return Err(anyhow!( "Saved session directory is unavailable: {}. Supply --cwd to reopen elsewhere.", header.cwd )); } resolved_options.config.cwd = PathBuf::from(header.cwd); } } } let options = &resolved_options; let config = &options.config; let settings = pa_core::settings::SettingsManager::create(&config.cwd, &config.agent_dir); let code_block_indent = settings.get_code_block_indent(); let show_images = settings.get_show_images(); let fullscreen_mouse = settings.get_fullscreen_mouse(); let tree_filter_mode = settings.get_tree_filter_mode(); let branch_summary_skip_prompt = settings.get_branch_summary_skip_prompt(); // A startup snapshot of the available models drives the `/model` picker // (entitlement refreshes run daemon-side); models.json custom models too. let auth = pa_core::auth::AuthStorage::create(&config.agent_dir); let mut registry = pa_core::models::ModelRegistry::create_for_agent_dir(auth, &config.agent_dir); registry.load_private_authorization_from_cache(); let catalog: Vec = registry.get_available().into_iter().cloned().collect(); let configured_providers: std::collections::HashSet = catalog.iter().map(|model| model.provider.clone()).collect(); let settings = pa_core::settings::SettingsManager::create(&config.cwd, &config.agent_dir); let recent = settings.get_recent_models(); let default_thinking_level = settings .get_default_thinking_level() .map(|level| level.model_level().wire_name().to_string()); let provider_auth = pa_tui::provider_auth::ProviderAuthCommandsHandle(std::sync::Arc::new( crate::provider_login::ProviderAuth::new(config.cwd.clone(), config.agent_dir.clone()), )); let onboarding = onboarding_task(options, Some(provider_auth.clone())); let tui_options_value = InteractiveOptions { code_block_indent, tree_filter_mode, branch_summary_skip_prompt, model_catalog: catalog, model_configured_providers: configured_providers, model_recent_models: recent, default_thinking_level, socket_path, cwd: config.cwd.clone(), session_dir, script_path, // Explicit CLI model flags ride every create request; the daemon // worker must treat them as authoritative. model_selection: ModelSelection { provider: config.provider.clone(), model: config.model.clone(), api_key: config.api_key.clone(), // The worker clamps `--thinking` to the model's levels. thinking: config.thinking, }, // The raw `--models` patterns ride the create config (the daemon // owns the resolution). models: config.models.clone(), no_session: options.session.no_session, session, initial_message: options.initial_message.clone(), show_images, fullscreen_mouse, theme: settings.get_theme().map(str::to_string).unwrap_or_default(), // The client-settings seam the interactive commands persist // through (`/settings`). client_settings: Some(crate::client_settings::CliClientSettings::new( config.cwd.clone(), config.agent_dir.clone(), )), version: crate::config::version().to_string(), onboarding, // `/mcp login` / `/mcp logout`: the client-side auth flows run in // this process (the TS interactive client's placement) and persist // through the shared auth store the daemon's sessions read. client_auth: Some(pa_tui::client_auth::ClientAuthCommandsHandle( std::sync::Arc::new(crate::mcp_login::TerminalMcpAuth::new( config.cwd.clone(), config.agent_dir.clone(), )), )), // `/update`: the same body `prime-agent update` runs, output // captured — the TUI stays mounted. update_commands: Some(pa_tui::update_command::UpdateCommandsHandle( std::sync::Arc::new(crate::client_update::ClientUpdate), )), provider_auth: Some(provider_auth), keybindings, // One process-wide prompt stash store, so the agents-view loop // keeps stashed drafts alive across its chat runs. prompt_stash, // A direct CLI session is a root run (RLM depth metadata comes // from the agents view). session_rlm_depth: None, session_has_children: false, restore_dock_focus: false, }; Ok(tui_options_value) } /// Map the CLI session flags onto the TUI session selection: explicit `--resume` /// selector, then a fresh session (`--continue` goes through the agents view). fn session_selection( session: &crate::mode::SessionOptions, session_dir: Option<&Path>, ) -> SessionSelection { if let Some(selector) = &session.resume { let default_dir = config::get_agent_dir().join("sessions"); let dir = session_dir.unwrap_or(&default_dir); return resolve_resume_selector(selector, dir); } SessionSelection::New } /// The interactive launch's fork arm: copy the source into a fresh session file /// client-side ([`pa_core::session::manager::SessionManager::fork_from`]) and /// hand the daemon the fork — never the source. /// /// # Errors /// /// A selector that matches nothing, or the `forkFrom` failures (an empty or /// headerless source file). fn fork_startup_selection( selector: &str, cwd: &Path, session_dir: Option<&Path>, ) -> Result { let default_dir = config::get_agent_dir().join("sessions"); let dir = session_dir.unwrap_or(&default_dir); let expanded = config::expand_tilde_path(selector); let selector = expanded.to_string_lossy(); let resolved = resolve_session_path(&selector, cwd, dir) .map_err(|error| anyhow!(crate::print_runtime::render_selector_error(&error)))?; let source = match resolved { ResolvedSession::Path(path) | ResolvedSession::Local(path) | ResolvedSession::Global { path, .. } => path, }; let forked = pa_core::session::manager::SessionManager::fork_from(&source, cwd, dir) .map_err(anyhow::Error::msg)?; let fork_file = forked .get_session_file() .map(Path::to_path_buf) .ok_or_else(|| { anyhow!( "Cannot fork: the forked session file is missing: {}", source.display() ) })?; Ok(SessionSelection::Resume(fork_file)) } /// A selector, continuation, or fork opens its target session directly; only the /// explicit `agents` request needs the onboarding guard. fn should_open_agents_view( options: &RunOptions, onboarding_pending: bool, continue_view: bool, ) -> bool { options.session.resume_bare || (options.agents_view_requested && !onboarding_pending && options.session.fork.is_none()) || continue_view } /// The `--continue` launch's agents-view target: the newest saved session /// for the cwd, plus the status-line notice that names it. #[derive(Debug, Clone, PartialEq, Eq)] struct ContinueRecentView { session_id: String, notice: String, } /// Resolve the `--continue` launch into an agents-view opening: never a blind /// newest-resume (a sanctioned divergence — TS reopens the candidate silently). fn continue_recent_view( options: &RunOptions, onboarding_pending: bool, ) -> Option { if !options.session.continue_recent || options.session.resume.is_some() || options.session.no_session || onboarding_pending { return None; } let session_dir = options .session .session_dir .clone() .unwrap_or_else(|| options.config.agent_dir.join("sessions")); let cwd = options.config.cwd.clone(); let path = pa_core::session::discovery::find_most_recent_session_for_cwd(&session_dir, &cwd)?; let header = pa_core::session::manager::read_session_header(&path)?; Some(ContinueRecentView { session_id: header.id.clone(), notice: format!( "Most recent session for this directory: {} — Enter continues it, or pick another session.", header.id ), }) } /// `--resume `: an existing session file path, a `.jsonl` under /// the sessions dir, or a live daemon session id (attach). fn resolve_resume_selector(selector: &str, session_dir: &Path) -> SessionSelection { let path = config::expand_tilde_path(selector); if path.is_file() { return SessionSelection::Resume(path); } let candidate = session_dir.join(format!("{selector}.jsonl")); if candidate.is_file() { return SessionSelection::Resume(candidate); } SessionSelection::Attach(selector.to_string()) }