add herdr and garmin app config; clean up runtime files
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
// 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=3
|
||||
// @ts-nocheck
|
||||
|
||||
import { createConnection } from "node:net";
|
||||
|
||||
const HERDR_ENV = process.env.HERDR_ENV;
|
||||
const socketPath = process.env.HERDR_SOCKET_PATH;
|
||||
const paneId = process.env.HERDR_PANE_ID;
|
||||
const source = "herdr:pi";
|
||||
|
||||
function enabled() {
|
||||
return HERDR_ENV === "1" && !!socketPath && !!paneId;
|
||||
}
|
||||
|
||||
function sendRequest(request: unknown): Promise<void> {
|
||||
if (!enabled()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
socket.destroy();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const socket = createConnection(socketPath!);
|
||||
socket.on("error", finish);
|
||||
socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
|
||||
socket.on("data", finish);
|
||||
socket.on("end", finish);
|
||||
const timeout = setTimeout(finish, 500);
|
||||
timeout.unref?.();
|
||||
});
|
||||
}
|
||||
|
||||
type AgentState = "working" | "blocked" | "idle";
|
||||
|
||||
type QueuedState = {
|
||||
state: AgentState;
|
||||
message?: string;
|
||||
seq: number;
|
||||
};
|
||||
|
||||
const idleDebounceMs = parseDurationEnv("HERDR_PI_IDLE_DEBOUNCE_MS", 250);
|
||||
const retryGraceMs = parseDurationEnv("HERDR_PI_RETRY_GRACE_MS", 2500);
|
||||
const retryableErrorPattern =
|
||||
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
|
||||
let reportSeq = Date.now() * 1000;
|
||||
let currentAgentSessionId: string | undefined;
|
||||
let currentAgentSessionPath: string | undefined;
|
||||
|
||||
function nextReportSeq(): number {
|
||||
reportSeq += 1;
|
||||
return reportSeq;
|
||||
}
|
||||
|
||||
function parseDurationEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
if (currentAgentSessionPath) {
|
||||
return { ...params, agent_session_path: currentAgentSessionPath };
|
||||
}
|
||||
if (currentAgentSessionId) {
|
||||
return { ...params, agent_session_id: currentAgentSessionId };
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function currentSessionRef(): Record<string, unknown> | undefined {
|
||||
if (currentAgentSessionPath) {
|
||||
return { agent_session_path: currentAgentSessionPath };
|
||||
}
|
||||
if (currentAgentSessionId) {
|
||||
return { agent_session_id: currentAgentSessionId };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function reportSession(): Promise<void> {
|
||||
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(),
|
||||
...sessionRef,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise<void> {
|
||||
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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function releaseAgent(): Promise<void> {
|
||||
return sendRequest({
|
||||
id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
||||
method: "pane.release_agent",
|
||||
params: {
|
||||
pane_id: paneId,
|
||||
source,
|
||||
agent: "pi",
|
||||
seq: nextReportSeq(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function lastAssistantMessage(messages: unknown[]): any | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i] as any;
|
||||
if (message?.role === "assistant") {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function retryableErrorMessage(event: any): string | undefined {
|
||||
const messages = Array.isArray(event?.messages) ? event.messages : [];
|
||||
const assistant = lastAssistantMessage(messages);
|
||||
if (assistant?.stopReason !== "error") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const errorMessage = String(assistant.errorMessage ?? "");
|
||||
if (!retryableErrorPattern.test(errorMessage)) {
|
||||
return undefined;
|
||||
}
|
||||
return errorMessage || "retryable provider error";
|
||||
}
|
||||
|
||||
export default function (pi) {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let agentActive = false;
|
||||
let retryHoldActive = false;
|
||||
let failureBlocked = false;
|
||||
let failureMessage: string | undefined;
|
||||
let blockedCount = 0;
|
||||
let blockedMessage: string | undefined;
|
||||
let lastState: AgentState | undefined;
|
||||
let lastMessage: string | undefined;
|
||||
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let rootSession = false;
|
||||
|
||||
function clearTimer(timer: ReturnType<typeof setTimeout> | undefined) {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingTimers() {
|
||||
clearTimer(idleTimer);
|
||||
clearTimer(retryTimer);
|
||||
idleTimer = undefined;
|
||||
retryTimer = undefined;
|
||||
}
|
||||
|
||||
function clearFailureState() {
|
||||
retryHoldActive = false;
|
||||
failureBlocked = false;
|
||||
failureMessage = undefined;
|
||||
}
|
||||
|
||||
function desiredState() {
|
||||
if (blockedCount > 0) {
|
||||
return { state: "blocked" as const, message: blockedMessage };
|
||||
}
|
||||
if (failureBlocked) {
|
||||
return { state: "blocked" as const, message: failureMessage };
|
||||
}
|
||||
if (agentActive || retryHoldActive) {
|
||||
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);
|
||||
}
|
||||
|
||||
function scheduleIdle() {
|
||||
clearPendingTimers();
|
||||
clearFailureState();
|
||||
idleTimer = setTimeout(() => {
|
||||
idleTimer = undefined;
|
||||
publishState();
|
||||
}, idleDebounceMs);
|
||||
idleTimer.unref?.();
|
||||
}
|
||||
|
||||
function holdForRetry(message: string) {
|
||||
clearPendingTimers();
|
||||
retryHoldActive = true;
|
||||
failureBlocked = false;
|
||||
failureMessage = message;
|
||||
publishState();
|
||||
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = undefined;
|
||||
retryHoldActive = false;
|
||||
failureBlocked = true;
|
||||
publishState();
|
||||
}, retryGraceMs);
|
||||
retryTimer.unref?.();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
clearPendingTimers();
|
||||
blockedCount += 1;
|
||||
blockedMessage = data.label;
|
||||
publishState();
|
||||
});
|
||||
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
if (ctx?.hasUI !== true) {
|
||||
return;
|
||||
}
|
||||
rootSession = true;
|
||||
updateSessionRef(ctx);
|
||||
void reportSession();
|
||||
publishState(true);
|
||||
});
|
||||
|
||||
pi.on("agent_start", () => {
|
||||
if (!rootSession) {
|
||||
return;
|
||||
}
|
||||
clearPendingTimers();
|
||||
clearFailureState();
|
||||
agentActive = true;
|
||||
publishState();
|
||||
});
|
||||
|
||||
pi.on("agent_end", (event) => {
|
||||
if (!rootSession) {
|
||||
return;
|
||||
}
|
||||
if (!agentActive) {
|
||||
// Pi can emit duplicate/late end events while auto-retry is already
|
||||
// holding the pane in Working. Do not let an unqualified duplicate end
|
||||
// cancel the retry hold and publish a false Idle.
|
||||
return;
|
||||
}
|
||||
|
||||
agentActive = false;
|
||||
|
||||
const retryableMessage = retryableErrorMessage(event);
|
||||
if (retryableMessage) {
|
||||
holdForRetry(retryableMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleIdle();
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
if (!rootSession) {
|
||||
return;
|
||||
}
|
||||
clearPendingTimers();
|
||||
await releaseAgent();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user