feat(hive-claude): add InfiniteSession (name+store+compaction policy)

This commit is contained in:
müde 2026-07-05 19:38:16 +02:00
commit 80d819e444
8 changed files with 501 additions and 283 deletions

View file

@ -1,5 +1,5 @@
//! The subprocess driver: spawn claude, pump + classify its streams, and
//! assemble an [`Outcome`].
//! assemble the result.
use std::collections::VecDeque;
use std::process::Stdio;
@ -8,7 +8,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{ChildStderr, ChildStdout, Command};
use crate::classify::Sentinels;
use crate::{Config, Error, Result, Session, Sink};
use crate::{Attach, Config, Error, Result, Sink};
/// Default program name spawned when [`Config::program`] is unset.
const DEFAULT_PROGRAM: &str = "claude";
@ -16,9 +16,9 @@ const DEFAULT_PROGRAM: &str = "claude";
/// How many trailing stderr lines to keep for [`Error::Exit`].
const STDERR_TAIL_LINES: usize = 20;
/// The driver entry point. A namespace for the run functions — there is
/// nothing to construct; call the associated functions directly
/// (`Claude::run(…)`, `Claude::run_resume_or_create(…)`).
/// The low-level driver entry point. A namespace for the run function — there
/// is nothing to construct; call `Claude::run(…)` directly. For a durable,
/// self-compacting session, use [`crate::InfiniteSession`] instead.
pub struct Claude;
impl Claude {
@ -40,12 +40,12 @@ impl Claude {
/// - [`Error::Exit`] on a non-zero exit that raised no sentinel.
pub async fn run(
config: &Config,
session: &Session,
attach: &Attach,
prompt: &str,
sink: &impl Sink,
) -> Result<()> {
let program = config.program.as_deref().unwrap_or(DEFAULT_PROGRAM);
let mut cmd = build_command(program, config, session);
let mut cmd = build_command(program, config, attach);
let mut child = cmd
.stdin(Stdio::piped())
@ -88,42 +88,12 @@ impl Claude {
}
Ok(())
}
/// Resume a titled session, creating it on first use. Runs
/// `--resume <title>`; on [`Error::SessionNotFound`] it re-runs the *same
/// prompt* once with `--name <title>` to mint the session.
///
/// Returns `Ok(true)` when a fresh session was created (the resume missed),
/// `Ok(false)` when an existing session was resumed. This is the common
/// "one durable session per agent, self-healing on first boot / after the
/// file is archived away" pattern, kept generic here.
///
/// # Errors
///
/// Propagates any [`Error`] from the underlying [`Claude::run`] calls
/// (other than the `SessionNotFound` on the first attempt, which is handled
/// by creating).
pub async fn run_resume_or_create(
config: &Config,
title: &str,
prompt: &str,
sink: &impl Sink,
) -> Result<bool> {
match Self::run(config, &Session::Resume(title.to_string()), prompt, sink).await {
Err(Error::SessionNotFound) => {
Self::run(config, &Session::Create(title.to_string()), prompt, sink).await?;
Ok(true)
}
Ok(()) => Ok(false),
Err(other) => Err(other),
}
}
}
/// Assemble the argv. `--print --verbose --output-format stream-json` are
/// mandatory (the driver parses that shape); everything else is gated on the
/// [`Config`] / [`Session`].
fn build_command(program: &str, config: &Config, session: &Session) -> Command {
/// [`Config`] / [`Attach`].
fn build_command(program: &str, config: &Config, attach: &Attach) -> Command {
let mut cmd = Command::new(program);
if let Some(cwd) = &config.cwd {
cmd.current_dir(cwd);
@ -138,17 +108,17 @@ fn build_command(program: &str, config: &Config, session: &Session) -> Command {
if let Some(effort) = &config.effort {
cmd.arg("--effort").arg(effort);
}
match session {
Session::Resume(id) => {
match attach {
Attach::Resume(id) => {
cmd.arg("--resume").arg(id);
}
Session::Create(title) => {
Attach::Create(title) => {
cmd.arg("--name").arg(title);
}
Session::Continue => {
Attach::Continue => {
cmd.arg("--continue");
}
Session::OneOff => {}
Attach::OneOff => {}
}
if let Some(path) = &config.system_prompt_file {
cmd.arg("--system-prompt-file").arg(path);