seher-sdk 0.0.32

Seher SDK: agent resolution, rate-limit checks, and provider clients
Documentation
use std::time::{SystemTime, UNIX_EPOCH};

use super::command::{BuildClaudeCommandOptions, build_claude_command};
use super::detect::{build_needles, detect_session_limit, paste_is_consumed};
use super::normalizer::normalize_text;
use super::transcript::{FileSystemTranscriptReader, default_transcript_root};
use super::types::{
    ClaudeTerminalError, ClaudeTerminalResponse, ClaudeTranscriptReader, FindClaudeSessionOptions,
    TerminalBackend, TerminalSession, TerminalStartOptions, WaitForAssistantResponseOptions,
};

const DEFAULT_TIMEOUT_MS: u64 = 15 * 60 * 1000;
const DEFAULT_POLL_INTERVAL_MS: u64 = 500;
const DEFAULT_READY_TIMEOUT_MS: u64 = 30 * 1000;
const DEFAULT_PASTE_VISIBLE_TIMEOUT_MS: u64 = 90 * 1000;
const DEFAULT_READY_POLL_INTERVAL_MS: u64 = 100;
const DEFAULT_READY_INDICATOR: &str = "";
const DEFAULT_PERMISSION_MODE: &str = "bypassPermissions";
const CAPTURE_FAILURE_LIMIT: usize = 3;

#[derive(Default)]
pub struct ClaudeTerminalSdkConfig {
    pub cwd: Option<String>,
    pub timeout_ms: Option<u64>,
    pub ready_timeout_ms: Option<u64>,
    pub paste_visible_timeout_ms: Option<u64>,
    pub poll_interval_ms: Option<u64>,
    pub ready_poll_interval_ms: Option<u64>,
    pub claude_bin: Option<String>,
    pub transcript_root: Option<String>,
    pub permission_mode: Option<String>,
    pub model: Option<String>,
    pub system_prompt: Option<String>,
    pub keep_session: bool,
    pub ready_indicator: Option<String>,
    /// Required. Use [`new_sdk_with_defaults`] to get a pre-wired instance.
    pub backend: Option<Box<dyn TerminalBackend>>,
    /// Required. Use [`new_sdk_with_defaults`] to get a pre-wired instance.
    pub reader: Option<Box<dyn ClaudeTranscriptReader>>,
}

pub struct ClaudeTerminalSdk {
    config: ClaudeTerminalSdkConfig,
}

impl ClaudeTerminalSdk {
    #[must_use]
    pub fn new(config: ClaudeTerminalSdkConfig) -> Self {
        Self { config }
    }

    /// Execute a prompt and return the full text response.
    ///
    /// # Errors
    ///
    /// Returns `ClaudeTerminalError` on tmux/spawn failures, timeouts, or session-limit.
    pub fn run(&self, prompt: &str) -> Result<String, ClaudeTerminalError> {
        let response = self.execute(prompt)?;
        Ok(normalize_text(&response))
    }

    fn now() -> u64 {
        let ms = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_millis();
        u64::try_from(ms).unwrap_or(u64::MAX)
    }

    fn execute(&self, prompt: &str) -> Result<ClaudeTerminalResponse, ClaudeTerminalError> {
        let cwd = self.config.cwd.clone().unwrap_or_else(|| {
            std::env::current_dir()
                .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().into_owned())
        });
        let timeout_ms = self.config.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
        let poll_ms = self
            .config
            .poll_interval_ms
            .unwrap_or(DEFAULT_POLL_INTERVAL_MS);
        let ready_timeout_ms = self
            .config
            .ready_timeout_ms
            .unwrap_or(DEFAULT_READY_TIMEOUT_MS);
        let paste_visible_ms = self
            .config
            .paste_visible_timeout_ms
            .unwrap_or(DEFAULT_PASTE_VISIBLE_TIMEOUT_MS);
        let ready_poll_ms = self
            .config
            .ready_poll_interval_ms
            .unwrap_or(DEFAULT_READY_POLL_INTERVAL_MS);
        let ready_indicator = self
            .config
            .ready_indicator
            .as_deref()
            .unwrap_or(DEFAULT_READY_INDICATOR);
        let transcript_root = self
            .config
            .transcript_root
            .clone()
            .unwrap_or_else(default_transcript_root);

