main
1// installed by herdr
2// managed by herdr; reinstalling or updating the integration overwrites this file.
3// add custom hooks/plugins beside this file instead of editing it.
4// HERDR_INTEGRATION_ID=pi
5// HERDR_INTEGRATION_VERSION=6
6// @ts-nocheck
7
8import net from "node:net";
9
10const HERDR_ENV = process.env.HERDR_ENV;
11const socketPath = process.env.HERDR_SOCKET_PATH;
12const socketEndpoint =
13 process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath;
14const paneId = process.env.HERDR_PANE_ID;
15const source = "herdr:pi";
16
17function enabled() {
18 return HERDR_ENV === "1" && !!socketPath && !!paneId;
19}
20
21function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
22 if (!enabled()) {
23 return Promise.resolve(true);
24 }
25
26 return new Promise((resolve) => {
27 let done = false;
28 let timeout: ReturnType<typeof setTimeout> | undefined;
29 const finish = (delivered: boolean) => {
30 if (done) return;
31 done = true;
32 if (timeout) {
33 clearTimeout(timeout);
34 }
35 socket.destroy();
36 resolve(delivered);
37 };
38
39 const socket = net.createConnection(socketEndpoint!);
40 socket.on("error", () => finish(false));
41 socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
42 socket.on("data", () => finish(true));
43 socket.on("end", () => finish(false));
44 timeout = setTimeout(() => finish(false), timeoutMs);
45 timeout.unref?.();
46 });
47}
48
49async function sendRequest(request: unknown): Promise<void> {
50 if (await sendRequestAttempt(request, 500)) {
51 return;
52 }
53 await sendRequestAttempt(request, 1500);
54}
55
56type AgentState = "working" | "blocked" | "idle";
57
58type QueuedState = {
59 state: AgentState;
60 message?: string;
61 seq: number;
62};
63
64let reportSeq = Date.now() * 1000;
65let currentAgentSessionId: string | undefined;
66let currentAgentSessionPath: string | undefined;
67
68function nextReportSeq(): number {
69 reportSeq += 1;
70 return reportSeq;
71}
72
73function updateSessionRef(ctx: any): void {
74 try {
75 const file = ctx?.sessionManager?.getSessionFile?.();
76 currentAgentSessionPath =
77 typeof file === "string" && file.startsWith("/") ? file : undefined;
78 } catch {
79 currentAgentSessionPath = undefined;
80 }
81
82 try {
83 const id = ctx?.sessionManager?.getSessionId?.();
84 currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined;
85 } catch {
86 currentAgentSessionId = undefined;
87 }
88}
89
90function withSessionRef(params: Record<string, unknown>): Record<string, unknown> {
91 if (currentAgentSessionPath) {
92 return { ...params, agent_session_path: currentAgentSessionPath };
93 }
94 if (currentAgentSessionId) {
95 return { ...params, agent_session_id: currentAgentSessionId };
96 }
97 return params;
98}
99
100function currentSessionRef(): Record<string, unknown> | undefined {
101 if (currentAgentSessionPath) {
102 return { agent_session_path: currentAgentSessionPath };
103 }
104 if (currentAgentSessionId) {
105 return { agent_session_id: currentAgentSessionId };
106 }
107 return undefined;
108}
109
110function reportSession(sessionStartSource?: string): Promise<void> {
111 const sessionRef = currentSessionRef();
112 if (!sessionRef) {
113 return Promise.resolve();
114 }
115
116 return sendRequest({
117 id: `${source}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`,
118 method: "pane.report_agent_session",
119 params: {
120 pane_id: paneId,
121 source,
122 agent: "pi",
123 seq: nextReportSeq(),
124 session_start_source: sessionStartSource,
125 ...sessionRef,
126 },
127 });
128}
129
130function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise<void> {
131 return sendRequest({
132 id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
133 method: "pane.report_agent",
134 params: withSessionRef({
135 pane_id: paneId,
136 source,
137 agent: "pi",
138 state,
139 message,
140 seq,
141 }),
142 });
143}
144
145function releaseAgent(): Promise<void> {
146 return sendRequest({
147 id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`,
148 method: "pane.release_agent",
149 params: {
150 pane_id: paneId,
151 source,
152 agent: "pi",
153 seq: nextReportSeq(),
154 },
155 });
156}
157
158function shouldReleaseOnSessionShutdown(event: any): boolean {
159 // Pi tears down and rebinds extension runtimes for internal lifecycle actions
160 // such as /reload, /new, /resume, and /fork. Those do not mean the pane's
161 // agent process has exited, and releasing hook authority there can suppress
162 // legitimate reports from the replacement runtime. Only a user/process quit
163 // should release Herdr's full-lifecycle authority.
164 const reason = event?.reason;
165 return reason === "quit";
166}
167
168let sendInFlight = false;
169let queuedState: QueuedState | undefined;
170
171function queueState(state: AgentState, message?: string): void {
172 queuedState = { state, message, seq: nextReportSeq() };
173 if (!sendInFlight) {
174 void drainStateQueue();
175 }
176}
177
178async function drainStateQueue(): Promise<void> {
179 if (sendInFlight) {
180 return;
181 }
182
183 sendInFlight = true;
184 try {
185 while (queuedState) {
186 const next = queuedState;
187 queuedState = undefined;
188 await sendState(next.state, next.message, next.seq);
189 }
190 } finally {
191 sendInFlight = false;
192 if (queuedState) {
193 void drainStateQueue();
194 }
195 }
196}
197
198export default function (pi) {
199 if (!enabled()) {
200 return;
201 }
202
203 let agentActive = false;
204 let blockedCount = 0;
205 let blockedMessage: string | undefined;
206 let lastState: AgentState | undefined;
207 let lastMessage: string | undefined;
208 let rootSession = false;
209
210 function desiredState() {
211 if (blockedCount > 0) {
212 return { state: "blocked" as const, message: blockedMessage };
213 }
214 if (agentActive) {
215 return { state: "working" as const, message: undefined };
216 }
217 return { state: "idle" as const, message: undefined };
218 }
219
220 function publishState(force = false) {
221 const next = desiredState();
222 if (!force && next.state === lastState && next.message === lastMessage) {
223 return;
224 }
225 lastState = next.state;
226 lastMessage = next.message;
227 queueState(next.state, next.message);
228 }
229
230 pi.events.on("herdr:blocked", (data) => {
231 if (!rootSession) {
232 return;
233 }
234 if (!data?.active) {
235 blockedCount = Math.max(0, blockedCount - 1);
236 if (blockedCount === 0) {
237 blockedMessage = undefined;
238 }
239 publishState();
240 return;
241 }
242
243 blockedCount += 1;
244 blockedMessage = data.label;
245 publishState();
246 });
247
248 pi.on("session_start", async (event, ctx) => {
249 if (ctx?.hasUI !== true) {
250 return;
251 }
252 rootSession = true;
253 updateSessionRef(ctx);
254 await reportSession(event?.reason);
255 // A reload can replace this extension mid-run without emitting another agent_start.
256 agentActive = ctx?.isIdle?.() === false;
257 publishState(true);
258 });
259
260 pi.on("agent_start", (_event, ctx) => {
261 if (!rootSession) {
262 return;
263 }
264 updateSessionRef(ctx);
265 void reportSession();
266 agentActive = true;
267 publishState();
268 });
269
270 pi.on("agent_settled", (_event, ctx) => {
271 if (!rootSession || ctx?.isIdle?.() !== true) {
272 return;
273 }
274
275 agentActive = false;
276 publishState();
277 });
278
279 pi.on("session_shutdown", async (event) => {
280 if (!rootSession) {
281 return;
282 }
283 if (shouldReleaseOnSessionShutdown(event)) {
284 await releaseAgent();
285 }
286 });
287}