ai: track pi sessions in agent-state
Assisted-by: GLM-5.2 via pi
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);
}
});
}