agents: sink visited blocked sessions to the bottom
Assisted-by: Claude Opus 5 via Claude Code
change kpqumymvwsuwomuzoyvvumqnppwkowrm
commit e9a2c268bc97f315786b4294612fb133b41917fd
author Alpha Chen <alpha@kejadlen.dev>
date
parent zsyuyzxy
diff --git a/.config/sketchybar/plugins/agents.sh b/.config/sketchybar/plugins/agents.sh
index 085f1d0f..9d12dc7c 100755
--- a/.config/sketchybar/plugins/agents.sh
+++ b/.config/sketchybar/plugins/agents.sh
@@ -85,7 +85,8 @@ cmd_status() {
     fi
 
     # list already orders blocked, then idle, then working, so the first row
-    # names the most urgent state and the label counts how many share it.
+    # names the most urgent state and the label counts how many share it. The
+    # ordering within a state doesn't matter here — only which state leads.
     # BSD head and cut have no long forms for these, so short flags it is.
     local state count
     state=$(head -n 1 <<<"$rows" | cut -f 1)
@@ -105,7 +106,9 @@ cmd_click() {
 }
 
 # list orders blocked first, so the top row is the session most likely to be
-# waiting on a human.
+# waiting on a human — and puts the ones already jumped to last within that
+# group, so repeated clicks walk the blocked sessions instead of sticking on
+# whichever one has been blocked longest.
 jump_to_top() {
     local pane
     pane=$(state_rows | head -n 1 | cut -f 6)
@@ -192,6 +195,11 @@ cmd_jump() {
     # three-step form races, as ,agents' comment explains at more length.
     tmux switch-client ${target[@]+"${target[@]}"} -t "$pane" 2>/dev/null || return 0
 
+    # Sinks this pane to the bottom of its state group in `list`, so the next
+    # left click reaches for a different session. Ignored unless the pane is
+    # blocked, which ,agent-state decides for itself.
+    [[ -x $STATE_SCRIPT ]] && "$STATE_SCRIPT" visited "$pane" 2>/dev/null
+
     # Raises the most recently used Ghostty window, which is the right one when
     # a single window holds the tmux client. With several, macOS decides. The
     # bundle id rather than -a Ghostty, since a name lookup can find the wrong
diff --git a/ai/bin/,agent-state b/ai/bin/,agent-state
index ba25bbc9..4c3ec932 100755
--- a/ai/bin/,agent-state
+++ b/ai/bin/,agent-state
@@ -3,8 +3,10 @@
 # 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 and by the sketchybar agents plugin.
+# Written by ai/claude/hooks/agent-state.sh, read by ai/bin/,agents and by the
+# sketchybar agents plugin. Those two readers also write, through `visited`:
+# jumping to a blocked session records the visit so the picker can move on to
+# the next one instead of offering the same session again.
 #
 # `list` is not read-only: it prunes rows whose pane is gone. The sketchybar
 # plugin runs it every time the hook reports a state change, so most pruning
@@ -53,6 +55,13 @@ CREATE TABLE IF NOT EXISTS panes (
   cwd        TEXT NOT NULL DEFAULT '',
   message    TEXT NOT NULL DEFAULT ''
 );
+-- Its own table rather than a column on panes, so an existing database picks it
+-- up from CREATE TABLE IF NOT EXISTS. ALTER TABLE ADD COLUMN would need a
+-- migration step, and this schema runs on every connection.
+CREATE TABLE IF NOT EXISTS visits (
+  pane       TEXT PRIMARY KEY,
+  visited_at INTEGER NOT NULL
+);
 "
 
 # Renders a value as a SQL string literal. SQLite escapes a single quote by
@@ -108,6 +117,33 @@ cmd_clear() {
 $PRAGMAS
 $SCHEMA
 DELETE FROM panes WHERE pane = $(sql_quote "$TMUX_PANE");
+DELETE FROM visits WHERE pane = $(sql_quote "$TMUX_PANE");
+SQL
+}
+
+# Records that a human jumped to this pane while it was blocked, so `list` can
+# rank it below the blocked sessions they have not seen yet. Called by the two
+# places that switch a tmux client: ,agents and the sketchybar agents item.
+#
+# Whether the pane is blocked is decided here rather than trusted from the
+# caller. Switching a client fires no Claude hook, so the stored state is still
+# current at the moment of the jump, and a caller holding a stale row cannot
+# demote a session that has since gone back to working.
+cmd_visited() {
+    local pane=${1:-}
+
+    [[ $pane =~ ^%[0-9]+$ ]] || return 0
+    [[ -f $STATE_DB ]] || return 0
+
+    # The WHERE clause is what lets this parse: in an INSERT ... SELECT, SQLite
+    # cannot otherwise tell ON CONFLICT from the ON of a join.
+    db <<SQL >/dev/null
+$PRAGMAS
+$SCHEMA
+INSERT INTO visits (pane, visited_at)
+SELECT pane, $(date +%s) FROM panes
+WHERE pane = $(sql_quote "$pane") AND state = 'blocked'
+ON CONFLICT(pane) DO UPDATE SET visited_at = excluded.visited_at;
 SQL
 }
 
@@ -117,6 +153,11 @@ SQL
 # 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.
+#
+# Except for sessions already visited while blocked, which sink below the rest
+# of their state, least recently visited first. Without that, jumping to a
+# blocked session and leaving it blocked would land on the same row the next
+# time the picker opens, since visiting a pane does not change its age.
 cmd_list() {
     [[ -f $STATE_DB ]] || return 0
 
@@ -143,12 +184,23 @@ $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);
+DELETE FROM visits WHERE pane NOT IN (SELECT pane FROM live);
 .mode list
 .separator "\t"
+-- A visit counts only until the session next reports, so visited_at older than
+-- updated_at means the pane has moved on and belongs back in the age ordering.
+-- That also expires the demotion when a session blocks again: recording a state
+-- bumps updated_at past the visit, and a fresh block is worth surfacing.
+--
+-- Sorting the demoted rows on the visit rather than the age is what makes them
+-- rotate. max() picks whichever timestamp ranks the row, so both groups sort
+-- with one expression: the visit for a demoted row, the age for every other.
 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
+LEFT JOIN visits v ON v.pane = p.pane
 ORDER BY CASE p.state WHEN 'blocked' THEN 0 WHEN 'idle' THEN 1 ELSE 2 END,
-         p.updated_at ASC;
+         CASE WHEN COALESCE(v.visited_at, 0) >= p.updated_at THEN 1 ELSE 0 END,
+         max(COALESCE(v.visited_at, 0), p.updated_at) ASC;
 SQL
 }
 
@@ -179,11 +231,12 @@ main() {
     }
 
     case $command in
-        record) cmd_record "$@" ;;
-        clear)  cmd_clear ;;
-        list)   cmd_list ;;
+        record)  cmd_record "$@" ;;
+        clear)   cmd_clear ;;
+        list)    cmd_list ;;
+        visited) cmd_visited "$@" ;;
         *)
-            echo "usage: ,agent-state <record|clear|list>" >&2
+            echo "usage: ,agent-state <record|clear|list|visited PANE>" >&2
             return 2
             ;;
     esac
diff --git a/ai/bin/,agents b/ai/bin/,agents
index 5815a48e..0d23aea1 100755
--- a/ai/bin/,agents
+++ b/ai/bin/,agents
@@ -1,9 +1,10 @@
 #!/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.
+# human come first — barring any already jumped to and left blocked, which
+# ,agent-state sinks to the bottom of that group. 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
@@ -77,7 +78,12 @@ main() {
     # last-active window instead.
     if ! tmux switch-client -t "$pane" 2>/dev/null; then
         tmux display-message "agents: pane $pane is gone"
+        return 0
     fi
+
+    # Recorded only once the jump lands, so a dead pane doesn't get demoted on
+    # the way out. ,agent-state ignores a pane that isn't blocked.
+    "$state_script" visited "$pane" || true
 }
 
 main "$@"