From 7a50facf390bb5cde6026a73ce7d448e6987cabb Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Mon, 29 Jun 2026 18:56:31 -0400 Subject: [PATCH 1/3] =?UTF-8?q?th-49de8d:=20th=20claude=20=E2=80=94=20tmux?= =?UTF-8?q?-driven=20Claude=20Code=20session=20supervisor=20(engine=20v1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `th claude run/ls/attach` and a dependency-light `smooth-tmux` crate. `th claude run` launches Claude Code in an isolated tmux session and supervises it: when the account-wide "temporarily limiting requests" throttle fires, it backs off with full jitter (pool-aware RateLimitGovernor) and resends the last message until it lands — preferring the message it sent, falling back to scraping the last user turn from the pane. Why: the throttle currently leaves a Claude Code turn dead on screen with no auto-recovery. This is the 1:1 vertical slice of the broader topology (1→N Big-Smooth-led farm, N→1, mixed) — all later wirings of the same supervisor + governor + registry primitives. The governor is shared so a 429 on any session backs off the whole pool rather than thundering the herd. - crates/smooth-tmux: spawn/send(bracketed-paste)/capture(scrollback)/idle (10 tests, incl. live tmux roundtrip) - claude/governor.rs: backoff+jitter + circuit breaker (pure math unit-tested) - claude/detect.rs: pane-state classification + last-message extraction - claude/registry.rs: ~/.smooth/claude/sessions/*.json + dead-session prune - claude/supervisor.rs: watch→govern→resend loop, Ctrl-C clean stop - 30 tests pass; clippy -D warnings clean; docs + changeset included Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DXqPyj8SvxyUbfyRPvBA6P --- .changeset/th-claude-driver.md | 19 + Cargo.lock | 10 + Cargo.toml | 1 + crates/smooth-cli/Cargo.toml | 1 + crates/smooth-cli/src/claude/detect.rs | 263 +++++++++++ crates/smooth-cli/src/claude/governor.rs | 246 +++++++++++ crates/smooth-cli/src/claude/mod.rs | 206 +++++++++ crates/smooth-cli/src/claude/registry.rs | 156 +++++++ crates/smooth-cli/src/claude/supervisor.rs | 261 +++++++++++ crates/smooth-cli/src/main.rs | 9 + crates/smooth-tmux/Cargo.toml | 22 + crates/smooth-tmux/src/lib.rs | 489 +++++++++++++++++++++ docs/Engineering/Using-th-CLI.md | 38 ++ 13 files changed, 1721 insertions(+) create mode 100644 .changeset/th-claude-driver.md create mode 100644 crates/smooth-cli/src/claude/detect.rs create mode 100644 crates/smooth-cli/src/claude/governor.rs create mode 100644 crates/smooth-cli/src/claude/mod.rs create mode 100644 crates/smooth-cli/src/claude/registry.rs create mode 100644 crates/smooth-cli/src/claude/supervisor.rs create mode 100644 crates/smooth-tmux/Cargo.toml create mode 100644 crates/smooth-tmux/src/lib.rs diff --git a/.changeset/th-claude-driver.md b/.changeset/th-claude-driver.md new file mode 100644 index 00000000..ed174147 --- /dev/null +++ b/.changeset/th-claude-driver.md @@ -0,0 +1,19 @@ +--- +"@smooai/smooth": patch +--- + +th claude: tmux-driven Claude Code session supervisor with a shared rate-limit governor + +Adds `th claude run / ls / attach` plus a new dependency-light `smooth-tmux` +crate. `th claude run` launches Claude Code inside an isolated tmux session and +supervises it: when the account-wide "temporarily limiting requests" throttle +fires, it backs off with full jitter (via a pool-aware `RateLimitGovernor`) and +resends the last message until it lands — auto-detecting the last user message +from the pane when it didn't send it itself. `th claude attach ` hands your +terminal to the session; `th claude ls` lists live sessions and prunes dead +ones. + +This is the 1:1 vertical slice of a broader topology (1→N Big-Smooth-led farm, +N→1 per-session supervisors, and mixed), all built on the same +supervisor + governor + registry primitives. The governor is shared so a 429 on +any session backs off the whole pool rather than thundering the herd. diff --git a/Cargo.lock b/Cargo.lock index 5a7ac441..0ca92f50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6228,6 +6228,7 @@ dependencies = [ "smooai-smooth-diver", "smooai-smooth-operator-core", "smooai-smooth-pearls", + "smooai-smooth-tmux", "smooai-smooth-tunnel", "smooai-smooth-web", "tabled", @@ -6510,6 +6511,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "smooai-smooth-tmux" +version = "0.15.7" +dependencies = [ + "anyhow", + "tempfile", + "uuid", +] + [[package]] name = "smooai-smooth-tunnel" version = "0.15.7" diff --git a/Cargo.toml b/Cargo.toml index 0a7a38d8..3b4141f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -213,6 +213,7 @@ smooth-bootstrap-bill = { version = "0.15.7", path = "crates/smooth-bootstrap-bi smooth-tunnel = { version = "0.15.7", path = "crates/smooth-tunnel", package = "smooai-smooth-tunnel" } smooth-host-stub = { version = "0.15.7", path = "crates/smooth-host-stub", package = "smooai-smooth-host-stub" } smooth-bench = { version = "0.15.7", path = "crates/smooth-bench", package = "smooai-smooth-bench" } +smooth-tmux = { version = "0.15.7", path = "crates/smooth-tmux", package = "smooai-smooth-tmux" } smooth-api-client = { version = "0.15.7", path = "crates/smooth-api-client", package = "smooai-smooth-api-client" } # Cross-runtime client shared library (github.com/SmooAI/client-shared). # SMOODEV-1464: switched from a local `path = "../client-shared/rust"` dep to diff --git a/crates/smooth-cli/Cargo.toml b/crates/smooth-cli/Cargo.toml index 8ef6bdd0..e5d13e4f 100644 --- a/crates/smooth-cli/Cargo.toml +++ b/crates/smooth-cli/Cargo.toml @@ -21,6 +21,7 @@ admin = [] [dependencies] smooth-bench.workspace = true +smooth-tmux.workspace = true smooth-bigsmooth.workspace = true smooth-bootstrap-bill = { workspace = true, default-features = false, features = ["server"] } smooth-code.workspace = true diff --git a/crates/smooth-cli/src/claude/detect.rs b/crates/smooth-cli/src/claude/detect.rs new file mode 100644 index 00000000..f2537d8f --- /dev/null +++ b/crates/smooth-cli/src/claude/detect.rs @@ -0,0 +1,263 @@ +//! Pure pane-state detection for Claude Code TUIs. +//! +//! A supervisor decides what to do by scraping the captured pane text. +//! All logic here is pure string analysis so it is exhaustively unit +//! testable on captured fixtures without a live tmux or a live Claude. +//! +//! These are heuristics against a TUI we don't control, so the patterns +//! are intentionally broad and the matching is case-insensitive. The +//! supervisor treats [`PaneState::RateLimited`] as "wait per the +//! governor and resend the last message" and is conservative about +//! everything else. + +/// What the pane appears to be doing right now. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaneState { + /// The model is actively working (an interrupt hint is visible). + Working, + /// The transient server throttle fired ("temporarily limiting + /// requests" / "Rate limited"). This is the one we auto-retry. + RateLimited, + /// The account hit its real usage/quota limit (resets at a time). + /// NOT auto-retried — backing off won't help until reset. + UsageLimit, + /// Claude is asking the human to approve a tool/edit. + AwaitingApproval, + /// A non-rate-limit error is on screen. + Errored, + /// The input box is idle and ready for a new message. + Idle, + /// Nothing matched confidently. + Unknown, +} + +impl PaneState { + /// Whether the supervisor should wait-and-resend for this state. + #[must_use] + pub fn is_retryable_rate_limit(self) -> bool { + matches!(self, PaneState::RateLimited) + } +} + +/// Substrings (lowercased) that mark the transient server throttle. +const RATE_LIMIT_MARKERS: &[&str] = &[ + "temporarily limiting requests", + "rate limited", + "(not your usage limit)", + "overloaded_error", + "529", +]; + +/// Substrings (lowercased) that mark a real usage/quota limit. Checked +/// BEFORE the throttle markers so "usage limit" never reads as the +/// retryable throttle. +const USAGE_LIMIT_MARKERS: &[&str] = &[ + "usage limit reached", + "approaching usage limit", + "limit will reset", + "limit resets at", + "out of credits", +]; + +/// Substrings that mark an approval prompt. +const APPROVAL_MARKERS: &[&str] = &[ + "do you want to proceed", + "do you want to make this edit", + "❯ 1. yes", + "1. yes", + "would you like to proceed", +]; + +/// Substrings that mark active work (interrupt hint). +const WORKING_MARKERS: &[&str] = &["esc to interrupt", "esc to cancel", "(running", "tokens · esc"]; + +/// Substrings that mark a generic error. +const ERROR_MARKERS: &[&str] = &["api error", "fatal error", "request failed", "execution error"]; + +fn contains_any(haystack: &str, needles: &[&str]) -> bool { + needles.iter().any(|n| haystack.contains(n)) +} + +/// Classify the pane. **Intended to run on the *visible* pane** (not full +/// scrollback): an error line that has scrolled into history would +/// otherwise make every later capture read as `RateLimited` forever. +/// +/// Order matters and is deliberate: +/// 1. `Working` first — the "esc to interrupt" hint only renders while +/// the model is actively streaming, so it is the most reliable *live* +/// signal. If it is present we are working, even if an older +/// rate-limit line is still visible above it; resending then would be +/// wrong. +/// 2. `UsageLimit` before `RateLimited` — the real quota limit must never +/// be mistaken for the retryable throttle. +#[must_use] +pub fn detect_state(pane: &str) -> PaneState { + let lower = pane.to_lowercase(); + + if contains_any(&lower, WORKING_MARKERS) { + return PaneState::Working; + } + // Real quota limit before the throttle — must never be confused. + if contains_any(&lower, USAGE_LIMIT_MARKERS) { + return PaneState::UsageLimit; + } + if contains_any(&lower, RATE_LIMIT_MARKERS) { + return PaneState::RateLimited; + } + if contains_any(&lower, APPROVAL_MARKERS) { + return PaneState::AwaitingApproval; + } + if contains_any(&lower, ERROR_MARKERS) { + return PaneState::Errored; + } + // Heuristic for "idle and ready": Claude Code shows a prompt box. If + // there's a recognizable prompt affordance and no working hint, call + // it idle. + if lower.contains("> ") || lower.contains("for shortcuts") || lower.contains("? for shortcuts") { + return PaneState::Idle; + } + PaneState::Unknown +} + +/// Best-effort extraction of the most recent **user** message from the +/// captured pane. Claude Code renders submitted user turns with a `>` +/// gutter; we collect the last contiguous run of `>`-prefixed lines. +/// +/// This is a fallback for the "attach to a session I didn't launch" +/// case — when the supervisor launched the task itself it already knows +/// the prompt and should prefer that. Returns `None` when no user turn +/// is recognizable. +#[must_use] +pub fn extract_last_user_message(pane: &str) -> Option { + // Walk lines bottom-up; capture the last block of gutter lines. + let lines: Vec<&str> = pane.lines().collect(); + let mut block: Vec = Vec::new(); + let mut seen_gutter = false; + for raw in lines.iter().rev() { + let line = raw.trim_end(); + let trimmed = line.trim_start(); + if let Some(rest) = gutter_content(trimmed) { + seen_gutter = true; + block.push(rest.to_string()); + continue; + } + if seen_gutter { + // We were in a user block and hit a non-gutter line — the + // block is complete. + break; + } + } + if block.is_empty() { + return None; + } + block.reverse(); + let joined = block.join("\n").trim().to_string(); + if joined.is_empty() { + None + } else { + Some(joined) + } +} + +/// If `line` is a Claude Code user-gutter line (`>` or `│ >`), return the +/// content after the gutter. Distinguishes the user gutter from shell +/// prompts by requiring the `>` to be followed by a space and content. +fn gutter_content(line: &str) -> Option<&str> { + // Common renderings: "> text", "│ > text", "> text │". + let stripped = line.strip_prefix("│ ").unwrap_or(line); + let after = stripped.strip_prefix("> ").or_else(|| stripped.strip_prefix(">"))?; + // Trim a trailing box border if present. + let content = after.trim_end_matches([' ', '│']).trim(); + if content.is_empty() { + None + } else { + Some(content) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rate_limit_is_detected() { + let pane = "Ran 1 shell command\n\n● API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited"; + assert_eq!(detect_state(pane), PaneState::RateLimited); + assert!(detect_state(pane).is_retryable_rate_limit()); + } + + #[test] + fn usage_limit_wins_over_rate_limit_wording() { + // Even if both appear, the real quota limit must not be retried. + let pane = "You've reached your usage limit. limit will reset at 4pm. (rate limited)"; + assert_eq!(detect_state(pane), PaneState::UsageLimit); + assert!(!detect_state(pane).is_retryable_rate_limit()); + } + + #[test] + fn approval_prompt_detected() { + let pane = "Edit file foo.rs?\n Do you want to proceed?\n ❯ 1. Yes\n 2. No"; + assert_eq!(detect_state(pane), PaneState::AwaitingApproval); + } + + #[test] + fn working_detected() { + let pane = "● Thinking…\n (esc to interrupt · 1.2k tokens)"; + assert_eq!(detect_state(pane), PaneState::Working); + } + + #[test] + fn live_working_beats_stale_rate_limit_on_screen() { + // After a successful resend the model streams again while the old + // throttle line is still visible. The live interrupt hint must win + // so the supervisor does NOT resend on top of working output. + let pane = "● API Error: temporarily limiting requests · Rate limited\n● Thinking…\n (esc to interrupt · 200 tokens)"; + assert_eq!(detect_state(pane), PaneState::Working); + } + + #[test] + fn idle_detected() { + let pane = "╭─────────╮\n│ > │\n╰─────────╯\n ? for shortcuts"; + assert_eq!(detect_state(pane), PaneState::Idle); + } + + #[test] + fn unknown_when_nothing_matches() { + assert_eq!(detect_state("just some neutral build output here"), PaneState::Unknown); + } + + #[test] + fn case_insensitive() { + assert_eq!(detect_state("RATE LIMITED"), PaneState::RateLimited); + } + + #[test] + fn extract_simple_user_message() { + let pane = "● done thinking\n\n> fix the flaky test in foo\n\n● Working…"; + // The last gutter block is the user message. + assert_eq!(extract_last_user_message(pane).as_deref(), Some("fix the flaky test in foo")); + } + + #[test] + fn extract_multiline_user_message() { + let pane = "● earlier\n> line one\n> line two\n● response"; + assert_eq!(extract_last_user_message(pane).as_deref(), Some("line one\nline two")); + } + + #[test] + fn extract_handles_box_gutters() { + let pane = "● prior\n│ > do the thing │\n● ok"; + assert_eq!(extract_last_user_message(pane).as_deref(), Some("do the thing")); + } + + #[test] + fn extract_picks_the_last_block() { + let pane = "> first question\n● answer\n> second question\n● working"; + assert_eq!(extract_last_user_message(pane).as_deref(), Some("second question")); + } + + #[test] + fn extract_none_when_no_user_turn() { + assert_eq!(extract_last_user_message("● only assistant output\nno gutter here"), None); + } +} diff --git a/crates/smooth-cli/src/claude/governor.rs b/crates/smooth-cli/src/claude/governor.rs new file mode 100644 index 00000000..fce21c22 --- /dev/null +++ b/crates/smooth-cli/src/claude/governor.rs @@ -0,0 +1,246 @@ +//! Shared, pool-aware rate-limit governor. +//! +//! The "temporarily limiting requests" throttle is **account-wide**, so +//! if N supervised Claude sessions each retry independently they thunder +//! the herd and make the throttle worse. The governor centralises the +//! backoff: a 429 on *any* session advances one shared backoff counter +//! and (optionally) trips a circuit breaker that holds the *whole* pool +//! off until a shared deadline. +//! +//! In the 1:1 topology there is one session and one governor — the pool +//! logic is inert but the same type is reused, so 1:N and mixed +//! topologies share one `Arc` with no code change. +//! +//! The backoff math is pure (`backoff_ceiling`, `jittered`) and unit +//! tested without any clock or RNG; the stateful methods accept an +//! injectable jitter unit so they are deterministic in tests too. + +// Backoff/jitter math is intentionally f64 to/from Duration millis; the +// precision loss and truncation are immaterial for sleep durations. +#![allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)] + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// Exponential-backoff parameters. Defaults are tuned for the Claude Code +/// server throttle: a few seconds base, capped at five minutes. +#[derive(Debug, Clone, Copy)] +pub struct BackoffPolicy { + /// Wait for the first retry (attempt 1), before jitter. + pub base: Duration, + /// Hard ceiling for any single wait, before jitter. + pub max: Duration, + /// Growth factor per consecutive failure. + pub multiplier: f64, +} + +impl Default for BackoffPolicy { + fn default() -> Self { + Self { + base: Duration::from_secs(5), + max: Duration::from_secs(300), + multiplier: 2.0, + } + } +} + +/// The pre-jitter backoff ceiling for the `attempt`-th consecutive +/// failure (1-based). `attempt <= 0` is treated as 1. Saturates at +/// `policy.max`. +#[must_use] +pub fn backoff_ceiling(policy: &BackoffPolicy, attempt: u32) -> Duration { + let attempt = attempt.max(1); + let exp = f64::from(attempt - 1); + let base_ms = policy.base.as_millis() as f64; + let max_ms = policy.max.as_millis() as f64; + let grown = base_ms * policy.multiplier.powf(exp); + let capped = grown.min(max_ms).max(0.0); + Duration::from_millis(capped as u64) +} + +/// Apply **full jitter**: a uniformly random wait in `[0, ceiling]`. +/// `rand_unit` must be in `[0, 1)`; values outside are clamped. Full +/// jitter (rather than equal jitter) maximally decorrelates retries +/// across a pool, which is what we want for an account-wide limit. +#[must_use] +pub fn jittered(ceiling: Duration, rand_unit: f64) -> Duration { + let unit = rand_unit.clamp(0.0, 1.0); + let ms = ceiling.as_millis() as f64 * unit; + Duration::from_millis(ms as u64) +} + +/// Shared backoff state across one pool of supervised sessions. +pub struct RateLimitGovernor { + policy: BackoffPolicy, + inner: Mutex, +} + +#[derive(Default)] +struct Inner { + /// Consecutive rate-limit hits across the pool since the last success. + consecutive: u32, + /// While set and in the future, the whole pool must hold off. + open_until: Option, +} + +impl RateLimitGovernor { + /// A governor with the default backoff policy. + #[must_use] + pub fn new() -> Self { + Self::with_policy(BackoffPolicy::default()) + } + + /// A governor with a custom backoff policy. + #[must_use] + pub fn with_policy(policy: BackoffPolicy) -> Self { + Self { + policy, + inner: Mutex::new(Inner::default()), + } + } + + /// Consecutive rate-limit count since the last success. + #[must_use] + pub fn consecutive(&self) -> u32 { + self.inner.lock().map(|i| i.consecutive).unwrap_or(0) + } + + /// Record a rate-limit hit and return how long this caller should + /// wait before retrying. Advances the shared counter and trips the + /// pool-wide circuit breaker for that duration. `rand_unit` injects + /// the jitter (use [`record_rate_limit`](Self::record_rate_limit) in + /// production, which draws its own). + pub fn record_rate_limit_with(&self, rand_unit: f64) -> Duration { + let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + inner.consecutive = inner.consecutive.saturating_add(1); + let wait = jittered(backoff_ceiling(&self.policy, inner.consecutive), rand_unit); + inner.open_until = Instant::now().checked_add(wait); + wait + } + + /// Record a rate-limit hit drawing real jitter. See + /// [`record_rate_limit_with`](Self::record_rate_limit_with). + pub fn record_rate_limit(&self) -> Duration { + // `rand` is already a dependency of the crate; a cheap thread-rng + // draw is plenty for jitter. + let unit: f64 = rand::random::(); + self.record_rate_limit_with(unit) + } + + /// Record a successful turn: reset the consecutive counter and clear + /// the circuit breaker so the pool resumes at full speed. + pub fn record_success(&self) { + let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + inner.consecutive = 0; + inner.open_until = None; + } + + /// Remaining pool-wide hold-off at `now`, or `None` if the pool may + /// proceed. Pure in `now` for testing. + #[must_use] + pub fn hold_off_at(&self, now: Instant) -> Option { + let inner = self.inner.lock().ok()?; + match inner.open_until { + Some(deadline) if deadline > now => Some(deadline - now), + _ => None, + } + } + + /// Remaining pool-wide hold-off right now. + #[must_use] + pub fn hold_off(&self) -> Option { + self.hold_off_at(Instant::now()) + } +} + +impl Default for RateLimitGovernor { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pol() -> BackoffPolicy { + BackoffPolicy { + base: Duration::from_secs(4), + max: Duration::from_secs(60), + multiplier: 2.0, + } + } + + #[test] + fn ceiling_grows_exponentially_then_caps() { + let p = pol(); + assert_eq!(backoff_ceiling(&p, 1), Duration::from_secs(4)); + assert_eq!(backoff_ceiling(&p, 2), Duration::from_secs(8)); + assert_eq!(backoff_ceiling(&p, 3), Duration::from_secs(16)); + assert_eq!(backoff_ceiling(&p, 4), Duration::from_secs(32)); + // 64 > max(60) → capped. + assert_eq!(backoff_ceiling(&p, 5), Duration::from_secs(60)); + assert_eq!(backoff_ceiling(&p, 50), Duration::from_secs(60)); + } + + #[test] + fn ceiling_treats_zero_attempt_as_first() { + assert_eq!(backoff_ceiling(&pol(), 0), Duration::from_secs(4)); + } + + #[test] + fn full_jitter_spans_zero_to_ceiling() { + let c = Duration::from_secs(10); + assert_eq!(jittered(c, 0.0), Duration::ZERO); + assert_eq!(jittered(c, 0.5), Duration::from_secs(5)); + // clamps >=1.0 to the ceiling. + assert_eq!(jittered(c, 1.0), Duration::from_secs(10)); + assert_eq!(jittered(c, 9.9), Duration::from_secs(10)); + // clamps negatives to 0. + assert_eq!(jittered(c, -1.0), Duration::ZERO); + } + + #[test] + fn governor_advances_and_resets() { + let g = RateLimitGovernor::with_policy(pol()); + assert_eq!(g.consecutive(), 0); + // Use max jitter (1.0) so the wait equals the ceiling and is + // deterministic. + let w1 = g.record_rate_limit_with(1.0); + assert_eq!(w1, Duration::from_secs(4)); + assert_eq!(g.consecutive(), 1); + let w2 = g.record_rate_limit_with(1.0); + assert_eq!(w2, Duration::from_secs(8)); + assert_eq!(g.consecutive(), 2); + g.record_success(); + assert_eq!(g.consecutive(), 0); + assert!(g.hold_off().is_none(), "success clears the breaker"); + } + + #[test] + fn circuit_breaker_holds_then_clears() { + let g = RateLimitGovernor::with_policy(pol()); + let now = Instant::now(); + g.record_rate_limit_with(1.0); // opens for ~4s + let remaining = g.hold_off_at(now).expect("breaker should be open"); + // The governor stamps `open_until` from its own (slightly later) + // `Instant::now()`, so remaining is ~4s plus a sliver. Assert the + // ballpark, not an exact bound. + assert!( + remaining > Duration::from_secs(3) && remaining < Duration::from_secs(5), + "remaining={remaining:?}" + ); + // Far in the future the breaker has elapsed. + let later = now + Duration::from_secs(10); + assert!(g.hold_off_at(later).is_none()); + } + + #[test] + fn zero_jitter_means_no_hold() { + let g = RateLimitGovernor::with_policy(pol()); + let wait = g.record_rate_limit_with(0.0); + assert_eq!(wait, Duration::ZERO); + // open_until set to now+0 → already elapsed. + assert!(g.hold_off().is_none()); + } +} diff --git a/crates/smooth-cli/src/claude/mod.rs b/crates/smooth-cli/src/claude/mod.rs new file mode 100644 index 00000000..f2cb390d --- /dev/null +++ b/crates/smooth-cli/src/claude/mod.rs @@ -0,0 +1,206 @@ +//! `th claude` — supervise Claude Code sessions running inside tmux. +//! +//! v1 ships the 1:1 topology: launch a session, auto-detect the last +//! message, and on the account-wide rate-limit throttle back off with +//! jitter and resend until it lands. Attach to drive it interactively. +//! +//! The pieces are built so the 1:N farm (one Big Smooth leading N +//! sessions on a shared governor) and N:1 / mixed topologies are later +//! wirings of the same `supervisor` + `governor` + `registry`. + +pub mod detect; +pub mod governor; +pub mod registry; +pub mod supervisor; + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use clap::Subcommand; +use owo_colors::OwoColorize; + +use supervisor::RunOpts; + +#[derive(Debug, Subcommand)] +pub enum ClaudeCommands { + /// Launch a Claude Code session in a supervised tmux session and keep + /// it alive: on the account-wide rate-limit throttle ("temporarily + /// limiting requests"), back off with jitter and resend the last + /// message until it lands. Attach with `th claude attach ` to + /// drive it; the session lives as long as this supervisor runs. + Run { + /// Initial prompt to send once the TUI is ready. Omit to just + /// launch + supervise an interactive session you attach to. + prompt: Option, + /// Working directory for the session (default: current dir). + #[arg(long)] + cwd: Option, + /// Label/role shown in `th claude ls`. + #[arg(long)] + label: Option, + /// Command to launch (default: `claude`). + #[arg(long, default_value = "claude")] + command: String, + /// Seconds between pane polls. + #[arg(long, default_value_t = 2)] + poll_secs: u64, + }, + /// List supervised Claude sessions (prunes any whose tmux session has + /// died). + Ls { + /// Emit JSON instead of a table. + #[arg(long)] + json: bool, + }, + /// Attach your terminal to a supervised session (`tmux attach`). + /// Accepts a full id or a unique prefix. + Attach { + /// Session id (or unique prefix) from `th claude ls`. + id: String, + }, +} + +/// Dispatch a `th claude` subcommand. +/// +/// # Errors +/// Propagates launch/attach failures. +pub async fn cmd_claude(cmd: ClaudeCommands) -> Result<()> { + match cmd { + ClaudeCommands::Run { + prompt, + cwd, + label, + command, + poll_secs, + } => { + let cwd = match cwd { + Some(c) => c, + None => std::env::current_dir().context("resolving current directory")?, + }; + run(RunOpts { + cwd, + label, + command, + initial_prompt: prompt, + poll: Duration::from_secs(poll_secs.max(1)), + boot_timeout: Duration::from_secs(30), + }) + .await + } + ClaudeCommands::Ls { json } => ls(json), + ClaudeCommands::Attach { id } => attach(&id), + } +} + +async fn run(opts: RunOpts) -> Result<()> { + let stop = Arc::new(AtomicBool::new(false)); + let stop_for_task = stop.clone(); + + // The supervise loop is blocking (tmux subprocess calls + sleeps), so + // it runs on a blocking thread while the async side owns Ctrl-C. + let mut handle = tokio::task::spawn_blocking(move || supervisor::supervise_blocking(opts, stop_for_task)); + + println!("{} supervising — {} to stop", "claude".bold(), "Ctrl-C".cyan()); + tokio::select! { + res = &mut handle => return res.context("supervisor task panicked")?, + _ = tokio::signal::ctrl_c() => { + eprintln!("\n{} stopping…", "⏹".yellow()); + stop.store(true, Ordering::SeqCst); + } + } + handle.await.context("supervisor task panicked")? +} + +fn ls(json: bool) -> Result<()> { + let live = registry::read_live_and_prune(); + if json { + println!("{}", serde_json::to_string_pretty(&live)?); + return Ok(()); + } + if live.is_empty() { + println!("No supervised Claude sessions. Start one with `{}`.", "th claude run".cyan()); + return Ok(()); + } + println!("{:<10} {:<12} {:<8} {}", "ID".bold(), "LABEL".bold(), "STARTED".bold(), "CWD".bold()); + for e in &live { + println!( + "{:<10} {:<12} {:<8} {}", + e.id.cyan(), + e.label.as_deref().unwrap_or("-"), + e.started_at.format("%H:%M").to_string(), + e.cwd.dimmed() + ); + } + Ok(()) +} + +fn attach(id: &str) -> Result<()> { + let matches: Vec<_> = registry::read_live_and_prune() + .into_iter() + .filter(|e| e.id == id || e.id.starts_with(id)) + .collect(); + let entry = match matches.as_slice() { + [] => return Err(anyhow!("no live session matching `{id}` — try `th claude ls`")), + [one] => one.clone(), + many => { + let ids: Vec<_> = many.iter().map(|e| e.id.as_str()).collect(); + return Err(anyhow!("`{id}` is ambiguous — matches {}", ids.join(", "))); + } + }; + + // Hand the terminal over to tmux by replacing this process. + use std::os::unix::process::CommandExt; + let err = std::process::Command::new("tmux") + .args(["-L", &entry.socket, "attach", "-t", &entry.session]) + .exec(); + Err(anyhow!("failed to exec `tmux attach` for session {}: {err}", entry.session)) +} + +#[cfg(test)] +mod tests { + use clap::CommandFactory; + + // Build a tiny clap harness so the subcommand wiring is validated + // without depending on the whole `th` Cli. + #[derive(clap::Parser)] + struct Harness { + #[command(subcommand)] + cmd: super::ClaudeCommands, + } + + #[test] + fn clap_wiring_is_valid() { + Harness::command().debug_assert(); + } + + #[test] + fn run_parses_prompt_and_flags() { + use clap::Parser; + let h = Harness::try_parse_from(["x", "run", "fix the bug", "--label", "fixer", "--poll-secs", "3"]).unwrap(); + match h.cmd { + super::ClaudeCommands::Run { + prompt, + label, + poll_secs, + command, + .. + } => { + assert_eq!(prompt.as_deref(), Some("fix the bug")); + assert_eq!(label.as_deref(), Some("fixer")); + assert_eq!(poll_secs, 3); + assert_eq!(command, "claude"); + } + _ => panic!("expected Run"), + } + } + + #[test] + fn attach_requires_id() { + use clap::Parser; + assert!(Harness::try_parse_from(["x", "attach"]).is_err()); + assert!(Harness::try_parse_from(["x", "attach", "abc"]).is_ok()); + } +} diff --git a/crates/smooth-cli/src/claude/registry.rs b/crates/smooth-cli/src/claude/registry.rs new file mode 100644 index 00000000..f75c7049 --- /dev/null +++ b/crates/smooth-cli/src/claude/registry.rs @@ -0,0 +1,156 @@ +//! On-disk registry of supervised Claude sessions. +//! +//! Each running supervisor owns one JSON file under +//! `~/.smooth/claude/sessions/.json`. A directory-of-files (rather +//! than one shared file) means concurrent supervisors never race on a +//! write — each owns its own file. `ls`/`attach` read the directory and +//! prune entries whose tmux session has died. + +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// A supervised session as recorded on disk. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionEntry { + /// Short, human-friendly id (also the registry file stem). + pub id: String, + /// tmux session name. + pub session: String, + /// tmux socket (`-L`) this session lives on. + pub socket: String, + /// Working directory the session was launched in. + pub cwd: String, + /// Optional label/role for display. + pub label: Option, + /// PID of the supervising `th` process. + pub pid: u32, + /// When the session was launched. + pub started_at: DateTime, +} + +/// `~/.smooth/claude/sessions`. +#[must_use] +pub fn registry_dir() -> PathBuf { + dirs_next::home_dir().unwrap_or_default().join(".smooth").join("claude").join("sessions") +} + +fn entry_path(id: &str) -> PathBuf { + registry_dir().join(format!("{id}.json")) +} + +/// Persist `entry`, creating the registry directory if needed. +/// +/// # Errors +/// On directory creation, serialization, or write failure. +pub fn write_entry(entry: &SessionEntry) -> Result { + let dir = registry_dir(); + std::fs::create_dir_all(&dir).with_context(|| format!("creating registry dir {}", dir.display()))?; + let path = entry_path(&entry.id); + let json = serde_json::to_string_pretty(entry).context("serializing session entry")?; + std::fs::write(&path, json).with_context(|| format!("writing {}", path.display()))?; + Ok(path) +} + +/// Remove a session's registry file. Missing file is not an error. +pub fn remove_entry(id: &str) { + let _ = std::fs::remove_file(entry_path(id)); +} + +/// Read every registry entry (without liveness checking). +/// +/// # Errors +/// Never — unreadable/corrupt files are skipped so one bad file can't +/// break `ls`. +#[must_use] +pub fn read_all() -> Vec { + let dir = registry_dir(); + let Ok(rd) = std::fs::read_dir(&dir) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for ent in rd.flatten() { + let path = ent.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + if let Ok(text) = std::fs::read_to_string(&path) { + if let Ok(entry) = serde_json::from_str::(&text) { + out.push(entry); + } + } + } + out.sort_by_key(|e| e.started_at); + out +} + +/// True if a tmux session is still present on its socket. +#[must_use] +pub fn is_session_live(entry: &SessionEntry) -> bool { + Command::new("tmux") + .args(["-L", &entry.socket, "has-session", "-t", &entry.session]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +/// Read all entries, removing the registry files of any whose tmux +/// session has died, and return only the live ones. +#[must_use] +pub fn read_live_and_prune() -> Vec { + let mut live = Vec::new(); + for entry in read_all() { + if is_session_live(&entry) { + live.push(entry); + } else { + remove_entry(&entry.id); + } + } + live +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(id: &str) -> SessionEntry { + SessionEntry { + id: id.to_string(), + session: format!("sess-{id}"), + socket: format!("sock-{id}"), + cwd: "/tmp".to_string(), + label: Some("fixer".to_string()), + pid: 4242, + started_at: "2026-06-29T12:00:00Z".parse().unwrap(), + } + } + + #[test] + fn registry_dir_is_under_smooth() { + let d = registry_dir(); + assert!(d.ends_with("claude/sessions"), "unexpected dir: {}", d.display()); + } + + #[test] + fn entry_roundtrips_through_serde() { + let e = sample("abc123"); + let json = serde_json::to_string(&e).unwrap(); + let back: SessionEntry = serde_json::from_str(&json).unwrap(); + assert_eq!(back.id, e.id); + assert_eq!(back.session, e.session); + assert_eq!(back.socket, e.socket); + assert_eq!(back.pid, e.pid); + assert_eq!(back.started_at, e.started_at); + } + + #[test] + fn dead_session_is_not_live() { + // A socket that doesn't exist → has-session fails → not live. + let e = sample("definitely-not-running-xyz"); + assert!(!is_session_live(&e)); + } +} diff --git a/crates/smooth-cli/src/claude/supervisor.rs b/crates/smooth-cli/src/claude/supervisor.rs new file mode 100644 index 00000000..ec4f3016 --- /dev/null +++ b/crates/smooth-cli/src/claude/supervisor.rs @@ -0,0 +1,261 @@ +//! The 1:1 supervisor loop: launch a Claude Code TUI in an isolated tmux +//! session and keep it alive. On the account-wide rate-limit throttle, +//! back off with jitter (via the shared [`RateLimitGovernor`]) and resend +//! the last message until it lands. +//! +//! This is the degenerate case of every topology — one supervisor, one +//! session, one governor. The 1:N farm reuses the same pieces with one +//! `Arc` shared across N supervisors. +//! +//! The blocking loop touches tmux, so it is exercised by the live smoke +//! test; the pure decision (`action_for`) and helpers are unit tested +//! without tmux. + +// `short_id` deliberately folds the nanosecond clock into a u64 for a +// short, throwaway id; the u128→u64 truncation is the intent. +#![allow(clippy::cast_possible_truncation)] + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use chrono::Utc; +use owo_colors::OwoColorize; +use smooth_tmux::TmuxDriver; + +use super::detect::{detect_state, extract_last_user_message, PaneState}; +use super::governor::RateLimitGovernor; +use super::registry::{self, SessionEntry}; + +/// How long to wait after a resend before re-evaluating the pane, so the +/// supervisor doesn't re-detect the stale throttle line (still on screen) +/// and resend on top of itself before the model has reacted. +const RESEND_SETTLE: Duration = Duration::from_secs(8); + +/// Options for one supervised run. +pub struct RunOpts { + /// Working directory for the session. + pub cwd: PathBuf, + /// Optional label/role for display. + pub label: Option, + /// Command to launch (default `claude`). + pub command: String, + /// Prompt to send once the TUI is ready (optional). + pub initial_prompt: Option, + /// Interval between pane polls. + pub poll: Duration, + /// How long to wait for the TUI to come up. + pub boot_timeout: Duration, +} + +/// What the supervisor should do for a given pane state. Pure so it can +/// be tested without a live session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SuperviseAction { + /// Transient throttle — back off and resend the last message. + Rescue, + /// Real quota limit — backing off won't help; stop and hand back. + GiveUp, + /// Working / idle / approval / unknown — keep watching. + Wait, +} + +/// Map a detected pane state to the supervisor's action. +#[must_use] +pub fn action_for(state: PaneState) -> SuperviseAction { + match state { + PaneState::RateLimited => SuperviseAction::Rescue, + PaneState::UsageLimit => SuperviseAction::GiveUp, + _ => SuperviseAction::Wait, + } +} + +/// A short, mostly-unique id from the clock and pid — enough to name a +/// session file and tmux session without pulling a uuid dep into the CLI. +#[must_use] +pub fn short_id() -> String { + let ns = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()); + let mixed = (ns as u64) ^ (u64::from(std::process::id()) << 21); + format!("{:08x}", mixed & 0xffff_ffff) +} + +/// Sleep up to `dur`, returning early if `stop` is set. Polls in small +/// steps so Ctrl-C is responsive even during a long backoff. +fn sleep_interruptible(dur: Duration, stop: &AtomicBool) { + let step = Duration::from_millis(200); + let deadline = Instant::now() + dur; + while Instant::now() < deadline { + if stop.load(Ordering::SeqCst) { + return; + } + std::thread::sleep(step.min(deadline - Instant::now())); + } +} + +/// Removes the registry entry when the supervisor exits (normal return or +/// panic). Note: a hard kill (SIGKILL) skips this, but `th claude ls` +/// prunes dead sessions on read, so a stale file self-heals. +struct RegistryGuard(String); +impl Drop for RegistryGuard { + fn drop(&mut self) { + registry::remove_entry(&self.0); + } +} + +/// Launch and supervise one Claude session until `stop` is set, the +/// session exits, or a non-retryable limit is hit. +/// +/// # Errors +/// On tmux launch failure or an unrecoverable tmux error mid-loop. +pub fn supervise_blocking(opts: RunOpts, stop: Arc) -> Result<()> { + let id = short_id(); + let session = format!("claude-{id}"); + + let mut driver = TmuxDriver::start(&session, &opts.cwd, &opts.command, opts.boot_timeout)?; + driver.set_capture_max_bytes(128 * 1024); + + let entry = SessionEntry { + id: id.clone(), + session: session.clone(), + socket: driver.socket().to_string(), + cwd: opts.cwd.to_string_lossy().into_owned(), + label: opts.label.clone(), + pid: std::process::id(), + started_at: Utc::now(), + }; + registry::write_entry(&entry)?; + let _guard = RegistryGuard(id.clone()); + + println!("{} session {} ({})", "▶".green(), id.bold(), session.dimmed()); + println!(" attach with: {}", format!("th claude attach {id}").cyan()); + + // Wait for the TUI to render, then send the initial prompt. + let mut last_message = opts.initial_prompt.clone(); + if let Some(prompt) = &opts.initial_prompt { + let _ = driver.wait_for_idle(Duration::from_secs(1), Duration::from_millis(300), Duration::from_secs(20)); + driver.send(prompt)?; + println!(" {} sent initial prompt", "→".green()); + } + + let governor = RateLimitGovernor::new(); + + loop { + if stop.load(Ordering::SeqCst) { + println!(" {} stopped", "⏹".yellow()); + break; + } + if !driver.is_alive() { + println!(" {} session ended", "✓".green()); + break; + } + + let visible = driver.capture_visible().unwrap_or_default(); + match action_for(detect_state(&visible)) { + SuperviseAction::Rescue => { + // Prefer the message we sent; fall back to scraping the + // last user turn out of full scrollback. + let msg = last_message + .clone() + .or_else(|| extract_last_user_message(&driver.capture().unwrap_or_default())); + let wait = governor.record_rate_limit(); + println!( + " {} rate limited (#{}) — backing off {}", + "⏳".yellow(), + governor.consecutive(), + fmt_dur(wait).yellow() + ); + sleep_interruptible(wait, &stop); + if stop.load(Ordering::SeqCst) { + continue; + } + match &msg { + Some(m) => { + driver.send(m)?; + last_message = Some(m.clone()); + println!(" {} resent last message", "↻".green()); + } + None => { + println!(" {} couldn't determine the last message — attach and resend manually", "⚠".red()); + } + } + // Let the model react before re-evaluating. + sleep_interruptible(RESEND_SETTLE, &stop); + } + SuperviseAction::GiveUp => { + println!( + " {} usage/quota limit reached — backing off won't help; leaving the session for you", + "🛑".red() + ); + break; + } + SuperviseAction::Wait => { + if governor.consecutive() > 0 { + governor.record_success(); + println!(" {} recovered — backoff reset", "✓".green()); + } + } + } + + sleep_interruptible(opts.poll, &stop); + } + + Ok(()) +} + +fn fmt_dur(d: Duration) -> String { + let secs = d.as_secs(); + if secs >= 60 { + format!("{}m{}s", secs / 60, secs % 60) + } else { + format!("{secs}s") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn action_mapping() { + assert_eq!(action_for(PaneState::RateLimited), SuperviseAction::Rescue); + assert_eq!(action_for(PaneState::UsageLimit), SuperviseAction::GiveUp); + assert_eq!(action_for(PaneState::Working), SuperviseAction::Wait); + assert_eq!(action_for(PaneState::Idle), SuperviseAction::Wait); + assert_eq!(action_for(PaneState::AwaitingApproval), SuperviseAction::Wait); + assert_eq!(action_for(PaneState::Errored), SuperviseAction::Wait); + assert_eq!(action_for(PaneState::Unknown), SuperviseAction::Wait); + } + + #[test] + fn short_id_is_hex_and_unique() { + let a = short_id(); + let b = short_id(); + assert_eq!(a.len(), 8); + assert!(a.chars().all(|c| c.is_ascii_hexdigit())); + assert_ne!(a, b, "ids from successive calls should differ"); + } + + #[test] + fn interruptible_sleep_returns_early_on_stop() { + let stop = AtomicBool::new(true); + let start = Instant::now(); + sleep_interruptible(Duration::from_secs(30), &stop); + assert!(start.elapsed() < Duration::from_secs(1), "should have bailed immediately"); + } + + #[test] + fn interruptible_sleep_waits_when_not_stopped() { + let stop = AtomicBool::new(false); + let start = Instant::now(); + sleep_interruptible(Duration::from_millis(300), &stop); + assert!(start.elapsed() >= Duration::from_millis(250)); + } + + #[test] + fn fmt_dur_formats_minutes() { + assert_eq!(fmt_dur(Duration::from_secs(45)), "45s"); + assert_eq!(fmt_dur(Duration::from_secs(125)), "2m5s"); + } +} diff --git a/crates/smooth-cli/src/main.rs b/crates/smooth-cli/src/main.rs index ec93c4b8..8a4b8379 100644 --- a/crates/smooth-cli/src/main.rs +++ b/crates/smooth-cli/src/main.rs @@ -7,6 +7,7 @@ mod active_org; mod admin; mod auth; mod boot_ui; +mod claude; mod config; mod gradient; mod hooks; @@ -290,6 +291,13 @@ enum Commands { }, /// Open the Smooth web dashboard in your browser. Web, + /// Supervise Claude Code sessions running inside tmux. On the + /// account-wide rate-limit throttle, back off with jitter and resend + /// the last message until it lands. `run` / `ls` / `attach`. + Claude { + #[command(subcommand)] + cmd: claude::ClaudeCommands, + }, /// Git worktree management Worktree { #[command(subcommand)] @@ -1414,6 +1422,7 @@ async fn main() -> Result<()> { println!("Start with: th up"); Ok(()) } + Some(Commands::Claude { cmd }) => claude::cmd_claude(cmd).await, Some(Commands::Worktree { cmd }) => cmd_worktree(cmd), Some(Commands::Tailscale { cmd }) => cmd_tailscale(cmd), Some(Commands::Access { cmd }) => cmd_access(cmd).await, diff --git a/crates/smooth-tmux/Cargo.toml b/crates/smooth-tmux/Cargo.toml new file mode 100644 index 00000000..91d88dea --- /dev/null +++ b/crates/smooth-tmux/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "smooai-smooth-tmux" +# Internal crate — part of the `th` binary, not a public library. +publish = false +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Smooth tmux driver — spawn, drive, and capture programs running inside isolated tmux sessions" + +[lib] +name = "smooth_tmux" + +[dependencies] +anyhow.workspace = true +uuid.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/smooth-tmux/src/lib.rs b/crates/smooth-tmux/src/lib.rs new file mode 100644 index 00000000..af620fc2 --- /dev/null +++ b/crates/smooth-tmux/src/lib.rs @@ -0,0 +1,489 @@ +//! `smooth-tmux` — a small, generic driver for running and steering +//! programs inside isolated tmux sessions. +//! +//! This crate carries the hard-won tmux glue from the bench harness +//! (`smooth-bench`) in a dependency-light form so the shipped `th` +//! binary can drive interactive TUIs (notably Claude Code) without +//! pulling the heavy benchmark dependency tree. +//! +//! Design notes baked in from prior pain: +//! - **Per-driver socket isolation** (`tmux -L `): every driver +//! gets a fresh tmux server so one driver's `Drop` can never tear down +//! another's session, and a stale session can never be inherited. +//! - **Bracketed-paste send**: multi-line payloads are pasted as one +//! submission rather than N newline-split submissions. +//! - **Scrollback capture** (`-S - -J`): the full history is captured, +//! not just the visible region, so a supervisor can see content that +//! scrolled off the top. +//! +//! The pure, IO-free helpers (`make_socket_name`, the `*_args` builders, +//! `truncate_from_front`, `samples_are_stable`) are public so they can be +//! unit-tested without a live tmux. + +// `wait_for_idle` folds Duration-millis (u128) into a small usize sample +// count; the truncation is intentional and bounded. +#![allow(clippy::cast_possible_truncation)] + +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Context, Result}; + +/// Default pane geometry. Wide enough that Claude Code's status line and +/// boxes render without wrapping artifacts that confuse pane scraping. +pub const PANE_WIDTH: u16 = 200; +/// Default pane height. +pub const PANE_HEIGHT: u16 = 50; + +/// Default front-truncation budget for [`TmuxDriver::capture`]. A chatty +/// session can produce tens of KiB; we keep the most recent bytes. +pub const DEFAULT_CAPTURE_MAX_BYTES: usize = 64 * 1024; + +/// A handle to a program running inside its own isolated tmux session. +/// +/// Dropping the driver kills the session (best effort) so callers don't +/// leak tmux servers. +pub struct TmuxDriver { + socket: String, + session: String, + capture_max_bytes: usize, +} + +/// Build the tmux socket name for a driver keyed on `session`. +/// +/// The name is made unique per process and per nanosecond so concurrent +/// drivers — and retries within one process — never collide. Non +/// alphanumeric characters in `session` are folded to `-`, and the +/// session-derived prefix is truncated so the final socket path stays +/// well under macOS's 104-byte `sun_path` limit. +#[must_use] +pub fn make_socket_name(session: &str) -> String { + let ns = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()); + let pid = std::process::id(); + let cleaned: String = session.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect(); + let stub: String = cleaned.chars().take(28).collect(); + format!("smth-{stub}-{pid}-{ns}") +} + +/// Arguments for `tmux new-session -d` in an isolated server. +/// +/// The `-L ` flag MUST come first — tmux treats it as a session +/// argument otherwise. +#[must_use] +pub fn new_session_args(socket: &str, session: &str, width: u16, height: u16, workdir: &str, shell_cmd: &str) -> Vec { + vec![ + "-L".into(), + socket.into(), + "new-session".into(), + "-d".into(), + "-s".into(), + session.into(), + "-x".into(), + width.to_string(), + "-y".into(), + height.to_string(), + "-c".into(), + workdir.into(), + "sh".into(), + "-c".into(), + shell_cmd.into(), + ] +} + +/// Arguments for `tmux capture-pane`. +/// +/// With `scrollback`, capture the full history (`-S -`) and join wrapped +/// lines (`-J`); without it, capture only the visible region (cheaper; +/// good for scraping the status line). +#[must_use] +pub fn capture_args(socket: &str, session: &str, scrollback: bool) -> Vec { + let mut args = vec!["-L".into(), socket.into(), "capture-pane".into(), "-t".into(), session.into(), "-p".into()]; + if scrollback { + args.push("-S".into()); + args.push("-".into()); + args.push("-J".into()); + } + args +} + +/// Truncate `s` from the FRONT (oldest content) so it fits in `max` +/// bytes, prepending a marker when truncation occurred. Recent content +/// is the most valuable to a supervisor, so we keep the tail. +#[must_use] +pub fn truncate_from_front(s: &str, max: usize) -> String { + const MARKER: &str = "…[truncated]…\n"; + if s.len() <= max { + return s.to_string(); + } + let keep = max.saturating_sub(MARKER.len()); + // Find a char boundary at or after `s.len() - keep`. + let mut start = s.len() - keep; + while start < s.len() && !s.is_char_boundary(start) { + start += 1; + } + format!("{MARKER}{}", &s[start..]) +} + +/// True when the last `dwell` samples are all byte-identical and +/// non-empty — i.e. the pane has stopped changing. `samples` is ordered +/// oldest→newest; only the last `dwell` are considered. +#[must_use] +pub fn samples_are_stable(samples: &[String], dwell: usize) -> bool { + if dwell == 0 || samples.len() < dwell { + return false; + } + let tail = &samples[samples.len() - dwell..]; + let first = &tail[0]; + !first.is_empty() && tail.iter().all(|s| s == first) +} + +impl TmuxDriver { + /// Spawn `shell_cmd` inside a fresh, isolated tmux session rooted at + /// `workdir`, then wait until the pane first renders. + /// + /// # Errors + /// - `tmux` is not on `PATH`. + /// - The session could not be created. + /// - The pane never rendered within `boot_timeout`. + pub fn start(session: &str, workdir: &Path, shell_cmd: &str, boot_timeout: Duration) -> Result { + require_tmux()?; + let socket = make_socket_name(session); + + let args = new_session_args(&socket, session, PANE_WIDTH, PANE_HEIGHT, &workdir.to_string_lossy(), shell_cmd); + let out = Command::new("tmux").args(&args).output().context("spawning tmux new-session")?; + if !out.status.success() { + return Err(anyhow!( + "tmux new-session for `{session}` exited non-zero: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + + let driver = Self { + socket, + session: session.to_string(), + capture_max_bytes: DEFAULT_CAPTURE_MAX_BYTES, + }; + + // Gate on the session being fully present (not on rendered + // content): right after `new-session -d` the pane may not yet + // exist for `capture-pane`, but a program that renders nothing + // until it receives input (e.g. `cat`) would never satisfy a + // "non-empty render" gate. "Session is up and a capture + // succeeds" is the right minimal guarantee that subsequent + // send/capture won't race creation. Waiting for a specific first + // render is the caller's job (`wait_for_idle` or poll for a + // marker). + let deadline = Instant::now() + boot_timeout; + loop { + if driver.is_alive() && driver.capture_visible().is_ok() { + return Ok(driver); + } + if Instant::now() >= deadline { + return Err(anyhow!("tmux session `{session}` never came up within {boot_timeout:?}")); + } + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// The tmux socket this driver owns. + #[must_use] + pub fn socket(&self) -> &str { + &self.socket + } + + /// The tmux session name. + #[must_use] + pub fn session(&self) -> &str { + &self.session + } + + /// Override the front-truncation budget for [`capture`](Self::capture). + pub fn set_capture_max_bytes(&mut self, n: usize) { + self.capture_max_bytes = n; + } + + /// Submit `text` to the pane as one bracketed-paste, followed by an + /// explicit `Enter`. A unique tmux buffer is used per send so + /// concurrent drivers never trample each other's payloads. + /// + /// # Errors + /// On any underlying tmux command failure. + pub fn send(&self, text: &str) -> Result<()> { + let buffer = format!("smth-{}-{}", self.session, uuid::Uuid::new_v4().simple()); + + let mut child = Command::new("tmux") + .args(["-L", &self.socket, "load-buffer", "-b", &buffer, "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .context("spawning tmux load-buffer")?; + { + use std::io::Write; + let stdin = child.stdin.as_mut().context("tmux load-buffer stdin missing")?; + stdin.write_all(text.as_bytes()).context("writing payload to tmux load-buffer")?; + } + let out = child.wait_with_output().context("waiting on tmux load-buffer")?; + if !out.status.success() { + return Err(anyhow!("tmux load-buffer exited non-zero: {}", String::from_utf8_lossy(&out.stderr).trim())); + } + + // `-p` wraps the paste in bracketed-paste markers so a TUI treats + // embedded newlines as soft newlines, not Enter. `-d` deletes the + // buffer afterward. + let out = Command::new("tmux") + .args(["-L", &self.socket, "paste-buffer", "-b", &buffer, "-t", &self.session, "-d", "-p"]) + .output() + .context("tmux paste-buffer")?; + if !out.status.success() { + let _ = Command::new("tmux") + .args(["-L", &self.socket, "delete-buffer", "-b", &buffer]) + .stderr(Stdio::null()) + .stdout(Stdio::null()) + .status(); + return Err(anyhow!("tmux paste-buffer exited non-zero: {}", String::from_utf8_lossy(&out.stderr).trim())); + } + + self.send_enter() + } + + /// Send a bare `Enter` keystroke (submit the current input). + /// + /// # Errors + /// On tmux failure. + pub fn send_enter(&self) -> Result<()> { + let out = Command::new("tmux") + .args(["-L", &self.socket, "send-keys", "-t", &self.session, "Enter"]) + .output() + .context("tmux send-keys (Enter)")?; + if !out.status.success() { + return Err(anyhow!( + "tmux send-keys (Enter) exited non-zero: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(()) + } + + /// Send a named key (e.g. `Escape`, `C-c`) to the pane. + /// + /// # Errors + /// On tmux failure. + pub fn send_key(&self, key: &str) -> Result<()> { + let out = Command::new("tmux") + .args(["-L", &self.socket, "send-keys", "-t", &self.session, key]) + .output() + .context("tmux send-keys")?; + if !out.status.success() { + return Err(anyhow!( + "tmux send-keys `{key}` exited non-zero: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(()) + } + + /// Capture the pane including full scrollback, front-truncated to the + /// configured byte budget. + /// + /// # Errors + /// On tmux failure (e.g. the session was killed because the child + /// exited). An empty pane returns `Ok("")`. + pub fn capture(&self) -> Result { + let raw = self.capture_raw(true)?; + Ok(truncate_from_front(&raw, self.capture_max_bytes)) + } + + /// Capture only the currently visible pane (no scrollback). + /// + /// # Errors + /// On tmux failure. + pub fn capture_visible(&self) -> Result { + self.capture_raw(false) + } + + fn capture_raw(&self, scrollback: bool) -> Result { + let args = capture_args(&self.socket, &self.session, scrollback); + let out = Command::new("tmux").args(&args).output().context("tmux capture-pane")?; + if !out.status.success() { + return Err(anyhow!( + "tmux capture-pane exited non-zero (session `{}`): {}", + self.session, + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } + + /// True while the tmux session is still alive. Once the child program + /// exits, tmux tears the session down and this returns `false`. + #[must_use] + pub fn is_alive(&self) -> bool { + Command::new("tmux") + .args(["-L", &self.socket, "has-session", "-t", &self.session]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + } + + /// Poll the pane every `poll_interval` and return its captured text + /// once it has been byte-identical for `dwell`. Errors after + /// `overall_timeout` regardless. + /// + /// # Errors + /// On tmux failure, or if the pane never settles in time. + pub fn wait_for_idle(&self, dwell: Duration, poll_interval: Duration, overall_timeout: Duration) -> Result { + let deadline = Instant::now() + overall_timeout; + let dwell_samples = (dwell.as_millis() / poll_interval.as_millis().max(1)).max(1) as usize + 1; + let mut samples: Vec = Vec::new(); + loop { + samples.push(self.capture()?); + if samples_are_stable(&samples, dwell_samples) { + return Ok(samples.pop().unwrap_or_default()); + } + if Instant::now() >= deadline { + return Err(anyhow!("pane for session `{}` never settled within {overall_timeout:?}", self.session)); + } + std::thread::sleep(poll_interval); + } + } + + /// Kill this driver's tmux session and server. + /// + /// # Errors + /// On tmux failure. Killing an already-dead session is not an error. + pub fn kill(&self) -> Result<()> { + let _ = Command::new("tmux") + .args(["-L", &self.socket, "kill-server"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + Ok(()) + } +} + +impl Drop for TmuxDriver { + fn drop(&mut self) { + let _ = self.kill(); + } +} + +fn require_tmux() -> Result<()> { + Command::new("tmux") + .arg("-V") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|e| anyhow!("tmux is required but could not be run ({e}); install it (macOS: `brew install tmux`)")) + .and_then(|s| { + if s.success() { + Ok(()) + } else { + Err(anyhow!("`tmux -V` failed; is tmux installed and on PATH?")) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn socket_name_is_unique_and_clean() { + let a = make_socket_name("SMOODEV-1/weird name"); + let b = make_socket_name("SMOODEV-1/weird name"); + assert_ne!(a, b, "two calls must differ (nanosecond clock)"); + assert!(a.starts_with("smth-")); + assert!(a.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'), "no socket-hostile chars: {a}"); + } + + #[test] + fn socket_name_truncates_long_session() { + let long = "x".repeat(200); + let name = make_socket_name(&long); + // Prefix stub is capped at 28 chars; total path must stay short. + assert!(name.len() < 80, "socket name too long: {} ({} bytes)", name, name.len()); + } + + #[test] + fn new_session_args_put_socket_first() { + let args = new_session_args("sock", "sess", 100, 40, "/tmp", "claude"); + assert_eq!(args[0], "-L"); + assert_eq!(args[1], "sock"); + assert_eq!(args[2], "new-session"); + assert!(args.contains(&"claude".to_string())); + // width/height rendered as strings in order. + let xi = args.iter().position(|a| a == "-x").unwrap(); + assert_eq!(args[xi + 1], "100"); + } + + #[test] + fn capture_args_scrollback_toggle() { + let with = capture_args("s", "sess", true); + assert!(with.contains(&"-S".to_string()) && with.contains(&"-J".to_string())); + let without = capture_args("s", "sess", false); + assert!(!without.contains(&"-S".to_string())); + assert!(without.contains(&"-p".to_string())); + } + + #[test] + fn truncate_keeps_tail_and_marks() { + let s = "abcdefghij".repeat(10); // 100 bytes + let out = truncate_from_front(&s, 40); + assert!(out.starts_with("…[truncated]…")); + assert!(out.ends_with("abcdefghij"), "tail preserved: {out}"); + assert!(out.len() <= 40 + "…[truncated]…\n".len()); + } + + #[test] + fn truncate_noop_when_small() { + assert_eq!(truncate_from_front("hi", 100), "hi"); + } + + #[test] + fn truncate_respects_char_boundary() { + let s = "αβγδε".repeat(20); // multibyte + let out = truncate_from_front(&s, 30); + // Must be valid UTF-8 (no panic on slice) and start with marker. + assert!(out.starts_with("…[truncated]…")); + assert!(std::str::from_utf8(out.as_bytes()).is_ok()); + } + + #[test] + fn stability_needs_full_dwell() { + let s = |x: &str| x.to_string(); + assert!(!samples_are_stable(&[s("a")], 2)); + assert!(!samples_are_stable(&[s("a"), s("b")], 2)); + assert!(samples_are_stable(&[s("b"), s("a"), s("a")], 2)); + assert!(!samples_are_stable(&[s("a"), s("a")], 0), "dwell 0 is never stable"); + } + + #[test] + fn stability_ignores_empty_panes() { + let s = |x: &str| x.to_string(); + assert!(!samples_are_stable(&[s(""), s("")], 2), "blank panes are not 'idle'"); + } + + /// Live tmux smoke test. Skips (does not fail) when tmux is absent so + /// CI runners without tmux stay green. + #[test] + fn live_roundtrip_when_tmux_available() { + if require_tmux().is_err() { + eprintln!("skipping: tmux not available"); + return; + } + let dir = tempfile::tempdir().unwrap(); + // `echo READY; cat` gives a deterministic first render and then + // echoes whatever we paste, so we can prove send+capture. + let driver = TmuxDriver::start("smth-test-roundtrip", dir.path(), "echo READY; cat", Duration::from_secs(5)).unwrap(); + assert!(driver.is_alive()); + driver.send("hello-tmux-smoke").unwrap(); + // Give cat a moment to echo. + let settled = driver + .wait_for_idle(Duration::from_millis(400), Duration::from_millis(100), Duration::from_secs(5)) + .unwrap_or_default(); + assert!(settled.contains("hello-tmux-smoke"), "echoed payload not seen; pane=\n{settled}"); + } +} diff --git a/docs/Engineering/Using-th-CLI.md b/docs/Engineering/Using-th-CLI.md index 05a48453..05e7d49a 100644 --- a/docs/Engineering/Using-th-CLI.md +++ b/docs/Engineering/Using-th-CLI.md @@ -472,6 +472,44 @@ th access pending / approve / deny / policy # access-control review queue th inbox # messages requiring attention ``` +### Claude session supervision (`th claude`) + +Drive a Claude Code TUI inside an isolated tmux session and keep it alive +through the account-wide rate-limit throttle ("Server is temporarily limiting +requests · Rate limited"). When that throttle fires, the supervisor backs off +with **full jitter** and **resends the last message** until it lands — instead +of leaving the turn dead on the screen. + +```bash +th claude run # launch + supervise an interactive session (attach to drive it) +th claude run "fix the flaky test" --label fixer # launch with an initial prompt +th claude run --cwd ../some-worktree # supervise a session rooted elsewhere +th claude ls # list live supervised sessions (prunes dead ones) +th claude ls --json +th claude attach # hand your terminal to a session (tmux attach; Ctrl-b d to detach) +``` + +How it decides what to do, per poll of the **visible** pane: + +- **`temporarily limiting requests` / `Rate limited`** → back off via the shared + governor and resend the last message (the one it sent, or — if it's babysitting + a session it didn't launch — the last user turn scraped from scrollback). +- **real `usage limit` / quota** → stop and hand the session back; backing off + won't help until reset. +- **`esc to interrupt` (working)** → the model is streaming; do nothing (this + live signal wins over a stale throttle line still on screen). + +The session lives as long as the supervisor runs; `Ctrl-C` stops it cleanly. +The rate-limit governor is **pool-aware**: it's the same primitive the planned +1→N farm (one Big Smooth leading N sessions) and N→1 supervisors share, so a 429 +on any session backs off the whole pool rather than thundering the herd. Pearls +th-49de8d (driver) / th-a43375 (attach picker). Requires `tmux` on `PATH`. + +> **Subscription/ToS note:** this drives your own Claude Code subscription auth. +> Backoff-and-resume that *honors* the limit is fine; running a large unattended +> fleet to maximize a flat-rate plan is the gray zone — keep concurrency +> tasteful, and use the metered API + smooth-operator for true fleet scale. + ### Worktree helpers ```bash From 9cd6a7f12e7e24040c0c323431826c4f49c83270 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Mon, 29 Jun 2026 19:02:19 -0400 Subject: [PATCH 2/3] =?UTF-8?q?th-49de8d:=20th=20claude=20mode=20=E2=80=94?= =?UTF-8?q?=20driving/manual/paused=20control=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-session control channel so Big Smooth and a human can share one tmux pane without typing over each other. `th claude mode driving|manual|paused` writes a control file the supervisor reads each poll: - driving: supervisor sends task input + steering and rescues rate-limits - manual: human drives (attach); supervisor only rescues their throttled turn - paused: supervisor stands down (no sending, no rescue) The initial prompt is only sent while driving; rate-limit rescue is gated on the mode. Worker sessions now launch with SMOOTH_AGENT_HANDLE / SMOOTH_SESSION exported so the smooth-agent plugin can register them on the th-mail bus. `th claude ls` gains a MODE column. control.rs parse logic is pure + unit-tested (35 tests pass; clippy -D warnings clean). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DXqPyj8SvxyUbfyRPvBA6P --- crates/smooth-cli/src/claude/control.rs | 146 +++++++++++++++++++++ crates/smooth-cli/src/claude/mod.rs | 53 +++++++- crates/smooth-cli/src/claude/supervisor.rs | 27 +++- 3 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 crates/smooth-cli/src/claude/control.rs diff --git a/crates/smooth-cli/src/claude/control.rs b/crates/smooth-cli/src/claude/control.rs new file mode 100644 index 00000000..f70563f2 --- /dev/null +++ b/crates/smooth-cli/src/claude/control.rs @@ -0,0 +1,146 @@ +//! Per-session control channel: who drives the pane. +//! +//! The supervisor (`th claude run`) and a human can share one tmux pane. +//! A per-session control file `/.control` arbitrates input +//! authority so the two never type at once. The supervisor reads it each +//! poll; `th claude mode ` writes it. +//! +//! The parse is pure (`parse_mode`) and unit tested; the IO wrappers are +//! thin. + +use std::fmt; +use std::path::PathBuf; +use std::str::FromStr; + +use anyhow::{anyhow, Context, Result}; + +use super::registry::registry_dir; + +/// Who currently has input authority over a supervised session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Mode { + /// Big Smooth drives: the supervisor sends task input + steering and + /// rescues rate-limits. + #[default] + Driving, + /// The human drives (attached): the supervisor sends no task input but + /// still rescues the human's own rate-limited turn. + Manual, + /// The supervisor only watches: no sending at all. + Paused, +} + +impl Mode { + /// May the supervisor send task input / steering? + #[must_use] + pub fn drives(self) -> bool { + matches!(self, Mode::Driving) + } + + /// May the supervisor resend on a rate-limit? + #[must_use] + pub fn rescues(self) -> bool { + matches!(self, Mode::Driving | Mode::Manual) + } + + /// Lowercase wire form. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Mode::Driving => "driving", + Mode::Manual => "manual", + Mode::Paused => "paused", + } + } +} + +impl fmt::Display for Mode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for Mode { + type Err = anyhow::Error; + fn from_str(s: &str) -> Result { + match s.trim().to_lowercase().as_str() { + "driving" | "drive" | "auto" => Ok(Mode::Driving), + "manual" | "human" | "take-over" | "takeover" => Ok(Mode::Manual), + "paused" | "pause" | "watch" => Ok(Mode::Paused), + other => Err(anyhow!("unknown mode `{other}` (expected driving|manual|paused)")), + } + } +} + +/// Parse a control-file body into a [`Mode`], defaulting to `Driving` for +/// empty/unrecognized contents. Pure — the IO-free core of [`read_mode`]. +#[must_use] +pub fn parse_mode(contents: &str) -> Mode { + contents.parse().unwrap_or_default() +} + +fn control_path(id: &str) -> PathBuf { + registry_dir().join(format!("{id}.control")) +} + +/// Read a session's mode, defaulting to `Driving` when the file is absent +/// or unreadable. +#[must_use] +pub fn read_mode(id: &str) -> Mode { + std::fs::read_to_string(control_path(id)).map(|s| parse_mode(&s)).unwrap_or_default() +} + +/// Write a session's mode to its control file. +/// +/// # Errors +/// On directory creation or write failure. +pub fn write_mode(id: &str, mode: Mode) -> Result<()> { + let path = control_path(id); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } + std::fs::write(&path, mode.as_str()).with_context(|| format!("writing {}", path.display()))?; + Ok(()) +} + +/// Remove a session's control file (best effort). +pub fn clear(id: &str) { + let _ = std::fs::remove_file(control_path(id)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_aliases() { + assert_eq!("driving".parse::().unwrap(), Mode::Driving); + assert_eq!("drive".parse::().unwrap(), Mode::Driving); + assert_eq!("MANUAL".parse::().unwrap(), Mode::Manual); + assert_eq!("take-over".parse::().unwrap(), Mode::Manual); + assert_eq!("pause".parse::().unwrap(), Mode::Paused); + assert!("nonsense".parse::().is_err()); + } + + #[test] + fn parse_mode_defaults_to_driving() { + assert_eq!(parse_mode(""), Mode::Driving); + assert_eq!(parse_mode("garbage"), Mode::Driving); + assert_eq!(parse_mode("manual"), Mode::Manual); + assert_eq!(parse_mode(" paused\n"), Mode::Paused); + } + + #[test] + fn authority_semantics() { + assert!(Mode::Driving.drives() && Mode::Driving.rescues()); + assert!(!Mode::Manual.drives() && Mode::Manual.rescues()); + assert!(!Mode::Paused.drives() && !Mode::Paused.rescues()); + } + + #[test] + fn display_roundtrips_through_parse() { + for m in [Mode::Driving, Mode::Manual, Mode::Paused] { + assert_eq!(m.to_string().parse::().unwrap(), m); + } + } +} diff --git a/crates/smooth-cli/src/claude/mod.rs b/crates/smooth-cli/src/claude/mod.rs index f2cb390d..000828e6 100644 --- a/crates/smooth-cli/src/claude/mod.rs +++ b/crates/smooth-cli/src/claude/mod.rs @@ -8,6 +8,7 @@ //! sessions on a shared governor) and N:1 / mixed topologies are later //! wirings of the same `supervisor` + `governor` + `registry`. +pub mod control; pub mod detect; pub mod governor; pub mod registry; @@ -61,6 +62,17 @@ pub enum ClaudeCommands { /// Session id (or unique prefix) from `th claude ls`. id: String, }, + /// Set who drives a session: `driving` (Big Smooth sends input + + /// rescues rate-limits), `manual` (you drive; the supervisor only + /// rescues your throttled turns), or `paused` (supervisor stands + /// down). Lets you hand control back and forth without killing the + /// session. + Mode { + /// Session id (or unique prefix) from `th claude ls`. + id: String, + /// `driving` | `manual` | `paused`. + mode: String, + }, } /// Dispatch a `th claude` subcommand. @@ -92,9 +104,23 @@ pub async fn cmd_claude(cmd: ClaudeCommands) -> Result<()> { } ClaudeCommands::Ls { json } => ls(json), ClaudeCommands::Attach { id } => attach(&id), + ClaudeCommands::Mode { id, mode } => set_mode(&id, &mode), } } +fn set_mode(id: &str, mode: &str) -> Result<()> { + let parsed: control::Mode = mode.parse()?; + // Resolve the id against live sessions so a typo fails loudly instead + // of silently writing a control file no supervisor reads. + let entry = registry::read_live_and_prune() + .into_iter() + .find(|e| e.id == id || e.id.starts_with(id)) + .ok_or_else(|| anyhow!("no live session matching `{id}` — try `th claude ls`"))?; + control::write_mode(&entry.id, parsed)?; + println!("{} session {} → {}", "⇄".cyan(), entry.id.bold(), parsed.to_string().bold()); + Ok(()) +} + async fn run(opts: RunOpts) -> Result<()> { let stop = Arc::new(AtomicBool::new(false)); let stop_for_task = stop.clone(); @@ -124,11 +150,19 @@ fn ls(json: bool) -> Result<()> { println!("No supervised Claude sessions. Start one with `{}`.", "th claude run".cyan()); return Ok(()); } - println!("{:<10} {:<12} {:<8} {}", "ID".bold(), "LABEL".bold(), "STARTED".bold(), "CWD".bold()); + println!( + "{:<10} {:<8} {:<12} {:<8} {}", + "ID".bold(), + "MODE".bold(), + "LABEL".bold(), + "STARTED".bold(), + "CWD".bold() + ); for e in &live { println!( - "{:<10} {:<12} {:<8} {}", + "{:<10} {:<8} {:<12} {:<8} {}", e.id.cyan(), + control::read_mode(&e.id).as_str(), e.label.as_deref().unwrap_or("-"), e.started_at.format("%H:%M").to_string(), e.cwd.dimmed() @@ -203,4 +237,19 @@ mod tests { assert!(Harness::try_parse_from(["x", "attach"]).is_err()); assert!(Harness::try_parse_from(["x", "attach", "abc"]).is_ok()); } + + #[test] + fn mode_parses_id_and_mode() { + use clap::Parser; + let h = Harness::try_parse_from(["x", "mode", "ab12", "manual"]).unwrap(); + match h.cmd { + super::ClaudeCommands::Mode { id, mode } => { + assert_eq!(id, "ab12"); + assert_eq!(mode, "manual"); + } + _ => panic!("expected Mode"), + } + // mode requires both args. + assert!(Harness::try_parse_from(["x", "mode", "ab12"]).is_err()); + } } diff --git a/crates/smooth-cli/src/claude/supervisor.rs b/crates/smooth-cli/src/claude/supervisor.rs index ec4f3016..9e7b769a 100644 --- a/crates/smooth-cli/src/claude/supervisor.rs +++ b/crates/smooth-cli/src/claude/supervisor.rs @@ -25,6 +25,7 @@ use chrono::Utc; use owo_colors::OwoColorize; use smooth_tmux::TmuxDriver; +use super::control; use super::detect::{detect_state, extract_last_user_message, PaneState}; use super::governor::RateLimitGovernor; use super::registry::{self, SessionEntry}; @@ -101,6 +102,7 @@ struct RegistryGuard(String); impl Drop for RegistryGuard { fn drop(&mut self) { registry::remove_entry(&self.0); + control::clear(&self.0); } } @@ -113,7 +115,14 @@ pub fn supervise_blocking(opts: RunOpts, stop: Arc) -> Result<()> { let id = short_id(); let session = format!("claude-{id}"); - let mut driver = TmuxDriver::start(&session, &opts.cwd, &opts.command, opts.boot_timeout)?; + // Export the agent handle so the `smooth-agent` Claude Code plugin's + // SessionStart hook registers this session on the th-mail bus under a + // handle Big Smooth can address (`th msg send --to `). The + // command is wrapped in `sh -c` by the driver, so an inline + // assignment reaches the launched process's environment. + let launch_cmd = format!("SMOOTH_AGENT_HANDLE={id} SMOOTH_SESSION={id} {}", opts.command); + + let mut driver = TmuxDriver::start(&session, &opts.cwd, &launch_cmd, opts.boot_timeout)?; driver.set_capture_max_bytes(128 * 1024); let entry = SessionEntry { @@ -131,12 +140,15 @@ pub fn supervise_blocking(opts: RunOpts, stop: Arc) -> Result<()> { println!("{} session {} ({})", "▶".green(), id.bold(), session.dimmed()); println!(" attach with: {}", format!("th claude attach {id}").cyan()); - // Wait for the TUI to render, then send the initial prompt. + // Wait for the TUI to render, then send the initial prompt — but only + // if Big Smooth is driving. In Manual/Paused the human owns input. let mut last_message = opts.initial_prompt.clone(); if let Some(prompt) = &opts.initial_prompt { - let _ = driver.wait_for_idle(Duration::from_secs(1), Duration::from_millis(300), Duration::from_secs(20)); - driver.send(prompt)?; - println!(" {} sent initial prompt", "→".green()); + if control::read_mode(&id).drives() { + let _ = driver.wait_for_idle(Duration::from_secs(1), Duration::from_millis(300), Duration::from_secs(20)); + driver.send(prompt)?; + println!(" {} sent initial prompt", "→".green()); + } } let governor = RateLimitGovernor::new(); @@ -151,8 +163,13 @@ pub fn supervise_blocking(opts: RunOpts, stop: Arc) -> Result<()> { break; } + let mode = control::read_mode(&id); let visible = driver.capture_visible().unwrap_or_default(); match action_for(detect_state(&visible)) { + SuperviseAction::Rescue if !mode.rescues() => { + // Paused: the human asked us to stand down entirely — don't + // resend even on a rate-limit. + } SuperviseAction::Rescue => { // Prefer the message we sent; fall back to scraping the // last user turn out of full scrollback. From 37282707053685cc626c977db1617d3762eb2b71 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Mon, 29 Jun 2026 19:02:32 -0400 Subject: [PATCH 3/3] th-49de8d: smooth Claude Code plugin marketplace + smooth-agent plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the team's first Claude Code plugin marketplace (.claude-plugin/ marketplace.json, name "smooth") hosted in this repo, and its flagship smooth-agent plugin — the recipe layer over the `th claude` engine: - commands/smooth.md: /smooth orchestrator (status/run/add-agent/drive/manual/ mail/ls/attach) that shells out to `th claude` + `th msg`/`th agent`/`th pearls` - skills/agent-comms: worker reports status / answers pings over th-mail - skills/pearls-flow: worker tracks work as pearls - hooks/register-agent.sh (SessionStart): auto-registers a worker on th-mail under $SMOOTH_AGENT_HANDLE so Big Smooth can address it; no-op for plain claude Why: makes the orchestration recipe + th-mail/pearls awareness installable with `/plugin install smooth-agent@smooth` and versioned, instead of hand-symlinking skills into ~/.claude. The stateful engine stays in the `th` binary; this is the declarative recipe layer. Repo-specific guardrail hooks stay project-scoped. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DXqPyj8SvxyUbfyRPvBA6P --- .changeset/th-claude-driver.md | 12 ++++ .claude-plugin/marketplace.json | 25 ++++++++ .../smooth-agent/.claude-plugin/plugin.json | 14 ++++ claude-plugins/smooth-agent/README.md | 53 +++++++++++++++ .../smooth-agent/commands/smooth.md | 64 +++++++++++++++++++ claude-plugins/smooth-agent/hooks/hooks.json | 15 +++++ .../smooth-agent/hooks/register-agent.sh | 20 ++++++ .../smooth-agent/skills/agent-comms/SKILL.md | 59 +++++++++++++++++ .../smooth-agent/skills/pearls-flow/SKILL.md | 39 +++++++++++ 9 files changed, 301 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 claude-plugins/smooth-agent/.claude-plugin/plugin.json create mode 100644 claude-plugins/smooth-agent/README.md create mode 100644 claude-plugins/smooth-agent/commands/smooth.md create mode 100644 claude-plugins/smooth-agent/hooks/hooks.json create mode 100755 claude-plugins/smooth-agent/hooks/register-agent.sh create mode 100644 claude-plugins/smooth-agent/skills/agent-comms/SKILL.md create mode 100644 claude-plugins/smooth-agent/skills/pearls-flow/SKILL.md diff --git a/.changeset/th-claude-driver.md b/.changeset/th-claude-driver.md index ed174147..22bd9343 100644 --- a/.changeset/th-claude-driver.md +++ b/.changeset/th-claude-driver.md @@ -13,7 +13,19 @@ from the pane when it didn't send it itself. `th claude attach ` hands your terminal to the session; `th claude ls` lists live sessions and prunes dead ones. +`th claude mode driving|manual|paused` hands control back and forth between +Big Smooth and a human sharing the same tmux pane: `driving` = the supervisor +sends input and rescues throttles, `manual` = the human drives and the supervisor +only rescues their throttled turns, `paused` = the supervisor stands down. Worker +sessions are launched with `SMOOTH_AGENT_HANDLE` exported so they can register on +the th-mail bus. + This is the 1:1 vertical slice of a broader topology (1→N Big-Smooth-led farm, N→1 per-session supervisors, and mixed), all built on the same supervisor + governor + registry primitives. The governor is shared so a 429 on any session backs off the whole pool rather than thundering the herd. + +Also adds the **`smooth` Claude Code plugin marketplace** (`.claude-plugin/ +marketplace.json`) with the **`smooth-agent`** plugin — a `/smooth` orchestrator +command plus `agent-comms` / `pearls-flow` worker skills and a SessionStart hook +that registers a worker on th-mail. The recipe layer over the `th claude` engine. diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..050ad40d --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "smooth", + "owner": { + "name": "SmooAI", + "email": "brent@smoo.ai" + }, + "metadata": { + "description": "SmooAI team Claude Code plugins — Big Smooth orchestration (th claude), agent comms (th-mail), and pearls work tracking.", + "pluginRoot": "./claude-plugins" + }, + "plugins": [ + { + "name": "smooth-agent", + "source": "smooth-agent", + "description": "Run a Big Smooth that drives Claude Code worker sessions over tmux (rate-limit-resilient), coordinate agents over th-mail, and track work in pearls. Provides the /smooth command.", + "version": "0.1.0", + "category": "orchestration", + "keywords": ["orchestration", "tmux", "multi-agent", "th-mail", "pearls", "rate-limit"], + "author": { + "name": "SmooAI" + } + } + ] +} diff --git a/claude-plugins/smooth-agent/.claude-plugin/plugin.json b/claude-plugins/smooth-agent/.claude-plugin/plugin.json new file mode 100644 index 00000000..497da94b --- /dev/null +++ b/claude-plugins/smooth-agent/.claude-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "smooth-agent", + "version": "0.1.0", + "description": "Big Smooth orchestration for Claude Code: tmux-supervised worker sessions that survive the account-wide rate-limit throttle, coordinate over th-mail, and track work in pearls. Provides the /smooth command.", + "author": { + "name": "SmooAI", + "email": "brent@smoo.ai" + }, + "homepage": "https://github.com/SmooAI/smooth", + "repository": "https://github.com/SmooAI/smooth", + "license": "MIT", + "keywords": ["orchestration", "tmux", "multi-agent", "th-mail", "pearls", "rate-limit"] +} diff --git a/claude-plugins/smooth-agent/README.md b/claude-plugins/smooth-agent/README.md new file mode 100644 index 00000000..69ff47ce --- /dev/null +++ b/claude-plugins/smooth-agent/README.md @@ -0,0 +1,53 @@ +# smooth-agent + +A Claude Code plugin for **Big Smooth orchestration** — drive Claude Code worker +sessions that survive the account-wide rate-limit throttle, coordinate them over +**th-mail**, and track work as **pearls**. Part of the `smooth` marketplace +(`SmooAI/smooth`). + +## What it gives you + +- **`/smooth`** command — the orchestrator surface. `run`, `add-agent`, `drive`/ + `manual`, `mail`, `status`, `ls`, `attach`. Drives the `th claude` engine. +- **`agent-comms`** skill — teaches a worker session to report status, answer + pings, and hand off work over `th msg`/`th agent`. +- **`pearls-flow`** skill — teaches a worker to track work as pearls + (`th pearls`). +- **SessionStart hook** — when a session is launched by `th claude run` (which + exports `SMOOTH_AGENT_HANDLE`), auto-registers it on the th-mail bus so Big + Smooth can address it by id. + +## Requires + +The `th` CLI (built from `SmooAI/smooth`) with the `th claude` engine, plus +`tmux` on `PATH`. The plugin is a thin recipe layer; the supervision, rate-limit +governor, and session control live in `th claude` (the binary). + +## Install + +``` +/plugin marketplace add SmooAI/smooth # or: /plugin marketplace add ./ from a local checkout +/plugin install smooth-agent@smooth +``` + +Then `th claude run ""` launches a supervised, plugin-active worker, and +`/smooth status` shows the farm. + +## How control works + +Each worker runs in a tmux session shared between Big Smooth and you. A per-session +**mode** arbitrates who types: + +- `driving` — Big Smooth sends input + rescues rate-limits. +- `manual` — you drive (`th claude attach `); the supervisor only rescues + your throttled turns. +- `paused` — the supervisor stands down. + +Flip with `/smooth drive ` / `/smooth manual ` or `th claude mode `. + +## Note on scale (subscription ToS) + +This drives Claude Code **subscription** auth. Backoff-and-resume that honors the +limit is fine; a large unattended fleet to maximize a flat-rate plan is the gray +zone — keep the worker count tasteful. True fleet scale belongs on the metered +API + smooth-operator. diff --git a/claude-plugins/smooth-agent/commands/smooth.md b/claude-plugins/smooth-agent/commands/smooth.md new file mode 100644 index 00000000..1567cca4 --- /dev/null +++ b/claude-plugins/smooth-agent/commands/smooth.md @@ -0,0 +1,64 @@ +--- +description: Big Smooth — orchestrate Claude Code worker sessions via the `th claude` engine (run, add-agent, drive/manual, mail, status) +argument-hint: "[status|run |add-agent |drive |manual |mail |ls|attach ] …" +allowed-tools: Bash(th claude:*), Bash(th msg:*), Bash(th agent:*), Bash(th pearls:*) +--- + +You are **Big Smooth**, the lead orchestrator. You coordinate Claude Code +**worker** sessions through the `th claude` engine — each worker runs in an +isolated tmux session that survives the account-wide rate-limit throttle +("temporarily limiting requests") by backing off with jitter and resending the +last message. You talk to workers two ways: by **driving their pane** (the engine +sends input while a session is in `driving` mode) and over **th-mail** +(`th msg`/`th agent`) for replies, status, and worker↔worker coordination. Track +all work as **pearls**. + +Current farm (live now): +!`th claude ls 2>/dev/null || echo "(no sessions; th claude not installed?)"` + +Mail waiting: +!`th msg inbox --pull --agent big-smooth 2>/dev/null | head -40 || echo "(none)"` + +## Interpret the request + +Mode = first word of `$ARGUMENTS`; the rest are its args. Dispatch: + +- **(empty) / `status`** — Summarize the farm above (ids, modes, labels) and any + waiting mail. Note which workers are `driving` vs `manual` vs `paused`. + +- **`run `** — Launch a supervised worker on ``: + `th claude run "" --label ` in the relevant working dir + (ask, or default to cwd). Tell the user the session id and that it will + self-heal rate-limits. Open a pearl for the task first + (`th pearls create --title=… --type=task`). + +- **`add-agent `** — Drop another worker into the pack: another + `th claude run "" --label `. Several supervised workers run in + parallel. Keep the count **tasteful** (subscription ToS — a big unattended + fleet is the gray zone; that scale belongs on the metered API). + +- **`drive ` / `manual ` / `pause `** — Hand control: + `th claude mode driving|manual|paused`. `driving` = Big Smooth sends input + and rescues throttles; `manual` = the human drives (attach with + `th claude attach `) and the supervisor only rescues their throttled turn; + `paused` = supervisor stands down. + +- **`mail `** — Steer a worker / broadcast over th-mail: + `th msg send --to --from big-smooth --body ""`. Read replies with + `th msg inbox --pull --agent big-smooth`; thread with `th msg thread `. + +- **`ls`** — `th claude ls`. **`attach `** — tell the user to run + `th claude attach ` themselves (attaching replaces the current process, so + you can't do it for them); `Ctrl-b d` detaches. + +## Operating rules + +- Prefer `th` over raw curl. Every tracked unit of work gets a pearl; close it + when the worker finishes. +- Workers launched via `th claude run` come up with `SMOOTH_AGENT_HANDLE=` set, + so the `smooth-agent` SessionStart hook auto-registers them on th-mail under + that id — address a worker as `th msg send --to `. +- Don't drive and let the human type at the same time: flip a session to `manual` + before handing it over, back to `driving` to resume. +- If a worker hits a **real usage/quota limit** (not the transient throttle), + backing off won't help — surface it and move on. diff --git a/claude-plugins/smooth-agent/hooks/hooks.json b/claude-plugins/smooth-agent/hooks/hooks.json new file mode 100644 index 00000000..e79e3b77 --- /dev/null +++ b/claude-plugins/smooth-agent/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/register-agent.sh" + } + ] + } + ] + } +} diff --git a/claude-plugins/smooth-agent/hooks/register-agent.sh b/claude-plugins/smooth-agent/hooks/register-agent.sh new file mode 100755 index 00000000..82b5c53d --- /dev/null +++ b/claude-plugins/smooth-agent/hooks/register-agent.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# smooth-agent SessionStart hook. +# +# Registers this Claude Code session on the th-mail bus so Big Smooth can +# reach it. No-op unless the session was launched by `th claude run`, which +# exports SMOOTH_AGENT_HANDLE=. stdout from a SessionStart hook +# is injected into the session as context. +set -euo pipefail + +handle="${SMOOTH_AGENT_HANDLE:-}" + +# Not a Big Smooth worker (a plain `claude` launch) → nothing to do. +[ -z "$handle" ] && exit 0 +command -v th >/dev/null 2>&1 || exit 0 + +# Idempotent registration; swallow errors so a hiccup never blocks startup. +th agent register --name "$handle" --harness claude-code >/dev/null 2>&1 || true + +echo "th-mail: online as agent '$handle'. Report status to Big Smooth and answer pings with the agent-comms skill; check 'th msg inbox --agent $handle'. Track work as pearls (pearls-flow skill)." +exit 0 diff --git a/claude-plugins/smooth-agent/skills/agent-comms/SKILL.md b/claude-plugins/smooth-agent/skills/agent-comms/SKILL.md new file mode 100644 index 00000000..f316ffe1 --- /dev/null +++ b/claude-plugins/smooth-agent/skills/agent-comms/SKILL.md @@ -0,0 +1,59 @@ +--- +name: agent-comms +description: Coordinate with Big Smooth and other agents over th-mail (the th msg / th agent bus) — report status, answer pings, and hand off work. Use when this session is a Big Smooth worker (launched via `th claude run`, with SMOOTH_AGENT_HANDLE set) or whenever you need to reach another agent. Invoke for "message the orchestrator", "tell big smooth", "reply to the agent", "who else is working". +--- + +# agent-comms — talk to Big Smooth and other agents over th-mail + +`th` ships a harness-agnostic agent mailbox: `th agent` (registry) + `th msg` +(mail), backed by the pearl Dolt store and synced over `refs/dolt/data`. When +this session was launched by `th claude run`, the `smooth-agent` SessionStart +hook already **registered** it under the handle in `$SMOOTH_AGENT_HANDLE` (your +session id), so Big Smooth can reach you. Your job is to **send** status and +**answer** pings — not to sit in a foreground poll. + +Your handle: `$SMOOTH_AGENT_HANDLE` (fall back to a name you pick if unset). +**Pass `--agent`/`--from ` on every command** — shell env doesn't persist +between Bash calls in this harness, and the default handle is `user@host`, not +yours. + +## Report status / hand off to the orchestrator + +```bash +th msg send --to big-smooth --from "$SMOOTH_AGENT_HANDLE" --body "done: ; pearl closed" +th msg send --to big-smooth --from "$SMOOTH_AGENT_HANDLE" --body "blocked: ; need " +``` + +Broadcast to everyone with `--to all`. Reply within a thread with `--re `. + +## Check for and answer pings + +```bash +th msg inbox --agent "$SMOOTH_AGENT_HANDLE" # local read (no lock contention) +th msg thread # full conversation if needed +th msg reply --from "$SMOOTH_AGENT_HANDLE" --body "…" +th msg inbox --unread --mark-read --agent "$SMOOTH_AGENT_HANDLE" # mark consumed +``` + +Check the inbox at natural breakpoints (finishing a step, before going idle). +Answer anything you can from context — a status request, an ack, a coordination +ping. Surface decisions that aren't yours to make to the user instead of +committing on their behalf. + +## See who's around + +```bash +th agent list # registered agents, most-recent first +``` + +## Footguns + +- **`--pull` writes to the shared Dolt store** and contends on its lock — + polling with `--pull` every few seconds once wedged *every* agent's mailbox + (`Error 1105: database is read only`). For same-machine agents the store is + local, so plain `th msg inbox` already sees new mail with **no** pull. Only + `--pull` occasionally, for genuinely cross-machine setups. +- **`th msg` (agent mail) ≠ `th inbox`** (operative review gates). Different + thing. +- One identity: always the same `--agent `, or you'll watch the wrong + mailbox. diff --git a/claude-plugins/smooth-agent/skills/pearls-flow/SKILL.md b/claude-plugins/smooth-agent/skills/pearls-flow/SKILL.md new file mode 100644 index 00000000..99f79621 --- /dev/null +++ b/claude-plugins/smooth-agent/skills/pearls-flow/SKILL.md @@ -0,0 +1,39 @@ +--- +name: pearls-flow +description: Track work as pearls (th pearls) — the dependency-graph work tracker shared across smooth/smooai. Create a pearl before starting work, claim it, close it when pushed. Use whenever you start a unit of work, are asked what to work on, or finish a task. Invoke for "track this", "what's ready", "file a pearl", "close it out". +--- + +# pearls-flow — track work as pearls + +`th pearls` is the work tracker (Dolt-backed, dependency-aware) used across the +SmooAI repos. As a Big Smooth worker, wrap each unit of work in a pearl so the +orchestrator and teammates can see what's in flight and what's done. + +## The loop + +```bash +th pearls ready # what's ready (open, no blockers) +th pearls show # details + dependencies + history +th pearls update --status=in_progress # claim it before you start +# … do the work … +th pearls close # when the work is committed/pushed +``` + +## Create work + +```bash +th pearls create --title="" --description="" --type=task|bug|feature --priority=2 +``` + +Priority is **0–4** (0 = critical, 2 = medium, 4 = backlog) — not "high"/"low". +Add dependencies with `th pearls dep add `. + +## Rules + +- **Create the pearl before writing code**; mark `in_progress` when you start; + close when pushed. Work isn't done until it's committed and pushed. +- Don't use ad-hoc TODO lists for multi-step work — pearls are the tracker. +- When you finish, report to the orchestrator over th-mail (see the + `agent-comms` skill): `th msg send --to big-smooth --from "$SMOOTH_AGENT_HANDLE" + --body "closed pearl : "`. +- Avoid interactive editor flows (`th pearls edit`) — they block on `$EDITOR`.