        let command = build_claude_command(&BuildClaudeCommandOptions {
            claude_bin: self
                .config
                .claude_bin
                .clone()
                .unwrap_or_else(|| "claude".to_string()),
            permission_mode: self
                .config
                .permission_mode
                .clone()
                .unwrap_or_else(|| DEFAULT_PERMISSION_MODE.to_string()),
            model: self.config.model.clone(),
            system_prompt: self.config.system_prompt.clone(),
        })?;

        let exclude_names = self.reader().list_session_names(&transcript_root, &cwd)?;
        let started_at_ms = Self::now();

        let session = self.backend().start(TerminalStartOptions {
            cwd: cwd.clone(),
            command,
            env: None,
        })?;

        let result = self.run_session(
            &session,
            prompt,
            &cwd,
            &transcript_root,
            &exclude_names,
            started_at_ms,
            timeout_ms,
            poll_ms,
            ready_timeout_ms,
            paste_visible_ms,
            ready_poll_ms,
            ready_indicator,
        );

        if !self.config.keep_session {
            let _ = self.backend().stop(&session);
        }

        result
    }

    #[expect(
        clippy::too_many_arguments,
        reason = "timeline parameters mirror the TS design"
    )]
    fn run_session(
        &self,
        session: &TerminalSession,
        prompt: &str,
        cwd: &str,
        transcript_root: &str,
        exclude_names: &std::collections::HashSet<String>,
        started_at_ms: u64,
        timeout_ms: u64,
        poll_ms: u64,
        ready_timeout_ms: u64,
        paste_visible_ms: u64,
        ready_poll_ms: u64,
        ready_indicator: &str,
    ) -> Result<ClaudeTerminalResponse, ClaudeTerminalError> {
        self.wait_for_ready(session, ready_indicator, ready_timeout_ms, ready_poll_ms)?;
        self.backend().paste_text(session, prompt)?;
        self.wait_for_paste_visible(session, prompt, paste_visible_ms, ready_poll_ms)?;
        self.backend().submit(session)?;

        let session_ref = self.reader().find_session(FindClaudeSessionOptions {
            cwd: cwd.to_string(),
            after_ms: started_at_ms,
            timeout_ms,
            poll_interval_ms: poll_ms,
            root: transcript_root.to_string(),
            exclude_names: exclude_names.clone(),
        })?;

        self.reader().wait_for_assistant_response(
            &session_ref,
            WaitForAssistantResponseOptions {
                timeout_ms,
                poll_interval_ms: poll_ms,
            },
        )
    }

    /// Capture the screen, retrying up to `CAPTURE_FAILURE_LIMIT` times on error.
    /// Returns `Ok(None)` when the capture failed (error consumed into counter) and
    /// `Err` when the failure limit is reached.
    fn capture_with_retry(
        &self,
        session: &TerminalSession,
        consecutive_failures: &mut usize,
        context: &str,
    ) -> Result<Option<String>, ClaudeTerminalError> {
        match self.backend().capture_screen(session) {
            Ok(s) => {
                *consecutive_failures = 0;
                Ok(Some(s))
            }
            Err(e) => {
                *consecutive_failures += 1;
                if *consecutive_failures >= CAPTURE_FAILURE_LIMIT {
                    return Err(ClaudeTerminalError::Other(format!(
                        "captureScreen failed {consecutive_failures} times in a row while {context}: {e}"
                    )));
                }
                Ok(None)
            }
        }
    }

    fn wait_for_ready(
        &self,
        session: &TerminalSession,
        indicator: &str,
        timeout_ms: u64,
        poll_ms: u64,
    ) -> Result<(), ClaudeTerminalError> {
        let deadline = Self::now().saturating_add(timeout_ms);
        let mut consecutive_failures = 0usize;
        loop {
            let screen = self
                .capture_with_retry(
                    session,
                    &mut consecutive_failures,
                    "waiting for Claude TUI to render",
                )?
                .unwrap_or_default();

            // Check for session limit BEFORE checking the ready indicator
            if let Some(reset_info) = detect_session_limit(&screen) {
                return Err(ClaudeTerminalError::SessionLimit { reset_info });
            }
            if screen.contains(indicator) {
                return Ok(());
            }
            if Self::now() >= deadline {
                return Err(ClaudeTerminalError::Timeout(format!(
                    "timed out waiting for Claude TUI to render (no \"{indicator}\" within {timeout_ms}ms)"
                )));
            }
            std::thread::sleep(std::time::Duration::from_millis(poll_ms));
        }
    }

    fn wait_for_paste_visible(
        &self,
        session: &TerminalSession,
        prompt: &str,
        timeout_ms: u64,
        poll_ms: u64,
    ) -> Result<(), ClaudeTerminalError> {
        let needles = build_needles(prompt);
        // Empty-needle short-circuit (whitespace-only / empty prompt)
        if paste_is_consumed("", &needles) {
            return Ok(());
        }
        let deadline = Self::now().saturating_add(timeout_ms);
        let mut consecutive_failures = 0usize;
        loop {
            let screen = self
                .capture_with_retry(
                    session,
                    &mut consecutive_failures,
                    "waiting for pasted prompt to render",
                )?
                .unwrap_or_default();

            if paste_is_consumed(&screen, &needles) {
                return Ok(());
            }
            if Self::now() >= deadline {
                return Err(ClaudeTerminalError::Timeout(format!(
                    "timed out waiting for pasted prompt to appear in Claude TUI within {timeout_ms}ms \
                     (prefix {prefix_len} chars, suffix {suffix_len} chars)",
                    prefix_len = needles.prefix.len(),
                    suffix_len = needles.suffix.len(),
                )));
            }
            std::thread::sleep(std::time::Duration::from_millis(poll_ms));
        }
    }

    fn backend(&self) -> &dyn TerminalBackend {
        self.config.backend.as_deref().unwrap_or_else(|| {
            panic!("ClaudeTerminalSdk: no backend set — use new_sdk_with_defaults()")
        })
    }

    fn reader(&self) -> &dyn ClaudeTranscriptReader {
        self.config.reader.as_deref().unwrap_or_else(|| {
            panic!("ClaudeTerminalSdk: no reader set — use new_sdk_with_defaults()")
        })
    }
}

