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}