Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 ralph runs ralph 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 zoom and recall tools.
  • Background workers - Claude starts, messages, and closes workers with spawn_worker, reply, and close_worker. Harnesses: claude in tmux, cursor, and opencode. Each worker gets its own git worktree. These tools come from ralph chat-mcp, which talks to the running chat over a Unix socket.
  • Inbox and watches - Worker reports and finished watch commands 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, $EDITOR drafts, clipboard images, ! shell cells, skills, and an opt-in vi mode ([chat] edit_mode = "vi").
  • Single-shot ask - ralph ask sends 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 .tmTheme files (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 (claude on PATH)
  • git, for worker worktrees
  • Per worker harness: tmux for claude workers, the Cursor agent CLI for cursor workers, opencode for opencode workers

Quick Start

  1. Chat:
ralph
  1. Ask a single-shot question:
ralph ask 'Explain this codebase'
  1. 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.

OptionDescription
-V, --versionPrint version information (see version scheme)
-h, --helpPrint help (-h shows a summary)

Commands

CommandDescription
themesList available syntax highlighting themes
askSend a single-shot prompt to the LLM (details)
chatChat with a persistent, compressed memory; default when no command is given (details)
wireRecord 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.dark
  • base16-mocha.dark
  • base16-ocean.dark (default)
  • base16-ocean.light
  • InspiredGitHub
  • Monokai Extended
  • Solarized (dark)
  • Solarized (light)

Environment Variables

Environment variables can be used instead of CLI flags. CLI flags take precedence over environment variables.

VariableEquivalent FlagDescription
RALPH_CONFIG_DIRN/AOverride config directory location
RALPH_THEME--themeSet syntax highlighting theme
RALPH_NO_BACKGROUND--no-backgroundDisable theme backgrounds (set to 1)
RALPH_ASK_PERMISSION_MODEask --permission-modePermission mode for ask
RALPH_CHAT_PERMISSION_MODEchat --permission-modePermission mode for chat turns and claude workers
RALPH_WORKTREE_DIRchat --worktree-dirDirectory for worker worktrees
RALPH_CHAT_SKILLSchat --skill:-separated skill directories for chat
RALPH_WRAP_WIDTHchat --wrap-widthColumn 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]
FlagDefaultDescription
--provideranthropicProvider to proxy
--port0Port on 127.0.0.1; 0 picks a free port
--upstreamprovider defaultUpstream base URL
--dir<tmp>/ralph-wireDirectory holding session folders
--responsesoffAlso 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]
FlagApplies toDescription
--sessionls, show, diffSession id (default: the latest)
--dirls, show, diff, sessionsDirectory holding session folders (default <tmp>/ralph-wire)
--alllsAlso show non-inference calls
NshowCall number (seq) from ls
A, BdiffCall numbers (seq); with only A, diff A against the earlier call sharing its longest prompt prefix
--jsonls, show, diff, sessionsPrint JSON instead of a table

Exit Codes

CodeMeaning
0Success - all operations completed successfully
1Error - operation failed (see error message for details)

