char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
0575637
main
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. */ 19readonly name :string ; 20/** The machine's `process.platform`, such as `linux`, `darwin`, or `win32`. */ 21readonly platform :string ; 22readonly home :string ; 23/** The process environment commands start from there. */ 24readonly variables :Readonly < Record < string , string >>; 25/** Aborted when the remote connection is lost; local environments have no connection lifetime. */ 26readonly 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*/ 31spawn ( 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*/ 36pty ( 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. */ 38connect ( hostname :string , port :number , context :Context ) :Promise < Socket >; 39/** Fetch from this machine, with streaming responses and cancellation through `init.signal`. */ 40fetch ( input :string | URL , init ?:RequestInit ) :Promise < Response >; 41} 42 43export type SpawnOptions = { 44readonly cwd ?:string ; 45readonly env ?:Record < string , string >; 46/** Own a POSIX process group; remote workers also stop descendants when its leader exits. */ 47readonly 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 { 54readonly readable :ReadableStream < Uint8Array >; 55readonly writable :WritableStream < Uint8Array >; 56resize ( cols :number , rows :number ) :void ; 57/** Its exit code, or `null` when a signal ended it. */ 58readonly exited :Promise < number | null >; 59/** Hang the terminal up, as closing a terminal window does. */ 60kill () :void ; 61} 62 63/** A program running on an environment's machine. */ 64export interface Process { 65readonly stdin :WritableStream < Uint8Array >; 66readonly stdout :ReadableStream < Uint8Array >; 67readonly stderr :ReadableStream < Uint8Array >; 68/** Its exit code, or `null` when a signal ended it. */ 69readonly exited :Promise < number | null >; 70kill ( signal ?:"SIGTERM" | "SIGKILL" ) :void ; 71} 72 73/** A TCP connection from an environment's machine. */ 74export interface Socket { 75readonly readable :ReadableStream < Uint8Array >; 76readonly writable :WritableStream < Uint8Array >; 77close () :void ; 78} 79 80class LocalEnvironment extends NodeExecutionEnv implements Environment { 81readonly name = LOCAL ; 82readonly platform = process . platform ; 83readonly home = homedir (); 84get variables () { 85return Deno . env . toObject (); 86} 87spawn ( argv :readonly string [], options :SpawnOptions , context :Context ) :Promise < Process > { 88return Promise . resolve ( spawnHere ( argv , { 89 ...options , 90cwd :options . cwd === undefined ?this . cwd :resolve ( this . cwd , options . cwd ), 91}, context )); 92} 93connect ( hostname :string , port :number , context :Context ) :Promise < Socket > { 94return connectHere ( hostname , port , context ); 95} 96fetch ( input :string | URL , init ?:RequestInit ) :Promise < Response > { 97return fetch ( input , init ); 98} 99pty ( argv :readonly string [], options :PtyOptions , context :Context ) :Promise < Pty > { 100// failing to start rejects, rather than throws 101return new Promise (( done ) => 102done ( ptyHere ( argv , { 103 ...options , 104cwd :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 > { 112let closed = false ; 113return new ReadableStream ({ 114start ( controller ) { 115pipe . on ( "data" , ( chunk :Uint8Array ) => { 116if ( closed ) return ; 117controller . enqueue ( chunk ); 118if (( controller . desiredSize ?? 0 ) <= 0 ) pipe . pause (); 119}); 120pipe . on ( "error" , ( error ) => { 121if ( closed ) return ; 122closed = true ; 123controller . error ( error ); 124}); 125pipe . once ( "close" , () => { 126if ( closed ) return ; 127closed = true ; 128controller . close (); 129}); 130pipe . once ( "end" , () => { 131if ( closed ) return ; 132closed = true ; 133controller . close (); 134}); 135}, 136pull :() => void pipe . resume (), 137cancel () { 138closed = true ; 139pipe . destroy (); 140}, 141}); 142} 143 144/** A program on this machine, `cwd` as given. */ 145export async function spawnHere ( 146argv :readonly string [], 147{ cwd, env, group} :SpawnOptions , 148context :Context , 149) :Promise < Process > { 150context . abortSignal ?. throwIfAborted (); 151if ( group && process . platform !== "win32" ) { 152const child = spawn ( argv [ 0 ], argv . slice ( 1 ), { 153 cwd, 154env :{ ...process . env , ...env }, 155detached :true , 156stdio :"pipe" , 157}); 158const kill = ( signal :"SIGTERM" | "SIGKILL" = "SIGTERM" ) => { 159// the group may still have descendants after its leader has exited 160if ( child . pid !== undefined ) { 161try { 162process . kill ( - child . pid , signal ); 163} catch { 164child . kill ( signal ); 165} 166} else child . kill ( signal ); 167}; 168const abort = () => kill (); 169context . abortSignal ?. addEventListener ( "abort" , abort , { once :true }); 170if ( context . abortSignal ?. aborted ) abort (); 171const exited = new Promise < number | null >(( resolve ) => child . once ( "exit" , resolve )); 172void exited . then (() => context . abortSignal ?. removeEventListener ( "abort" , abort )); 173try { 174await new Promise < void >(( resolve , reject ) => { 175child . once ( "spawn" , resolve ); 176child . once ( "error" , reject ); 177}); 178} catch ( error ) { 179context . abortSignal ?. removeEventListener ( "abort" , abort ); 180throw error ; 181} 182child . on ( "error" , () => {}); 183child . stdin ! . on ( "error" , () => {}); 184return { 185stdin :Writable . toWeb ( child . stdin ! ) as WritableStream < Uint8Array >, 186stdout :processOutput ( child . stdout ! ), 187stderr :processOutput ( child . stderr ! ), 188 exited, 189 kill, 190}; 191} 192const child = new Deno . Command ( argv [ 0 ], { 193args :argv . slice ( 1 ), 194 ...( cwd === undefined ?{} :{ cwd}), 195 ...( env === undefined ?{} :{ env}), 196stdin :"piped" , 197stdout :"piped" , 198stderr :"piped" , 199}). spawn (); 200const kill = ( signal :"SIGTERM" | "SIGKILL" = "SIGTERM" ) => { 201try { 202child . kill ( signal ); 203} catch { 204// already gone 205} 206}; 207const abort = () => kill (); 208context . abortSignal ?. addEventListener ( "abort" , abort , { once :true }); 209if ( context . abortSignal ?. aborted ) abort (); 210const exited = child . status . then (( status ) => ( status . signal ?null :status . code )); 211void exited . finally (() => context . abortSignal ?. removeEventListener ( "abort" , abort )); 212return { stdin :child . stdin , stdout :child . stdout , stderr :child . stderr , exited, kill}; 213} 214 215/** A TCP connection from this machine. */ 216export async function connectHere ( 217hostname :string , 218port :number , 219context :Context , 220) :Promise < Socket > { 221context . abortSignal ?. throwIfAborted (); 222const connection = await Deno . connect ({ hostname, port}); 223return { 224readable :connection . readable , 225writable :connection . writable , 226close () { 227try { 228connection . 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 { 238return 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 > { 243const target = getOrThrow ( await env . canonicalPath ( path . trim () || env . home , context )); 244if ( getOrThrow ( await env . fileInfo ( target , context )). kind !== "directory" ) { 245throw new Error ( `Not a directory: ${ target } ` ); 246} 247return target ; 248}