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.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. Reproducible benchmark results — with hardware, shell, history size, and methodology — will be published, and this article updated when they are.
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.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() {
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. We have not yet published reproducible measurements of it, so we do not quote a figure here.
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 loads up to the newest 5,000 entries that pass the active filters, then matches and ranks your words in memory. Every typed word must appear literally, and results are ordered by match tier and a boosted matcher score.
The second path has a known limitation in 0.4.1: an older match outside the newest 5,000 eligible entries can be missing from typed results. The search documentation explains the workarounds; a fix is planned. For that reason, fast typed search on a large history is not evidence that every match was considered. We will measure result completeness alongside latency when we publish benchmarks.
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.