1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
/**
 * Agent State - report this pi session's state to ,agent-state.
 *
 * 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.
 *
 *   agent_start             -> working
 *   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 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
 * 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 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 = [
	"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" | "done" | "idle";
type BlockedKind = "permission" | "question" | "auth";

interface BlockedEvent {
	active: boolean;
	label?: string;
	kind?: BlockedKind;
}

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 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();

	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; kind?: BlockedKind } {
		if (blocked) return { state: "blocked", message: blockedMessage, kind: blockedKind };
		if (agentActive) return { state: "working" };
		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 &&
			next.kind === lastKind
		) {
			return;
		}
		lastState = next.state;
		lastMessage = 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) => {
		if (!interactive) return;
		const event = data as BlockedEvent;

		blocked = event.active;
		blockedMessage = event.active ? event.label : undefined;
		blockedKind = event.kind ?? "permission";
		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;
		ranTurn = 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"]);
	});
}