char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somcleanup: hang up PTYs when their process exits0575637

main
5.6 KiB148 linesraw
1import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2import { Type } from "@earendil-works/pi-ai";
3import {
4  type Channel,
5  type ChannelMessage,
6  defineExtension,
7  type Environment,
8  type Pty,
9} from "@berry/api";
10import { Ghostty, type Terminal } from "./ghostty.ts";
11import { COLORS } from "./palette.ts";
12import { shell } from "./shell.ts";
13import { type ClientMessage, OUTPUT, type ServerMessage, SNAPSHOT, tagged } from "./protocol.ts";
14
15const WASM = new URL("./ghostty-vt.wasm", import.meta.url);
16const SIZE = { min: 1, max: 1000 };
17
18/** A conversation's shell, its terminal kept here too, so whoever opens the view later sees the screen so far. */
19type Session = {
20  readonly pty: Pty;
21  readonly input: WritableStreamDefaultWriter<Uint8Array>;
22  readonly terminal: Terminal;
23  /** Once its output has ended, which its pty sees to shortly after the shell exits. */
24  readonly pumped: Promise<void>;
25  exit: { code: number | null } | null;
26};
27
28export default defineExtension({
29  name: "terminal",
30  description: "libghostty-backed terminal on the target machine",
31  alwaysOn: true,
32  initial: () => null,
33  setup(ext) {
34    let loaded: Promise<Ghostty> | undefined;
35    const ghostty = () =>
36      loaded ??= Deno.readFile(WASM).then(Ghostty.load, (error) => {
37        loaded = undefined;
38        throw error instanceof Deno.errors.NotFound
39          ? new Error("extensions/terminal/ghostty-vt.wasm is missing: run `deno task ghostty`")
40          : error;
41      });
42    /** One per conversation, until its shell exits and another is asked for. */
43    const sessions = new Map<string, Session>();
44    const starting = new Set<string>();
45    /** Whoever has a conversation's terminal open. */
46    const viewers = new Map<string, Set<Channel>>();
47    const broadcast = (tid: string, message: ChannelMessage | ServerMessage) => {
48      for (const viewer of viewers.get(tid) ?? []) viewer.send(message);
49    };
50
51    async function start(tid: string, env: Environment, cols: number, rows: number) {
52      const terminal = (await ghostty()).terminal(cols, rows);
53      terminal.colors(COLORS);
54      let pty: Pty;
55      try {
56        pty = await env.pty(await shell(env), {
57          cols,
58          rows,
59          env: { TERM: "xterm-256color", COLORTERM: "truecolor" },
60        }, BACKGROUND_CONTEXT);
61      } catch (error) {
62        terminal.free();
63        throw error;
64      }
65      sessions.get(tid)?.terminal.free();
66      const pumped = (async () => {
67        for await (const chunk of pty.readable) {
68          terminal.write(chunk);
69          broadcast(tid, tagged(OUTPUT, chunk));
70        }
71      })().catch(() => {});
72      const input = pty.writable.getWriter();
73      const session: Session = { pty, input, terminal, pumped, exit: null };
74      sessions.set(tid, session);
75      // the program's queries, such as where the cursor is, are answered once, here, rather than by every viewer
76      terminal.answer((data) => void input.write(data).catch(() => {}));
77      broadcast(tid, tagged(SNAPSHOT, terminal.snapshot()));
78
79      // only once its output has ended, so nothing of this shell reaches the next one's screen
80      void Promise.all([pty.exited, pumped]).then(([code]) => {
81        session.exit = { code };
82        if (sessions.get(tid) === session) broadcast(tid, { t: "exit", code });
83      });
84    }
85
86    ext.onDispose(async () => {
87      const ending = [...sessions.values()];
88      sessions.clear();
89      await Promise.all(ending.map(async (session) => {
90        session.pty.kill();
91        await session.pumped;
92        session.terminal.free();
93      }));
94    });
95
96    ext.channel({
97      name: "session",
98      parameters: Type.Object({}),
99      async open(_, channel, ctx) {
100        // a missing module is the reason to give the view, rather than a terminal that never draws
101        await ghostty();
102        const { tid } = ctx;
103        const watching = viewers.get(tid) ?? viewers.set(tid, new Set()).get(tid)!;
104        watching.add(channel);
105        ctx.signal.addEventListener("abort", () => watching.delete(channel));
106        const session = sessions.get(tid);
107        if (session === undefined) channel.send({ t: "none" } satisfies ServerMessage);
108        else {
109          channel.send(tagged(SNAPSHOT, session.terminal.snapshot()));
110          if (session.exit) channel.send({ t: "exit", ...session.exit } satisfies ServerMessage);
111        }
112
113        void (async () => {
114          for await (const message of channel.messages) {
115            const session = sessions.get(tid);
116            const running = session && !session.exit ? session : undefined;
117            if (message instanceof Uint8Array) {
118              await running?.input.write(message).catch(() => {});
119              continue;
120            }
121            const { t, cols, rows } = message as ClientMessage;
122            const sized = [cols, rows].every((n) =>
123              Number.isInteger(n) && n >= SIZE.min && n <= SIZE.max
124            );
125            if (!sized) continue;
126            if (t === "start" && !running && !starting.has(tid)) {
127              starting.add(tid);
128              start(tid, ctx.env, cols, rows)
129                .catch((error) =>
130                  channel.send(
131                    {
132                      t: "error",
133                      message: error instanceof Error ? error.message : String(error),
134                    } satisfies ServerMessage,
135                  )
136                )
137                .finally(() => starting.delete(tid));
138            } else if (t === "resize" && running) {
139              running.pty.resize(cols, rows);
140              running.terminal.resize(cols, rows);
141              broadcast(tid, { t: "size", cols, rows });
142            }
143          }
144        })();
145      },
146    });
147  },
148});