Skip to content

Session matching & hooks

Session Matching with Hooks

For reliable session-to-pane mapping (especially with multiple sessions of the same agent in the same project), install hooks:

Terminal window
ccmux setup # Install hooks for every supported agent found on PATH
ccmux setup --agent codex # Limit to a single agent (installs even if not on PATH)
ccmux setup --status # Report install state without writing
ccmux setup --uninstall # Remove hooks

Hooks write PID marker files under ~/.config/ccmux/session-pids/ whenever a session starts or begins its first invocation, a turn completes, or the agent asks the user to approve a tool. The daemon picks up the markers in real time via a filesystem watcher. See docs/architecture.md#hook-lifecycle for the full flow (marker writes, chokidar dispatch, per-agent correlation).

Gemini CLI is tracked through process detection and terminal pattern matching, so it needs no setup.

Claude Code

Uses Claude’s native hooks in ~/.claude/settings.json with three scripts under ~/.claude/hooks/:

  • ccmux-session-start.sh: writes the marker on session create/resume
  • ccmux-session-end.sh: removes the marker
  • ccmux-state-notify.sh: updates state on idle_prompt / permission_prompt

Codex CLI

Uses Codex’s native hooks (~/.codex/hooks.json plus the codex hooks feature flag in ~/.codex/config.toml, which is [features] codex_hooks = true pre-0.124 and [features] hooks = true on 0.124+; ccmux recognizes either) with three scripts under ~/.codex/hooks/:

  • ccmux-session-start.sh: writes the marker when a Codex session starts
  • ccmux-stop.sh: refreshes the marker at the end of every turn
  • ccmux-permission-request.sh: marks the session as waiting_permission when the user is asked to approve a tool

Tool-approval detection (PermissionRequest) needs Codex >= 0.122.

Cursor CLI

Uses Cursor’s native hooks (~/.cursor/hooks.json) with four scripts under ~/.cursor/hooks/:

  • ccmux-session-start.sh: writes the marker on fresh chat launch
  • ccmux-session-end.sh: unlinks the marker when the chat ends
  • ccmux-before-submit-prompt.sh: flips state to working and records the last prompt (1 KB cap)
  • ccmux-stop.sh: refreshes state back to idle at turn completion

Requires cursor-agent >= 2026.1.16 (when the hooks feature landed).

OpenCode

Uses OpenCode’s plugin system rather than shell hooks. ccmux setup --agent opencode drops a single auto-discovered JS plugin at ~/.config/opencode/plugin/ccmux.js (honors $XDG_CONFIG_HOME). The plugin subscribes to OpenCode’s in-process event bus and writes a marker for every session on the server:

  • session.created / session.updated: marker with directory + title
  • session.status (busy/retry/idle): refreshes state to working or idle
  • message.updated / message.part.updated: captures the user’s last prompt (1 KB cap) into the marker (parity with Claude/Codex/Cursor)
  • permission.asked / permission.replied: flips state to waiting_permission with the pending tool, clears back to working on reply
  • session.deleted: unlinks the marker

Because one OpenCode server can host many sessions, the daemon folds all markers sharing a server PID into the single ccmux Session for the tmux pane that hosts the server. Status is worst-of (waiting > working > idle); cwd and nativeSessionId come from the newest-activity marker, while pendingTool and the attention indicator come from the newest-waiting marker.

Pi / oh-my-pi

Both use Pi’s extension system rather than shell hooks (oh-my-pi, omp, is a hard fork of Pi that kept the extension API). ccmux setup --agent pi / --agent omp drops a single auto-discovered JS extension at ~/.pi/agent/extensions/ccmux.js or ~/.omp/agent/extensions/ccmux.js. The extension subscribes to the agent’s lifecycle events and writes one marker per session:

  • session_start: marker with the session id, transcript path, and cwd (fired at launch, so the marker carries full identity immediately)
  • before_agent_start: captures the user’s last prompt (1 KB cap)
  • agent_start / agent_end: flips state to working / idle (these bracket one full user prompt, so the row never flickers mid-response the way per-turn events would)
  • session_shutdown: unlinks the marker

