,agents: add a tmux picker ranked by who is waiting
Wanted herdr's "jump to whichever agent is waiting on me" without
running herdr, whose picker ignores its own agent view filters.

ai/bin/,agent-state keeps one SQLite row per tmux pane, and ,agents
renders it with fzf on prefix+A, in the same floating pane as ,z and
,tmux-grab. Loading the live pane list into a temp table puts the join,
the prune of sessions that died without firing SessionEnd, and the
blocked-first ordering in a single statement. Both sit under ai/bin as
agent tooling rather than general-purpose commands, with .zshrc gaining
the directory so the comma-commands stay typeable.

Unlike claude-tmux-attention, Stop maps to idle rather than being
dropped: ranking by who is waiting means a finished turn is a hit, just
below a hard block. SubagentStop stays unwired because recap can fire it
after the main turn already stopped, reviving an idle pane.

PRAGMA busy_timeout has to be the first statement on every connection.
Switching journal_mode takes an exclusive lock, and with the default
zero timeout a colliding writer fails outright instead of waiting — 3 of
60 concurrent writes dropped before the reorder, none after. The pragmas
are fenced with .output because an assigning PRAGMA returns a row, which
would otherwise corrupt list output and make the hook write to stdout
inside Claude's tool loop.

Assisted-by: Claude Opus 5 via Claude Code
change woxrxyxquktpylpozovyxnvpntsxlnxr
commit 3e4506ee931b93c17ca3778ed567f0c83e2d82b5
author Alpha Chen <alpha@kejadlen.dev>
date
parent yvqosknk
diff --git a/.config/tmux/tmux.conf b/.config/tmux/tmux.conf
index d2ad5c53..53b97025 100644
--- a/.config/tmux/tmux.conf
+++ b/.config/tmux/tmux.conf
@@ -89,6 +89,10 @@ bind s split-window -v -c '#{pane_current_path}';
 # (it's current after creation, so no -t target is needed).
 bind g new-pane -x 80% -y 60% -X 10% -Y 20% -e IN_TMUX_FLOAT=1 -- ~/.dotfiles/bin/,z \; select-pane -T 'zoxide';
 
+# Claude session picker in the same floating pane, ranked so sessions blocked
+# on a permission prompt sort above idle ones. Lowercase a is send-prefix.
+bind A new-pane -x 80% -y 60% -X 10% -Y 20% -e IN_TMUX_FLOAT=1 -- ~/.dotfiles/ai/bin/,agents \; select-pane -T 'agents';
+
 # ,tmux-grab in the same floating pane as above. It looks up the triggering
 # pane itself via {last} — new-pane doesn't expand #{pane_id} in a
 # shell-command argument, so passing it here isn't an option.
diff --git a/.zshrc b/.zshrc
index 8a6581ab..c85e27e9 100644
--- a/.zshrc
+++ b/.zshrc
@@ -83,6 +83,7 @@ z4h init || return
 path=(
   .git/safe/../../bin
   ~/.dotfiles/bin
+  ~/.dotfiles/ai/bin
   ~/.volta/bin
   $path
 )
