ralph
ralph wraps claude -p with rich terminal rendering. Bare ralph opens one persistent chat whose memory is a compressed tree of summaries. From that chat, Claude can start background workers in their own git worktrees and watch long-running commands. ralph ask sends one prompt and stores nothing.
Features
- Persistent chat - Bare
ralphrunsralph chat. One Claude process serves the chat, started with a compressed view of the chat so far, stored under~/.ralph/chat. - Compressed memory - The chat log is summarized into a tree by a background nap pass. Claude opens details with the
zoomandrecalltools. - Background workers - Claude starts, messages, and closes workers with
spawn_worker,reply, andclose_worker. Harnesses:claudein tmux,cursor, andopencode. Each worker gets its own git worktree. These tools come fromralph chat-mcp, which talks to the running chat over a Unix socket. - Inbox and watches - Worker reports and finished
watchcommands land in the chat inbox. Read them with/inbox. - Status line - Shows the chat mode (
NAP,INBOX,WORK,IDLE); colors are set under[theme.status]. - Chat input - Multi-line editor with history,
$EDITORdrafts, clipboard images,!shell cells, skills, and an opt-in vi mode ([chat] edit_mode = "vi"). - Single-shot ask -
ralph asksends one prompt from an argument or stdin and stores nothing. - Rich terminal output - Syntax highlighting for code, diffs, and tool results, with built-in themes or custom
.tmThemefiles (ralph themes).
Installation
git clone https://github.com/cloudbridgeuy/ralph.git
cd ralph
cargo install --path crates/ralph --root ~/.local --force # Install to ~/.local/bin
cargo install --path crates/ralph --root /usr/local --force # Custom prefix (binary in /usr/local/bin)
Requirements
- Rust 1.95.0 or later
- Claude Code (
claudeonPATH) - git, for worker worktrees
- Per worker harness:
tmuxforclaudeworkers, the CursoragentCLI forcursorworkers,opencodeforopencodeworkers
Quick Start
- Chat:
ralph
- Ask a single-shot question:
ralph ask 'Explain this codebase'
- List syntax highlighting themes:
ralph themes
CLI Reference
Complete reference for all ralph commands and options.
Global Options
ralph [COMMAND]
The command is optional: bare ralph runs ralph chat.
| Option | Description |
|---|---|
-V, --version | Print version information (see version scheme) |
-h, --help | Print help (-h shows a summary) |
Commands
| Command | Description |
|---|---|
themes | List available syntax highlighting themes |
ask | Send a single-shot prompt to the LLM (details) |
chat | Chat with a persistent, compressed memory; default when no command is given (details) |
wire | Record harness-to-provider traffic through a local proxy (details) |
ralph themes
List available syntax highlighting themes.
Usage
ralph themes
Description
Displays all built-in themes that can be used with the --theme flag. Custom .tmTheme files can also be loaded by specifying a file path instead of a theme name.
Built-in Themes
base16-eighties.darkbase16-mocha.darkbase16-ocean.dark(default)base16-ocean.lightInspiredGitHubMonokai ExtendedSolarized (dark)Solarized (light)
Environment Variables
Environment variables can be used instead of CLI flags. CLI flags take precedence over environment variables.
| Variable | Equivalent Flag | Description |
|---|---|---|
RALPH_CONFIG_DIR | N/A | Override config directory location |
RALPH_THEME | --theme | Set syntax highlighting theme |
RALPH_NO_BACKGROUND | --no-background | Disable theme backgrounds (set to 1) |
RALPH_ASK_PERMISSION_MODE | ask --permission-mode | Permission mode for ask |
RALPH_CHAT_PERMISSION_MODE | chat --permission-mode | Permission mode for chat turns and claude workers |
RALPH_WORKTREE_DIR | chat --worktree-dir | Directory for worker worktrees |
RALPH_CHAT_SKILLS | chat --skill | :-separated skill directories for chat |
RALPH_WRAP_WIDTH | chat --wrap-width | Column cap for chat replies and the prompt area (also [chat] wrap_width); 0 = terminal width |
Examples
# Set theme via environment variable
export RALPH_THEME="Solarized (dark)"
ralph ask 'your prompt'
# Use custom config directory
export RALPH_CONFIG_DIR=/custom/config
ralph ask 'your prompt'
# One-off theme override
RALPH_THEME="Monokai Extended" ralph ask 'your prompt'
ralph wire
Run a local recording proxy, or inspect recorded sessions with ls, show, diff, and sessions. Details: ralph wire.
ralph wire [--provider anthropic] [--port N] [--upstream URL] [--dir PATH] [--responses]
| Flag | Default | Description |
|---|---|---|
--provider | anthropic | Provider to proxy |
--port | 0 | Port on 127.0.0.1; 0 picks a free port |
--upstream | provider default | Upstream base URL |
--dir | <tmp>/ralph-wire | Directory holding session folders |
--responses | off | Also store decoded SSE events per call |
ralph wire ls [--session ID] [--dir PATH] [--all] [--json]
ralph wire show N [--session ID] [--dir PATH] [--json]
ralph wire diff A [B] [--session ID] [--dir PATH] [--json]
ralph wire sessions [--dir PATH] [--json]
| Flag | Applies to | Description |
|---|---|---|
--session | ls, show, diff | Session id (default: the latest) |
--dir | ls, show, diff, sessions | Directory holding session folders (default <tmp>/ralph-wire) |
--all | ls | Also show non-inference calls |
N | show | Call number (seq) from ls |
A, B | diff | Call numbers (seq); with only A, diff A against the earlier call sharing its longest prompt prefix |
--json | ls, show, diff, sessions | Print JSON instead of a table |
Exit Codes
| Code | Meaning |
|---|---|
0 | Success - all operations completed successfully |
1 | Error - operation failed (see error message for details) |
See Also
- Configuration - Detailed configuration file documentation
- ralph themes - List available syntax highlighting themes
ralph chat Command
ralph chat is a single long-running chat with one persistent memory. Messages run on one persistent claude -p process that chat resets under a policy (see Process reset). The model sees a compressed view of the whole chat and can open details through a private MCP server. Bare ralph (no subcommand) runs ralph chat.
CLI
ralph chat [--skill <PATH>]... [--permission-mode <MODE>] [--worktree-dir <DIR>] [--no-inbox-sound] [--no-inbox-notification]
ralph # same as ralph chat with defaults
--skill <PATH>(repeatable) adds a skill directory. See Skills.--permission-mode <MODE>sets the claude permission mode for turns and claude workers. Precedence: flag,RALPH_CHAT_PERMISSION_MODE,[chat] permission_modein the ralph config, thenauto. Resolved once at chat start.ralph askhas its own setting.--worktree-dir <DIR>sets where spawned workers get their worktrees. Precedence: flag,RALPH_WORKTREE_DIR,[chat] worktree_dir, then.worktrees. A leading~expands to home; a relative path resolves against the repo root of each spawn, not the chat’s cwd. The chat process resolves the rest on each spawn and close.--wrap-width <N>caps the wrap width of rendered replies, worker reports and the prompt area (status line, draft, footer). Precedence: flag,RALPH_WRAP_WIDTH,[chat] wrap_width;0or unset = terminal width. The smaller of the cap and the terminal width wins, read at each render. A value that is not a whole number stops chat start with an error. See configuration.--no-inbox-soundand--no-inbox-notificationturn off the inbox alert parts (see Inbox alerts).--store <DIR>sets the store path. Precedence:--store, thenRALPH_CHAT_STORE, then~/.ralph/chat; an emptyRALPH_CHAT_STOREcounts as unset, and a missing home directory is an error when neither is set. A relative path resolves against the cwd at startup. The directory is created at startup. Parallel chat instances need separate stores; otherwise they share the log,history.txt,editor.md,mcp.json, andinbox/.- Hidden subcommands:
ralph chat-mcp --store <DIR> --socket <PATH>andralph chat-nap --store <DIR> [--timeout 600](NAP_TIMEOUT_SECS). Both are spawned by ralph itself. - Input is a reedline editor on the main thread with prompt
>>>. Enter sends; Alt-Enter or Ctrl-J inserts a newline; Ctrl-G opens[chat] editor(an argv list such as["nvim", "-c", "normal! Go", "-c", "startinsert"]; the file path is appended as the last arg; an empty list warns and falls back), else$VISUAL,$EDITOR, orvi(one program, no splitting), on<store>/editor.md, prefilled with the last assistant reply, a──── write below ────line, and the draft; the text below the last separator line replaces the draft (never sent; an empty result keeps the old draft). Ctrl-V pastes a clipboard image as[Image #N: path](see Images). Esc Esc clears the whole draft (Ctrl-Z undoes it, Ctrl-O redoes). With[chat] edit_mode = "vi"the editor uses reedline’s vi insert and normal keymaps: Esc leaves insert mode, so Esc Esc does not clear; Ctrl-U clears the whole draft in insert and normal mode instead. The insert keymap keeps Alt-Enter, Ctrl-J, Ctrl-V, Ctrl-G and Ctrl-O; normal mode keeps Ctrl-G. Visual mode is off: when a key (v) enters it, the edit-mode wrapper switches straight back to normal mode before reedline paints, so no selection starts;fv,tvandrvstill work. The prompt indicator is>>>in insert mode and:::in normal mode. The purechat_edit_modecore module resolves the key and the indicator. Up/Down and Ctrl-R use the history file<store>/history.txt(1000 entries). Ctrl-D or Ctrl-C at the prompt quits; an empty message does nothing unless reports are unread (see Worker inbox);[chat] empty_submitandempty_promptchange this (see configuration). A turn has no time limit. Ctrl-C during a turn cancels that turn (nothing is stored) and returns to the prompt with the message restored as the draft; SIGTERM and SIGHUP end the chat. While a turn runs, chat keeps a<store>/turnmarker (userorworker, removed when the turn ends); the chat socket server reads it sospawn_worker,close_workerandwatchwork only in a user-started turn (replyalso in a worker turn). - A dark-gray full-width rule (the shared termimad skin’s
---, default gray(6)―) prints before the turn and after the reply.
Layout
crates/core/src/chat_memory/: pure chat memory, split by concern.mod.rsholds the types (Entry,Node,Tree) and re-exports;log.rsparses and encodes the log and tree files;tree.rshascover,pending,split_view,stable_cut,tail_lines,verbatim_start,estimate_tokens;lookup.rshaszoomandrecall;search.rshas the ranked search (tokenizer, query, BM25 scan, cursor; see search);index.rsandarchive.rshold the log and tree indexes andArchive, whosereadandzoomload a window of entries and the summaries they show (see Index);tools.rshas the MCP tool schemas,handle_requestandfinish_call;nap.rshas the nap and retry prompts,nap_source,check_summary,fallback_summary, and the nap scheduling and commit decisionsnap_stepandcommit_decision;usage.rshas the per-call usage records.crates/core/src/chat.rs:VIEW_LINES,RECENT_HISTORY_TOKENS,OLDER_VIEW_STEP,SYSTEM_PROMPT,turn_system_prompt,turn_prompt,build_mcp_args.crates/core/src/chat_status.rs: footer hints, input routing, worker state parsing, memory size.crates/core/src/status_mode.rs: pure mode selection for the status line mode block.crates/core/src/status_segments.rs: status line segments, priorities, and the pure width layout.crates/ralph/src/status_theme.rs:[theme.status]keys, defaults and validation.crates/ralph/src/chat/statusbar.rs: paints the laid-out segments with the[theme.status]colors.crates/core/src/watch_command.rs:/watchargument parsing, watch ids, time-left and list formatting.crates/core/src/diff_command.rs:/diffargv (git,delta,hunk) and shell-word splitting.crates/ralph/src/chat/diff.rs: spawns that argv with the terminal inherited.crates/core/src/chat_skills.rs:resolve_skills,plan_links,expand_slash_command,plugin_manifest,build_plugin_args.crates/ralph/src/chat/mod.rs:runand shared setup.crates/ralph/src/chat/turn.rs: turn shell and nap spawn.crates/ralph/src/chat/editor.rs,prompt.rs: reedline input and prompt.crates/ralph/src/chat/monitor.rs: status, watch and stdin monitors.crates/ralph/src/chat/skills.rs: MCP config and skill plugin setup.crates/ralph/src/chat/memory.rs:Store(files,LOCK,NAP, append, nap loop and commit, indexedreadandzoom).crates/ralph/src/chat/index_files.rs:FileSource,open_archive, and thelog.idxandtree.idxwrites (update_indexfor writers,save_stalefor readers).crates/ralph/src/chat/mcp.rs:chat-mcpstdio loop; answerszoom,recall,searchandread, forwards worker tools to the chat socket.crates/ralph/src/chat/socket.rs: chat socket bind, sweep, server andforwardclient.crates/core/src/retry.rs: pure retry rules:classify,FailureKind,OUTAGE_MARKERS,OUTAGE_BACKOFF_SECS,backoff,retry_delay.crates/ralph/src/backoff.rs: shell loopretry_on_outagewith an injectable wait,attempt_fromandwait_unless; used byturn.rsand the nap inmain.rs.crates/core/src/chat_socket.rs: socket request/response lines, owner record,socket_fateandorphan_sidecar_fate.crates/ralph/src/invoke.rs:build_chat_command,build_memory_command.crates/ralph/src/cli/mod.rsandmain.rs: arguments, default dispatch,chat-mcpandchat-naphandlers.
Turn loop
- A launch (
Store::load_after_nap) first waits for a running nap, thenStore::load_settledtakes a sharedLOCK, then reads log and tree. It waits only while an append or a nap commit holdsLOCK(one fsynced line each), not for curator calls. Nodes that are still pending show as described in Pending summaries. split_view(entries, tree, VIEW_LINES, recent_history_tokens)builds the memory view in two parts. The cut snaps down to a multiple ofOLDER_VIEW_STEP(32 entries). The older part is the view (VIEW_LINES, 200 lines) of every entry before the cut. So the older view stays byte-identical across respawns until the step boundary moves or nap adds summaries. The tail starts at the cut. Entries between the cut and the verbatim run show as summaries. The rest is the verbatim run: the newest entries within the recent-history budget (see Recent history). The two cover every entry once. The view is built only when the turn needs a newclaudeprocess (step 4); a turn on a live process does not use it.- For a worker turn, one
workerentry per report is appended to the log. The user message waits for step 6. - One
claudeprocess serves the whole chat. The first turn (and any turn after the process died or a turn failed) runsbuild_chat_command: a freshclaude --verbose --permission-mode <MODE>(the chat’s resolved mode) with isolation args,--append-system-prompt <SYSTEM_PROMPT + older view>(turn_system_prompt; justSYSTEM_PROMPTwhile the older part is empty), the MCP args,--settingswithautoCompactEnabledfalse (merged with the CLAUDE.md exclude),--input-format stream-json --output-format stream-jsonand-p. Memory is injected at process start only: the first message is the tail lines, a blank line,New message:, then the message. Later turns send the message text only, as one stream-jsonuserline (ralph_core::stream::user_line); the process keeps the conversation. The command starts withCLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1, so claude starts no background tasks and the process stays alive after eachresult. TheSessionowns the piped stdin and the stdout/stderr reader threads for its whole life, andSession::turncan be called many times. A turn ends at the top-levelresultevent (parsed withralph_core::stream::result_is_error, not text-matched); if claude exits mid-turn ralph reads its remaining lines for up to 3 s (EXIT_GRACE) before it finishes; chat turns have no time limit (timeout_secsis not used). The process stays up and the next turn reuses it. The session is dead, and the next turn respawns it from the store, when the process exits, stdout closes beforeresult, or a turn is killed. Before each turn chat polls the child withwaitidandWNOWAIT, which does not reap it, so a process that exited while idle is dropped and respawned. Before it sends the message,Session::drivediscards the stdout lines that the process wrote after the lastresult. If one of them is aresult, the turn fails withLeftoverResultand chat respawns the process, as for a process that exited while idle. A line that arrives after this check still goes into the turn: stream-json lines carry no turn id. A turn that exits non-zero, has no reply, or errors drops the session too, as does a reset (see Process reset). On cancel or SIGINT the session writes onecontrol_requestinterruptline (interrupt_line) and keeps reading untilresult. With noresultwithin 3s it kills the process group and the session is dead. Dropping theSessioncloses stdin, waits, and kills after 3s. On every chat exit, a lost terminal included (watch_inputdrops the session beforeexit), ralph kills the claude process group before it reaps claude and never signals the group after that (group_signal_safe); nap and inbox-alert helpers start throughspawn_detached, which reaps them on a thread. There is no--resumeand no--agent. Worker triggers use the same path. - Output goes through the spinner subprocess runner and
StreamProcessor, as inralph ask. - On exit code 0 with response text, the user message (if any) and then the reply are appended verbatim and a detached
ralph chat-napis spawned (stdio null; spawn failure only warns). Otherwise a warningchat turn failed (exit N)prints and the loop continues. Only the subprocess call is wrapped inretry_on_outage: a non-zero exit whose stderr or response text matches an outage marker, waits 2s then 8s and respawns the process from the store with the first-message form and runs the turn again (at most 2 retries), warningchat turn hit an outage (<kind>); retrying in Ns (k/2). The wait polls the cancel flag every 100ms, so Ctrl-C ends the turn as a cancel; SIGTERM and SIGHUP end it too. Cancel and interrupt are never retried. If the first stdin write to a reused process fails (it died after the check), the turn starts a fresh process from the store once and sends the first-message form; the dead process never got the message, so it is sent once, and this is not an outage retry. After the retries run out, or for any non-outage failure, the warnings and draft restore below apply unchanged. Only the final attempt writes a usage record, and only the first turn of each process carries the view and prompt-size fields. - A turn that is cancelled (Ctrl-C), fails, or hits a subprocess error stores nothing for a user message, including any partial reply, and returns to the prompt with the message preloaded as the draft (
draft_after). A cancelled turn also drops the claude process, so the next turn respawns it from the store. Worker report entries from step 3 stay in the log. SIGTERM and SIGHUP end the chat.
SYSTEM_PROMPT tells the model that the message starts with the compressed chat, that #<id> lines are summaries, to use zoom, recall and search before guessing or asking (read pages a range of ids), that [worker ...] messages come from workers, not the person, and never to claim manual saving. On a worker message the model may reply only with an answer already settled in the chat (a decision the person made, approved scope, a step in an agreed plan); merges, pushes, PRs, Linear status changes, new scope and anything public go to the person, and every worker reply is listed in its next message to the person. spawn_worker and close_worker are only for what the person asked in their latest message, abandon only when the person said to drop the work, and force only when the person said to close a worker of another chat. The chat MCP server enforces this with the pure worker::command_allowed: in a turn the person started every worker command is allowed, in a worker-triggered turn only reply is, and anything else is refused. Messages that start with [worker watch:<name> · watch] are watch reports from the watch tool, not from an agent or the person; before calling watch the model shows the exact command and waits for approval. Its delegation rules keep turns short: the model reads inline only to scope (at most 3 tool calls per question), sends multi-file reads, broad searches and test runs to evidence-collector, all code edits to implementer, and fixing work that fell short to reviser, and every dispatch prompt states goal, question, known facts, owned files, output cap, stop condition and tool budget, “Report not-found, do not widen scope.”, and “You are a subagent. Don’t run mem.” After 2 failed repair rounds it diagnoses; after a 3rd it waits for the person. It also must lead with the answer and end with one next step, show a 3-5 line plan when a request is ambiguous, treat a worker “done” as unverified until it checks the diff, tests, git and Linear, cite the #<id> behind facts from older chat, and say it has no erase tool when asked to forget.
Worker inbox
Worker reports never interrupt the draft. A watcher thread polls every 500 ms and repaints the prompt in place (reedline RepaintSignal), keeping the buffer and cursor.
- Each arriving inbox file moves into the queue
<store>/inbox.json(oldest first, positions from 1 that shift up when a report leaves); unread is the queue length. A report leaves the queue only when sent to main or dropped, and is deleted from the file. An old file withnext_id,idandreadfields still loads; its read entries are dropped. Tmux workers have sessions namedralph-<project>-<id>(<project>= the git top-level folder name, letters, digits and dashes only, elserepo), so the same id in two projects does not collide. Names are cosmetic: at spawn ralph tags each session with the tmux user options@ralph_store= the canonical store path and@ralph_id= the bare id, and the status line reads the id from@ralph_id. Only sessions tagged with this chat’s store and an id are listed (sessions without both, including ones started before@ralph_id, are ignored; missing tmux or no server means no tmux workers). Headless Cursor and opencode workers are listed from their owner files instead (see Headless workers). A worker with ablocked/<id>marker (a pending permission request) showspermissionwhatever its reports say, and counts as working. A claude worker’s state is the status of its latest report, elserunning. Areply, a nudge or a spawn writes the marker<store>/sent/<id>; while it exists adone,blockedorquestionstatus is ignored (acheckstatus is not), so the worker shows as working until its next report, which removes the marker (chat_status::superseded). For opencode workers ralph asks the chat’s own opencode server forGET /api/session/active(one call for all workers, every 5 s and after each turn or inbox read, only while an opencode worker runs):busywhileses_ralph<id>is in it, else the latest report status, elseidle; if the call fails they fall back to the claude rule. Memory is the log entry count and the view size at about 4 chars per token, refreshed every 5 s and after each turn or inbox read. - Worker check: when
[chat] worker_check_secsis above 0 (default 60), the watcher thread runsMonitor::checkevery that many seconds. For each running worker it reads theready,blocked,waiting,unreportedandsentmarkers (asentmarker means the last report is old, so the worker is never counted as reported), and the last real report (done,blockedorquestion) seen in the inbox or logged as a worker entry in the chat log at start (so a restart keeps it). OnlyClaudeworkers get a pane capture, skipped when blocked, waiting, or ready and reported; headless workers have no pane. The pureworker_check::planclassifies the observations. Markers, all in<store>/<dir>/<id>:blocked= a permission request is pending,waiting= the worker stopped without a report after one reminder,unreported= the reminder failed (the hooks and host turn logic that write and clear them are in MCP tools). A waiting worker gets one nudge per wait,worker_check::NUDGE_TEXT(“You stopped without calling report. Call the report tool now: done, blocked or question.”) throughWorkers::nudge: typed into tmux or queued to the headless host. Only a report that lands after the nudge re-arms it. Each state change that needs the person posts one inbox message with statuscheck, which raises the usual alert;checkposts never count as reports. Both headless kinds report waiting without an attach hint. A Cursor permission wait posts no check message: the worker host posts its own alert for every pending request (see Cursor permissions); an opencode permission wait posts a check message with instructions to ask the person and relay the answer withreply. - Heartbeat: a Cursor or opencode worker’s host writes
activity/<id>(<epoch secs> running|idle, rewritten only when the second or the flag changes) on every ACP request, notification, turn start and turn end (opencode: every SSE event of the worker’s session, running from prompt or turn start to turn end), andgone/<id>when theagent acpchild exits or its connection ends without a close or chat exit (reason text; the host’s 250 ms polltry_waits each live child, which also reaps it, and writesagent acp exited (<status>)once per exit) or when the sharedopencode servedies without a chat exit (first linescope server pid <n>, then the reason). Spawn, resume and close clear both. A server-scopegonestays across the server restart; the monitor deletes it after it posts the Dead message, so the next server death alerts again; withworker_check_secs = 0no Dead message posts and the marker stays until spawn, resume or close. On each worker check the monitor feeds the markers to the pureHeadlessStatereducer andliveness: a Running worker (not blocked) with no activity for[chat] worker_quiet_secs(default 300, unverified) is Quiet and posts onecheckmessagequiet <n> s; may be stuck on a prompt; agonemarker makes it Dead and posts one message naming the reason. Each fires once per episode; new activity re-arms Quiet. Idle, blocked and starting workers are never Quiet. Runs on the worker check tick, soworker_check_secs = 0turns it off. One server death posts a single message naming the server, the reason and every affected worker (the message’s worker field is the sorted comma-joined ids). Claude workers: the check remembers which Claude sessions were alive; one that vanishes while itsready/<id>marker remains getsgone/<id>(tmux session ended) and one Dead message. Close clearsready/<id>before killing the session (and restores it if the session survives), so a clean close posts nothing. A death while chat is not running posts nothing; the startup leftover hint (orphan_hint) lists it. - Tmux-only check states: trust = the folder-trust dialog shows; unknown = the pane has not changed since the last check, no report came, no Claude busy marker shows in the pane footer (
esc to interrupt, or← N agent(s)with N of 1 or more), and nounreportedmarker is set (that marker meanswaitingis due at the nextidle_prompt, so the worker counts as working until then). ralph never types into a permission prompt or the trust dialog. Tmux check messages describe waiting, a permission prompt, the trust dialog or an unknown wait and end withtmux attach -t <session>. Unknown posts at most once per wait: flips to working (a spinner or status-line redraw) do not post it again; a report, waiting, blocked or trust state re-arms it. - Inbox alerts: on each poll the watcher compares pending reports with the last poll (reports pending at chat start never alert). For new reports it asks tmux for focus and the pure
inbox_alert::inbox_alertdecides. Inside tmux,tmux display -p -t $TMUX_PANE '#{pane_active} #{window_active} #{session_id}'plustmux list-clients -t <session> -F '#{client_flags}'giveVisiblewhen the pane and its window are active and a client has thefocusedflag, elseHidden. Outside tmux, no attached client, or a failed query giveUnknown, which alerts unless[chat] inbox_alert_unknown_focus = "silent".Visiblenever alerts, and nothing alerts when bothinbox_alert_soundandinbox_alert_notificationare off. An alert plays a sound (afplay /System/Library/Sounds/Glass.aiffon macOS, else a terminal bell) and, withinbox_alert_notification, a notification<id> <status>, ...(osascript display notificationon macOS, else OSC 9, wrapped for tmux passthrough). Limits: a tmux client that never got a focus-out (background pty, terminal without focus reporting) counts as focused and suppresses the alert; focus follows the terminal app, not window visibility (minimised, other Space, second monitor). Only inbox reports alert. - Status line: a lualine-style bar painted above the input.
status_segments::segmentsbuilds segments fromStatus,layoutpicks which fit, andchat_statusbarpaints them. Left: mode block (bright, bold; one ofNAP,INBOX,WORK,IDLE, see below),📬 N unread(soft; absent at 0 unread), the store path (last two parts). Right, soft to bright:workers,watch,mem N msgs ~Xk tok,cache R read W write P% tok(soft),$cost(bright, bold). The cache segment comes from the lastturnline inusage.jsonland is absent before any turn; its percentage is the cache-read token shareread * 100 / (input + read + write), rounded down, or 0 when the total is zero (TurnCache::token_share_percent), not a cost share.$costis the turn’s total cost and is absent when unavailable. The filler runs the full width in the base background. Each segment has long-to-compact forms (📬 2 unreadto📬 2,mem 132 msgs ~4.1k tokto132m ~4.1k, workers with state, names,2 workers,2w; watches2wt; cachec96% tok). When the bar is too wide,layoutcompacts the least important segment first, and drops segments only after every form is compact. Drop order, first to last: store, watches, workers, cache, cost, memory, mail; the mode block stays. Colors come only from the[theme.status]table (mode_*,store_fg,workers_fg,watches_fg,memory_fgandbase_bg, resolved bystatus_theme.rs; keys and defaults in configuration). Segments are plain colored blocks;[chat] status_powerline = trueadds powerline glyphs between them. With no color (NO_COLORor no TTY) the bar is plain text with│between segments and no padding. Not shown: git branch and elapsed time, because the status data has none of them. - Status modes:
status_mode::modepicks the mode block, first match wins:NAP(a nap holds theNAPlock),INBOX(unread above 0),WORK(a worker isrunning,busyorpermission, or any watch exists), elseIDLE. Each mode has its ownmode_<name>_fg/mode_<name>_bgkeys. The mail, cache and cost blocks take the active mode’s colors, so the coloured ends of the bar match the mode block; blocks of the same color run together with no separator in block mode. There is no turn mode because the bar only renders at the prompt.Store::nappingtry-locks the storeNAPfile in shared mode and releases it at once, so the probe never blocks. Limit: a nap started by anotherralph chaton the same store also holds that lock, so the bar showsNAPthere too. - Footer:
footerinchat_status.rsbuilds a hint line, painted under the input with one empty line after it, so the prompt is never the last terminal line. Stock reedline 0.52.1 erases everything below the cursor on repaint, sovendor/reedlinecarries upstream nushell/reedline plus the footer commit (Prompt::render_prompt_footer) andReedline::with_wrap_width. The painter soft-wraps the prompt area at the capped width, or at the terminal width without a cap: draft rows break between words (UAX #14, as in replies; a word longer than a row breaks inside it) and each wrapped row starts with the:::continuation prefix. The breaks and prefixes are display-only; the submitted text is what was typed. Mouse-click mapping still assumes character wrap; chat does not enable it through[patch.crates-io]. Bracketed paste is on, so a multi-line paste stays in the draft.
/inbox commands
N is a position in the unread queue: oldest first, from 1, positive integer. Positions shift up after a send or a drop. An unknown N prints No report N. /inbox list shows the queue. and changes nothing.
| Command | Effect | Starts a turn |
|---|---|---|
/inbox, or Enter on an empty prompt with reports unread | Sends every queued report to main and deletes it from the queue: one worker log entry per report, then one turn with all reports in one message. With nothing unread, /inbox prints No unread reports. and Enter does nothing. [chat] empty_submit = "inbox-first" sends empty_prompt instead of doing nothing; "phrase" always sends it, so only /inbox opens the inbox | yes |
/inbox next | Same as /inbox for the oldest report only (position 1). With nothing unread, prints No unread reports. | yes |
/inbox N | Same as /inbox for the report at position N only. Unknown N prints the not-found message | yes, when the report exists |
/inbox list | Prints one row per queued report, #<position> <worker> <status> <age>, columns padded, oldest first (No reports. when empty); age is <n>s under a minute, <n>m under an hour, <n>h under a day, else <n>d, 0s for a future time and ? for an unparsable one (pure inbox::list) | no |
/inbox peek N | Prints report N in full with the same header box and markdown as /inbox; removes nothing | no |
/inbox drop N | Removes report N from the queue and prints Dropped report N.. Before the queue changes, appends one JSON line {timestamp, worker, status, text} to <store>/inbox-drops.jsonl (append-only, never rewritten); an unknown N logs nothing. The unread count refreshes after a drop | no |
any other /inbox argument | Prints the usage string (below) | no |
Usage string: Usage: /inbox [next | N | list | peek N | drop N]. /inboxfoo is a normal message. Local commands never start a turn or call the model. Each report that /inbox, next, N or peek prints goes under a header box from the pure frame::worker_header (● worker <id> · <status>, border and status colors from the agent palette: blue border, green for done, red for blocked or failed, yellow for waiting or check by default; sized by display width and cut to the terminal width), then its text as markdown through the chat renderer with the agent MarkdownTheme from [theme.agent] (see configuration; code blocks default to the assistant theme); plain text (header [worker <id> · <status>]) when stdout is not a TTY or NO_COLOR is set. The model still gets the plain [worker <id> · <status>] message.
Watches
A watch is a shell command (sh -c) that runs as a child process of ralph chat, in its own process group, with stdin closed. Ralph keeps only a bounded tail of its stdout and stderr.
- When the watch exits, ralph posts exactly one
watchreport to the worker inbox. The text holds the exit code (or killed or timed-out note), the output tail, and the follow-up instruction. - The report goes through the inbox, so it never interrupts the chat channel.
- Stopping a watch posts one “stopped” report. Closing the chat kills every watch and its child processes and posts nothing.
- The runner lives in
crates/ralph/src/watches.rs; the pure report and tail logic lives incrates/core/src/watch.rs. - Persistent watches are saved as one JSON file each in
<store>/watches/(spec,started_at, timeout deadline, wake deadline), written atomically before the command starts. - On every
ralph chatstart,Watches::restorereruns all saved persistent watches, also after Ctrl-D or/quit. Normal records are deleted, never restored. An unreadable record warns and is deleted. - A restart keeps the original
started_atand deadlines. The timeout becomes the time left. A command that is exactlysleep <N>is rewritten tosleep <left>(rounded up to whole seconds). Other commands rerun unchanged. - A watch whose wake deadline passed posts an exit-0 report right away. One whose timeout deadline passed posts a timed-out report right away (the earlier deadline wins). Neither is spawned.
- The saved record is removed when the watch exits, times out, or is stopped. Closing the chat kills the watch but keeps the record. The pure logic lives in
crates/core/src/watch_store.rs; the file store iscrates/ralph/src/watch_store.rs. - The agent starts a watch with the
watchtool (see MCP tools). The chat socket server queues the request by writing a file to<store>/watch-requests/(atomic, one JSON file per request). The file is claimed with a hard link, which fails if the name exists, so a second call for an id whose request still waits returns the errorwatch <id> is already queuedand queues nothing. The chat watcher thread takes every request on each 500 ms poll, callsWatches::start, and deletes the file (crates/ralph/src/watch_requests.rs; pure parsing incrates/core/src/watch_request.rs). If the start fails (duplicate id, spawn error), chat posts onewatchreport with the error to the inbox. - Every watch id is stored, reported and shown as
watch:<name>: generated ids arewatch:w1,watch:w2, …; awatchtool idfoobecomeswatch:foo, and an id that already starts withwatch:is not doubled (watch_idincrates/core/src/watch.rs). Worker ids allow only letters, digits and dashes, so a watch id never equals a worker id./watch stopacceptsw1orwatch:w1. - Watch reports show as
[worker watch:<name> · watch]. The system prompt says they come from a watch, not from an agent or the person. - Approval: the system prompt tells the model to show the person the exact command and wait for approval before it calls
watch. The prompt enforces this, not the tool. The tool only refuses outside a user-started turn, likespawn_worker. /watchis local: it never starts a turn or calls the model. The shell side iscrates/ralph/src/chat/watch.rs; the pure parsing and formatting iscrates/core/src/watch_command.rs./watch start [--persistent] <command> -- <follow-up>starts a watch in this process (Watches::start). The input splits at the last--; both parts must be non-empty. The id is the lowest unusedwatch:w<n>(n from 1) among running and saved watches. It printsStarted <kind> watch <id>.. There is no timeout flag./watch listprints one row per active watch: id, kind (normalorpersistent), time left, command. Time left is the earlier of the wake deadline (a time-basedsleep N) and the timeout deadline, shown as<n>s,<n>m,<n>hor<n>d, or—when there is none. It printsNo active watches.when empty./watch stop <id>kills the watch and posts its one “stopped” report. A stopped persistent watch is removed from<store>/watches/, so it does not come back after a restart. This is the only way to remove a persistent watch. If the id is not running but has a saved record, the record is removed and the message says so. An unknown id prints a message, not an error.- Bare
/watchor any other argument printsUsage: /watch start [--persistent] <command> -- <follow-up> | list | stop <id>./watchfoois a normal message.
- A restarted watch that times out reports the original timeout, not the time left.
- Status line: running watches show after the workers as
watch <id> <left>, <id>, ...(the time left is omitted when there is none). It sheds detail as the terminal narrows: time left, then names to<n> watches, then<n>wt, then nothing. It refreshes on the same 500 ms poll, so it updates when a watch starts, ends or is stopped. - Every watch has a timeout. A watch without its own
timeout_secs(from thewatchtool,/watch start, or an old saved record) gets[chat] watch_timeout_secs(default 3600, so 1 hour;0is a config error, resolved byresolve_watch_timeoutincrates/core/src/chat.rs, same file-then-default precedence asworker_check_secs).WatchSpec::with_default_timeoutapplies it at start and at restore. When it passes, ralph kills the watch and posts one report that saystimed out after <n>s and was killed, where<n>is the original limit.
/diff
/diff is local: it never starts a turn or calls the model. /difffoo is a normal message.
| Input | Shows |
|---|---|
/diff | the last finished chat turn (same as /diff last 1) |
/diff last N | net changes over the last N finished chat turns (N >= 1) |
/diff ago N | the single chat turn N back (N >= 1) |
/diff worker <id> | that worker’s changes since it spawned |
/diff worker <id> last N or ago N | that worker’s last N turns, or the turn N back |
/diff now | the working tree against the index, as plain git diff |
The ranges use the same snapshots and rules as the changes tool (last and ago, rebase-aware). If snapshots are off, there is none yet, or N is larger than the finished turns, /diff prints one plain line and runs nothing. After an upstream change it may print the upstream note first. An empty snapshot range prints no changes (same wording as the changes tool) and runs no pager. There are no absolute turn numbers.
The words after the range keywords are git diff args: /diff -U1 -- a is the last chat turn with -U1 -- a, and /diff worker w1 last 2 --stat is a worker range with --stat. /diff now [git diff args] is the working-tree diff: git diff in the chat’s working directory. Words are split on whitespace, with quotes and backslash escapes (a trailing backslash is kept; no $ expansion or globs), and are passed after [changes] diff_args. In a range, the two snapshot revs come right after diff, before diff_args. An unclosed quote prints /diff: unclosed quote and runs nothing. A bad count, a missing worker id, or last, ago or now as the worker id prints a usage line and runs nothing.
[changes]
pager = "git" # git | delta | hunk
diff_args = [] # default git diff args; typed /diff args follow
pager = "git"(default):git diff <revs> <diff_args> <typed>. The user’score.pagerin git config is left untouched.pager = "delta":git -c core.pager=delta diff ...for this call only.pager = "hunk":git -c core.pager=hunk pager diff ..., withcore.pager=hunk pageras one argument.- Any other pager string warns once at chat start (
[changes] pager = "..." is unknown; using git) and usesgit. Loading the config still succeeds. - ralph does not write
core.pager. Snapshot ranges setGIT_DIRand the alternate object dir for the spawned git only;/diff nowsets no environment variables.
The spawn runs only after reedline read_line has turned raw mode off, so a full-screen pager returns to a normal prompt. git diff exiting 1 (there are changes) is not a chat error. A failure to start git warns and returns to the prompt.
Process reset
The persistent claude process is dropped, and the next turn respawns it from the store through Turn::launch, when reset_due (crates/core/src/chat.rs) is true after a turn:
- Turns on this process reach
[chat] reset_turns(default 30). - The last assistant message’s
context_tokensreach[chat] reset_tokens(default 140000). No usage means no token trigger. - The
resultevent hasterminal_reasonblocking_limit(claude’s context is full). Only the structured field counts, never the textPrompt is too long. The turn counts as failed: warning,Unanswered, no reply stored, session dropped. The usage record is still written.
Compaction stays off. A respawn waits for a running nap (load_after_nap: shared NAP try-lock, spinner while it blocks) and then loads under the shared LOCK; no lock is held while claude runs. A nap that is spawned but has not yet taken NAP when the respawn loads is not waited for: the respawn reads the committed tree and shows the nodes as pending.
Turn snapshots
Unless [changes] enabled = false, every turn (user and worker-report turns) snapshots the working tree of the repo the chat started in, before and after the model call. A chat outside a git repo does nothing and prints no warning; this is decided once at start. A snapshot failure warns and never stops the turn.
- Storage: one bare git dir per repo at
<store>/snapshots/<repo-dir-name>.git, with its own objects and no link to the repo. The repo’s own refs and objects are untouched. - Refs:
refs/ralph/chat/turn-<n>/startand.../end.<n>continues from the highest existing number, also across restarts. - Content:
git read-tree -m -i HEADinto a private index at<side>/index/chat(skipped when the branch has no commit yet), thengit add -A, so tracked files stay even when ignored and.gitignoreandinfo/excludeapply to untracked files, thenwrite-treeandcommit-tree.read-treeruns without the side object dir; stat-clean entries reuse blobs an earlier snapshot wrote there andadd -Arehashes the rest. Snapshot commits have no parents. - Message: the ref name, then trailers
Ralph-Head: <sha>(HEAD at snapshot time) andRalph-Base: <sha>(merge-base of HEAD and the default branch, left out when it cannot be resolved; same default-branch rule as the worker base). Older snapshots have no trailers. Built bysnapshot_message, read byparse_snapshot_base. - Pruning: after each turn the last 20 turns stay; older start and end refs are deleted, then
git gc --auto --prune=1.hour.agoreclaims objects. - Code: pure logic in
crates/core/src/snapshot.rs; git calls incrates/ralph/src/snapshot.rs; the turn hook incrates/ralph/src/chat/snapshots.rs.
Worker snapshots and report diffstats
Claude, Cursor and opencode workers snapshot their worktree into the same side dir, keyed by the main repo name (from git rev-parse --git-common-dir). Snapshots come from ralph’s own side dir, not from opencode’s /api/session/<sid>/diff.
- Refs:
refs/ralph/workers/<id>/turn-<k>, private index at<side>/index/workers/<id>. Turn 0 is taken at spawn; turnkat the end of each turn (Claude Stop hook, Cursorrun_turns, opencodeEffect::Snapshot, which the pureTurnStateemits first on everyTurnEndedfrom SSE orreconcile, resends included, but not while the chat is closing; the chat’s SSE thread runs the git calls). The/opencode.jsonline that spawn adds to the repo’sinfo/excludekeeps ralph’s opencode config out of the snapshots unless the repo tracks that file. The last 20 stay.close_workerdeletes the refs and the index. - Report: the worker MCP server (
worker-mcp --worktree) appends twogit diff --statblocks to the report text, rendered byrender_changes: since spawn (merge-base ofHEADand the worker base branch against a fresh tree of the worktree, so untracked files count) and since the last turn snapshot (labelled turnlatest + 1). A block that cannot be computed is left out. Output is uncapped. The since-spawn block is already rebase-safe, because it diffs from the current merge-base. The since-last-turn block is rebase-aware (see “Reading changes”). [changes] enabled = falsealso turns off worker snapshots and report diffstats: the spawn decides once (snapshots_enabled), takes no turn 0, and passes no--worktreeto the worker hooks and MCP server (opencode:opencode.json’sworker-mcpcommand, built byworker_mcp_args; the spawn handoff carriessnapshots, and resumed opencode sessions read the config at chat start).close_workerstill clears leftover refs and index from workers spawned while it was on.- Code: pure helpers in
crates/core/src/snapshot.rsandcrates/core/src/changes.rs; git calls incrates/ralph/src/worker_changes.rs.
Reading changes
The changes tool of chat-mcp lets the main thread read a diff without leaving chat. It runs git diff in the side dir (--no-pager --no-color), so the output is plain text.
- Scope: turns are counted back from the newest finished turn, so the model never needs absolute turn numbers.
last=Nandago=N(1 or more, not both) are resolved by the pureresolve_rangeover the listed refs. With noworker, the chat repo (chat-mcpruns in the chat’s directory); finished turns are those with an end snapshot, so the running turn never counts.last=N(default 1) gives the start snapshot of the Nth newest finished turn to the end snapshot of the newest;ago=Ngives one finished turn (ago=1is the newest,ago=2the one before). Withworker, that worker’s worktree, where each step between two consecutive turn snapshots is a finished turn: nolastoragogivesturn-0to the highest turn (since spawn);last=Nspans the last N steps;ago=Nis the single step N back. An N larger than the finished turns is not clamped: it is an error such aslast=5 is more than the 3 finished turns of the chat; use last 3 or less. The removedturnandsincearguments are rejected with an error that nameslastandago. Other errors:no finished chat turn snapshots yet,no snapshots for worker X,no snapshots. - Rebase handling: after a rebase onto a newer default branch, a plain diff would count commits pulled in from the default branch.
diff_basecompares theRalph-Baseof the two snapshots (the freshcurrent_basefor the since-last-turn report block). Same base, or both unresolved:Direct, diff as is. The newer snapshot hasRalph-Baseequal to itsRalph-Head(the checkout sits on the default branch, such as a chat on main):Direct, so a moved base is its own commits. Both bases known and different:Rebase, and the older snapshot is moved onto the new base withgit merge-tree --write-tree --merge-base=<old> <new> <snapshot>, then that tree is diffed. Missing trailers or one unresolved base:Unknown, diff as is. Anymerge-treefailure (git older than 2.40, a conflict, unparsable output) falls back to the unmoved diff. Every fallback andUnknownappends the linenote: could not separate upstream commits from these snapshots; counts may include commits pulled in by a rebase. The rebase step never fails the call.merge-treeruns withGIT_DIRset to the side dir and the repo object dir as an alternate, so its objects land in the side dir and never in the user’s repo. - Format:
statis--stat,filesis--name-status,patchis--patch.pathslimit the diff. The first output line says what the range is, such aschanged in chat turn 3. No changes givesno changes. - Cap: output keeps whole lines from the start while the body fits in
max_bytes(default 32768), never splits a UTF-8 character, and ends with… <B> bytes omitted (<F> files not shown); narrow with paths or format=stat. Body and footer together stay withinmax_bytes, except whenmax_bytesis smaller than the footer: then only the footer shows. The label line is not counted. Worker report diffstats stay uncapped. [changes] enabled = falsemakes the tool return an error that says snapshots are off. A chat outside a git repo, or with no snapshots yet, returnsno snapshots.- Limit: changes after the latest snapshot are not shown; a running turn has no end snapshot until it finishes.
- Code: pure parsing and range resolution in
crates/core/src/changes.rs, the cap incrates/core/src/changes/cap.rs; rebase handling incrates/core/src/changes/rebase.rs; the tool call incrates/ralph/src/chat/changes.rs;render_diffand the git helpers incrates/ralph/src/snapshot.rs.
Executable cells
A fenced block in a message you type whose info string is !<runtime> (for example ```!zsh) is a cell. Worker reports and agent replies never run cells; a ! fence inside another fence is not a cell. Parsing and message building are pure in ralph_core::chat_cell; PATH lookup, spawn and streaming are in crates/ralph/src/chat/cell.rs.
Shorthand: a message whose first character is ! is the same as one !shell cell holding everything after the ! (all lines; trailing newlines dropped; a longer fence when the body has backtick fences). expand_bang_shell in crates/ralph/src/chat/cell.rs rewrites it before parsing. Only the very first character counts: !ls or hi !ls is plain text, and so is ! alone or ! followed only by whitespace. A failed or cancelled cell restores the draft as typed (!ls).
- On Enter, cells run in order, one process each, in the chat’s working directory. The runtime is found by exact name on
PATH; the code goes to its stdin. No sandbox. Stdout and stderr share one pipe. While a cell runs the terminal shows one dim line▸ cell N · runtime; the output is not echoed live. - Claude gets the message with the
!removed, then per cell:[cell N · runtime · exit C], the code, and the output. A non-zero exit still sends. The store keeps this enriched message. - After all cells ran, the terminal shows the message (the
!removed) rendered as markdown. Each cell is followed by its full, untrimmed output in a```resultfence (longer fence if the output has backticks; none when the output is empty) and, for a non-zero exit, anexit Nline (core::chat_cell::display_results). The display never reaches Claude,log.jsonlor memory. - Caps shape only what Claude receives:
[chat] cell_output_limit(200 lines, first half and last half kept) andcell_output_bytes(65536, head and tail kept), each with a marker in place of the cut:… N lines trimmed …and… N bytes trimmed …. For an odd limit the head keepslimit / 2and the tail keeps the larger rest.0disables a cap. See configuration. - A missing runtime prints
runtime `X` not found, runs nothing, sends nothing, and puts the text back in the prompt. Ctrl-C kills the running cell and its process group, ends the turn without sending, and restores the draft. !shellruns$SHELL -i(/bin/shwhen unset) and keeps state between!shellcells, in one message and in later messages, until ralph exits. One process per cell, no long-running shell. The stdin is a generated prologue plus the cell code. The prologue empties the prompt variables and prompt hooks, turns off verbose and xtrace, unsetsHISTFILE(a leading space keeps the first line out of history) so no state or code reaches the history file, dumps a START snapshot to fd 3 (a pipe ralph reads into memory, never disk), restores the saved state, installs an exit hook that dumps an END snapshot to fd 3, then re-enables verbose and xtrace if they were on. The snapshot has the exported env,$PWD, functions and aliases.diff(END, START)is the saved state: new, changed and removed exports, functions and aliases, plus the end directory. START is taken before the restore, so the diff holds everything done since ralph started, and inherited env and.zshrcdefinitions are never copied. Output before the prologue’s end marker (rc noise, the first prompt) is dropped from the result. Restore text and the prologue never appear in the output, in Claude’s message or in the result fences.!shellstate works for zsh and bash only (bash is best effort; macOS bash 3.2 untested). Any other shell runs without state and warns once. A snapshot that cannot be read (for example the cell closed fd 3) or a failed spawn warns and resets the state to clean; the message still sends. A cancelled cell (Ctrl-C) keeps the state from before that cell. Not carried: shell options, background jobs, open connections,precmd/preexechooks (cleared inside!shellcells), anything outside the snapshot.- The prompt shows the raw buffer, so the
!stays visible while you type.
Memory
- Log:
log.jsonl, append-only, one{role, at, text}per line (roleisuser,assistantorworker,atis RFC 3339). Each append is fsynced underLOCK, thenlog.idxis brought up to date under the same lock (see Index). A complete line that does not parse is kept as a placeholder entry at its position, shown as[corrupt entry](roleworker, emptyat). The placeholder carries an explicitcorruptflag that only the parser sets: the flag is never written tolog.jsonl(#[serde(skip)]), so a real entry with that text stays a normal entry.parse_log(Store::load: turn view, status bar, nap, check, editor) and the archive (read,zoom,recall,search) both give the placeholder the id of its line and show every later entry with the same ids. The placeholder can never be written back: the log writer takes a separateNewEntrytype, so a loadedEntry(including a[corrupt entry]placeholder) cannot be written back, and the compiler enforces it. The turn view shows no placeholder as a message.viewandtail_linesreplace it with a notice line,[#12: unreadable log line, skipped], and merge consecutive placeholders into one line,[#12-14: unreadable log lines, skipped](merge_notices, pure, innotice.rs); ids of the other entries do not change, and a run that crosses the older view and the tail gives one notice on each side. The status bar counts the same lines and shows no entry text.read,zoom,recallandsearchkeep the[corrupt entry]line per id, so a caller can still address each id.recallandsearchnever match the placeholder.pendingnever offers a leaf or block whose range holds a placeholder, so the nap never summarizes it; the view falls back to the children of a skipped range. A trailing line without newline is ignored.Store::appendtruncates a torn last line (a crash mid-append) under the exclusiveLOCKbefore it writes, so a new entry never glues onto it (log_tail, pure; the file reads andset_lenare intrim_torn_tail), and warns with the byte offset. - Tree:
tree.jsonl, one{lo, hi, text}record per line, indexed bytree.idx(see Index). Nodes are aligned power-of-two blocks[lo, hi)over log indexes, ids print as5or4-7. First record wins for a duplicate node; invalid records warn and are skipped. Summaries are at mostSUMMARY_BYTES= 512 bytes, one line. - Leaves are single messages. Outside the verbatim run (see Recent history), messages of 512 bytes or fewer stay in the view with whitespace collapsed and need no node. Longer ones get a leaf summary node. Parent nodes merge two children and exist only for complete blocks. A node is written once and never rebuilt.
cover(t, budget)picks the nodes for the view: all leaves if the log fits the budget, otherwise a cover of the whole log, oldest first, tuned to use the budget. A cover node with no summary yet is shown through its halves or a marker (see Pending summaries).- View line formats: message
#<i> <role> <at> <text>; summary#<id> <summary>. zoomleaf lines already carry the full timestamp (entry_line), while range lines are summaries with no single time, so no zoom change was made for timestamps.- This is a port of the OptMem cover, pending and tree_put design. There is no runtime dependency on
~/.optmem.
Index
log.idx and tree.idx let read and zoom load only the entries and summaries they show. log.jsonl and tree.jsonl stay the record; each index is a cache that is always safe to delete.
- Format (
chat_memory/index.rs, one genericIndex<S: Slot>): a 52-byte header (magicRLPHLOGXorRLPHTREX, version 3,covered,tail,hash,skippedline count, slot count, all little-endian), then fixed-size slots.log.idxhas oneu64start offset per entry;tree.idxhas one{lo, hi, start, end}slot of fouru64per valid record, in file order.coveredends on the newline of the last scanned line,tailis that line’s start, andhashis the FNV-1a hash of the bytes[tail, covered). - Same visible content as the parsers: only complete, non-blank lines are scanned. In
log.idxevery complete line is a slot, a corrupt one too, so later entries stay visible; the line is not parsed when the index is built. Entry ids are line ordinals, so#<id>does not change. Intree.idxan invalid record is skipped (parse_tree), andTreeIndex::findreturns the first slot of each node, so the first record wins. - Freshness (
Index::freshness, pure): a file shorter thancoveredor a tail whose hash differs meansRebuild; a file that ends atcoveredisFresh; a longer file isExtend, which scans only the bytes pastcovered. A file with a bad magic, version, length, slot or offset order decodes as missing, which also means a full rebuild. - Writes:
Store::appendupdateslog.idxunder the exclusiveLOCKafter the log fsync; the nap commit updatestree.idxunder the same lock after the record is written.refreshandwrite_plandecide: nothing, an in-place extend (new slots at the end, then the header at 0; a crash in between leaves a length the header does not match, so the next load rebuilds), or a full file written to a temp file and renamed over the index. The temp file is named.<index>.<6 letters or digits>.tmp(index_temp_prefix,is_index_temp).check_pathrejects a symlinked or hard-linked index. A failed index write only warns: the line is already committed. - Readers (
read,zoom,recall,search,Store::with_archive) hold a sharedLOCKfor the whole call. They open the log and the tree and fix their lengths, read both indexes and extend or rebuild them in memory, answer, then save each index they had to extend or rebuild (save_stale). That save writes the whole file to a temp file and renames it, never in place: no writer runs under the shared lock, readers that hold it see the same lengths and so write the same bytes, and a concurrent reader sees the old file or the new one. No write plan is built on this path. An index for a missing file is not saved, and a store directory that does not exist reads as empty without creating anything (no directory, noLOCK). Summaries come fromIndexedTree, one read per wanted node. - Temp-file sweep: a save killed between the temp write and the rename leaves its temp file.
Store::appendremoves every file whose nameis_index_tempmatches (sweep_index_temps) after its own index update, under the exclusiveLOCK: no save runs then, so every match is orphaned and no age check is needed. Symlinks and other names stay. A failed sweep only warns. - Self-healing (
Archive::retry): a decode only checks the header, lengths and offset order, so a damaged slot can still decode. Each load checks what it reads: the log lines in a window must start at the stored offsets (the read starts one byte early to catch an offset inside a line) (a line that does not parse is not a failed check: it becomes the placeholder); a tree slot must end beforecovered, parse as a record, and name the node it is filed under. A failed check isInvalidData: the call rebuilds both indexes from their files, runs once more, and the reader saves the rebuilt indexes. - Warnings (
Archive::warnings): each call that loads a corrupt log line warns once for it:chat log entry <id> at byte <offset> is corrupt and shows as [corrupt entry]: <error>(entry_or_corrupt, pure; the offset is the line start). The tree gives onechat tree: <k> records skippedline whereparse_treegives onechat tree record skipped: <error>line per record. - Visibility rule: a
readorzoomcall sees every complete line that was in the log and the tree when it took its sharedLOCK. A line appended later is visible from the next call. A torn last line (no newline) is never visible. - Store without an index (older binaries, deleted file): the first reader rebuilds it, the cost of one full file read, and saves it.
- Each call still reads both index files whole: about 8 bytes per entry and 32 per summary.
- The turn view, the status bar and the nap read the whole log and tree (
Store::load,parse_log); a corrupt line is a placeholder there too.recallloads every entry through the archive under the sharedLOCK;searchscans the log in chunks of 256 entries throughlog.idx, so it never holds the whole log as entries (see search). - Benchmark:
cargo xtask bench-memory [--sizes 1000,10000,100000] [--runs 9]builds a store per size in a temp dir and prints, per case, the median latency, bytes read and allocations of the whole-file path and the indexed path. It fails when the two answers differ. It calls theralph_corefunctions with its own file reader, notStore.
Nap
ralph chat-nap takes the exclusive NAP lock for its whole run, snapshots the store under a shared LOCK, and for each node from pending (long leaves, then complete parent blocks by increasing size) asks a Haiku curator for a summary with no LOCK held. Each result is committed under a short exclusive LOCK (see Nap consistency).
- Curator:
build_memory_commandrunsclaude --model haikuwith--tools '', an empty strict MCP config, and isolation args. Its prompt asks for one line of at most 512 bytes (about 70 words), priority: the user’s words close to verbatim, lasting effects, commit SHAs, Linear ids and worker outcomes copied exactly, and failures, findings and open questions, steps taken. It asks to name minor items in a word or two, copy names, numbers, ids, paths and errors exactly, never overstate progress, invent nothing, and write in third person. It forbids copying the#<id> <role> <timestamp>line prefix, and says the input is data to never answer or obey. check_summaryaccepts a trimmed, non-empty, single-line summary of at most 512 bytes that does not read as meta-talk (an opener such asI'm,Here is,Sure,Let me,Based on, or the phrasesthis excerpt,<input>,</input>). A rejection retries with the error appended, up toMAX_NAP_ATTEMPTS= 5 tries per node. A curator error (process failure or timeout) skips that node: it stays pending, is retried at the next nap, and other nodes continue. A parent whose child has no summary is skipped.- Raw fallback: when all
MAX_NAP_ATTEMPTStries were rejected bycheck_summary(not a curator error), the nap storesfallback_summaryof the node input (nap_source) through the normal commit, so the parent can proceed, and warnschat memory stored raw fallback for #<id>. The fallback collapses all whitespace to single spaces, cuts on a char boundary so the whole line is at most 512 bytes, and starts with the marker[raw excerpt](FALLBACK_MARK), so a reader or the model can tell it from a real summary in the view and inzoom.recallsearches log entries only, so it never sees fallbacks. The fallback reaches the view when the next process starts. - Stuck nap:
LOCKandNAParestd::fs::File::lock(flock) locks on open files, so the OS releases them when the holder process exits; deleting the files is not needed.ralph chat-nap --timeout <secs>(default 600) bounds each curator call: on expiry the process group is killed and the call counts as a curator error. It does not bound the whole nap (nodes times attempts). A slow nap does not block turns; it only delays later naps, which wait onNAP. A status bar stuck onNAPmeans a live nap. Find it withlsof <store>/NAPorpgrep -fl 'ralph chat-nap'and kill it; the next nap retries the pending nodes. - Outage backoff: the curator subprocess call runs through
retry_on_outage.classifysorts a failure intoTimeout,OutageorRefusal. It is anOutagewhen the exit code is non-zero and the lowercased stderr plus response text contains one ofOUTAGE_MARKERS(overloaded,api error: 5,api error: 429,rate limit,rate_limit,529,503,502,500 internal,econnreset,econnrefused,etimedout,socket hang up,connection error,network error,request timed out). Everything else, such as a refusal, bad output or an unknown error, is aRefusaland is never retried. An outage waitsOUTAGE_BACKOFF_SECS= 2s then 8s before the next call, at most 2 retries; if it still fails, the node stays pending as before. The waits run with noLOCKheld, so they do not delay turns. A timeout is never retried: each one lasts up to--timeoutseconds and holdsNAP, so retries would delay later naps, and the next nap retries the node anyway. Thecheck_summaryretry loop is separate and has no waits. Chat turns use the same schedule. - A turn on a live process does not wait for the nap. A launch (first turn, or a respawn after a reset or failure) waits for a running nap through
Store::load_after_nap, then reads the committed tree and shows pending nodes as below.
Nap consistency
- Snapshot: under a shared
LOCKthe nap reads log and tree once.log.jsonlis append-only, so the entries under any pending node never change after the snapshot. - Curator calls,
check_summaryretries and outage backoff run with noLOCKheld. - Commit: for each summary (or raw fallback) the nap takes the exclusive
LOCK, re-readstree.jsonl, and askscommit_decision.Writeappends one fsynced record, then updatestree.idx.Takenmeans another nap committed the node first: the nap drops its own text, warnschat memory kept the first summary for #<id>, and keeps the committed text. Either way the nap continues from the re-read tree. - Order:
nap_stepreturnsDonefor a node already in the tree,Blockedfor a parent whose child has no committed summary, elseAskwith the prompt. A parent prompt is always built from committed child text, so a parent never summarizes a dropped child. - Racing naps:
NAPmakes naps run one at a time. If two naps still overlap (for example an older binary withoutNAP), first-writer-wins at commit plus first-record-wins inparse_treekeep one summary per node. Records are written whole under the exclusiveLOCK, so naps never interleave lines. - Failure: a curator error, a timeout, or a nap that is killed before its commit writes nothing for that node. The node stays in
pendingand the next nap retries it. Nodes committed before the failure stay. - Tests: pure unit tests in
nap.rscovernap_step,commit_decision, a race of two naps on one node, and uncommitted work staying pending. The shell testnap_curator_runs_without_the_store_lockchecks thatLOCKis free during curator calls.
Recent history
The newest live-tail messages show their full raw text, newlines kept, within a token budget.
- Budget:
--recent-history-tokens <TOKENS>, else[chat] recent_history_tokens, else 8192 (RECENT_HISTORY_TOKENS).0turns it off: the tail is then settled lines only. There is no env var. - Estimate:
ceil(bytes / 3)of the message text (estimate_tokens). This is more conservative than thechars / 4used by the status line, so the verbatim run stays under the budget. - Run:
verbatim_startwalks from the newest message back to the start of the tail and adds each estimate. It stops at the first message that does not fit, so the run is one contiguous suffix with no gaps. - Oversized: a newest message above the budget gives an empty run. It and all older tail messages use the settled rule (summary, or the
[raw excerpt]fallback while the summary is pending). zoomon a message still returns its full raw text.- Rollover: only the tail is verbatim. Older messages are in the older view in compressed form. The memory is read when a process starts, so a long-lived process keeps its first view; messages after it arrive as turns in the process’s own conversation.
- The turn and the status size (
measure_memory) use the same budget.
Pending summaries
While a nap runs, a turn can load a tree with pending nodes. view and tail_lines never drop them:
- A cover node with no summary is shown through its halves when the line budget allows, down to single messages; otherwise as a marker (see below). Halves with committed summaries show those summaries.
- A long message with no leaf summary shows
fallback_summaryof its text, marked[raw excerpt]. This excerpt exists only in the view; it is never written totree.jsonl. - The view stays within its line budget (200 lines), or the cover length if that is larger. To show a missing node through its halves, the view first merges the oldest sibling pair whose parent has a summary (one line freed), then splits the newest missing node (one line spent). A missing node that cannot be shown this way is one line
#<id> [not summarized] zoom <id> for its parts(UNAVAILABLE_MARK).zoomon it lists its halves (summary ornot compressed yet), down to full messages. - Tradeoff: under a tight budget with nothing to merge, old ranges that the previous rule expanded in full now show as a marker, so the model sees less until it zooms. Only older ranges become markers; newer ranges stay expanded. The view is read at process start, so a new process uses the committed summaries.
MCP tools
[chat] mcp_servers in the ralph config (default ["mcptools"]) names servers to import. select_servers copies each entry verbatim from the top-level mcpServers of ~/.claude.json. A name absent there, or equal to ralph-chat, prints one warning and chat starts without it. ralph-chat is inserted last, so an import cannot shadow it. The file is written once at chat start: config changes need a restart. Imported entries can carry secrets, which is why they go in a file and not in argv. Turns run with the chat’s permission mode (default auto), which decides whether imported tools need approval.
Hidden ralph chat-mcp --store <DIR> --socket <PATH> speaks newline-delimited JSON-RPC 2.0 on stdio (server name ralph-chat). At chat start the shell writes <store>/mcp.json (mode 0600) holding ralph-chat plus the servers named in [chat] mcp_servers, and build_mcp_args passes --mcp-config <store>/mcp.json --allowedTools mcp__<server>__*,... --strict-mcp-config, so only those servers load. The allow list names every server in mcp.json: --setting-sources project,local skips allow rules in ~/.claude/settings.json, and without this list the auto-mode classifier blocks these MCP calls. chat-mcp takes no permission mode or worktree directory; the chat process owns both. Each recall call re-reads log and tree without locking, so it sees fresh state. zoom and read hold a shared LOCK as described in Index.
Worker tools run inside ralph chat, not in chat-mcp. At start the chat sweeps <store>, then creates the private directory <store>/chat-<pid>/ (mode 0700, created with DirBuilder so it fails if the path exists; a leftover directory that the sweep kept makes chat start fail with a clear error), binds <store>/chat-<pid>/chat.sock inside it (then mode 0600, so the socket is never reachable with looser rights) and writes the owner record <store>/chat-<pid>.owner (mode 0600, JSON pid and ps -o lstart= start time). The sweep reads each chat-<pid> directory and each .owner record. The pure chat_socket::socket_fate deletes a directory and its record when the record is missing, unreadable or names another pid, or when the owner is dead or has a different start time; it keeps them only when the owner is alive with the same start time. An orphan .owner record, one with no chat-<pid> directory, goes through chat_socket::orphan_sidecar_fate with the same rule: it is deleted unless its owner is alive with the same start time. Quitting the chat (Ctrl-D, /quit, or SIGTERM/SIGHUP during a turn) ends the main loop, and SocketGuard drops and removes its own directory (with the socket) and record. A SIGTERM/SIGHUP or a lost terminal stdin at the prompt goes through the input watcher instead: ExitCleanup::run kills watches, shuts down headless hosts, removes the same directory and record, then exits with code 129. A SIGKILL leaves both for the next start’s sweep. mcp.json passes --socket <path> to chat-mcp. chat-mcp keeps zoom and recall and forwards spawn_worker, reply, close_worker and watch to the socket as one JSON line {tool, args} per connection; the chat answers with one line {text, is_error} after it checks the turn marker (command_allowed) and parses the arguments (worker::parse_command). If nothing listens, the tool returns an error that starts with no running ralph chat and names the socket path; there is no fallback. ralph ask loads no chat-mcp, so it cannot spawn workers. A Unix socket path may not exceed 103 bytes (macOS sun_path is 104 with the NUL byte); a longer <store>/chat-<pid>/chat.sock makes chat start fail with a message that asks for a shorter --store.
chat-mcp answers zoom, recall, search, read and changes itself. It forwards watch (and spawn_worker, reply, close_worker, below) to the chat socket, so the running chat produces their results.
| Tool | Args | Result |
|---|---|---|
zoom | id (string, such as 16-31 or 5) | A block id returns its two halves (summary lines, or #<id> not compressed yet; halves past the log end are dropped). A single index returns the raw message. |
recall | pattern (string) | Case-insensitive regex over raw messages. Returns an index, newest hit first, one #<id> · <role> · <date> · <preview> line per hit (preview: ~80 chars around the first match, … at cut ends), at most 50 lines then N more, narrow the pattern, and a closing (zoom <id> for the full text). No hits gives no hits for <pattern>. No byte cap. |
search | query (string: bare words, "quoted phrases", identifiers), optional role (user, assistant, worker), since, until (RFC 3339 timestamp or YYYY-MM-DD), limit (1 to 50, default 20), cursor | Ranked hits over raw messages, one #<id> · <role> · <at> · <snippet> line per hit, then next cursor: <cursor> or (end of results), then (read <id> for the full text). No hits gives no hits for <query>. See search. |
read | id (inclusive range such as 16-40, or one index), optional mode (raw default, summary), budget (tokens, 1 to 16384, default 4096), cursor | One page of a flat range, in order. See read. |
changes | optional worker (string), last or ago (integers 1 or more, not both), format (stat default, files, patch), paths (array of strings), max_bytes (positive integer, default 32768) | Plain-text diff between turn snapshots, no pager, no colour, under max_bytes (see Reading changes). |
watch | id (string, no spaces or slashes; stored as watch:<id>), command (string), follow_up (string), optional persistent (boolean, default false), optional timeout_secs (positive integer, default [chat] watch_timeout_secs, 1 hour) | Queues the watch and returns at once. The report arrives in the inbox when the command ends. Needs a user-started turn. The model must get the person’s approval of the exact command first. |
Invalid ids, ids beyond the log, bad regexes, bad search dates, roles, limits or cursors (a cursor from another query or filter: cursor does not match this query; after the log grew: stale cursor; the log changed), and store read errors return a tool result with isError: true.
search
search (chat_memory/search.rs) ranks the raw messages for a query; recall stays the regex tool (every hit, newest first). The pure part tokenizes, parses the query, filters and scores; search drives it over an Archive in chunks of 256 entries, so only one chunk of Entry values is live. There is no term index: each call scans the whole log under the shared LOCK, so an entry is searchable as soon as its append commits.
- Tokens: lowercase runs of letters, digits and
_. Identifiers stay whole:-,.and::between word characters join (guz-709,foo::bar,a.b,main.rs). A token is compared whole, never as a substring:GUZ-709does not matchGUZ-7090. A joined token also keeps its parts (split on-,.and:::rate-limitedhasrateandlimited;chat_memory::searchhaschat_memoryandsearch). A single-part query term matches a whole token or a part:ratefindsrate-limited,guzfindsGUZ-709. A joined query term such asGUZ-709matches whole tokens only. A joined token counts at most once for a term, whether the term matches its whole form or any of its parts, however often a part repeats; the length for BM25 stays the number of whole tokens. A quoted single word such as"rate"is a phrase of one word: it matches whole tokens only, so it does not matchrate-limited. - Query: bare words are optional units; each
"quoted phrase"is a unit whose whole tokens must appear consecutively and is required; parts do not join phrases, so"rate limited"does not matchrate-limited. An unclosed quote is an error (unclosed quote in query.). A hit needs every phrase and at least one matching unit. A query with no token is an error. - Rank: BM25 (
k11.2,b0.75, token counts as length) over the entries that passrole,sinceanduntil; document frequencies come from one scan of those entries. Ties go to the newer entry. - Dates:
sinceanduntilparse withchrono.sincelater thanuntil, after the date expansion, is an error; the same date for both is valid. A date means that UTC day:sinceis its start,untilincludes the whole day. A timestamp bound is inclusive and may carry any offset. An entry whoseatdoes not parse is excluded when a date bound is set. - Cursor:
<offset>.<count>.<hash>wherecountis the log entry count the scan ended with (a mid-scan index rebuild is counted, so the next page is not stale) andhashis FNV-1a over the normalized query, role, bounds and limit. Each page rescans and ranks again, then returns hits fromoffset. A changed query, filter or limit givescursor does not match this query; a different entry count givesstale cursor; the log changed. - Snippet: about 160 characters of whitespace-collapsed text, with the first match near the start,
…at cut ends. - Evidence recall: a labeled fixture in the unit tests (10 queries, recall@5 = 1.0) measures whether the right entry ranks in the top 5, not whether a model answers correctly.
- Bench:
cargo xtask bench-memoryhas two search cases (one rare phrase, one word in every entry). Itsfullpath rebuilds both indexes before the scan, itsindexedpath loads them.
read
read (chat_memory/range.rs) pages through a flat range of the log. zoom navigates the tree one node at a time and recall searches by regex; read returns the originals in order, so the model can read a stretch it already located. The id is a range a-b (inclusive, any bounds, not only aligned blocks) or one index. b is clamped to the log end; a past the end, a > b, a budget outside 1 to 16384, a malformed cursor, or a cursor outside the range all return isError: true.
- Each record is a header
#<id> · <role> · <date> · <n> charsand then its text verbatim (whitespace and newlines kept). Records are separated by a blank line. mode: rawreturns the original text.mode: summaryreturns the leaf summary for a message longer thanSUMMARY_BYTES,[not summarized](UNAVAILABLE_MARK) when that leaf has no summary yet, and the raw text for short messages. Use the same mode on every page of one read, because the cursor offset counts characters of the text shown; the cursor records its mode and a read in the other mode is rejected.budgetis a page size in tokens, measured withestimate_tokens(bytes / 3). A page holds whole records while they fit.- A record that does not fit is split at a character boundary, never inside a UTF-8 character, and its header gains
chars a-b(1-based, inclusive). A page always carries at least one character, so paging always advances. - When more remains the page ends with
next cursor: <next>.<offset>@<end><m>. Pass it back ascursorwith the sameidandmode.<offset>is the character offset inside record<next>;<m>isr(raw) ors(summary), the mode that issued the cursor. A cursor in the other mode, one with no marker (the older format) or one with an unknown marker returnsisError: true. Otherwise the page ends with(end of range). - Snapshot: the first call fixes
endasmin(b + 1, log length)and the cursor carries it, so messages appended between pages never appear, and a full traversal has no gaps or duplicates. - Loading (
Archive::read): each call loads a window of entries from the cursor (ora) throughlog.idx, never the whole log; insummarymode it reads only the leaf summaries of long entries in the window, throughtree.idx. The first window holds entries whose JSON lines add up to the page budget in bytes; when the page needs an entry past the window before the budget is spent (Read::More), the window doubles. The page is the same as one built from the whole log (tested).
Workers can be tmux-hosted or headless (Cursor and opencode). See Headless workers for HarnessId, old cursor: true/false record compatibility, the chat-owned HeadlessHost lifecycle and how to add another headless harness.
spawn_worker takes id, task, optional harness (claude default, opencode, or cursor), optional model (a model name or alias for claude and cursor, provider/model for opencode). The claude model is the pure worker::resolve_claude_model, first hit wins: the model argument, [claude] model, then claude’s own default (no --model flag); an invalid or unloadable config adds a warning and uses the default. The opencode model is the pure worker::resolve_opencode_model, first hit wins: the model argument, [opencode] model, then openai/gpt-6.1-sol; the chat process resolves it at spawn, and an [opencode] model that is not provider/model adds a warning to the result and uses the default; a config that fails to load also adds a warning and uses the default. The cursor model is the pure worker::resolve_cursor_model, first hit wins: the model argument, [cursor] model, then cursor’s own default (no --model flag). An invalid or unloadable config adds a warning and uses the default. Cursor’s ACP fixes one variant per model, so spawn has no reasoning-effort or fast argument. worker::parse_command refuses an unknown harness, a claude or cursor model that is empty or has whitespace, and an opencode model without a provider and name. Claude runs in tmux session ralph-<project>-<id> through a login zsh -lic; every harness works in <dir>/<id> on branch worker/<id>. The worktree directory comes from the pure worker::resolve_worktree_dir, first non-empty wins: --worktree-dir, RALPH_WORKTREE_DIR, [chat] worktree_dir, then .worktrees; a leading ~ expands to home, then the result joins the repo root (an absolute path stays as is). A config that fails to load skips worktree_dir and adds a warning to the result. Spawn and close both prune stale worktree entries, then take the path of worker/<id> from git worktree list --porcelain (worker::worktree_for_branch) and use <dir>/<id> only when the branch has no worktree, so a worker made under an earlier setting is still found. Spawn reuses that worktree and an existing worker/<id> branch. Otherwise it runs wt switch [--create --base @] worker/<id> --no-cd with --config-set worktree-path=<dir>/<id>, so the worktree lands where close expects it whatever the user’s wt template is; if wt is not installed or fails, it falls back to git worktree add [-b]. The spawn result says which path ran (WorktreeVia::note). close_worker removes either kind with git worktree remove and git branch -D. Claude gets --permission-mode <MODE> (the chat’s mode), --mcp-config <store>/workers/<id>.json, --add-dir <store>/attachments (spawn creates it; pasted [Image #N: <path>] paths in task or reply text are read from there), and the worker rules via --append-system-prompt. opencode runs headless on the chat’s own server (see the opencode harness below), with no tmux session; ralph writes <worktree>/opencode.json with model, permission.external_directory allowing <store>/attachments/**, and a local ralph-worker MCP server, and adds /opencode.json next to the worktree directory (only when it is inside the repo) in the shared .git/info/exclude. Each worker signals ready by creating <store>/ready/<id>, which spawn deletes first: Claude through a SessionStart hook (matcher startup) in <store>/workers/<id>.settings.json passed with --settings; opencode, the chat, once the server accepts the first prompt. For Claude, spawn polls up to 20 s, reading the pane only to accept the folder-trust prompt; a timeout is not an error (“not confirmed yet” plus the tmux session), but a gone tmux session or a trust prompt still showing is. Before any git step, spawn runs a preflight (workers/spawn.rs), so a failed preflight leaves no worktree, branch or exclude line: claude needs tmux and zsh on PATH and claude in a zsh -lic shell (command -v); cursor needs agent on PATH; opencode writes <store>/opencode-preflight/<id> (the time) and waits up to 30 s for the chat to start or reuse its server and write <store>/opencode-healthy/<id>, or to write <store>/opencode-error/<id>, so a missing binary, an unhealthy server or no running chat fails here. If start fails after the worktree step, spawn rolls back only what it created, decided by the pure worker::rollback_plan: it removes the worktree (git worktree remove, never --force, so a dirty worktree stays) only when this spawn created it and the harness is not still running (a live tmux session or an owner file), and deletes the branch (git branch -D) only when the worktree was removed, this spawn created the branch, and git rev-list --count <tip at creation>..worker/<id> is 0. The user sees the start error; rollback failures are appended to it (worker::rollback_report).
Cursor harness: cursor runs one agent acp child per worker (JSON-RPC over stdio through the agent-client-protocol crate, no tmux, cwd = the worktree). The child is always agent acp. A resolved model is applied after session/new, and again after session/load on resume, with session/set_config_option (worker::select_cursor_model). An exact advertised option value is sent as-is. A short name selects the advertised value for that name. Cursor’s ACP fixes one variant per model name: session/new offers only mode and model, and a value that is not in that list is rejected. If a name is listed more than once, the cheapest listed value is sent (fast=false, then no fast, then fast=true, then the lowest effort). Any other string is sent unchanged, and the agent’s rejection is the spawn error. Setting the option rewrites the user’s Cursor model default. Before the set, ralph writes cursor-model-default.json in the ralph config directory (worker::cursor_default_snapshot). It then puts back model, selectedModel, modelParameters, hasChangedDefaultModel, modelSelectionHistory and maxMode in ~/.cursor/cli-config.json, and selectedModelVariantId in ~/.cursor/acp-config.json (worker::restore_json_keys), and deletes the snapshot. A crash between the set and that restore leaves the files on the worker model. The next chat start (CursorHost::new) sees the snapshot and repairs them (worker::cursor_default_crash). With no snapshot the files cannot be told from a change the user made. agent --model does not change the ACP session’s current model. chat-mcp lives only for one turn, so the long-lived ralph chat owns the child. Spawn prepares the worktree, writes <store>/cursor-spawn/<id> (a JSON SpawnRequest with the time) and polls every 250 ms for <store>/ready/<id> or <store>/cursor-error/<id>, giving up after 30 s (it removes the request). The host drops a request older than 30 s. Right after starting the child, before initialize, it writes <store>/cursor/<id>: a JSON owner record with the chat pid and start time and the process group id and start time (ps -o lstart=, guarding pid reuse), not a bare child pid. It runs initialize (protocol must be 1, else error) and session/new with the ralph-worker stdio MCP server, removes the spawn request and writes ready; if the request is already gone (spawn timed out), it terminates the child. A failed or timed-out start kills the group and removes its owner file. Each agent acp start rewrites ~/.cursor/cli-config.json, as reported in GUZ-570 testing. A model set also rewrites the model default in that file and selectedModelVariantId in ~/.cursor/acp-config.json; ralph restores those keys after the set. The snapshot written before the set is removed when the restore succeeds. Accepted risk: a global Cursor MCP server named ralph-worker (worker::SERVER_NAME) replaces ralph’s own. A different agent --version than the tested 2026.10.14 only warns; authRequired becomes “run agent login”.
Cursor ownership and cleanup: the child runs in its own process group, so its MCP servers die with it: terminate sends SIGTERM to the group, waits 3 s, then SIGKILL. The agent ignores stdin EOF after session/new, so a killed chat leaves the child alive until a sweep: on chat start (every owner file), on close and on respawn of that id, the pure worker::sweep_decision returns Reclaim (owner chat dead: kill the group if its start time still matches, delete the files, go on at once), Wait (this chat owns it: use the close handoff below) or Busy (another live chat owns it: refuse). A close is not refused when the owner is the caller’s own chat, found by worker::caller_owns_worker on the caller’s ancestor pids from ps -o ppid=, since chat-mcp runs below claude below ralph chat. Quitting ralph chat terminates every owned child through its host (see Headless workers). The ACP MCP entry passes --owner <store>/cursor/<id> to worker-mcp, which refuses a report (tool error “worker closed”, nothing posted) once the owner file is gone (worker::accept_report).
Cursor permissions: the session thread registers an on_receive_request handler for session/request_permission; it stores the Responder and the options in a per-worker queue and never answers by itself, except for the worker’s own ralph-worker-report: report tool: worker::auto_allow_option picks the allow_once option and answers at once with its exact optionId, no queue and no blocked/<id> marker. With [cursor] permissions = "yolo" (read at worker start; default ask) auto_allow_option does the same for every request that has an allow_once option and never picks allow_always; a wrong value or unreadable config warns and uses ask. Every other tool waits for the person, and each such request (the second and later too) posts one inbox message with status blocked naming the tool, its summary and the valid optionIds (worker::permission_alert_report); the monitor’s check adds no second message for Cursor workers. The front request is mirrored, within one 250 ms chat poll, in <store>/blocked/<id> as JSON {tool, summary, options:[{optionId,name,kind}]} (built by worker::permission_content; existing blocked readers only test that the file exists). The person answers through reply: for a cursor worker with both the owner file and blocked/<id>, Workers::reply writes <store>/cursor-answer/<id> and waits up to 30 s for the chat poll to consume it. The chat applies worker::match_option (exact, case-sensitive optionId after trimming surrounding whitespace; option names are not accepted): a match answers Selected, then shows the next queued request or removes the marker; no match leaves the request pending and returns an error through cursor-error/<id> listing the valid optionIds. CursorHost::cancel_pending answers every pending request Cancelled and clears the marker; close, shutdown and terminate call it before killing the group.
Cursor prompts and interrupts: a reply with no waiting permission writes one entry per message under <store>/cursor-prompt/<id>/ (named worker::prompt_entry_name, written hidden then renamed). The chat poll queues them in name order; run_turns starts a session/prompt only after the previous one ended, so a mid-turn reply waits for the next turn. interrupt: true skips the permission answer and writes under <store>/cursor-interrupt/<id>/ instead. Opencode also supports interrupts through its own queue (see below); tmux workers return “interrupt needs a Cursor or opencode worker”. Cursor’s result is the real outcome, handed back through a result file: the entry name travels with Inbound::Interrupt, and after PromptQueue::accept run_turns writes cancelled (the steps include CancelSession, checked by worker::cancels_turn) or sent to <store>/cursor-interrupt-result/<id>/<entry> (written hidden then renamed). Workers::reply polls every 250 ms for up to 30 s, removes the result, and worker::interrupt_reply words it: cancelled, or sent with nothing cancelled; a timeout says the interrupt was queued but ralph chat did not confirm, outcome unknown. Chat start removes the whole cursor-interrupt-result/ dir (a killed chat leaves results); close, sweep and spawn remove the id’s dir. The chat poll sends the interrupt to run_turns, where worker::PromptQueue puts it at the front and returns CancelPermissions (all pending permissions answered Cancelled) and, when a turn is running, CancelSession (an ACP session/cancel notification). The pending prompt returns cancelled; worker::turn_outcome maps it (or end_turn after an interrupt) to Cancelled, which queues no REPORT_REMINDER, posts no blocked report and sets no waiting/unreported marker. The interrupt text runs next in the same session. Close and sweep remove cursor-interrupt/<id> with cursor-prompt/<id>.
Cursor traffic log: every agent acp worker appends to <store>/acp-logs/<id>.log the JSON-RPC bytes in both directions (lines <epoch secs> in|out <chunk>, in = ralph to agent) and the agent’s stderr (err), through LoggedRead/LoggedWrite around the stdio handed to the connection (acp_log.rs). Chunks are raw reads and writes, not one frame per line. When a write would pass 1 MB (ACP_LOG_CAP, decided by worker::rotates) the file is renamed to <id>.log.1, replacing the old one, and a fresh file starts, so at most about 2 MB per worker. The log is appended across respawns and never deleted on close.
Cursor frames: the pure worker::encode turns a WorkerCommand into an AcpCall (Prompt, Cancel, PermissionResponse) that acp_worker.rs sends for the prompt, cancel and permission-answer sites; worker::decode_update maps the report tool call to WorkerEvent::Reported; the prompt-response stop reason stays decoded by worker::turn_outcome, because ACP stop reasons do not fit TurnEnd without losing TurnOutcome::Stopped.
Cursor reporting: after session/new the chat sends the task as session/prompt (WORKER_PROMPT, a blank line, then the task, as for opencode) without delaying ready. Each worker has one prompt queue; worker::turn_outcome maps each response, and a prompt that errors or ends with anything but end_turn posts a blocked report to the inbox. Close sets a closing flag first, so the cancelled stop reason posts no blocked report. The session/update handler sets a per-turn flag when a tool_call_update is the ralph-worker-report: report tool (worker::is_report_tool_call). A turn ending with end_turn and no report queues one REPORT_REMINDER prompt; if that turn also ends without a report, the worker gets waiting/<id> and unreported/<id>, as a claude worker does after its Stop hook gives up. The next prompt removes waiting/<id>. Worker checks use Workers::nudge to queue a cursor-prompt/<id>/ entry; no pane or tmux attach hint is used.
Cursor resume: Cursor saves a session only once a prompt turn has sent its first session/update, so the chat does not write the record after session/new. run_turns holds it and writes <store>/cursor-session/<id> (JSON SessionRecord: sessionId, cwd, inbox, the canonical chat store path as owner, the resolved model when one was set) once, after the first turn that ends with a stop reason and saw at least one session/update while not loading (worker::session_saved). Quitting the chat while a turn runs sends session/cancel, waits for that turn to end, then applies the same rule; CursorHost::shutdown stops every worker first, then waits for all of them against one shared 500 ms deadline before it kills the groups, so an agent that never answers is bounded by it. A worker quit before its first turn produced an update has no record, so it is not resumed and is not resumable. Quitting the chat (and the chat-start sweep of a dead chat’s owner file) keeps ready/<id>, so the leftover hint lists it at the next chat start; only close removes ready/<id>. Quitting the chat keeps a written record; close, a failed start and an unclaimed start remove it. A chat store holds exactly one chat, so the store path is the chat identity. On chat start, after the owner sweep, a background thread resumes each record (CursorHost::resume_all): worker::resume_decision skips, with a warning, a record with no owner, with another store, or when the agent does not advertise loadSession; a record whose owner file is still held by a live chat, an unreadable record, or a missing worktree is skipped the same way. An eligible record gets a new agent acp, initialize, session/load (sessionId, cwd, the same MCP server), then session/set_config_option when the record stored a model, and no task prompt. While session/load runs the session/update handler ignores updates, so replayed history never marks a turn reported. A failed load (including an active run) is warned, written to cursor-error/<id> and not retried until the next chat start; the record stays.
Opencode harness: opencode runs on one opencode serve --hostname 127.0.0.1 --port <free> child of ralph chat, started for preflight/spawn or supervision of persisted sessions, directly without a shell wrapper (ralph’s own environment plus OPENCODE_PASSWORD; a missing binary fails at once with “opencode not found on PATH”). It runs in its own process group with basic auth (user opencode, a random password in OPENCODE_PASSWORD). The chat writes <temp_dir>/ralph-opencode-<chat pid>.json (std::env::temp_dir(), port, password, chat pid and ps -o lstart= start time, the server pid and its start time, mode 0600, written fresh with create_new) so the status line can ask GET /api/session/active; a read that parses a record whose pid or start time is not this chat’s removes it (an unreadable or unparsable file is left alone), and chat start deletes records of dead or pid-reused chats (worker::server_record_fate) and first stops the old server process group (SIGTERM, 500 ms, SIGKILL) when the recorded server pid and start time still match (worker::stale_server_group; a record without server fields only loses its file). Spawn writes <store>/opencode-spawn/<id> (a JSON request with the time) and polls every 250 ms for <store>/ready/<id> or <store>/opencode-error/<id>, giving up after 30 s. The host’s poll thread consumes requests in order: close, answer, prompt, interrupt, preflight, spawn. It drops a spawn request older than 30 s, waits up to 20 s for /api/info when starting the server, creates the session with POST /api/session (id = ses_ralph<id>, location.directory = the worktree, model, permissions), writes <store>/opencode-session/<id>, posts WORKER_PROMPT and the task to /api/session/<sid>/prompt, then writes the owner file <store>/opencode-owner/<id> and the ready marker.
Opencode turns: session create, prompt posts and permission replies retry up to TRANSIENT_RETRIES (2) times, 500 ms apart, on HTTP 404, 408, 429 or 5xx (transient_request_error); a turn that fails with a transient status resends its prompt the same way. One GET /api/event stream (SSE) feeds the pure TurnState for every session; a silent stream is dropped after 45 s and reconnected. After connection/reconnection, reconcile_all verifies persisted sessions and repairs state from /api/session/active, pending permissions and latest messages. The separate 3-second snapshot tracks process descendants for shutdown, not session reconciliation. reply writes prompts under <store>/opencode-prompt/<id>/ and, with interrupt: true, under <store>/opencode-interrupt/<id>/ (the chat calls /api/session/<sid>/interrupt first, synchronously, and writes cancelled or sent from its {"interrupted":bool} reply to <store>/opencode-interrupt-result/<id>/<entry>; Workers::reply awaits it like Cursor’s, up to 30 s, and a failed call leaves the outcome unknown; chat start, spawn and close clear the dir); a reply to a worker with a waiting permission writes <store>/opencode-answer/<id> and the chat posts /api/session/<sid>/permission/<rid>/reply.
Opencode supervision: a server that dies mid-turn posts a blocked report; the supervision loop attempts a restart without waiting for a new request. Chat-owned host shutdown (see Headless workers) removes the server record, sends SIGTERM to the process group, waits up to 3 s, sends SIGKILL, then kills tracked descendants. Worker close does not stop this server.
Reporting: the worker’s ralph-worker MCP tool report (status = done, blocked or question, any other value is a tool error; text = full summary) is the only way a worker reaches the chat. After it posts to <store>/inbox/, the tool touches the marker <store>/reported/<id>; spawn deletes the marker first. Harness hooks never send or build a report; they only stop a worker from going idle without one. Claude’s settings file adds a Stop hook that runs the hidden ralph worker-stop-hook --reported <store>/reported/<id> --unreported <store>/unreported/<id>. It reads the hook input with serde, and the pure worker::stop_decision decides: while background_tasks is not empty it allows the stop; else if the marker exists it deletes it and allows; else if stop_hook_active is false it blocks with worker::REPORT_REMINDER; else it gives up and touches the unreported marker, so a worker that still will not report gets one push per stop and no loop; a report or the consume step removes the unreported marker. Malformed input, or input without stop_hook_active, lets the worker stop and prints a warning to stderr. Claude permission prompts stay open and wait for the person; the PermissionRequest hook only touches <store>/blocked/<id> and prints nothing, so it neither allows nor denies. A Notification hook with matcher idle_prompt deletes blocked/<id> and touches <store>/waiting/<id> only while unreported/<id> exists. A second Stop hook deletes blocked/<id>. A prompt cancelled with Esc or denied fires no PostToolUse, so these two hooks end a stale blocked state; an Esc cancel clears it at the next idle_prompt (about 60 s). UserPromptSubmit and PostToolUse hooks delete blocked/<id> and waiting/<id>, so new input or an answered prompt ends the wait. opencode workers get the same reminder and markers from the chat: the SSE turn state (TurnState) queues one REPORT_REMINDER prompt after a turn that ended without a report, and a second such turn touches waiting/<id> and unreported/<id>. Sessions are created with the permission rules allow * then deny question (the last match wins), so tools run without asking and questions are refused; a permission request that still arises is mirrored in blocked/<id> and answered with reply_worker.
Tmux ownership: spawn_worker, reply and close_worker look up the worker’s session by name (ralph-<project>-<id>, or the older ralph-<id>) and compare its @ralph_store tag with this chat’s store in the pure worker::session_owner. The shell canonicalizes both sides (symlinks resolved) and worker::same_store also normalizes lexically (trailing and doubled slashes, ., ..). A session tagged with another store, or untagged, belongs to another chat: spawn, reply and close refuse it; close_worker with force skips the check. Headless ownership uses the store-local owner/session files described above, not tmux tags.
close_worker takes id, optional abandon and optional force (both default false). Like spawn_worker, it needs the newest log entry to be from the person. The shell collects the facts (owner, hosting kind, worktree dirty per git status --porcelain, git rev-list --count <base>..worker/<id>, opencode metadata from the chat’s opencode owner or session file, or <worktree>/opencode.json), and the pure worker::close_plan decides. The base comes from the pure worker::base_branch, first non-empty wins: [chat] worker_base in the ralph config (read at each close), the branch behind refs/remotes/origin/HEAD, then main. Close never uses the branch checked out at spawn, so a stale or deleted spawn branch cannot block it. After a successful close it unsets any leftover branch.worker/<id>.ralphBase that an earlier version wrote; if that fails, the close still succeeds and its result ends with a warning that gives the git config --unset command. A missing config file is normal; a config that fails to read or parse skips worker_base, and the close result (success or refusal) ends with a warning naming the config path, the error and the base used instead. It refuses a foreign worker unless force is set; it refuses unmerged commits or a dirty worktree unless abandon is set. If no session, worktree or branch is left, it clears any remaining markers, otherwise refuses.
The first close step is dispatched by HarnessId, not by a Cursor flag or a generic “headless” test:
| Harness | Worker-specific close |
|---|---|
Claude | KillSession: tmux kill-session for the owned session, if present |
Cursor | TerminateCursor: hand off through <store>/cursor-close/<id> to terminate only that worker’s ACP process group and remove its files |
Opencode | RemoveOpencodeSession: hand off through <store>/opencode-close/<id> to interrupt an in-flight turn, delete only that worker’s session (DELETE /api/session/<sid>, normally ses_ralph<id>; HTTP 404 means already gone), evict its worktree location with DELETE /api/debug/location?location[directory]=<worktree>, and remove its opencode files and owner |
The headless close handoffs wait up to 30 s for the owner file to vanish. The opencode handoff waits until both the owner file and the session record are gone, so session deletion finishes before worktree removal; on timeout or an error the close stops before removing the worktree. For a non-Opencode harness that still has opencode metadata, close_plan prepends RemoveOpencodeSession before the kind-specific step. An opencode close never kills a tmux session, calls TerminateCursor, or shuts down the shared opencode serve; other workers keep their sessions.
The common remaining steps, skipping absent resources, are git worktree remove (--force when dirty and abandoned), git branch -D worker/<id>, git worktree prune, then clearing the worker’s markers (worker::marker_paths: <store>/ready/<id>, reported/<id>, blocked/<id>, waiting/<id>, unreported/<id>, sent/<id>, activity/<id>, gone/<id>), which spawn also clears first. A failed step stops the close and names the step; opencode location-eviction errors only warn. After successful worktree removal, home-directory cleanup runs only for the matching harness: Claude data for Claude, Cursor data for Cursor, neither for Opencode (so it never scans ~/.cursor). Cleanup failures only add warnings; the exact paths and ownership checks are documented in Worker leftovers.
At chat start, ids in <store>/ready/ print one hint line (chat_status::orphan_hint) when they are not a discovered running worker (a tmux session, a Cursor worker this chat owns, or an opencode worker), not a Cursor worker whose owner-file chat is alive and whose agent acp process group is still the one recorded there (worker::agent_held; a dead chat’s agent is reclaimed by the sweep, so it is listed), and not a Cursor worker that still has a cursor-session/<id> record. The hint still says “no tmux session” and suggests close_worker. Chat never deletes these markers by itself. Cursor workers keep ready/<id> across a chat quit or crash; close removes it.
Skills
Turns run with --setting-sources project,local, so ~/.claude/skills does not load. No skills are bundled. The user names skill directories (each holds a SKILL.md, for example ~/.claude/skills/show-me) and chat exposes them through a plugin.
- Sources, highest first:
--skill <PATH>(repeatable),RALPH_CHAT_SKILLS(:-separated, empty entries dropped),[chat] skills = [...]in the ralph config. The highest non-empty source replaces the lower ones; lists are not merged. With none set, no--plugin-diris passed and nothing is written. - A leading
~or~/expands to the home directory. Duplicate paths keep the first. Relative paths are made absolute. - At start, a path without a readable
SKILL.mdprints one warning and is skipped. Two skills with the same directory name: the first wins, the other warns and is skipped. - Slash expansion: chat turns wrap the input, so Claude Code never expands a leading
/<name>. Ralph does it for user turns only (worker messages are never expanded). If the message starts with/<name>followed by whitespace or end, and<name>is a configured skill, the prompt getsBase directory for this skill: <dir>, theSKILL.mdbody without its frontmatter, then the original message. The rest of the message, trimmed, replaces every$ARGUMENTSin the body; without that token and with non-empty arguments, anARGUMENTS: <args>line is appended. The store keeps the original message; only the sent prompt is expanded. Unknown names, a bare/, and/nameprefixes that are longer words pass through unchanged. Adisable-model-invocationfrontmatter flag only stops the model from invoking the skill itself;/<name>still runs it, and ralph does not warn about it. - Config is loaded once at start; it supplies both
mcp_serversandskills. On load error chat warns once and uses defaults. - If at least one skill survives, chat builds a temporary plugin directory (removed when chat exits) with
.claude-plugin/plugin.json({"name": "ralph"}) andskills/<name>as symlinks to the skill directories. Each turn adds--plugin-dir <dir>. Skills appear asralph:<name>. - Skills are read at start: changes to the list need a restart.
Images
- Ctrl-V (macOS only) runs
osascriptto write the clipboard as PNG to<store>/attachments/.clipboard-<pid>.tmp, thenattachment::parse_imagechecks it. A valid image is renamed to<sha256>.<ext>(same image, same file; nothing is ever deleted) and[Image #N: <absolute path>]is inserted at the cursor.Nis one above the largest[Image #N:already in the draft (attachment::next_number), else 1. No image on the clipboard printsNo image on the clipboard.; a bad image printsImage paste failed: <error>and removes the temp file. - Limits: 10 MiB per image (
MAX_IMAGE_BYTES); PNG, JPEG, GIF or WebP by magic bytes (the clipboard script always asks for PNG). No paste on Linux or Windows; a path typed by hand works the same way. - The placeholder is plain text:
log.jsonl, history and memory hold only the text with the path, never the image bytes. Claude reads the image itself with its Read tool when it sees the path. Chat creates<store>/attachmentsat start and each turn passes--add-dir <store>/attachments(build_attachments_args), so a store outside the working directory still reads without a prompt. - Workers: placeholders in
spawn_workertask orreplytext pass through unchanged. Claude workers get--add-dir <store>/attachments; opencode workers getpermission.external_directoryfor<store>/attachments/**(see MCP tools).
Storage and isolation
- Global store
~/.ralph/chat:log.jsonl,tree.jsonl,log.idx,tree.idx,LOCK,NAP,history.txt,editor.md,mcp.json,usage.jsonl,attachments/. It is not project-scoped. LOCKis a file lock: exclusive for append and for each nap commit, shared for settled load and the nap snapshot.NAPis an exclusive file lock held for the whole nap;napping()probes it. Store paths that are symlinks or multiply-linked files are rejected.- Isolation:
--setting-sources project,localplus aclaudeMdExcludesentry for~/.claude/CLAUDE.mdwhen the home directory is known. Project and local settings stay active; this is context isolation, not a sandbox. - User agents in
~/.claude/agentsdo not load, sobuild_agents_argspasses the bundledSUBAGENTS(evidence-collectorhaiku,implementersonnet,reviseropus) as--agents <json>. These copies may drift from~/.claude/agents. - Turns and claude workers run with the chat’s resolved permission mode (default
auto). The chat process adds--permission-mode <MODE>to each claude worker command, so chat and workers never differ. opencode workers ignore it.
Usage
Every completed Claude call (each turn, including failed exits but not interrupted ones, and each nap attempt) appends one line to usage.jsonl: {at, kind, trigger, input_tokens, cache_creation_input_tokens, cache_read_input_tokens, output_tokens, cost_usd, duration_ms, context_tokens}, where context_tokens is input + cache read + cache creation tokens of the last assistant message (absent when none carried usage), with kind turn or nap, and trigger (turn only) user or worker. On the first turn of each process, turn lines also carry view_bytes, view_lines, view_head_hash (FNV-1a 64 of the first 1024 bytes of the rendered view) and view_hash (of the whole view), to trace prompt-cache misses. Calls without usage data append nothing. Appends do not take LOCK. Total cost per kind:
jq -s 'group_by(.kind) | map({kind: .[0].kind, usd: (map(.cost_usd // 0) | add)})' ~/.ralph/chat/usage.jsonl
Out of scope (v1)
- Mid-turn message injection.
- Logging tool calls and results into memory.
- Subagent reports.
Worker leftovers
A running list of things workers and smoke tests leave behind outside the repo. Workers append what they leave. Nobody deletes entries by hand.
Cleanup by worker kind
close_worker scopes home-directory cleanup by HarnessId, and runs it only after a successful close whose plan includes RemoveWorktree (cleans_claude_leftovers and cleans_cursor_leftovers in crates/core/src/). A markers-only close does not clean home directories. Cleanup failures add warnings to the result rather than failing the close.
Claude: removes the worktree’s transcript folder~/.claude/projects/<mangled path>and its trust entry underprojectsin~/.claude.json. The mangled path follows Claude Code: every UTF-16 unit that is not an ASCII letter or digit becomes-, and a name over 200 characters keeps its first 200 and gets-<base36 hash>appended. The trust file is rewritten through a temp file unique to each close, while ralph holds a lock onralph/claude-json.lockin the OS per-user data folder (~/Library/Application Supporton macOS,$XDG_DATA_HOMEor~/.local/shareon Linux). Every close uses that one path whatever--storeorRALPH_CONFIG_DIRsays, and the lock file stays after close so all closes lock the same file. A concurrent change retries the rewrite up to three times, then skips it with a warning. Without~/.claude.jsonthe trust step is skipped and no lock is created; without a per-user data folder it is skipped with a warning. Successful cleanup leaves neither item; a missing worktree or a cleanup warning can leave them behind.Cursor: runsagent acp, neverclaude. It leaves~/.cursor/projects/<slug>(plus a hashed socket dir there when the path is long) and~/.cursor/acp-sessions/<id>/. Close removes project paths derived from the worktree’s real path and sessions whosemeta.jsoncwdexactly matches the worktree or its real path, and lists each removed path in its result. It keeps the project folders, with a warning, when another known path (agit worktree listentry or anacp-sessionscwd) maps to the same slug or the git listing fails. This does not restore~/.cursor/cli-config.jsonchanges made byagent acp.Opencode: closes only that worker’s session, evicts its worktree location and removes its worker files plus worktree/branch. It never scans or deletes~/.cursoror Claude home-directory data, and does not stop the sharedopencode serve, even for the last worker. No general cleanup of opencode’s own home-directory data is implemented.
See ralph chat for close steps and safety checks, and Headless workers for hosting kinds, compatibility and chat-owned host shutdown.
Ralph itself never reads these transcript folders.
Kinds
- Trust entries in
~/.claude.jsonfor paths that no longer exist. - Transcript folders in
~/.claude/projects/<path>, where every character of the path that is not an ASCII letter or digit becomes-.
Known items
- 14
/tmptrust entries and 81 transcript folders from earlier closed workers and smoke tests (not itemized). - From the GUZ-508 smoke test: trust entry
/private/tmp/guz508smoke.VvrX/repoand folder~/.claude/projects/-private-tmp-guz508smoke-VvrX-repo/.
ralph ask Command
This document provides comprehensive documentation for the ralph ask command, which sends a single-shot prompt to Claude. Nothing is stored.
Code Locations
| Component | File | Description |
|---|---|---|
| CLI args | crates/ralph/src/cli/mod.rs | AskArgs struct definition |
| Permission mode | crates/ralph/src/invoke.rs, crates/ralph/src/config.rs | resolve_permission_mode(), AppConfig::ask_permission_mode() |
| Shared engine | crates/ralph/src/invoke.rs | InvocationConfig, invoke(), command building |
| Prompt source | crates/ralph/src/prompt_source.rs | classify_prompt_source(), read_from_source() |
| Execution | crates/ralph/src/main.rs | execute_ask(), build_invocation_config(), resolve_ask_prompt() |
| Summary | crates/ralph/src/startup/display.rs | display_ask_summary() |
| Config | crates/ralph/src/config.rs | AppConfig with AskSection |
Purpose
The ralph ask command provides a streamlined interface for quick LLM interactions. ask sends a single prompt and displays the response. It keeps no session, history, or log on disk; each call is independent. Use ralph chat for a conversation that persists.
CLI Flags
| Flag | Type | Default | Description |
|---|---|---|---|
[PROMPT] | positional | stdin | The prompt to send (inline, file path, or - for stdin) |
--theme | string | base16-ocean.dark | Syntax highlighting theme |
--no-background | flag | false | Disable theme background colors |
--timeout | u64 | 600 | Subprocess timeout in seconds |
--verbose-tools | string | none | Verbose output for tools (omit value for all, or specify names) |
--no-prompt | flag | false | Suppress prompt display before execution |
--permission-mode | string | bypassPermissions | Permission mode for tool execution |
Verbose Tools Examples
ralph ask --verbose-tools 'prompt' # Enable for all tools
ralph ask --verbose-tools=grep,bash 'prompt' # Enable for specific tools
Prompt Resolution
The prompt can be provided from multiple sources:
| Source | Example | Description |
|---|---|---|
| Positional argument | ralph ask 'What is 2+2?' | Inline prompt text |
| File path | ralph ask /path/to/prompt.txt | Read prompt from file (if path exists) |
| Stdin explicit | ralph ask - | Read from stdin (explicit) |
| Stdin piped | echo 'prompt' | ralph ask | Read from stdin when piped (automatic) |
Resolution Priority
- If argument is
-, read from stdin - If argument is a valid file path, read from file
- If argument is any other string, use as inline prompt
- If no argument and stdin is piped (non-terminal), read from stdin
- If no argument and stdin is terminal, error (no prompt provided)
Permission Modes
The --permission-mode flag sets claude --permission-mode. ask has its own setting, default bypassPermissions: the user is present, watching, and can stop the run with Ctrl+C. Chat and its workers use [chat] permission_mode and RALPH_CHAT_PERMISSION_MODE, default auto, which do not change ask. Choices are claude’s: acceptEdits, auto, bypassPermissions, manual, dontAsk, plan (see claude --help).
ralph ask --permission-mode plan 'Analyze this code'
Configuration Precedence
- CLI flag (
--permission-mode) - Environment variable (
RALPH_ASK_PERMISSION_MODE, ignored when empty) [ask] permission_modein~/.config/ralph/config.toml- Default (
bypassPermissions)
Config file example:
[ask]
permission_mode = "acceptEdits"
Theme Configuration
Theme settings follow this precedence:
- CLI flag (
--theme,--no-background) - Environment variable (
RALPH_THEME,RALPH_NO_BACKGROUND) - Config file (if present)
- Default:
base16-ocean.dark
Output Patterns
Prompt Display
Before execution (unless --no-prompt):
╭─── Prompt ────────────────────────────────────────────────────╮
│ What is Rust? │
╰───────────────────────────────────────────────────────────────╯
Streaming Response
Response is streamed with syntax highlighting for code blocks.
Summary
After completion:
✓ Ask Complete
─── Metrics ───
Cost: $0.0234
Duration: 5.2s
Tokens: 1.2K input | 567 output
Examples
Basic Usage
# Simple question
ralph ask 'What is the capital of France?'
# Multi-line prompt from heredoc
ralph ask <<'EOF'
Review this function for potential improvements:
fn add(a: i32, b: i32) -> i32 {
a + b
}
EOF
# Prompt from file
ralph ask /path/to/detailed-prompt.txt
Piping
# Pipe prompt from another command
cat requirements.txt | ralph ask 'Analyze these dependencies'
# Pipe through multiple tools
git diff | ralph ask 'Review this diff'
# Use with process substitution
ralph ask < <(echo "Combined prompt from $(pwd)")
Permission Control
# Read-only exploration
ralph ask --permission-mode plan 'What files are in src/?'
# Trusted editing
ralph ask --permission-mode acceptEdits 'Add error handling to main.rs'
# Full automation (default)
ralph ask 'Refactor the test suite'
Theme and Display
# Custom theme
ralph ask --theme 'Solarized (dark)' 'prompt'
# No background colors
ralph ask --no-background 'prompt'
# Hide prompt display
ralph ask --no-prompt 'prompt'
# Verbose tool output
ralph ask --verbose-tools=bash 'Run the tests'
Related Documentation
- ralph chat Command - Persistent chat with compressed memory
Configuration
Ralph uses a TOML configuration file for persistent settings. This document covers file locations, available options, and configuration precedence.
Configuration File Location
Ralph stores its configuration in platform-specific directories:
| Platform | Config Directory | Config File |
|---|---|---|
| Linux | ~/.config/ralph/ | ~/.config/ralph/config.toml |
| macOS | ~/Library/Application Support/ralph/ | ~/Library/Application Support/ralph/config.toml |
| Windows | %APPDATA%\ralph\ | %APPDATA%\ralph\config.toml |
Environment Variable Overrides
You can override the config directory with an environment variable:
| Variable | Purpose |
|---|---|
RALPH_CONFIG_DIR | Override config directory (where config.toml is stored) |
Example:
# Use a custom config directory
export RALPH_CONFIG_DIR=/path/to/custom/config
ralph ask 'your prompt'
Configuration Precedence
Settings are resolved in this order (highest priority first):
- CLI flags (e.g.,
--theme "Solarized (dark)") - Environment variables (e.g.,
RALPH_THEME=Solarized (dark)) - Config file (
config.tomlin config directory) - Default values
This means CLI flags always win, allowing you to override config file settings for specific invocations.
Configuration File Format
The config file uses TOML format. All sections and fields are optional - only include what you want to customize.
Complete Example
# Ralph Configuration File
# Location: ~/.config/ralph/config.toml (Linux)
# ~/Library/Application Support/ralph/config.toml (macOS)
# %APPDATA%\ralph\config.toml (Windows)
[theme]
# Syntax highlighting theme for code blocks and diffs
# Can be a built-in theme name or path to a .tmTheme file
name = "base16-ocean.dark"
# When true, don't apply theme background colors
# Useful if you want your terminal's background to show through
no_background = false
Minimal Example
# Just set the theme, everything else uses defaults
[theme]
name = "Solarized (dark)"
Configuration Sections
[theme] - Syntax Highlighting
Controls how code blocks and diffs are displayed in the terminal.
| Field | Type | Default | Description |
|---|---|---|---|
name | string | "base16-ocean.dark" | Theme name or path to .tmTheme file |
no_background | bool | false | Disable theme background colors |
Per-role themes: [theme.assistant] and [theme.agent]
Each role has its own markdown theme. assistant styles the replies of ralph ask and ralph chat. agent styles worker messages that ralph chat shows from /inbox, including the worker header box.
[theme.assistant]
code = "Monokai Extended" # code-block theme; overrides [theme] name
heading1 = "cyan"
[theme.agent]
code = "Solarized (dark)" # default: the assistant code theme
heading1 = "#ff6ac1"
inline_code = "dark_cyan"
border = "magenta"
| Key | Assistant default | Agent default | Styles |
|---|---|---|---|
code | [theme] name | assistant code theme | Syntax theme for fenced code blocks |
heading1, heading2, heading3 | cyan, blue, magenta | green, dark_green, dark_yellow | Headings (always bold) |
bold, italic | terminal default | terminal default | Emphasis color |
inline_code | yellow | dark_cyan | Inline code (on a dark gray background) |
bullet | dark_grey | dark_green | List bullets |
quote, table | termimad default | termimad default | Quote mark, table borders |
link, url | blue, cyan | blue, cyan | Link label (underlined), link URL |
memory_ref | magenta | magenta | #N memory references |
border | dark_blue | dark_blue | Worker header box border |
done, blocked, waiting | dark_green, dark_red, dark_yellow | same | Worker status (failed uses blocked, check uses waiting) |
A color is a name (black, red, green, yellow, blue, magenta, cyan, white, grey, their dark_ forms, dark_grey, reset) or #rrggbb. ralph chat warns at start for an unknown key or a bad color and keeps the default for it. Plain output stays plain when stdout is not a TTY or NO_COLOR is set.
Status line colors: [theme.status]
ralph chat paints its status line from this table. Every key is optional and takes the same color values as the role themes. Keys that are missing keep the defaults below, so an empty or missing table draws the same bar as before. ralph chat warns at start for an unknown key or a bad color and keeps the default for it.
[theme.status]
mode_idle_fg = "black" # IDLE block text
mode_idle_bg = "green" # IDLE block background
mode_nap_fg = "black" # NAP block text
mode_nap_bg = "magenta" # NAP block background
mode_inbox_fg = "black" # INBOX block text
mode_inbox_bg = "yellow" # INBOX block background
mode_work_fg = "white" # WORK block text
mode_work_bg = "blue" # WORK block background
store_fg = "reset" # store path text
workers_fg = "reset" # workers text
watches_fg = "reset" # watches text
memory_fg = "reset" # memory size text
base_bg = "#303030" # bar background: filler, store, workers, watches, memory
The default base_bg is ANSI color 236, written #303030 above. reset is the terminal’s own text color. Powerline glyphs take their colors from the neighbouring blocks, so they have no keys.
Code theme precedence for the assistant: --theme > RALPH_THEME > [theme.assistant] code > [theme] name > default.
The old [theme] agent = "<theme>" string still works as [theme.agent] code = "<theme>"; ralph chat prints a deprecation warning for it.
Built-in Themes
Run ralph themes to see all available themes:
Available syntax highlighting themes:
InspiredGitHub
Solarized (dark)
Solarized (light)
base16-eighties.dark
base16-mocha.dark
base16-ocean.dark (default)
base16-ocean.light
Using a Custom Theme
You can use any TextMate-compatible .tmTheme file:
[theme]
name = "/path/to/my-custom-theme.tmTheme"
Theme Environment Variable
The RALPH_THEME environment variable can be used instead of the config file:
# Set theme via environment variable
export RALPH_THEME="Solarized (dark)"
ralph ask 'your prompt'
# Or for a single command
RALPH_THEME="Monokai Extended" ralph ask 'your prompt'
Disabling Background Colors
If your terminal has a specific background color you want to preserve:
[theme]
name = "base16-ocean.dark"
no_background = true
Or via environment variable:
export RALPH_NO_BACKGROUND=1
Or via CLI flag:
ralph ask --no-background 'your prompt'
[ask] - Single-Shot Prompts
| Field | Type | Default | Description |
|---|---|---|---|
permission_mode | string | "bypassPermissions" | Tool permission mode for ralph ask |
Precedence: ask --permission-mode, then RALPH_ASK_PERMISSION_MODE, then this key, then bypassPermissions. [chat] permission_mode does not apply to ask. See ralph ask.
[chat] - Persistent Chat
| Field | Type | Default | Description |
|---|---|---|---|
mcp_servers | list of strings | ["mcptools"] | Servers from ~/.claude.json to load in chat turns |
skills | list of paths | [] | Skill directories exposed to chat |
recent_history_tokens | integer | 8192 | Token budget for the newest chat messages shown verbatim in the memory view; 0 turns it off |
worker_check_secs | integer | 60 | Seconds between worker checks while chat is open; 0 turns the check off |
worker_quiet_secs | integer | 300 | Seconds a running Cursor worker may send no ACP traffic before one inbox alert; must be above 0, else a warning and 300. The 300 s default is an unverified guess, to be measured by GUZ-651. Runs on the worker check, so worker_check_secs = 0 turns it off too |
reset_turns | integer | 30 | Turns one claude process serves before chat drops it and respawns from the store; must be above 0, else a warning and 30 |
reset_tokens | integer | 140000 | Context tokens of the last assistant message at which chat drops the process and respawns from the store; must be above 0, else a warning and 140000 |
watch_timeout_secs | integer | 3600 | Seconds before a watch is killed and reports that it timed out; applies to every watch without its own timeout_secs; 0 is an error |
wrap_width | integer | unset | Column cap for chat replies and the prompt area; 0 or unset wraps at the terminal width |
cell_output_limit | integer | 200 | Lines of cell output sent to Claude: first half and last half kept; 0 sends all |
cell_output_bytes | integer | 65536 | Bytes of cell output sent to Claude: head and tail kept; 0 sends all |
worker_base | string | unset | Base branch that close_worker checks for unmerged commits |
worktree_dir | string | .worktrees | Directory that holds worker worktrees; a leading ~ expands to home, relative paths resolve against the repo root |
permission_mode | string | "auto" | claude --permission-mode for chat turns and claude workers |
inbox_alert_sound | bool | true | Play a sound when a worker report arrives and the chat is not focused |
inbox_alert_notification | bool | false | Also send a system notification (<id> <status>) |
inbox_alert_unknown_focus | "alert" or "silent" | "alert" | What to do when focus cannot be detected (outside tmux, no attached client, tmux query failed) |
status_powerline | bool | false | Use powerline glyphs (\ue0b0, \ue0b2) between status line segments; needs a patched font. Off draws plain colour blocks |
editor | list of strings | unset | Ctrl-G editor argv, draft path appended, e.g. ["nvim", "-c", "normal! Go", "-c", "startinsert"]; unset uses $VISUAL, $EDITOR, then vi; empty or wrong type warns and falls back |
edit_mode | "emacs" or "vi" | "emacs" | Key bindings of the chat prompt; another value prints a warning and uses "emacs". See ralph chat |
empty_submit | "inbox", "inbox-first" or "phrase" | "inbox" | What Enter on an empty prompt does. inbox: open the inbox when reports are unread, else nothing. inbox-first: open the inbox when reports are unread, else send empty_prompt. phrase: always send empty_prompt, even with unread reports; use /inbox to read them. An unknown value warns and uses inbox |
empty_prompt | string | unset | Phrase sent by inbox-first and phrase, e.g. "continue". Unset, empty or whitespace-only warns and the mode behaves like inbox |
--skill and RALPH_CHAT_SKILLS replace skills. For permission_mode the precedence is chat --permission-mode, then RALPH_CHAT_PERMISSION_MODE, then this key, then auto; chat passes the resolved mode to every claude worker it spawns, so chat and workers always match. --recent-history-tokens <TOKENS> overrides recent_history_tokens; see recent history. Without worker_base, close_worker uses origin/HEAD, then main. For worktree_dir the precedence is chat --worktree-dir, then RALPH_WORKTREE_DIR, then this key, then .worktrees. A worker that already has a worktree keeps it: spawn and close look up worker/<ID> in git worktree list first, so changing the key does not lose running workers. A directory inside the repo is added to .git/info/exclude. A config that fails to read or parse skips worktree_dir, and the spawn or close result ends with a warning. For wrap_width the precedence is chat --wrap-width <N>, then RALPH_WRAP_WIDTH, then this key; a value that is not a whole number stops chat start with an error. The width used is the smaller of this cap and the terminal width, read at each render, so a narrower terminal still wins. The cap is read once at chat start: a change to this key or RALPH_WRAP_WIDTH needs a chat restart; a terminal resize does not. It applies to rendered prose (chat turns and drained worker reports) and to the reedline prompt area: the status line, the draft, and the footer. Code blocks are not wrapped. For cell_output_limit and cell_output_bytes the precedence is RALPH_CELL_OUTPUT_LIMIT / RALPH_CELL_OUTPUT_BYTES, then the key, then the default; a value that is not a whole number stops chat start with an error. The caps shape only what Claude receives; the terminal shows all output in result fences. See executable cells. worker_check_secs has no flag or env var; the check is described in ralph chat. chat --no-inbox-sound and --no-inbox-notification turn off the matching alert for one run; focus rules are in ralph chat. See ralph chat.
[cursor] - Cursor Workers
| Field | Type | Default | Description |
|---|---|---|---|
permissions | "ask" or "yolo" | "ask" | "yolo" answers every Cursor session/request_permission with the allow-once option at once; "ask" waits for the person, except for the worker report tool |
model | string | unset (cursor’s own default) | Model id for Cursor workers that spawn_worker starts without a model. Applied with session/set_config_option, not agent --model. Cursor’s ACP fixes the variant for that model |
yolo picks only the allow-once option, never allow-always, and writes nothing to ~/.cursor/cli-config.json. A request with no allow-once option still waits for the person. Cursor’s own rules (deny lists, sandbox) still apply. Risk: shell commands and MCP tools run without asking. A wrong value or type, or a config that fails to read, warns and uses "ask". The key is read when a Cursor worker starts.
A model passed to spawn_worker wins over this key. A value that is empty or has whitespace warns in the spawn result and uses cursor’s default. A config that fails to load warns and uses cursor’s default. The key is read at each spawn. The child is agent acp. After session/new, and again after session/load when chat resumes the worker, ralph sets the session model option from the value Cursor advertises there (worker::select_cursor_model). An exact advertised value is sent as-is. A short name selects the advertised value for that name. Cursor’s ACP fixes one variant per model name, so effort and fast mode cannot be chosen: there is no [cursor] reasoning_effort or [cursor] fast. If a name is listed more than once, the cheapest listed value is sent (fast=false, then no fast, then fast=true, then the lowest effort). Any other string is sent unchanged, and the agent’s rejection is the spawn error. agent --model does not change the ACP session model. The set rewrites the user’s Cursor default. Before the set, ralph writes cursor-model-default.json next to config.toml in the ralph config directory, holding only the model keys. It then restores model, selectedModel, modelParameters, hasChangedDefaultModel, modelSelectionHistory and maxMode in ~/.cursor/cli-config.json, and selectedModelVariantId in ~/.cursor/acp-config.json, and deletes the snapshot. A crash between the set and the restore leaves those files on the worker model. The next ralph chat start sees the snapshot and puts the saved keys back. With no snapshot, that start cannot tell the switched files from a change the user made. The resolved model is stored on the session record and applied again on resume; a record with no model leaves the agent’s current model alone.
[claude] - Claude Workers
| Field | Type | Default | Description |
|---|---|---|---|
model | string | unset (claude’s own default) | Model name or alias passed as --model to Claude workers that spawn_worker starts without a model |
A model passed to spawn_worker wins over this key. A value that is empty or has whitespace warns in the spawn result and uses claude’s default. A config that fails to load warns and uses claude’s default. The key is read at each spawn.
[opencode] - opencode Workers
| Field | Type | Default | Description |
|---|---|---|---|
model | string | "openai/gpt-6.1-sol" | provider/model for opencode workers that spawn_worker starts without a model |
A model passed to spawn_worker wins over this key. A value that is not provider/model warns in the spawn result and uses the built-in default. A config that fails to load uses the built-in default. The key is read at each spawn.
[mermaid] - Diagram Rendering
In a terminal, a fenced mermaid code block renders as a Unicode text diagram for flowchart/graph, sequenceDiagram, erDiagram, and classDiagram. Rendering runs in a child process (ralph __mermaid) that ralph kills on timeout or memory overrun. Any refusal, kill, error, or output wider than the terminal shows the highlighted source with no error text. Piped output keeps the raw source.
| Key | Default | Meaning |
|---|---|---|
timeout_ms | 500 | Kill the render child after this many milliseconds |
max_rss_mb | 256 | Kill the child above this resident size (macOS and Linux only; elsewhere only the timeout applies) |
max_input_bytes | 1048576 | Refuse a block with more source bytes |
max_flowchart_edges | 50 | Refuse a flowchart/graph with more arrow tokens |
max_class_relations | 400 | Refuse a classDiagram with more relation tokens (provisional: dense graphs among few classes can exceed the timeout well below 400; the timeout still bounds them) |
Fixed limits: flowchart source up to 16 KB; sequenceDiagram up to 20 participants, 64 KB, and 2000 statements; erDiagram up to 64 KB and 2000 statements. A config that fails to load prints a warning and uses these defaults.
[changes] - /diff pager and turn snapshots
/diff in chat runs git diff. See ralph chat. Chat also snapshots the repo around each turn. See ralph chat.
| Key | Default | Meaning |
|---|---|---|
pager | "git" | git, delta, or hunk. An unknown string warns once at chat start and uses git; the config still loads |
diff_args | [] | git diff arguments placed before the ones typed after /diff |
enabled | true | Snapshot the chat’s repo at the start and end of each turn. false turns it off |
delta and hunk are one-off git -c core.pager=... (hunk passes core.pager=hunk pager as one argument). ralph sets no environment variables and does not change core.pager in git config. With pager = "git", git uses the user’s pager.
[wire.prices] - ralph wire ls price overrides
ralph wire ls prices calls from a built-in dated table. A [wire.prices."<model prefix>"] table overrides it for that exact model id, or the same id plus an 8-digit date (-20261021). Longest prefix wins. Any [...] suffix on the recorded model is ignored. An override applies at every prompt size. A negative price fails config load, so ralph wire ls fails. A config that fails to parse fails the same way. See ralph wire.
[wire.prices."claude-haiku-5-5"]
input = 0.1
output = 0.5
| Key | Default | Meaning |
|---|---|---|
input | required | USD per million uncached input tokens |
cache_read | input x provider multiplier | USD per million cache-read tokens |
cache_write_5m | input x provider multiplier | USD per million 5-minute cache-write tokens |
cache_write_1h | input x provider multiplier | USD per million 1-hour cache-write tokens |
output | input x provider multiplier | USD per million output tokens |
Unknown keys are an error. Speed and geography factors from the recorded usage still apply on top.
Chat Store
ralph ask stores nothing. ralph chat keeps its store at ~/.ralph/chat. For another path the precedence is chat --store <DIR>, then RALPH_CHAT_STORE, then ~/.ralph/chat; a relative path resolves against the cwd at startup. Parallel chats need separate stores. See ralph chat.
Verbose Tools Configuration
The --verbose-tools flag controls how much detail is shown for LLM tool invocations during ralph ask.
Usage
# Enable verbose output for all tools
ralph ask --verbose-tools 'your prompt'
# Enable verbose for specific tools (comma-separated, case-insensitive)
ralph ask --verbose-tools=read,grep,bash 'your prompt'
# Single tool
ralph ask --verbose-tools=glob 'your prompt'
Supported Tools
| Tool | Verbose Output Shows |
|---|---|
read | Full file content with syntax highlighting and line numbers |
edit | Full diff with syntax highlighting |
write | Full diff showing new/changed content |
glob | All matched files grouped by directory |
grep | Full regex pattern and matched content |
bash | Full command and output with shell syntax highlighting |
todowrite | Full todo list with status indicators |
notebookedit | Full cell diff for Jupyter notebooks |
Non-Verbose Mode (Default)
Without --verbose-tools, tool output is shown in a compact format:
- File paths shown without content
- Large outputs truncated
- Pattern matches summarized
Environment Variables Reference
| Variable | Purpose | Example |
|---|---|---|
RALPH_CONFIG_DIR | Override config directory | /custom/config |
RALPH_THEME | Set syntax highlighting theme | Solarized (dark) |
RALPH_NO_BACKGROUND | Disable theme backgrounds | 1 |
RALPH_ASK_PERMISSION_MODE | Permission mode for ralph ask | acceptEdits |
RALPH_CHAT_PERMISSION_MODE | Permission mode for chat turns and claude workers | auto |
RALPH_WORKTREE_DIR | Directory for worker worktrees; ~ expands, relative paths resolve against the repo root | .worktrees |
RALPH_CHAT_STORE | Chat store directory; relative paths resolve against the cwd | ~/chats/a |
RALPH_CHAT_SKILLS | :-separated skill directories for chat | ~/skills/a:~/skills/b |
RALPH_WRAP_WIDTH | Column cap for chat replies and the prompt area; 0 = terminal width | 80 |
Troubleshooting
Finding Your Config Location
If you’re unsure where ralph is looking for configuration:
# Check what paths ralph uses
ralph --version
# Then check the platform-specific path for your OS
Config File Not Loading
- Verify the file exists at the correct path
- Check file permissions (must be readable)
- Validate TOML syntax - even small errors prevent loading:
# Test TOML validity cat ~/.config/ralph/config.toml | python3 -c "import sys, tomllib; tomllib.loads(sys.stdin.read())"
Theme Not Applying
- Verify the theme name matches exactly (run
ralph themesfor the list) - For custom themes, ensure the file path is absolute and the file exists
- Check if a CLI flag or environment variable is overriding your config
Output Blocks
This document explains how output blocks flow through ralph during execution.
Overview
During live execution, ralph renders content to stdout while it also accumulates structured OutputBlock variants. Nothing writes the blocks to disk. extract_response_text() joins the text blocks into the final response text that chat turns and naps use.
Data Flow
Claude subprocess (--output-format stream-json)
│
▼
StreamProcessor::process_line() → parse_stream_line() → StreamEvent
│
▼
handle_event()
├── Render to stdout (syntax highlighting)
└── Build OutputBlock → push to output_blocks
│
▼
StreamProcessor::finish() → StreamProcessorResult { output_blocks }
│
▼
extract_response_text(&output_blocks) → Option<String>
OutputBlock Enum
The OutputBlock enum captures all renderable content:
#![allow(unused)]
fn main() {
#[non_exhaustive]
pub enum OutputBlock {
Text(TextBlock), // Prose, code, or diff text
ToolInvocation(ToolInvocationBlock), // Tool header display
ToolResult(ToolResultBlock), // Tool output display
Separator, // Visual separator between responses
}
}
Text Blocks
Text blocks wrap ParsedChunk from ralph_core:
#![allow(unused)]
fn main() {
pub struct TextBlock {
pub chunk: ParsedChunk, // Prose, Code, or Diff chunk type
}
}
Tool Invocation Blocks
Each tool type has a specific variant for capturing invocation data:
| Tool | Variant | Captured Data |
|---|---|---|
| Bash | Bash | command, description |
| Grep | Grep | pattern, path, output_mode, glob, file_type, case_insensitive |
| Read | Read | file_path, offset, limit |
| Glob | Glob | pattern, path |
| TodoWrite | TodoWrite | todos (Vec of TodoItem) |
| Others | Default | key_argument, is_path |
The Grep variant uses GrepInvocationBuilder for fluent construction with sensible defaults.
Tool Result Blocks
Tool results have variants to capture before/after states:
| Tool | Variants | Purpose |
|---|---|---|
| Bash | Bash | content, truncated flag |
| Edit | EditBeforeAfter, EditDiff, EditNoChanges | Before/after display, unified diff, or no-op |
| Write | WriteNewFile, WriteOverwrite, WriteNoChanges | New file, overwrite, or no-op (includes is_new_file flag) |
| Read | Read | file_path, content, line_count, truncated |
| Grep | Grep | match_count, output_mode, content |
| Glob | Glob | file_count, content, truncated |
| TodoWrite | TodoWrite | message |
| NotebookEdit | NotebookEdit | notebook_path, cell_identifier, diff_content, etc. |
| Others | Default | content |
Key Implementation Files
| File | Responsibility |
|---|---|
stream_processor/output_block.rs | OutputBlock enum and all variant definitions |
stream_processor/block_builders.rs | Helper functions to build OutputBlocks from tool data |
stream_processor/processor.rs | Accumulates blocks during streaming |
stream_processor/event_handler.rs | Pushes blocks during event processing |
Live Execution
For each event, StreamProcessor renders formatted content to stdout and pushes the matching OutputBlock:
#![allow(unused)]
fn main() {
// In event_handler.rs - both actions happen together
if self.show_tool_invocations {
let formatted = format_tool_invocation(...);
output_parts.push(formatted); // Render
let block = build_tool_invocation_block(...);
self.output_blocks.push(block); // Accumulate
}
}
Design Decisions
Why Store Data, Not Rendered Strings?
Output blocks capture structured data rather than pre-rendered strings:
- Testability: Block data can be asserted without parsing ANSI codes
- Text extraction: The response text comes from text blocks, not from ANSI output
Why Tool-Specific Variants?
Each tool has unique display requirements:
- Edit/Write: Need before/after content for diff display
- Grep: Has pattern, path, and multiple options to display
- Bash: Has command and optional description. Single-line commands are pretty-printed when wider than the terminal, or always when they chain statements with
;(a short command with;also splits at&&,||and&), byralph_core::shell_format::format_commandinto a highlightedshblock: one statement per line on;,&&,||, indented loop andifbodies, and long pipelines broken before|. Long commands keep the command and subcommands on line one, then one flag group per two-space-indented line, with\continuations except on the last line. Quotes are never split; indivisible values may exceed the width. Short commands without;, multi-line commands, and unparsable commands render unchanged. - TodoWrite: Has structured todo items with status
Generic storage would lose this structure.
Snapshot Pattern for Before/After
Edit, Write, and NotebookEdit tools use “snapshots” captured before execution:
#![allow(unused)]
fn main() {
// Before tool executes, capture current state
let snapshot = EditSnapshot {
file_path: path.clone(),
original_content: fs::read_to_string(&path).ok(),
old_string: old_string.clone(),
new_string: new_string.clone(),
};
// After tool executes, snapshot enables before/after display
// without re-reading the file system
}
Non-Exhaustive Enums
All block enums use #[non_exhaustive]:
#![allow(unused)]
fn main() {
#[non_exhaustive]
pub enum OutputBlock { ... }
#[non_exhaustive]
pub enum ToolInvocationVariant { ... }
#[non_exhaustive]
pub enum ToolResultVariant { ... }
}
This allows adding new variants without breaking downstream code.
Development Guide
This guide covers local development setup and build instructions for ralph.
Prerequisites
- Rust toolchain: 1.95.0 or later (MSRV)
The root
Cargo.tomlsetsrust-version = "1.95.0"under[workspace.package], and CI and release workflows pin toolchain 1.95.0. Member manifests do not inheritrust-version, so Cargo does not enforce it for workspace crates. - Cargo: Included with Rust
- Git: For version control and hooks
Install Rust via rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Verify your installation:
rustc --version # Should be 1.95.0 or later
cargo --version
Building
Clone the repository and build:
git clone https://github.com/guzmanmonne/ralph.git
cd ralph
# Debug build
cargo build
# Release build (optimized)
cargo build --release
Running
Run ralph directly from cargo:
# Start chat (bare ralph)
cargo run
# Run a subcommand
cargo run -- ask 'What is Rust?'
cargo run -- themes
For release builds:
cargo run --release -- ask 'What is Rust?'
Testing
Run the test suite:
# Run all tests
cargo test
# Run tests with output
cargo test -- --nocapture
# Run a specific test
cargo test test_name
# Run tests for a specific crate
cargo test -p ralph
cargo test -p ralph_core
Documentation Book
The docs build as an mdBook site from book.toml and docs/SUMMARY.md.
cargo install mdbook --locked
mdbook build # Output in target/book
mdbook serve --open # Live preview in the browser
Code Quality
Use the xtask lint command for comprehensive code quality checks:
# Run all checks (format, check, clippy, test)
cargo xtask lint
# Auto-fix formatting and clippy issues
cargo xtask lint --fix
# Check all files (not just staged)
cargo xtask lint --force
The lint command runs these checks in order:
cargo fmt- Code formattingcargo check- Compilation verificationcargo clippy- Lint checks (warnings as errors)cargo test- Full test suitecargo rail unify --check- Dependency unification, unused deps, dead features
Git Hooks
Install pre-commit hooks to run lint checks automatically:
# Install hooks
cargo xtask lint --install-hooks
# Check hooks status
cargo xtask lint --hooks-status
# Test hooks
cargo xtask lint --test-hooks
# Uninstall hooks
cargo xtask lint --uninstall-hooks
Installing Locally
Install ralph to your local bin directory:
# Install to ~/.local/bin (default)
cargo install --path crates/ralph --root ~/.local --force
# Install to custom prefix (binary in <prefix>/bin)
cargo install --path crates/ralph --root /usr/local --force
Ensure the installation directory is in your PATH.
Workspace Structure
Ralph uses a Cargo workspace with multiple crates:
ralph/
├── Cargo.toml # Workspace root with shared dependencies
├── clippy.toml # Clippy configuration (max 5 args)
├── .cargo/
│ └── config.toml # Cargo aliases (xtask shortcut)
├── crates/
│ ├── core/ # ralph_core - Pure business logic
│ │ └── src/
│ │ ├── chat*.rs # Chat turn, status, skills logic
│ │ ├── chat_memory/ # Compressed memory tree
│ │ ├── chunk/ # Content chunking
│ │ └── stream/ # LLM stream processing
│ └── ralph/ # ralph CLI - I/O shell
│ └── src/
│ ├── main.rs # Entry point and command dispatch
│ ├── cli/ # CLI definitions (clap)
│ ├── chat*.rs # Chat shell, memory store, MCP server
│ ├── invoke.rs # Shared claude invocation
│ ├── render/ # Terminal rendering
│ ├── stream_processor/ # Stream handling
│ │ ├── tool_display/ # Tool output rendering
│ │ └── tool_results/ # Tool result processing
│ └── subprocess/ # Process management
├── xtask/ # Build automation tasks
│ └── src/
│ └── main.rs # xtask commands (lint, install)
└── docs/ # Documentation
└── context/ # Development pattern docs
Crate Responsibilities
| Crate | Purpose |
|---|---|
ralph_core | Pure business logic (no I/O). Handles parsing, state, and transformations. |
ralph | CLI application. Handles I/O, subprocess management, terminal rendering. |
xtask | Build automation. Provides lint checks and installation commands. |
This follows the Functional Core - Imperative Shell pattern:
- Core crate: Pure functions that are easy to test in isolation
- Ralph crate: Orchestrates I/O using the core’s business logic
Code Standards
Clippy Rules
The project enforces strict clippy rules:
- No
.unwrap()or.expect()in production code (test code exempt) - Maximum 5 function arguments - use config structs for more
- Warnings treated as errors
These are configured in each crate’s lib.rs or main.rs:
#![allow(unused)]
#![cfg_attr(not(test), deny(clippy::unwrap_used))]
#![cfg_attr(not(test), deny(clippy::expect_used))]
#![cfg_attr(not(test), deny(clippy::too_many_arguments))]
fn main() {
}
Formatting
All code must be formatted with cargo fmt. Run cargo xtask lint before committing.
Environment Variables
For development, these environment variables can override default paths:
| Variable | Description |
|---|---|
RALPH_CONFIG_DIR | Config directory override |
RALPH_THEME | Theme selection |
RALPH_NO_BACKGROUND | Disable theme backgrounds |
Troubleshooting
Rust Version Too Old
If you see errors about unsupported Rust features:
rustup update stable
rustup default stable
Clippy Failures
If clippy fails on .unwrap() usage, use proper error handling:
#![allow(unused)]
fn main() {
// Instead of:
let value = some_option.unwrap();
// Use:
let value = some_option.ok_or_else(|| Error::msg("value missing"))?;
}
Test Failures
Tests must not use #[serial] or modify global state. If tests interfere with each other, refactor to use pure functions that don’t depend on environment variables or the filesystem.