ai: adopt OSC 7501 vocabulary in agent state
Nothing in tmux or Ghostty consumes OSC 7501 yet, so this aligns the states, kind, and app with the spec without emitting the sequence.

Assisted-by: Claude Opus 5.5 via Claude Code
change krvyvtltnytxyzmpmpkrqymolumkukyz
commit 884a01717f648ee17ece23b25068059c452ceeb4
author Alpha Chen <alpha@kejadlen.dev>
date
parent uomlmtot
diff --git a/.config/sketchybar/plugins/agents.sh b/.config/sketchybar/plugins/agents.sh
index 9d12dc7c..02759761 100755
--- a/.config/sketchybar/plugins/agents.sh
+++ b/.config/sketchybar/plugins/agents.sh
@@ -41,17 +41,18 @@ 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_DONE=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.
+# icons (alert-circle, check-circle, sleep, cog), which Nerd Fonts v3 carries at
+# the codepoints MDI itself assigns.
 readonly GLYPH_BLOCKED=󰀨
-readonly GLYPH_IDLE=󰗠
+readonly GLYPH_DONE=󰗠
+readonly GLYPH_IDLE=󰒲
 readonly GLYPH_WORKING=󰒓
 
 state_rows() {
@@ -62,7 +63,7 @@ state_rows() {
 color_for() {
     case ${1-} in
         blocked) printf '%s' "$COLOR_BLOCKED" ;;
-        idle) printf '%s' "$COLOR_IDLE" ;;
+        done) printf '%s' "$COLOR_DONE" ;;
         *) printf '%s' "$COLOR_WORKING" ;;
     esac
 }
@@ -70,6 +71,7 @@ color_for() {
 glyph_for() {
     case ${1-} in
         blocked) printf '%s' "$GLYPH_BLOCKED" ;;
+        done) printf '%s' "$GLYPH_DONE" ;;
         idle) printf '%s' "$GLYPH_IDLE" ;;
         *) printf '%s' "$GLYPH_WORKING" ;;
     esac
@@ -84,7 +86,7 @@ cmd_status() {
         return 0
     fi
 
-    # list already orders blocked, then idle, then working, so the first row
+    # list already orders blocked, done, idle, then working, so the first row
     # 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.
@@ -141,7 +143,7 @@ show_popup() {
             label.padding_right=6
     else
         local index=0
-        local state _session dir age message pane
+        local state _session dir age message pane _app kind
         # 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
@@ -150,7 +152,8 @@ show_popup() {
         # 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
+        while IFS=$'\x1f' read -r state _session dir age message pane _app kind; do
+            [[ -n $message ]] || message=$kind
             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.
@@ -195,10 +198,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.
+    # Sinks a blocked pane to the bottom of its state group in `list`, so the
+    # next left click reaches for a different session, and turns a done pane
+    # idle. A visit can change the leading state, so the item redraws after.
     [[ -x $STATE_SCRIPT ]] && "$STATE_SCRIPT" visited "$pane" 2>/dev/null
+    cmd_status
 
     # 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
diff --git a/ai/bin/,agent-state b/ai/bin/,agent-state
index ce9158f4..2139c693 100755
--- a/ai/bin/,agent-state
+++ b/ai/bin/,agent-state
@@ -3,11 +3,17 @@
 # Tracks the state of Claude Code and pi sessions, keyed by the tmux pane each
 # one runs in, so ,agents can rank them by who is waiting on a human.
 #
+# States, kind, and app follow the vocabulary of OSC 7501, the Program Status
+# Protocol (https://www.superlogical.com/rex/docs/build/program-status), so a
+# producer here can emit the sequence later without translating. Nothing in
+# this stack consumes it yet, which is why the record still lives here.
+#
 # Written by ai/claude/hooks/agent-state.sh and by the pi extension
 # ai/pi/extensions/agent-state.ts; 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.
+# instead of offering the same session again, and jumping to a done session
+# marks it idle.
 #
 # `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
@@ -48,17 +54,21 @@ PRAGMA synchronous = NORMAL;
 .output stdout
 "
 
+# Rows are rebuilt by the next hook to fire, so a schema change renames the
+# table and drops the old one rather than migrating it.
 readonly SCHEMA="
-CREATE TABLE IF NOT EXISTS panes (
+DROP TABLE IF EXISTS panes;
+CREATE TABLE IF NOT EXISTS records (
   pane       TEXT PRIMARY KEY,
   state      TEXT NOT NULL,
+  kind       TEXT NOT NULL DEFAULT '',
+  app        TEXT NOT NULL DEFAULT '',
   updated_at INTEGER NOT NULL,
   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.
+-- Its own table rather than a column on records, so a records schema change
+-- drops nothing a hook cannot rebuild.
 CREATE TABLE IF NOT EXISTS visits (
   pane       TEXT PRIMARY KEY,
   visited_at INTEGER NOT NULL
@@ -77,17 +87,34 @@ db() {
     sqlite3 -batch -noheader "$STATE_DB"
 }
 
+readonly RECORD_USAGE="usage: ,agent-state record [--app APP] [--kind permission|question|auth] <blocked|done|idle|working> [cwd] [message]"
+
 cmd_record() {
+    local app="" kind=""
+    while [[ ${1:-} == --* ]]; do
+        case $1 in
+            --app)  app=${2:-}; shift 2 || { echo "$RECORD_USAGE" >&2; return 2; } ;;
+            --kind) kind=${2:-}; shift 2 || { echo "$RECORD_USAGE" >&2; return 2; } ;;
+            *)      echo "$RECORD_USAGE" >&2; return 2 ;;
+        esac
+    done
+
     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
-            ;;
+        blocked|done|idle|working) ;;
+        *) echo "$RECORD_USAGE" >&2; return 2 ;;
+    esac
+
+    # The spec gives kind meaning only on blocked.
+    case $kind in
+        "") ;;
+        permission|question|auth) [[ $state == blocked ]] || kind="" ;;
+        *) echo "$RECORD_USAGE" >&2; return 2 ;;
     esac
 
