aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/assets/pi/pi-agent-state.ts
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/assets/pi/pi-agent-state.ts')
-rw-r--r--crates/dinggerd/assets/pi/pi-agent-state.ts186
1 files changed, 186 insertions, 0 deletions
diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts
new file mode 100644
index 0000000..49d4b9c
--- /dev/null
+++ b/crates/dinggerd/assets/pi/pi-agent-state.ts
@@ -0,0 +1,186 @@
+// installed by dingger
+// managed by dingger; reinstalling or updating the integration overwrites this file.
+// add custom hooks/plugins beside this file instead of editing it.
+
+import net from "node:net";
+import path from "node:path";
+import { execSync } from "node:child_process";
+
+// The dingger hook receiver listens here; override with DINGGER_AGENT_SOCKET.
+const socketPath =
+ process.env.DINGGER_AGENT_SOCKET ||
+ `${process.env.HOME}/.local/state/dingger/agent.sock`;
+
+// The extension runs inside a tmux pane, so it finds its own pane id.
+function discoverPaneId(): string | undefined {
+ try {
+ const out = execSync('tmux display-message -p "#{pane_id}"', {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ });
+ const paneId = out.trim();
+ return paneId.startsWith("%") ? paneId : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+const paneId = process.env.DINGGER_PANE_ID || discoverPaneId();
+const source = "dingger:pi";
+
+function enabled() {
+ return !!paneId;
+}
+
+function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
+ if (!enabled()) {
+ return Promise.resolve(true);
+ }
+
+ return new Promise((resolve) => {
+ let done = false;
+ let timeout: ReturnType<typeof setTimeout> | undefined;
+ const finish = (delivered: boolean) => {
+ if (done) return;
+ done = true;
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ socket.destroy();
+ resolve(delivered);
+ };
+
+ const socket = net.createConnection(socketPath);
+ 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<void> {
+ if (await sendRequestAttempt(request, 500)) {
+ return;
+ }
+ await sendRequestAttempt(request, 1500);
+}
+
+type AgentState = "working" | "blocked" | "idle";
+
+function report(state: AgentState, message?: string): Promise<void> {
+ return sendRequest({
+ id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
+ method: "pane.report_agent",
+ params: {
+ pane_id: paneId!,
+ source,
+ agent: "pi",
+ state,
+ message,
+ seq: Date.now() * 1000,
+ },
+ });
+}
+
+let sendInFlight = false;
+let queuedState: { state: AgentState; message?: string } | undefined;
+
+function queueState(state: AgentState, message?: string): void {
+ queuedState = { state, message };
+ if (!sendInFlight) {
+ void drainStateQueue();
+ }
+}
+
+async function drainStateQueue(): Promise<void> {
+ if (sendInFlight) {
+ return;
+ }
+
+ sendInFlight = true;
+ try {
+ while (queuedState) {
+ const next = queuedState;
+ queuedState = undefined;
+ await report(next.state, next.message);
+ }
+ } finally {
+ sendInFlight = false;
+ if (queuedState) {
+ void drainStateQueue();
+ }
+ }
+}
+
+export default function (pi) {
+ if (!enabled()) {
+ return;
+ }
+
+ let agentActive = false;
+ let blockedCount = 0;
+ let blockedMessage: string | undefined;
+ let lastState: AgentState | undefined;
+ let lastMessage: string | undefined;
+
+ 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("blocked", (data) => {
+ 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.
+ if (ctx?.mode !== "tui") {
+ return;
+ }
+ // 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) => {
+ agentActive = true;
+ publishState();
+ });
+
+ pi.on("agent_settled", (_event, ctx) => {
+ if (ctx?.isIdle?.() !== true) {
+ return;
+ }
+
+ agentActive = false;
+ publishState();
+ });
+}