Renders an AI coding agent's live session state in tmux's status bar: a pulsing light sweep while the agent is active, and statusline fields — cost, tokens, context burn-down, tool calls — refreshed by the host's own hook events. Works with Claude Code, Codex, opencode, and Hermes Agent.
Named for the lighthouse of Alexandria: a fixed beacon that makes what's
happening somewhere else visible from a distance, without you having to
go look. The tmux pulse is a literal sweeping beam signaling the agent is
active. The statusline is everything else worth a glance without
switching windows: cost, tokens, context burn-down, and whatever else a
plugin adds (see examples/guards.ts for a full worked example). Paired
with psyche, which injects the
agent's inner voice, pharos is what a human standing outside the agent
can actually see of it.
curl -fsSL https://raw.githubusercontent.com/ahokinson/pharos/develop/scripts/install.sh | shInstalls the latest release binary to ~/.local/bin/pharos (set
PHAROS_INSTALL_DIR to install elsewhere, or PHAROS_VERSION to pin a
specific tag). Prebuilt binaries cover macOS and Linux, arm64 and x64; see
Releases. To build from
source instead, see CONTRIBUTING.md.
Check pharos --version after installing, then generate the integration
artifacts for the harnesses you use:
pharos init --harness all --output ./pharos-initThe command never edits a harness configuration. It writes a small,
auditable bundle containing a Claude settings fragment, Codex hooks.json,
an OpenCode bridge, and a Hermes shell hook/config fragment, plus a manifest
with the exact target location and follow-up for each file. This makes it
safe for declarative configuration managers as well as manual installs.
Use --harness claude (or codex, opencode, hermes) to generate one
integration; output directories must not already exist unless --force is
given. Codex hook files must be reviewed with /hooks; Hermes shell hooks
must be approved and checked with hermes hooks doctor.
After deploying an artifact, run pharos tmux init from inside a tmux
session. It keeps your normal tab line, adds up to two lighthouse lanes, and
shows field rows only when the selected pane has emitted AI hooks.
One binary, four entry points, sharing color/palette code that today is duplicated across two zsh scripts against the same Catppuccin Frappe theme:
pharos tmux init— removes legacy Pharos status-bar formats and stale beams while leaving the user's regular tmux bar intact. The sidecard is now Pharos's only display surface.pharos tmux dispatch <state>— wired as several Claude Code hooks (PreToolUse/PostToolUse/UserPromptSubmit/Stop/Notification/ SessionStart/SessionEnd); flips tmux state to start or stop the pulse, captured on the hook-emitting pane as#{@pharos_pulse}.pharos tmux pulse <session> <token>— spawned detached viatmux run-shell -bbydispatch; a ~30fps session loop that animates up to two active-agent lanes (and summarizes overflow), independent of the agents' process trees.pharos tmux render— reads the host's JSON on stdin (the same payloaddispatchgets) and writes rendered field rows to the hook-emitting pane's#{@pharos_row1}and#{@pharos_row2}, marking that pane as an AI pane. The rows remain available after it becomes idle, until tmux closes the pane.#{@pharos_status}is the joined pre-3.4 fallback. Each row has its own status line and budgets the client width in full, so the lowest-priority field (seefieldSettings) is what surrenders space first — there's no clutter to trim by hand. It also publishes two markers on that pane for anything drawing outside it:#{@pharos_ai}, set for good the first time pharos renders there, and#{@pharos_pid}, the agent process behind the pane (resolved by walking up from the hook to the pane's own shell). Polling that pid is how an external card knows the agent is gone even when it crashed or was killed outright and no hook ever fired. It stays unset when the pane's command is the agent, since there the pane's own lifetime already says the same thing.pharos tmux pane <template> <source-pane>— continuously repaints a named ANSI template stored onsource-pane. It is intended as the command in a dedicated tmux pane; tmux layout policy remains the user's concern.pharos statusline scrape— wired into Claude Code'sstatusLineconfig, not a hook. No hook event ever carries cost, context-window size, or rate limits (verified against a live payload — see "Known gaps");statusLineis the only surface that does. This command reads that payload, persists what it finds, and prints nothing — Claude Code's own status line stays blank, since the tmux sidecard is the only surface anyone looks at.computeRowsfolds the result in as a source below an explicit hook field but above a mined-transcript guess.pharos list— prints every metric pharos knows about, built-in and plugin-loaded, with whether it's currently on. See "Discovering metrics" below.
tmux init is safe to run again: it only clears Pharos-owned historical
status-bar references.
For cost/context-window/rate-limit data on Claude Code, also add a
statusLine entry (a separate config key from hooks) alongside the hooks
above:
"statusLine": { "type": "command", "command": "pharos statusline scrape" }pharos can render for opencode instead of Claude
Code: pass --tool=opencode, or set "tool": "opencode" in config. The
opencode adapter reads opencode's own history DB
(${XDG_DATA_HOME:-~/.local/share}/opencode/opencode-stable.db; override
with PHAROS_OPENCODE_DB) rather than a JSONL transcript, and rebuilds its
numbers from aggregate reads on every render — SQLite rows mutate (tool
parts go running→error, token counts finalize as they stream), so a
read-once checkpoint would silently miss status changes. The DB schema is
internal to opencode and migration-owned; if a future migration breaks
mining, pharos fails open to an unenriched render rather than erroring.
opencode doesn't spawn hook processes itself; it exposes in-process plugin
events instead. Copy examples/opencode-bridge.ts
to ~/.config/opencode/plugins/ (or a project's .opencode/plugins/),
restart opencode, and the bridge maps opencode's plugin events onto pharos tmux dispatch/tmux render — the pulsing light while the agent works, and
the field rows on tmux's two pharos lines. A task-spawned subagent
session folds into the same totals as the main conversation, and opencode's
plan mode lights up the (opt-in) permission field the same way Claude
Code's does.
Hermes has shell hooks that run for both CLI and gateway sessions. The
generated hermes/pharos bridge maps its lifecycle events to Pharos's pulse
and renders after session and tool updates. The Hermes adapter reads
${PHAROS_HERMES_DB:-~/.hermes/state.db} on each render, using the observed
sessions, messages, and session_model_usage data to show model, token,
tool, error, cost, Git, and delegated-child totals. This is deliberately
best-effort: Hermes owns and migrates that database schema, so a missing or
changed database results in an unenriched card rather than a broken session.
Hosts render differently, and the differences are honest: a Claude Code
hook payload carries session_id/transcript_path but never cost,
rate limits, or the real context-window size — verified against a live
payload, not just docs; no hook event, from PreToolUse through
SessionEnd, carries them. Transcript mining still feeds tools, tokens,
tool errors, and permission. The one surface that does carry them is
statusLine, a separate Claude Code config key from hooks; see
pharos statusline scrape above — without that entry configured, Claude
Code sessions show an honest "unknown" placeholder for context usage and
no rate-limit meter at all, rather than a wrong number. Codex has the same
hook-payload story but a mining fallback for all three, plus a smaller
verified surface (see src/adapters/codex/session.ts). opencode gets the
full set, since its DB enriches by session id alone. And the bar only
refreshes while the agent is alive in the first place — its hooks fire
around the session's own events, so when it exits, the bar falls still.
The diff metric is mined from the session's own edit calls, not the
worktree: Claude Edit/MultiEdit/Write/ApplyPatch deltas, Codex and Hermes
edit-tool arguments, and opencode's per-message summary.diffs. When
nothing recoverable was found, Claude's host-reported total fills in; the
rest fall back to zero. opencode's session-level summary_* columns are
hard-zeroed since v1.16.0, so the per-message diffs are the surviving
source there. A bare Write counts every line as added (no baseline), so
overwritten files overcount. The worktreeDiff field is deliberately
separate: that one is the whole worktree's total from
git diff --numstat HEAD, staged and unstaged alike.
Repository fields — branch, remote, origin, worktree,
worktreeDiff — come from git itself rather than a host's transcript, so
they work under every harness. The directory is whichever the host
reported (Codex's payload, Hermes's database, the cwd Claude Code stamps
on each transcript line), falling back to the hook process's own working
directory, which is what covers opencode. When that directory isn't a git
worktree, or git doesn't answer, the fields stay unset and a card shows its
own placeholder rather than claiming the tree is clean. worktreeDiff is
silent when a worktree holds only untracked files, since those contribute
no line counts — the same "blank rather than a misleading zero" rule
diff, cost, and tokens follow.
Optional, at ${XDG_CONFIG_HOME:-$HOME/.config}/pharos/config.json. A
missing or invalid file falls back to the built-in defaults, so nothing
here is required. Every section is independently overridable; unset keys
keep their default.
{
"palette": { "green": "#a6d189" },
"fieldOrder": ["diff", "tools", "toolErrors", "cost", "tokens", "context", "model", "rate"],
"fieldSettings": { "diff": { "row": 1, "priority": 10 } },
"templates": {
"sidecard": {
"format": "ansi",
"lines": [
"⚓ Pharos · {{tool}} · {{state}}",
"{{#model}}Model {{{model}}}{{/model}}",
"{{#tokens}}Tokens {{{tokens}}}{{/tokens}}"
]
}
},
"widths": { "diff": 7, "tools": 15, "cost": 6, "tokens": 13 },
"metricStyle": {
"cost": {
"steps": [{ "at": 15, "color": "red" }, { "at": 5, "color": "peach" }, { "at": 1, "color": "yellow" }],
"base": "green"
},
"rate": { "warnAt": 80, "from": "green", "to": "red" },
"context": { "sparklineWindow": 8, "trendSlopeThreshold": 1000 },
"tools": {
"categoryOrder": ["agents", "reads", "searches", "web", "edits", "runs", "other"],
"glyphs": { "edits": "*" }
}
},
"context": { "sampleCap": 40 },
"pulse": {
"tail": 200, "stepMs": 33,
"themeVars": { "think": "@thm_blue", "tool": "@thm_lavender", "ask": "@thm_yellow", "background": "@thm_mantle" },
"fallbackColors": { "think": "#8caaee", "tool": "#babbf1", "ask": "#e5c890", "background": "#292c3c" }
},
"plugins": ["~/.config/pharos/plugins/example.ts"]
}palette: hex overrides for any named color; unnamed colors keep the Catppuccin Frappe default.fieldOrder: which metrics render, and in what left-to-right order. Omitting an id disables it entirely. Defaults to every built-in exceptpermission(see below), plus nothing from plugins until you list it.fieldSettings: per-metricrow(1 or 2) andpriority(dropped first when a row is too narrow to fit;priority >= 100is never dropped).templates: named, multi-line Mustache views.formatis"ansi"for a terminal pane or"tmux"for tmux format strings. The context suppliestool,state, and every configured metric. Use{{#tokens}}…{{/tokens}}to omit an unavailable field and{{{tokens}}}to preserve Pharos's colorized metric text. ExistingfieldOrder/fieldSettingsstatus rendering remains unchanged when no templates are configured.widths: a fixed visible-width to pad each metric to, so a shorter or absent value still holds its column;0(the default forcontext,permission,model,rate) means no padding.metricStyle: metric id → that metric's own style config (ramp thresholds, gradient endpoints, glyphs, whatever itsrenderfunction reads back viactx.style.settings(id, defaults)). Every built-in publishes its own shape here (runpharos listto see the ids). Which raw tool name falls into whichtoolsbucket (e.g.Edit/Write→edits) isn't configurable this way, since that mapping is Claude Code's own tool vocabulary and not user data; the bucket order and glyphs are (metricStyle.tools.categoryOrder/glyphs).toolErrors: silent unless a tool call actually failed this session (tool_result.is_errorin the transcript). Counts failures across the main transcript and any subagent ones: aTask-spawned agent gets its own transcript file, and its tool calls and token spend fold into the same totals as the main conversation's, since it's still work this session did (seesrc/session/mining.ts).permission: off by default, since Claude Code already surfaces the live permission mode itself and pharos repeating it just costs a column. Add"permission"tofieldOrderto turn it on. Once enabled it stays silent while the mode is"default"; otherwise it shows the mode by name, colored permetricStyle.permission.colors(bypassPermissionsred by default, since that's the mode where whatever external safety tooling you've wired up via a plugin is the only protection left; see "Example: cerberus" below).pulse.stepMs: milliseconds per animation frame (speed of the beam sweep).tail: how many hex shades the fade from accent to background is broken into.plugins: absolute paths (env-expanded,~/$VARwork) to plugin modules — see below.
A concrete worked example of what a plugin can do: examples/guards.ts
(shipped in this repo) rebuilds pharos's old built-in "guards" feature, a
shield glyph plus one severity-colored deny count per guard, as an ordinary
metric plugin with nothing pharos-specific beyond compute/render and
ctx.style/ctx.process. Point plugins at it directly, or copy it as a
starting point for your own guard tool:
{ "plugins": ["/path/to/pharos/examples/guards.ts"] }Its defaults already wire up cerberus,
a three-headed PreToolUse guard whose heads are risk (command-pattern
scanning), policy (policy evaluation), and judgement (scripted
situational checks like git safety), with nothing further to configure. A
few things about that mapping aren't obvious from the plugin's shape alone:
- The guard ids are cerberus's head names verbatim. Its violations file is
keyed the same way (
risk=N,policy=N,judgement=N), which is what lets each count land in the right place. Renaming an id here without renaming it there means that count silently reads zero forever. - The counts carry no glyph of their own. Position within
orderand the guard'scolorare what tell them apart, so they read as one group under the shield instead of three separate widgets. binaryis"cerberus"for all three guards rather than"tirith"or"cupcake". A singlecerberus guardhook runs all three heads, socerberuson PATH is what every cell actually needs.requirementscovers what each head wraps underneath:tirith, andcupcake/opa, are separate binaries cerberus shells out to, whilejudgementruns in-process with nothing external to require.- The default
degradedSentinel($XDG_STATE_HOME/guard/degraded) already matches wherecerberus healthwrites it atSessionStart. That check is a real canary rather than a presence test: it feeds a known-dangerous command through each enabled head and confirms it actually comes back denied. So a degraded cell here means the guard truly isn't enforcing, not merely that a binary is missing.
To wire up a different guard tool, override metricStyle.guards in your
own config (shieldGlyph, degradedSentinel, order, definitions,
same shape with your own values), or fork examples/guards.ts outright.
Everything above configures pharos's own built-in metrics. Plugins are for
new behavior: a metric pharos doesn't compute, styled however you like.
There's no separate concept for anything more elaborate — examples/guards.ts
(see above) is a full worked example of building something as involved as
a multi-count health-check shield entirely as an ordinary plugin. Each path
in plugins is dynamically imported at startup (pharos list and pharos tmux render only, not the tmux pulse); a plugin that fails to import, or
throws at render time, is skipped and never breaks the statusline.
Trust model: a plugin path is your own code, loaded and run with
pharos's own privileges. That's the same trust boundary as a shell rc
file or tmux config, not a sandboxed extension format. Only point
plugins at files you wrote or trust.
A plugin's default export matches this shape. No import needed: pharos
isn't published, so a plugin just duck-types it. Palette colors, ramps,
gradients, humanized numbers, and external-tool health checks all arrive
through ctx, so a plugin never has to import pharos internals or
hand-roll PATH scanning to style or check its output:
// ~/.config/pharos/plugins/example.ts
export default {
metrics: [
{
id: "greeting", // add this id to fieldOrder to show it
label: "Greeting", // shown by `pharos list`; defaults to id
row: 1, // defaults used only if fieldSettings/widths
priority: 15, // don't already configure this id
styleDefaults: { color: "teal" }, // this metric's own style knobs
compute: (ctx) => ctx.session.model,
render: (model, ctx) => {
const { color } = ctx.style.settings("greeting", { color: "teal" });
return `${ctx.style.color(color)}hi, ${model}`;
},
},
],
};compute(ctx) receives the same context every built-in metric does:
ctx.session, ctx.mined (tool histogram, token counts, context
samples), ctx.onPlan, ctx.nowEpoch, ctx.config, ctx.style, and
ctx.process. It returns whatever raw value render(value, ctx) needs to
produce the field's text (plain or ANSI-colored), or null to hide it for
this render.
ctx.style is the coloring/formatting toolkit every built-in uses:
ramp/lerp/gradient (palette-key-based coloring), sparkline/trend
(context-style burn-down helpers), countdown/humanize, and
settings(id, defaults), which reads this metric's own slice of
metricStyle, shallow-merged over defaults.
ctx.process covers external-tool liveness: the exact checks pharos's
own guards feature used to have, now available to any plugin.
commandExists(bin) checks whether it's on PATH; checkHealth(binary, requirements?, sentinel?) returns absent/degraded/healthy from binary
presence plus optional path/binary requirements plus an optional shared
kill-switch sentinel file. See examples/guards.ts for both in real use.
A plugin id matching a built-in one (e.g. "cost") shadows it entirely,
letting a plugin replace built-in behavior, not just add to it.
pharos list prints every metric pharos knows about, built-in and loaded
from plugins, with its row/priority, whether it's currently in
fieldOrder, and where it came from. pharos list --json gives the same
data as JSON, for scripting your own config.
Fully implemented, tested (bun test), and running as the live daily
tmux render/hook commands. CI runs typecheck and tests on every PR; tagged
releases publish prebuilt binaries (see Installation).
See CONTRIBUTING.md for build and test tooling.