use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
#[cfg(unix)]
use std::os::fd::{AsRawFd, RawFd};
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
use arrrg::CommandLine;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use handled::SError;
use rc_conf::SwitchPosition;
use rustyline::DefaultEditor;
use rustyline::config::{Config, EditMode};
use rustyline::error::ReadlineError;
use serde_json::{Value, json};
use utf8path::Path;
use claudius::chat::{ChatAgent, ChatArgs, ChatConfig, ChatSession, SessionStats};
use claudius::{
Anthropic, ContentBlock, Effort, KnownModel, MessageParam, MessageParamContent, MessageRole,
Model, TokenRates,
};
use claudius::{OperatorLine, Renderer, StopReason, StreamContext};
use sid_isnt_done::config::{
AGENTS_CONF_FILE, AnthropicConfig, COMPACTION_PROMPT_ID, Config as SidConfig, TOOLS_CONF_FILE,
};
use sid_isnt_done::raw_mode::{RawInput, RawServer, RawToolOutputObserver, RawUsageReportObserver};
use sid_isnt_done::raw_protocol::{
RAW_PROTOCOL_VERSION, RawEvent, RawHello, RawPrompt, RawRequest, RawRequestEnvelope,
RawResultEnvelope, RawServerMessage, install_tool_output_observer,
install_usage_report_observer,
};
use sid_isnt_done::render::{CompactLineBuffer, PlainTextRenderer};
use sid_isnt_done::{
COMPACTION_REQUEST_PROMPT, SidAgent, append_resumed_bash_reset_marker, compacted_transcript,
extract_last_assistant_text, sanitize_transcript_messages, seatbelt, session,
session::SidSession,
};
const DEFAULT_SYSTEM_PROMPT: &str = concat!(
"You are sid, a concise coding agent with access to the current workspace mounted at /.\n",
"Use configured tools when they help accomplish the user's request.\n",
"Ground your answers in the files and tool results you can access, explain changes you make, and do not claim to have run commands or changed files unless you actually did."
);
const BUILTIN_AGENT_ID: &str = "sid";
const SANDBOX_UNAVAILABLE_WARNING: &str = concat!(
"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n",
"!! WARNING: /usr/bin/sandbox-exec is unavailable.\n",
"!! WARNING: sid will run bash and external tools UNSANDBOXED.\n",
"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n",
);
const MICRO_CENTS_PER_DOLLAR: f64 = 100_000_000.0;
const SESSION_SPEND_EXHAUSTED: &str = concat!(
"Session spend limit exhausted. ",
"Use /spend to increase or /spend clear to remove the limit."
);
const HISTORY_MAX_ENTRIES: usize = 100;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct SidSpendState {
limit_micro_cents: u64,
used_micro_cents: u64,
}
impl SidSpendState {
fn new(limit_micro_cents: u64) -> Self {
Self {
limit_micro_cents,
used_micro_cents: 0,
}
}
fn from_dollars(dollars: f64) -> Self {
Self::new(dollars_to_micro_cents(dollars))
}
fn remaining_micro_cents(&self) -> u64 {
self.limit_micro_cents.saturating_sub(self.used_micro_cents)
}
fn record_cost(&mut self, cost_micro_cents: u64) {
self.used_micro_cents = self.used_micro_cents.saturating_add(cost_micro_cents);
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct SpendUsageTotals {
input_tokens: u64,
output_tokens: u64,
cache_creation_tokens: u64,
cache_read_tokens: u64,
}
impl SpendUsageTotals {
fn from_stats_delta(before: &SessionStats, after: &SessionStats) -> Self {
Self {
input_tokens: after
.total_input_tokens
.saturating_sub(before.total_input_tokens),
output_tokens: after
.total_output_tokens
.saturating_sub(before.total_output_tokens),
cache_creation_tokens: after
.total_cache_creation_tokens
.saturating_sub(before.total_cache_creation_tokens),
cache_read_tokens: after
.total_cache_read_tokens
.saturating_sub(before.total_cache_read_tokens),
}
}
fn cost_micro_cents(&self, rates: TokenRates) -> u64 {
self.input_tokens
.saturating_mul(rates.input)
.saturating_add(self.output_tokens.saturating_mul(rates.output))
.saturating_add(
self.cache_creation_tokens
.saturating_mul(rates.cache_creation),
)
.saturating_add(self.cache_read_tokens.saturating_mul(rates.cache_read))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct SpendTurnClamp {
original_max_tokens: Option<u32>,
clamped_max_tokens: u32,
}
fn dollars_to_micro_cents(dollars: f64) -> u64 {
let result = dollars * MICRO_CENTS_PER_DOLLAR;
if result.is_finite() && result >= 0.0 {
result as u64
} else {
u64::MAX
}
}
fn model_token_rates(model: &Model) -> TokenRates {
match model {
Model::Known(KnownModel::ClaudeHaiku45 | KnownModel::ClaudeHaiku4520251001) => {
KnownModel::ClaudeHaiku45.token_rates()
}
Model::Known(
KnownModel::Claude37SonnetLatest
| KnownModel::Claude37Sonnet20250219
| KnownModel::ClaudeSonnet40
| KnownModel::ClaudeSonnet420250514
| KnownModel::Claude4Sonnet20250514
| KnownModel::ClaudeSonnet45
| KnownModel::ClaudeSonnet4520250929,
) => KnownModel::ClaudeSonnet45.token_rates(),
Model::Known(
KnownModel::ClaudeOpus4520251101
| KnownModel::ClaudeOpus45
| KnownModel::ClaudeOpus46
| KnownModel::ClaudeOpus47,
) => KnownModel::ClaudeOpus45.token_rates(),
Model::Known(_) | Model::Custom(_) => KnownModel::ClaudeSonnet45.token_rates(),
}
}
struct SidTerminal {
editor: DefaultEditor,
renderer: PlainTextRenderer,
histfile: PathBuf,
pending_history: Vec<String>,
compact_tool_output: bool,
raw_tool_output_compactors: BTreeMap<(String, String), CompactLineBuffer>,
}
impl SidTerminal {
fn new(
use_color: bool,
interrupted: Arc<AtomicBool>,
sid_home: PathBuf,
compact_tool_output: bool,
) -> Result<Self, SError> {
let config = Config::builder().edit_mode(EditMode::Vi).build();
let mut editor = DefaultEditor::with_config(config).map_err(|err| {
cli_error(
"readline_init_failed",
"failed to initialize the terminal editor",
)
.with_string_field("cause", &err.to_string())
})?;
let histfile = sid_home.join("histfile");
if let Some(parent) = histfile.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = editor.load_history(&histfile);
Ok(Self {
editor,
renderer: PlainTextRenderer::with_color_and_interrupt(use_color, interrupted)
.with_compact_tool_output(compact_tool_output),
histfile,
pending_history: Vec::new(),
compact_tool_output,
raw_tool_output_compactors: BTreeMap::new(),
})
}
fn read_line(&mut self, prompt: &str) -> io::Result<OperatorLine> {
match self.editor.readline(prompt) {
Ok(line) => Ok(OperatorLine::Line(line)),
Err(ReadlineError::Interrupted) => Ok(OperatorLine::Interrupted),
Err(ReadlineError::Eof) => Ok(OperatorLine::Eof),
Err(err) => Err(io::Error::other(err.to_string())),
}
}
fn add_history_entry(&mut self, line: &str) {
let Some(entry) = history_entry(line) else {
return;
};
if self.editor.add_history_entry(entry).unwrap_or(false) {
self.pending_history.push(entry.to_string());
self.flush_history();
}
}
fn flush_history(&mut self) {
if self.pending_history.is_empty() {
return;
}
if append_history_entries(&self.histfile, &self.pending_history).is_ok() {
self.pending_history.clear();
}
}
fn write_raw_tool_output(
&mut self,
tool_use_id: &str,
stream: &str,
bytes: &[u8],
) -> Result<(), SError> {
if !self.compact_tool_output {
return write_tool_output_bytes(stream, bytes);
}
let key = (tool_use_id.to_string(), stream.to_string());
let compactor = self
.raw_tool_output_compactors
.entry(key)
.or_insert_with(|| CompactLineBuffer::new(Vec::<u8>::new(), true));
let output = compactor.write(bytes);
if output.is_empty() {
Ok(())
} else {
write_tool_output_bytes(stream, &output)
}
}
fn finish_raw_tool_output(&mut self, tool_use_id: &str) -> Result<(), SError> {
let keys = self
.raw_tool_output_compactors
.keys()
.filter(|(id, _)| id == tool_use_id)
.cloned()
.collect::<Vec<_>>();
for (id, stream) in keys {
if let Some(mut compactor) = self
.raw_tool_output_compactors
.remove(&(id, stream.clone()))
{
let output = compactor.finish();
if !output.is_empty() {
write_tool_output_bytes(&stream, &output)?;
}
}
}
Ok(())
}
fn finish_all_raw_tool_output(&mut self) -> Result<(), SError> {
let keys = self
.raw_tool_output_compactors
.keys()
.cloned()
.collect::<Vec<_>>();
for (id, stream) in keys {
if let Some(mut compactor) = self
.raw_tool_output_compactors
.remove(&(id, stream.clone()))
{
let output = compactor.finish();
if !output.is_empty() {
write_tool_output_bytes(&stream, &output)?;
}
}
}
Ok(())
}
}
fn history_entry(line: &str) -> Option<&str> {
if line.chars().next().is_some_and(char::is_whitespace) {
return None;
}
let entry = line.trim();
if entry.is_empty() { None } else { Some(entry) }
}
fn append_history_entries(path: &std::path::Path, entries: &[String]) -> io::Result<()> {
if entries.is_empty() {
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = open_history_file(path)?;
let _lock = lock_history_file(&file)?;
let mut text = String::new();
file.read_to_string(&mut text)?;
let mut history = parse_history_file(&text);
history.extend(entries.iter().cloned());
if history.len() > HISTORY_MAX_ENTRIES {
history.drain(..history.len() - HISTORY_MAX_ENTRIES);
}
file.seek(SeekFrom::Start(0))?;
file.set_len(0)?;
write_history_file(&mut file, &history)?;
file.flush()
}
fn open_history_file(path: &std::path::Path) -> io::Result<fs::File> {
let mut options = OpenOptions::new();
options.read(true).write(true).create(true);
#[cfg(unix)]
options.mode(0o600);
let file = options.open(path)?;
#[cfg(unix)]
file.set_permissions(fs::Permissions::from_mode(0o600))?;
Ok(file)
}
fn parse_history_file(text: &str) -> Vec<String> {
let mut lines = text.lines();
let Some(first) = lines.next() else {
return Vec::new();
};
if first == "#V2" {
lines.filter_map(decode_history_line).collect()
} else {
std::iter::once(first)
.chain(lines)
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect()
}
}
fn decode_history_line(line: &str) -> Option<String> {
if line.is_empty() {
return None;
}
let mut decoded = String::with_capacity(line.len());
let mut chars = line.chars();
while let Some(ch) = chars.next() {
if ch != '\\' {
decoded.push(ch);
continue;
}
match chars.next() {
Some('n') => decoded.push('\n'),
Some('\\') => decoded.push('\\'),
Some(other) => {
decoded.push('\\');
decoded.push(other);
}
None => decoded.push('\\'),
}
}
Some(decoded)
}
fn write_history_file(mut writer: impl Write, entries: &[String]) -> io::Result<()> {
writer.write_all(b"#V2\n")?;
for entry in entries {
if entry.is_empty() {
continue;
}
write_history_entry(&mut writer, entry)?;
writer.write_all(b"\n")?;
}
Ok(())
}
fn write_history_entry(mut writer: impl Write, entry: &str) -> io::Result<()> {
for ch in entry.chars() {
match ch {
'\n' => writer.write_all(br"\n")?,
'\\' => writer.write_all(br"\\")?,
_ => write!(writer, "{ch}")?,
}
}
Ok(())
}
#[cfg(unix)]
struct HistoryFileLock {
fd: RawFd,
}
#[cfg(unix)]
fn lock_history_file(file: &fs::File) -> io::Result<HistoryFileLock> {
let fd = file.as_raw_fd();
if unsafe { libc::flock(fd, libc::LOCK_EX) } == 0 {
Ok(HistoryFileLock { fd })
} else {
Err(io::Error::last_os_error())
}
}
#[cfg(unix)]
impl Drop for HistoryFileLock {
fn drop(&mut self) {
let _ = unsafe { libc::flock(self.fd, libc::LOCK_UN) };
}
}
#[cfg(not(unix))]
struct HistoryFileLock;
#[cfg(not(unix))]
fn lock_history_file(_file: &fs::File) -> io::Result<HistoryFileLock> {
Ok(HistoryFileLock)
}
impl Renderer for SidTerminal {
fn start_agent(&mut self, context: &dyn StreamContext) {
self.renderer.start_agent(context);
}
fn finish_agent(&mut self, context: &dyn StreamContext, stop_reason: Option<&StopReason>) {
self.renderer.finish_agent(context, stop_reason);
}
fn print_text(&mut self, context: &dyn StreamContext, text: &str) {
self.renderer.print_text(context, text);
}
fn print_thinking(&mut self, context: &dyn StreamContext, text: &str) {
self.renderer.print_thinking(context, text);
}
fn print_error(&mut self, context: &dyn StreamContext, error: &str) {
self.renderer.print_error(context, error);
}
fn print_info(&mut self, context: &dyn StreamContext, info: &str) {
self.renderer.print_info(context, info);
}
fn start_tool_use(&mut self, context: &dyn StreamContext, name: &str, id: &str) {
self.renderer.start_tool_use(context, name, id);
}
fn print_tool_input(&mut self, context: &dyn StreamContext, partial_json: &str) {
self.renderer.print_tool_input(context, partial_json);
}
fn finish_tool_use(&mut self, context: &dyn StreamContext) {
self.renderer.finish_tool_use(context);
}
fn start_tool_result(
&mut self,
context: &dyn StreamContext,
tool_use_id: &str,
is_error: bool,
) {
self.renderer
.start_tool_result(context, tool_use_id, is_error);
}
fn print_tool_result_text(&mut self, context: &dyn StreamContext, text: &str) {
self.renderer.print_tool_result_text(context, text);
}
fn finish_tool_result(&mut self, context: &dyn StreamContext) {
self.renderer.finish_tool_result(context);
}
fn finish_response(&mut self, context: &dyn StreamContext) {
self.renderer.finish_response(context);
}
fn print_interrupted(&mut self, context: &dyn StreamContext) {
self.renderer.print_interrupted(context);
}
fn should_interrupt(&self) -> bool {
self.renderer.should_interrupt()
}
fn read_operator_line(&mut self, prompt: &str) -> io::Result<Option<OperatorLine>> {
self.read_line(prompt).map(Some)
}
}
struct PromptRenderer {
renderer: PlainTextRenderer,
}
impl PromptRenderer {
fn new(use_color: bool, interrupted: Arc<AtomicBool>, compact_tool_output: bool) -> Self {
Self {
renderer: PlainTextRenderer::with_color_and_interrupt(use_color, interrupted)
.with_compact_tool_output(compact_tool_output),
}
}
}
impl Renderer for PromptRenderer {
fn start_agent(&mut self, context: &dyn StreamContext) {
self.renderer.start_agent(context);
}
fn finish_agent(&mut self, context: &dyn StreamContext, stop_reason: Option<&StopReason>) {
self.renderer.finish_agent(context, stop_reason);
}
fn print_text(&mut self, _context: &dyn StreamContext, _text: &str) {}
fn print_thinking(&mut self, _context: &dyn StreamContext, _text: &str) {}
fn print_error(&mut self, context: &dyn StreamContext, error: &str) {
self.renderer.print_error(context, error);
}
fn print_info(&mut self, context: &dyn StreamContext, info: &str) {
self.renderer.print_info(context, info);
}
fn start_tool_use(&mut self, context: &dyn StreamContext, name: &str, id: &str) {
self.renderer.start_tool_use(context, name, id);
}
fn print_tool_input(&mut self, context: &dyn StreamContext, partial_json: &str) {
self.renderer.print_tool_input(context, partial_json);
}
fn finish_tool_use(&mut self, context: &dyn StreamContext) {
self.renderer.finish_tool_use(context);
}
fn start_tool_result(
&mut self,
context: &dyn StreamContext,
tool_use_id: &str,
is_error: bool,
) {
self.renderer
.start_tool_result(context, tool_use_id, is_error);
}
fn print_tool_result_text(&mut self, context: &dyn StreamContext, text: &str) {
self.renderer.print_tool_result_text(context, text);
}
fn finish_tool_result(&mut self, context: &dyn StreamContext) {
self.renderer.finish_tool_result(context);
}
fn finish_response(&mut self, context: &dyn StreamContext) {
self.renderer.finish_response(context);
}
fn print_interrupted(&mut self, context: &dyn StreamContext) {
self.renderer.print_interrupted(context);
}
fn should_interrupt(&self) -> bool {
self.renderer.should_interrupt()
}
fn read_operator_line(&mut self, prompt: &str) -> io::Result<Option<OperatorLine>> {
print!("{prompt}");
io::stdout().flush()?;
let mut line = String::new();
match io::stdin().read_line(&mut line) {
Ok(0) => {
println!();
Ok(Some(OperatorLine::Eof))
}
Ok(_) => Ok(Some(OperatorLine::Line(line))),
Err(err) if err.kind() == io::ErrorKind::Interrupted => {
println!();
Ok(Some(OperatorLine::Interrupted))
}
Err(err) => Err(err),
}
}
}
#[derive(arrrg_derive::CommandLine, Debug, Default, PartialEq, Eq)]
struct SidArgs {
#[arrrg(nested)]
param: ChatArgs,
#[arrrg(
optional,
"Run COMMAND through the builtin bash tool and exit",
"COMMAND"
)]
bash_debug: Option<String>,
#[arrrg(optional, "Resume an existing session by ID or directory", "SESSION")]
resume: Option<String>,
#[arrrg(flag, "Start a fresh session instead of resuming one")]
new: bool,
#[arrrg(optional, "Run one prompt non-interactively and exit", "PROMPT")]
prompt: Option<String>,
#[arrrg(optional, "Start the session using the named agent", "AGENT")]
agent: Option<String>,
#[arrrg(flag, "Run a JSONL protocol server on stdin/stdout")]
raw: bool,
#[arrrg(flag, "Compact live tool output in terminal display")]
compact: bool,
#[arrrg(
optional,
"Run a reconnectable JSONL protocol server on SPEC; implies --raw",
"SPEC"
)]
listen: Option<String>,
#[arrrg(
optional,
"Connect to a reconnectable JSONL protocol server on SPEC",
"SPEC"
)]
connect: Option<String>,
}
enum StartupSetup {
Local(Box<PreRuntimeSetup>),
Connect(ConnectSetup),
}
struct PreRuntimeSetup {
config: ChatConfig,
workspace_root: Path<'static>,
config_root: Path<'static>,
sid_session: Arc<SidSession>,
workspace_display: String,
session_display: String,
bash_debug: Option<String>,
prompt: Option<String>,
agent: Option<String>,
raw: bool,
listen: Option<String>,
compact: bool,
resumed: bool,
}
struct ConnectSetup {
spec: String,
use_color: bool,
compact: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AgentSummary {
id: String,
display_name: Option<String>,
description: Option<String>,
enabled: SwitchPosition,
current: bool,
}
#[derive(Debug, Clone, PartialEq)]
enum SidCommand {
Clear,
Compact,
System(String),
Spend(f64),
ClearSpend,
SaveTranscript(String),
LoadTranscript(String),
Help,
Quit,
Stats,
ShowConfig,
Invalid(String),
}
struct QuietRenderer;
impl Renderer for QuietRenderer {
fn print_text(&mut self, _context: &dyn StreamContext, _text: &str) {}
fn print_thinking(&mut self, _context: &dyn StreamContext, _text: &str) {}
fn print_error(&mut self, _context: &dyn StreamContext, _error: &str) {}
fn print_info(&mut self, _context: &dyn StreamContext, _info: &str) {}
fn start_tool_use(&mut self, _context: &dyn StreamContext, _name: &str, _id: &str) {}
fn print_tool_input(&mut self, _context: &dyn StreamContext, _partial_json: &str) {}
fn finish_tool_use(&mut self, _context: &dyn StreamContext) {}
fn start_tool_result(
&mut self,
_context: &dyn StreamContext,
_tool_use_id: &str,
_is_error: bool,
) {
}
fn print_tool_result_text(&mut self, _context: &dyn StreamContext, _text: &str) {}
fn finish_tool_result(&mut self, _context: &dyn StreamContext) {}
fn finish_response(&mut self, _context: &dyn StreamContext) {}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct SessionStatsRollup {
total_input_tokens: u64,
total_output_tokens: u64,
total_requests: u64,
total_cache_creation_tokens: u64,
total_cache_read_tokens: u64,
}
impl SessionStatsRollup {
fn add(&mut self, stats: &SessionStats) {
self.total_input_tokens = self
.total_input_tokens
.saturating_add(stats.total_input_tokens);
self.total_output_tokens = self
.total_output_tokens
.saturating_add(stats.total_output_tokens);
self.total_requests = self.total_requests.saturating_add(stats.total_requests);
self.total_cache_creation_tokens = self
.total_cache_creation_tokens
.saturating_add(stats.total_cache_creation_tokens);
self.total_cache_read_tokens = self
.total_cache_read_tokens
.saturating_add(stats.total_cache_read_tokens);
}
fn apply(&self, stats: &mut SessionStats) {
stats.total_input_tokens = stats
.total_input_tokens
.saturating_add(self.total_input_tokens);
stats.total_output_tokens = stats
.total_output_tokens
.saturating_add(self.total_output_tokens);
stats.total_requests = stats.total_requests.saturating_add(self.total_requests);
stats.total_cache_creation_tokens = stats
.total_cache_creation_tokens
.saturating_add(self.total_cache_creation_tokens);
stats.total_cache_read_tokens = stats
.total_cache_read_tokens
.saturating_add(self.total_cache_read_tokens);
}
}
#[derive(Clone, Debug, Default)]
struct SessionOverrides {
session_spend: Option<Option<f64>>,
}
impl SessionOverrides {
fn apply_to(&self, config: &mut ChatConfig) {
if let Some(session_spend) = self.session_spend {
config.set_session_spend(session_spend);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AgentSwitchResult {
NoChange,
Switched(AgentSummary),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CompactResult {
parent_session_id: String,
new_session_id: String,
new_session_root: String,
}
struct SidRuntimeSession {
client: Anthropic,
chat: ChatSession<SidAgent>,
fallback_config: ChatConfig,
workspace_root: Path<'static>,
config_root: Path<'static>,
sid_session: Arc<SidSession>,
current_agent_id: String,
overrides: SessionOverrides,
rolled_up_stats: SessionStatsRollup,
sid_spend: Option<SidSpendState>,
auto_compact_tokens: Option<u64>,
}
impl SidRuntimeSession {
#[allow(clippy::too_many_arguments)]
fn new(
client: Anthropic,
chat: ChatSession<SidAgent>,
fallback_config: ChatConfig,
workspace_root: Path<'static>,
config_root: Path<'static>,
sid_session: Arc<SidSession>,
current_agent_id: String,
auto_compact_tokens: Option<u64>,
) -> Self {
let sid_spend = chat
.config()
.session_spend
.as_ref()
.map(|spend| SidSpendState::new(spend.total_micro_cents()));
Self {
client,
chat,
fallback_config,
workspace_root,
config_root,
sid_session,
current_agent_id,
overrides: SessionOverrides::default(),
rolled_up_stats: SessionStatsRollup::default(),
sid_spend,
auto_compact_tokens,
}
}
fn current_agent_id(&self) -> &str {
&self.current_agent_id
}
fn config(&self) -> &ChatConfig {
self.chat.config()
}
fn stats(&self) -> SessionStats {
let mut stats = self.chat.stats();
self.rolled_up_stats.apply(&mut stats);
match self.sid_spend {
Some(spend) => {
stats.session_spend_micro_cents = Some(spend.limit_micro_cents);
stats.spend_used_micro_cents = spend.used_micro_cents;
}
None => {
stats.session_spend_micro_cents = None;
stats.spend_used_micro_cents = 0;
}
}
stats
}
fn should_auto_compact(&self) -> bool {
match self.auto_compact_tokens {
Some(threshold) => self.stats().total_output_tokens >= threshold,
None => false,
}
}
async fn send_message(
&mut self,
message: MessageParam,
renderer: &mut dyn Renderer,
) -> Result<(), claudius::Error> {
let model = self.chat.config().model();
let before = self.chat.stats();
let turn_start_len = self.chat.clone_messages().len();
let clamp = match self.spend_turn_clamp() {
Ok(clamp) => clamp,
Err(err) => {
renderer.print_error(&(), SESSION_SPEND_EXHAUSTED);
return Err(err);
}
};
if let Some(clamp) = clamp {
self.chat
.config_mut()
.set_max_tokens(clamp.clamped_max_tokens);
}
let (result, interrupted) = {
let mut renderer = InterruptTrackingRenderer::new(renderer);
let result = self.chat.send_message(message, &mut renderer).await;
(result, renderer.was_interrupted())
};
let after = self.chat.stats();
if let Some(clamp) = clamp {
self.chat.config_mut().template.max_tokens = clamp.original_max_tokens;
}
if result.is_ok() {
self.record_sid_spend_delta(&before, &after, &model);
if interrupted {
match self.rewind_interrupted_turn(turn_start_len) {
Ok(Some(note)) => renderer.print_info(&(), ¬e),
Ok(None) => {}
Err(err) => return Err(claudius::Error::unknown(err.to_string())),
}
}
}
result
}
fn spend_turn_clamp(&self) -> Result<Option<SpendTurnClamp>, claudius::Error> {
let Some(spend) = self.sid_spend else {
return Ok(None);
};
let remaining = spend.remaining_micro_cents();
let rates = model_token_rates(&self.chat.config().model());
let affordable_tokens = remaining / rates.output.max(1);
if affordable_tokens == 0 {
return Err(claudius::Error::bad_request(
"session spend exhausted",
Some("spend".to_string()),
));
}
let max_tokens = self.chat.config().max_tokens();
let affordable_tokens = u32::try_from(affordable_tokens).unwrap_or(u32::MAX);
if affordable_tokens < max_tokens {
Ok(Some(SpendTurnClamp {
original_max_tokens: self.chat.config().template.max_tokens,
clamped_max_tokens: affordable_tokens,
}))
} else {
Ok(None)
}
}
fn record_sid_spend_delta(
&mut self,
before: &SessionStats,
after: &SessionStats,
model: &Model,
) {
let Some(spend) = self.sid_spend.as_mut() else {
return;
};
let usage = SpendUsageTotals::from_stats_delta(before, after);
spend.record_cost(usage.cost_micro_cents(model_token_rates(model)));
}
fn clear(&mut self) -> Result<(), SError> {
self.chat.clear();
self.persist_transcript()
}
fn insert_system_message(&mut self, message: String) -> Result<(), SError> {
self.chat.insert_system_message(message);
self.persist_transcript()
}
fn save_transcript_to(&self, path: &str) -> Result<(), SError> {
self.chat
.save_transcript_to(path)
.map_err(|err| transcript_error("transcript_save_failed", path, &err.to_string()))
}
fn load_transcript_from(&mut self, path: &str) -> Result<(), SError> {
self.chat
.load_transcript_from(path)
.map_err(|err| transcript_error("transcript_load_failed", path, &err.to_string()))?;
self.sanitize_transcript();
self.rewind_trailing_incomplete_turn()?;
self.persist_transcript()
}
fn load_resumed_transcript(&mut self, resumed: bool) -> Result<(), SError> {
if !resumed {
return Ok(());
}
let transcript_path = self.sid_session.transcript_path();
if transcript_path.is_file() {
self.chat
.load_transcript_from(&transcript_path)
.map_err(|err| {
cli_error(
"resume_transcript_failed",
"failed to load resumed transcript",
)
.with_string_field("path", transcript_path.to_string_lossy().as_ref())
.with_string_field("cause", &err.to_string())
})?;
self.sanitize_transcript();
self.rewind_trailing_incomplete_turn()?;
}
let mut messages = self.chat.clone_messages();
append_resumed_bash_reset_marker(&mut messages);
self.chat.replace_messages(messages);
self.persist_transcript()
}
fn set_session_spend(&mut self, dollars: Option<f64>) {
self.chat.config_mut().set_session_spend(dollars);
self.overrides.session_spend = Some(dollars);
self.sid_spend = dollars.map(SidSpendState::from_dollars);
}
fn current_agent_summary(&self) -> Result<AgentSummary, SError> {
self.agent_summary(self.current_agent_id())?.ok_or_else(|| {
cli_error("missing_agent", "current agent is unavailable")
.with_string_field("agent", self.current_agent_id())
})
}
fn agent_summary(&self, agent_id: &str) -> Result<Option<AgentSummary>, SError> {
Ok(self
.list_agents()?
.into_iter()
.find(|summary| summary.id == agent_id))
}
fn list_agents(&self) -> Result<Vec<AgentSummary>, SError> {
load_agent_summaries(&self.config_root, self.current_agent_id())
}
fn switch_agent(&mut self, agent_id: &str) -> Result<AgentSwitchResult, SError> {
if agent_id == self.current_agent_id() {
return Ok(AgentSwitchResult::NoChange);
}
let messages = self.chat.clone_messages();
let mut next_agent = SidAgent::from_workspace_agent_with_config_root(
&self.workspace_root,
&self.config_root,
agent_id,
self.fallback_config.clone(),
)?
.with_session(self.sid_session.clone());
self.overrides.apply_to(next_agent.config_mut());
let next_client = build_anthropic_client(next_agent.anthropic_config())?;
self.auto_compact_tokens = next_agent.auto_compact_tokens();
self.roll_up_current_stats();
let mut next_chat = ChatSession::with_agent(next_client.clone(), next_agent);
next_chat.replace_messages(messages);
self.client = next_client;
self.chat = next_chat;
self.current_agent_id = agent_id.to_string();
self.sync_configured_sid_spend();
self.persist_transcript()?;
let summary = self.current_agent_summary()?;
Ok(AgentSwitchResult::Switched(summary))
}
fn sync_configured_sid_spend(&mut self) {
if self.sid_spend.is_some() || self.overrides.session_spend.is_some() {
return;
}
self.sid_spend = self
.chat
.config()
.session_spend
.as_ref()
.map(|spend| SidSpendState::new(spend.total_micro_cents()));
}
async fn compact(&mut self) -> Result<CompactResult, SError> {
let parent_session_id = self.sid_session.id().to_string();
let parent_session_dir = self.sid_session.root().to_string_lossy().into_owned();
let messages = self.chat.clone_messages();
let mut compactor_fallback = self.fallback_config.clone();
compactor_fallback.set_system_prompt(None);
compactor_fallback.transcript_path = None;
let mut compactor = SidAgent::from_workspace_compactor_with_config_root(
&self.workspace_root,
&self.config_root,
compactor_fallback,
)?
.with_memory_source(self.sid_session.compaction_provenance().cloned());
self.overrides.apply_to(compactor.config_mut());
let expert = compactor.compaction_snapshot();
let compaction_prompt = compactor
.named_prompt_markdown(COMPACTION_PROMPT_ID)
.unwrap_or(COMPACTION_REQUEST_PROMPT)
.to_string();
let compactor_client = build_anthropic_client(compactor.anthropic_config())?;
let mut compactor_chat = ChatSession::with_agent(compactor_client, compactor);
compactor_chat.replace_messages(messages);
let mut renderer = QuietRenderer;
compactor_chat
.send_message(
claudius::MessageParam::user(compaction_prompt),
&mut renderer,
)
.await
.map_err(|err| {
cli_error(
"compaction_failed",
"failed to generate conversation summary",
)
.with_string_field("cause", &err.to_string())
})?;
let summary =
extract_last_assistant_text(&compactor_chat.clone_messages()).ok_or_else(|| {
cli_error(
"compaction_empty",
"compaction agent produced no assistant summary",
)
})?;
let next_sid_session = Arc::new(SidSession::create_compacted_with_workspace(
&self.config_root,
&self.workspace_root,
session::CompactionProvenance {
session_id: parent_session_id.clone(),
session_dir: parent_session_dir,
expert,
},
)?);
let transcript_path = next_sid_session.transcript_path();
self.fallback_config.transcript_path = Some(transcript_path.clone());
let mut next_agent = SidAgent::from_workspace_agent_with_config_root(
&self.workspace_root,
&self.config_root,
&self.current_agent_id,
self.fallback_config.clone(),
)?
.with_session(next_sid_session.clone());
self.overrides.apply_to(next_agent.config_mut());
next_agent.config_mut().transcript_path = Some(transcript_path);
let next_client = build_anthropic_client(next_agent.anthropic_config())?;
self.auto_compact_tokens = next_agent.auto_compact_tokens();
let mut next_chat = ChatSession::with_agent(next_client.clone(), next_agent);
next_chat.replace_messages(compacted_transcript(&parent_session_id, &summary));
self.client = next_client;
self.chat = next_chat;
self.sid_session = next_sid_session.clone();
self.rolled_up_stats = SessionStatsRollup::default();
self.sync_configured_sid_spend();
self.persist_transcript()?;
Ok(CompactResult {
parent_session_id,
new_session_id: next_sid_session.id().to_string(),
new_session_root: next_sid_session.root().display().to_string(),
})
}
fn roll_up_current_stats(&mut self) {
let stats = self.chat.stats();
self.rolled_up_stats.add(&stats);
}
fn persist_transcript(&self) -> Result<(), SError> {
let Some(path) = self.chat.config().transcript_path.as_ref() else {
return Ok(());
};
self.chat
.save_transcript_to(path)
.map_err(|err| transcript_error_path("transcript_save_failed", path, &err.to_string()))
}
fn sanitize_transcript(&mut self) {
let mut messages = self.chat.clone_messages();
sanitize_transcript_messages(&mut messages);
self.chat.replace_messages(messages);
}
fn rewind_interrupted_turn(&mut self, turn_start_len: usize) -> Result<Option<String>, SError> {
let mut messages = self.chat.clone_messages();
let Some(rewind) = rewind_interrupted_messages(&mut messages, turn_start_len) else {
return Ok(None);
};
self.chat.replace_messages(messages);
self.persist_transcript()?;
Ok(Some(rewind.note))
}
fn rewind_trailing_incomplete_turn(&mut self) -> Result<Option<String>, SError> {
let mut messages = self.chat.clone_messages();
let Some(rewind) = rewind_trailing_incomplete_messages(&mut messages) else {
return Ok(None);
};
self.chat.replace_messages(messages);
self.persist_transcript()?;
Ok(Some(rewind.note))
}
fn last_assistant_text(&self) -> Option<String> {
extract_last_assistant_text(&self.chat.clone_messages())
}
#[cfg(test)]
fn clone_messages(&self) -> Vec<claudius::MessageParam> {
self.chat.clone_messages()
}
#[cfg(test)]
fn replace_messages(&mut self, messages: Vec<claudius::MessageParam>) -> Result<(), SError> {
self.chat.replace_messages(messages);
self.persist_transcript()
}
}
struct InterruptRewind {
note: String,
}
fn rewind_interrupted_messages(
messages: &mut Vec<MessageParam>,
turn_start_len: usize,
) -> Option<InterruptRewind> {
if messages.len() <= turn_start_len
|| !messages
.last()
.is_some_and(|message| message.role == MessageRole::Assistant)
{
return None;
}
let removed = messages.pop()?;
let note = interrupted_rewind_note(tail_line_from_message(&removed).as_deref());
messages.push(MessageParam::user(note.clone()));
Some(InterruptRewind { note })
}
fn rewind_trailing_incomplete_messages(
messages: &mut Vec<MessageParam>,
) -> Option<InterruptRewind> {
if !messages
.last()
.is_some_and(trailing_assistant_message_is_incomplete)
{
return None;
}
let removed = messages.pop()?;
let note = interrupted_rewind_note(tail_line_from_message(&removed).as_deref());
messages.push(MessageParam::user(note.clone()));
Some(InterruptRewind { note })
}
fn trailing_assistant_message_is_incomplete(message: &MessageParam) -> bool {
if message.role != MessageRole::Assistant {
return false;
}
match &message.content {
MessageParamContent::String(text) => text.trim().is_empty(),
MessageParamContent::Array(blocks) => {
blocks.is_empty()
|| blocks.iter().any(ContentBlock::is_tool_use)
|| blocks.iter().all(ContentBlock::is_thinking)
}
}
}
fn interrupted_rewind_note(tail: Option<&str>) -> String {
let tail = tail
.filter(|tail| !tail.trim().is_empty())
.unwrap_or("[no streamed content]");
format!("Rewound interrupted partial assistant message.\ncontinuing from: {tail}")
}
fn tail_line_from_message(message: &MessageParam) -> Option<String> {
let mut text = String::new();
match &message.content {
MessageParamContent::String(content) => text.push_str(content),
MessageParamContent::Array(blocks) => {
for block in blocks {
append_rewind_tail_text(&mut text, block);
}
}
}
let tail = text.lines().rev().find(|line| !line.trim().is_empty())?;
Some(truncate_tail_line(tail.trim(), 240))
}
fn append_rewind_tail_text(text: &mut String, block: &ContentBlock) {
if let Some(block) = block.as_text() {
text.push_str(&block.text);
} else if let Some(block) = block.as_thinking() {
text.push_str(&block.thinking);
} else if let Some(block) = block.as_tool_use() {
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&block.name);
text.push_str(" input: ");
match block.input.as_str() {
Some(input) => text.push_str(input),
None => text.push_str(&block.input.to_string()),
}
}
}
fn truncate_tail_line(line: &str, max_chars: usize) -> String {
let char_count = line.chars().count();
if char_count <= max_chars {
return line.to_string();
}
let keep = max_chars.saturating_sub(3);
let tail = line
.chars()
.skip(char_count.saturating_sub(keep))
.collect::<String>();
format!("...{tail}")
}
struct InterruptTrackingRenderer<'a> {
inner: &'a mut dyn Renderer,
interrupted: bool,
}
impl<'a> InterruptTrackingRenderer<'a> {
fn new(inner: &'a mut dyn Renderer) -> Self {
Self {
inner,
interrupted: false,
}
}
fn was_interrupted(&self) -> bool {
self.interrupted
}
}
impl Renderer for InterruptTrackingRenderer<'_> {
fn start_agent(&mut self, context: &dyn StreamContext) {
self.inner.start_agent(context);
}
fn finish_agent(&mut self, context: &dyn StreamContext, stop_reason: Option<&StopReason>) {
self.inner.finish_agent(context, stop_reason);
}
fn print_text(&mut self, context: &dyn StreamContext, text: &str) {
self.inner.print_text(context, text);
}
fn print_thinking(&mut self, context: &dyn StreamContext, text: &str) {
self.inner.print_thinking(context, text);
}
fn print_error(&mut self, context: &dyn StreamContext, error: &str) {
self.inner.print_error(context, error);
}
fn print_info(&mut self, context: &dyn StreamContext, info: &str) {
self.inner.print_info(context, info);
}
fn start_tool_use(&mut self, context: &dyn StreamContext, name: &str, id: &str) {
self.inner.start_tool_use(context, name, id);
}
fn print_tool_input(&mut self, context: &dyn StreamContext, partial_json: &str) {
self.inner.print_tool_input(context, partial_json);
}
fn finish_tool_use(&mut self, context: &dyn StreamContext) {
self.inner.finish_tool_use(context);
}
fn start_tool_result(
&mut self,
context: &dyn StreamContext,
tool_use_id: &str,
is_error: bool,
) {
self.inner.start_tool_result(context, tool_use_id, is_error);
}
fn print_tool_result_text(&mut self, context: &dyn StreamContext, text: &str) {
self.inner.print_tool_result_text(context, text);
}
fn finish_tool_result(&mut self, context: &dyn StreamContext) {
self.inner.finish_tool_result(context);
}
fn finish_response(&mut self, context: &dyn StreamContext) {
self.inner.finish_response(context);
}
fn print_interrupted(&mut self, context: &dyn StreamContext) {
self.interrupted = true;
self.inner.print_interrupted(context);
}
fn should_interrupt(&self) -> bool {
self.inner.should_interrupt()
}
fn read_operator_line(&mut self, prompt: &str) -> io::Result<Option<OperatorLine>> {
self.inner.read_operator_line(prompt)
}
}
fn pre_runtime_setup() -> Result<StartupSetup, SError> {
let SidArgs {
param,
bash_debug,
resume,
new,
prompt,
agent,
raw,
compact,
listen,
connect,
} = parse_sid_args()?;
if let Some(connect) = connect {
validate_connect_mode(
raw,
listen.as_deref(),
resume.as_deref(),
new,
bash_debug.as_deref(),
prompt.as_deref(),
)?;
let config = ChatConfig::try_from(param).map_err(|err| {
cli_error("invalid_cli_args", "failed to parse command line arguments")
.with_string_field("cause", &err.to_string())
})?;
return Ok(StartupSetup::Connect(ConnectSetup {
spec: connect,
use_color: config.use_color,
compact,
}));
}
let raw = raw || listen.is_some();
let mut config = ChatConfig::try_from(param).map_err(|err| {
cli_error("invalid_cli_args", "failed to parse command line arguments")
.with_string_field("cause", &err.to_string())
})?;
if config.template.system.is_none() {
config = config.with_system_prompt(DEFAULT_SYSTEM_PROMPT.to_string());
}
let workspace_root = Path::try_from(std::env::current_dir().map_err(|err| {
cli_error(
"io_error",
"failed to determine the current working directory",
)
.with_string_field("cause", &err.to_string())
})?)
.map_err(|err| {
cli_error(
"invalid_workspace_path",
"current working directory is not valid UTF-8",
)
.with_string_field("cause", &err.to_string())
})?
.into_owned();
let config_root = resolve_sid_home()?;
validate_session_selection(new, resume.as_deref(), prompt.as_deref())?;
let (sid_session, resumed) = match resume.as_deref() {
Some(session) => (Arc::new(SidSession::resume(&config_root, session)?), true),
None => (
Arc::new(SidSession::create_with_workspace(
&config_root,
&workspace_root,
)?),
false,
),
};
config.transcript_path = Some(sid_session.transcript_path());
unsafe {
std::env::set_var("SID_WORKSPACE_ROOT", workspace_root.as_str());
std::env::set_var(session::SID_SESSION_ID_ENV, sid_session.id());
std::env::set_var(session::SID_SESSION_DIR_ENV, sid_session.root());
std::env::set_var(session::SID_SESSIONS_ENV, sid_session.sessions_root());
}
let workspace_display = workspace_root.as_str().to_string();
let session_display = sid_session.root().display().to_string();
Ok(StartupSetup::Local(Box::new(PreRuntimeSetup {
config,
workspace_root,
config_root,
sid_session,
workspace_display,
session_display: if resumed {
format!("{session_display} (resumed)")
} else {
session_display
},
bash_debug,
prompt,
agent,
raw,
listen,
compact,
resumed,
})))
}
fn main() {
let setup = match pre_runtime_setup() {
Ok(s) => s,
Err(err) => {
eprintln!("{err}");
std::process::exit(1);
}
};
let runtime = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
if let Err(err) = runtime.block_on(try_main(setup)) {
eprintln!("{err}");
std::process::exit(1);
}
}
async fn try_main(setup: StartupSetup) -> Result<(), SError> {
let setup = match setup {
StartupSetup::Local(setup) => *setup,
StartupSetup::Connect(setup) => return run_connect_mode(setup),
};
let PreRuntimeSetup {
config,
workspace_root,
config_root,
sid_session,
workspace_display,
session_display,
bash_debug,
prompt,
raw,
agent: agent_id_override,
listen,
compact,
resumed,
} = setup;
validate_runtime_mode(raw, bash_debug.as_deref(), prompt.as_deref())?;
warn_if_sandbox_unavailable();
let agent = match agent_id_override.as_deref() {
Some(agent_id) => SidAgent::from_workspace_agent_with_config_root(
&workspace_root,
&config_root,
agent_id,
config.clone(),
)?,
None => SidAgent::from_workspace_with_config_root(
&workspace_root,
&config_root,
config.clone(),
)?,
}
.with_session(sid_session.clone())
.with_compact_tool_output(compact);
let agent_id = agent.id().to_string();
let startup_confirmation_required = agent.requires_confirmation();
let use_color = agent.config().use_color;
if raw {
let client = build_anthropic_client(agent.anthropic_config())?;
let auto_compact_tokens = agent.auto_compact_tokens();
let chat = ChatSession::with_agent(client.clone(), agent);
let mut session = SidRuntimeSession::new(
client,
chat,
config,
workspace_root.clone(),
config_root.clone(),
sid_session.clone(),
agent_id,
auto_compact_tokens,
);
session.load_resumed_transcript(resumed)?;
if let Some(listen) = listen {
return run_raw_session(
session,
sid_isnt_done::raw_listen::listen(&listen)
.map_err(|err| raw_io_error("failed to start raw listener", &err))?,
workspace_display,
session_display,
resumed,
startup_confirmation_required,
)
.await;
}
return run_raw_session(
session,
RawServer::stdio(),
workspace_display,
session_display,
resumed,
startup_confirmation_required,
)
.await;
}
if let Some(prompt) = prompt {
let interrupted = Arc::new(AtomicBool::new(false));
let mut renderer = PromptRenderer::new(use_color, interrupted.clone(), compact);
install_ctrlc_handler(interrupted)?;
if startup_confirmation_required && !confirm_manual_agent(&mut renderer, &agent_id)? {
println!("Aborted.");
return Ok(());
}
let client = build_anthropic_client(agent.anthropic_config())?;
let auto_compact_tokens = agent.auto_compact_tokens();
let chat = ChatSession::with_agent(client.clone(), agent);
let mut session = SidRuntimeSession::new(
client,
chat,
config,
workspace_root.clone(),
config_root.clone(),
sid_session.clone(),
agent_id.clone(),
auto_compact_tokens,
);
session.load_resumed_transcript(resumed)?;
return run_prompt_mode(session, prompt, &mut renderer).await;
}
let interrupted = Arc::new(AtomicBool::new(false));
let mut terminal = SidTerminal::new(
use_color,
interrupted.clone(),
PathBuf::from(config_root.as_str()),
compact,
)?;
let context = ();
if startup_confirmation_required && !confirm_manual_agent(&mut terminal, &agent_id)? {
println!("Aborted.");
return Ok(());
}
if let Some(command) = bash_debug {
return run_bash_debug(
&agent,
&agent_id,
&workspace_display,
&command,
&mut terminal,
)
.await;
}
let client = build_anthropic_client(agent.anthropic_config())?;
let auto_compact_tokens = agent.auto_compact_tokens();
let chat = ChatSession::with_agent(client.clone(), agent);
let mut session = SidRuntimeSession::new(
client,
chat,
config,
workspace_root.clone(),
config_root.clone(),
sid_session.clone(),
agent_id.clone(),
auto_compact_tokens,
);
session.load_resumed_transcript(resumed)?;
let interrupted_clone = interrupted.clone();
install_ctrlc_handler(interrupted_clone)?;
println!(
"sid (agent: {agent_id}, model: {})",
session.config().model()
);
println!("workspace: {workspace_display}");
println!("session: {session_display}");
println!("Type /help for commands, /quit to exit\n");
loop {
interrupted.store(false, Ordering::Relaxed);
match terminal.read_line("You: ") {
Ok(OperatorLine::Line(input)) => {
let line = input.trim();
if line.is_empty() {
continue;
}
terminal.add_history_entry(&input);
if line == "/edit" {
match invoke_external_editor() {
Ok(Some(content)) => {
let input = content;
let content = input.trim();
terminal.add_history_entry(&input);
let message = claudius::MessageParam::user(content);
if let Err(err) = session.send_message(message, &mut terminal).await {
terminal.print_error(&context, &err.to_string());
} else {
maybe_auto_compact(&mut session, &mut terminal, &context).await;
}
}
Ok(None) => {
terminal.print_info(&context, "Editor returned empty; nothing sent.");
}
Err(err) => {
terminal
.print_error(&context, &format!("Failed to invoke editor: {err}"));
}
}
continue;
}
if let Some(cmd) = parse_sid_command(line) {
match cmd {
SidCommand::Compact => match session.compact().await {
Ok(result) => terminal.print_info(
&context,
&format!(
"Compacted session {} into {} ({})",
result.parent_session_id,
result.new_session_id,
result.new_session_root
),
),
Err(err) => terminal.print_error(&context, &err.to_string()),
},
SidCommand::Quit => {
println!("Goodbye!");
break;
}
SidCommand::Clear => match session.clear() {
Ok(()) => terminal.print_info(&context, "Conversation cleared."),
Err(err) => terminal.print_error(&context, &err.to_string()),
},
SidCommand::System(prompt) => {
match session.insert_system_message(prompt.clone()) {
Ok(()) => terminal.print_info(
&context,
&format!("System message inserted: {prompt}"),
),
Err(err) => terminal.print_error(&context, &err.to_string()),
}
}
SidCommand::Help => {
print_help();
}
SidCommand::Spend(dollars) => {
session.set_session_spend(Some(dollars));
terminal.print_info(
&context,
&format!("Session spend limit set to ${dollars:.2}."),
);
}
SidCommand::ClearSpend => {
session.set_session_spend(None);
terminal.print_info(&context, "Session spend limit cleared.");
}
SidCommand::SaveTranscript(path) => {
match session.save_transcript_to(&path) {
Ok(()) => terminal
.print_info(&context, &format!("Transcript saved to {path}")),
Err(err) => terminal.print_error(
&context,
&format!("Failed to save transcript: {err}"),
),
}
}
SidCommand::LoadTranscript(path) => {
match session.load_transcript_from(&path) {
Ok(()) => terminal.print_info(
&context,
&format!("Transcript loaded from {path}"),
),
Err(err) => terminal.print_error(
&context,
&format!("Failed to load transcript: {err}"),
),
}
}
SidCommand::Stats => {
print_stats(&session.stats());
}
SidCommand::ShowConfig => {
print_config(&session.stats());
}
SidCommand::Invalid(message) => {
terminal.print_error(&context, &message);
}
}
continue;
}
let message = claudius::MessageParam::user(line);
if let Err(err) = session.send_message(message, &mut terminal).await {
terminal.print_error(&context, &err.to_string());
} else {
maybe_auto_compact(&mut session, &mut terminal, &context).await;
}
}
Ok(OperatorLine::Interrupted) => {
println!();
continue;
}
Ok(OperatorLine::Eof) => {
println!("\nGoodbye!");
break;
}
Err(err) => {
terminal.print_error(&context, &format!("Input error: {err}"));
break;
}
}
}
terminal.flush_history();
Ok(())
}
fn run_connect_mode(setup: ConnectSetup) -> Result<(), SError> {
let interrupted = Arc::new(AtomicBool::new(false));
let mut terminal = SidTerminal::new(
setup.use_color,
interrupted.clone(),
PathBuf::from(resolve_sid_home()?.as_str()),
setup.compact,
)?;
install_ctrlc_handler(interrupted.clone())?;
let mut client = RawTerminalClient::connect(&setup.spec, interrupted.clone())?;
let hello = client.read_hello(&mut terminal)?;
if !hello.sandbox_available {
eprint!("{SANDBOX_UNAVAILABLE_WARNING}");
}
println!(
"sid (agent: {}, model: {})",
hello.current_agent, hello.model
);
println!("workspace: {}", hello.workspace_root);
println!("session: {}", hello.session_dir);
println!("connected: {}", setup.spec);
println!("Type /help for commands, /quit to exit\n");
if hello.startup_confirmation_required {
let result = client.read_until_result("startup", &mut terminal)?;
if !result.ok {
terminal.print_error(&(), &raw_result_message(&result));
return Ok(());
}
}
client.drain_replay(&mut terminal)?;
loop {
interrupted.store(false, Ordering::Relaxed);
match terminal.read_line("You: ") {
Ok(OperatorLine::Line(input)) => {
let line = input.trim();
if line.is_empty() {
continue;
}
terminal.add_history_entry(&input);
if line == "/edit" {
match invoke_external_editor() {
Ok(Some(content)) => {
let input = content;
let content = input.trim();
terminal.add_history_entry(&input);
client.send_user_turn(content, &mut terminal)?;
}
Ok(None) => {
terminal.print_info(&(), "Editor returned empty; nothing sent.");
}
Err(err) => {
terminal.print_error(&(), &format!("Failed to invoke editor: {err}"));
}
}
continue;
}
if let Some(cmd) = parse_sid_command(line) {
if client.handle_sid_command(cmd, &mut terminal)? {
break;
}
continue;
}
client.send_user_turn(line, &mut terminal)?;
}
Ok(OperatorLine::Interrupted) => {
println!();
client.shutdown_server(&mut terminal)?;
break;
}
Ok(OperatorLine::Eof) => {
println!("\nGoodbye!");
break;
}
Err(err) => {
terminal.print_error(&(), &format!("Input error: {err}"));
break;
}
}
}
terminal.flush_history();
Ok(())
}
async fn run_prompt_mode(
mut session: SidRuntimeSession,
prompt: String,
renderer: &mut dyn Renderer,
) -> Result<(), SError> {
session
.send_message(claudius::MessageParam::user(prompt), renderer)
.await
.map_err(|err| cli_error("send_message_failed", &err.to_string()))?;
let response = session.last_assistant_text().ok_or_else(|| {
cli_error(
"empty_response",
"assistant produced no text response for --prompt",
)
})?;
maybe_auto_compact(&mut session, renderer, &()).await;
print!("{response}");
if !response.ends_with('\n') {
println!();
}
io::stdout().flush().map_err(|err| {
cli_error("io_error", "failed to flush prompt output")
.with_string_field("cause", &err.to_string())
})
}
struct RawTerminalClient {
connection: sid_isnt_done::raw_listen::RawConnection,
interrupted: Arc<AtomicBool>,
next_request_id: u64,
current_agent: Option<String>,
replay_complete: bool,
}
impl RawTerminalClient {
fn connect(spec: &str, interrupted: Arc<AtomicBool>) -> Result<Self, SError> {
let connection = sid_isnt_done::raw_listen::RawConnection::connect(spec)
.map_err(|err| raw_io_error("failed to connect raw listener", &err))?;
Ok(Self {
connection,
interrupted,
next_request_id: 1,
current_agent: None,
replay_complete: false,
})
}
fn read_hello(&mut self, terminal: &mut SidTerminal) -> Result<RawHello, SError> {
loop {
match self.read_message()? {
RawServerMessage::Hello(hello) => {
self.current_agent = Some(hello.current_agent.clone());
return Ok(hello);
}
message => {
self.handle_server_message(message, terminal, None)?;
}
}
}
}
fn drain_replay(&mut self, terminal: &mut SidTerminal) -> Result<(), SError> {
let mut pending_request_id = None;
while !self.replay_complete {
match self.read_message()? {
RawServerMessage::ReplayComplete(_) => {
self.replay_complete = true;
}
RawServerMessage::Hello(hello) => {
self.current_agent = Some(hello.current_agent);
}
RawServerMessage::Prompt(prompt) => {
pending_request_id.get_or_insert_with(|| prompt.request_id.clone());
self.answer_prompt(prompt, terminal)?;
}
RawServerMessage::Event(event) => {
render_raw_event(event.event, terminal)?;
}
RawServerMessage::Request(request) => {
pending_request_id = Some(request.request_id);
}
RawServerMessage::Result(result) => {
if pending_request_id.as_deref() == Some(result.request_id.as_str()) {
pending_request_id = None;
}
}
RawServerMessage::PromptAck(_) => {}
}
}
if let Some(request_id) = pending_request_id {
let result = self.read_until_result(&request_id, terminal)?;
if !result.ok {
terminal.print_error(&(), &raw_result_message(&result));
} else if let Some(data) = result.data.as_ref() {
self.update_identity(data);
}
}
Ok(())
}
fn send_user_turn(&mut self, text: &str, terminal: &mut SidTerminal) -> Result<(), SError> {
let result = self.send_request(
"turn",
RawRequest::UserTurn {
text: text.to_string(),
},
terminal,
)?;
if !result.ok {
terminal.print_error(&(), &raw_result_message(&result));
} else if let Some(data) = result.data.as_ref() {
self.update_identity(data);
}
Ok(())
}
fn shutdown_server(&mut self, terminal: &mut SidTerminal) -> Result<(), SError> {
let result = self.send_request("shutdown", RawRequest::Shutdown, terminal)?;
if !result.ok {
terminal.print_error(&(), &raw_result_message(&result));
}
Ok(())
}
fn handle_sid_command(
&mut self,
cmd: SidCommand,
terminal: &mut SidTerminal,
) -> Result<bool, SError> {
match cmd {
SidCommand::Compact => {
if let Some(data) =
self.send_request_data("compact", RawRequest::Compact, terminal)?
{
terminal.print_info(
&(),
&format!(
"Compacted session {} into {} ({})",
json_str(&data, "parent_session_id").unwrap_or("?"),
json_str(&data, "new_session_id").unwrap_or("?"),
json_str(&data, "new_session_root").unwrap_or("?")
),
);
}
}
SidCommand::Quit => {
println!("Goodbye!");
return Ok(true);
}
SidCommand::Clear => {
if self
.send_request_data("clear", RawRequest::Clear, terminal)?
.is_some()
{
terminal.print_info(&(), "Conversation cleared.");
}
}
SidCommand::System(prompt) => {
if self
.send_request_data(
"system",
RawRequest::InsertSystemMessage {
text: prompt.clone(),
},
terminal,
)?
.is_some()
{
terminal.print_info(&(), &format!("System message inserted: {prompt}"));
}
}
SidCommand::Help => {
print_help();
}
SidCommand::Spend(dollars) => {
if self
.send_request_data(
"spend",
RawRequest::SetSpend {
dollars: Some(dollars),
},
terminal,
)?
.is_some()
{
terminal.print_info(&(), &format!("Session spend limit set to ${dollars:.2}."));
}
}
SidCommand::ClearSpend => {
if self
.send_request_data("spend", RawRequest::SetSpend { dollars: None }, terminal)?
.is_some()
{
terminal.print_info(&(), "Session spend limit cleared.");
}
}
SidCommand::SaveTranscript(path) => {
if self
.send_request_data(
"save-transcript",
RawRequest::SaveTranscript { path: path.clone() },
terminal,
)?
.is_some()
{
terminal.print_info(&(), &format!("Transcript saved to {path}"));
}
}
SidCommand::LoadTranscript(path) => {
if self
.send_request_data(
"load-transcript",
RawRequest::LoadTranscript { path: path.clone() },
terminal,
)?
.is_some()
{
terminal.print_info(&(), &format!("Transcript loaded from {path}"));
}
}
SidCommand::Stats => {
if let Some(data) = self.send_request_data("stats", RawRequest::Stats, terminal)? {
print_remote_stats(&data);
}
}
SidCommand::ShowConfig => {
if let Some(data) =
self.send_request_data("config", RawRequest::ShowConfig, terminal)?
{
print_remote_config(&data);
}
}
SidCommand::Invalid(message) => {
terminal.print_error(&(), &message);
}
}
Ok(false)
}
fn send_request_data(
&mut self,
prefix: &str,
request: RawRequest,
terminal: &mut SidTerminal,
) -> Result<Option<Value>, SError> {
let result = self.send_request(prefix, request, terminal)?;
if result.ok {
if let Some(data) = result.data.as_ref() {
self.update_identity(data);
}
Ok(result.data)
} else {
terminal.print_error(&(), &raw_result_message(&result));
Ok(None)
}
}
fn send_request(
&mut self,
prefix: &str,
request: RawRequest,
terminal: &mut SidTerminal,
) -> Result<RawResultEnvelope, SError> {
let request_id = self.next_request_id(prefix);
self.interrupted.store(false, Ordering::Relaxed);
self.write_request(&request_id, request)?;
let _interrupt_watcher = self.start_interrupt_watcher(&request_id);
self.read_until_result(&request_id, terminal)
}
fn start_interrupt_watcher(&self, request_id: &str) -> RawInterruptWatcher {
let writer = self.connection.writer_handle();
let interrupted = self.interrupted.clone();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let request_id = format!("{request_id}:interrupt");
let handle = thread::spawn(move || {
while !stop_thread.load(Ordering::Relaxed) {
if interrupted.swap(false, Ordering::Relaxed) {
let _ = writer.write_request(&RawRequestEnvelope {
protocol_version: RAW_PROTOCOL_VERSION,
request_id,
request: RawRequest::Interrupt,
});
break;
}
thread::sleep(Duration::from_millis(25));
}
});
RawInterruptWatcher {
stop,
handle: Some(handle),
}
}
fn read_until_result(
&mut self,
request_id: &str,
terminal: &mut SidTerminal,
) -> Result<RawResultEnvelope, SError> {
loop {
let message = self.read_message()?;
if let Some(result) = self.handle_server_message(message, terminal, Some(request_id))? {
return Ok(result);
}
}
}
fn handle_server_message(
&mut self,
message: RawServerMessage,
terminal: &mut SidTerminal,
awaited_request_id: Option<&str>,
) -> Result<Option<RawResultEnvelope>, SError> {
match message {
RawServerMessage::Hello(hello) => {
self.current_agent = Some(hello.current_agent);
Ok(None)
}
RawServerMessage::ReplayComplete(_) => {
self.replay_complete = true;
Ok(None)
}
RawServerMessage::Request(_) => Ok(None),
RawServerMessage::Event(event) => {
render_raw_event(event.event, terminal)?;
Ok(None)
}
RawServerMessage::Prompt(prompt) => {
self.answer_prompt(prompt, terminal)?;
Ok(None)
}
RawServerMessage::PromptAck(_) => Ok(None),
RawServerMessage::Result(result) => {
if awaited_request_id == Some(result.request_id.as_str()) {
return Ok(Some(result));
}
if !result.ok {
terminal.print_error(&(), &raw_result_message(&result));
}
Ok(None)
}
}
}
fn answer_prompt(
&mut self,
prompt: RawPrompt,
terminal: &mut SidTerminal,
) -> Result<(), SError> {
loop {
let input = terminal
.read_operator_line(&prompt.message)
.map_err(|err| {
cli_error("io_error", "failed to read raw prompt response")
.with_string_field("prompt_id", &prompt.prompt_id)
.with_string_field("cause", &err.to_string())
})?;
let response = match input {
Some(OperatorLine::Line(input)) if prompt.kind == "confirmation" => {
match parse_confirmation(&input) {
Some(true) => "yes".to_string(),
Some(false) => "no".to_string(),
None => {
println!("Please answer yes or no.");
continue;
}
}
}
Some(OperatorLine::Line(input)) => input,
Some(OperatorLine::Interrupted) | Some(OperatorLine::Eof) | None => prompt
.choices
.iter()
.find(|choice| choice.eq_ignore_ascii_case("no"))
.cloned()
.or_else(|| prompt.choices.last().cloned())
.unwrap_or_default(),
};
let request_id = self.next_request_id("prompt");
self.write_request(
&request_id,
RawRequest::PromptResponse {
prompt_id: prompt.prompt_id,
response,
},
)?;
return Ok(());
}
}
fn next_request_id(&mut self, prefix: &str) -> String {
let request_id = format!("sid-connect-{prefix}-{}", self.next_request_id);
self.next_request_id = self.next_request_id.saturating_add(1);
request_id
}
fn read_message(&mut self) -> Result<RawServerMessage, SError> {
self.connection
.read_message()
.map_err(|err| raw_io_error("failed to read raw server message", &err))?
.ok_or_else(|| cli_error("io_error", "raw connection closed"))
}
fn write_request(&mut self, request_id: &str, request: RawRequest) -> Result<(), SError> {
self.connection
.write_request(&RawRequestEnvelope {
protocol_version: RAW_PROTOCOL_VERSION,
request_id: request_id.to_string(),
request,
})
.map_err(|err| raw_io_error("failed to write raw request", &err))
}
fn update_identity(&mut self, data: &Value) {
if let Some(agent) = json_str(data, "current_agent").or_else(|| json_str(data, "id")) {
self.current_agent = Some(agent.to_string());
}
}
}
struct RawInterruptWatcher {
stop: Arc<AtomicBool>,
handle: Option<thread::JoinHandle<()>>,
}
impl Drop for RawInterruptWatcher {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
struct RemoteStreamContext {
label: Option<String>,
depth: usize,
}
impl StreamContext for RemoteStreamContext {
fn label(&self) -> Option<&str> {
self.label.as_deref()
}
fn depth(&self) -> usize {
self.depth
}
}
fn render_raw_event(event: RawEvent, terminal: &mut SidTerminal) -> Result<(), SError> {
match event {
RawEvent::AgentStart { label, depth } => {
let context = RemoteStreamContext { label, depth };
terminal.start_agent(&context);
}
RawEvent::AgentFinish {
label,
depth,
stop_reason,
} => {
let context = RemoteStreamContext { label, depth };
let stop_reason = stop_reason.as_deref().and_then(parse_raw_stop_reason);
terminal.finish_agent(&context, stop_reason.as_ref());
}
RawEvent::AssistantTextDelta { label, depth, text } => {
let context = RemoteStreamContext { label, depth };
terminal.print_text(&context, &text);
}
RawEvent::ThinkingDelta { label, depth, text } => {
let context = RemoteStreamContext { label, depth };
terminal.print_thinking(&context, &text);
}
RawEvent::Info {
label,
depth,
message,
} => {
let context = RemoteStreamContext { label, depth };
terminal.print_info(&context, &message);
}
RawEvent::Error {
label,
depth,
message,
} => {
let context = RemoteStreamContext { label, depth };
terminal.print_error(&context, &message);
}
RawEvent::ToolUseStart {
label,
depth,
name,
tool_use_id,
} => {
let context = RemoteStreamContext { label, depth };
terminal.start_tool_use(&context, &name, &tool_use_id);
}
RawEvent::ToolInputDelta {
label,
depth,
partial_json,
} => {
let context = RemoteStreamContext { label, depth };
terminal.print_tool_input(&context, &partial_json);
}
RawEvent::ToolUseEnd { label, depth } => {
let context = RemoteStreamContext { label, depth };
terminal.finish_tool_use(&context);
}
RawEvent::ToolResultStart {
label,
depth,
tool_use_id,
is_error,
} => {
terminal.finish_raw_tool_output(&tool_use_id)?;
let context = RemoteStreamContext { label, depth };
terminal.start_tool_result(&context, &tool_use_id, is_error);
}
RawEvent::ToolResultTextDelta { label, depth, text } => {
let context = RemoteStreamContext { label, depth };
terminal.print_tool_result_text(&context, &text);
}
RawEvent::ToolResultEnd { label, depth } => {
let context = RemoteStreamContext { label, depth };
terminal.finish_tool_result(&context);
}
RawEvent::ResponseFinish { label, depth } => {
terminal.finish_all_raw_tool_output()?;
let context = RemoteStreamContext { label, depth };
terminal.finish_response(&context);
}
RawEvent::Interrupted { label, depth } => {
let context = RemoteStreamContext { label, depth };
terminal.print_interrupted(&context);
}
RawEvent::ToolOutput {
tool_use_id,
stream,
text,
data_b64,
..
} => {
let bytes = raw_tool_output_bytes(text.as_deref(), data_b64.as_deref())?;
terminal.write_raw_tool_output(&tool_use_id, &stream, &bytes)?;
}
}
Ok(())
}
fn parse_raw_stop_reason(value: &str) -> Option<StopReason> {
value.parse::<StopReason>().ok().or(match value {
"EndTurn" => Some(StopReason::EndTurn),
"MaxTokens" => Some(StopReason::MaxTokens),
"StopSequence" => Some(StopReason::StopSequence),
"ToolUse" => Some(StopReason::ToolUse),
"PauseTurn" => Some(StopReason::PauseTurn),
"Refusal" => Some(StopReason::Refusal),
"ModelContextWindowExceeded" => Some(StopReason::ModelContextWindowExceeded),
_ => None,
})
}
fn raw_tool_output_bytes(text: Option<&str>, data_b64: Option<&str>) -> Result<Vec<u8>, SError> {
let bytes = match (text, data_b64) {
(Some(text), _) => text.as_bytes().to_vec(),
(None, Some(data_b64)) => BASE64_STANDARD.decode(data_b64).map_err(|err| {
cli_error(
"invalid_raw_tool_output",
"failed to decode raw tool output",
)
.with_string_field("cause", &err.to_string())
})?,
(None, None) => Vec::new(),
};
Ok(bytes)
}
fn write_tool_output_bytes(stream: &str, bytes: &[u8]) -> Result<(), SError> {
if stream == "stderr" {
let mut stderr = io::stderr();
stderr.write_all(bytes).map_err(|err| {
cli_error("io_error", "failed to write raw tool stderr")
.with_string_field("cause", &err.to_string())
})?;
stderr.flush().map_err(|err| {
cli_error("io_error", "failed to flush raw tool stderr")
.with_string_field("cause", &err.to_string())
})?;
} else {
let mut stdout = io::stdout();
stdout.write_all(bytes).map_err(|err| {
cli_error("io_error", "failed to write raw tool stdout")
.with_string_field("cause", &err.to_string())
})?;
stdout.flush().map_err(|err| {
cli_error("io_error", "failed to flush raw tool stdout")
.with_string_field("cause", &err.to_string())
})?;
}
Ok(())
}
fn raw_result_message(result: &RawResultEnvelope) -> String {
result
.error
.as_ref()
.map(|error| error.message.clone())
.unwrap_or_else(|| "raw request failed".to_string())
}
fn parse_sid_command(input: &str) -> Option<SidCommand> {
let input = input.trim();
if !input.starts_with('/') {
return None;
}
let mut parts = input[1..].splitn(2, ' ');
let command = parts.next()?.to_ascii_lowercase();
let argument = parts.next().map(str::trim).filter(|s| !s.is_empty());
let result = match command.as_str() {
"clear" => SidCommand::Clear,
"compact" => match argument {
Some(_) => SidCommand::Invalid("/compact does not take any arguments".to_string()),
None => SidCommand::Compact,
},
"system" => match argument {
Some(arg) => SidCommand::System(arg.to_string()),
None => SidCommand::Invalid("/system requires a message".to_string()),
},
"help" | "?" => SidCommand::Help,
"quit" | "exit" | "q" => SidCommand::Quit,
"stats" | "status" => SidCommand::Stats,
"config" => SidCommand::ShowConfig,
"spend" => match argument {
Some(arg) if arg.eq_ignore_ascii_case("clear") => SidCommand::ClearSpend,
Some(arg) => match arg.parse::<f64>() {
Ok(value) if value.is_finite() && value > 0.0 => SidCommand::Spend(value),
_ => SidCommand::Invalid("/spend expects a positive dollar amount".to_string()),
},
None => SidCommand::Invalid("/spend requires a dollar amount".to_string()),
},
"save" => match argument {
Some(arg) => SidCommand::SaveTranscript(arg.to_string()),
None => SidCommand::Invalid("/save requires a file path".to_string()),
},
"load" => match argument {
Some(arg) => SidCommand::LoadTranscript(arg.to_string()),
None => SidCommand::Invalid("/load requires a file path".to_string()),
},
_ => SidCommand::Invalid(format!("Unknown command: /{command}")),
};
Some(result)
}
fn help_text() -> &'static str {
r#"Available commands:
/clear Clear conversation history
/compact Summarize the session and continue in a new child session
/edit Compose a message in an external editor
/system <prompt> Insert a system message into the conversation
/spend <dollars> Set session spend limit in dollars (or 'clear')
/save <file> Save the current transcript immediately
/load <file> Load a transcript from disk
/stats Show session statistics
/config Show current configuration
/help Show this help message
/quit Exit the chat"#
}
fn print_help() {
for line in help_text().lines() {
println!(" {line}");
}
}
fn load_agent_summaries(
config_root: &Path,
current_agent_id: &str,
) -> Result<Vec<AgentSummary>, SError> {
if !config_root
.join(AGENTS_CONF_FILE)
.is_file()
.unwrap_or(false)
&& !config_root.join(TOOLS_CONF_FILE).is_file().unwrap_or(false)
{
return Ok(vec![AgentSummary {
id: BUILTIN_AGENT_ID.to_string(),
display_name: None,
description: None,
enabled: SwitchPosition::Yes,
current: current_agent_id == BUILTIN_AGENT_ID,
}]);
}
let config = SidConfig::load(config_root)?;
Ok(config
.agents
.values()
.map(|agent| AgentSummary {
id: agent.id.clone(),
display_name: agent.display_name.clone(),
description: agent.description.clone(),
enabled: agent.enabled,
current: agent.id == current_agent_id,
})
.collect())
}
async fn maybe_auto_compact(
session: &mut SidRuntimeSession,
terminal: &mut dyn Renderer,
context: &(),
) {
if !session.should_auto_compact() {
return;
}
terminal.print_info(
context,
&format!(
"Auto-compacting: output token threshold ({}) reached.",
session.auto_compact_tokens.unwrap_or(0),
),
);
match session.compact().await {
Ok(result) => terminal.print_info(
context,
&format!(
"Compacted session {} into {} ({})",
result.parent_session_id, result.new_session_id, result.new_session_root
),
),
Err(err) => terminal.print_error(context, &format!("Auto-compaction failed: {err}")),
}
}
async fn run_raw_session(
mut session: SidRuntimeSession,
mut server: RawServer<impl RawInput, impl Write + Send + 'static>,
workspace_display: String,
session_display: String,
resumed: bool,
startup_confirmation_required: bool,
) -> Result<(), SError> {
server
.write_message(&RawServerMessage::Hello(RawHello {
protocol_version: RAW_PROTOCOL_VERSION,
sequence: 0,
session_id: session.sid_session.id().to_string(),
session_dir: session_display,
workspace_root: workspace_display,
current_agent: session.current_agent_id().to_string(),
model: session.config().model().to_string(),
resumed,
startup_confirmation_required,
sandbox_available: sid_isnt_done::seatbelt::sandbox_available(),
}))
.map_err(|err| raw_io_error("failed to write raw hello", &err))?;
if startup_confirmation_required {
server.set_request_id("startup".to_string());
let allowed = confirm_manual_agent(&mut server, session.current_agent_id())?;
if allowed {
server
.write_ok_result("startup", Some(json!({ "started": true })))
.map_err(|err| raw_io_error("failed to write raw startup result", &err))?;
} else {
server
.write_error_result(
"startup",
Some("manual_agent_denied"),
"manual agent start denied",
)
.map_err(|err| raw_io_error("failed to write raw startup error", &err))?;
server.clear_request_id();
return Ok(());
}
server.clear_request_id();
}
while let Some(request) = server
.read_request()
.map_err(|err| raw_io_error("failed to read raw request", &err))?
{
let request_id = request.request_id.clone();
if request.protocol_version != RAW_PROTOCOL_VERSION {
server
.write_error_result(
&request_id,
Some("unsupported_protocol_version"),
&format!(
"unsupported raw protocol version {}",
request.protocol_version
),
)
.map_err(|err| raw_io_error("failed to write raw protocol error", &err))?;
continue;
}
server
.write_accepted_request(&request)
.map_err(|err| raw_io_error("failed to write raw accepted request", &err))?;
server.set_request_id(request_id.clone());
let usage_observer = Arc::new(RawUsageReportObserver::new(server.output(), &request_id));
let _usage_observer = install_usage_report_observer(Some(usage_observer));
let result = handle_raw_request(&mut session, &mut server, request).await;
server.clear_request_id();
match result {
Ok(RequestDisposition::Continue(data)) => {
server
.write_ok_result(&request_id, data)
.map_err(|err| raw_io_error("failed to write raw result", &err))?;
}
Ok(RequestDisposition::Shutdown(data)) => {
server
.write_ok_result(&request_id, data)
.map_err(|err| raw_io_error("failed to write raw shutdown result", &err))?;
break;
}
Err(err) => {
server
.write_error_result(&request_id, None, &err.to_string())
.map_err(|io_err| raw_io_error("failed to write raw error result", &io_err))?;
}
}
}
Ok(())
}
enum RequestDisposition {
Continue(Option<Value>),
Shutdown(Option<Value>),
}
async fn handle_raw_request<R, W>(
session: &mut SidRuntimeSession,
server: &mut RawServer<R, W>,
request: RawRequestEnvelope,
) -> Result<RequestDisposition, SError>
where
R: RawInput,
W: std::io::Write + Send + 'static,
{
match request.request {
RawRequest::UserTurn { text } => {
let observer = Arc::new(RawToolOutputObserver::new(server.output()));
let _observer = install_tool_output_observer(Some(observer));
session
.send_message(claudius::MessageParam::user(text), server)
.await
.map_err(|err| cli_error("send_message_failed", &err.to_string()))?;
maybe_auto_compact(session, server, &()).await;
Ok(RequestDisposition::Continue(Some(session_identity_json(
session,
))))
}
RawRequest::InsertSystemMessage { text } => {
session.insert_system_message(text)?;
Ok(RequestDisposition::Continue(Some(session_identity_json(
session,
))))
}
RawRequest::PromptResponse { .. } => Err(cli_error(
"unexpected_prompt_response",
"prompt_response is only valid while the server is waiting on a prompt",
)),
RawRequest::Interrupt => Ok(RequestDisposition::Continue(None)),
RawRequest::ShowAgent => Ok(RequestDisposition::Continue(Some(agent_summary_json(
&session.current_agent_summary()?,
session,
)))),
RawRequest::ListAgents => Ok(RequestDisposition::Continue(Some(json!({
"agents": session
.list_agents()?
.iter()
.map(agent_json)
.collect::<Vec<_>>()
})))),
RawRequest::SwitchAgent { agent } => {
match session.agent_summary(&agent)? {
Some(summary)
if summary.enabled == SwitchPosition::Manual
&& !confirm_manual_agent(server, &summary.id)? =>
{
return Err(cli_error(
"manual_agent_denied",
"manual agent switch denied",
));
}
_ => {}
}
session.switch_agent(&agent)?;
Ok(RequestDisposition::Continue(Some(agent_summary_json(
&session.current_agent_summary()?,
session,
))))
}
RawRequest::Compact => {
let result = session.compact().await?;
Ok(RequestDisposition::Continue(Some(json!({
"parent_session_id": result.parent_session_id,
"new_session_id": result.new_session_id,
"new_session_root": result.new_session_root,
"current_agent": session.current_agent_id(),
}))))
}
RawRequest::Clear => {
session.clear()?;
Ok(RequestDisposition::Continue(Some(session_identity_json(
session,
))))
}
RawRequest::SetSpend { dollars } => {
session.set_session_spend(dollars);
Ok(RequestDisposition::Continue(Some(config_json(
&session.stats(),
))))
}
RawRequest::SaveTranscript { path } => {
session.save_transcript_to(&path)?;
Ok(RequestDisposition::Continue(Some(json!({ "path": path }))))
}
RawRequest::LoadTranscript { path } => {
session.load_transcript_from(&path)?;
Ok(RequestDisposition::Continue(Some(session_identity_json(
session,
))))
}
RawRequest::Stats => Ok(RequestDisposition::Continue(Some(stats_json(
&session.stats(),
)))),
RawRequest::ShowConfig => Ok(RequestDisposition::Continue(Some(config_json(
&session.stats(),
)))),
RawRequest::Shutdown => Ok(RequestDisposition::Shutdown(Some(json!({
"session_id": session.sid_session.id(),
})))),
}
}
fn agent_summary_json(summary: &AgentSummary, session: &SidRuntimeSession) -> Value {
let mut agent = agent_json(summary);
if let Some(object) = agent.as_object_mut() {
object.insert(
"model".to_string(),
json!(session.config().model().to_string()),
);
}
agent
}
fn agent_json(summary: &AgentSummary) -> Value {
json!({
"id": summary.id,
"display_name": summary.display_name,
"description": summary.description,
"enabled": describe_agent_enabled(summary.enabled),
"current": summary.current,
})
}
fn session_identity_json(session: &SidRuntimeSession) -> Value {
json!({
"session_id": session.sid_session.id(),
"session_dir": session.sid_session.root().display().to_string(),
"current_agent": session.current_agent_id(),
"model": session.config().model().to_string(),
})
}
fn stats_json(stats: &SessionStats) -> Value {
json!({
"model": stats.model.to_string(),
"message_count": stats.message_count,
"max_tokens": stats.max_tokens,
"system_prompt": stats.system_prompt,
"temperature": stats.temperature,
"top_p": stats.top_p,
"top_k": stats.top_k,
"stop_sequences": stats.stop_sequences,
"thinking_budget": stats.thinking_budget,
"thinking": describe_thinking(stats),
"thinking_adaptive": stats.thinking_adaptive,
"effort": stats.effort.map(effort_name),
"session_spend_micro_cents": stats.session_spend_micro_cents,
"spend_used_micro_cents": stats.spend_used_micro_cents,
"transcript_path": stats.transcript_path.as_ref().map(|path| path.display().to_string()),
"total_input_tokens": stats.total_input_tokens,
"total_output_tokens": stats.total_output_tokens,
"total_requests": stats.total_requests,
"last_turn_input_tokens": stats.last_turn_input_tokens,
"last_turn_output_tokens": stats.last_turn_output_tokens,
"caching_enabled": stats.caching_enabled,
"total_cache_creation_tokens": stats.total_cache_creation_tokens,
"total_cache_read_tokens": stats.total_cache_read_tokens,
})
}
fn config_json(stats: &SessionStats) -> Value {
json!({
"model": stats.model.to_string(),
"max_tokens": stats.max_tokens,
"system_prompt": stats.system_prompt,
"temperature": stats.temperature,
"top_p": stats.top_p,
"top_k": stats.top_k,
"stop_sequences": stats.stop_sequences,
"thinking": describe_thinking(stats),
"thinking_budget": stats.thinking_budget,
"thinking_adaptive": stats.thinking_adaptive,
"effort": stats.effort.map(effort_name),
"caching_enabled": stats.caching_enabled,
"transcript_path": stats.transcript_path.as_ref().map(|path| path.display().to_string()),
})
}
fn effort_name(effort: Effort) -> &'static str {
match effort {
Effort::Low => "low",
Effort::Medium => "medium",
Effort::High => "high",
Effort::XHigh => "xhigh",
Effort::Max => "max",
}
}
fn raw_io_error(context: &str, err: &io::Error) -> SError {
cli_error("io_error", context).with_string_field("cause", &err.to_string())
}
fn describe_agent_enabled(enabled: SwitchPosition) -> &'static str {
match enabled {
SwitchPosition::Yes => "YES",
SwitchPosition::Manual => "MANUAL",
SwitchPosition::No => "NO",
}
}
fn transcript_error(code: &str, path: &str, cause: &str) -> SError {
cli_error(code, "transcript operation failed")
.with_string_field("path", path)
.with_string_field("cause", cause)
}
fn transcript_error_path(code: &str, path: &std::path::Path, cause: &str) -> SError {
cli_error(code, "transcript operation failed")
.with_string_field("path", &path.display().to_string())
.with_string_field("cause", cause)
}
fn parse_sid_args() -> Result<SidArgs, SError> {
let (args, free) = SidArgs::from_command_line_relaxed("sid [OPTIONS]");
validate_no_free_args(&free)?;
Ok(args)
}
fn validate_no_free_args(free: &[String]) -> Result<(), SError> {
if free.is_empty() {
return Ok(());
}
Err(
cli_error("invalid_cli_args", "unexpected positional arguments")
.with_string_field("args", &free.join(" ")),
)
}
async fn run_bash_debug(
agent: &SidAgent,
agent_id: &str,
workspace_display: &str,
command: &str,
terminal: &mut SidTerminal,
) -> Result<(), SError> {
eprintln!("sid bash debug (agent: {agent_id})");
eprintln!("workspace: {workspace_display}");
match agent.bash_with_renderer(command, false, terminal).await {
Ok(output) => {
print!("{output}");
if !output.ends_with('\n') {
println!();
}
io::stdout().flush().map_err(|err| {
cli_error("io_error", "failed to flush bash debug output")
.with_string_field("cause", &err.to_string())
})?;
Ok(())
}
Err(err) => {
let mut message = err.to_string();
if err.kind() == io::ErrorKind::Unsupported {
message.push_str("\nhint: configure bash in tools.conf or set SID_HOME=init");
}
Err(cli_error("bash_debug_failed", &message))
}
}
}
fn cli_error(code: &str, message: &str) -> SError {
SError::new("sid-cli").with_code(code).with_message(message)
}
fn build_anthropic_client(config: &AnthropicConfig) -> Result<Anthropic, SError> {
let client = Anthropic::new(config.api_key.clone()).map_err(|err| {
cli_error(
"client_init_failed",
"failed to initialize the Anthropic client",
)
.with_string_field("cause", &err.to_string())
})?;
Ok(match config.base_url.as_ref() {
Some(base_url) => client.with_base_url(base_url.clone()),
None => client,
})
}
fn validate_runtime_mode(
raw: bool,
bash_debug: Option<&str>,
prompt: Option<&str>,
) -> Result<(), SError> {
if raw && bash_debug.is_some() {
return Err(cli_error(
"invalid_cli_args",
"--raw cannot be combined with --bash-debug",
));
}
if raw && prompt.is_some() {
return Err(cli_error(
"invalid_cli_args",
"--raw cannot be combined with --prompt",
));
}
if bash_debug.is_some() && prompt.is_some() {
return Err(cli_error(
"invalid_cli_args",
"--bash-debug cannot be combined with --prompt",
));
}
Ok(())
}
fn validate_session_selection(
new: bool,
resume: Option<&str>,
prompt: Option<&str>,
) -> Result<(), SError> {
if new && resume.is_some() {
return Err(cli_error(
"invalid_cli_args",
"--new cannot be combined with --resume",
));
}
if prompt.is_some() && !new && resume.is_none() {
return Err(cli_error(
"invalid_cli_args",
"--prompt requires --new or --resume to select a session",
));
}
Ok(())
}
fn validate_connect_mode(
raw: bool,
listen: Option<&str>,
resume: Option<&str>,
new: bool,
bash_debug: Option<&str>,
prompt: Option<&str>,
) -> Result<(), SError> {
if raw {
return Err(cli_error(
"invalid_cli_args",
"--connect cannot be combined with --raw",
));
}
if listen.is_some() {
return Err(cli_error(
"invalid_cli_args",
"--connect cannot be combined with --listen",
));
}
if resume.is_some() {
return Err(cli_error(
"invalid_cli_args",
"--connect cannot be combined with --resume",
));
}
if new {
return Err(cli_error(
"invalid_cli_args",
"--connect cannot be combined with --new",
));
}
if bash_debug.is_some() {
return Err(cli_error(
"invalid_cli_args",
"--connect cannot be combined with --bash-debug",
));
}
if prompt.is_some() {
return Err(cli_error(
"invalid_cli_args",
"--connect cannot be combined with --prompt",
));
}
Ok(())
}
fn install_ctrlc_handler(interrupted: Arc<AtomicBool>) -> Result<(), SError> {
ctrlc::set_handler(move || {
interrupted.store(true, Ordering::Relaxed);
})
.map_err(|err| {
cli_error(
"signal_handler_failed",
"failed to install the Ctrl-C handler",
)
.with_string_field("cause", &err.to_string())
})
}
fn warn_if_sandbox_unavailable() {
if seatbelt::sandbox_available() {
return;
}
eprint!("{SANDBOX_UNAVAILABLE_WARNING}");
}
fn resolve_sid_home() -> Result<Path<'static>, SError> {
resolve_sid_home_from_env(std::env::var("SID_HOME"))
}
fn resolve_sid_home_from_env(
sid_home: Result<String, std::env::VarError>,
) -> Result<Path<'static>, SError> {
match sid_home {
Ok(path) if !path.is_empty() => Ok(Path::new(&path).into_owned()),
Ok(_) | Err(std::env::VarError::NotPresent) => Err(cli_error(
"missing_sid_home",
"SID_HOME must be set and non-empty",
)),
Err(std::env::VarError::NotUnicode(_)) => {
Err(cli_error("invalid_sid_home", "SID_HOME is not valid UTF-8"))
}
}
}
fn confirm_manual_agent(renderer: &mut dyn Renderer, agent_id: &str) -> Result<bool, SError> {
loop {
let prompt = format!("Agent '{agent_id}' is MANUAL. Continue? [yes/no]: ");
let input = match renderer
.read_operator_line(&prompt)
.map_err(|err| {
cli_error("io_error", "failed to read manual-agent confirmation input")
.with_string_field("agent", agent_id)
.with_string_field("cause", &err.to_string())
})?
.ok_or_else(|| {
cli_error(
"io_error",
"manual-agent confirmation requires an interactive renderer",
)
.with_string_field("agent", agent_id)
})? {
OperatorLine::Line(input) => input,
OperatorLine::Eof | OperatorLine::Interrupted => return Ok(false),
};
match parse_confirmation(&input) {
Some(answer) => return Ok(answer),
None => println!("Please answer yes or no."),
}
}
}
fn parse_confirmation(input: &str) -> Option<bool> {
match input.trim().to_ascii_lowercase().as_str() {
"y" | "yes" => Some(true),
"n" | "no" => Some(false),
_ => None,
}
}
fn describe_thinking(stats: &SessionStats) -> String {
if stats.thinking_adaptive {
match stats.effort {
Some(effort) => format!("adaptive (effort: {})", effort_name(effort)),
None => "adaptive".to_string(),
}
} else {
match stats.thinking_budget {
Some(budget) => format!("enabled ({budget} tokens)"),
None => "disabled".to_string(),
}
}
}
fn print_stats(stats: &SessionStats) {
println!(" Session Statistics:");
println!(" Model: {}", stats.model);
println!(" Messages: {}", stats.message_count);
println!(" Max tokens: {}", stats.max_tokens);
println!(" Temperature: {}", describe_float(stats.temperature));
println!(" Top-p: {}", describe_float(stats.top_p));
println!(" Top-k: {}", describe_top_k(stats.top_k));
if let Some(prompt) = stats.system_prompt.as_deref() {
println!(" System prompt: {prompt}");
} else {
println!(" System prompt: (none)");
}
println!(" Thinking: {}", describe_thinking(stats));
print_stop_sequences(&stats.stop_sequences);
println!(
" Total tokens: {} in / {} out ({} requests)",
stats.total_input_tokens, stats.total_output_tokens, stats.total_requests
);
if stats.caching_enabled {
println!(
" Cache tokens: {} created / {} read",
stats.total_cache_creation_tokens, stats.total_cache_read_tokens
);
}
if let Some(input) = stats.last_turn_input_tokens {
let output = stats.last_turn_output_tokens.unwrap_or(0);
println!(" Last turn tokens: {input} in / {output} out");
}
if let Some(limit) = stats.session_spend_micro_cents {
let spent = stats.spend_used_micro_cents as f64 / 100_000_000.0;
let total = limit as f64 / 100_000_000.0;
let remaining = limit.saturating_sub(stats.spend_used_micro_cents) as f64 / 100_000_000.0;
println!(" Spend limit: ${spent:.4}/${total:.2} (${remaining:.4} remaining)",);
} else {
println!(" Spend limit: (not set)");
}
}
fn print_config(stats: &SessionStats) {
println!(" Current Configuration:");
println!(" Model: {}", stats.model);
println!(" Max tokens: {}", stats.max_tokens);
println!(" Temperature: {}", describe_float(stats.temperature));
println!(" Top-p: {}", describe_float(stats.top_p));
println!(" Top-k: {}", describe_top_k(stats.top_k));
println!(" Thinking: {}", describe_thinking(stats));
println!(
" Caching: {}",
if stats.caching_enabled {
"enabled"
} else {
"disabled"
}
);
if let Some(prompt) = stats.system_prompt.as_deref() {
println!(" System prompt: {prompt}");
} else {
println!(" System prompt: (none)");
}
print_stop_sequences(&stats.stop_sequences);
}
fn print_remote_stats(stats: &Value) {
println!(" Session Statistics:");
println!(" Model: {}", json_str(stats, "model").unwrap_or("?"));
println!(
" Messages: {}",
json_u64(stats, "message_count").unwrap_or(0)
);
println!(
" Max tokens: {}",
json_u64(stats, "max_tokens").unwrap_or(0)
);
println!(
" Temperature: {}",
describe_json_float(stats, "temperature")
);
println!(" Top-p: {}", describe_json_float(stats, "top_p"));
println!(" Top-k: {}", describe_json_u64(stats, "top_k"));
if let Some(prompt) = json_str(stats, "system_prompt") {
println!(" System prompt: {prompt}");
} else {
println!(" System prompt: (none)");
}
println!(
" Thinking: {}",
json_str(stats, "thinking").unwrap_or("disabled")
);
print_stop_sequences(&json_string_array(stats, "stop_sequences"));
println!(
" Total tokens: {} in / {} out ({} requests)",
json_u64(stats, "total_input_tokens").unwrap_or(0),
json_u64(stats, "total_output_tokens").unwrap_or(0),
json_u64(stats, "total_requests").unwrap_or(0)
);
if json_bool(stats, "caching_enabled").unwrap_or(false) {
println!(
" Cache tokens: {} created / {} read",
json_u64(stats, "total_cache_creation_tokens").unwrap_or(0),
json_u64(stats, "total_cache_read_tokens").unwrap_or(0)
);
}
if let Some(input) = json_u64(stats, "last_turn_input_tokens") {
let output = json_u64(stats, "last_turn_output_tokens").unwrap_or(0);
println!(" Last turn tokens: {input} in / {output} out");
}
if let Some(limit) = json_u64(stats, "session_spend_micro_cents") {
let spent = json_u64(stats, "spend_used_micro_cents").unwrap_or(0);
let spent_dollars = spent as f64 / 100_000_000.0;
let total = limit as f64 / 100_000_000.0;
let remaining = limit.saturating_sub(spent) as f64 / 100_000_000.0;
println!(" Spend limit: ${spent_dollars:.4}/${total:.2} (${remaining:.4} remaining)");
} else {
println!(" Spend limit: (not set)");
}
}
fn print_remote_config(config: &Value) {
println!(" Current Configuration:");
println!(" Model: {}", json_str(config, "model").unwrap_or("?"));
println!(
" Max tokens: {}",
json_u64(config, "max_tokens").unwrap_or(0)
);
println!(
" Temperature: {}",
describe_json_float(config, "temperature")
);
println!(" Top-p: {}", describe_json_float(config, "top_p"));
println!(" Top-k: {}", describe_json_u64(config, "top_k"));
println!(
" Thinking: {}",
json_str(config, "thinking").unwrap_or("disabled")
);
println!(
" Caching: {}",
if json_bool(config, "caching_enabled").unwrap_or(false) {
"enabled"
} else {
"disabled"
}
);
if let Some(prompt) = json_str(config, "system_prompt") {
println!(" System prompt: {prompt}");
} else {
println!(" System prompt: (none)");
}
print_stop_sequences(&json_string_array(config, "stop_sequences"));
}
fn print_stop_sequences(stop_sequences: &[String]) {
if stop_sequences.is_empty() {
println!(" Stop sequences: (none)");
} else {
println!(" Stop sequences:");
for stop_sequence in stop_sequences {
println!(" - {stop_sequence}");
}
}
}
fn describe_float(value: Option<f32>) -> String {
value
.map(|value| format!("{value:.2}"))
.unwrap_or_else(|| "default".to_string())
}
fn describe_top_k(value: Option<u32>) -> String {
value
.map(|value| value.to_string())
.unwrap_or_else(|| "default".to_string())
}
fn describe_json_float(value: &Value, key: &str) -> String {
value
.get(key)
.and_then(Value::as_f64)
.map(|value| format!("{value:.2}"))
.unwrap_or_else(|| "default".to_string())
}
fn describe_json_u64(value: &Value, key: &str) -> String {
value
.get(key)
.and_then(Value::as_u64)
.map(|value| value.to_string())
.unwrap_or_else(|| "default".to_string())
}
fn json_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
value.get(key).and_then(Value::as_str)
}
fn json_bool(value: &Value, key: &str) -> Option<bool> {
value.get(key).and_then(Value::as_bool)
}
fn json_u64(value: &Value, key: &str) -> Option<u64> {
value.get(key).and_then(Value::as_u64)
}
fn json_string_array(value: &Value, key: &str) -> Vec<String> {
value
.get(key)
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn invoke_external_editor() -> io::Result<Option<String>> {
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string());
let tmp_dir = std::env::temp_dir();
let filename = format!(
"sid-{}-{}.txt",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_micros()
);
let tmp_path = tmp_dir.join(filename);
let status = Command::new(&editor)
.arg(&tmp_path)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.map_err(|err| io::Error::other(format!("{editor}: {err}")))?;
let result = if status.success() {
match std::fs::read_to_string(&tmp_path) {
Ok(content) if content.trim().is_empty() => Ok(None),
Ok(content) => Ok(Some(content)),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
} else {
Ok(None)
};
let _ = std::fs::remove_file(&tmp_path);
result
}
#[cfg(test)]
mod tests {
use super::{
AgentSummary, AgentSwitchResult, DEFAULT_SYSTEM_PROMPT, QuietRenderer,
SANDBOX_UNAVAILABLE_WARNING, SidArgs, SidCommand, SidRuntimeSession, SidTerminal,
SpendTurnClamp, SwitchPosition, build_anthropic_client, dollars_to_micro_cents,
handle_raw_request, history_entry, parse_confirmation, parse_history_file,
parse_sid_command, resolve_sid_home_from_env, rewind_interrupted_messages,
rewind_trailing_incomplete_messages, validate_connect_mode, validate_no_free_args,
validate_runtime_mode, validate_session_selection, write_history_file,
};
use arrrg::{CommandLine, NoExitCommandLine};
use claudius::chat::{ChatConfig, ChatSession};
use claudius::{
Anthropic, ContentBlock, MessageParam, MessageParamContent, MessageRole, TextBlock,
ToolResultBlock, ToolUseBlock,
};
use serde::Deserialize;
use serde_json::json;
use sid_isnt_done::config::AnthropicConfig;
use sid_isnt_done::raw_mode::RawServer;
use sid_isnt_done::raw_protocol::{RAW_PROTOCOL_VERSION, RawRequest, RawRequestEnvelope};
use sid_isnt_done::{SidAgent, append_resumed_bash_reset_marker, session::SidSession};
use std::fs;
use std::io::{BufReader, Cursor};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::{SystemTime, UNIX_EPOCH};
use utf8path::Path;
#[derive(Debug, PartialEq)]
struct SessionSnapshot {
agent_id: String,
messages: Vec<MessageParam>,
model: claudius::Model,
temperature: Option<f32>,
stop_sequences: Vec<String>,
has_session_spend: bool,
}
#[derive(Debug, Deserialize, PartialEq)]
struct TranscriptSnapshot {
version: u8,
messages: Vec<MessageParam>,
}
fn parse_args(argv: &[&str]) -> (SidArgs, Vec<String>, i32, Vec<String>) {
let (parsed, free) =
NoExitCommandLine::<SidArgs>::from_arguments_relaxed("sid [OPTIONS]", argv);
let (args, messages, status) = parsed.into_parts();
(args, free, status, messages)
}
#[test]
fn build_anthropic_client_uses_configured_credentials() {
let config = AnthropicConfig {
api_key: Some("test-api-key".to_string()),
base_url: Some("http://localhost".to_string()),
};
build_anthropic_client(&config).unwrap();
}
#[test]
fn parse_confirmation_accepts_yes_values() {
assert_eq!(parse_confirmation("yes"), Some(true));
assert_eq!(parse_confirmation("Y"), Some(true));
}
#[test]
fn parse_confirmation_accepts_no_values() {
assert_eq!(parse_confirmation("no"), Some(false));
assert_eq!(parse_confirmation("N"), Some(false));
}
#[test]
fn parse_confirmation_rejects_other_values() {
assert_eq!(parse_confirmation(""), None);
assert_eq!(parse_confirmation("maybe"), None);
}
#[test]
fn resolve_sid_home_accepts_non_empty_sid_home() {
let root = resolve_sid_home_from_env(Ok("init".to_string())).unwrap();
assert_eq!(root.as_str(), "init");
}
#[test]
fn resolve_sid_home_rejects_missing_sid_home() {
let err = resolve_sid_home_from_env(Err(std::env::VarError::NotPresent))
.unwrap_err()
.to_string();
assert!(
err.contains("SID_HOME must be set and non-empty"),
"error: {err}"
);
}
#[test]
fn resolve_sid_home_rejects_empty_sid_home() {
let err = resolve_sid_home_from_env(Ok(String::new()))
.unwrap_err()
.to_string();
assert!(
err.contains("SID_HOME must be set and non-empty"),
"error: {err}"
);
}
#[test]
fn history_entry_trims_without_saving_leading_whitespace() {
assert_eq!(history_entry("hello "), Some("hello"));
assert_eq!(history_entry(" secret"), None);
assert_eq!(history_entry("\tsecret"), None);
assert_eq!(history_entry(""), None);
}
#[test]
fn history_file_round_trips_escaped_entries() {
let entries = vec!["line one\\two\nline three".to_string()];
let mut buffer = Vec::new();
write_history_file(&mut buffer, &entries).unwrap();
assert_eq!(
parse_history_file(std::str::from_utf8(&buffer).unwrap()),
entries
);
}
#[test]
fn terminal_history_appends_entries_immediately() {
let root = unique_workspace_root("history-immediate");
let mut terminal = SidTerminal::new(
false,
Arc::new(AtomicBool::new(false)),
PathBuf::from(root.as_str()),
false,
)
.unwrap();
terminal.add_history_entry("hello");
assert_eq!(read_history_entries(&root), vec!["hello".to_string()]);
fs::remove_dir_all(PathBuf::from(root.as_str())).unwrap();
}
#[test]
fn terminal_history_merges_concurrent_sessions() {
let root = unique_workspace_root("history-concurrent");
let mut first = SidTerminal::new(
false,
Arc::new(AtomicBool::new(false)),
PathBuf::from(root.as_str()),
false,
)
.unwrap();
let mut second = SidTerminal::new(
false,
Arc::new(AtomicBool::new(false)),
PathBuf::from(root.as_str()),
false,
)
.unwrap();
first.add_history_entry("first");
second.add_history_entry("second");
assert_eq!(
read_history_entries(&root),
vec!["first".to_string(), "second".to_string()]
);
fs::remove_dir_all(PathBuf::from(root.as_str())).unwrap();
}
#[test]
fn parse_args_accept_resume_option() {
let (args, free, status, messages) =
parse_args(&["--resume", "2026-04-20T18-42-13.123456-0700"]);
assert_eq!(status, 0, "unexpected parser status: {messages:?}");
assert!(free.is_empty());
assert_eq!(
args.resume.as_deref(),
Some("2026-04-20T18-42-13.123456-0700")
);
}
#[test]
fn parse_args_accept_raw_option() {
let (args, free, status, messages) = parse_args(&["--raw"]);
assert_eq!(status, 0, "unexpected parser status: {messages:?}");
assert!(free.is_empty());
assert!(args.raw);
}
#[test]
fn parse_args_accept_compact_option() {
let (args, free, status, messages) = parse_args(&["--compact"]);
assert_eq!(status, 0, "unexpected parser status: {messages:?}");
assert!(free.is_empty());
assert!(args.compact);
}
#[test]
fn parse_args_accept_listen_option() {
let (args, free, status, messages) = parse_args(&["--listen", "unix:///tmp/sid.sock"]);
assert_eq!(status, 0, "unexpected parser status: {messages:?}");
assert!(free.is_empty());
assert_eq!(args.listen.as_deref(), Some("unix:///tmp/sid.sock"));
}
#[test]
fn parse_args_accept_connect_option() {
let (args, free, status, messages) = parse_args(&["--connect", "unix:///tmp/sid.sock"]);
assert_eq!(status, 0, "unexpected parser status: {messages:?}");
assert!(free.is_empty());
assert_eq!(args.connect.as_deref(), Some("unix:///tmp/sid.sock"));
}
#[test]
fn parse_args_accept_prompt_option() {
let (args, free, status, messages) = parse_args(&["--prompt", "review this"]);
assert_eq!(status, 0, "unexpected parser status: {messages:?}");
assert!(free.is_empty());
assert_eq!(args.prompt.as_deref(), Some("review this"));
}
#[test]
fn load_resumed_transcript_restores_saved_history_and_appends_bash_reset_marker() {
let workspace_root = unique_workspace_root("resume-transcript");
let sid_session = test_sid_session(&workspace_root);
fs::write(
sid_session.transcript_path(),
serde_json::to_vec_pretty(&serde_json::json!({
"version": 1,
"messages": [MessageParam::user("resume me")]
}))
.unwrap(),
)
.unwrap();
let mut session =
new_runtime_session(&workspace_root, &workspace_root, sid_session.clone(), None);
session.load_resumed_transcript(true).unwrap();
let mut expected = vec![MessageParam::user("resume me")];
append_resumed_bash_reset_marker(&mut expected);
assert_eq!(session.clone_messages(), expected);
fs::remove_dir_all(PathBuf::from(workspace_root.as_str())).unwrap();
}
#[test]
fn interrupted_rewind_removes_partial_assistant_tool_input_and_adds_tail_note() {
let mut messages = vec![
MessageParam::user("Do the work."),
MessageParam::new(
MessageParamContent::Array(vec![
ContentBlock::Text(TextBlock::new("I will inspect it.\n".to_string())),
ToolUseBlock::new(
"toolu_partial",
"bash",
serde_json::Value::String("{\"command\":\"pw".to_string()),
)
.into(),
]),
MessageRole::Assistant,
),
];
let rewind = rewind_interrupted_messages(&mut messages, 0).unwrap();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0], MessageParam::user("Do the work."));
assert_eq!(messages[1].role, MessageRole::User);
let note = user_text(&messages[1]);
assert_eq!(rewind.note, note);
assert!(note.contains("Rewound interrupted partial assistant message."));
assert!(note.contains("continuing from: bash input: {\"command\":\"pw"));
}
#[test]
fn interrupted_rewind_preserves_completed_tool_exchange() {
let result = ToolResultBlock::new("toolu_done".to_string())
.with_string_content("done\n".to_string());
let mut messages = vec![
MessageParam::user("Do the work."),
MessageParam::new(
MessageParamContent::Array(vec![
ToolUseBlock::new("toolu_done", "bash", json!({"command": "pwd"})).into(),
]),
MessageRole::Assistant,
),
MessageParam::new(
MessageParamContent::Array(vec![result.into()]),
MessageRole::User,
),
MessageParam::assistant("Now I can summarize: the"),
];
let rewind = rewind_interrupted_messages(&mut messages, 0).unwrap();
assert_eq!(messages.len(), 4);
let MessageParamContent::Array(blocks) = &messages[1].content else {
panic!("expected completed assistant tool use");
};
assert_eq!(
blocks[0].as_tool_use().unwrap().input,
json!({"command": "pwd"})
);
let MessageParamContent::Array(blocks) = &messages[2].content else {
panic!("expected completed user tool result");
};
assert_eq!(
blocks[0].as_tool_result().unwrap().tool_use_id,
"toolu_done"
);
assert_eq!(user_text(&messages[3]), rewind.note);
assert!(
rewind
.note
.contains("continuing from: Now I can summarize: the")
);
}
#[test]
fn trailing_incomplete_rewind_repairs_resumed_partial_tool_use() {
let mut messages = vec![
MessageParam::user("Do the work."),
MessageParam::new(
MessageParamContent::Array(vec![
ToolUseBlock::new("toolu_partial", "bash", json!({"command": "pwd"})).into(),
]),
MessageRole::Assistant,
),
];
let rewind = rewind_trailing_incomplete_messages(&mut messages).unwrap();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0], MessageParam::user("Do the work."));
assert_eq!(user_text(&messages[1]), rewind.note);
assert!(
rewind
.note
.contains("continuing from: bash input: {\"command\":\"pwd\"}")
);
}
#[test]
fn parse_compact_command() {
assert_eq!(parse_sid_command("/compact"), Some(SidCommand::Compact));
assert_eq!(
parse_sid_command("/compact now"),
Some(SidCommand::Invalid(
"/compact does not take any arguments".to_string()
))
);
}
#[test]
fn parse_unknown_commands_are_invalid() {
for input in ["/agent", "/agents", "/agent switch review"] {
assert_eq!(
parse_sid_command(input),
Some(SidCommand::Invalid(format!(
"Unknown command: /{}",
input[1..].split(' ').next().unwrap()
)))
);
}
}
#[test]
fn parse_session_commands() {
assert_eq!(parse_sid_command("/quit"), Some(SidCommand::Quit));
assert_eq!(parse_sid_command("/exit"), Some(SidCommand::Quit));
assert_eq!(parse_sid_command("/help"), Some(SidCommand::Help));
assert_eq!(parse_sid_command("/clear"), Some(SidCommand::Clear));
assert_eq!(parse_sid_command("/stats"), Some(SidCommand::Stats));
assert_eq!(parse_sid_command("/config"), Some(SidCommand::ShowConfig));
assert_eq!(
parse_sid_command("/spend 2.50"),
Some(SidCommand::Spend(2.50))
);
assert_eq!(
parse_sid_command("/spend clear"),
Some(SidCommand::ClearSpend)
);
assert_eq!(
parse_sid_command("/save /tmp/t.md"),
Some(SidCommand::SaveTranscript("/tmp/t.md".to_string()))
);
assert_eq!(
parse_sid_command("/load /tmp/t.md"),
Some(SidCommand::LoadTranscript("/tmp/t.md".to_string()))
);
assert_eq!(
parse_sid_command("/system be terse"),
Some(SidCommand::System("be terse".to_string()))
);
}
#[test]
fn parse_session_command_errors() {
assert_eq!(
parse_sid_command("/spend"),
Some(SidCommand::Invalid(
"/spend requires a dollar amount".to_string()
))
);
assert_eq!(
parse_sid_command("/spend nope"),
Some(SidCommand::Invalid(
"/spend expects a positive dollar amount".to_string()
))
);
assert_eq!(
parse_sid_command("/save"),
Some(SidCommand::Invalid(
"/save requires a file path".to_string()
))
);
assert_eq!(
parse_sid_command("/system"),
Some(SidCommand::Invalid(
"/system requires a message".to_string()
))
);
}
#[test]
fn parse_model_param_commands_are_rejected() {
for input in [
"/model claude-sonnet-4-0",
"/max_tokens 8192",
"/temperature 0.5",
"/top_p 0.5",
"/top_k 40",
"/stop add END",
"/thinking 2048",
"/effort high",
"/cache on",
"/transcript /tmp/t.md",
] {
assert_eq!(
parse_sid_command(input),
Some(SidCommand::Invalid(format!(
"Unknown command: /{}",
input[1..].split(' ').next().unwrap()
))),
"expected {input} to be rejected"
);
}
}
#[test]
fn parse_chat_lines_are_untouched() {
assert_eq!(parse_sid_command("hello there"), None);
}
#[test]
fn list_agents_reports_current_manual_and_disabled_agents() {
let root = unique_workspace_root("agent-list");
write_multi_agent_config(&root);
let session = configured_runtime_session(&root, "build");
assert_eq!(
session.list_agents().unwrap(),
vec![
AgentSummary {
id: "build".to_string(),
display_name: Some("Builder".to_string()),
description: Some("Build changes".to_string()),
enabled: SwitchPosition::Yes,
current: true,
},
AgentSummary {
id: "off".to_string(),
display_name: None,
description: Some("Disabled agent".to_string()),
enabled: SwitchPosition::No,
current: false,
},
AgentSummary {
id: "review".to_string(),
display_name: Some("Reviewer".to_string()),
description: Some("Review changes".to_string()),
enabled: SwitchPosition::Manual,
current: false,
},
]
);
fs::remove_dir_all(PathBuf::from(root.as_str())).unwrap();
}
#[test]
fn switch_agent_preserves_transcript_spend_and_saved_transcript() {
let root = unique_workspace_root("agent-switch");
write_multi_agent_config(&root);
let mut session = configured_runtime_session(&root, "build");
session
.replace_messages(vec![MessageParam::user("resume me")])
.unwrap();
session.set_session_spend(Some(5.0));
assert_eq!(
session.switch_agent("review").unwrap(),
AgentSwitchResult::Switched(AgentSummary {
id: "review".to_string(),
display_name: Some("Reviewer".to_string()),
description: Some("Review changes".to_string()),
enabled: SwitchPosition::Manual,
current: true,
})
);
assert_eq!(
snapshot(&session),
SessionSnapshot {
agent_id: "review".to_string(),
messages: vec![MessageParam::user("resume me")],
model: "claude-haiku-4-5".parse().unwrap(),
temperature: None,
stop_sequences: vec![],
has_session_spend: true,
}
);
assert!(
session
.config()
.system_prompt_text()
.unwrap()
.contains("# Review Agent"),
"expected review prompt in system prompt"
);
assert_eq!(
read_saved_transcript(&session.sid_session.transcript_path()),
TranscriptSnapshot {
version: 1,
messages: vec![MessageParam::user("resume me")],
}
);
fs::remove_dir_all(PathBuf::from(root.as_str())).unwrap();
}
#[tokio::test]
async fn sid_spend_blocks_next_turn_after_cumulative_cost_exhausts_limit() {
let root = unique_workspace_root("spend-block");
let sid_session = test_sid_session(&root);
let mut session = new_runtime_session(&root, &root, sid_session, None);
session
.chat
.config_mut()
.set_model("claude-sonnet-4-5".parse().unwrap());
session.set_session_spend(Some(0.000015));
session
.sid_spend
.as_mut()
.unwrap()
.record_cost(dollars_to_micro_cents(0.000015));
let mut renderer = QuietRenderer;
let err = session
.send_message(
MessageParam::user("should not reach the API"),
&mut renderer,
)
.await
.unwrap_err();
assert!(
err.to_string().contains("session spend exhausted"),
"error: {err}"
);
assert!(session.clone_messages().is_empty());
fs::remove_dir_all(PathBuf::from(root.as_str())).unwrap();
}
#[test]
fn sid_spend_clamps_max_tokens_to_affordable_output_tokens() {
let root = unique_workspace_root("spend-clamp");
let sid_session = test_sid_session(&root);
let mut session = new_runtime_session(&root, &root, sid_session, None);
session
.chat
.config_mut()
.set_model("claude-sonnet-4-5".parse().unwrap());
session.chat.config_mut().set_max_tokens(100);
session.set_session_spend(Some(0.000045));
assert_eq!(
session.spend_turn_clamp().unwrap(),
Some(SpendTurnClamp {
original_max_tokens: Some(100),
clamped_max_tokens: 3,
})
);
fs::remove_dir_all(PathBuf::from(root.as_str())).unwrap();
}
#[test]
fn sid_spend_uses_sonnet_rates_for_custom_model_clamping() {
let root = unique_workspace_root("spend-custom-model");
let sid_session = test_sid_session(&root);
let mut session = new_runtime_session(&root, &root, sid_session, None);
session
.chat
.config_mut()
.set_model("custom-model".parse().unwrap());
session.chat.config_mut().set_max_tokens(100);
session.set_session_spend(Some(0.000030));
assert_eq!(
session.spend_turn_clamp().unwrap(),
Some(SpendTurnClamp {
original_max_tokens: Some(100),
clamped_max_tokens: 2,
})
);
fs::remove_dir_all(PathBuf::from(root.as_str())).unwrap();
}
#[test]
fn sid_spend_switch_agent_preserves_used_spend_and_effective_limit() {
let root = unique_workspace_root("spend-agent-switch");
write_multi_agent_config(&root);
let mut session = configured_runtime_session(&root, "build");
session.set_session_spend(Some(0.000030));
session
.sid_spend
.as_mut()
.unwrap()
.record_cost(dollars_to_micro_cents(0.000015));
assert_eq!(
session.switch_agent("review").unwrap(),
AgentSwitchResult::Switched(AgentSummary {
id: "review".to_string(),
display_name: Some("Reviewer".to_string()),
description: Some("Review changes".to_string()),
enabled: SwitchPosition::Manual,
current: true,
})
);
let stats = session.stats();
assert_eq!(
stats.session_spend_micro_cents,
Some(dollars_to_micro_cents(0.000030))
);
assert_eq!(
stats.spend_used_micro_cents,
dollars_to_micro_cents(0.000015)
);
assert_eq!(
session.spend_turn_clamp().unwrap(),
Some(SpendTurnClamp {
original_max_tokens: Some(4096),
clamped_max_tokens: 3,
})
);
fs::remove_dir_all(PathBuf::from(root.as_str())).unwrap();
}
#[test]
fn sid_spend_clear_removes_local_guard() {
let root = unique_workspace_root("spend-clear");
let sid_session = test_sid_session(&root);
let mut session = new_runtime_session(&root, &root, sid_session, None);
session
.chat
.config_mut()
.set_model("claude-sonnet-4-5".parse().unwrap());
session.set_session_spend(Some(0.000015));
session
.sid_spend
.as_mut()
.unwrap()
.record_cost(dollars_to_micro_cents(0.000015));
session.set_session_spend(None);
assert!(session.spend_turn_clamp().unwrap().is_none());
let stats = session.stats();
assert_eq!(stats.session_spend_micro_cents, None);
assert_eq!(stats.spend_used_micro_cents, 0);
fs::remove_dir_all(PathBuf::from(root.as_str())).unwrap();
}
#[test]
fn sid_spend_set_after_prior_spend_starts_new_cap() {
let root = unique_workspace_root("spend-reset");
let sid_session = test_sid_session(&root);
let mut session = new_runtime_session(&root, &root, sid_session, None);
session
.chat
.config_mut()
.set_model("claude-sonnet-4-5".parse().unwrap());
session.set_session_spend(Some(0.000015));
session
.sid_spend
.as_mut()
.unwrap()
.record_cost(dollars_to_micro_cents(0.000015));
session.set_session_spend(Some(0.000030));
let stats = session.stats();
assert_eq!(
stats.session_spend_micro_cents,
Some(dollars_to_micro_cents(0.000030))
);
assert_eq!(stats.spend_used_micro_cents, 0);
assert_eq!(
session.spend_turn_clamp().unwrap(),
Some(SpendTurnClamp {
original_max_tokens: Some(4096),
clamped_max_tokens: 2,
})
);
fs::remove_dir_all(PathBuf::from(root.as_str())).unwrap();
}
#[tokio::test]
async fn raw_set_spend_follows_interactive_reset_behavior() {
let root = unique_workspace_root("raw-spend-reset");
let sid_session = test_sid_session(&root);
let mut session = new_runtime_session(&root, &root, sid_session, None);
session
.chat
.config_mut()
.set_model("claude-sonnet-4-5".parse().unwrap());
session.set_session_spend(Some(0.000015));
session
.sid_spend
.as_mut()
.unwrap()
.record_cost(dollars_to_micro_cents(0.000015));
let mut server = RawServer::new(BufReader::new(Cursor::new(Vec::new())), Vec::new());
handle_raw_request(
&mut session,
&mut server,
RawRequestEnvelope {
protocol_version: RAW_PROTOCOL_VERSION,
request_id: "set-spend".to_string(),
request: RawRequest::SetSpend {
dollars: Some(0.000030),
},
},
)
.await
.unwrap();
let stats = match handle_raw_request(
&mut session,
&mut server,
RawRequestEnvelope {
protocol_version: RAW_PROTOCOL_VERSION,
request_id: "stats".to_string(),
request: RawRequest::Stats,
},
)
.await
.unwrap()
{
super::RequestDisposition::Continue(Some(stats)) => stats,
_ => panic!("unexpected raw disposition"),
};
assert_eq!(
stats["session_spend_micro_cents"].as_u64(),
Some(dollars_to_micro_cents(0.000030))
);
assert_eq!(stats["spend_used_micro_cents"].as_u64(), Some(0));
fs::remove_dir_all(PathBuf::from(root.as_str())).unwrap();
}
#[test]
fn switch_agent_rebuilds_client_from_target_agent_config() {
let root = unique_workspace_root("agent-client-switch");
write_multi_agent_config(&root);
let agents_conf = fs::read_to_string(root.join("agents.conf").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
format!(
"{agents_conf}review_API_KEY='file://{}/missing.key'\n",
root.as_str()
),
)
.unwrap();
let mut session = configured_runtime_session(&root, "build");
let err = session.switch_agent("review").unwrap_err().to_string();
assert!(err.contains("client_init_failed"), "error: {err}");
assert_eq!(session.current_agent_id(), "build");
fs::remove_dir_all(PathBuf::from(root.as_str())).unwrap();
}
#[test]
fn switch_agent_rejects_disabled_agents() {
let root = unique_workspace_root("agent-disabled");
write_multi_agent_config(&root);
let mut session = configured_runtime_session(&root, "build");
let err = session.switch_agent("off").unwrap_err().to_string();
assert!(err.contains("disabled_agent"), "error: {err}");
fs::remove_dir_all(PathBuf::from(root.as_str())).unwrap();
}
fn unique_workspace_root(prefix: &str) -> Path<'static> {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("sid-isnt-done-{prefix}-{nanos}"));
fs::create_dir_all(&root).unwrap();
Path::try_from(root).unwrap().into_owned()
}
fn read_history_entries(root: &Path) -> Vec<String> {
parse_history_file(&fs::read_to_string(root.join("histfile").as_str()).unwrap())
}
fn test_chat_config(transcript_path: PathBuf) -> ChatConfig {
let mut config = ChatConfig::new().with_system_prompt(DEFAULT_SYSTEM_PROMPT.to_string());
config.transcript_path = Some(transcript_path);
config
}
fn new_runtime_session(
workspace_root: &Path,
config_root: &Path,
sid_session: Arc<SidSession>,
agent: Option<&str>,
) -> SidRuntimeSession {
let config = test_chat_config(sid_session.transcript_path());
let agent = match agent {
Some(agent) => SidAgent::from_workspace_agent_with_config_root(
workspace_root,
config_root,
agent,
config.clone(),
)
.unwrap(),
None => SidAgent::from_workspace_with_config_root(
workspace_root,
config_root,
config.clone(),
)
.unwrap(),
}
.with_session(sid_session.clone());
let current_agent_id = agent.id().to_string();
let auto_compact_tokens = agent.auto_compact_tokens();
let client = Anthropic::new(Some("test-api-key".to_string())).unwrap();
let chat = ChatSession::with_agent(client.clone(), agent);
SidRuntimeSession::new(
client,
chat,
config,
workspace_root.clone().into_owned(),
config_root.clone().into_owned(),
sid_session,
current_agent_id,
auto_compact_tokens,
)
}
fn configured_runtime_session(root: &Path, agent: &str) -> SidRuntimeSession {
let sid_session = test_sid_session(root);
new_runtime_session(root, root, sid_session, Some(agent))
}
fn test_sid_session(root: &Path) -> Arc<SidSession> {
let sessions_root = PathBuf::from(root.join("test-sessions").as_str());
Arc::new(SidSession::create_in(sessions_root).unwrap())
}
fn write_multi_agent_config(root: &Path) {
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
concat!(
"DEFAULT_AGENT=build\n",
"build_ENABLED=YES\n",
"build_NAME='Builder'\n",
"build_DESC='Build changes'\n",
"build_TOOLS='bash'\n",
"build_API_KEY='test-api-key'\n",
"review_ENABLED=MANUAL\n",
"review_NAME='Reviewer'\n",
"review_DESC='Review changes'\n",
"review_TOOLS='bash'\n",
"review_API_KEY='test-api-key'\n",
"off_ENABLED=NO\n",
"off_DESC='Disabled agent'\n",
"off_TOOLS='bash'\n",
),
)
.unwrap();
fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
fs::write(root.join("agents/build.md").as_str(), "# Build Agent\n").unwrap();
fs::write(root.join("agents/review.md").as_str(), "# Review Agent\n").unwrap();
fs::write(root.join("agents/off.md").as_str(), "# Off Agent\n").unwrap();
}
fn snapshot(session: &SidRuntimeSession) -> SessionSnapshot {
let stats = session.stats();
SessionSnapshot {
agent_id: session.current_agent_id().to_string(),
messages: session.clone_messages(),
model: session.config().model(),
temperature: session.config().template.temperature,
stop_sequences: session.config().stop_sequences().to_vec(),
has_session_spend: stats.session_spend_micro_cents.is_some(),
}
}
fn read_saved_transcript(path: &std::path::Path) -> TranscriptSnapshot {
serde_json::from_slice(&fs::read(path).unwrap()).unwrap()
}
fn user_text(message: &MessageParam) -> String {
assert_eq!(message.role, MessageRole::User);
match &message.content {
MessageParamContent::String(text) => text.clone(),
MessageParamContent::Array(blocks) => blocks
.iter()
.filter_map(|block| block.as_text().map(|text| text.text.as_str()))
.collect::<Vec<_>>()
.join(""),
}
}
#[test]
fn sid_args_parse_prefixed_temperature_param() {
let (args, free, status, messages) = parse_args(&["--param-temperature", "0.5"]);
assert_eq!(status, 0);
assert!(
messages.is_empty(),
"unexpected parser messages: {messages:?}"
);
assert!(free.is_empty(), "unexpected free args: {free:?}");
assert_eq!(args.param.temperature.as_deref(), Some("0.5"));
}
#[test]
fn sid_args_parse_prefixed_param_and_bash_debug() {
let (args, free, status, messages) =
parse_args(&["--param-model", "claude-haiku-4-5", "--bash-debug", "pwd"]);
assert_eq!(status, 0);
assert!(
messages.is_empty(),
"unexpected parser messages: {messages:?}"
);
assert!(free.is_empty(), "unexpected free args: {free:?}");
assert_eq!(args.param.model.as_deref(), Some("claude-haiku-4-5"));
assert_eq!(args.bash_debug.as_deref(), Some("pwd"));
}
#[test]
fn sid_args_parse_prompt_and_resume() {
let (args, free, status, messages) =
parse_args(&["--resume", "sid-session", "--prompt", "continue"]);
assert_eq!(status, 0);
assert!(
messages.is_empty(),
"unexpected parser messages: {messages:?}"
);
assert!(free.is_empty(), "unexpected free args: {free:?}");
assert_eq!(args.resume.as_deref(), Some("sid-session"));
assert_eq!(args.prompt.as_deref(), Some("continue"));
}
#[test]
fn sid_args_reject_unprefixed_chat_params() {
let (_args, _free, status, messages) = parse_args(&["--temperature", "0.5"]);
assert_eq!(status, 64);
assert!(
messages
.iter()
.any(|message| message.contains("Unrecognized option: 'temperature'")),
"expected unrecognized option message, got {messages:?}"
);
}
#[test]
fn sid_args_reject_debug_alias() {
let (_args, _free, status, messages) = parse_args(&["--debug", "pwd"]);
assert_eq!(status, 64);
assert!(
messages
.iter()
.any(|message| message.contains("Unrecognized option: 'debug'")),
"expected unrecognized option message, got {messages:?}"
);
}
#[test]
fn sid_args_reject_free_positional_args() {
let (_args, free, status, messages) = parse_args(&["hello"]);
assert_eq!(status, 0);
assert!(
messages.is_empty(),
"unexpected parser messages: {messages:?}"
);
assert_eq!(free, vec!["hello".to_string()]);
assert!(validate_no_free_args(&free).is_err());
}
#[test]
fn sid_args_reject_missing_bash_debug_command() {
let (_args, _free, status, messages) = parse_args(&["--bash-debug"]);
assert_eq!(status, 64);
assert!(
messages
.iter()
.any(|message| message.contains("Argument to option 'bash-debug' missing")),
"expected missing argument message, got {messages:?}"
);
}
#[test]
fn validate_runtime_mode_rejects_prompt_with_raw() {
let err = validate_runtime_mode(true, None, Some("hello"))
.unwrap_err()
.to_string();
assert!(err.contains("--raw cannot be combined with --prompt"));
}
#[test]
fn validate_runtime_mode_rejects_prompt_with_bash_debug() {
let err = validate_runtime_mode(false, Some("pwd"), Some("hello"))
.unwrap_err()
.to_string();
assert!(err.contains("--bash-debug cannot be combined with --prompt"));
}
#[test]
fn parse_args_accept_new_option() {
let (args, free, status, messages) = parse_args(&["--new"]);
assert_eq!(status, 0, "unexpected parser status: {messages:?}");
assert!(free.is_empty());
assert!(args.new);
}
#[test]
fn validate_session_selection_rejects_new_with_resume() {
let err = validate_session_selection(true, Some("sid-session"), None)
.unwrap_err()
.to_string();
assert!(err.contains("--new cannot be combined with --resume"));
}
#[test]
fn validate_session_selection_rejects_prompt_without_new_or_resume() {
let err = validate_session_selection(false, None, Some("hello"))
.unwrap_err()
.to_string();
assert!(err.contains("--prompt requires --new or --resume"));
}
#[test]
fn validate_session_selection_accepts_prompt_with_new_or_resume() {
assert!(validate_session_selection(true, None, Some("hello")).is_ok());
assert!(validate_session_selection(false, Some("sid-session"), Some("hello")).is_ok());
assert!(validate_session_selection(false, None, None).is_ok());
assert!(validate_session_selection(true, None, None).is_ok());
assert!(validate_session_selection(false, Some("sid-session"), None).is_ok());
}
#[test]
fn validate_connect_mode_rejects_raw() {
let err = validate_connect_mode(true, None, None, false, None, None)
.unwrap_err()
.to_string();
assert!(err.contains("--connect cannot be combined with --raw"));
}
#[test]
fn validate_connect_mode_rejects_new() {
let err = validate_connect_mode(false, None, None, true, None, None)
.unwrap_err()
.to_string();
assert!(err.contains("--connect cannot be combined with --new"));
}
#[test]
fn sandbox_warning_is_loud_and_explicit() {
assert!(SANDBOX_UNAVAILABLE_WARNING.contains("WARNING"));
assert!(SANDBOX_UNAVAILABLE_WARNING.contains("/usr/bin/sandbox-exec is unavailable"));
assert!(SANDBOX_UNAVAILABLE_WARNING.contains("UNSANDBOXED"));
}
}