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