/// Convenience builder that wires up real `TmuxBackend` + `FileSystemTranscriptReader`.
#[must_use]
pub fn new_sdk_with_defaults(
    claude_bin: Option<String>,
    tmux_bin: Option<String>,
    model: Option<String>,
    system_prompt: Option<String>,
    timeout_ms: Option<u64>,
    cwd: Option<String>,
) -> ClaudeTerminalSdk {
    use super::tmux_backend::TmuxBackend;
    ClaudeTerminalSdk::new(ClaudeTerminalSdkConfig {
        claude_bin,
        model,
        system_prompt,
        timeout_ms,
        cwd,
        backend: Some(Box::new(TmuxBackend::new(tmux_bin))),
        reader: Some(Box::new(FileSystemTranscriptReader::new())),
        ..Default::default()
    })
}

/// Run a prompt through `ClaudeTerminalSdk` on a dedicated thread,
/// emitting `StreamChunk`s compatible with seher-cli's `drain_to_stdout`.
#[must_use]
pub fn stream_via_thread(
    sdk: ClaudeTerminalSdk,
    prompt: String,
    provider_label: String,
) -> std::sync::mpsc::Receiver<crate::sdk::StreamChunk> {
    use crate::sdk::{LimitError, StreamChunk};
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || match sdk.run(&prompt) {
        Ok(text) => {
            let _ = tx.send(StreamChunk::Delta(text));
            let _ = tx.send(StreamChunk::Done(String::new()));
        }
        Err(ClaudeTerminalError::SessionLimit { reset_info: _ }) => {
            let _ = tx.send(StreamChunk::Limit(LimitError {
                provider: provider_label,
                reset_at: None,
            }));
        }
        Err(e) => {
            let _ = tx.send(StreamChunk::Error(e.to_string()));
        }
    });
    rx
}