sketchybar: show running agents and jump to them
The item refreshes only when ai/claude/hooks/agent-state.sh fires, never on
a timer, so a pane that dies without a SessionEnd leaves a stale row until
the next hook runs in any session.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/.config/jj/workspaces/83c132759be286887823/metadata.binpb b/.config/jj/workspaces/83c132759be286887823/metadata.binpb
new file mode 100644
index 00000000..e92d5585
--- /dev/null
+++ b/.config/jj/workspaces/83c132759be286887823/metadata.binpb
@@ -0,0 +1,2 @@
+
++/Users/alpha.chen/src/gusto/gusto_scope/.jj
\ No newline at end of file
diff --git a/.config/sketchybar/plugins/agents.sh b/.config/sketchybar/plugins/agents.sh
new file mode 100755
index 00000000..085f1d0f
--- /dev/null
+++ b/.config/sketchybar/plugins/agents.sh
@@ -0,0 +1,217 @@
+#!/usr/bin/env bash
+
+# The agents item: how many Claude Code sessions ,agent-state is tracking, and
+# a way into whichever one needs attention. Three roles in one script, picked by
+# the first argument, so the state colors and the tmux logic have one home:
+#
+# (none) draw the item — most urgent state and its count, hidden if idle
+# click left jumps to the most urgent session, right opens the popup
+# jump PANE switch to that tmux pane and bring Ghostty forward
+#
+# State is re-read on every invocation rather than cached, so a click lands on
+# what is true now instead of what the bar last drew.
+#
+# sketchybar runs under launchd with a minimal PATH, and ,agent-state needs tmux
+# to tell which panes are still alive. Without tmux its `list` prints nothing
+# rather than failing, so a missing PATH entry would leave this item permanently
+# hidden instead of visibly broken — hence the explicit PATH.
+
+set -uo pipefail
+if [[ "${TRACE-0}" == "1" ]]; then
+ set -o xtrace
+fi
+
+export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
+
+readonly STATE_SCRIPT="$HOME/.dotfiles/ai/bin/,agent-state"
+
+# Popup rows point their click_script back here, so the script needs its own
+# path. sketchybar invokes it by the absolute path built from $PLUGIN_DIR, but a
+# relative one would land in each row's click_script and resolve against
+# whatever cwd sketchybar happens to have, failing silently on click.
+self="${BASH_SOURCE[0]}"
+[[ $self == /* ]] || self="$PWD/$self"
+readonly SELF="$self"
+
+# sketchybar sets NAME to the item it is invoking, which for a popup row is
+# agents.popup.N rather than the item that owns the popup. Stripping the suffix
+# keeps every path pointed at the same item without hardcoding its name.
+item="${NAME:-agents}"
+readonly ITEM="${item%%.popup.*}"
+
+# The same colors prefix+A uses in ,agents, so the bar and the picker agree.
+readonly COLOR_BLOCKED=0xffff5f5f
+readonly COLOR_IDLE=0xffffd75f
+readonly COLOR_WORKING=0xff888888
+
+# Nerd Font glyphs rather than emoji, because these go in a *label* and a label
+# takes one font — the default SauceCodePro Nerd Font, which has no emoji
+# coverage. An emoji there would draw as nothing at all, the way icon=🤖 did
+# before it got an explicit Apple Color Emoji font. These are Material Design
+# icons (alert-circle, check-circle, cog), which Nerd Fonts v3 carries at the
+# codepoints MDI itself assigns.
+readonly GLYPH_BLOCKED=
+readonly GLYPH_IDLE=
+readonly GLYPH_WORKING=
+
+state_rows() {
+ [[ -x $STATE_SCRIPT ]] || return 0
+ "$STATE_SCRIPT" list 2>/dev/null
+}
+
+color_for() {
+ case ${1-} in
+ blocked) printf '%s' "$COLOR_BLOCKED" ;;
+ idle) printf '%s' "$COLOR_IDLE" ;;
+ *) printf '%s' "$COLOR_WORKING" ;;
+ esac
+}
+
+glyph_for() {
+ case ${1-} in
+ blocked) printf '%s' "$GLYPH_BLOCKED" ;;
+ idle) printf '%s' "$GLYPH_IDLE" ;;
+ *) printf '%s' "$GLYPH_WORKING" ;;
+ esac
+}
+
+cmd_status() {
+ local rows
+ rows=$(state_rows)
+
+ if [[ -z $rows ]]; then
+ sketchybar --set "$ITEM" drawing=off
+ return 0
+ 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.
+ # 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)
+ count=$(cut -f 1 <<<"$rows" | grep --count --line-regexp --fixed-strings "$state")
+
+ sketchybar --set "$ITEM" \
+ drawing=on \
+ label="$count $state" \
+ label.color="$(color_for "$state")"
+}
+
+cmd_click() {
+ case "${BUTTON:-left}" in
+ right) show_popup ;;
+ *) jump_to_top ;;
+ esac
+}
+
+# list orders blocked first, so the top row is the session most likely to be
+# waiting on a human.
+jump_to_top() {
+ local pane
+ pane=$(state_rows | head -n 1 | cut -f 6)
+ [[ -n $pane ]] || return 0
+ cmd_jump "$pane"
+}
+
+show_popup() {
+ # Toggle off when it is already open, so a second right click closes it.
+ local drawing
+ drawing=$(sketchybar --query "$ITEM" 2>/dev/null | jq -r '.popup.drawing' 2>/dev/null) || drawing=off
+ if [[ $drawing == "on" ]]; then
+ sketchybar --set "$ITEM" popup.drawing=off
+ return 0
+ fi
+
+ sketchybar --remove "/${ITEM}\\.popup\\./" 2>/dev/null
+
+ local rows
+ rows=$(state_rows)
+
+ if [[ -z $rows ]]; then
+ # Only reachable if the last session ended between the bar drawing the
+ # item and the click landing, since the item hides itself when empty.
+ sketchybar --add item "$ITEM.popup.0" "popup.$ITEM" \
+ --set "$ITEM.popup.0" \
+ icon.drawing=off \
+ label="No sessions tracked" \
+ label.padding_left=6 \
+ label.padding_right=6
+ else
+ local index=0
+ local state _session dir age message pane
+ # Fields are re-joined on US (0x1f) before reading them. Tab counts as
+ # IFS whitespace, so `read` collapses a run of tabs into one delimiter
+ # and silently shifts every later field left — a session with no message
+ # would land its pane id in `message` and leave `pane` empty, which is
+ # the same trap ai/claude/hooks/agent-state.sh calls out. US is not
+ # whitespace, so empty fields survive.
+ # session:window is read but not shown: clicking a row goes to the pane
+ # directly, so the tmux coordinate is noise here. ,agents still lists it.
+ while IFS=$'\x1f' read -r state _session dir age message pane; do
+ local row="$ITEM.popup.$index"
+ # Padded here rather than in ,agent-state, which stays a plain data
+ # source. The label font is monospace, so columns line up.
+ sketchybar --add item "$row" "popup.$ITEM" \
+ --set "$row" \
+ icon.drawing=off \
+ label="$(printf '%s %-14s %5s %s' \
+ "$(glyph_for "$state")" "$dir" "$age" "$message")" \
+ label.color="$(color_for "$state")" \
+ label.padding_left=6 \
+ label.padding_right=6 \
+ click_script="\"$SELF\" jump $pane"
+ index=$((index + 1))
+ done <<<"$(tr '\t' '\037' <<<"$rows")"
+ fi
+
+ sketchybar --set "$ITEM" popup.drawing=on
+}
+
+cmd_jump() {
+ local pane=${1:-}
+ [[ -n $pane ]] || return 0
+
+ # Harmless when the popup is already closed, which is the left-click case.
+ sketchybar --set "$ITEM" popup.drawing=off >/dev/null 2>&1 || true
+
+ local session
+ session=$(tmux display-message -p -t "$pane" '#{session_name}' 2>/dev/null) || return 0
+
+ # Prefer a client already looking at that session: switching a client that
+ # sits on some other session would yank that window away from whatever the
+ # user left in it. With no match, -c is omitted so tmux falls back to its
+ # own idea of the most recently active client.
+ local client
+ client=$(tmux list-clients -F '#{client_name} #{client_session}' 2>/dev/null |
+ awk -F'\t' -v session="$session" '$2 == session { print $1; exit }')
+
+ local target=()
+ [[ -n $client ]] && target=(-c "$client")
+
+ # A pane target resolves session, window and pane in one step; the
+ # three-step form races, as ,agents' comment explains at more length.
+ tmux switch-client ${target[@]+"${target[@]}"} -t "$pane" 2>/dev/null || return 0
+
+ # 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
+ # copy.
+ open -b com.mitchellh.ghostty
+}
+
+main() {
+ local command=${1-status}
+ shift || true
+
+ case $command in
+ status) cmd_status ;;
+ click) cmd_click ;;
+ jump) cmd_jump "$@" ;;
+ *)
+ echo "usage: agents.sh <status|click|jump PANE>" >&2
+ return 2
+ ;;
+ esac
+}
+
+main "$@"
diff --git a/.config/sketchybar/sketchybarrc b/.config/sketchybar/sketchybarrc
index 42741499..6e1184cc 100755
--- a/.config/sketchybar/sketchybarrc
+++ b/.config/sketchybar/sketchybarrc
@@ -109,7 +109,19 @@ sketchybar --add item datetime right \
update_freq=120 \
click_script="sketchybar -m --set \$NAME label.drawing=toggle" \
script="$PLUGIN_DIR/battery.sh" \
- --subscribe battery system_woke power_source_change
+ --subscribe battery system_woke power_source_change \
+ --add event agent_state_change \
+ --add item agents right \
+ --subscribe agents agent_state_change \
+ --set agents \
+ icon= \
+ icon.padding_right=8 \
+ popup.background.color=0xff222222 \
+ popup.background.corner_radius=6 \
+ popup.background.border_color=0xff444444 \
+ popup.background.border_width=1 \
+ script="$PLUGIN_DIR/agents.sh" \
+ click_script="$PLUGIN_DIR/agents.sh click"
# --add item meetingbar right \
# --set meetingbar \
# icon= \
diff --git a/ai/bin/,agent-state b/ai/bin/,agent-state
index 1c1c3d77..ba25bbc9 100755
--- a/ai/bin/,agent-state
+++ b/ai/bin/,agent-state
@@ -4,7 +4,12 @@
# 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).
+# ai/bin/,agents and by the sketchybar agents plugin.
+#
+# `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
+# happens there rather than when a human opens the picker. Nothing polls, so a
+# pane that dies without a SessionEnd stays listed until the next hook fires.
#
# 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:
diff --git a/ai/claude/hooks/agent-state.sh b/ai/claude/hooks/agent-state.sh
index cf3f019d..3bcd05be 100755
--- a/ai/claude/hooks/agent-state.sh
+++ b/ai/claude/hooks/agent-state.sh
@@ -40,11 +40,12 @@ fields=$(jq -r '
(.hook_event_name | flat),
(.agent_id | flat),
(.cwd | flat),
- (.message | flat)
+ (.message | flat),
+ (.tool_name | flat)
] | join("\u001f")
' <<<"$payload" 2>/dev/null) || exit 0
-IFS=$'\x1f' read -r event agent_id cwd message <<<"$fields"
+IFS=$'\x1f' read -r event agent_id cwd message tool <<<"$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
@@ -55,23 +56,59 @@ IFS=$'\x1f' read -r event agent_id cwd message <<<"$fields"
# 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.
+changed=""
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'
+ #
+ # What gets recorded is the *reason*, short enough to read in one
+ # column. A notification's own text is nearly all boilerplate —
+ # "Claude needs your permission to use Bash" — so the prefix comes off
+ # and whatever names the tool is what remains. PreToolUse doesn't set
+ # message at all, but it names the tool in a field.
+ if [[ -n $tool ]]; then
+ message=$tool
+ else
+ message=${message#Claude needs your permission to use }
+ message=${message#Claude needs your permission to }
+ message=${message#Claude needs your permission}
+ message=${message%.}
+ # Distinct from the 'waiting for input' a question gets: this
+ # session is stopped on a permission prompt, tool unknown.
+ [[ -n $message ]] || message='needs permission'
+ fi
"$state_script" record blocked "$cwd" "$message" || true
+ changed=1
;;
Stop)
"$state_script" record idle "$cwd" 'turn finished' || true
+ changed=1
;;
UserPromptSubmit|PostToolUse|PermissionDenied)
"$state_script" record working "$cwd" '' || true
+ changed=1
;;
SessionEnd)
"$state_script" clear || true
+ changed=1
;;
esac
+# Pushes the new state at sketchybar's agents item instead of leaving it to
+# poll. --trigger is a message to the running instance over its socket, so this
+# costs one short-lived process and never blocks on the bar redrawing.
+#
+# Absent in ramekin containers and on any non-macOS host, so a missing binary is
+# a silent skip, not an error. The opt path is the fallback because Claude's
+# hook environment does not always carry a Homebrew PATH.
+if [[ -n $changed ]]; then
+ sketchybar_bin=$(command -v sketchybar) ||
+ sketchybar_bin=/opt/homebrew/opt/sketchybar/bin/sketchybar
+ if [[ -x $sketchybar_bin ]]; then
+ "$sketchybar_bin" --trigger agent_state_change >/dev/null 2>&1 || true
+ fi
+fi
+
exit 0