// installed by herdr // managed by herdr; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. // HERDR_INTEGRATION_ID=pi // HERDR_INTEGRATION_VERSION=8 // @ts-nocheck // // --------------------------------------------------------------------------- // 以下为本包(@muzi3/herdr)的说明,不属于 herdr 官方正文: // // 1. 本文件上方内容是 herdr 官方 pi 集成正文的副本,保留是为了将来能直接与上游重新同步: // 上游 `HERDR_INTEGRATION_VERSION` 从当前的 8 往上升时,重取 `herdr integration install pi` // 注入的文件并替换本文件的官方段落即可(文件末尾的「本包新增」段落不受影响)。 // 本包对官方段落只有**一处**改动:`export default` 开头插入一行 // `registerBlockedReporting(pi);`(带「本包新增」标记)。因为官方的 default export 就是本 // 扩展唯一入口,blocked 上报只能挂在那里;重贴官方段落后必须补回这一行。 // 对齐判据:`diff <重取的注入文件> extensions/herdr-agent-state/index.ts` 应只有三处新增、零删除 —— // (1)`@ts-nocheck` 之后的本包说明段落;(2)官方 `export default` 开头那 1 行插入; // (3)文件末尾的「本包新增」段落。出现任何删除行,都说明官方段落被改写了。 // 漏掉那行插入会让 blocked 上报静默失效(tests/herdr-agent-state.test.ts 会失败)。 // 其余官方代码不改写,缩进保持其 2 空格风格;本包新增段落用仓库统一的 tab 缩进。 // 2. 职责:向 herdr pane socket 上报 agent 状态(working / blocked / idle)与会话引用, // 并附带 blocked 状态上报(原先那个独立的 blocked-state.ts 扩展已并入文件末尾)。 // 3. 本文件是包内三个**互相独立**的扩展之一,每个扩展独占一个目录、以 `index.ts` 为入口 // (herdr-agent-state / pane-agent-tools / pi-hot-restart 互不 import,各自在 // `pi.extensions` 中显式列出)。目录名与 herdr 集成注入的文件同名,便于与注入副本对齐。 // --------------------------------------------------------------------------- import net from "node:net"; const HERDR_ENV = process.env.HERDR_ENV; const socketPath = process.env.HERDR_SOCKET_PATH; const socketEndpoint = process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath; const paneId = process.env.HERDR_PANE_ID; const source = "herdr:pi"; function enabled() { return HERDR_ENV === "1" && !!socketPath && !!paneId; } function sendRequestAttempt(request: unknown, timeoutMs: number): Promise { if (!enabled()) { return Promise.resolve(true); } return new Promise((resolve) => { let done = false; let timeout: ReturnType | undefined; const finish = (delivered: boolean) => { if (done) return; done = true; if (timeout) { clearTimeout(timeout); } socket.destroy(); resolve(delivered); }; const socket = net.createConnection(socketEndpoint!); socket.on("error", () => finish(false)); socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`)); socket.on("data", () => finish(true)); socket.on("end", () => finish(false)); timeout = setTimeout(() => finish(false), timeoutMs); timeout.unref?.(); }); } async function sendRequest(request: unknown): Promise { if (await sendRequestAttempt(request, 500)) { return; } await sendRequestAttempt(request, 1500); } type AgentState = "working" | "blocked" | "idle"; type QueuedState = { state: AgentState; message?: string; seq: number; }; let reportSeq = Date.now() * 1000; let currentAgentSessionId: string | undefined; let currentAgentSessionPath: string | undefined; function nextReportSeq(): number { reportSeq += 1; return reportSeq; } function updateSessionRef(ctx: any): void { try { const file = ctx?.sessionManager?.getSessionFile?.(); currentAgentSessionPath = typeof file === "string" && file.startsWith("/") ? file : undefined; } catch { currentAgentSessionPath = undefined; } try { const id = ctx?.sessionManager?.getSessionId?.(); currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined; } catch { currentAgentSessionId = undefined; } } function withSessionRef(params: Record): Record { if (currentAgentSessionPath) { return { ...params, agent_session_path: currentAgentSessionPath }; } if (currentAgentSessionId) { return { ...params, agent_session_id: currentAgentSessionId }; } return params; } function currentSessionRef(): Record | undefined { if (currentAgentSessionPath) { return { agent_session_path: currentAgentSessionPath }; } if (currentAgentSessionId) { return { agent_session_id: currentAgentSessionId }; } return undefined; } function reportSession(sessionStartSource?: string): Promise { const sessionRef = currentSessionRef(); if (!sessionRef) { return Promise.resolve(); } return sendRequest({ id: `${source}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`, method: "pane.report_agent_session", params: { pane_id: paneId, source, agent: "pi", seq: nextReportSeq(), session_start_source: sessionStartSource, ...sessionRef, }, }); } function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise { return sendRequest({ id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`, method: "pane.report_agent", params: withSessionRef({ pane_id: paneId, source, agent: "pi", state, message, seq, }), }); } let sendInFlight = false; let queuedState: QueuedState | undefined; function queueState(state: AgentState, message?: string): void { queuedState = { state, message, seq: nextReportSeq() }; if (!sendInFlight) { void drainStateQueue(); } } async function drainStateQueue(): Promise { if (sendInFlight) { return; } sendInFlight = true; try { while (queuedState) { const next = queuedState; queuedState = undefined; await sendState(next.state, next.message, next.seq); } } finally { sendInFlight = false; if (queuedState) { void drainStateQueue(); } } } export default function (pi) { registerBlockedReporting(pi); // 本包新增:本扩展唯一入口在这里,blocked 上报只能挂这里(见文件头第 1 条) if (!enabled()) { return; } let agentActive = false; let blockedCount = 0; let blockedMessage: string | undefined; let lastState: AgentState | undefined; let lastMessage: string | undefined; let rootSession = false; function desiredState() { if (blockedCount > 0) { return { state: "blocked" as const, message: blockedMessage }; } if (agentActive) { return { state: "working" as const, message: undefined }; } return { state: "idle" as const, message: undefined }; } function publishState(force = false) { const next = desiredState(); if (!force && next.state === lastState && next.message === lastMessage) { return; } lastState = next.state; lastMessage = next.message; queueState(next.state, next.message); } pi.events.on("herdr:blocked", (data) => { if (!rootSession) { return; } if (!data?.active) { blockedCount = Math.max(0, blockedCount - 1); if (blockedCount === 0) { blockedMessage = undefined; } publishState(); return; } blockedCount += 1; blockedMessage = data.label; publishState(); }); pi.on("session_start", async (event, ctx) => { // TUI only: RPC/JSON/print modes are headless (no PTY herdr can display), // and RPC still reports hasUI=true, so mode is the reliable gate. if (ctx?.mode !== "tui") { return; } rootSession = true; updateSessionRef(ctx); await reportSession(event?.reason); // A reload can replace this extension mid-run without emitting another agent_start. agentActive = ctx?.isIdle?.() === false; publishState(true); }); pi.on("agent_start", (_event, ctx) => { if (!rootSession) { return; } updateSessionRef(ctx); void reportSession(); agentActive = true; publishState(); }); pi.on("agent_settled", (_event, ctx) => { if (!rootSession || ctx?.isIdle?.() !== true) { return; } agentActive = false; publishState(); }); } /* =========================================================================== * 本包新增(@muzi3/herdr)—— 以上为 herdr 官方正文,以下为本包增量。 * * blocked 状态上报:原 blocked-state.ts(已删除的独立扩展)的全部内容并入本文件,与 agent * 状态上报同属「向 herdr 上报状态」这一个职责。 * * 合并只是减少一个文件,没改行为:这里仍然只发 `herdr:blocked` 事件,由上方官方段落的 * 监听器换算成 blockedCount。保持事件通道而不是直接写 socket,一是 pi-subagents 的 * herdr-status bridge 也通过它上报,共用同一条计数路径才不会出现两个入口重复计数; * 二是正式契约不变,与上游重新同步时无需改动监听逻辑。 * =========================================================================== */ /** herdr pi 集成的入站事件通道(由上方官方段落消费)。 */ const HERDR_BLOCKED_EVENT = "herdr:blocked"; /** @juicesharp/rpiv-ask-user-question 的公开事件通道。 */ const ASK_USER_PROMPT_EVENT = "rpiv:ask-user:prompt"; /** 与官方段落同一门槛:确认自己确实跑在 herdr 的 pane 里。 */ const IN_HERDR = process.env.HERDR_ENV === "1" && !!process.env.HERDR_PANE_ID; const MAX_LABEL_CHARS = 60; const WAITING = "等待回答"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } /** label 是一行状态文字,换行/多空格先折叠掉。 */ function flatten(text: string): string { return text.replace(/\s+/g, " ").trim(); } function truncate(text: string, max: number): string { return text.length > max ? `${text.slice(0, max - 1)}…` : text; } /** 取第一条问题的 header(短)与原文(长)。 */ function readQuestion(data: unknown): { header?: string; question?: string } | undefined { if (!isRecord(data) || !Array.isArray(data.questions)) { return undefined; } const first = data.questions.find(isRecord); if (!first) { return undefined; } const header = typeof first.header === "string" ? flatten(first.header) : ""; const question = typeof first.question === "string" ? flatten(first.question) : ""; if (!header && !question) { return undefined; } return { header: header || undefined, question: question || undefined }; } /** * blocked 状态上报(原 blocked-state.ts,由本文件 default export 调用)。 * * herdr 的 pi 集成只认 `herdr:blocked` 事件,而它内置的终端识别规则只有一条 * working_literal(匹配 "Working...")、没有任何 blocked 规则 —— 没有这个桥, * ask_user_question 期间 pane 会一直显示 working,切到别的 pane 就看不出这个会话 * 正等着人回话。 * * 信号来源是 pi core 的 `ui_prompt_start` / `ui_prompt_end`:runner 统一包装了 * select/confirm/input/editor/custom,只在外层提示触发,所以覆盖所有阻塞弹窗。 * `custom`(ask_user_question 走这条)不带 title,额外借 `rpiv:ask-user:prompt` 的 * 问题内容补 label。 * * herdr 侧的 blockedCount 是计数式的:切 label 前必须先发 `{active:false}` 降自己 * 那一份再发 `{active:true}`。本模块严格配对 raise/lower,只占一个计数。 * * 签名不标注 ExtensionAPI:本文件是 @ts-nocheck 的官方副本,上方官方段落同样是 * 未标注的 `function (pi)`,保持一致便无需为类型向官方段落插入 import。 */ export function registerBlockedReporting(pi) { if (!IN_HERDR) { return; } let rootSession = false; let raised = false; let raisedLabel: string | undefined; let askUser: { header?: string; question?: string } | undefined; function lower(): void { if (!raised) return; raised = false; raisedLabel = undefined; pi.events.emit(HERDR_BLOCKED_EVENT, { active: false }); } function raise(detail: string | undefined): void { const label = detail ? `${WAITING}:${truncate(detail, MAX_LABEL_CHARS)}` : WAITING; if (raised && raisedLabel === label) return; lower(); // 先降自己那一份再抬高,保证 label 切换时计数不残留 raised = true; raisedLabel = label; pi.events.emit(HERDR_BLOCKED_EVENT, { active: true, label }); } pi.events.on(ASK_USER_PROMPT_EVENT, (data) => { askUser = readQuestion(data); }); pi.on("ui_prompt_start", (event) => { if (!rootSession) return; // custom 提示(ask_user_question 走这条)的 title 是空的,用问题内容补 label。 raise(event.kind === "custom" ? (askUser?.header ?? askUser?.question) : event.title); }); pi.on("ui_prompt_end", () => { askUser = undefined; if (!rootSession) return; lower(); }); pi.on("session_start", (_event, ctx) => { // 与 herdr 集成同一门槛:RPC/print 没有可展示的 PTY; // reload 会同时重载集成与本扩展,两侧计数一起归零。 if (ctx?.mode !== "tui") return; rootSession = true; raised = false; raisedLabel = undefined; askUser = undefined; }); pi.on("session_shutdown", () => { lower(); rootSession = false; askUser = undefined; }); }