omp additionally handles the in-place session swaps that change the session id (session_switch for /new and /resume, session_branch for /branch and fork): each reaps the old session’s marker and seeds a fresh one for the new id, since omp mutates the session in place rather than emitting a shutdown/start pair.

Both run one session per process, so there’s no server-style aggregation; the daemon correlates the marker’s PID to its tmux pane via process ancestry and links nativeSessionId.

Approvals are where the two diverge. Unlike Pi, omp gates tool calls behind an Approve/Deny prompt, so its extension also tracks tool_approval_requested (flips to waiting_permission with the gated tool’s name) and tool_approval_resolved (clears back to working once the last outstanding approval is answered; approve and deny both resume the loop). omp rows show a real waiting state and get actionable Approve/Deny buttons; Pi rows never raise one. omp emits these events only when an approval mode is configured; on its default yolo mode nothing is gated, and installing the ccmux extension does not change that either way.

Antigravity CLI

Uses Antigravity’s global named-hook config at ~/.gemini/config/hooks.json with two scripts under ~/.gemini/config/hooks/:

  • ccmux-preinvocation.sh: creates or refreshes the marker as working before each model invocation
  • ccmux-stop.sh: refreshes the marker as idle when the execution loop stops

Antigravity exposes no session-start hook, so a fresh idle session remains pane-tracked until its first prompt. ccmux deliberately does not install PreToolUse: in Antigravity v1.1.1, an empty {} response silently denies the tool call. Permission attention instead comes from the native permission dialog detected in pane content.

Copilot CLI

Drops one hooks file plus its marker script into Copilot’s auto-discovered ~/.copilot/hooks/ dir (ccmux-copilot.json and ccmux-copilot.sh), registering observational events only:

  • sessionStart: writes the marker (working if the session launched with an initial prompt, else idle)
  • userPromptSubmitted: flips the marker to working
  • notification: flips to waiting when the payload is a permission or elicitation dialog (other notification types are ignored)
  • agentStop: flips back to idle
  • sessionEnd: removes the marker

ccmux deliberately does not install Copilot’s permissionRequest hook: it is a deciding hook whose output can allow or deny the tool call. Permission attention is observed through notification instead. Copilot’s events.jsonl is also tailed as a log source (it flushes in real time, including the mid-wait permission.requested), and its held-open session.db backs no-hooks native-id discovery.

Matching priority (with hooks installed)

  1. Marker file (authoritative): Direct PID/TTY/session-id/transcript from the hook, re-verified on every scan so a wrong binding heals itself
  2. Process start time: For panes markers don’t claim, ccmux correlates session timestamps with agent process start times, matching each same-directory group as a whole within a 10-minute tolerance. When two candidates are too close to call, the session is left unbound rather than guessed.

Without hooks, the daemon does not use historical session IDs to claim pane ownership. It creates pane-scoped sessions from live process + tmux discovery, then attaches agent log metadata only when it can safely tie a log to the running process.

Sandboxes and pty wrappers

Agents launched through a sandbox or a pty-allocating wrapper (nono run -- claude, fence, script -q /dev/null claude) still get a row: the wrapper keeps the pane’s terminal and moves the agent onto a pty of its own, and ccmux follows the pane’s process tree down to the agent when the terminals don’t match. Verified with Claude Code and pi.

This works for agents ccmux recognizes by executable name (Claude Code, pi, codex, cursor, copilot). Gemini CLI, Antigravity and oh-my-pi are also matched by a path fragment of the full command line, so a wrapper whose own arguments spell that path (script -q /dev/null /opt/homebrew/bin/gemini) is itself detected as the agent, on the pane’s tty, and the tree is never consulted. The row still appears and still points at the right pane; its PID is the wrapper’s.

Hook (native) tracking is a separate question. A sandbox must allow writes to ~/.config/ccmux for the agent’s hook to write its marker file. Denied, the row still appears and still binds to the right pane, but the authoritative per-turn hook signals are gone and status falls back to whatever the agent’s own log says. For Claude that means the transcript alone, and it is worth knowing that a Claude row tracked this way gets no terminal-pattern fallback: its state changes when the transcript records the end of a turn, which can lag the visible answer on screen.