+    [[ $app =~ ^[A-Za-z0-9_.+-]{0,32}$ ]] || { echo "$RECORD_USAGE" >&2; return 2; }
+
     # The pane is the row identity, so outside tmux there is nothing to record.
     [[ -n "${TMUX_PANE:-}" ]] || return 0
 
@@ -99,11 +126,13 @@ cmd_record() {
     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"))
+INSERT INTO records (pane, state, kind, app, updated_at, cwd, message)
+VALUES ($(sql_quote "$TMUX_PANE"), $(sql_quote "$state"), $(sql_quote "$kind"),
+        $(sql_quote "$app"), $(date +%s), $(sql_quote "$cwd"), $(sql_quote "$message"))
 ON CONFLICT(pane) DO UPDATE SET
   state      = excluded.state,
+  kind       = excluded.kind,
+  app        = excluded.app,
   updated_at = excluded.updated_at,
   cwd        = excluded.cwd,
   message    = excluded.message;
@@ -117,19 +146,21 @@ cmd_clear() {
     db <<SQL >/dev/null
 $PRAGMAS
 $SCHEMA
-DELETE FROM panes WHERE pane = $(sql_quote "$TMUX_PANE");
+DELETE FROM records 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.
+# Records that a human jumped to this pane. A blocked pane gets a visit, so
+# `list` can rank it below the blocked sessions they have not seen yet. A done
+# pane becomes idle, since done means the result is unseen and now it has been.
+# 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.
+# The pane's state is read 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:-}
 
@@ -142,16 +173,19 @@ cmd_visited() {
 $PRAGMAS
 $SCHEMA
 INSERT INTO visits (pane, visited_at)
-SELECT pane, $(date +%s) FROM panes
+SELECT pane, $(date +%s) FROM records
 WHERE pane = $(sql_quote "$pane") AND state = 'blocked'
 ON CONFLICT(pane) DO UPDATE SET visited_at = excluded.visited_at;
+-- updated_at stays put, so the age still counts from when the turn finished.
+UPDATE records SET state = 'idle'
+WHERE pane = $(sql_quote "$pane") AND state = 'done';
 SQL
 }
 
 # Emits one tab-separated row per live session:
-#   state  session:window  dir  age  message  pane
+#   state  session:window  dir  age  message  pane  app  kind
 #
-# Ordered by state (blocked, then idle, then working) and, within a state,
+# Ordered by state (blocked, done, idle, then working) and, within a state,
 # oldest first — the session sitting in it longest is the one most likely to
 # have been forgotten.
 #
@@ -184,7 +218,7 @@ $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);
+DELETE FROM records WHERE pane NOT IN (SELECT pane FROM live);
 DELETE FROM visits WHERE pane NOT IN (SELECT pane FROM live);
 .mode list
 .separator "\t"
@@ -196,10 +230,10 @@ DELETE FROM visits WHERE pane NOT IN (SELECT pane FROM live);
 -- 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
+SELECT p.state, l.label, p.cwd, p.updated_at, p.message, p.pane, p.app, p.kind
+FROM records 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,
+ORDER BY CASE p.state WHEN 'blocked' THEN 0 WHEN 'done' THEN 1 WHEN 'idle' THEN 2 ELSE 3 END,
          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
@@ -217,7 +251,7 @@ format_rows() {
         }
         {
             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
+            printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", $1, $2, parts[n], age(now - $4), $5, $6, $7, $8
         }
     '
 }