diff --git a/ai/bin/,agent-state b/ai/bin/,agent-state
new file mode 100755
index 00000000..1c1c3d77
--- /dev/null
+++ b/ai/bin/,agent-state
@@ -0,0 +1,187 @@
+#!/usr/bin/env bash
+
+# Tracks the state of Claude Code sessions, keyed by the tmux pane each one
+# runs in, so ,agents can rank them by who is waiting on a human.
+#
+# Written by ai/claude/hooks/agent-state.sh (the only writer), read by
+# ai/bin/,agents (the only reader).
+#
+# State lives in a SQLite database, one row per pane. Hooks fire concurrently
+# across sessions, so the database opens in WAL mode with a busy timeout:
+# writers don't block the reader, and a writer that collides with another
+# retries instead of failing. Every value is interpolated into SQL through
+# sql_quote, never pasted raw.
+
+set -euo pipefail
+if [[ "${TRACE-0}" == "1" ]]; then
+    set -o xtrace
+fi
+
+STATE_DB="${AGENT_STATE_DB:-${XDG_STATE_HOME:-$HOME/.local/state}/agent-state/state.db}"
+
+# Applied to every connection. WAL and busy_timeout are the two that matter for
+# concurrent hooks; synchronous=NORMAL is safe under WAL and keeps a hook from
+# waiting on an fsync inside Claude's tool loop. journal_mode is persistent in
+# the file, but busy_timeout is per-connection, so this runs every time.
+#
+# The .output fencing is load-bearing: an assigning PRAGMA *returns a row*, so
+# without it `journal_mode = WAL` prints "wal" onto stdout, which corrupts the
+# tab-separated rows `list` emits and makes the hook write to stdout inside
+# Claude's tool loop.
+# busy_timeout MUST come first. Switching journal_mode takes an exclusive lock,
+# and with the default zero timeout a writer that collides with another fails
+# instantly with "database is locked" rather than waiting — which showed up as
+# roughly 1 in 15 dropped writes under concurrent hooks.
+readonly PRAGMAS="
+.output /dev/null
+PRAGMA busy_timeout = 3000;
+PRAGMA journal_mode = WAL;
+PRAGMA synchronous = NORMAL;
+.output stdout
+"
+
+readonly SCHEMA="
+CREATE TABLE IF NOT EXISTS panes (
+  pane       TEXT PRIMARY KEY,
+  state      TEXT NOT NULL,
+  updated_at INTEGER NOT NULL,
+  cwd        TEXT NOT NULL DEFAULT '',
+  message    TEXT NOT NULL DEFAULT ''
+);
+"
+
+# Renders a value as a SQL string literal. SQLite escapes a single quote by
+# doubling it, and that is the only escape a single-quoted literal needs.
+sql_quote() {
+    printf "'%s'" "${1//\'/\'\'}"
+}
+
+db() {
+    mkdir -p "$(dirname "$STATE_DB")"
+    # -batch stops sqlite3 from ever prompting; -noheader keeps output parseable.
+    sqlite3 -batch -noheader "$STATE_DB"
+}
+
+cmd_record() {
+    local state=${1:-} cwd=${2:-$PWD} message=${3:-}
+
+    case $state in
+        blocked|idle|working) ;;
+        *)
+            echo "usage: ,agent-state record <blocked|idle|working> [cwd] [message]" >&2
+            return 2
+            ;;
+    esac
+
+    # The pane is the row identity, so outside tmux there is nothing to record.
+    [[ -n "${TMUX_PANE:-}" ]] || return 0
+
+    # Tab and newline are separators in this script's own output, so neither
+    # survives into a stored value.
+    cwd=$(printf '%s' "$cwd" | tr '\t\n' '  ')
+    message=$(printf '%s' "$message" | tr '\t\n' '  ')
+
+    db <<SQL >/dev/null
+$PRAGMAS
+$SCHEMA
+INSERT INTO panes (pane, state, updated_at, cwd, message)
+VALUES ($(sql_quote "$TMUX_PANE"), $(sql_quote "$state"), $(date +%s),
+        $(sql_quote "$cwd"), $(sql_quote "$message"))
+ON CONFLICT(pane) DO UPDATE SET
+  state      = excluded.state,
+  updated_at = excluded.updated_at,
+  cwd        = excluded.cwd,
+  message    = excluded.message;
+SQL
+}
+
+cmd_clear() {
+    [[ -n "${TMUX_PANE:-}" ]] || return 0
+    [[ -f $STATE_DB ]] || return 0
+
+    db <<SQL >/dev/null
+$PRAGMAS
+$SCHEMA
+DELETE FROM panes WHERE pane = $(sql_quote "$TMUX_PANE");
+SQL
+}
+
+# Emits one tab-separated row per live session:
+#   state  session:window  dir  age  message  pane
+#
+# Ordered by state (blocked, then idle, then working) and, within a state,
+# oldest first — the session sitting in it longest is the one most likely to
+# have been forgotten.
+cmd_list() {
+    [[ -f $STATE_DB ]] || return 0
+
+    local live
+    live=$(tmux list-panes -a -F '#{pane_id}	#{session_name}:#{window_index}' 2>/dev/null) || return 0
+    [[ -n $live ]] || return 0
+
+    # The live pane list goes into a temp table so the join, the prune and the
+    # ordering all happen in one statement. A row whose pane is gone belongs to
+    # a session that died without firing SessionEnd.
+    local values=""
+    local pane label
+    while IFS=$'\t' read -r pane label; do
+        # tmux pane ids are always %<digits>; anything else is not something to
+        # interpolate into SQL.
+        [[ $pane =~ ^%[0-9]+$ ]] || continue
+        values+="($(sql_quote "$pane"), $(sql_quote "$label")),"
+    done <<<"$live"
+    [[ -n $values ]] || return 0
+
+    db <<SQL | format_rows
+$PRAGMAS
+$SCHEMA
+CREATE TEMP TABLE live (pane TEXT PRIMARY KEY, label TEXT NOT NULL);
+INSERT OR IGNORE INTO live (pane, label) VALUES ${values%,};
+DELETE FROM panes WHERE pane NOT IN (SELECT pane FROM live);
+.mode list
+.separator "\t"
+SELECT p.state, l.label, p.cwd, p.updated_at, p.message, p.pane
+FROM panes p JOIN live l ON l.pane = p.pane
+ORDER BY CASE p.state WHEN 'blocked' THEN 0 WHEN 'idle' THEN 1 ELSE 2 END,
+         p.updated_at ASC;
+SQL
+}
+
+# Turns the raw SELECT into display columns: cwd to its basename, updated_at to
+# a relative age. Done in awk rather than SQL because both are string and clock
+# work that SQLite makes awkward, and awk -F'\t' keeps empty fields intact.
+format_rows() {
+    awk -F'\t' -v now="$(date +%s)" '
+        function age(secs) {
+            if (secs < 60)   return secs "s"
+            if (secs < 3600) return int(secs / 60) "m"
+            return int(secs / 3600) "h" int((secs % 3600) / 60) "m"
+        }
+        {
+            n = split($3, parts, "/")
+            printf "%s\t%s\t%s\t%s\t%s\t%s\n", $1, $2, parts[n], age(now - $4), $5, $6
+        }
+    '
+}
+
+main() {
+    local command=${1:-}
+    shift || true
+
+    command -v sqlite3 >/dev/null 2>&1 || {
+        echo ",agent-state: sqlite3 not found" >&2
+        return 1
+    }
+
+    case $command in
+        record) cmd_record "$@" ;;
+        clear)  cmd_clear ;;
+        list)   cmd_list ;;
+        *)
+            echo "usage: ,agent-state <record|clear|list>" >&2
+            return 2
+            ;;
+    esac
+}
+
+main "$@"
diff --git a/ai/bin/,agents b/ai/bin/,agents
new file mode 100755
index 00000000..5815a48e
--- /dev/null
+++ b/ai/bin/,agents
@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+
+# Picker for running Claude Code sessions, ranked so the ones blocked on a
+# human come first. Bound to prefix+A in tmux.conf, where it runs in the same
+# floating pane as ,z and ,tmux-grab. State comes from ,agent-state, which the
+# Claude hook writes to.
+
+set -euo pipefail
+if [[ "${TRACE-0}" == "1" ]]; then
+    set -o xtrace
+fi
+
+main() {
+    local state_script
+    state_script="$(dirname "${BASH_SOURCE[0]}")/,agent-state"
+
+    local rows
+    rows=$("$state_script" list)
+    if [[ -z $rows ]]; then
+        tmux display-message 'No Claude sessions tracked'
+        exit 0
+    fi
+
+    # Pad the display columns here rather than in ,agent-state, so that script
+    # stays a plain data source. Colour is applied after padding, since escape
+    # bytes would otherwise count toward the field width. The pane id is left
+    # visible as the trailing field: hiding it with --with-nth would make {2}
+    # in --preview ambiguous about whether it indexes the original line or the
+    # displayed one, and seeing the pane is mildly useful anyway.
+    local lines
+    lines=$(awk -F'\t' '
+        {
+            state = sprintf("%-7s", $1)
+            if      ($1 == "blocked") state = "\033[31m" state "\033[0m"
+            else if ($1 == "idle")    state = "\033[33m" state "\033[0m"
+            else                      state = "\033[2m"  state "\033[0m"
+            # Pane id leads so the delimiting tab lands at a fixed column; after
+            # the variable-length message it would leave a ragged edge.
+            printf "%s\t%s %-16s %-14s %5s  %s\n", $6, state, $2, $3, $4, $5
+        }
+    ' <<<"$rows")
+
+    # Float via fzf's own tmux popup, unless the caller already gave us a
+    # floating container, where --tmux would stack a redundant popup on top.
+    # FZF_DEFAULT_OPTS (.zshrc) carries its own --tmux default, so it's cleared
+    # here too — otherwise it fires regardless of our own flag.
+    local tmux_opts=(--tmux "80%,60%")
+    [[ -n "${IN_TMUX_FLOAT:-}" ]] && tmux_opts=()
+
+    local selection
+    # ${arr[@]+"${arr[@]}"} splices the array only when it has elements; a bare
+    # "${arr[@]}" aborts under `set -u` on macOS bash 3.2, which counts an
+    # empty array as unbound.
+    # No --select-1, unlike ,z and ,tmux-grab. The pane this runs from has a row
+    # too, so with a single session it would auto-jump to where the user already
+    # is: the float would blink and nothing would appear to happen.
+    selection=$(printf '%s\n' "$lines" | FZF_DEFAULT_OPTS='' fzf --no-multi \
+        --no-margin --no-padding \
+        ${tmux_opts[@]+"${tmux_opts[@]}"} \
+        --ansi --no-sort \
+        --layout reverse \
+        --height 100% \
+        --highlight-line --info inline-right \
+        --delimiter '\t' \
+        --border-label ' agents ' \
+        --header 'enter: jump to session' --header-first \
+        --preview 'tmux capture-pane -p -t {1} | tail -n 50' \
+        --bind 'ctrl-/:change-preview-window(down|hidden|)') || exit 0
+
+    local pane
+    pane=$(cut -f1 <<<"$selection")
+    [[ -n $pane ]] || exit 0
+
+    # A pane target resolves to its session, window and pane in one step. The
+    # three-step form races: switch-client returns before the client has moved,
+    # so a follow-up select-window can land on the destination session's
+    # last-active window instead.
+    if ! tmux switch-client -t "$pane" 2>/dev/null; then
+        tmux display-message "agents: pane $pane is gone"
+    fi
+}
+
+main "$@"
diff --git a/ai/claude/hooks/agent-state.sh b/ai/claude/hooks/agent-state.sh
new file mode 100755
index 00000000..cf3f019d
--- /dev/null
+++ b/ai/claude/hooks/agent-state.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+
+# Reports this Claude Code session's state to ,agent-state, which backs the
+# prefix+A session picker in tmux.
+#
+# Registered in ~/.claude/settings.json against the events in the case
+# statement below. Hooks run synchronously inside Claude's tool loop, so this
+# stays quiet and always exits 0 — a session picker is never worth breaking a
+# session over.
+
+set -uo pipefail
+
+command -v jq >/dev/null 2>&1 || exit 0
+# ,agent-state complains to stderr when sqlite3 is missing, which is right for
+# a human at a prompt but would repeat on every tool call in here.
+command -v sqlite3 >/dev/null 2>&1 || exit 0
+
+# The pane is the row's identity, so there is nothing to record outside tmux.
+# It has to come from $TMUX_PANE, which tmux sets for every process in a pane:
+# an untargeted `tmux display-message -p '#{pane_id}'` reports whichever pane
+# the client is *looking at*, which is rarely the one Claude runs in.
+[[ -n "${TMUX_PANE:-}" ]] || exit 0
+
+state_script="$HOME/.dotfiles/ai/bin/,agent-state"
+[[ -x $state_script ]] || exit 0
+
+payload=$(cat) || exit 0
+
+# Fields are joined on US (0x1f) rather than @tsv's tab. Tab counts as IFS
+# whitespace, so `read` would collapse a run of them into one delimiter and
+# silently shift every later field left — an absent agent_id would land the cwd
+# in agent_id and trip the subagent guard below on every event. US is not
+# whitespace, so empty fields survive. Newlines are scrubbed in jq because
+# `read` stops at the first one.
+fields=$(jq -r '
+    # Each `| flat` needs its own parens: jq binds `,` tighter than `|`, so
+    # without them the whole filter reads as one pipeline and errors out.
+    def flat: (. // "") | tostring | gsub("[\r\n\t]"; " ");
+    [
+      (.hook_event_name | flat),
+      (.agent_id        | flat),
+      (.cwd             | flat),
+      (.message         | flat)
+    ] | join("\u001f")
+' <<<"$payload" 2>/dev/null) || exit 0
+
+IFS=$'\x1f' read -r event agent_id cwd message <<<"$fields"
+
+# A subagent's hooks carry an agent_id and share their parent's pane, so
+# letting them report would have background tool calls overwrite the state of
+# the session the user actually interacts with.
+[[ -z $agent_id ]] || exit 0
+
+# SubagentStop is deliberately absent from the case below and must stay that
+# way. It is a completion event that recap and away-summary can fire *after*
+# the main turn has already stopped, which would flip a finished session back
+# to working and drop it to the bottom of the picker.
+case $event in
+    Notification|PreToolUse)
+        # Wired only for permission_prompt notifications and for
+        # AskUserQuestion, which raises no notification of its own. Both mean
+        # Claude has stopped and cannot continue without the user.
+        [[ -n $message ]] || message='waiting for input'
+        "$state_script" record blocked "$cwd" "$message" || true
+        ;;
+    Stop)
+        "$state_script" record idle "$cwd" 'turn finished' || true
+        ;;
+    UserPromptSubmit|PostToolUse|PermissionDenied)
+        "$state_script" record working "$cwd" '' || true
+        ;;
+    SessionEnd)
+        "$state_script" clear || true
+        ;;
+esac
+
+exit 0