char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somwindows: add terminal support via ConPTY + PowerShell0853dde

main
11.8 KiB366 linesraw
1// Runs on the machine an environment names. berry starts it over ssh and bootstraps it on stdin; from then on, every
2// line in is a call on this machine's environment, and every line out what came of one.
3
4import { homedir } from "node:os";
5import process from "node:process";
6import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context";
7import type { Context } from "@earendil-works/chord";
8import { FileError, type ShellExecOptions } from "@earendil-works/pi-durable/env";
9import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
10import { resolve } from "node:path";
11import {
12  connectHere,
13  type Process,
14  type Pty,
15  type PtyOptions,
16  spawnHere,
17  type SpawnOptions,
18} from "@berry/api/environment.ts";
19import { ptyHere } from "@berry/api/pty.ts";
20import {
21  decode,
22  encode,
23  type FetchOptions,
24  lines,
25  PROTOCOL,
26  type Request,
27  type Response,
28} from "./codec.ts";
29
30/** What the bootstrap read of stdin before handing it on. */
31type Input = { reader: ReadableStreamDefaultReader<Uint8Array>; buffered: Uint8Array };
32const input = (globalThis as { berryStdin?: Input }).berryStdin ??
33  { reader: Deno.stdin.readable.getReader(), buffered: new Uint8Array() };
34
35const output = Deno.stdout.writable.getWriter();
36const encoder = new TextEncoder();
37let shuttingDown = false;
38// berry gone: nothing to tell
39const send = (message: Response) => {
40  if (shuttingDown) return;
41  void output.write(encoder.encode(`${encode(message)}\n`)).catch(() => {});
42};
43
44/** Methods that take paths and return a result, and nothing else. */
45const PLAIN = new Set([
46  "absolutePath",
47  "joinPath",
48  "readTextFile",
49  "readTextLines",
50  "readBinaryFile",
51  "writeFile",
52  "appendFile",
53  "truncateFile",
54  "flushFile",
55  "renameFile",
56  "fileInfo",
57  "listDir",
58  "canonicalPath",
59  "exists",
60  "createDir",
61  "remove",
62  "createTempDir",
63  "createTempFile",
64]);
65
66/** Readers and watchers berry holds open, by number. */
67const handles = new Map<number, Record<string, (...args: unknown[]) => Promise<unknown>>>();
68let handled = 0;
69const running = new Map<number, AbortController>();
70const settling = new Set<Promise<unknown>>();
71
72/** Programs and sockets berry talks to through their pipes, by number. */
73const pipes = new Map<
74  number,
75  {
76    stdin: WritableStreamDefaultWriter<Uint8Array>;
77    kill(signal?: "SIGTERM" | "SIGKILL"): void;
78    resize?(cols: number, rows: number): void;
79    close(): Promise<void>;
80  }
81>();
82let piped = 0;
83
84async function settleWithin(promise: Promise<unknown>, ms: number) {
85  let timer: ReturnType<typeof setTimeout> | undefined;
86  try {
87    await Promise.race([
88      promise.catch(() => {}),
89      new Promise<void>((resolve) => timer = setTimeout(resolve, ms)),
90    ]);
91  } finally {
92    clearTimeout(timer);
93  }
94}
95
96/** Forward what `process` says to call `i`, until it exits. */
97function pipe(
98  i: number,
99  process: Process & Partial<Pick<Pty, "resize">>,
100  { group = false, socket = false } = {},
101) {
102  const id = ++piped;
103  const stdin = process.stdin.getWriter();
104  const readers = [process.stdout.getReader(), process.stderr.getReader()];
105  const forward = async (reader: ReadableStreamDefaultReader<Uint8Array>, name: string) => {
106    try {
107      while (true) {
108        const { done, value } = await reader.read();
109        if (done) break;
110        send({ i, o: [name, value] });
111      }
112    } finally {
113      send({ i, o: [name, null] });
114      reader.releaseLock();
115    }
116  };
117  const streaming = Promise.allSettled([
118    forward(readers[0], "stdout"),
119    forward(readers[1], "stderr"),
120  ]);
121  let closing: Promise<void> | undefined;
122  const close = () =>
123    closing ??= (async () => {
124      process.kill("SIGKILL");
125      await settleWithin(
126        Promise.allSettled([
127          stdin.abort(),
128          ...readers.map((reader) => reader.cancel().catch(() => {})),
129          streaming,
130        ]),
131        1000,
132      );
133    })();
134  pipes.set(id, { stdin, kill: process.kill, resize: process.resize, close });
135  const complete = (async () => {
136    if (socket) await streaming;
137    const code = await process.exited;
138    send({ i, o: ["exit", code] });
139    if (group) {
140      process.kill();
141      await settleWithin(streaming, 1000);
142      // descendants cannot keep an exited leader's pipes or process group alive indefinitely
143      process.kill("SIGKILL");
144      await close();
145    } else {
146      await streaming;
147      void stdin.abort().catch(() => {});
148    }
149    pipes.delete(id);
150    send({ i, o: ["end", null] });
151  })();
152  void complete.catch(() => {
153    send({ i, o: ["exit", null] });
154    void close().finally(() => {
155      pipes.delete(id);
156      send({ i, o: ["end", null] });
157    });
158  });
159  return { pipe: id };
160}
161
162type Opened = { ok: true; value: object } | { ok: false; error: unknown };
163
164function keep(result: Opened, extra: (value: object) => object = () => ({})) {
165  if (!result.ok) return result;
166  const handle = ++handled;
167  handles.set(handle, result.value as Record<string, (...args: unknown[]) => Promise<unknown>>);
168  return { ok: true, value: { handle, ...extra(result.value) } };
169}
170
171async function dispatch(
172  i: number,
173  m: string,
174  a: readonly unknown[],
175  cwd: string,
176  context: Context,
177) {
178  const env = new NodeExecutionEnv({ cwd });
179  switch (m) {
180    case "exec": {
181      const [command, options] = a as [string | string[], ShellExecOptions & { output: boolean }];
182      const { output: streamed, ...rest } = options;
183      return await env.exec(command, {
184        ...rest,
185        ...(streamed ? { onOutput: (text, _, info) => send({ i, o: [text, info] }) } : {}),
186      }, context);
187    }
188    case "openBinaryReader":
189      return keep(
190        await env.openBinaryReader(a[0] as string, a[1] as { noFollow?: boolean }, context),
191      );
192    case "openTextLineReader":
193      return keep(await env.openTextLineReader(a[0] as string, context));
194    case "openDirReader":
195      return keep(await env.openDirReader(a[0] as string, context));
196    case "fetch": {
197      const [url, options] = a as [string, FetchOptions];
198      const lifetime = new AbortController();
199      const abort = () => lifetime.abort(context.abortSignal?.reason);
200      context.abortSignal?.addEventListener("abort", abort, { once: true });
201      if (context.abortSignal?.aborted) abort();
202      let response: globalThis.Response;
203      try {
204        response = await fetch(url, {
205          ...options,
206          body: options.body === undefined ? undefined : new Uint8Array(options.body),
207          signal: lifetime.signal,
208        });
209      } finally {
210        context.abortSignal?.removeEventListener("abort", abort);
211      }
212      const metadata = {
213        status: response.status,
214        statusText: response.statusText,
215        headers: [...response.headers],
216        url: response.url,
217        redirected: response.redirected,
218      };
219      if (!response.body) return metadata;
220      const reader = response.body.getReader();
221      const handle = ++handled;
222      const close = async () => {
223        handles.delete(handle);
224        lifetime.abort();
225        await reader.cancel().catch(() => {});
226      };
227      handles.set(handle, {
228        async read(context: Context) {
229          const abort = () => lifetime.abort(context.abortSignal?.reason);
230          context.abortSignal?.addEventListener("abort", abort, { once: true });
231          if (context.abortSignal?.aborted) abort();
232          try {
233            const next = await reader.read();
234            if (next.done) handles.delete(handle);
235            return next;
236          } catch (error) {
237            await close();
238            throw error;
239          } finally {
240            context.abortSignal?.removeEventListener("abort", abort);
241          }
242        },
243        close,
244      } as Record<string, (...args: unknown[]) => Promise<unknown>>);
245      return { ...metadata, handle };
246    }
247    case "spawn": {
248      const [argv, options] = a as [string[], SpawnOptions];
249      const at = options.cwd === undefined ? cwd : resolve(cwd, options.cwd);
250      // it outlives this call: berry kills it through its pipe
251      return pipe(i, await spawnHere(argv, { ...options, cwd: at }, BACKGROUND_CONTEXT), {
252        group: options.group,
253      });
254    }
255    case "pty": {
256      const [argv, options] = a as [string[], PtyOptions];
257      const at = options.cwd === undefined ? cwd : resolve(cwd, options.cwd);
258      const pty = await ptyHere(argv, { ...options, cwd: at }, BACKGROUND_CONTEXT);
259      return pipe(i, {
260        stdin: pty.writable,
261        stdout: pty.readable,
262        stderr: new ReadableStream({ start: (controller) => controller.close() }),
263        exited: pty.exited,
264        kill: pty.kill,
265        resize: pty.resize,
266      });
267    }
268    case "connect": {
269      const socket = await connectHere(a[0] as string, a[1] as number, context);
270      return pipe(i, {
271        stdin: socket.writable,
272        stdout: socket.readable,
273        stderr: new ReadableStream({ start: (controller) => controller.close() }),
274        // done once the far end closes it
275        exited: Promise.resolve(0),
276        kill: socket.close,
277      }, { socket: true });
278    }
279    case "pipe": {
280      const [id, operation, ...rest] = a as [
281        number,
282        "write" | "end" | "kill" | "resize",
283        ...unknown[],
284      ];
285      const target = pipes.get(id);
286      // already ended
287      if (target === undefined) return;
288      if (operation === "write") await target.stdin.write(rest[0] as Uint8Array);
289      else if (operation === "end") await target.stdin.close();
290      else if (operation === "resize") target.resize?.(rest[0] as number, rest[1] as number);
291      else target.kill(rest[0] as "SIGTERM" | "SIGKILL" | undefined);
292      return;
293    }
294    case "watch":
295      return keep(
296        await env.watch(a[0] as never, (change) => send({ i, o: change }), context),
297        (watcher) => ({ mode: (watcher as { mode: string }).mode }),
298      );
299    case "handle": {
300      const [handle, method, ...rest] = a as [number, string, ...unknown[]];
301      const target = handles.get(handle);
302      // as a reader refuses use after it is closed, and takes another close as done
303      if (target === undefined) {
304        return method === "close"
305          ? undefined
306          : { ok: false, error: new FileError("invalid", `handle ${handle} is closed`) };
307      }
308      if (method === "close") handles.delete(handle);
309      return await target[method].call(target, ...rest, context);
310    }
311  }
312  if (!PLAIN.has(m)) throw new Error(`No method ${m}`);
313  const method = env[m as keyof NodeExecutionEnv] as (...args: unknown[]) => Promise<unknown>;
314  return await method.call(env, ...a, context);
315}
316
317send({
318  hello: {
319    protocol: PROTOCOL,
320    platform: process.platform,
321    home: homedir(),
322    variables: Object.fromEntries(
323      Object.entries(process.env).filter((entry): entry is [string, string] =>
324        entry[1] !== undefined
325      ),
326    ),
327  },
328});
329
330for await (const line of lines(input.reader, input.buffered)) {
331  if (line === "") continue;
332  const request = decode(line) as Request;
333  if ("c" in request) {
334    running.get(request.c)?.abort();
335    continue;
336  }
337  const { i, m, a, cwd = homedir() } = request;
338  const controller = new AbortController();
339  running.set(i, controller);
340  const settled = dispatch(i, m, a, cwd, withAbortSignal(controller.signal, BACKGROUND_CONTEXT))
341    .then(
342      (r) => send({ i, r }),
343      (error) => send({ i, e: error instanceof Error ? error.message : String(error) }),
344    )
345    .finally(() => {
346      running.delete(i);
347      settling.delete(settled);
348    });
349  settling.add(settled);
350}
351
352// berry has gone: so does everything it started
353shuttingDown = true;
354for (const controller of running.values()) controller.abort();
355const owned = [...pipes.values()];
356for (const { kill } of owned) kill();
357await settleWithin(Promise.allSettled(settling), 1000);
358await settleWithin(
359  Promise.allSettled([
360    ...owned.map((pipe) => pipe.close()),
361    ...[...handles.values()].map((handle) => handle.close?.call(handle, BACKGROUND_CONTEXT)),
362  ]),
363  1000,
364);
365await settleWithin(output.close(), 1000);
366Deno.exit(0);