jobctl is a read-only query tool for discovering and inspecting running Claude Code sessions ("jobs"). It has two commands: list-jobs and show-job.
jobctl reads from a single SQLite database: ~/.local/state/claude/hooks-tracker.db. This database is populated by Claude Code's hook system (via hookctl), not by jobctl itself. jobctl never writes to it.
Key tables:
jobs— job_id, name, created_at, tmux coordinates, statejob_sessions— maps jobs to Claude Code session IDs and PIDsevents— hook events with permission_mode, tool_name, cwdsession_env— environment variables per session (includingCLAUDE_PROJECT_DIR)
The core query in api.py:25-34 pulls all non-ended jobs, joining to job_sessions for the latest PID and session_id. It then validates each PID is alive via os.kill(pid, 0) — catching unclean exits where the SessionEnd hook never fired.
By default, jobctl scopes results to:
- Current tmux session — only shows jobs whose
tmux_sessionmatches yours - Current project — only shows jobs whose
CLAUDE_PROJECT_DIRmatchesos.getcwd()
Flags override this: --all shows headless jobs too, --all-projects removes the project filter, --history includes ended/crashed jobs.
jobctl derives display state from the DB state + PID liveness:
| DB state | PID alive? | Display state |
|---|---|---|
running |
yes | working |
running |
no | crashed |
stopped |
yes | stopped |
stopped |
no | ended |
ended |
— | ended |
jobctl reads the events table to build a mode history. Events with permission_mode = "plan" map to "plan"; everything else maps to "act". The output shows both initial_mode and current_mode.
Takes an identifier (job name, job_id, session_id, or substring). If omitted and you're in tmux, it auto-detects the job in your current window. Shows full details including mode history, tmux target, session timeline, and waiting state.
Tmux integration is deep and bidirectional, but most of it lives in hookctl, not jobctl itself.
helpers.py — get_tmux_context() checks $TMUX, then runs tmux display-message -p "#{session_name}\n#{window_index}" to get the current session/window. This is used for scoping list-jobs and auto-detecting jobs in show-job.
Each job stores tmux_session, tmux_window, and tmux_pane in the database. list-jobs formats these as a tmux target: session:window.pane.
hookctl sync-tmux (run_sync_tmux.py) keeps the database in sync with tmux reality:
- Lists all panes via
tmux list-panes -sto getpane_pid → (session, window, pane)mappings - Builds a full process tree from
ps -axo "pid=,ppid=" - Walks the tree to map every descendant PID back to its tmux pane
- Updates the
jobstable if any coordinates changed - Handles pane migrations — if a job's PID disappeared from its session, scans all sessions (covers
break-pane/move-panescenarios)
Triggered by tmux hooks and after place-pane moves.
hookctl place-pane (run_place_pane.py) is called by the on_job_named Claude Code hook. It:
- Places the Claude pane in the correct tmux session/window
- Detects conflicts (two Claude instances in the same window) — newer instance moves, older stays
- Fires
hookctl sync-tmuxafterward to update coordinates
jobctl has no prise integration. It operates exclusively through tmux.
Prise (the terminal multiplexer fork) is managed by a separate tool — prisectl (apps/prisectl/), which speaks msgpack-RPC to the prise socket at /tmp/prise-{uid}.sock. prisectl has its own commands (run-claude, start-pty, etc.) but doesn't feed into the hooks-tracker database that jobctl reads.
The two worlds are currently separate: tmux sessions are tracked by jobctl via hookctl's sync mechanism, while prise PTYs are managed by prisectl independently.