ai: track pi sessions in agent-state
Assisted-by: GLM-5.2 via pi
change ztptqqtvuvusoyqttyxpkqzkutnlysnz
commit bb664114480fae0ae9aaad11a09319123fe307b2
author Alpha Chen <alpha@kejadlen.dev>
date
parent owpnukrn
diff --git a/ai/bin/,agent-state b/ai/bin/,agent-state
index 4c3ec932..ce9158f4 100755
--- a/ai/bin/,agent-state
+++ b/ai/bin/,agent-state
@@ -1,12 +1,13 @@
 #!/usr/bin/env bash
 
-# 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.
+# 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.
 #
-# 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.
+# 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.
 #
 # `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
diff --git a/ai/bin/,agents b/ai/bin/,agents
index 0d23aea1..91b683b7 100755
--- a/ai/bin/,agents
+++ b/ai/bin/,agents
@@ -1,10 +1,11 @@
 #!/usr/bin/env bash
 
-# Picker for running Claude Code sessions, ranked so the ones blocked on a
-# human come first — barring any already jumped to and left blocked, which
+# Picker for running Claude Code and pi sessions, ranked so the ones blocked
+# 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 writes to.
+# State comes from ,agent-state, which the Claude hook and the pi extension
+# write to.
 
 set -euo pipefail
 if [[ "${TRACE-0}" == "1" ]]; then