See Also

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_mode in the ralph config, then auto. Resolved once at chat start. ralph ask has 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; 0 or 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-sound and --no-inbox-notification turn off the inbox alert parts (see Inbox alerts).
  • --store <DIR> sets the store path. Precedence: --store, then RALPH_CHAT_STORE, then ~/.ralph/chat; an empty RALPH_CHAT_STORE counts 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, and inbox/.
  • Hidden subcommands: ralph chat-mcp --store <DIR> --socket <PATH> and ralph 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, or vi (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, tv and rv still work. The prompt indicator is >>> in insert mode and ::: in normal mode. The pure chat_edit_mode core 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_submit and empty_prompt change 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>/turn marker (user or worker, removed when the turn ends); the chat socket server reads it so spawn_worker, close_worker and watch work only in a user-started turn (reply also 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.rs holds the types (Entry, Node, Tree) and re-exports; log.rs parses and encodes the log and tree files; tree.rs has cover, pending, split_view, stable_cut, tail_lines, verbatim_start, estimate_tokens; lookup.rs has zoom and recall; search.rs has the ranked search (tokenizer, query, BM25 scan, cursor; see search); index.rs and archive.rs hold the log and tree indexes and Archive, whose read and zoom load a window of entries and the summaries they show (see Index); tools.rs has the MCP tool schemas, handle_request and finish_call; nap.rs has the nap and retry prompts, nap_source, check_summary, fallback_summary, and the nap scheduling and commit decisions nap_step and commit_decision; usage.rs has 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: /watch argument parsing, watch ids, time-left and list formatting.
  • crates/core/src/diff_command.rs: /diff argv (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: run and 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, indexed read and zoom).
  • crates/ralph/src/chat/index_files.rs: FileSource, open_archive, and the log.idx and tree.idx writes (update_index for writers, save_stale for readers).
  • crates/ralph/src/chat/mcp.rs: chat-mcp stdio loop; answers zoom, recall, search and read, forwards worker tools to the chat socket.
  • crates/ralph/src/chat/socket.rs: chat socket bind, sweep, server and forward client.
  • crates/core/src/retry.rs: pure retry rules: classify, FailureKind, OUTAGE_MARKERS, OUTAGE_BACKOFF_SECS, backoff, retry_delay.
  • crates/ralph/src/backoff.rs: shell loop retry_on_outage with an injectable wait, attempt_from and wait_unless; used by turn.rs and the nap in main.rs.
  • crates/core/src/chat_socket.rs: socket request/response lines, owner record, socket_fate and orphan_sidecar_fate.
  • crates/ralph/src/invoke.rs: build_chat_command, build_memory_command.
  • crates/ralph/src/cli/mod.rs and main.rs: arguments, default dispatch, chat-mcp and chat-nap handlers.

Turn loop

  1. A launch (Store::load_after_nap) first waits for a running nap, then Store::load_settled takes a shared LOCK, then reads log and tree. It waits only while an append or a nap commit holds LOCK (one fsynced line each), not for curator calls. Nodes that are still pending show as described in Pending summaries.
  2. split_view(entries, tree, VIEW_LINES, recent_history_tokens) builds the memory view in two parts. The cut snaps down to a multiple of OLDER_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 new claude process (step 4); a turn on a live process does not use it.
  3. For a worker turn, one worker entry per report is appended to the log. The user message waits for step 6.
  4. One claude process serves the whole chat. The first turn (and any turn after the process died or a turn failed) runs build_chat_command: a fresh claude --verbose --permission-mode <MODE> (the chat’s resolved mode) with isolation args, --append-system-prompt <SYSTEM_PROMPT + older view> (turn_system_prompt; just SYSTEM_PROMPT while the older part is empty), the MCP args, --settings with autoCompactEnabled false (merged with the CLAUDE.md exclude), --input-format stream-json --output-format stream-json and -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-json user line (ralph_core::stream::user_line); the process keeps the conversation. The command starts with CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1, so claude starts no background tasks and the process stays alive after each result. The Session owns the piped stdin and the stdout/stderr reader threads for its whole life, and Session::turn can be called many times. A turn ends at the top-level result event (parsed with ralph_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_secs is 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 before result, or a turn is killed. Before each turn chat polls the child with waitid and WNOWAIT, which does not reap it, so a process that exited while idle is dropped and respawned. Before it sends the message, Session::drive discards the stdout lines that the process wrote after the last result. If one of them is a result, the turn fails with LeftoverResult and 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 one control_request interrupt line (interrupt_line) and keeps reading until result. With no result within 3s it kills the process group and the session is dead. Dropping the Session closes stdin, waits, and kills after 3s. On every chat exit, a lost terminal included (watch_input drops the session before exit), 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 through spawn_detached, which reaps them on a thread. There is no --resume and no --agent. Worker triggers use the same path.
  5. Output goes through the spinner subprocess runner and StreamProcessor, as in ralph ask.
  6. On exit code 0 with response text, the user message (if any) and then the reply are appended verbatim and a detached ralph chat-nap is spawned (stdio null; spawn failure only warns). Otherwise a warning chat turn failed (exit N) prints and the loop continues. Only the subprocess call is wrapped in retry_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), warning chat 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.
  7. 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 with next_id, id and read fields still loads; its read entries are dropped. Tmux workers have sessions named ralph-<project>-<id> (<project> = the git top-level folder name, letters, digits and dashes only, else repo), 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 a blocked/<id> marker (a pending permission request) shows permission whatever its reports say, and counts as working. A claude worker’s state is the status of its latest report, else running. A reply, a nudge or a spawn writes the marker <store>/sent/<id>; while it exists a done, blocked or question status is ignored (a check status 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 for GET /api/session/active (one call for all workers, every 5 s and after each turn or inbox read, only while an opencode worker runs): busy while ses_ralph<id> is in it, else the latest report status, else idle; 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_secs is above 0 (default 60), the watcher thread runs Monitor::check every that many seconds. For each running worker it reads the ready, blocked, waiting, unreported and sent markers (a sent marker means the last report is old, so the worker is never counted as reported), and the last real report (done, blocked or question) seen in the inbox or logged as a worker entry in the chat log at start (so a restart keeps it). Only Claude workers get a pane capture, skipped when blocked, waiting, or ready and reported; headless workers have no pane. The pure worker_check::plan classifies 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.”) through Workers::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 status check, which raises the usual alert; check posts 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 with reply.
  • 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), and gone/<id> when the agent acp child exits or its connection ends without a close or chat exit (reason text; the host’s 250 ms poll try_waits each live child, which also reaps it, and writes agent acp exited (<status>) once per exit) or when the shared opencode serve dies without a chat exit (first line scope server pid <n>, then the reason). Spawn, resume and close clear both. A server-scope gone stays across the server restart; the monitor deletes it after it posts the Dead message, so the next server death alerts again; with worker_check_secs = 0 no Dead message posts and the marker stays until spawn, resume or close. On each worker check the monitor feeds the markers to the pure HeadlessState reducer and liveness: a Running worker (not blocked) with no activity for [chat] worker_quiet_secs (default 300, unverified) is Quiet and posts one check message quiet <n> s; may be stuck on a prompt; a gone marker 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, so worker_check_secs = 0 turns 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 its ready/<id> marker remains gets gone/<id> (tmux session ended) and one Dead message. Close clears ready/<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 no unreported marker is set (that marker means waiting is due at the next idle_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 with tmux 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_alert decides. Inside tmux, tmux display -p -t $TMUX_PANE '#{pane_active} #{window_active} #{session_id}' plus tmux list-clients -t <session> -F '#{client_flags}' give Visible when the pane and its window are active and a client has the focused flag, else Hidden. Outside tmux, no attached client, or a failed query give Unknown, which alerts unless [chat] inbox_alert_unknown_focus = "silent". Visible never alerts, and nothing alerts when both inbox_alert_sound and inbox_alert_notification are off. An alert plays a sound (afplay /System/Library/Sounds/Glass.aiff on macOS, else a terminal bell) and, with inbox_alert_notification, a notification <id> <status>, ... (osascript display notification on 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::segments builds segments from Status, layout picks which fit, and chat_statusbar paints them. Left: mode block (bright, bold; one of NAP, 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 last turn line in usage.jsonl and is absent before any turn; its percentage is the cache-read token share read * 100 / (input + read + write), rounded down, or 0 when the total is zero (TurnCache::token_share_percent), not a cost share. $cost is 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 unread to 📬 2, mem 132 msgs ~4.1k tok to 132m ~4.1k, workers with state, names, 2 workers, 2w; watches 2wt; cache c96% tok). When the bar is too wide, layout compacts 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_fg and base_bg, resolved by status_theme.rs; keys and defaults in configuration). Segments are plain colored blocks; [chat] status_powerline = true adds powerline glyphs between them. With no color (NO_COLOR or 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::mode picks the mode block, first match wins: NAP (a nap holds the NAP lock), INBOX (unread above 0), WORK (a worker is running, busy or permission, or any watch exists), else IDLE. Each mode has its own mode_<name>_fg / mode_<name>_bg keys. 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::napping try-locks the store NAP file in shared mode and releases it at once, so the probe never blocks. Limit: a nap started by another ralph chat on the same store also holds that lock, so the bar shows NAP there too.
  • Footer: footer in chat_status.rs builds 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, so vendor/reedline carries upstream nushell/reedline plus the footer commit (Prompt::render_prompt_footer) and Reedline::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.

CommandEffectStarts a turn
/inbox, or Enter on an empty prompt with reports unreadSends 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 inboxyes
/inbox nextSame as /inbox for the oldest report only (position 1). With nothing unread, prints No unread reports.yes
/inbox NSame as /inbox for the report at position N only. Unknown N prints the not-found messageyes, when the report exists
/inbox listPrints 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 NPrints report N in full with the same header box and markdown as /inbox; removes nothingno
/inbox drop NRemoves 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 dropno
any other /inbox argumentPrints 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 watch report 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 in crates/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 chat start, Watches::restore reruns 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_at and deadlines. The timeout becomes the time left. A command that is exactly sleep <N> is rewritten to sleep <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 is crates/ralph/src/watch_store.rs.
  • The agent starts a watch with the watch tool (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 error watch <id> is already queued and queues nothing. The chat watcher thread takes every request on each 500 ms poll, calls Watches::start, and deletes the file (crates/ralph/src/watch_requests.rs; pure parsing in crates/core/src/watch_request.rs). If the start fails (duplicate id, spawn error), chat posts one watch report with the error to the inbox.
  • Every watch id is stored, reported and shown as watch:<name>: generated ids are watch:w1, watch:w2, …; a watch tool id foo becomes watch:foo, and an id that already starts with watch: is not doubled (watch_id in crates/core/src/watch.rs). Worker ids allow only letters, digits and dashes, so a watch id never equals a worker id. /watch stop accepts w1 or watch: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, like spawn_worker.
  • /watch is local: it never starts a turn or calls the model. The shell side is crates/ralph/src/chat/watch.rs; the pure parsing and formatting is crates/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 unused watch:w<n> (n from 1) among running and saved watches. It prints Started <kind> watch <id>.. There is no timeout flag.
    • /watch list prints one row per active watch: id, kind (normal or persistent), time left, command. Time left is the earlier of the wake deadline (a time-based sleep N) and the timeout deadline, shown as <n>s, <n>m, <n>h or <n>d, or — when there is none. It prints No 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 /watch or any other argument prints Usage: /watch start [--persistent] <command> -- <follow-up> | list | stop <id>. /watchfoo is 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 the watch tool, /watch start, or an old saved record) gets [chat] watch_timeout_secs (default 3600, so 1 hour; 0 is a config error, resolved by resolve_watch_timeout in crates/core/src/chat.rs, same file-then-default precedence as worker_check_secs). WatchSpec::with_default_timeout applies it at start and at restore. When it passes, ralph kills the watch and posts one report that says timed 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.

InputShows
/diffthe last finished chat turn (same as /diff last 1)
/diff last Nnet changes over the last N finished chat turns (N >= 1)
/diff ago Nthe single chat turn N back (N >= 1)
/diff worker <id>that worker’s changes since it spawned
/diff worker <id> last N or ago Nthat worker’s last N turns, or the turn N back
/diff nowthe 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’s core.pager in 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 ..., with core.pager=hunk pager as one argument.
  • Any other pager string warns once at chat start ([changes] pager = "..." is unknown; using git) and uses git. Loading the config still succeeds.
  • ralph does not write core.pager. Snapshot ranges set GIT_DIR and the alternate object dir for the spawned git only; /diff now sets 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_tokens reach [chat] reset_tokens (default 140000). No usage means no token trigger.
  • The result event has terminal_reason blocking_limit (claude’s context is full). Only the structured field counts, never the text Prompt 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>/start and .../end. <n> continues from the highest existing number, also across restarts.
  • Content: git read-tree -m -i HEAD into a private index at <side>/index/chat (skipped when the branch has no commit yet), then git add -A, so tracked files stay even when ignored and .gitignore and info/exclude apply to untracked files, then write-tree and commit-tree. read-tree runs without the side object dir; stat-clean entries reuse blobs an earlier snapshot wrote there and add -A rehashes the rest. Snapshot commits have no parents.
  • Message: the ref name, then trailers Ralph-Head: <sha> (HEAD at snapshot time) and Ralph-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 by snapshot_message, read by parse_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.ago reclaims objects.
  • Code: pure logic in crates/core/src/snapshot.rs; git calls in crates/ralph/src/snapshot.rs; the turn hook in crates/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; turn k at the end of each turn (Claude Stop hook, Cursor run_turns, opencode Effect::Snapshot, which the pure TurnState emits first on every TurnEnded from SSE or reconcile, resends included, but not while the chat is closing; the chat’s SSE thread runs the git calls). The /opencode.json line that spawn adds to the repo’s info/exclude keeps ralph’s opencode config out of the snapshots unless the repo tracks that file. The last 20 stay. close_worker deletes the refs and the index.
  • Report: the worker MCP server (worker-mcp --worktree) appends two git diff --stat blocks to the report text, rendered by render_changes: since spawn (merge-base of HEAD and the worker base branch against a fresh tree of the worktree, so untracked files count) and since the last turn snapshot (labelled turn latest + 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 = false also turns off worker snapshots and report diffstats: the spawn decides once (snapshots_enabled), takes no turn 0, and passes no --worktree to the worker hooks and MCP server (opencode: opencode.json’s worker-mcp command, built by worker_mcp_args; the spawn handoff carries snapshots, and resumed opencode sessions read the config at chat start). close_worker still clears leftover refs and index from workers spawned while it was on.
  • Code: pure helpers in crates/core/src/snapshot.rs and crates/core/src/changes.rs; git calls in crates/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=N and ago=N (1 or more, not both) are resolved by the pure resolve_range over the listed refs. With no worker, the chat repo (chat-mcp runs 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=N gives one finished turn (ago=1 is the newest, ago=2 the one before). With worker, that worker’s worktree, where each step between two consecutive turn snapshots is a finished turn: no last or ago gives turn-0 to the highest turn (since spawn); last=N spans the last N steps; ago=N is the single step N back. An N larger than the finished turns is not clamped: it is an error such as last=5 is more than the 3 finished turns of the chat; use last 3 or less. The removed turn and since arguments are rejected with an error that names last and ago. 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_base compares the Ralph-Base of the two snapshots (the fresh current_base for the since-last-turn report block). Same base, or both unresolved: Direct, diff as is. The newer snapshot has Ralph-Base equal to its Ralph-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 with git merge-tree --write-tree --merge-base=<old> <new> <snapshot>, then that tree is diffed. Missing trailers or one unresolved base: Unknown, diff as is. Any merge-tree failure (git older than 2.40, a conflict, unparsable output) falls back to the unmoved diff. Every fallback and Unknown appends the line note: 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-tree runs with GIT_DIR set 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: stat is --stat, files is --name-status, patch is --patch. paths limit the diff. The first output line says what the range is, such as changed in chat turn 3. No changes gives no 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 within max_bytes, except when max_bytes is smaller than the footer: then only the footer shows. The label line is not counted. Worker report diffstats stay uncapped.
  • [changes] enabled = false makes the tool return an error that says snapshots are off. A chat outside a git repo, or with no snapshots yet, returns no 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 in crates/core/src/changes/cap.rs; rebase handling in crates/core/src/changes/rebase.rs; the tool call in crates/ralph/src/chat/changes.rs; render_diff and the git helpers in crates/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 ```result fence (longer fence if the output has backticks; none when the output is empty) and, for a non-zero exit, an exit N line (core::chat_cell::display_results). The display never reaches Claude, log.jsonl or memory.
  • Caps shape only what Claude receives: [chat] cell_output_limit (200 lines, first half and last half kept) and cell_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 keeps limit / 2 and the tail keeps the larger rest. 0 disables 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.
  • !shell runs $SHELL -i (/bin/sh when unset) and keeps state between !shell cells, 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, unsets HISTFILE (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 .zshrc definitions 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.
  • !shell state 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/preexec hooks (cleared inside !shell cells), 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 (role is user, assistant or worker, at is RFC 3339). Each append is fsynced under LOCK, then log.idx is 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] (role worker, empty at). The placeholder carries an explicit corrupt flag that only the parser sets: the flag is never written to log.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 separate NewEntry type, so a loaded Entry (including a [corrupt entry] placeholder) cannot be written back, and the compiler enforces it. The turn view shows no placeholder as a message. view and tail_lines replace 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, in notice.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, recall and search keep the [corrupt entry] line per id, so a caller can still address each id. recall and search never match the placeholder. pending never 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::append truncates a torn last line (a crash mid-append) under the exclusive LOCK before it writes, so a new entry never glues onto it (log_tail, pure; the file reads and set_len are in trim_torn_tail), and warns with the byte offset.
  • Tree: tree.jsonl, one {lo, hi, text} record per line, indexed by tree.idx (see Index). Nodes are aligned power-of-two blocks [lo, hi) over log indexes, ids print as 5 or 4-7. First record wins for a duplicate node; invalid records warn and are skipped. Summaries are at most SUMMARY_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>.
  • zoom leaf 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 generic Index<S: Slot>): a 52-byte header (magic RLPHLOGX or RLPHTREX, version 3, covered, tail, hash, skipped line count, slot count, all little-endian), then fixed-size slots. log.idx has one u64 start offset per entry; tree.idx has one {lo, hi, start, end} slot of four u64 per valid record, in file order. covered ends on the newline of the last scanned line, tail is that line’s start, and hash is the FNV-1a hash of the bytes [tail, covered).
  • Same visible content as the parsers: only complete, non-blank lines are scanned. In log.idx every 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. In tree.idx an invalid record is skipped (parse_tree), and TreeIndex::find returns the first slot of each node, so the first record wins.
  • Freshness (Index::freshness, pure): a file shorter than covered or a tail whose hash differs means Rebuild; a file that ends at covered is Fresh; a longer file is Extend, which scans only the bytes past covered. A file with a bad magic, version, length, slot or offset order decodes as missing, which also means a full rebuild.
  • Writes: Store::append updates log.idx under the exclusive LOCK after the log fsync; the nap commit updates tree.idx under the same lock after the record is written. refresh and write_plan decide: 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_path rejects 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 shared LOCK for 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, no LOCK). Summaries come from IndexedTree, one read per wanted node.
  • Temp-file sweep: a save killed between the temp write and the rename leaves its temp file. Store::append removes every file whose name is_index_temp matches (sweep_index_temps) after its own index update, under the exclusive LOCK: 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 before covered, parse as a record, and name the node it is filed under. A failed check is InvalidData: 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 one chat tree: <k> records skipped line where parse_tree gives one chat tree record skipped: <error> line per record.
  • Visibility rule: a read or zoom call sees every complete line that was in the log and the tree when it took its shared LOCK. 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. recall loads every entry through the archive under the shared LOCK; search scans the log in chunks of 256 entries through log.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 the ralph_core functions with its own file reader, not Store.

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_command runs claude --model haiku with --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_summary accepts a trimmed, non-empty, single-line summary of at most 512 bytes that does not read as meta-talk (an opener such as I'm , Here is, Sure, Let me, Based on, or the phrases this excerpt, <input>, </input>). A rejection retries with the error appended, up to MAX_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_ATTEMPTS tries were rejected by check_summary (not a curator error), the nap stores fallback_summary of the node input (nap_source) through the normal commit, so the parent can proceed, and warns chat 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 in zoom. recall searches log entries only, so it never sees fallbacks. The fallback reaches the view when the next process starts.
  • Stuck nap: LOCK and NAP are std::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 on NAP. A status bar stuck on NAP means a live nap. Find it with lsof <store>/NAP or pgrep -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. classify sorts a failure into Timeout, Outage or Refusal. It is an Outage when the exit code is non-zero and the lowercased stderr plus response text contains one of OUTAGE_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 a Refusal and is never retried. An outage waits OUTAGE_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 no LOCK held, so they do not delay turns. A timeout is never retried: each one lasts up to --timeout seconds and holds NAP, so retries would delay later naps, and the next nap retries the node anyway. The check_summary retry 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 LOCK the nap reads log and tree once. log.jsonl is append-only, so the entries under any pending node never change after the snapshot.
  • Curator calls, check_summary retries and outage backoff run with no LOCK held.
  • Commit: for each summary (or raw fallback) the nap takes the exclusive LOCK, re-reads tree.jsonl, and asks commit_decision. Write appends one fsynced record, then updates tree.idx. Taken means another nap committed the node first: the nap drops its own text, warns chat memory kept the first summary for #<id>, and keeps the committed text. Either way the nap continues from the re-read tree.
  • Order: nap_step returns Done for a node already in the tree, Blocked for a parent whose child has no committed summary, else Ask with the prompt. A parent prompt is always built from committed child text, so a parent never summarizes a dropped child.
  • Racing naps: NAP makes naps run one at a time. If two naps still overlap (for example an older binary without NAP), first-writer-wins at commit plus first-record-wins in parse_tree keep one summary per node. Records are written whole under the exclusive LOCK, 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 pending and the next nap retries it. Nodes committed before the failure stay.
  • Tests: pure unit tests in nap.rs cover nap_step, commit_decision, a race of two naps on one node, and uncommitted work staying pending. The shell test nap_curator_runs_without_the_store_lock checks that LOCK is 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). 0 turns 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 the chars / 4 used by the status line, so the verbatim run stays under the budget.
  • Run: verbatim_start walks 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).
  • zoom on 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_summary of its text, marked [raw excerpt] . This excerpt exists only in the view; it is never written to tree.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). zoom on it lists its halves (summary or not 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.

ToolArgsResult
zoomid (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.
recallpattern (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.
searchquery (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), cursorRanked 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.
readid (inclusive range such as 16-40, or one index), optional mode (raw default, summary), budget (tokens, 1 to 16384, default 4096), cursorOne page of a flat range, in order. See read.
changesoptional 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).
watchid (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 (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-709 does not match GUZ-7090. A joined token also keeps its parts (split on -, . and ::: rate-limited has rate and limited; chat_memory::search has chat_memory and search). A single-part query term matches a whole token or a part: rate finds rate-limited, guz finds GUZ-709. A joined query term such as GUZ-709 matches 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 match rate-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 match rate-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 (k1 1.2, b 0.75, token counts as length) over the entries that pass role, since and until; document frequencies come from one scan of those entries. Ties go to the newer entry.
  • Dates: since and until parse with chrono. since later than until, after the date expansion, is an error; the same date for both is valid. A date means that UTC day: since is its start, until includes the whole day. A timestamp bound is inclusive and may carry any offset. An entry whose at does not parse is excluded when a date bound is set.
  • Cursor: <offset>.<count>.<hash> where count is the log entry count the scan ended with (a mid-scan index rebuild is counted, so the next page is not stale) and hash is FNV-1a over the normalized query, role, bounds and limit. Each page rescans and ranks again, then returns hits from offset. A changed query, filter or limit gives cursor does not match this query; a different entry count gives stale 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-memory has two search cases (one rare phrase, one word in every entry). Its full path rebuilds both indexes before the scan, its indexed path 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> chars and then its text verbatim (whitespace and newlines kept). Records are separated by a blank line.
  • mode: raw returns the original text. mode: summary returns the leaf summary for a message longer than SUMMARY_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.
  • budget is a page size in tokens, measured with estimate_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 as cursor with the same id and mode. <offset> is the character offset inside record <next>; <m> is r (raw) or s (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 returns isError: true. Otherwise the page ends with (end of range).
  • Snapshot: the first call fixes end as min(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 (or a) through log.idx, never the whole log; in summary mode it reads only the leaf summaries of long entries in the window, through tree.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:

HarnessWorker-specific close
ClaudeKillSession: tmux kill-session for the owned session, if present
CursorTerminateCursor: hand off through <store>/cursor-close/<id> to terminate only that worker’s ACP process group and remove its files
OpencodeRemoveOpencodeSession: 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-dir is 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.md prints 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 gets Base directory for this skill: <dir>, the SKILL.md body without its frontmatter, then the original message. The rest of the message, trimmed, replaces every $ARGUMENTS in the body; without that token and with non-empty arguments, an ARGUMENTS: <args> line is appended. The store keeps the original message; only the sent prompt is expanded. Unknown names, a bare /, and /name prefixes that are longer words pass through unchanged. A disable-model-invocation frontmatter 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_servers and skills. 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"}) and skills/<name> as symlinks to the skill directories. Each turn adds --plugin-dir <dir>. Skills appear as ralph:<name>.
  • Skills are read at start: changes to the list need a restart.

Images

  • Ctrl-V (macOS only) runs osascript to write the clipboard as PNG to <store>/attachments/.clipboard-<pid>.tmp, then attachment::parse_image checks 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. N is one above the largest [Image #N: already in the draft (attachment::next_number), else 1. No image on the clipboard prints No image on the clipboard.; a bad image prints Image 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>/attachments at 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_worker task or reply text pass through unchanged. Claude workers get --add-dir <store>/attachments; opencode workers get permission.external_directory for <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.
  • LOCK is a file lock: exclusive for append and for each nap commit, shared for settled load and the nap snapshot. NAP is 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,local plus a claudeMdExcludes entry for ~/.claude/CLAUDE.md when the home directory is known. Project and local settings stay active; this is context isolation, not a sandbox.
  • User agents in ~/.claude/agents do not load, so build_agents_args passes the bundled SUBAGENTS (evidence-collector haiku, implementer sonnet, reviser opus) 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 under projects in ~/.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 on ralph/claude-json.lock in the OS per-user data folder (~/Library/Application Support on macOS, $XDG_DATA_HOME or ~/.local/share on Linux). Every close uses that one path whatever --store or RALPH_CONFIG_DIR says, 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.json the 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: runs agent acp, never claude. 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 whose meta.json cwd exactly 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 (a git worktree list entry or an acp-sessions cwd) maps to the same slug or the git listing fails. This does not restore ~/.cursor/cli-config.json changes made by agent 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 ~/.cursor or Claude home-directory data, and does not stop the shared opencode 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

  1. Trust entries in ~/.claude.json for paths that no longer exist.
  2. Transcript folders in ~/.claude/projects/<path>, where every character of the path that is not an ASCII letter or digit becomes -.

Known items

  • 14 /tmp trust 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/repo and 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

ComponentFileDescription
CLI argscrates/ralph/src/cli/mod.rsAskArgs struct definition
Permission modecrates/ralph/src/invoke.rs, crates/ralph/src/config.rsresolve_permission_mode(), AppConfig::ask_permission_mode()
Shared enginecrates/ralph/src/invoke.rsInvocationConfig, invoke(), command building
Prompt sourcecrates/ralph/src/prompt_source.rsclassify_prompt_source(), read_from_source()
Executioncrates/ralph/src/main.rsexecute_ask(), build_invocation_config(), resolve_ask_prompt()
Summarycrates/ralph/src/startup/display.rsdisplay_ask_summary()
Configcrates/ralph/src/config.rsAppConfig 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

FlagTypeDefaultDescription
[PROMPT]positionalstdinThe prompt to send (inline, file path, or - for stdin)
--themestringbase16-ocean.darkSyntax highlighting theme
--no-backgroundflagfalseDisable theme background colors
--timeoutu64600Subprocess timeout in seconds
--verbose-toolsstringnoneVerbose output for tools (omit value for all, or specify names)
--no-promptflagfalseSuppress prompt display before execution
--permission-modestringbypassPermissionsPermission 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:

SourceExampleDescription
Positional argumentralph ask 'What is 2+2?'Inline prompt text
File pathralph ask /path/to/prompt.txtRead prompt from file (if path exists)
Stdin explicitralph ask -Read from stdin (explicit)
Stdin pipedecho 'prompt' | ralph askRead from stdin when piped (automatic)

Resolution Priority

  1. If argument is -, read from stdin
  2. If argument is a valid file path, read from file
  3. If argument is any other string, use as inline prompt
  4. If no argument and stdin is piped (non-terminal), read from stdin
  5. 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

  1. CLI flag (--permission-mode)
  2. Environment variable (RALPH_ASK_PERMISSION_MODE, ignored when empty)
  3. [ask] permission_mode in ~/.config/ralph/config.toml
  4. Default (bypassPermissions)

Config file example:

[ask]
permission_mode = "acceptEdits"

Theme Configuration

Theme settings follow this precedence:

  1. CLI flag (--theme, --no-background)
  2. Environment variable (RALPH_THEME, RALPH_NO_BACKGROUND)
  3. Config file (if present)
  4. 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'

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:

PlatformConfig DirectoryConfig 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:

VariablePurpose
RALPH_CONFIG_DIROverride 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):

  1. CLI flags (e.g., --theme "Solarized (dark)")
  2. Environment variables (e.g., RALPH_THEME=Solarized (dark))
  3. Config file (config.toml in config directory)
  4. 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.

FieldTypeDefaultDescription
namestring"base16-ocean.dark"Theme name or path to .tmTheme file
no_backgroundboolfalseDisable 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"
KeyAssistant defaultAgent defaultStyles
code[theme] nameassistant code themeSyntax theme for fenced code blocks
heading1, heading2, heading3cyan, blue, magentagreen, dark_green, dark_yellowHeadings (always bold)
bold, italicterminal defaultterminal defaultEmphasis color
inline_codeyellowdark_cyanInline code (on a dark gray background)
bulletdark_greydark_greenList bullets
quote, tabletermimad defaulttermimad defaultQuote mark, table borders
link, urlblue, cyanblue, cyanLink label (underlined), link URL
memory_refmagentamagenta#N memory references
borderdark_bluedark_blueWorker header box border
done, blocked, waitingdark_green, dark_red, dark_yellowsameWorker 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

FieldTypeDefaultDescription
permission_modestring"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

FieldTypeDefaultDescription
mcp_serverslist of strings["mcptools"]Servers from ~/.claude.json to load in chat turns
skillslist of paths[]Skill directories exposed to chat
recent_history_tokensinteger8192Token budget for the newest chat messages shown verbatim in the memory view; 0 turns it off
worker_check_secsinteger60Seconds between worker checks while chat is open; 0 turns the check off
worker_quiet_secsinteger300Seconds 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_turnsinteger30Turns one claude process serves before chat drops it and respawns from the store; must be above 0, else a warning and 30
reset_tokensinteger140000Context 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_secsinteger3600Seconds 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_widthintegerunsetColumn cap for chat replies and the prompt area; 0 or unset wraps at the terminal width
cell_output_limitinteger200Lines of cell output sent to Claude: first half and last half kept; 0 sends all
cell_output_bytesinteger65536Bytes of cell output sent to Claude: head and tail kept; 0 sends all
worker_basestringunsetBase branch that close_worker checks for unmerged commits
worktree_dirstring.worktreesDirectory that holds worker worktrees; a leading ~ expands to home, relative paths resolve against the repo root
permission_modestring"auto"claude --permission-mode for chat turns and claude workers
inbox_alert_soundbooltruePlay a sound when a worker report arrives and the chat is not focused
inbox_alert_notificationboolfalseAlso 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_powerlineboolfalseUse powerline glyphs (\ue0b0, \ue0b2) between status line segments; needs a patched font. Off draws plain colour blocks
editorlist of stringsunsetCtrl-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_promptstringunsetPhrase 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

FieldTypeDefaultDescription
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
modelstringunset (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

FieldTypeDefaultDescription
modelstringunset (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

FieldTypeDefaultDescription
modelstring"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.

KeyDefaultMeaning
timeout_ms500Kill the render child after this many milliseconds
max_rss_mb256Kill the child above this resident size (macOS and Linux only; elsewhere only the timeout applies)
max_input_bytes1048576Refuse a block with more source bytes
max_flowchart_edges50Refuse a flowchart/graph with more arrow tokens
max_class_relations400Refuse 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.

KeyDefaultMeaning
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
enabledtrueSnapshot 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
KeyDefaultMeaning
inputrequiredUSD per million uncached input tokens
cache_readinput x provider multiplierUSD per million cache-read tokens
cache_write_5minput x provider multiplierUSD per million 5-minute cache-write tokens
cache_write_1hinput x provider multiplierUSD per million 1-hour cache-write tokens
outputinput x provider multiplierUSD 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

ToolVerbose Output Shows
readFull file content with syntax highlighting and line numbers
editFull diff with syntax highlighting
writeFull diff showing new/changed content
globAll matched files grouped by directory
grepFull regex pattern and matched content
bashFull command and output with shell syntax highlighting
todowriteFull todo list with status indicators
notebookeditFull 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

VariablePurposeExample
RALPH_CONFIG_DIROverride config directory/custom/config
RALPH_THEMESet syntax highlighting themeSolarized (dark)
RALPH_NO_BACKGROUNDDisable theme backgrounds1
RALPH_ASK_PERMISSION_MODEPermission mode for ralph askacceptEdits
RALPH_CHAT_PERMISSION_MODEPermission mode for chat turns and claude workersauto
RALPH_WORKTREE_DIRDirectory for worker worktrees; ~ expands, relative paths resolve against the repo root.worktrees
RALPH_CHAT_STOREChat store directory; relative paths resolve against the cwd~/chats/a
RALPH_CHAT_SKILLS:-separated skill directories for chat~/skills/a:~/skills/b
RALPH_WRAP_WIDTHColumn cap for chat replies and the prompt area; 0 = terminal width80

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

  1. Verify the file exists at the correct path
  2. Check file permissions (must be readable)
  3. 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

  1. Verify the theme name matches exactly (run ralph themes for the list)
  2. For custom themes, ensure the file path is absolute and the file exists
  3. 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:

ToolVariantCaptured Data
BashBashcommand, description
GrepGreppattern, path, output_mode, glob, file_type, case_insensitive
ReadReadfile_path, offset, limit
GlobGlobpattern, path
TodoWriteTodoWritetodos (Vec of TodoItem)
OthersDefaultkey_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:

ToolVariantsPurpose
BashBashcontent, truncated flag
EditEditBeforeAfter, EditDiff, EditNoChangesBefore/after display, unified diff, or no-op
WriteWriteNewFile, WriteOverwrite, WriteNoChangesNew file, overwrite, or no-op (includes is_new_file flag)
ReadReadfile_path, content, line_count, truncated
GrepGrepmatch_count, output_mode, content
GlobGlobfile_count, content, truncated
TodoWriteTodoWritemessage
NotebookEditNotebookEditnotebook_path, cell_identifier, diff_content, etc.
OthersDefaultcontent

Key Implementation Files

FileResponsibility
stream_processor/output_block.rsOutputBlock enum and all variant definitions
stream_processor/block_builders.rsHelper functions to build OutputBlocks from tool data
stream_processor/processor.rsAccumulates blocks during streaming
stream_processor/event_handler.rsPushes 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 &), by ralph_core::shell_format::format_command into a highlighted sh block: one statement per line on ;, &&, ||, indented loop and if bodies, 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.toml sets rust-version = "1.95.0" under [workspace.package], and CI and release workflows pin toolchain 1.95.0. Member manifests do not inherit rust-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:

  1. cargo fmt - Code formatting
  2. cargo check - Compilation verification
  3. cargo clippy - Lint checks (warnings as errors)
  4. cargo test - Full test suite
  5. cargo 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

CratePurpose
ralph_corePure business logic (no I/O). Handles parsing, state, and transformations.
ralphCLI application. Handles I/O, subprocess management, terminal rendering.
xtaskBuild 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:

VariableDescription
RALPH_CONFIG_DIRConfig directory override
RALPH_THEMETheme selection
RALPH_NO_BACKGROUNDDisable 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.