ai: move agent state into a Claude mod
Assisted-by: GPT-5.6 Terra via pi
change uuluuskmzlzuwnxwumwtwslvxxnlmsqp
commit 5eb542cbfde555746fe0c142487f09e78a17bc23
author Alpha Chen <alpha@kejadlen.dev>
date
parent qxrvwvsu
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");
+});