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.5.1.
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. Updated again for Suvadu 0.5.1, which changed the hook (default options, the starting directory for the git branch), added a second search index that every write updates, sped up prefix and substring search, and re-ran the benchmark; the numbers below are from the 19 October 2026 baseline and the measurements beside it in BENCHMARKS.md.
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.5.1, 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() {
emulate -L zsh # default options, whatever yours are
local LC_ALL=C # keep the decimal point a literal '.' in any locale
_SUVADU_CMD="$1"
_SUVADU_START_PWD="$PWD" # where the command started, for its git branch
# 10# reads the milliseconds as decimal even if they start with 0
_SUVADU_START_TIME=$(( ${EPOCHREALTIME%.*} * 1000 + 10#${${EPOCHREALTIME#*.}:0:3} ))
} A locale guard and three variable assignments. Since 0.5.1 the hook also runs under Zsh's default options (emulate -L zsh), so options such as OCTAL_ZEROES or SH_WORD_SPLIT in your own config can't break it, and it remembers the directory the command started in, because a cd moves $PWD before the next hook runs and the git branch has to be read from where the command was typed.
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" "${start_dir[@]}" # --start-cwd, only after a cd 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
- Since 0.5.1, reads the git branch from the repository's own
HEADfile, with nogitprocess: 11–21 µs per lookup on an Apple M4 Max - Opens the SQLite database and checks its schema version
- Executes a parameterized INSERT, which also updates the table's indexes and, through triggers, two trigram full-text indexes: one over the command as typed, and since 0.5.1 one with its separators removed, for the search fallback that lets
hiveagentfindhive-agent - 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. BENCHMARKS.md, the only place a Suvadu performance number originates, measures it two ways, both on an Apple M4 Max (128 GB RAM, macOS 27.2), one run each — an order of magnitude rather than a specification:
- The database write (the 19 October 2026 baseline, schema v13, a generated 100,000-entry corpus): p50 110 µs, p95 207 µs, p99 1.27 ms per command. In 0.4.2 it was p50 82 µs and p95 151 µs; the second trigram index added in 0.5.1 accounts for about 25 µs of the difference.
- A whole
suv addprocess, as the hook runs it at each prompt, from a directory seven levels inside a repository: 13.4 ms p50 and 14.3 ms p95 with branch capture on, 13.3 ms and 14.1 ms with it off. Nearly all of that is starting the process and opening the database; branch capture is inside the run-to-run noise.
The second figure is the one your prompt pays. It is well above the “under 2 ms” this article's title once implied, which is why that claim was removed.
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. In 0.5.1,
-- schema v12 replaces idx_entries_command with a case-folding (NOCASE)
-- index for prefix lookups, and schema v13 widens the (command,
-- started_at) index with the exit status and executor for Smart rank. 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 should help prefix queries such as arrow-key navigation (LIKE 'git comm%'), but before 0.5.1 it did not: LIKE folds ASCII case, and a binary-ordered index cannot narrow a case-insensitive range, so every lookup read the whole table. Schema v12's NOCASE index fixed that (see below).
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.
Update (0.5.1): it turned out SQLite never handed a LIKE … ESCAPE to the full-text index, so every terms and literal query still scanned the whole history. 0.5.1 makes them seek the trigram index. On the 100,000-entry benchmark corpus most judged queries fell from 10–40 ms to well under a millisecond, with the same results; queries with a non-ASCII word still scan, at about 15 ms. Across the judged set, the 19 October baseline is 0.05–15 ms p95 at 100,000 entries.
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. Since 0.5.0 each query runs on a background thread with its own read-only connection, so typing never waits for a search. It 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; since 0.5.1 that score also weighs frecency, failure rate and what usually follows the shell's last command (Smart rank).
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.
Ranking costs something too. BENCHMARKS.md times a whole keystroke in recall — count, ranked window of 5,000, ranking, first page — on a realistic 100,000-run history while typing git com: 24–99 ms p50 per keystroke in the Executions view and 21–57 ms in Commands, the one-letter query being the slowest. Smart rank's statistics are read on the first keystroke and reused, which made a one-letter query in Commands 11 ms slower than before; later keystrokes cost what they did.
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) is, since 0.5.1, one range of the case-folding NOCASE index, and the command is chosen from that index alone. A whole suv get --prefix lookup, which Up-arrow and the zsh-autosuggestions strategy run, fell from 12–25 ms to 5–8 ms p95 on a 100,000-command history on the same Apple M4 Max, about 1–4 ms of it beyond starting the process.
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, start directory + $EPOCHREALTIME | In-shell arithmetic; no subprocess | Not separately |
| Detection | Environment variable cascade | Shell function run in a subshell | Not separately |
| Record | suv add: config, exclusions, redaction, git branch, INSERT | One suv process per command | Whole process 13.4 ms p50 / 14.3 ms p95; the write alone 207 µs p95 at 100k entries |
| Typed search | SQL candidate selection over the whole history, then ranking of up to 5,000 matches | Inside the running TUI, on a background thread | Judged queries 0.05–15 ms p95 at 100k; a whole keystroke 21–99 ms p50 on a realistic 100k history; completeness asserted |
| Arrow recall | suv get --prefix | One suv process per lookup | 5–8 ms p95 at 100k commands |
All figures in the table are from one Apple M4 Max (BENCHMARKS.md), one run each. 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; the harness is in the repository. Cold start, peak memory and the effect of concurrent writes are still unmeasured, and there is no comparison with another tool.
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.