Skip to content

Repository files navigation

pharos

CI

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.

Philosophy

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.

Installation

curl -fsSL https://raw.githubusercontent.com/ahokinson/pharos/develop/scripts/install.sh | sh

Installs 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-init

The 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.

Design

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 via tmux run-shell -b by dispatch; 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 payload dispatch gets) 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 (see fieldSettings) 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 on source-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's statusLine config, not a hook. No hook event ever carries cost, context-window size, or rate limits (verified against a live payload — see "Known gaps"); statusLine is 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. computeRows folds 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" }

Rendering for opencode

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.

Rendering for Hermes

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.

Known gaps

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.

Configuration

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 except permission (see below), plus nothing from plugins until you list it.
  • fieldSettings: per-metric row (1 or 2) and priority (dropped first when a row is too narrow to fit; priority >= 100 is never dropped).
  • templates: named, multi-line Mustache views. format is "ansi" for a terminal pane or "tmux" for tmux format strings. The context supplies tool, state, and every configured metric. Use {{#tokens}}…{{/tokens}} to omit an unavailable field and {{{tokens}}} to preserve Pharos's colorized metric text. Existing fieldOrder/fieldSettings status 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 for context, permission, model, rate) means no padding.
  • metricStyle: metric id → that metric's own style config (ramp thresholds, gradient endpoints, glyphs, whatever its render function reads back via ctx.style.settings(id, defaults)). Every built-in publishes its own shape here (run pharos list to see the ids). Which raw tool name falls into which tools bucket (e.g. Edit/Writeedits) 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_error in the transcript). Counts failures across the main transcript and any subagent ones: a Task-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 (see src/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" to fieldOrder to turn it on. Once enabled it stays silent while the mode is "default"; otherwise it shows the mode by name, colored per metricStyle.permission.colors (bypassPermissions red 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, ~/$VAR work) to plugin modules — see below.

Example: cerberus

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 order and the guard's color are what tell them apart, so they read as one group under the shield instead of three separate widgets.
  • binary is "cerberus" for all three guards rather than "tirith" or "cupcake". A single cerberus guard hook runs all three heads, so cerberus on PATH is what every cell actually needs. requirements covers what each head wraps underneath: tirith, and cupcake/opa, are separate binaries cerberus shells out to, while judgement runs in-process with nothing external to require.
  • The default degradedSentinel ($XDG_STATE_HOME/guard/degraded) already matches where cerberus health writes it at SessionStart. 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.

Writing a plugin

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.

Discovering metrics

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.

Status

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.

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages