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
8.8 KiB248 linesraw
1// Where a conversation's files and commands are. Server-side only: web bundles import nothing from here.
2
3import { homedir } from "node:os";
4import { resolve } from "node:path";
5import { spawn } from "node:child_process";
6import process from "node:process";
7import { type Readable, Writable } from "node:stream";
8import type { Context } from "@earendil-works/chord";
9import { type ExecutionEnv, getOrThrow } from "@earendil-works/pi-durable/env";
10import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
11import { ptyHere } from "./pty.ts";
12
13/** The name of the machine berry itself runs on. */
14export const LOCAL = "local";
15
16/** A machine a conversation is bound to, at its working directory (`cwd`). */
17export interface Environment extends ExecutionEnv {
18  /** `local`, or the name settings give it. */
19  readonly name: string;
20  /** The machine's `process.platform`, such as `linux`, `darwin`, or `win32`. */
21  readonly platform: string;
22  readonly home: string;
23  /** The process environment commands start from there. */
24  readonly variables: Readonly<Record<string, string>>;
25  /** Aborted when the remote connection is lost; local environments have no connection lifetime. */
26  readonly lifetime?: AbortSignal;
27  /**
28   * Start a program there, `argv[0]` found on its PATH, and talk to it while it runs: a desktop's helper, or an MCP
29   * server. `env` adds to the variables it starts from; aborting `context` kills it. Rejects when it cannot start.
30   */
31  spawn(argv: readonly string[], options: SpawnOptions, context: Context): Promise<Process>;
32  /**
33   * Start a program there on a terminal of its own, in a session it leads, as a login shell wants. Only on linux, for
34   * now. Aborting `context` hangs the terminal up, and so does the program exiting, whatever it leaves running.
35   */
36  pty(argv: readonly string[], options: PtyOptions, context: Context): Promise<Pty>;
37  /** Open a TCP connection from there, so `127.0.0.1` is that machine's own. */
38  connect(hostname: string, port: number, context: Context): Promise<Socket>;
39  /** Fetch from this machine, with streaming responses and cancellation through `init.signal`. */
40  fetch(input: string | URL, init?: RequestInit): Promise<Response>;
41}
42
43export type SpawnOptions = {
44  readonly cwd?: string;
45  readonly env?: Record<string, string>;
46  /** Own a POSIX process group; remote workers also stop descendants when its leader exits. */
47  readonly group?: boolean;
48};
49
50export type PtyOptions = SpawnOptions & { readonly cols: number; readonly rows: number };
51
52/** A program on a pseudo-terminal: what it writes to the terminal, what it reads as typed there, and its size. */
53export interface Pty {
54  readonly readable: ReadableStream<Uint8Array>;
55  readonly writable: WritableStream<Uint8Array>;
56  resize(cols: number, rows: number): void;
57  /** Its exit code, or `null` when a signal ended it. */
58  readonly exited: Promise<number | null>;
59  /** Hang the terminal up, as closing a terminal window does. */
60  kill(): void;
61}
62
63/** A program running on an environment's machine. */
64export interface Process {
65  readonly stdin: WritableStream<Uint8Array>;
66  readonly stdout: ReadableStream<Uint8Array>;
67  readonly stderr: ReadableStream<Uint8Array>;
68  /** Its exit code, or `null` when a signal ended it. */
69  readonly exited: Promise<number | null>;
70  kill(signal?: "SIGTERM" | "SIGKILL"): void;
71}
72
73/** A TCP connection from an environment's machine. */
74export interface Socket {
75  readonly readable: ReadableStream<Uint8Array>;
76  readonly writable: WritableStream<Uint8Array>;
77  close(): void;
78}
79
80class LocalEnvironment extends NodeExecutionEnv implements Environment {
81  readonly name = LOCAL;
82  readonly platform = process.platform;
83  readonly home = homedir();
84  get variables() {
85    return Deno.env.toObject();
86  }
87  spawn(argv: readonly string[], options: SpawnOptions, context: Context): Promise<Process> {
88    return Promise.resolve(spawnHere(argv, {
89      ...options,
90      cwd: options.cwd === undefined ? this.cwd : resolve(this.cwd, options.cwd),
91    }, context));
92  }
93  connect(hostname: string, port: number, context: Context): Promise<Socket> {
94    return connectHere(hostname, port, context);
95  }
96  fetch(input: string | URL, init?: RequestInit): Promise<Response> {
97    return fetch(input, init);
98  }
99  pty(argv: readonly string[], options: PtyOptions, context: Context): Promise<Pty> {
100    // failing to start rejects, rather than throws
101    return new Promise((done) =>
102      done(ptyHere(argv, {
103        ...options,
104        cwd: options.cwd === undefined ? this.cwd : resolve(this.cwd, options.cwd),
105      }, context))
106    );
107  }
108}
109
110// Readable.toWeb cancels pipes with an AbortError that Deno's node adapter can leave unhandled.
111function processOutput(pipe: Readable): ReadableStream<Uint8Array> {
112  let closed = false;
113  return new ReadableStream({
114    start(controller) {
115      pipe.on("data", (chunk: Uint8Array) => {
116        if (closed) return;
117        controller.enqueue(chunk);
118        if ((controller.desiredSize ?? 0) <= 0) pipe.pause();
119      });
120      pipe.on("error", (error) => {
121        if (closed) return;
122        closed = true;
123        controller.error(error);
124      });
125      pipe.once("close", () => {
126        if (closed) return;
127        closed = true;
128        controller.close();
129      });
130      pipe.once("end", () => {
131        if (closed) return;
132        closed = true;
133        controller.close();
134      });
135    },
136    pull: () => void pipe.resume(),
137    cancel() {
138      closed = true;
139      pipe.destroy();
140    },
141  });
142}
143
144/** A program on this machine, `cwd` as given. */
145export async function spawnHere(
146  argv: readonly string[],
147  { cwd, env, group }: SpawnOptions,
148  context: Context,
149): Promise<Process> {
150  context.abortSignal?.throwIfAborted();
151  if (group && process.platform !== "win32") {
152    const child = spawn(argv[0], argv.slice(1), {
153      cwd,
154      env: { ...process.env, ...env },
155      detached: true,
156      stdio: "pipe",
157    });
158    const kill = (signal: "SIGTERM" | "SIGKILL" = "SIGTERM") => {
159      // the group may still have descendants after its leader has exited
160      if (child.pid !== undefined) {
161        try {
162          process.kill(-child.pid, signal);
163        } catch {
164          child.kill(signal);
165        }
166      } else child.kill(signal);
167    };
168    const abort = () => kill();
169    context.abortSignal?.addEventListener("abort", abort, { once: true });
170    if (context.abortSignal?.aborted) abort();
171    const exited = new Promise<number | null>((resolve) => child.once("exit", resolve));
172    void exited.then(() => context.abortSignal?.removeEventListener("abort", abort));
173    try {
174      await new Promise<void>((resolve, reject) => {
175        child.once("spawn", resolve);
176        child.once("error", reject);
177      });
178    } catch (error) {
179      context.abortSignal?.removeEventListener("abort", abort);
180      throw error;
181    }
182    child.on("error", () => {});
183    child.stdin!.on("error", () => {});
184    return {
185      stdin: Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
186      stdout: processOutput(child.stdout!),
187      stderr: processOutput(child.stderr!),
188      exited,
189      kill,
190    };
191  }
192  const child = new Deno.Command(argv[0], {
193    args: argv.slice(1),
194    ...(cwd === undefined ? {} : { cwd }),
195    ...(env === undefined ? {} : { env }),
196    stdin: "piped",
197    stdout: "piped",
198    stderr: "piped",
199  }).spawn();
200  const kill = (signal: "SIGTERM" | "SIGKILL" = "SIGTERM") => {
201    try {
202      child.kill(signal);
203    } catch {
204      // already gone
205    }
206  };
207  const abort = () => kill();
208  context.abortSignal?.addEventListener("abort", abort, { once: true });
209  if (context.abortSignal?.aborted) abort();
210  const exited = child.status.then((status) => (status.signal ? null : status.code));
211  void exited.finally(() => context.abortSignal?.removeEventListener("abort", abort));
212  return { stdin: child.stdin, stdout: child.stdout, stderr: child.stderr, exited, kill };
213}
214
215/** A TCP connection from this machine. */
216export async function connectHere(
217  hostname: string,
218  port: number,
219  context: Context,
220): Promise<Socket> {
221  context.abortSignal?.throwIfAborted();
222  const connection = await Deno.connect({ hostname, port });
223  return {
224    readable: connection.readable,
225    writable: connection.writable,
226    close() {
227      try {
228        connection.close();
229      } catch {
230        // already closed
231      }
232    },
233  };
234}
235
236/** The machine berry runs on, at `cwd`. */
237export function localEnvironment(cwd: string): Environment {
238  return new LocalEnvironment({ cwd });
239}
240
241/** The directory `path` names from the environment's `cwd`, links resolved; an empty path is home. */
242export async function directory(env: Environment, path: string, context: Context): Promise<string> {
243  const target = getOrThrow(await env.canonicalPath(path.trim() || env.home, context));
244  if (getOrThrow(await env.fileInfo(target, context)).kind !== "directory") {
245    throw new Error(`Not a directory: ${target}`);
246  }
247  return target;
248}