# agent-status
A small Rust CLI + library that shows in tmux's `status-right` which AI coding
agent sessions are waiting on user input. Supports [Claude Code], [pi], [omp], and
[opencode]; the architecture is set up to plug in additional agents (Codex CLI,
Cursor CLI) without restructuring.
```text
$ agent-status status # one session waiting
[!] agent-status
$ agent-status status # multiple sessions waiting
[!] 3 projects waiting
$ agent-status status # nothing waiting
# (no output, exit 0)
```
## How it works
Claude Code [hooks][hooks] fire `agent-status set notify` on `Notification` and
`PermissionRequest`, `set done` on `Stop`, `set working` on `UserPromptSubmit`
and `PreToolUse`, `set idle` on `SessionStart`, and `clear` on `SessionEnd`.
Each `set` writes one JSON file per session under
`${XDG_RUNTIME_DIR:-/tmp}/agent-status/`, keyed by `session_id`. tmux's
`status-right` invokes `agent-status status` on its refresh interval; the
command lists the state directory and renders the indicator (project name for
one waiting session, count for many, nothing for none).
No daemon. The filesystem is the state store; each session writes only its own
keyed file, so concurrent writers never contend.
## Install
```sh
cargo install agent-status
```
The binary lands in `~/.cargo/bin`. As long as that's on your `$PATH` (Cargo's
installer adds it by default), the alias-based wiring below works without any
further configuration. The binary is around 550 KB stripped and has no runtime
dependencies (tmux is invoked best-effort to refresh the status bar; if it isn't
running, the failure is silenced).
For the popup picker, also install the companion crate:
```sh
cargo install agent-switcher
```
## Configure
### Claude Code
Drop this alias into your shell rc (`.zshrc`, `.bashrc`, etc.):
```sh
alias claude='claude --settings "$(agent-status agent-extension --agent claude-code)"'
```
That's it. Each time you run `claude`, the alias expands so claude launches with
`--settings <generated.json>` — a seven-hook file that `agent-status`
regenerates on every invocation from its own absolute path. Claude Code merges
`--settings` on top of your user/project settings, so nothing you've already
configured gets overwritten.
The generated file lives at
`${XDG_RUNTIME_DIR:-/tmp}/agent-status/extensions/claude-code.json` and is
rewritten every run — no cleanup, no env vars, no PATH manipulation.
#### Wiring the hooks manually (fallback)
If you'd rather see the hooks in your settings file, skip the alias and merge
this into the top-level `hooks` block of `~/.claude/settings.json` instead:
```json
{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "agent-status set --agent claude-code notify"
}
]
}
],
"PermissionRequest": [
{
"hooks": [
{
"type": "command",
"command": "agent-status set --agent claude-code notify"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "agent-status set --agent claude-code done"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "agent-status set --agent claude-code working"
}
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "agent-status set --agent claude-code working"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "agent-status set --agent claude-code idle"
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "agent-status clear --agent claude-code"
}
]
}
]
}
}
```
Pick one route, not both. If you have manual hooks AND the alias, each event
fires twice — idempotent for repeats of the same `set` but wasteful.
`SessionStart` writes a placeholder `idle` row so every Claude session appears
in the [`agent-switcher`][switcher] popup from the moment it starts — even
before you type the first prompt. `UserPromptSubmit` and `PreToolUse` then flip
the row to `working` while Claude is mid-turn. The tmux status indicator filters
both `idle` and `working` out, so the bar still only shows "needs you now"
sessions (`notify` from `Notification`/`PermissionRequest`, `done` from `Stop`).
The switcher shows every row, rendering `idle` as a dim dot and `working` as a
spinner.
### pi
Drop this alias into your shell rc (`.zshrc`, `.bashrc`, etc.):
```sh
alias pi='pi -e "$(agent-status agent-extension --agent pi)"'
```
Each `pi` invocation regenerates
`${XDG_RUNTIME_DIR:-/tmp}/agent-status/extensions/pi.ts` with the
absolute path to the current `agent-status` binary baked into the bridge's `BIN`
constant. pi's `-e <path>` flag loads the file as a one-shot extension,
alongside whatever else you have under `~/.pi/agent/extensions/`.
The extension fires on these pi lifecycle events:
| `session_start` | `set idle` (session visible from launch) |
| `before_agent_start` | `set working` (activity = first line of the prompt) |
| `tool_execution_start` | `set working`, or `set notify` for the `ask` tool and for tools pi will show an approval dialog for |
| `tool_call` | `set working` (fires at approval-granted; skipped for `ask`) |
| `tool_execution_end` | `set working` (returns from `notify` once answered/denied) |
| `agent_end` | `set done` (activity = assistant's last message) |
| `session_switch` / `session_branch` | `clear` the previous session's row, `set idle` for the new one |
| `session_shutdown` | `clear` (removes the row) |
**Notify semantics:** pi has no shell hook for "agent paused for permission",
but the bridge reconstructs it from two sources: the built-in `ask` tool
(always `notify`, with the question as the activity message) and a prediction
of pi's tool-approval dialog read from `~/.pi/agent/settings.json`
(`tools.approvalMode`: `always-ask` / `write` / `yolo`, plus per-tool
`tools.approval.<tool>` overrides). In the default `yolo` mode only `ask`
produces `notify`. The `write`-mode prediction uses a built-in read-tier
list (`read`, `search`, `find`) and errs toward over-reporting for unknown
tools.
### omp (oh-my-pi)
[omp] is a coding-first fork of pi and keeps pi's per-launch `-e <file>`
extension flag, so the same alias pattern applies. Drop this into your shell rc:
```sh
alias omp='omp -e "$(agent-status agent-extension --agent oh-my-pi)"'
```
Each `omp` invocation regenerates
`${XDG_RUNTIME_DIR:-/tmp}/agent-status/extensions/oh-my-pi.ts` with the
absolute path to the current `agent-status` binary baked into the bridge's `BIN`
constant, alongside whatever else you have under
`~/.omp/agent/extensions/`.
The extension fires on these omp lifecycle events:
| `session_start` | `set --agent oh-my-pi idle` |
| `session_switch`, `session_branch` | `clear` for the previous session id, then `set idle` for the new one |
| `before_agent_start` | `set --agent oh-my-pi working` (activity = first line of prompt) |
| `tool_execution_start` | `set --agent oh-my-pi working` (activity string); `ask` tool → `notify` |
| `tool_execution_end` | `set --agent oh-my-pi working` — `ask` tool only (closes the gap) |
| `agent_end` | `set --agent oh-my-pi done` (activity = assistant's last text) |
| `session_shutdown` | `clear --agent oh-my-pi` |
omp's built-in `ask` tool — the agent explicitly asking you a question — is
its canonical "waiting for your input" signal and maps to `notify`, so omp
sessions get the full Claude Code state vocabulary (idle / working / notify /
done). `tool_execution_end → working` fires for the `ask` tool only: omp
emits no event when you answer an `ask` prompt, so the end of the `ask`
tool's execution is what flips the row out of `notify`. Other tools
deliberately skip it — every `set` overwrites the activity message, and omp
spends most wall-clock thinking between fast tool calls, so wiping on every
tool end (Claude Code's `PostToolUse` behavior) would leave the Activity
column blank almost always. Skipping it keeps the last tool's activity
visible through the thinking gap, like the pi bridge.
**Known limitation:** omp's tool-approval gate (`tools.approvalMode:
always-ask` or `write`) runs before any extension event fires, so
approval prompts can't be surfaced as `notify`. omp's default mode is `yolo`
(no approval prompts), which makes this gap mostly theoretical; if you run a
prompting approval mode, the row stays at `working` while an approval dialog
is open.
### opencode
opencode discovers plugins from `~/.config/opencode/plugins/` (global) or
`.opencode/plugins/` (per-project) at startup — there's no per-launch extension
flag, so the alias pattern can't apply. Generate the plugin and copy it once:
```sh
mkdir -p ~/.config/opencode/plugins
cp "$(agent-status agent-extension --agent opencode)" ~/.config/opencode/plugins/agent-status.ts
```
`agent-status agent-extension --agent opencode` writes a regenerated
`${XDG_RUNTIME_DIR:-/tmp}/agent-status/extensions/opencode.ts` with the absolute
path to the current `agent-status` binary baked in, and prints that path. The
`cp` lands a fresh copy in opencode's discovery directory. Re-run this whenever
you move or rebuild the `agent-status` binary so the baked-in path stays
correct.
The plugin fires on these opencode events:
| `session.idle` | `set --agent opencode done` (agent finished a turn) |
| `permission.updated` | `set --agent opencode notify` (agent paused for permission) |
| `session.created` | `clear --agent opencode` |
| `session.deleted` | `clear --agent opencode` |
In practice opencode persists sessions for resume, so `session.deleted` rarely
fires on graceful exit — the `clear` arm is defensive. `session.created`
likewise fires once at the start of each new session and resolves to a no-op
clear (no state file to remove yet); it exists so a stale state file from a
previous crash gets dropped at session start.
Unlike pi, opencode emits a `permission.updated` event when an agent pauses for
a permission prompt, so opencode supports both `notify` and `done` indicator
states (full feature parity with Claude Code). The one wart: opencode has no
event for "user submitted a prompt", so after a turn ends the indicator stays on
`done` while the user types the next prompt — by design, since the session _is_
the one that needs your attention.
### tmux (`~/.tmux.conf`)
Drop `#(agent-status status)` into your existing `status-right` wherever you
want the indicator to appear, and lower the refresh interval so updates feel
snappy:
```tmux
set -g status-interval 5
set -g status-right "#(agent-status status) <your existing status-right here>"
```
Reload with `tmux source-file ~/.tmux.conf`.
For the popup picker (prefix + `C-a`), see the [`agent-switcher`][switcher]
crate.
## Usage
```sh
agent-status --help # top-level help
agent-status set [EVENT] --agent NAME # mark this session as waiting (reads JSON on stdin)
agent-status clear --agent NAME # clear this session's state (reads JSON on stdin)
agent-status status # print the status-right line, empty if nothing waiting
agent-status list # print TSV (session_id, pane, display) for every session; marker reflects state
```
`set` and `clear` expect a JSON object on stdin with at least
`{"session_id": "..."}`. Empty or missing `session_id` is a silent no-op.
## State location
`${XDG_RUNTIME_DIR:-/tmp}/agent-status/<session_id>` — one file per active
session. Inspectable with `ls`/`cat`. Format:
```json
{
"agent": "claude-code",
"project": "agent-status",
"cwd": "/path/to/project",
"event": "notify",
"tmux_pane": "%17",
"ts": 1778163565,
"message": "Permission required",
"pid": 12345
}
```
The `message` field is optional and only present when the agent's hook payload
supplies one (e.g. Claude Code's `Notification` event). Older state files
written before this field existed still load — `message` defaults to absent.
The `agent` field is `"claude-code"`, `"oh-my-pi"`, `"opencode"`, or `"pi"`; new
agents use their own lowercase-hyphenated name.
The `pid` field records the agent process's PID so `agent-status status` and
`list` can detect and remove entries whose owning process has died without
firing its session-end hook. Files written by older binaries — which lack `pid`
— are never auto-pruned; they age out only on tmpfs cleanup. Such entries should
disappear naturally after one `set`/`clear` cycle on the affected session.
## Caveats
- **The `Stop` hook fires on every turn end**, so any session that just finished
a response shows up as "waiting" until you send the next prompt. Intentional —
the whole point is to know which session needs you while you're heads-down
elsewhere. Drop the `Stop` line from `settings.json` if it proves too eager.
- **opencode has no "user submitted a prompt" event**, so once `session.idle`
marks an opencode session `done`, the indicator stays on `done` while you type
the next prompt and only refreshes its timestamp on the next idle. Same intent
as the `Stop` caveat above — the session _is_ the one waiting on you.
- **Architecture-specific binary.** The compiled binary is platform-locked. On a
new machine, rebuild from source (`cargo install agent-status`).
- **opencode does not record a `working` state** today. opencode sessions appear
in the switcher when they're waiting (`done` / `notify`) but not while they're
mid-turn. Claude Code, pi, and omp all record `working` and `idle`.
## License
MIT. See [LICENSE](LICENSE).
[hooks]: https://docs.claude.com/en/docs/claude-code/hooks
[Claude Code]: https://claude.com/claude-code
[pi]: https://pi.dev
[omp]: https://omp.sh
[opencode]: https://opencode.ai
[switcher]: https://crates.io/crates/agent-switcher