diff --git a/ai/bin/,agents b/ai/bin/,agents
index 91b683b7..d695936f 100755
--- a/ai/bin/,agents
+++ b/ai/bin/,agents
@@ -34,11 +34,13 @@ main() {
         {
             state = sprintf("%-7s", $1)
             if      ($1 == "blocked") state = "\033[31m" state "\033[0m"
-            else if ($1 == "idle")    state = "\033[33m" state "\033[0m"
+            else if ($1 == "done")    state = "\033[33m" state "\033[0m"
             else                      state = "\033[2m"  state "\033[0m"
+            # A blocked row without a message still says what it is waiting on.
+            message = $5 != "" ? $5 : $8
             # 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
+            printf "%s\t%s %-11s %-16s %-14s %5s  %s\n", $6, state, $7, $2, $3, $4, message
         }
     ' <<<"$rows")
 
@@ -83,8 +85,14 @@ main() {
     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.
+    # the way out. A visit can turn done into idle, so sketchybar redraws too.
     "$state_script" visited "$pane" || true
+    local sketchybar_bin
+    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
 }
 
 main "$@"
diff --git a/ai/claude/hooks/agent-state.sh b/ai/claude/hooks/agent-state.sh
index 3bcd05be..3c5acce8 100755
--- a/ai/claude/hooks/agent-state.sh
+++ b/ai/claude/hooks/agent-state.sh
@@ -40,12 +40,11 @@ fields=$(jq -r '
       (.hook_event_name | flat),
       (.agent_id        | flat),
       (.cwd             | flat),
-      (.message         | flat),
-      (.tool_name       | flat)
+      (.message         | flat)
     ] | join("\u001f")
 ' <<<"$payload" 2>/dev/null) || exit 0
 
-IFS=$'\x1f' read -r event agent_id cwd message tool <<<"$fields"
+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
@@ -63,31 +62,30 @@ case $event in
         # AskUserQuestion, which raises no notification of its own. Both mean
         # Claude has stopped and cannot continue without the user.
         #
-        # 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
+        # kind says which of the two it is, so a question needs no message.
+        # For a permission prompt, what gets recorded is the tool, 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.
+        if [[ $event == PreToolUse ]]; then
+            kind=question
+            message=''
         else
+            kind=permission
             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
+        "$state_script" record --app claude-code --kind "$kind" blocked "$cwd" "$message" || true
         changed=1
         ;;
     Stop)
-        "$state_script" record idle "$cwd" 'turn finished' || true
+        "$state_script" record --app claude-code 'done' "$cwd" 'turn finished' || true
         changed=1
         ;;
     UserPromptSubmit|PostToolUse|PermissionDenied)
-        "$state_script" record working "$cwd" '' || true
+        "$state_script" record --app claude-code working "$cwd" '' || true
         changed=1
         ;;
     SessionEnd)
diff --git a/ai/pi/extensions/README.md b/ai/pi/extensions/README.md
index 61912019..85c18762 100644
--- a/ai/pi/extensions/README.md
+++ b/ai/pi/extensions/README.md
@@ -1,6 +1,6 @@
 # Agent State
 
-Reports the session's blocked/working/idle state to `,agent-state`, so
+Reports the session's blocked/working/done/idle state to `,agent-state`, so
 pi sessions appear in the `,agents` picker (prefix+A) and the sketchybar
 agents item alongside Claude Code ones.
 
@@ -12,7 +12,7 @@ bundled pi integration:
 | Event | State |
 |-------|-------|
 | `agent_start` | working |
-| `agent_settled` (when actually idle) | idle, "turn finished" |
+| `agent_settled` (when actually idle) | done, "turn finished" |
 | `session_start` | republish current state (reload-safe) |
 | `session_shutdown` | clear the pane's row |
 
