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

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

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