A command-line console for OpenFox: inspect, diagnose, maintain and act on sessions without going through the web UI.
ofx reads OpenFox's SQLite event log directly (read-only), and uses OpenFox's own HTTP
API for anything that writes. Nothing about it is specific to one incident or one
instance: it is the operator console for any OpenFox installation, and it keeps working
when the web UI does not — a runaway session, a stalled state load, an unreachable API.
| Need | Commands |
|---|---|
| Health check, on a terminal or from cron | status (informational), diagnose / watch — exit 0/1/2 = ok/warn/critical |
| Session inventory: which session holds what | list, list --all |
| See what is running right now, and where it stands | live |
| Explain where the time and the megabytes go | inspect, plan |
| Steer a session without the UI | stop, pause, resume, continue |
| Create, remove, archive, migrate a session | new, rm, export, import |
| Database care: coherent backup, offline VACUUM | backup, vacuum |
| Service lifecycle and logs | service start|stop|restart|status|install|upgrade, logs [-f] |
| Keep the numbers | redirect any command to a file — see Your own reports and diagnostics |
Every command is a plain stdout writer with a documented exit code, and all the read-only
ones need no credentials, so ofx composes with your shell, your cron and your monitoring
instead of replacing them.
OpenFox stores every session as an append-only event log in SQLite. Two design properties make that log grow far faster than the work it describes:
tool.preparingis written once per streamed fragment, each carrying the accumulated JSON arguments. So a tool call whose arguments end up 29 KB long emits thousands of events whose payloads are progressively longer prefixes of the same string. The write cost is quadratic in the argument size.turn.snapshotis only written when a whole turn resolves. A turn that runs for hours writes no snapshot and triggers no cleanup for its entire duration.
The read side then turns that into a stall. getEvents() is called without a
fromSeq bound, so it re-reads the entire journal; and because better-sqlite3 is
synchronous, that read blocks the main thread — including the HTTP server.
Measured on a real incident (OpenFox 2.0.160, one session):
| Metric | Value |
|---|---|
| Session event log | 111,808 events / 210 MB, a single turn.snapshot |
| Cost of one state load | 876 ms, re-reading 203 MB |
getLatestSnapshot() |
~32–45 ms, walking 83,337 index entries to find one row |
getEvents() with fromSeq vs without |
747 ms vs 877 ms — the snapshot was too old to help |
tool.preparing events for 243 real tool calls |
59,913 — a 864× inflation, 203 MB of the 210 MB |
| Fragments written for a single tool call | 6,473 — 84 MB stored where 28 KB would do |
These numbers are why ofx reports lag — the bytes accumulated since the last
snapshot — rather than counting events, and why inspect measures the real state-load
cost instead of inferring it. The measurements come from one instance (see Background);
the mechanism is the same on every OpenFox installation, and it is what the tool is built
to make visible, prove with numbers, and act on safely.
- Linux (reads
/proc, usessystemctlfor the service lifecycle) - Node.js — the same interpreter that runs OpenFox.
better-sqlite3is a native module, so an ABI mismatch makesrequire()fail. Theofxlauncher finds the right interpreter automatically from the running OpenFox process. - OpenFox installed at
/opt/openfox(the default; overridable viaOPENFOX_DIR) - Optionally: a
systemdunit namedopenfox
ofx has no npm dependencies. It reuses the better-sqlite3 that OpenFox already
ships.
git clone <this-repo> ~/projects/ofx
cd ~/projects/ofx
./install.shinstall.sh symlinks ofx into a directory on your PATH (default ~/.local/bin),
checks that the native module resolves, runs a smoke test, and explains how to
authenticate. It is idempotent and does not copy files.
./install.sh --prefix /usr/local/bin # install elsewhere
./install.sh --uninstall # remove the symlinkofx status # service, process, API, database — one screen
ofx list # sessions, sorted by snapshot lag
ofx inspect --hot # the worst session, in detail
ofx plan --hot # what a compaction would do (dry-run, changes nothing)Everything above is read-only and needs no credentials.
To act on a session, authenticate once:
ofx auth login # masked password prompt -> token stored with mode 600ofx stop --hot # stop the running turn of the worst session
ofx export --hot # archive it to a file first
ofx compact --hot --apply --restart| Command | What it does |
|---|---|
status |
systemd unit, processes, API reachability, DB size/WAL/holders, session lag |
list [--all] |
sessions with event count, payload size, snapshot count, lag |
inspect <id|--hot> [--fast] |
per-type breakdown, snapshot positions, real state-load cost |
plan <id|--hot> |
dry-run of a consolidation: eligibility, what survives, what is lost |
live [--interval N] [--min N] |
live dashboard of running sessions: step, elapsed time, token speed, context, lag |
watch |
live journal growth rate |
diagnose |
full health report with thresholds, exit 0/1/2 |
backup [--out DIR] [--keep N] |
coherent DB backup via SQLite's online backup API |
vacuum [--dry-run] [--yes] |
checkpoint + VACUUM (server must be stopped) |
ofx live draws the sessions that are running right now — one card each: workflow
step, elapsed time, context occupancy, token speed, tokens used and snapshot lag —
and, when fewer than two are active, fills the screen with up to two of the most
recently finished sessions (duration, final step, final context size, outcome).
ofx live # every 2 s, until Ctrl+C
ofx live --interval 1 # faster samples
ofx live --min 0 # running sessions only
ofx live --items # expand criteria and findings, one line each
ofx live --once # one screen, for a logEverything is read-only, and the database connection is opened and closed on every
sample: a dashboard left open for hours never pins a WAL read snapshot, so it never
delays a checkpoint. A sample was measured at 2–10 ms on a 1.4 GB database — the
lag is accounted incrementally and turn.snapshot payloads are never parsed in the
loop.
The frame follows the terminal width — there is no upper cap, so a wide terminal gets
a wide dashboard: the RECENT table gains columns and a wider STEP. The width is
re-read on every sample, so resizing the window is picked up on the next one, and 60
columns is the floor below which the cards cannot stay readable.
A session blocked on a human is the one thing the dashboard must not let you miss: it
is listed first, its rail and badge turn red, the glyph alternates between ◆
and ◇ every 500 ms, and the second line says what is awaited. The pulse is bounded to
the first minute — an older wait stays red and static, so an abandoned question (or a
session whose process died while a question was pending) cannot animate the screen
forever, and the waiting Ns counter keeps counting between two samples.
▌ ◆ WAITING 3f2c1a9e Fix the login redirect
▌ Build & Verify › Implement · build waiting 12s · question
▌ ◆ WAITING 7b4d0c21 Add the CSV export
▌ Build & Verify › Finalize · verification waiting 4s · access
| Last event | Meaning |
|---|---|
chat.done with reason waiting_for_user |
a question is pending — OpenFox writes it immediately after chat.ask_user, so this is the state that is actually observable |
chat.ask_user |
a question is pending, in the instant before that chat.done |
path.confirmation_pending |
an access request on a path, waiting for approval |
path.confirmation_responded |
answered — no longer waiting |
The blink redraws the frame already in memory: the sample cadence, and the single
database open/close per sample, are unchanged. It is disabled when stdout is not a
color terminal (--no-color, NO_COLOR, redirected output), which always gets the
"on" phase — a logged sample stays stable and grep-able.
OpenFox writes the build acceptance criteria and the review findings as metadata.set
events (keys criteria and review_findings), rewriting the whole list each time one
point changes. They survive cleanupOldEvents(), so they are visible for finished
sessions too. ofx live shows one glyph per point, i.e. the list exactly as it is
validated, one item at a time:
▌ criteria ✓✓◐✗··✓◐ 4/8 done 1 failed · 2 in progress · 3 passed · 2 pending
▌ review ✓○– 2/3 done 1 open · 1 resolved · 1 dismissed
| Status | Glyph | Meaning |
|---|---|---|
pending |
· |
not started |
completed |
◐ |
implemented, not verified yet |
passed / failed |
✓ / ✗ |
verified during the review |
open |
○ |
review finding not addressed |
resolved / dismissed |
✓ / – |
finding closed |
ofx live --items expands each list, one line per point with its id and description.
The finished-session table gets a CHECKS column (criteria · findings ratios) when
the terminal is wide enough.
Two limits come from OpenFox's own data model, and ofx live states them instead of
inventing numbers: sessions.total_tokens_used is not maintained (0 on every
session) and cleanupOldEvents() purges the journal of a finished session. Token
totals for sessions that already ended are therefore shown as -. What survives is
the final context size (turn.snapshot.contextState), the workflow step, the
duration and the outcome.
| Command | Endpoint |
|---|---|
stop <id> |
POST /api/sessions/:id/stop |
pause <id> / resume <id> |
POST /api/sessions/:id/pause / resume |
continue <id> |
POST /api/sessions/:id/continue |
export <id> [-o FILE] |
GET /api/sessions/:id/export — writes nothing to the session |
import <FILE> --project <id> |
POST /api/sessions/import — creates a new session |
new --project <id> [--title T] |
POST /api/sessions |
rm <id> [--yes] |
DELETE /api/sessions/:id — definitive |
An export contains everything needed to recreate the session: the source metadata, the
cached layout, the messages, and the raw event log. ofx export is read-only;
ofx import creates a new session and leaves the original alone.
The import path has a ceiling. OpenFox caps HTTP request bodies at 75 MB:
app.use(express.json({ limit: "75mb" })); // dist/chunk-2J6OW67H.js:8608A session whose export exceeds that can be exported but not re-imported. ofx import
measures the file before sending and refuses with an explanation, rather than letting the
server fail with a bare HTTP 413.
On the instance this tool came from, one session reached 80,367 events / 64.5 MB and produced a 76.3 MB export — already past the limit, and still growing. An archive taken too late is a one-way file. Export while the session is still small, and keep an eye on
ofx list.
| Command | What it does |
|---|---|
auth login |
masked password prompt, exchanges it for a token, verifies it, stores it 600 |
auth set-token |
paste an existing token |
auth status |
which credential source would be used, and under what permissions |
auth check |
verify the credentials against the API |
auth clear |
delete the stored token |
Resolution order: OPENFOX_TOKEN → token file → OPENFOX_PASSWORD.
| Command | What it does |
|---|---|
service start|stop|restart|status |
drive the openfox systemd unit |
service install [version] |
destructive — rm -rf /opt/openfox then npm install -g |
service upgrade |
destructive — stop, reinstall, start |
logs [N] [-f] |
journalctl -u openfox, falling back to the log file |
service install and service upgrade execute a sudo rm -rf on the install
directory. They require an interactive confirmation that cannot be bypassed, and
install refuses to run while the service is active.
ofx compact <id> # dry-run: exactly like `plan`
ofx compact <id> --apply # backup, then stop the session via the API
ofx compact <id> --apply --restart # …then restart the serviceofx does not implement compaction. Consolidation is OpenFox's own code
(EventStore.consolidateSession) and runs automatically at server startup. Replaying
its fold from the outside would risk corrupting a session. ofx verifies eligibility
and orchestrates the restart instead.
Three independent tiers, in increasing order of consequence:
1. Read-only. list, inspect, plan, status, backup, watch, live,
diagnose open the database with readonly: true and PRAGMA query_only = true.
No writes, no locks held, no kill.
2. Writes through the API. stop, pause, resume, continue, new, rm,
export. OpenFox keeps snapshotCache and promptsCache in memory and only
invalidates them on writes that pass through it. Writing to SQLite from outside would
leave those caches stale — which is precisely the failure mode of the original
incident. So ofx never does that.
3. Offline writes. Only compact --apply, and only after a mandatory backup that
aborts the whole operation if it fails.
Additional guarantees:
compactruns its pre-checks before the backup, so it never writes a 700 MB backup for an operation that cannot proceed.auth loginverifies the token against the API before writing it to disk. A stored token that does not work fails later and elsewhere.- The token file is created via
O_EXCLwith mode600and then renamed, so it is never readable by anyone else, not even momentarily. - Masked prompts read in raw terminal mode and refuse to run when stdin is not a TTY. A secret cannot be piped in.
ofx reads with set-based queries instead of per-session correlated subqueries: on a
232-session database, list issues a handful of grouped queries against events, not
232 × N lookups. And it only ever reads the columns it needs.
The measurement that matters is not ofx's own speed, though — it is that the numbers
it prints explain a 210 MB regression that was invisible from inside the UI.
ofx message.POST /api/sessions/:id/messagecallsqueueMessage(), which only pushes into an in-memoryMap. Nothing starts a turn from HTTP:/chatand/continueare validation stubs that return{accepted:true}without doing anything. Turns are started by a WebSocket client message. Amessagecommand over HTTP would queue into a structure nothing drains, and reportsuccess: truewhile doing nothing. Shipping that would be worse than not shipping it.POST /api/sessions/:id/truncate. This cuts the conversation withdeleteEventsAfterSeq. It is not a compaction, and it is destructive. Use the UI.- Provider, MCP and config endpoints. These are settings. They belong in the UI, and exposing them on the command line multiplies the ways to break a working setup.
ofx was written while working a real incident — an OpenFox instance pinned a CPU core
for 9h30 and became unstoppable from its own UI. That incident shaped the tool's
non-negotiables (read-only by default, pre-checks before expensive writes, no compaction
of its own, no write that bypasses OpenFox's in-memory caches), but it is not the
scope: the same commands are what you use to keep an instance healthy, to size it, to
archive or migrate a session, and to answer "why is this disk filling up". The numbers in
Why the event log gets out of hand were measured on that instance; ofx inspect and
ofx diagnose are what made them visible in the first place.
The full analysis — incident narrative, bug report, prevention plan, maintenance runbook
and the scripts that regenerate those numbers — is not part of this repository. It
lives in docs/, which .gitignore excludes on purpose: it describes one specific
instance (host metrics, session sizes, project names, operator notes), and it stays on
the machine that produced it.
This repository ships the tool only — no instance data, no logs, no reports. Anything
you measure is yours, and the git-ignored docs/ directory is the natural place to keep
it:
mkdir -p docs
ofx status > docs/status-$(date +%F).txt
ofx list > docs/sessions-$(date +%F).txt
ofx inspect --hot --fast > docs/hot-session-$(date +%F).txt
ofx live --once > docs/live-$(date +%F-%H%M).txt
ofx diagnose > docs/health-$(date +%F).txt
ofx watch --samples 10 --interval 30 > docs/growth-$(date +%F).txtEvery command writes plain lines to stdout and gates its colors on isatty, so the
output is safe to redirect to a file or to pipe into another tool.
ofx diagnose doubles as a monitoring probe: exit 0 = healthy, 1 = a warning
threshold is crossed, 2 = critical — ofx diagnose >/dev/null; echo $?.
Because docs/ is ignored, a report can never be committed by accident. That matters:
a report quotes session titles, sizes and paths.
MIT — see LICENSE.