diff --git a/ai/pi/extensions/agent-state.ts b/ai/pi/extensions/agent-state.ts
index df03a464..7164e2b9 100644
--- a/ai/pi/extensions/agent-state.ts
+++ b/ai/pi/extensions/agent-state.ts
@@ -2,19 +2,19 @@
  * Agent State - report this pi session's state to ,agent-state.
  *
  * The pi counterpart to ai/claude/hooks/agent-state.sh: records blocked,
- * working, and idle into the SQLite database keyed by tmux pane, so the
+ * working, done, and idle into the SQLite database keyed by tmux pane, so the
  * ,agents picker (prefix+A) and the sketchybar agents item rank pi sessions
  * alongside Claude Code ones.
  *
  *   agent_start             -> working
- *   agent_settled (isIdle)  -> idle, "turn finished"
+ *   agent_settled (isIdle)  -> done, "turn finished"
  *   session_start           -> republish current state (reload-safe)
  *   session_shutdown        -> clear the pane's row
  *
  * pi has no built-in permission prompt, so blocked arrives on the shared
- * event bus: permission-gate.ts emits "agent-state:blocked" around its
- * dialogs. Those are modal and every open is paired with a close, so a
- * boolean tracks it.
+ * event bus as "agent-state:blocked", from whichever extension raises a
+ * dialog. Those are modal and every open is paired with a close, so a
+ * boolean tracks it. kind defaults to permission, the gate's case.
  *
  * Writes are fire-and-forget through a serial queue. Ordering matters
  * (the clear that /resume fires must land before the session_start record
@@ -35,11 +35,13 @@ const TRIGGER_SKETCHYBAR = [
 	"[ -x \"$sketchybar_bin\" ] && \"$sketchybar_bin\" --trigger agent_state_change >/dev/null 2>&1 || true",
 ].join("; ");
 
-type AgentState = "blocked" | "working" | "idle";
+type AgentState = "blocked" | "working" | "done" | "idle";
+type BlockedKind = "permission" | "question" | "auth";
 
 interface BlockedEvent {
 	active: boolean;
 	label?: string;
+	kind?: BlockedKind;
 }
 
 export default function (pi: ExtensionAPI) {
@@ -56,8 +58,12 @@ export default function (pi: ExtensionAPI) {
 	let agentActive = false;
 	let blocked = false;
 	let blockedMessage: string | undefined;
+	let blockedKind: BlockedKind = "permission";
+	// A fresh session is at rest; only a turn that ran and settled is done.
+	let ranTurn = false;
 	let lastState: AgentState | undefined;
 	let lastMessage: string | undefined;
+	let lastKind: BlockedKind | undefined;
 
 	let queue: Promise<void> = Promise.resolve();
 
@@ -77,18 +83,30 @@ export default function (pi: ExtensionAPI) {
 		return task;
 	}
 
-	function desired(): { state: AgentState; message?: string } {
-		if (blocked) return { state: "blocked", message: blockedMessage };
+	function desired(): { state: AgentState; message?: string; kind?: BlockedKind } {
+		if (blocked) return { state: "blocked", message: blockedMessage, kind: blockedKind };
 		if (agentActive) return { state: "working" };
-		return { state: "idle", message: "turn finished" };
+		if (ranTurn) return { state: "done", message: "turn finished" };
+		return { state: "idle" };
 	}
 
 	function publish(force = false): void {
 		const next = desired();
-		if (!force && next.state === lastState && next.message === lastMessage) return;
+		if (
+			!force &&
+			next.state === lastState &&
+			next.message === lastMessage &&
+			next.kind === lastKind
+		) {
+			return;
+		}
 		lastState = next.state;
 		lastMessage = next.message;
-		void run(STATE_SCRIPT, ["record", next.state, cwd, next.message ?? ""]);
+		lastKind = next.kind;
+		const args = ["record", "--app", "pi"];
+		if (next.kind) args.push("--kind", next.kind);
+		args.push(next.state, cwd, next.message ?? "");
+		void run(STATE_SCRIPT, args);
 	}
 
 	pi.events.on("agent-state:blocked", (data) => {
@@ -97,6 +115,7 @@ export default function (pi: ExtensionAPI) {
 
 		blocked = event.active;
 		blockedMessage = event.active ? event.label : undefined;
+		blockedKind = event.kind ?? "permission";
 		publish();
 	});
 
@@ -113,6 +132,7 @@ export default function (pi: ExtensionAPI) {
 	pi.on("agent_start", async () => {
 		if (!interactive) return;
 		agentActive = true;
+		ranTurn = true;
 		publish();
 	});