char-slop/ai-dots

ai dotfiles

git clone https://git.t4t.associates/char-slop/ai-dots

Charlotte Somrename 'open_url' to 'new_browser_tab' for claritybfafa36

main
18.1 KiB465 linesraw
1import { spawn } from "node:child_process";
2import { createHash } from "node:crypto";
3import { readdirSync, statSync } from "node:fs";
4import { createConnection } from "node:net";
5import { join } from "node:path";
6import { StringEnum, Type, type ImageContent, type TextContent } from "@earendil-works/pi-ai";
7import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8import { Text } from "@earendil-works/pi-tui";
9
10const runtimeDir = process.env.XDG_RUNTIME_DIR ?? `/run/user/${process.getuid?.()}`;
11
12interface ComputerUseDetails {
13  screenshotIds: string[];
14}
15
16function waylandEnv() {
17  const configured = process.env.WAYLAND_DISPLAY;
18  if (configured) {
19    try {
20      if (statSync(join(runtimeDir, configured)).isSocket()) {
21        return { ...process.env, XDG_RUNTIME_DIR: runtimeDir, WAYLAND_DISPLAY: configured };
22      }
23    } catch {}
24  }
25
26  const display = readdirSync(runtimeDir)
27    .filter((name) => /^wayland-\d+$/.test(name))
28    .map((name) => ({ name, modified: statSync(join(runtimeDir, name)).mtimeMs }))
29    .sort((a, b) => b.modified - a.modified)[0]?.name;
30
31  if (!display) throw new Error(`No Wayland compositor found in ${runtimeDir}`);
32  return { ...process.env, XDG_RUNTIME_DIR: runtimeDir, WAYLAND_DISPLAY: display };
33}
34
35function wait(ms: number, signal?: AbortSignal): Promise<void> {
36  return new Promise((resolve, reject) => {
37    if (signal?.aborted) {
38      reject(new Error("Operation aborted"));
39      return;
40    }
41
42    const timer = setTimeout(() => {
43      signal?.removeEventListener("abort", abort);
44      resolve();
45    }, ms);
46    const abort = () => {
47      clearTimeout(timer);
48      reject(new Error("Operation aborted"));
49    };
50    signal?.addEventListener("abort", abort, { once: true });
51  });
52}
53
54function run(command: string, args: string[], signal?: AbortSignal): Promise<Buffer> {
55  return new Promise((resolve, reject) => {
56    if (signal?.aborted) {
57      reject(new Error("Operation aborted"));
58      return;
59    }
60
61    const child = spawn(command, args, {
62      env: waylandEnv(),
63      stdio: ["ignore", "pipe", "pipe"],
64    });
65    const stdout: Buffer[] = [];
66    const stderr: Buffer[] = [];
67    const abort = () => child.kill();
68
69    child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
70    child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
71    child.on("error", reject);
72    child.on("close", (code) => {
73      signal?.removeEventListener("abort", abort);
74      if (signal?.aborted) reject(new Error("Operation aborted"));
75      else if (code === 0) resolve(Buffer.concat(stdout));
76      else {
77        reject(
78          new Error(
79            Buffer.concat(stderr).toString().trim() || `${command} exited with code ${code}`,
80          ),
81        );
82      }
83    });
84
85    signal?.addEventListener("abort", abort, { once: true });
86  });
87}
88
89async function connectVnc(signal?: AbortSignal) {
90  const socket = createConnection({ host: "127.0.0.1", port: 5900, signal });
91  socket.on("error", () => {});
92  socket.setTimeout(5_000, () => socket.destroy(new Error("WayVNC handshake timed out")));
93  const chunks = socket.iterator({ destroyOnReturn: false });
94  let buffered = Buffer.alloc(0);
95
96  async function read(length: number): Promise<Buffer> {
97    while (buffered.length < length) {
98      const chunk = await chunks.next();
99      if (chunk.done) throw new Error("WayVNC disconnected during handshake");
100      buffered = Buffer.concat([buffered, chunk.value]);
101    }
102    const result = buffered.subarray(0, length);
103    buffered = buffered.subarray(length);
104    return result;
105  }
106
107  try {
108    if ((await read(12)).toString() !== "RFB 003.008\n") {
109      throw new Error("Expected WayVNC to support RFB 3.8");
110    }
111    socket.write("RFB 003.008\n");
112    const securityTypes = await read((await read(1))[0]);
113    if (!securityTypes.includes(1)) {
114      throw new Error("Local WayVNC must allow unauthenticated connections");
115    }
116    socket.write(Buffer.from([1]));
117    if ((await read(4)).readUInt32BE() !== 0) {
118      throw new Error("WayVNC rejected the connection");
119    }
120    socket.write(Buffer.from([1])); // Share the desktop with existing VNC clients.
121    await read(24);
122    await chunks.return?.();
123    socket.setTimeout(0);
124    socket.resume();
125
126    // Give Firefox time to bind the seat's newly advertised input devices.
127    await wait(100, signal);
128    if (socket.destroyed) throw socket.errored ?? new Error("WayVNC disconnected");
129    return socket;
130  } catch (error) {
131    socket.destroy();
132    throw error;
133  }
134}
135
136export default function (pi: ExtensionAPI) {
137  if (!process.env.WAYLAND_DISPLAY) return;
138
139  const visibleScreenshotResults = new Set<string>();
140  pi.on("agent_end", () => visibleScreenshotResults.clear());
141  pi.on("context", (event) => ({
142    messages: event.messages.map((message) => {
143      if (
144        message.role !== "toolResult" ||
145        message.toolName !== "computer_use" ||
146        visibleScreenshotResults.has(message.toolCallId)
147      ) {
148        return message;
149      }
150
151      return { ...message, content: message.content.filter((content) => content.type !== "image") };
152    }),
153  }));
154
155  pi.registerTool({
156    name: "computer_use",
157    label: "Computer Use",
158    description:
159      "Interact with visible desktop applications. Prefer web_search and web_fetch for ordinary web research and page reading because they return text directly; use the GUI browser when visual or interactive access is useful. Coordinates are absolute across the current 1920×1080 screen, from (0, 0) at top-left to (1919, 1079) at bottom-right. Take a screenshot before and after interacting; use separate calls when an intermediate screenshot is needed.",
160    promptSnippet:
161      "Interact with visible desktop applications; prefer textual web tools for ordinary research",
162    promptGuidelines: [
163      "Prefer web_search/web_fetch over opening Firefox and reading screenshots when either textual tool can handle the task adequately.",
164      "All coordinates are absolute screen positions on a 1920×1080 screen, not positions relative to a window, element, screenshot crop, or the current pointer.",
165      "Screenshots remain available throughout the current turn. Take another after interacting with the computer, or recall one from an earlier turn by its ID.",
166      "Recalled screenshots are not visible until the entire action sequence completes.",
167    ],
168    parameters: Type.Object(
169      {
170        actions: Type.Array(
171          Type.Union([
172            Type.Object(
173              {
174                action: StringEnum(["move"] as const),
175                x: Type.Integer({
176                  minimum: 0,
177                  maximum: 1919,
178                  description: "Absolute screen x-coordinate: 0 is left, 1919 is right",
179                }),
180                y: Type.Integer({
181                  minimum: 0,
182                  maximum: 1079,
183                  description: "Absolute screen y-coordinate: 0 is top, 1079 is bottom",
184                }),
185              },
186              { additionalProperties: false },
187            ),
188            Type.Object(
189              {
190                action: StringEnum(["click"] as const),
191                x: Type.Optional(
192                  Type.Integer({
193                    minimum: 0,
194                    maximum: 1919,
195                    description:
196                      "Absolute screen x-coordinate (0 left to 1919 right); omit x and y to click in place",
197                  }),
198                ),
199                y: Type.Optional(
200                  Type.Integer({
201                    minimum: 0,
202                    maximum: 1079,
203                    description:
204                      "Absolute screen y-coordinate (0 top to 1079 bottom); omit x and y to click in place",
205                  }),
206                ),
207                button: Type.Optional(
208                  StringEnum(["left", "middle", "right"] as const, { default: "left" }),
209                ),
210              },
211              { additionalProperties: false },
212            ),
213            Type.Object(
214              {
215                action: StringEnum(["scroll"] as const),
216                x: Type.Optional(
217                  Type.Integer({
218                    minimum: 0,
219                    maximum: 1919,
220                    description:
221                      "Absolute screen x-coordinate (0 left to 1919 right); x and y must be supplied together",
222                  }),
223                ),
224                y: Type.Optional(
225                  Type.Integer({
226                    minimum: 0,
227                    maximum: 1079,
228                    description:
229                      "Absolute screen y-coordinate (0 top to 1079 bottom); x and y must be supplied together",
230                  }),
231                ),
232                deltaX: Type.Optional(Type.Integer({ description: "Horizontal scroll amount" })),
233                deltaY: Type.Optional(
234                  Type.Integer({ description: "Vertical scroll amount; positive scrolls down" }),
235                ),
236              },
237              { additionalProperties: false },
238            ),
239            Type.Object(
240              {
241                action: StringEnum(["type"] as const),
242                text: Type.String({
243                  description: "Literal text to type; use a key action for named keys and shortcuts",
244                }),
245              },
246              { additionalProperties: false },
247            ),
248            Type.Object(
249              {
250                action: StringEnum(["key"] as const),
251                key: Type.String({
252                  description:
253                    "One XKB key name, such as Return, Escape, Tab, BackSpace, Delete, Left, Page_Down, F5, or a. Put shortcut modifiers in modifiers; do not put them in key",
254                  pattern: "^[A-Za-z0-9_]+$",
255                }),
256                modifiers: Type.Optional(
257                  Type.Array(StringEnum(["shift", "ctrl", "alt", "logo"] as const), {
258                    description: "Modifiers held while pressing the key",
259                    uniqueItems: true,
260                  }),
261                ),
262              },
263              { additionalProperties: false },
264            ),
265            Type.Object(
266              {
267                action: StringEnum(["new_browser_tab"] as const),
268                url: Type.String({
269                  description:
270                    "HTTP or HTTPS URL to open in a new Firefox tab every time; prefer web_fetch for ordinary page reading",
271                  pattern: "^https?://",
272                }),
273              },
274              { additionalProperties: false },
275            ),
276            Type.Object(
277              {
278                action: StringEnum(["recall"] as const),
279                id: Type.String({
280                  description: "ID from an earlier screenshot result",
281                  pattern: "^sc_[0-9a-f]{16}$",
282                }),
283              },
284              { additionalProperties: false },
285            ),
286            Type.Object(
287              { action: StringEnum(["screenshot"] as const) },
288              { additionalProperties: false },
289            ),
290            Type.Object(
291              {
292                action: StringEnum(["sleep"] as const),
293                ms: Type.Integer({
294                  minimum: 0,
295                  maximum: 30_000,
296                  description: "Time to wait, in milliseconds",
297                }),
298              },
299              { additionalProperties: false },
300            ),
301          ]),
302          { minItems: 1, maxItems: 20 },
303        ),
304      },
305      { additionalProperties: false },
306    ),
307    renderCall(args, theme) {
308      const actions = args.actions.map((action) => {
309        if (action.action === "move") return `move (${action.x ?? "…"}, ${action.y ?? "…"})`;
310        if (action.action === "click") {
311          const position =
312            action.x === undefined && action.y === undefined
313              ? ""
314              : ` (${action.x ?? "…"}, ${action.y ?? "…"})`;
315          return `click ${action.button ?? "left"}${position}`;
316        }
317        if (action.action === "scroll") return `scroll (${action.deltaX ?? 0}, ${action.deltaY ?? 0})`;
318        if (action.action === "type") return `type ${JSON.stringify(action.text ?? "")}`;
319        if (action.action === "key") return `press ${[...(action.modifiers ?? []), action.key ?? "…"].join("+")}`;
320        if (action.action === "new_browser_tab") return `new tab ${action.url ?? "…"}`;
321        if (action.action === "recall") return `recall ${action.id ?? "…"}`;
322        if (action.action === "screenshot") return "screenshot";
323        return `wait ${action.ms ?? 0}ms`;
324      });
325      return new Text(
326        theme.fg("toolTitle", theme.bold("computer_use ")) +
327          theme.fg("accent", actions.join(" → ")),
328        0,
329        0,
330      );
331    },
332    async execute(toolCallId, params, signal, _onUpdate, ctx) {
333      const screenshots: { id: string; image: ImageContent; recalled: boolean }[] = [];
334
335      // WayVNC keeps seat capabilities stable while wlrctl/wtype come and go.
336      const vnc = params.actions.some((action) =>
337        ["move", "click", "scroll", "type", "key"].includes(action.action),
338      )
339        ? await connectVnc(signal)
340        : undefined;
341      try {
342        for (const action of params.actions) {
343          if (vnc?.destroyed) throw vnc.errored ?? new Error("WayVNC disconnected");
344          if (action.action === "move") {
345            await run("wlrctl", ["pointer", "move", "-100000", "-100000"], signal);
346            await run("wlrctl", ["pointer", "move", String(action.x), String(action.y)], signal);
347          } else if (action.action === "click") {
348            const hasPosition = action.x !== undefined || action.y !== undefined;
349            if (hasPosition && (action.x === undefined || action.y === undefined)) {
350              throw new Error("x and y must be supplied together");
351            }
352            if (hasPosition) {
353              await run("wlrctl", ["pointer", "move", "-100000", "-100000"], signal);
354              await run("wlrctl", ["pointer", "move", String(action.x), String(action.y)], signal);
355            }
356            await run("wlrctl", ["pointer", "click", action.button ?? "left"], signal);
357          } else if (action.action === "scroll") {
358            const hasPosition = action.x !== undefined || action.y !== undefined;
359            if (hasPosition && (action.x === undefined || action.y === undefined)) {
360              throw new Error("x and y must be supplied together");
361            }
362            if (hasPosition) {
363              await run("wlrctl", ["pointer", "move", "-100000", "-100000"], signal);
364              await run("wlrctl", ["pointer", "move", String(action.x), String(action.y)], signal);
365            }
366            await run(
367              "wlrctl",
368              ["pointer", "scroll", String(action.deltaY ?? 0), String(action.deltaX ?? 0)],
369              signal,
370            );
371          } else if (action.action === "type") {
372            await run("wtype", ["--", action.text], signal);
373          } else if (action.action === "key") {
374            await run(
375              "wtype",
376              [...(action.modifiers ?? []).flatMap((modifier) => ["-M", modifier]), "-k", action.key],
377              signal,
378            );
379          } else if (action.action === "new_browser_tab") {
380            const url = new URL(action.url);
381            if (url.protocol !== "http:" && url.protocol !== "https:") {
382              throw new Error("Only HTTP and HTTPS URLs are supported");
383            }
384
385            const child = spawn("firefox", ["--new-tab", url.href], {
386              env: { ...waylandEnv(), MOZ_ENABLE_WAYLAND: "1" },
387              detached: true,
388              stdio: "ignore",
389            });
390            await new Promise<void>((resolve, reject) => {
391              child.once("spawn", resolve);
392              child.once("error", reject);
393            });
394            child.unref();
395          } else if (action.action === "recall") {
396            const branch = ctx.sessionManager.getBranch();
397            let image: ImageContent | undefined;
398
399            for (let index = branch.length - 1; index >= 0 && !image; index--) {
400              const entry = branch[index];
401              if (
402                entry.type !== "message" ||
403                entry.message.role !== "toolResult" ||
404                entry.message.toolName !== "computer_use"
405              ) {
406                continue;
407              }
408
409              const screenshotIds = (entry.message.details as ComputerUseDetails | undefined)
410                ?.screenshotIds;
411              if (!Array.isArray(screenshotIds)) continue;
412
413              const imageIndex = screenshotIds.indexOf(action.id);
414              if (imageIndex === -1) continue;
415              image = entry.message.content.filter((content) => content.type === "image")[imageIndex];
416            }
417
418            if (!image) throw new Error(`Screenshot ${action.id} was not found in this session branch`);
419            screenshots.push({ id: action.id, image, recalled: true });
420          } else if (action.action === "screenshot") {
421            const png = await run("grim", ["-c", "-"], signal);
422            screenshots.push({
423              id: `sc_${createHash("sha256").update(png).digest("hex").slice(0, 16)}`,
424              image: {
425                type: "image",
426                data: png.toString("base64"),
427                mimeType: "image/png",
428              },
429              recalled: false,
430            });
431          } else {
432            await wait(action.ms ?? 0, signal);
433          }
434        }
435
436        if (vnc?.destroyed) throw vnc.errored ?? new Error("WayVNC disconnected");
437      } finally {
438        if (vnc) {
439          // Let clients process the last input before removing the seat's devices.
440          await wait(100);
441          vnc.destroy();
442        }
443      }
444
445      const content: (TextContent | ImageContent)[] = [
446        { type: "text", text: `${params.actions.length} actions completed.` },
447      ];
448      for (const screenshot of screenshots) {
449        content.push(
450          {
451            type: "text",
452            text: `Screenshot ${screenshot.id} (${screenshot.recalled ? "recalled" : "captured"}):`,
453          },
454          screenshot.image,
455        );
456      }
457
458      if (screenshots.length) visibleScreenshotResults.add(toolCallId);
459      return {
460        content,
461        details: { screenshotIds: screenshots.map(({ id }) => id) } satisfies ComputerUseDetails,
462      };
463    },
464  });
465}