ai: move agent state into a Claude mod
Assisted-by: GPT-5.6 Terra via pi
diff --git a/ai/bin/,agent-state b/ai/bin/,agent-state
index 2139c693..a2307f44 100755
--- a/ai/bin/,agent-state
+++ b/ai/bin/,agent-state
@@ -8,7 +8,7 @@
# 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
+# Written by ai/skills/agent-state/hooks/register.ts 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
@@ -158,7 +158,7 @@ SQL
# agents item.
#
# 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
+# a client fires no Claude Code event, 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() {
diff --git a/ai/bin/,agents b/ai/bin/,agents
index d695936f..e802bff2 100755
--- a/ai/bin/,agents
+++ b/ai/bin/,agents
@@ -4,7 +4,7 @@
# on a 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 and the pi extension
+# State comes from ,agent-state, which the Claude Code mod and the pi extension
# write to.
set -euo pipefail
diff --git a/ai/claude/hooks/agent-state.sh b/ai/claude/hooks/agent-state.sh
deleted file mode 100755
index 3c5acce8..00000000
--- a/ai/claude/hooks/agent-state.sh
+++ /dev/null
@@ -1,112 +0,0 @@
-#!/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.
-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.
- #
- # 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%.}
- fi
- "$state_script" record --app claude-code --kind "$kind" blocked "$cwd" "$message" || true
- changed=1
- ;;
- Stop)
- "$state_script" record --app claude-code 'done' "$cwd" 'turn finished' || true
- changed=1
- ;;
- UserPromptSubmit|PostToolUse|PermissionDenied)
- "$state_script" record --app claude-code 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
diff --git a/ai/pi/extensions/agent-state.ts b/ai/pi/extensions/agent-state.ts
index 7164e2b9..0e7f1fef 100644
--- a/ai/pi/extensions/agent-state.ts
+++ b/ai/pi/extensions/agent-state.ts
@@ -1,7 +1,7 @@
/**
* Agent State - report this pi session's state to ,agent-state.
*
- * The pi counterpart to ai/claude/hooks/agent-state.sh: records blocked,
+ * The pi counterpart to ai/skills/agent-state/hooks/register.ts: records blocked,
* 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.
@@ -27,7 +27,7 @@ import { join } from "node:path";
const STATE_SCRIPT = join(process.env.HOME ?? "", ".dotfiles/ai/bin/,agent-state");
-// Runs after each write, matching the Claude hook: pushes the new state at
+// Runs after each write, matching the Claude mod: pushes the new state at
// sketchybar's agents item instead of leaving it to poll. Resolved through
// sh with the Homebrew fallback because the spawn may not carry brew's PATH.
const TRIGGER_SKETCHYBAR = [
diff --git a/ai/skills/agent-state/.claude-plugin/plugin.json b/ai/skills/agent-state/.claude-plugin/plugin.json
new file mode 100644
index 00000000..82200964
--- /dev/null
+++ b/ai/skills/agent-state/.claude-plugin/plugin.json
@@ -0,0 +1,8 @@
+{
+ "name": "agent-state",
+ "version": "0.1.0",
+ "description": "Reports Claude Code session state to the tmux agent picker",
+ "author": {
+ "name": "Alpha Chen"
+ }
+}
diff --git a/ai/skills/agent-state/hooks/hooks.json b/ai/skills/agent-state/hooks/hooks.json
new file mode 100644
index 00000000..9f451014
--- /dev/null
+++ b/ai/skills/agent-state/hooks/hooks.json
@@ -0,0 +1,3 @@
+{
+ "modules": ["./register.ts"]
+}
diff --git a/ai/skills/agent-state/hooks/register.ts b/ai/skills/agent-state/hooks/register.ts
new file mode 100644
index 00000000..acff3d2f
--- /dev/null
+++ b/ai/skills/agent-state/hooks/register.ts
@@ -0,0 +1,87 @@
+import type { EngineInterface, Register } from "claude-code";
+import {
+ done,
+ isPrimaryAgent,
+ permissionRequest,
+ questionRequest,
+ recordArguments,
+ stateScript,
+ working,
+ type StateRecord,
+} from "./state.ts";
+
+const sketchybarTrigger = [
+ "sketchybar_bin=$(command -v sketchybar) || sketchybar_bin=/opt/homebrew/opt/sketchybar/bin/sketchybar",
+ '[ -x "$sketchybar_bin" ] && "$sketchybar_bin" --trigger agent_state_change >/dev/null 2>&1 || true',
+].join("; ");
+
+async function cwd($: EngineInterface): Promise<string> {
+ return $.session.cwd();
+}
+
+async function triggerSketchybar($: EngineInterface): Promise<void> {
+ await $.process.run(["/bin/sh", "-c", sketchybarTrigger], { timeoutMs: 5000 });
+}
+
+async function publish($: EngineInterface, record: StateRecord): Promise<void> {
+ const [pane, home] = await Promise.all([$.env.get("TMUX_PANE"), $.env.get("HOME")]);
+ if (pane === undefined || pane === "" || home === undefined || home === "") return;
+
+ try {
+ await $.process.run([stateScript(home), ...recordArguments(record)], { timeoutMs: 5000 });
+ await triggerSketchybar($);
+ } catch {
+ // State tracking must not disrupt the Claude session.
+ }
+}
+
+async function clear($: EngineInterface): Promise<void> {
+ const [pane, home] = await Promise.all([$.env.get("TMUX_PANE"), $.env.get("HOME")]);
+ if (pane === undefined || pane === "" || home === undefined || home === "") return;
+
+ try {
+ await $.process.run([stateScript(home), "clear"], { timeoutMs: 5000 });
+ await triggerSketchybar($);
+ } catch {
+ // State tracking must not disrupt the Claude session.
+ }
+}
+
+export const register: Register = on => {
+ let writes: Promise<void> = Promise.resolve();
+ const enqueue = (operation: () => Promise<void>) => {
+ writes = writes.then(operation, operation).catch(() => undefined);
+ return writes;
+ };
+ on("turn.start", async ($, event, next) => {
+ if (isPrimaryAgent(event.agentId)) await enqueue(async () => publish($, working(await cwd($))));
+ return next(event);
+ });
+
+ on("turn.complete", async ($, event, next) => {
+ if (isPrimaryAgent(event.agentId)) await enqueue(async () => publish($, done(await cwd($))));
+ return next(event);
+ });
+
+ on("tool.call", { tool: "AskUserQuestion" }, async ($, event, next) => {
+ if (isPrimaryAgent(event.agentId)) await enqueue(async () => publish($, questionRequest(await cwd($))));
+ return next(event);
+ }).catch(($, event, next) => next(event));
+
+ on("classic.PermissionRequest", async ($, event, next) => {
+ if (isPrimaryAgent(event.agent_id)) {
+ await enqueue(async () => publish($, permissionRequest(event.tool_name, await cwd($))));
+ }
+ return next(event);
+ }).catch(($, event, next) => next(event));
+
+ on("classic.PermissionDenied", async ($, event, next) => {
+ if (isPrimaryAgent(event.agent_id)) await enqueue(async () => publish($, working(await cwd($))));
+ return next(event);
+ }).catch(($, event, next) => next(event));
+
+ on("session.end", async ($, event, next) => {
+ await enqueue(() => clear($));
+ return next(event);
+ });
+};
diff --git a/ai/skills/agent-state/hooks/state.ts b/ai/skills/agent-state/hooks/state.ts
new file mode 100644
index 00000000..a8d04ba1
--- /dev/null
+++ b/ai/skills/agent-state/hooks/state.ts
@@ -0,0 +1,38 @@
+export type AgentState = "blocked" | "working" | "done";
+export type BlockedKind = "permission" | "question";
+
+export type StateRecord = {
+ state: AgentState;
+ cwd: string;
+ kind?: BlockedKind;
+ message?: string;
+};
+
+export const stateScript = (home: string) => `${home}/.dotfiles/ai/bin/,agent-state`;
+
+export function recordArguments(record: StateRecord): string[] {
+ const args = ["record", "--app", "claude-code"];
+ if (record.kind !== undefined) args.push("--kind", record.kind);
+ args.push(record.state, record.cwd, record.message ?? "");
+ return args;
+}
+
+export function permissionRequest(tool: string | undefined, cwd: string): StateRecord {
+ return { state: "blocked", kind: "permission", cwd, message: tool ?? "" };
+}
+
+export function questionRequest(cwd: string): StateRecord {
+ return { state: "blocked", kind: "question", cwd };
+}
+
+export function working(cwd: string): StateRecord {
+ return { state: "working", cwd };
+}
+
+export function done(cwd: string): StateRecord {
+ return { state: "done", cwd, message: "turn finished" };
+}
+
+export function isPrimaryAgent(agentId: string | undefined): boolean {
+ return agentId === undefined || agentId === "";
+}
diff --git a/ai/skills/agent-state/tests/state.test.ts b/ai/skills/agent-state/tests/state.test.ts
new file mode 100644
index 00000000..4b9a9424
--- /dev/null
+++ b/ai/skills/agent-state/tests/state.test.ts
@@ -0,0 +1,65 @@
+import { expect, test } from "claude-code/testing";
+import {
+ done,
+ isPrimaryAgent,
+ permissionRequest,
+ questionRequest,
+ recordArguments,
+ stateScript,
+ working,
+} from "../hooks/state.ts";
+
+test("permission request records the blocked tool", () => {
+ expect(recordArguments(permissionRequest("Bash", "/repo"))).toEqual([
+ "record",
+ "--app",
+ "claude-code",
+ "--kind",
+ "permission",
+ "blocked",
+ "/repo",
+ "Bash",
+ ]);
+});
+
+test("question request records a blocked question without a message", () => {
+ expect(recordArguments(questionRequest("/repo"))).toEqual([
+ "record",
+ "--app",
+ "claude-code",
+ "--kind",
+ "question",
+ "blocked",
+ "/repo",
+ "",
+ ]);
+});
+
+test("turn lifecycle records working and done states", () => {
+ expect(recordArguments(working("/repo"))).toEqual([
+ "record",
+ "--app",
+ "claude-code",
+ "working",
+ "/repo",
+ "",
+ ]);
+ expect(recordArguments(done("/repo"))).toEqual([
+ "record",
+ "--app",
+ "claude-code",
+ "done",
+ "/repo",
+ "turn finished",
+ ]);
+});
+
+test("only primary-agent events update the tmux pane", () => {
+ expect(isPrimaryAgent(undefined)).toBe(true);
+ expect(isPrimaryAgent("")).toBe(true);
+ expect(isPrimaryAgent("subagent-1")).toBe(false);
+});
+
+test("the state script is installed from the dotfiles directory", () => {
+ expect(stateScript("/Users/alpha.chen")).toBe("/Users/alpha.chen/.dotfiles/ai/bin/,agent-state");
+});