heartbeat-rs 0.5.3

Heartbeat pattern for persistent AI CLI sessions — stop hook (heartbeat-stop) and PTY launcher (heartbeat-launch)
Documentation
//! heartbeat-launch: Launch a command inside a PTY.
//!
//! Designed to give Claude Code interactive mode by allocating a real PTY,
//! so CC's isTTY check succeeds and it runs in `cli` (not `sdk-cli`) mode.
//!
//! Usage:
//!   heartbeat-launch [--cwd <dir>] [--timeout <secs>] -- claude --model opus "Read CLAUDE.md"
//!
//! Everything after `--` is argv passed directly to the child process.
//! No inbox management, no settings.json generation, no handshake.
//! The consumer handles all of that.

use clap::Parser;
use heartbeat_rs::pty::{self, IdleConfig};
use std::path::PathBuf;
use std::process;

#[derive(Parser)]
#[command(name = "heartbeat-launch")]
#[command(about = "Launch a command inside a PTY. Designed to give Claude Code interactive mode.")]
#[command(
    long_about = "Allocates a PTY via portable-pty (Unix PTY + Windows ConPTY), spawns the \
                  given command inside it, and forwards stdout to the current process. \
                  Polls for child exit with a configurable timeout and exits with the \
                  child's exit code.\n\n\
                  Everything after `--` is the command and its arguments. The consumer \
                  is responsible for inbox setup, settings.json, and stop hook wiring."
)]
struct Cli {
    /// Working directory for the child process.
    #[arg(long, default_value = ".")]
    cwd: String,

    /// Timeout in seconds (0 = no timeout).
    #[arg(long, default_value = "3600")]
    timeout: u64,

    /// Optional path to an exit signal file.
    ///
    /// When heartbeat-stop decides the session should end (Approve), it
    /// touches this file. heartbeat-launch detects the file in its poll loop,
    /// deletes it, and terminates the child's process group (SIGTERM then
    /// SIGKILL after a short grace period), ending the session.
    ///
    /// Must match the `--signal-file` value passed to heartbeat-stop.
    /// If omitted, no signal-file coordination is performed.
    #[arg(long)]
    exit_signal: Option<PathBuf>,

    /// Idle detection timeout in seconds (0 = disabled, default).
    ///
    /// If the PTY produces no output for this many seconds, a keepalive
    /// sequence is injected: ESC (to cancel any stalled generation) followed
    /// by --idle-prompt and a newline. This unsticks sessions where the
    /// Anthropic API stream has hung mid-generation.
    #[arg(long, default_value = "0")]
    idle_timeout: u64,

    /// Text to inject after ESC when idle is detected.
    ///
    /// Only used when --idle-timeout > 0. Sent as plain text followed by a
    /// newline to the PTY master after the ESC cancel byte.
    #[arg(long, default_value = "Continue")]
    idle_prompt: String,

    /// Maximum keepalive injections before giving up and killing the child.
    ///
    /// If the session remains idle after this many injections, the child is
    /// killed and heartbeat-launch exits 125 (distinct from --timeout's 124).
    /// Only used when --idle-timeout > 0.
    #[arg(long, default_value = "3")]
    max_idle_retries: u32,

    /// Command and arguments to run inside the PTY.
    /// Pass everything after `--`.
    #[arg(trailing_var_arg = true, required = true)]
    cmd: Vec<String>,
}

fn main() {
    let cli = Cli::parse();

    let cwd = PathBuf::from(&cli.cwd);
    if !cwd.exists() {
        eprintln!(
            "heartbeat-launch: working directory does not exist: {}",
            cwd.display()
        );
        process::exit(1);
    }

    let cwd = match cwd.canonicalize() {
        Ok(p) => p,
        Err(e) => {
            eprintln!(
                "heartbeat-launch: cannot resolve working directory {}: {e}",
                cli.cwd
            );
            process::exit(1);
        }
    };

    if cli.cmd.is_empty() {
        eprintln!("heartbeat-launch: no command specified");
        process::exit(1);
    }

    if cli.timeout > 0 {
        eprintln!(
            "heartbeat-launch: spawning {:?} in {} with {}s timeout",
            cli.cmd,
            cwd.display(),
            cli.timeout
        );
    } else {
        eprintln!(
            "heartbeat-launch: spawning {:?} in {} (no timeout)",
            cli.cmd,
            cwd.display()
        );
    }

    let idle_cfg = if cli.idle_timeout > 0 {
        Some(IdleConfig {
            timeout_secs: cli.idle_timeout,
            prompt: cli.idle_prompt.clone(),
            max_retries: cli.max_idle_retries,
        })
    } else {
        None
    };

    let result = pty::run(
        &cli.cmd,
        &cwd,
        cli.timeout,
        cli.exit_signal.as_deref(),
        idle_cfg.as_ref(),
    );

    match result {
        Ok(result) => {
            // Cap exit code at 123 to avoid colliding with 124 (Timeout),
            // 125 (IdleExhausted), and the signal-death range (126-127 on
            // POSIX shells).  Values above 123 from a process exit() call are
            // technically valid but unusual; saturation avoids silent i32
            // wrapping on values that exceed i32::MAX (portable-pty: u32).
            let code = result.exit_code.min(123) as i32;
            process::exit(code);
        }
        Err(pty::PtyError::Timeout(secs)) => {
            eprintln!("heartbeat-launch: timeout after {secs}s — child killed");
            process::exit(124); // same convention as `timeout(1)` on Linux
        }
        Err(pty::PtyError::IdleExhausted(secs)) => {
            eprintln!("heartbeat-launch: idle exhausted — no output for {secs}s after maximum keepalive retries, child killed");
            process::exit(125);
        }
        Err(e) => {
            eprintln!("heartbeat-launch: error: {e}");
            process::exit(1);
        }
    }
}