cekanje 0.2.0

tmux notifier daemon for Claude Code sessions: track every active session, surface a native popup when one needs attention, jump to its pane via fzf
cekanje-0.2.0 is not a library.

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)

brew tap abosnjakovic/cekanje https://github.com/abosnjakovic/cekanje
brew install cekanje

Installs cekanje and cek (symlink) into $(brew --prefix)/bin.

cargo

cargo install cekanje

The cek shortcut isn't created automatically by cargo; symlink it yourself:

ln -sf cekanje "$(dirname $(which cekanje))/cek"

From source

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):

{
  "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:

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:

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:

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:

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

Event New status Notification
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.rstmux shell-out helpers (is_pane_focused, switch_to_pane)
  • src/menu.rs — fzf picker
  • src/notify.rsnotify-rust wrapper
  • src/client.rs — minimal HTTP client for the CLI subcommands

Development

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