@@ -18,7 +19,7 @@ main() {
     local rows
     rows=$("$state_script" list)
     if [[ -z $rows ]]; then
-        tmux display-message 'No Claude sessions tracked'
+        tmux display-message 'No agent sessions tracked'
         exit 0
     fi
 
diff --git a/ai/pi/extensions/README.md b/ai/pi/extensions/README.md
index e5f6dd7f..439f7063 100644
--- a/ai/pi/extensions/README.md
+++ b/ai/pi/extensions/README.md
@@ -1,3 +1,32 @@
+# Agent State
+
+Reports the session's blocked/working/idle state to `,agent-state`, so
+pi sessions appear in the `,agents` picker (prefix+A) and the sketchybar
+agents item alongside Claude Code ones.
+
+## How it works
+
+Maps pi lifecycle events to states, following the design of herdr's
+bundled pi integration:
+
+| Event | State |
+|-------|-------|
+| `agent_start` | working |
+| `agent_settled` (when actually idle) | idle, "turn finished" |
+| `session_start` | republish current state (reload-safe) |
+| `session_shutdown` | clear the pane's row |
+
+Active only in TUI mode inside tmux — RPC/JSON/print modes are headless,
+and the tmux pane is the row's identity. Writes go through a serial
+fire-and-forget queue to `~/.dotfiles/ai/bin/,agent-state`, then poke
+sketchybar to redraw.
+
+## Blocked state
+
+pi has no built-in permission prompt, so `permission-gate.ts` emits
+`agent-state:blocked` events on the shared extension event bus around its
+dialogs, and this extension records `blocked` for their duration.
+
 # Permission Gate
 
 Requires explicit user confirmation before every tool call, with
diff --git a/ai/pi/extensions/agent-state.ts b/ai/pi/extensions/agent-state.ts
new file mode 100644
index 00000000..df03a464
--- /dev/null
+++ b/ai/pi/extensions/agent-state.ts
@@ -0,0 +1,131 @@
+/**
+ * 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
+ * ,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"
+ *   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.
+ *
+ * Writes are fire-and-forget through a serial queue. Ordering matters
+ * (the clear that /resume fires must land before the session_start record
+ * that follows it), and a dropped write only means a stale row until the
+ * next event.
+ */
+
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+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
+// 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 = [
+	"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("; ");
+
+type AgentState = "blocked" | "working" | "idle";
+
+interface BlockedEvent {
+	active: boolean;
+	label?: string;
+}
+
+export default function (pi: ExtensionAPI) {
+	// The pane is the row's identity, so outside tmux there is nothing to
+	// record. ,agent-state reads TMUX_PANE itself and no-ops without it.
+	if (!process.env.TMUX_PANE) return;
+
+	// TUI only: RPC/JSON/print modes are headless, and RPC still reports
+	// hasUI=true, so mode is the reliable gate. Set once at session_start;
+	// every other handler checks it.
+	let interactive = false;
+	let cwd = process.cwd();
+
+	let agentActive = false;
+	let blocked = false;
+	let blockedMessage: string | undefined;
+	let lastState: AgentState | undefined;
+	let lastMessage: string | undefined;
+
+	let queue: Promise<void> = Promise.resolve();
+
+	function run(command: string, args: string[]): Promise<void> {
+		const task = queue.then(async () => {
+			try {
+				const result = await pi.exec(command, args, { timeout: 5000 });
+				if (result.code !== 0) return;
+				if (command === STATE_SCRIPT) {
+					await pi.exec("/bin/sh", ["-c", TRIGGER_SKETCHYBAR]);
+				}
+			} catch {
+				// Never surface state-tracking failures into the session.
+			}
+		});
+		queue = task;
+		return task;
+	}
+
+	function desired(): { state: AgentState; message?: string } {
+		if (blocked) return { state: "blocked", message: blockedMessage };
+		if (agentActive) return { state: "working" };
+		return { state: "idle", message: "turn finished" };
+	}
+
+	function publish(force = false): void {
+		const next = desired();
+		if (!force && next.state === lastState && next.message === lastMessage) return;
+		lastState = next.state;
+		lastMessage = next.message;
+		void run(STATE_SCRIPT, ["record", next.state, cwd, next.message ?? ""]);
+	}
+
+	pi.events.on("agent-state:blocked", (data) => {
+		if (!interactive) return;
+		const event = data as BlockedEvent;
+
+		blocked = event.active;
+		blockedMessage = event.active ? event.label : undefined;
+		publish();
+	});
+
+	pi.on("session_start", async (_event, ctx) => {
+		if (ctx.mode !== "tui") return;
+		interactive = true;
+		cwd = ctx.cwd;
+		// A reload can replace this extension mid-run without another
+		// agent_start, so re-derive activity from the session itself.
+		agentActive = ctx.isIdle() === false;
+		publish(true);
+	});
+
+	pi.on("agent_start", async () => {
+		if (!interactive) return;
+		agentActive = true;
+		publish();
+	});
+
+	pi.on("agent_settled", async (_event, ctx) => {
+		// Not settled if another extension started a new run, or if
+		// auto-retry/compaction is still queued.
+		if (!interactive || ctx.isIdle() !== true) return;
+		agentActive = false;
+		publish();
+	});
+
+	pi.on("session_shutdown", async () => {
+		if (!interactive) return;
+		void run(STATE_SCRIPT, ["clear"]);
+	});
+}
diff --git a/ai/pi/extensions/permission-gate.ts b/ai/pi/extensions/permission-gate.ts
index e9e2cdd9..3c350d7f 100644
--- a/ai/pi/extensions/permission-gate.ts
+++ b/ai/pi/extensions/permission-gate.ts
@@ -312,6 +312,16 @@ function isAllowed(toolName: string, input: Record<string, unknown>, ctx: Extens
   return false;
 }
 
+// ---------------------------------------------------------------------------
+// Blocked reporting
+// ---------------------------------------------------------------------------
+
+// Tells agent-state.ts that a permission dialog has the session stopped on
+// a human. Emitting is always safe: with no subscriber it is a no-op.
+function reportBlocked(pi: ExtensionAPI, active: boolean, label?: string): void {
+  pi.events.emit("agent-state:blocked", { active, label });
+}
+
 // ---------------------------------------------------------------------------
 // Extension entry point
 // ---------------------------------------------------------------------------
@@ -379,15 +389,20 @@ export default function(pi: ExtensionAPI) {
       const pending = pendingProjectRules;
       pendingProjectRules = null; // Clear so we only prompt once
 
-      const summary = summarizeRules(pending.rules);
-      const message = "Project wants to auto-allow:\n" + summary.map((s) => `  ${s}`).join("\n");
-      const approved = await ctx.ui.confirm("Project permissions", message);
-
-      if (approved) {
-        const approvals = loadApprovals();
-        approvals[ctx.cwd] = { hash: hashContent(pending.raw) };
-        saveApprovals(approvals);
-        allowedCommands = mergeRules(BASE_COMMANDS, pending.rules);
+      reportBlocked(pi, true, "project permissions");
+      try {
+        const summary = summarizeRules(pending.rules);
+        const message = "Project wants to auto-allow:\n" + summary.map((s) => `  ${s}`).join("\n");
+        const approved = await ctx.ui.confirm("Project permissions", message);
+
+        if (approved) {
+          const approvals = loadApprovals();
+          approvals[ctx.cwd] = { hash: hashContent(pending.raw) };
+          saveApprovals(approvals);
+          allowedCommands = mergeRules(BASE_COMMANDS, pending.rules);
+        }
+      } finally {
+        reportBlocked(pi, false);
       }
     }
 
@@ -395,27 +410,34 @@ export default function(pi: ExtensionAPI) {
 
     const summary = formatArgs(event.toolName, event.input);
 
-    // For write/edit, offer a "allow for session" option
-    if (event.toolName === "write" || event.toolName === "edit") {
-      const choice = await ctx.ui.select(`${event.toolName}: ${summary}`, [
-        "Allow once",
-        "Allow for session",
-        "Block",
-      ]);
-
-      if (choice === "Allow for session") {
-        sessionAllowEdits = true;
-        ctx.ui.notify("Edits allowed for this session (tracked files only)", "info");
-        return;
+    // Every dialog below blocks the session on a human; the finally keeps
+    // the paired un-block report happening on early returns too.
+    reportBlocked(pi, true, event.toolName);
+    try {
+      // For write/edit, offer a "allow for session" option
+      if (event.toolName === "write" || event.toolName === "edit") {
+        const choice = await ctx.ui.select(`${event.toolName}: ${summary}`, [
+          "Allow once",
+          "Allow for session",
+          "Block",
+        ]);
+
+        if (choice === "Allow for session") {
+          sessionAllowEdits = true;
+          ctx.ui.notify("Edits allowed for this session (tracked files only)", "info");
+          return;
+        }
+        if (choice === "Allow once") return;
+        return { block: true, reason: "Blocked by user" };
       }
-      if (choice === "Allow once") return;
-      return { block: true, reason: "Blocked by user" };
-    }
 
-    const allowed = await ctx.ui.confirm(event.toolName, summary);
+      const allowed = await ctx.ui.confirm(event.toolName, summary);
 
-    if (!allowed) {
-      return { block: true, reason: "Blocked by user" };
+      if (!allowed) {
+        return { block: true, reason: "Blocked by user" };
+      }
+    } finally {
+      reportBlocked(pi, false);
     }
   });
 }