How Suvadu Records Shell History Without Slowing Your Shell
How Suvadu's Zsh hooks, executor detection, and SQLite WAL writes record each command — and what has and hasn't been measured in 0.4.2.
Review note: I build Suvadu. I reviewed this article on 19 September 2026 against the Suvadu 0.4.1 source. Earlier versions gave per-stage timings and an overall “under 2 ms” figure that did not come from a published, reproducible benchmark, so I have removed them. This version describes the recording design and states what has and has not been measured. Updated 21 September 2026 for Suvadu 0.4.2, which publishes that benchmark: BENCHMARKS.md records the harness, corpus, hardware and results, and is the only place a Suvadu performance number originates. The recording path described here did not change in 0.4.2; the typed-search description did.
When you're building a tool that hooks into every single command execution, performance isn't a feature. It's a hard requirement. If your shell feels slower, the tool has failed. Suvadu is designed so that recording a command does as little work as possible on the path between pressing Enter and seeing your next prompt. Here's how that path works in 0.4.2, and what we have and haven't measured.
The Architecture
Suvadu's Zsh recording pipeline has three stages around each command:
- Preexec hook (before the command runs): capture the command string and the start time
- Command executes: Suvadu is not involved here at all
- Precmd hook (before the next prompt): capture the exit code, compute the end time, detect the executor, and write to SQLite
Any overhead you feel comes from steps 1 and 3. Step 1 is pure shell arithmetic. Step 3 does the real work: it runs a small shell function to detect the executor and then starts the suv binary, which writes the record.
Stage 1: The Preexec Hook
Zsh provides a preexec function that fires just before any command runs. Here's what Suvadu's hook does:
_suvadu_preexec() {
local LC_ALL=C # keep the decimal point a literal '.' in any locale
_SUVADU_CMD="$1"
_SUVADU_START_TIME=$(( ${EPOCHREALTIME%.*} * 1000 + ${${EPOCHREALTIME#*.}:0:3} ))
} A locale guard and two variable assignments.
The key detail is Zsh's native $EPOCHREALTIME variable, available since Zsh 5.1 (2015). It gives the current Unix timestamp with microsecond precision as a decimal string (e.g., 1707500000.123456). The hook extracts millisecond precision using Zsh string manipulation and arithmetic, without starting a subprocess. Setting LC_ALL=C locally keeps the decimal separator a literal . under locales that would otherwise print a comma.
Stage 2: Executor Detection
Before writing to the database, Suvadu needs to know who ran the command. The detection logic is a cascade of environment variable checks:
# Simplified from the generated hook code
__suvadu_detect_executor() {
# 1. CI/CD environments
if [[ -n "$CI" ]]; then
# Check GITHUB_ACTIONS, GITLAB_CI, CIRCLECI...
# 2. Custom agents from config.toml, then built-in AI agents
elif [[ -n "$CLAUDE_CODE" ]]; then
executor_type="agent"; executor="claude-code"
elif [[ -n "$CODEX_THREAD_ID" ]]; then
executor_type="agent"; executor="openai-codex"
# 3. IDE terminals
elif [[ -n "$CURSOR_INJECTION" ]]; then
executor_type="ide"; executor="cursor"
elif [[ -n "$VSCODE_INJECTION" ]]; then
executor_type="ide"; executor="vscode"
# 4. Human (TTY check)
elif [[ -t 0 ]]; then
executor_type="human"; executor="terminal"
# 5. Fallback
else
executor_type="programmatic"; executor="subprocess"
fi
echo "$executor_type:$executor"
} Each check is an in-shell test of an environment variable, and [[ -t 0 ]] is a terminal check. Detection rules you add to config.toml are inserted before the built-in agent checks. The precmd hook calls this function through command substitution ($(__suvadu_detect_executor)), which runs it in a subshell. We have not published a separate measurement of this step.
This detection happens per-command because the executor context can change during a session.
Stage 3: The SQLite Write
The precmd hook calls suv add with all the captured metadata:
$_SUVADU_BIN add \
--session-id "$SUVADU_SESSION_ID" \
--command "$_SUVADU_CMD" \
--cwd "$PWD" \
--exit-code "$exit_code" \
--started-at "$_SUVADU_START_TIME" \
--ended-at "$end_time" \
--executor-type "$executor_type" \
--executor "$executor" This is the most expensive operation in the pipeline. The suv add process:
- Starts the
suvbinary - Checks whether recording is paused, then loads configuration for the command's directory (global config plus the nearest
.suvadu.tomloverlay) - Stops early if recording is disabled or the command matches an exclusion pattern
- Applies auto-tags and secret redaction
- Opens the SQLite database and checks its schema version
- Executes a parameterized INSERT, which also updates the table's indexes and the full-text index through triggers
- Exits
How long this takes depends on the machine, disk, filesystem cache, configuration (for example, the number of exclusion and redaction patterns), and database size. Suvadu 0.4.2 publishes a reproducible measurement of it: on an Apple M4 Max (128 GB RAM, macOS 27.0) against a generated 100,000-entry corpus, recording overhead per command was p50 82µs, p95 151µs, p99 1.42 ms — one machine, one run, an order of magnitude rather than a specification. The method, corpus and full results are in BENCHMARKS.md, which is the only place a Suvadu performance number originates.
Why WAL Mode Matters
SQLite's default rollback-journal mode blocks readers while a write commits.
WAL (Write-Ahead Logging) mode changes this:
// From db.rs - connection initialization
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?; With WAL mode, writes are appended to a separate -wal file while readers continue reading a consistent snapshot. Readers and a writer don't block each other, although only one writer can commit at a time; concurrent shells wait up to a five-second busy timeout.
The synchronous=NORMAL pragma matters as well. In WAL mode, FULL syncs the WAL file at every commit, while NORMAL syncs at checkpoints. That trades a small durability window after a power loss for fewer disk flushes per command.
The Insert Query
INSERT INTO entries (
session_id, command, cwd, exit_code,
started_at, ended_at, duration_ms,
context, tag_id, executor_type, executor
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) The Database Schema
CREATE TABLE entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
command TEXT NOT NULL,
cwd TEXT NOT NULL,
exit_code INTEGER,
started_at INTEGER NOT NULL,
ended_at INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
context TEXT,
tag_id INTEGER REFERENCES tags(id),
executor_type TEXT,
executor TEXT
);
CREATE INDEX idx_entries_started_at ON entries(started_at);
CREATE INDEX idx_entries_command ON entries(command);
CREATE INDEX idx_entries_session_id ON entries(session_id);
CREATE INDEX idx_entries_tag_id ON entries(tag_id);
-- Later migrations add composite indexes, such as (cwd, started_at)
-- and (exit_code, started_at), for filters and statistics. The started_at index matters most for the common case. Most history views sort by recency (ORDER BY e.started_at DESC), so the database can walk the index in reverse order instead of sorting the whole table.
A B-tree index on command cannot accelerate LIKE '%query%' searches because the leading wildcard prevents prefix matching. It is still useful for prefix queries such as arrow-key navigation (LIKE 'git comm%').
Update (0.4.0): A trigram-tokenized FTS5 index (entries_fts) is kept in sync with entries via insert/update/delete triggers, as an external-content table so it doesn't duplicate the command text. Database-level substring queries on the command field now run their LIKE against entries_fts instead of scanning entries.command, with the same results. The index is meant to avoid scanning every row as history grows; we have not published a reproducible measurement of the difference.
Synchronous by Design
The suv add call is synchronous: the next prompt waits until the write finishes. If it ran asynchronously, pressing the Up arrow immediately after a command could miss the most recent entry. We chose that guarantee over returning the prompt sooner; its cost is the Stage 3 time above.
The Search Side
Suvadu's search TUI is built with ratatui and has two query paths:
- Opening search with a query (
suv search -q …, or Ctrl+R with text already on your prompt): a parameterized SQL query withLIKE '%input%'against the trigram index, plus any active filters, paginated withLIMIT/OFFSET. - Typing inside the TUI: Suvadu selects and counts matching entries in SQL over every entry that passes the active filters. Every typed word must appear literally, and the newest 5,000 matches are then ranked in memory by match tier and a boosted matcher score.
Until 0.4.2, the second path loaded only the newest 5,000 eligible entries and matched them in memory, so an older match could be missing from typed results. It now matches and counts across the whole eligible history, so an older command is found and the reported result count is always one you can page to; the 5,000 limit bounds only how many matches are ranked by relevance, and anything beyond them is paged newest-first. The search documentation describes the current behavior. Result completeness is now measured alongside latency: the benchmark compares what the database returned against an exhaustive scan of the whole corpus, and a missing match fails the run however fast it was.
Recency-First Arrow-Key Recall
SELECT command FROM entries
WHERE command LIKE 'git comm%'
ORDER BY started_at DESC,
CASE WHEN cwd = '/Users/me/project' THEN 0 ELSE 1 END
LIMIT 1 OFFSET 3 The prefix match (LIKE 'query%' without a leading wildcard) can use the B-tree index on command.
Recall is ordered primarily by started_at DESC. Same-directory context only breaks ties between equally recent entries; command frequency does not drive arrow-key recall.
Putting It All Together
| Stage | Operation | Process work | Published measurement |
|---|---|---|---|
| Preexec | Capture command + $EPOCHREALTIME | In-shell arithmetic; no subprocess | Not yet |
| Detection | Environment variable cascade | Shell function run in a subshell | Not yet |
| Record | suv add: config, exclusions, redaction, INSERT | One suv process per command | Not yet |
| Typed search | Candidate load + in-memory matching | Inside the running TUI | Not yet (latency and completeness) |
The design keeps the preexec step trivial and puts one short-lived process on the post-command path, so the new command is available to recall immediately. Whether that overhead is noticeable on your machine is something you can check yourself. We will add reproducible measurements here when we publish them, rather than asking you to take a number on trust.
The best developer tools are the ones you forget are running. Suvadu is designed to be invisible until you need it.
Want to try it? Install Suvadu and start building a better command history.
Builder of Suvadu. Writes Rust, thinks about shell history more than most people, and believes developer tools should be local-first.