# cekanje
Croatian *čekanje* — "waiting".
A tiny Rust daemon that tracks Claude Code sessions inside tmux, surfaces "needs attention" with a native macOS / Linux notification, and offers an fzf-style popup picker bound to a tmux key. Visiting the pane clears the notification automatically.
The binary installs as `cekanje`, with `cek` as a shorter symlink — both names are interchangeable everywhere below.
## Why
Multiple parallel Claude sessions across tmux windows are easy to lose track of. cekanje pairs each session to its tmux pane via Claude Code hooks, marks sessions Waiting on `Notification` / `Stop` events, and pops a desktop notification. From inside tmux, a key binding opens a picker; selection switches you straight to the pane, and the focus-in hook clears the Waiting flag.
## Architecture
- `cek serve` — axum HTTP daemon on `127.0.0.1:8731`. State lives in RAM. Auto-exits after configurable idle (default 30 min, zero sessions).
- Claude Code's HTTP hooks POST event JSON; the daemon reads `X-Tmux-Pane` and `X-Tmux-Socket` headers (forwarded from `$TMUX_PANE` / `$TMUX`) to bind the session to a pane.
- On a Notification or Stop, if the pane is *not* the currently focused pane on any attached client, mark Waiting + fire a native notification. If the pane is focused, treat as Working (no badge bump).
- `cek menu` queries `/list`, pipes through `fzf`, and on selection runs `tmux switch-client` + `select-pane`.
- `cek visit <pane>` clears Waiting; wired to the tmux `pane-focus-in` hook.
```
┌──────────────────┐ HTTP POST ┌──────────────────────┐
│ Claude hooks │─────────────▶│ cek serve │
│ (HTTP, env-var │ events + │ axum on 127.0.0.1 │
│ headers) │ pane meta │ state in RAM │
└──────────────────┘ │ notify-rust on event│
└──────┬───────────────┘
┌──────────────────┐ HTTP │ │
│ cek status │◀─────────────┤ │ shells out
│ cek list │ │ │ to tmux
│ cek visit │ │ ▼
│ cek menu │ │ switch-client / select-pane
└──────────────────┘ └────────────────────────────
```
## Subcommands
`cekanje` and `cek` accept the same arguments — pick whichever you like.
| `cek serve [--port 8731] [--ensure] [--idle-secs 1800] [--rebuild-window-secs 300]` | Run daemon. `--ensure` no-ops if already up, otherwise spawns detached. `--idle-secs 0` disables auto-shutdown. `--rebuild-window-secs 0` disables cold-start state rebuild from `~/.claude/projects/`. |
| `cek status` | Print `⏳N` if any session is Waiting; empty otherwise. (For users with a tmux status bar.) |
| `cek list` | Dump current state as JSON. |
| `cek visit <pane>` | Mark the pane's session as visited. |
| `cek menu` | fzf picker over sessions; on selection, jump to the pane. |
## Install
### Homebrew (macOS)
```bash
brew tap abosnjakovic/cekanje https://github.com/abosnjakovic/cekanje
brew install cekanje
```
Installs `cekanje` and `cek` (symlink) into `$(brew --prefix)/bin`.
### cargo
```bash
cargo install cekanje
```
The `cek` shortcut isn't created automatically by cargo; symlink it yourself:
```bash
ln -sf cekanje "$(dirname $(which cekanje))/cek"
```
### From source
```bash
cargo build --release
install -m 0755 target/release/cekanje ~/.local/bin/cekanje
ln -sf cekanje ~/.local/bin/cek
```
Requires: tmux (≥3.2 for `pane-focus-in`), `fzf`, Claude Code with HTTP-hook support.
## Configure Claude Code
Add to `~/.claude/settings.json` (top-level `hooks`):
```json
{
"hooks": {
"SessionStart": [{ "matcher": "", "hooks": [{ "type": "http", "url": "http://127.0.0.1:8731/hooks/event", "headers": { "X-Tmux-Pane": "${TMUX_PANE}", "X-Tmux-Socket": "${TMUX}" }, "allowedEnvVars": ["TMUX_PANE","TMUX"] }]}],
"Notification": [{ "matcher": "", "hooks": [{ "type": "http", "url": "http://127.0.0.1:8731/hooks/event", "headers": { "X-Tmux-Pane": "${TMUX_PANE}", "X-Tmux-Socket": "${TMUX}" }, "allowedEnvVars": ["TMUX_PANE","TMUX"] }]}],
"Stop": [{ "matcher": "", "hooks": [{ "type": "http", "url": "http://127.0.0.1:8731/hooks/event", "headers": { "X-Tmux-Pane": "${TMUX_PANE}", "X-Tmux-Socket": "${TMUX}" }, "allowedEnvVars": ["TMUX_PANE","TMUX"] }]}],
"SessionEnd": [{ "matcher": "", "hooks": [{ "type": "http", "url": "http://127.0.0.1:8731/hooks/event", "headers": { "X-Tmux-Pane": "${TMUX_PANE}", "X-Tmux-Socket": "${TMUX}" }, "allowedEnvVars": ["TMUX_PANE","TMUX"] }]}]
}
}
```
Daemon binds 127.0.0.1 only — no auth needed.
## Configure tmux
Append to `~/.config/tmux/tmux.conf`:
```tmux
set-hook -g session-created 'run-shell -b "cek serve --ensure --idle-secs 0"'
set-hook -g pane-focus-in 'run-shell -b "cek visit #{pane_id}"'
bind-key -n M-i run-shell 'cek menu'
```
`cek menu` self-launches a borderless fullscreen `tmux display-popup` and re-execs itself inside it (signalled via `CEK_FULLSCREEN_HOST=1`), so the binding stays trivial. Requires tmux 3.3+ for the `-B` flag.
Reload: `tmux source-file ~/.config/tmux/tmux.conf`. Then bootstrap once with `cek serve --ensure --idle-secs 0` (the `session-created` hook only fires for newly-opened tmux sessions, so leaving the auto-idle-shutdown off avoids a dead port mid-session).
If you keep a tmux status bar, you can also add `set -ag status-right '#(cek status) '` plus `set -g status-interval 5`.
## Configure systemd (Linux only)
When you don't have tmux's `session-created` hook to bootstrap the daemon (or want it running independently), use the bundled user unit:
```bash
mkdir -p ~/.config/systemd/user
install -m 0644 dist/systemd/cekanje.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now cekanje.service
```
Optional: `loginctl enable-linger $USER` so the daemon survives logout.
The unit runs `cek serve --idle-secs 0` — systemd is the lifecycle manager, so the auto-idle-shutdown is off.
## Cold-start rebuild
On startup, the daemon scans `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl` and pre-populates sessions whose transcript files were modified inside the rebuild window (default 5 minutes). Each rebuilt session lands in **Working** state with no tmux pane bound; the next hook event from that session attaches its pane.
Caveats:
- The rebuild path never marks Waiting — transcript scraping for that signal is too brittle. `Notification` / `Stop` events from the live hook still fire correctly.
- Path encoding is naive (`-` ↔ `/`). cwds containing literal `-` round-trip ambiguously, but won't crash.
- Disable with `--rebuild-window-secs 0`. Widen with e.g. `--rebuild-window-secs 3600` to restore everything from the last hour.
## Hot-reload on binary upgrade
A long-lived daemon inside tmux outlives most package upgrades. To avoid serving stale code after `brew upgrade cekanje`, `cargo install cekanje`, or a manual tarball swap, the running daemon fingerprints its own on-disk binary (dev/inode/mtime/size, following symlinks so Homebrew's `Cellar` chain is observed correctly) at startup, and re-checks the fingerprint after every `/hooks/event` request.
When the fingerprint changes:
1. A single-flight latch is claimed so concurrent hook events don't double-swap.
2. axum is signalled to drain — in-flight requests have up to 3 seconds to finish.
3. The process replaces its own image in place via `execve(2)`. The PID is preserved, so tmux's child relationship with the daemon survives. argv is replayed verbatim, so flags like `--idle-secs 0` carry across the swap.
4. The new image rebinds 127.0.0.1:8731 (with a brief retry loop covering the kernel's port-release window) and rebuilds session state from `~/.claude/projects/` via the usual cold-start path.
Typical end-to-end swap latency is under 200 ms; an in-flight Claude hook generally sees no disruption.
Inspect what the running daemon thinks of itself:
```bash
curl -s 127.0.0.1:8731/admin/version
# {"version":"0.1.3","startup_fp":{...},"current_fp":{...},"swap_in_flight":false}
```
`startup_fp` and `current_fp` differing means a swap is queued for the next hook event. Manual probe without waiting for Claude:
```bash
curl -s -X POST 127.0.0.1:8731/hooks/event \
-H 'content-type: application/json' \
-d '{"hook_event_name":"SessionStart","session_id":"manual-swap-probe"}'
```
If something prevents the swap (binary missing, permission error), the daemon logs the failure and keeps serving the old image — it never euthanises itself on a transient filesystem error.
## State machine
| `SessionStart`, `UserPromptSubmit` | Working | — |
| `Notification`, `Stop` (pane focused) | Working | — *(auto-clear)* |
| `Notification`, `Stop` (pane not focused) | Waiting | macOS / Linux popup |
| `SessionEnd` | dropped | — |
| `cek visit <pane>` | Working | — |
Auto-clear (pane focused) means: if any attached tmux client's `client_pane` equals the session's pane, no badge bump and no popup. This avoids a flood of notifications for the pane you're already looking at.
## Files
- `src/main.rs` — clap dispatch
- `src/serve.rs` — axum app, event handlers, idle-shutdown task, hot-reload wiring
- `src/state.rs` — Session / State types and transitions
- `src/reload.rs` — binary fingerprint + in-place process-image swap
- `src/tmux.rs` — `tmux` shell-out helpers (`is_pane_focused`, `switch_to_pane`)
- `src/menu.rs` — fzf picker
- `src/notify.rs` — `notify-rust` wrapper
- `src/client.rs` — minimal HTTP client for the CLI subcommands
## Development
```bash
make help # all commands
make lint # cargo fmt + clippy -D warnings (CI parity)
make test # cargo test
make test-release # full local simulation of the release flow
```
## Verification
After install, walk through [docs/SMOKE.md](docs/SMOKE.md) — daemon up, single session, idle → notification, popup picker, focus-clear, idle shutdown.
Release flow runs as `.github/workflows/release.yml` on tag push (`v*`). It builds for aarch64-apple-darwin, x86_64-apple-darwin, and x86_64-unknown-linux-gnu, uploads tarballs to the release, regenerates `Formula/cekanje.rb` with fresh SHA256s, and publishes to crates.io. Required repo secrets: `CRATES_IO_TOKEN`, `HOMEBREW_TAP_TOKEN`.
## Limitations / TODO
- No persistence — daemon restart loses session state until each session's next hook event re-registers it.
- No web UI.
- macOS notification path tested; Linux works via `notify-rust` but autostart unit (systemd user) not yet provided.
- No rebuild from `~/.claude/projects/*` transcripts on cold start.
- Single tmux server tracked per session via the `X-Tmux-Socket` header. Multiple tmux servers run side by side fine, but cross-server menu jump only works when each client is on the right server.