aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/assets/pi/pi-agent-state.ts
blob: 4c21afe4bbe19324a60d4dfd08f05340c2adf8b0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
// 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";

// 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`;

// tmux sets TMUX_PANE in the environment of every process it starts in a
// pane, so it names exactly the pane this pi runs in. (Asking tmux for "the
// current pane" is wrong: that is the session's active pane, not ours.)
const paneId = process.env.DINGGER_PANE_ID || process.env.TMUX_PANE;
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";

// The session file, so `dingger restore` can resume this session with
// `pi --session <file>`. It changes with /new, /resume, and forks.
let sessionFile: string | undefined;

function updateSessionFile(ctx: any): void {
  try {
    const file = ctx?.sessionManager?.getSessionFile?.();
    sessionFile = typeof file === "string" && file.length > 0 ? file : undefined;
  } catch {
    sessionFile = undefined;
  }
}

// The agent's importance (/importance): it orders agents within a status
// in dingger's list and jump, and a low agent makes no sound. Saved in the
// session (a custom entry, never sent to the model), so it comes back with
// the conversation: on /resume, `pi --session`, and dingger's restore.
// Other extensions can follow it on the event bus: `dingger:importance`,
// `{ level }`, at session start and on every change.
type Importance = "low" | "normal" | "high";
const importanceLevels: Importance[] = ["high", "normal", "low"];
const importanceEntry = "dingger-importance";
let importance: Importance = "normal";

function savedImportance(ctx: any): Importance {
  try {
    const entries = ctx?.sessionManager?.getEntries?.() ?? [];
    for (let i = entries.length - 1; i >= 0; i--) {
      const entry = entries[i];
      if (entry?.type === "custom" && entry.customType === importanceEntry) {
        const level = entry.data?.level;
        return importanceLevels.includes(level) ? level : "normal";
      }
    }
  } catch {
    // An unreadable session: the default.
  }
  return "normal";
}

function report(state: AgentState | undefined, 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 ? { state } : {}),
      message,
      importance,
      seq: Date.now() * 1000,
      ...(sessionFile ? { agent_session_path: sessionFile } : {}),
    },
  });
}

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();
  });

  function showImportance(ctx: any) {
    ctx?.ui?.setStatus?.("dingger-importance", importance === "normal" ? undefined : `importance: ${importance}`);
    pi.events.emit("dingger:importance", { level: importance });
  }

  pi.registerCommand("importance", {
    description: "Set this agent's importance in dingger: high, normal, or low",
    // None once a level is typed out, so Enter runs the command at once.
    getArgumentCompletions: (prefix: string) =>
      importanceLevels.includes(prefix.trim() as Importance)
        ? null
        : importanceLevels
            .filter((level) => level.startsWith(prefix.trim()))
            .map((level) => ({ value: level, label: level })),
    handler: async (args: string, ctx: any) => {
      const wanted = args.trim();
      if (!wanted) {
        ctx.ui.notify(`importance: ${importance}`, "info");
        return;
      }
      if (!importanceLevels.includes(wanted as Importance)) {
        ctx.ui.notify("importance is high, normal, or low", "error");
        return;
      }
      if (wanted !== importance) {
        importance = wanted as Importance;
        pi.appendEntry(importanceEntry, { level: importance });
        showImportance(ctx);
        // Without a state: the state stays as last reported.
        if (enabled()) {
          void report(undefined);
        }
      }
      ctx.ui.notify(`importance: ${importance}`, "info");
    },
  });

  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;
    updateSessionFile(ctx);
    // Each session has its own: /new starts at normal, /resume brings its own back.
    importance = savedImportance(ctx);
    showImportance(ctx);
    publishState(true);
  });

  pi.on("agent_start", (_event, ctx) => {
    agentActive = true;
    updateSessionFile(ctx);
    publishState();
  });

  pi.on("agent_settled", (_event, ctx) => {
    if (ctx?.isIdle?.() !== true) {
      return;
    }

    agentActive = false;
    publishState();
  });
}