refactor(agent): extract claude driver into hive-claude crate
This commit is contained in:
parent
e35acca814
commit
a3b66241d1
13 changed files with 907 additions and 442 deletions
106
hive-claude/src/classify.rs
Normal file
106
hive-claude/src/classify.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! Sentinel detection: mapping claude-code CLI output onto [`crate::Error`]
|
||||
//! variants.
|
||||
//!
|
||||
//! These marker strings are claude-code CLI knowledge, not app knowledge. They
|
||||
//! are empirically stable across CLI versions; if one drifts the run degrades
|
||||
//! gracefully (a clean turn, or a hard [`crate::Error::Exit`] on a non-zero
|
||||
//! exit) rather than misbehaving.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Emitted when the prompt/context exceeds the model's window.
|
||||
const PROMPT_TOO_LONG_MARKER: &str = "Prompt is too long";
|
||||
|
||||
/// Substrings indicating the API refused for rate-limit / usage-cap / credit
|
||||
/// reasons. On stdout these are only trusted inside a JSON `error` event (see
|
||||
/// [`Sentinels::scan_stdout_json`] / [`Sentinels::scan_rate_limit_text`]) so a
|
||||
/// model *discussing* a rate limit in prose can't trigger a false positive.
|
||||
const RATE_LIMIT_MARKERS: [&str; 5] = [
|
||||
"rate_limit_error",
|
||||
"overloaded_error",
|
||||
"Credit balance is too low",
|
||||
"Usage limit reached",
|
||||
"Request rate limit exceeded",
|
||||
];
|
||||
|
||||
/// Substrings indicating the API rejected the request as unauthenticated (401)
|
||||
/// — an expired/revoked OAuth session. Sourced from claude-code's `api_retry`
|
||||
/// JSON events and its human-readable give-up line.
|
||||
const AUTH_FAIL_MARKERS: [&str; 3] = [
|
||||
"\"error\":\"authentication_failed\"",
|
||||
"\"error_status\":401",
|
||||
"Failed to authenticate. API Error: 401",
|
||||
];
|
||||
|
||||
/// Substrings indicating `--resume` could not resolve its target: no session
|
||||
/// with the given title, or no conversation with the given id.
|
||||
const SESSION_NOT_FOUND_MARKERS: [&str; 2] = [
|
||||
"does not match any session title",
|
||||
"No conversation found with session ID",
|
||||
];
|
||||
|
||||
/// Shared, lock-free sentinel flags accumulated while both output streams are
|
||||
/// pumped concurrently. Read once after the child exits.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Sentinels {
|
||||
prompt_too_long: AtomicBool,
|
||||
rate_limited: AtomicBool,
|
||||
auth_failed: AtomicBool,
|
||||
session_not_found: AtomicBool,
|
||||
}
|
||||
|
||||
impl Sentinels {
|
||||
/// Scan a raw line (stdout or stderr) for the always-on markers:
|
||||
/// prompt-too-long, auth-failed, session-not-found. Rate-limit is handled
|
||||
/// separately because on stdout it must only fire on JSON `error` events.
|
||||
pub(crate) fn scan_line(&self, line: &str) {
|
||||
if line.contains(PROMPT_TOO_LONG_MARKER) {
|
||||
self.prompt_too_long.store(true, Ordering::Relaxed);
|
||||
}
|
||||
if AUTH_FAIL_MARKERS.iter().any(|m| line.contains(m)) {
|
||||
self.auth_failed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
if SESSION_NOT_FOUND_MARKERS.iter().any(|m| line.contains(m)) {
|
||||
self.session_not_found.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Trust a rate-limit hit on a JSON `error` event's serialized form.
|
||||
pub(crate) fn scan_stdout_json(&self, event: &serde_json::Value) {
|
||||
if event.get("type").and_then(|t| t.as_str()) == Some("error")
|
||||
&& RATE_LIMIT_MARKERS
|
||||
.iter()
|
||||
.any(|m| event.to_string().contains(m))
|
||||
{
|
||||
self.rate_limited.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Trust a rate-limit hit on raw text (non-JSON stdout, or any stderr) —
|
||||
/// these are CLI messages, not conversation content.
|
||||
pub(crate) fn scan_rate_limit_text(&self, line: &str) {
|
||||
if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) {
|
||||
self.rate_limited.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The recognized-sentinel error, if any fired — `None` means no sentinel
|
||||
/// (so the run either completed or failed hard on its exit code). The
|
||||
/// sentinels keep a fixed priority (too-long > rate > auth); a
|
||||
/// session-not-found can only arise on a resume that made no model call,
|
||||
/// so it never coincides with the others.
|
||||
pub(crate) fn soft_error(&self) -> Option<crate::Error> {
|
||||
use crate::Error;
|
||||
if self.prompt_too_long.load(Ordering::Relaxed) {
|
||||
Some(Error::PromptTooLong)
|
||||
} else if self.rate_limited.load(Ordering::Relaxed) {
|
||||
Some(Error::RateLimited)
|
||||
} else if self.auth_failed.load(Ordering::Relaxed) {
|
||||
Some(Error::AuthFailed)
|
||||
} else if self.session_not_found.load(Ordering::Relaxed) {
|
||||
Some(Error::SessionNotFound)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
58
hive-claude/src/config.rs
Normal file
58
hive-claude/src/config.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//! Invocation config: how to build one `claude --print` command line.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Which claude session a run should attach to. Kept separate from [`Config`]
|
||||
/// so a single config can drive resume + create + `/compact` of the same
|
||||
/// logical session across turns.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Session {
|
||||
/// `--resume <id-or-title>` — resume an existing session by UUID or by the
|
||||
/// display title set via [`Session::Create`]. Yields
|
||||
/// [`crate::Outcome::SessionNotFound`] if nothing matches.
|
||||
Resume(String),
|
||||
/// `--name <title>` — start a new session carrying the given display title
|
||||
/// (persisted as a `custom-title` event, which `--resume <title>` later
|
||||
/// resolves against).
|
||||
Create(String),
|
||||
/// `--continue` — resume the most recent session in the cwd. Ambiguous
|
||||
/// when other claude processes share the cwd; prefer titled sessions.
|
||||
Continue,
|
||||
/// No session flag — a one-off, unnamed session.
|
||||
OneOff,
|
||||
}
|
||||
|
||||
/// Everything needed to build one headless `claude --print` invocation, minus
|
||||
/// the [`Session`] attachment (passed separately to [`crate::run`]).
|
||||
///
|
||||
/// Fields map one-to-one to CLI flags; `None`/empty means "don't pass the
|
||||
/// flag". `--print --verbose --output-format stream-json` are always set by
|
||||
/// the driver and are not configurable here (the driver depends on the
|
||||
/// stream-json shape).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Config {
|
||||
/// `--model`. Empty omits the flag (claude falls back to its own default).
|
||||
pub model: String,
|
||||
/// `--effort <level>`. `None` omits the flag.
|
||||
pub effort: Option<String>,
|
||||
/// Working directory for the child. Claude derives its per-project session
|
||||
/// dir from this path. `None` inherits the parent process cwd.
|
||||
pub cwd: Option<PathBuf>,
|
||||
/// `--system-prompt-file <path>`.
|
||||
pub system_prompt_file: Option<PathBuf>,
|
||||
/// `--mcp-config <path>`.
|
||||
pub mcp_config: Option<PathBuf>,
|
||||
/// Pass `--strict-mcp-config` (only the configured MCP servers, no
|
||||
/// discovery).
|
||||
pub strict_mcp_config: bool,
|
||||
/// `--tools <expr>` — the built-in tool allow-list expression.
|
||||
pub tools: Option<String>,
|
||||
/// `--allowedTools <expr>`.
|
||||
pub allowed_tools: Option<String>,
|
||||
/// `--add-dir <path>` (repeatable) — extra readable directories.
|
||||
pub add_dirs: Vec<PathBuf>,
|
||||
/// Any additional raw args appended verbatim after the ones above.
|
||||
pub extra_args: Vec<String>,
|
||||
/// Program to spawn. `None` defaults to `claude` (resolved on `PATH`).
|
||||
pub program: Option<String>,
|
||||
}
|
||||
209
hive-claude/src/driver.rs
Normal file
209
hive-claude/src/driver.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
//! The subprocess driver: spawn claude, pump + classify its streams, and
|
||||
//! assemble an [`Outcome`].
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::process::Stdio;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{ChildStderr, ChildStdout, Command};
|
||||
|
||||
use crate::classify::Sentinels;
|
||||
use crate::{Config, Error, Result, Session, Sink};
|
||||
|
||||
/// Default program name spawned when [`Config::program`] is unset.
|
||||
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(…)`).
|
||||
pub struct Claude;
|
||||
|
||||
impl Claude {
|
||||
/// Spawn one headless `claude --print` turn, stream its output through
|
||||
/// `sink`, and report the result.
|
||||
///
|
||||
/// The wake prompt is written to claude's stdin. stdout (`stream-json`)
|
||||
/// and stderr are pumped concurrently while the child runs. A clean turn
|
||||
/// returns `Ok(())`; every non-completion state — recognized sentinel or
|
||||
/// hard failure — is an [`Error`] variant, so callers branch with a single
|
||||
/// `match`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - A recognized sentinel: [`Error::PromptTooLong`], [`Error::RateLimited`],
|
||||
/// [`Error::AuthFailed`], [`Error::SessionNotFound`].
|
||||
/// - [`Error::Spawn`] if the binary can't be launched.
|
||||
/// - [`Error::Stdin`] / [`Error::Wait`] on stdin-write / child-wait failure.
|
||||
/// - [`Error::Exit`] on a non-zero exit that raised no sentinel.
|
||||
pub async fn run(
|
||||
config: &Config,
|
||||
session: &Session,
|
||||
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 child = cmd
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|source| Error::Spawn {
|
||||
program: program.to_string(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(prompt.as_bytes()).await.map_err(Error::Stdin)?;
|
||||
// Best-effort flush/close; claude sees EOF and starts the turn.
|
||||
stdin.shutdown().await.ok();
|
||||
}
|
||||
let stdout = child.stdout.take().expect("stdout piped");
|
||||
let stderr = child.stderr.take().expect("stderr piped");
|
||||
|
||||
let sentinels = Sentinels::default();
|
||||
// Pump both streams and wait for exit concurrently on this task — no
|
||||
// `spawn`, so the sink needn't be `'static` and borrows stay simple.
|
||||
let ((), stderr_tail, status) = tokio::join!(
|
||||
pump_stdout(stdout, sink, &sentinels),
|
||||
pump_stderr(stderr, sink, &sentinels),
|
||||
child.wait(),
|
||||
);
|
||||
let status = status.map_err(Error::Wait)?;
|
||||
|
||||
// A recognized sentinel takes precedence over the exit code; otherwise
|
||||
// a non-zero exit with no sentinel is a hard failure.
|
||||
if let Some(sentinel) = sentinels.soft_error() {
|
||||
return Err(sentinel);
|
||||
}
|
||||
if !status.success() {
|
||||
return Err(Error::Exit {
|
||||
status,
|
||||
stderr_tail,
|
||||
});
|
||||
}
|
||||
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 {
|
||||
let mut cmd = Command::new(program);
|
||||
if let Some(cwd) = &config.cwd {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
cmd.arg("--print")
|
||||
.arg("--verbose")
|
||||
.arg("--output-format")
|
||||
.arg("stream-json");
|
||||
if !config.model.is_empty() {
|
||||
cmd.arg("--model").arg(&config.model);
|
||||
}
|
||||
if let Some(effort) = &config.effort {
|
||||
cmd.arg("--effort").arg(effort);
|
||||
}
|
||||
match session {
|
||||
Session::Resume(id) => {
|
||||
cmd.arg("--resume").arg(id);
|
||||
}
|
||||
Session::Create(title) => {
|
||||
cmd.arg("--name").arg(title);
|
||||
}
|
||||
Session::Continue => {
|
||||
cmd.arg("--continue");
|
||||
}
|
||||
Session::OneOff => {}
|
||||
}
|
||||
if let Some(path) = &config.system_prompt_file {
|
||||
cmd.arg("--system-prompt-file").arg(path);
|
||||
}
|
||||
if let Some(path) = &config.mcp_config {
|
||||
cmd.arg("--mcp-config").arg(path);
|
||||
}
|
||||
if config.strict_mcp_config {
|
||||
cmd.arg("--strict-mcp-config");
|
||||
}
|
||||
if let Some(tools) = &config.tools {
|
||||
cmd.arg("--tools").arg(tools);
|
||||
}
|
||||
if let Some(allowed) = &config.allowed_tools {
|
||||
cmd.arg("--allowedTools").arg(allowed);
|
||||
}
|
||||
for dir in &config.add_dirs {
|
||||
cmd.arg("--add-dir").arg(dir);
|
||||
}
|
||||
for extra in &config.extra_args {
|
||||
cmd.arg(extra);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Read stdout line by line: classify each line, parse JSON, hand events (or
|
||||
/// raw non-JSON lines) to the sink.
|
||||
async fn pump_stdout(stdout: ChildStdout, sink: &impl Sink, sentinels: &Sentinels) {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
sentinels.scan_line(&line);
|
||||
if let Ok(event) = serde_json::from_str::<serde_json::Value>(&line) {
|
||||
sentinels.scan_stdout_json(&event);
|
||||
sink.on_event(&event);
|
||||
} else {
|
||||
sentinels.scan_rate_limit_text(&line);
|
||||
sink.on_stdout_line(&line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read stderr line by line: classify, forward to the sink, and retain the
|
||||
/// last [`STDERR_TAIL_LINES`] for a possible [`Error::Exit`]. Returns the
|
||||
/// newline-joined tail.
|
||||
async fn pump_stderr(stderr: ChildStderr, sink: &impl Sink, sentinels: &Sentinels) -> String {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
let mut tail: VecDeque<String> = VecDeque::with_capacity(STDERR_TAIL_LINES);
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
sentinels.scan_line(&line);
|
||||
sentinels.scan_rate_limit_text(&line);
|
||||
sink.on_stderr_line(&line);
|
||||
if tail.len() >= STDERR_TAIL_LINES {
|
||||
tail.pop_front();
|
||||
}
|
||||
tail.push_back(line);
|
||||
}
|
||||
tail.into_iter().collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
77
hive-claude/src/error.rs
Normal file
77
hive-claude/src/error.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
//! Typed errors for the driver. See the crate-level docs for why this is a
|
||||
//! `thiserror` enum rather than `anyhow`.
|
||||
|
||||
use std::process::ExitStatus;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Why a claude run did not complete cleanly. A normal, finished turn is
|
||||
/// `Ok(())`; everything else is one of these variants.
|
||||
///
|
||||
/// Two families share the enum on purpose, so a caller can handle them with a
|
||||
/// single `match` on the `Result`:
|
||||
///
|
||||
/// - **Recognized sentinels** — [`Error::PromptTooLong`],
|
||||
/// [`Error::RateLimited`], [`Error::AuthFailed`], [`Error::SessionNotFound`].
|
||||
/// These are *expected* non-completion states parsed from claude's output,
|
||||
/// not crashes; callers typically compact, park-and-retry, re-auth, or
|
||||
/// create-a-session in response.
|
||||
/// - **Hard failures** — [`Error::Spawn`], [`Error::Stdin`], [`Error::Wait`],
|
||||
/// [`Error::Exit`], [`Error::Io`]. The process couldn't run, or died with no
|
||||
/// recognizable reason.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// `Prompt is too long` — the session is past the model's context window.
|
||||
#[error("prompt is too long for the model's context window")]
|
||||
PromptTooLong,
|
||||
|
||||
/// The API refused for rate-limit / usage-cap / credit-balance reasons.
|
||||
#[error("request was rate-limited or hit a usage/credit cap")]
|
||||
RateLimited,
|
||||
|
||||
/// The API rejected the request with 401 (auth/session expired or revoked).
|
||||
#[error("authentication failed (HTTP 401)")]
|
||||
AuthFailed,
|
||||
|
||||
/// `--resume` matched no session for the given id or title (e.g. the title
|
||||
/// was never created, or its backing file was moved away).
|
||||
#[error("no session matched the requested id or title")]
|
||||
SessionNotFound,
|
||||
|
||||
/// The `claude` binary could not be spawned (not on `PATH`, not
|
||||
/// executable, …).
|
||||
#[error("failed to spawn `{program}`: {source}")]
|
||||
Spawn {
|
||||
/// The program name we tried to run.
|
||||
program: String,
|
||||
/// The underlying spawn error.
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
/// Writing the prompt to claude's stdin failed.
|
||||
#[error("writing prompt to claude stdin failed: {0}")]
|
||||
Stdin(#[source] std::io::Error),
|
||||
|
||||
/// Awaiting the child process failed.
|
||||
#[error("waiting on claude failed: {0}")]
|
||||
Wait(#[source] std::io::Error),
|
||||
|
||||
/// claude exited non-zero and raised none of the recognized sentinels.
|
||||
/// `stderr_tail` is the last handful of stderr lines (empty if there were
|
||||
/// none), included so the caller can surface a real diagnostic.
|
||||
#[error("claude exited {status}\n{stderr_tail}")]
|
||||
Exit {
|
||||
/// The child's exit status.
|
||||
status: ExitStatus,
|
||||
/// Tail of stderr, newline-joined; empty when claude wrote nothing.
|
||||
stderr_tail: String,
|
||||
},
|
||||
|
||||
/// A filesystem operation (session lookup / archive) failed.
|
||||
#[error("session store i/o failed: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Convenience alias for results from this crate.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
61
hive-claude/src/lib.rs
Normal file
61
hive-claude/src/lib.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
//! `hive-claude` — a small, reusable async driver for headless
|
||||
//! `claude --print` (Claude Code CLI) sessions.
|
||||
//!
|
||||
//! It spawns the CLI, streams and classifies its `stream-json` output, and
|
||||
//! reports the result as a `Result<(), Error>`: a clean turn is `Ok(())`, and
|
||||
//! every non-completion state — both recognized sentinels (rate-limit,
|
||||
//! prompt-too-long, …) and hard failures (spawn, non-zero exit) — is a variant
|
||||
//! of the single [`Error`] enum, so callers branch with one `match`. The crate
|
||||
//! also locates and archives on-disk sessions by title ([`SessionStore`]). It
|
||||
//! knows only about the Claude Code CLI — no application types, policy,
|
||||
//! watermarks, or logging. Callers wire streaming output through a [`Sink`] and
|
||||
//! layer their own compaction / retry / reset policy on top.
|
||||
//!
|
||||
//! # `thiserror` here, `anyhow` in the apps
|
||||
//!
|
||||
//! This is a **library**, so it exposes a concrete, matchable error enum built
|
||||
//! with [`thiserror`](https://docs.rs/thiserror): a caller can distinguish
|
||||
//! `Error::Exit { status, .. }` from `Error::Spawn { .. }` and branch on it.
|
||||
//! A library should never force its callers to reach into `anyhow`'s
|
||||
//! type-erased error to find out what went wrong.
|
||||
//!
|
||||
//! The **applications** in this workspace (the `hive-*` binaries) use
|
||||
//! [`anyhow`](https://docs.rs/anyhow) instead. At the top level you usually
|
||||
//! only want to attach context and log or bubble a failure up — not match on
|
||||
//! it — and `anyhow::Result` + `?` + `.context()` is the ergonomic fit.
|
||||
//! `anyhow::Error` implements `From<E>` for any `std::error::Error`, so a
|
||||
//! `hive_claude::Error` converts into an `anyhow::Error` for free at the `?`
|
||||
//! boundary. Rule of thumb: **libraries return `thiserror` enums, binaries
|
||||
//! consume them with `anyhow`.**
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # async fn ex() {
|
||||
//! use hive_claude::{Claude, Config, Error, NoopSink, Session};
|
||||
//!
|
||||
//! let config = Config {
|
||||
//! model: "haiku".into(),
|
||||
//! ..Default::default()
|
||||
//! };
|
||||
//! match Claude::run(&config, &Session::Resume("my-session".into()), "hello", &NoopSink).await {
|
||||
//! Ok(()) => {}
|
||||
//! Err(Error::PromptTooLong) => { /* caller compacts + retries */ }
|
||||
//! Err(Error::RateLimited) => { /* caller parks + retries */ }
|
||||
//! Err(other) => eprintln!("claude: {other}"),
|
||||
//! }
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
mod classify;
|
||||
mod config;
|
||||
mod driver;
|
||||
mod error;
|
||||
mod sink;
|
||||
mod store;
|
||||
|
||||
pub use config::{Config, Session};
|
||||
pub use driver::Claude;
|
||||
pub use error::{Error, Result};
|
||||
pub use sink::{NoopSink, Sink};
|
||||
pub use store::SessionStore;
|
||||
30
hive-claude/src/sink.rs
Normal file
30
hive-claude/src/sink.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
//! Streaming-output consumer hook.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Consumer callbacks for a claude run's output streams. Every method has a
|
||||
/// no-op default, so an implementor overrides only what it needs.
|
||||
///
|
||||
/// Methods are called synchronously from the stdout/stderr readers as lines
|
||||
/// arrive, so keep them cheap — the idiomatic body forwards to a channel or an
|
||||
/// event bus rather than blocking. The driver handles sentinel classification
|
||||
/// (rate-limit, prompt-too-long, …) itself; a sink only *observes* the stream.
|
||||
pub trait Sink {
|
||||
/// A parsed `stream-json` object from stdout (assistant/result/system/…
|
||||
/// event). The driver has already classified it for sentinels.
|
||||
fn on_event(&self, _event: &Value) {}
|
||||
|
||||
/// A stdout line that was not valid JSON — occasional Claude Code CLI
|
||||
/// chatter rather than conversation content.
|
||||
fn on_stdout_line(&self, _line: &str) {}
|
||||
|
||||
/// A stderr line, delivered verbatim. The last several are also retained
|
||||
/// by the driver for [`crate::Error::Exit`].
|
||||
fn on_stderr_line(&self, _line: &str) {}
|
||||
}
|
||||
|
||||
/// A [`Sink`] that discards everything. Useful for fire-and-forget runs where
|
||||
/// only the [`crate::Outcome`] matters.
|
||||
pub struct NoopSink;
|
||||
|
||||
impl Sink for NoopSink {}
|
||||
95
hive-claude/src/store.rs
Normal file
95
hive-claude/src/store.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! Locating and archiving on-disk claude sessions by title.
|
||||
|
||||
use std::io::BufRead as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// On-disk claude session store scoped to one working directory.
|
||||
///
|
||||
/// Claude Code keeps sessions under
|
||||
/// `<claude_home>/projects/<slug(cwd)>/<uuid>.jsonl`, one project dir per cwd.
|
||||
/// This type locates and archives those files by their display title. It is
|
||||
/// generic Claude Code layout knowledge — no application specifics.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionStore {
|
||||
claude_home: PathBuf,
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
/// Build a store for `cwd`, with claude's home dir (normally `~/.claude`)
|
||||
/// at `claude_home`.
|
||||
pub fn new(claude_home: impl Into<PathBuf>, cwd: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
claude_home: claude_home.into(),
|
||||
cwd: cwd.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<claude_home>/projects/<slug>` for this cwd. Claude slugises the
|
||||
/// absolute cwd by replacing every `/` and `.` with `-` (verified against
|
||||
/// claude 2.1.197 — e.g. `/agents/iris/state` → `-agents-iris-state`).
|
||||
#[must_use]
|
||||
pub fn project_dir(&self) -> PathBuf {
|
||||
let slug: String = self
|
||||
.cwd
|
||||
.to_string_lossy()
|
||||
.chars()
|
||||
.map(|c| if c == '/' || c == '.' { '-' } else { c })
|
||||
.collect();
|
||||
self.claude_home.join("projects").join(slug)
|
||||
}
|
||||
|
||||
/// Find the `<uuid>.jsonl` in the project dir whose `customTitle` equals
|
||||
/// `title` (the value `--name` sets, stored in a `custom-title` event).
|
||||
///
|
||||
/// Reads each session file line by line and stops at the first match, so a
|
||||
/// huge transcript isn't slurped into memory. Returns `None` if no session
|
||||
/// carries the title or the project dir is absent. Non-`.jsonl` files
|
||||
/// (including anything already archived to `*.jsonl.archived`) are skipped.
|
||||
#[must_use]
|
||||
pub fn find_by_title(&self, title: &str) -> Option<PathBuf> {
|
||||
let marker = format!("\"customTitle\":\"{title}\"");
|
||||
for entry in std::fs::read_dir(self.project_dir()).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(&path) else {
|
||||
continue;
|
||||
};
|
||||
if std::io::BufReader::new(file)
|
||||
.lines()
|
||||
.map_while(std::result::Result::ok)
|
||||
.any(|line| line.contains(&marker))
|
||||
{
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Archive the session titled `title` by renaming its backing file
|
||||
/// `<uuid>.jsonl` → `<uuid>.jsonl.archived`. That drops it out of claude's
|
||||
/// `*.jsonl` resolution glob (so a later `--resume <title>` misses) while
|
||||
/// preserving the full transcript on disk. Only the file with a matching
|
||||
/// `customTitle` is touched.
|
||||
///
|
||||
/// Returns the archived file's new path, or `None` if no session carried
|
||||
/// the title.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::Io`] if the rename fails.
|
||||
pub fn archive_by_title(&self, title: &str) -> Result<Option<PathBuf>> {
|
||||
let Some(path) = self.find_by_title(title) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut target = path.clone().into_os_string();
|
||||
target.push(".archived");
|
||||
let target = PathBuf::from(target);
|
||||
std::fs::rename(&path, &target).map_err(Error::Io)?;
|
||||
Ok(Some(target))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue