io-terminal.ts (3272B)
1 // Terminal provider; cloneable identities only, independent of evaluators. 2 import type { Event, Completion } from './io-events.js'; 3 import { OutputBuffer } from './io-output.js'; 4 export type Reply = Completion & { run: string; id: number }; 5 export class Terminal { 6 readonly output = new OutputBuffer(); 7 private nextAfter = 0; 8 private reader: Extract<Event, { kind: 'read' }> | undefined; 9 private buffered: number[] = []; 10 private eof = false; 11 private ended = false; 12 constructor(readonly run: string) { 13 if (!/^[1-9][0-9]*$/.test(run) || run.length > 20 || BigInt(run) > 0xffffffffffffffffn) throw new Error('invalid terminal run'); 14 } 15 get waiting(): { run: string; id: number } | undefined { 16 return this.reader ? { run: this.run, id: this.reader.id } : undefined; 17 } 18 private read(): Reply | undefined { 19 const reader = this.reader; 20 if (!reader || !this.buffered.length && !this.eof) return; 21 const bytes = this.buffered.splice(0, reader.limit); this.reader = undefined; 22 return { run: this.run, id: reader.id, kind: 'read', bytes, eof: bytes.length === 0 }; 23 } 24 handle(event: Event): Reply | undefined { 25 if (this.ended || event.run !== this.run || !Number.isSafeInteger(event.id) || event.id < 1) throw new Error('invalid terminal event'); 26 if (event.kind === 'cancel') { 27 if (event.id > this.nextAfter || !['read', 'write'].includes(event.operation)) throw new Error('invalid terminal cancellation'); 28 // Completed synchronous writes/reads already have a normal acknowledgement 29 // queued. A racing abort does not erase it or manufacture a second reply. 30 if (this.reader?.id !== event.id) return; 31 if (event.operation !== 'read') throw new Error('invalid terminal cancellation'); 32 this.reader = undefined; 33 return { run: this.run, id: event.id, kind: 'cancelled' }; 34 } 35 if (event.id <= this.nextAfter) throw new Error('stale terminal event'); 36 if (event.kind === 'read') { 37 if (this.reader || !Number.isInteger(event.limit) || event.limit < 1 || event.limit > 4096) throw new Error('invalid terminal read'); 38 this.reader = { ...event }; 39 } else if (event.kind === 'write') this.output.append(event.stream, event.bytes); 40 else throw new Error('invalid terminal operation'); 41 this.nextAfter = event.id; 42 return event.kind === 'read' ? this.read() : { run: this.run, id: event.id, kind: 'write', count: event.bytes.length }; 43 } 44 // Explicit one-line UI: no CR/LF in the submitted text, append ONE LF; an 45 // empty line is data, never EOF. Scalars, not UTF-16 code units, are bounded. 46 line(run: string, id: number, value: unknown): Reply | undefined { 47 if (run !== this.run || this.reader?.id !== id || typeof value !== 'string' || value.length > 512 || 48 /[\r\n\uD800-\uDFFF]/u.test(value) || Array.from(value).length > 256) return; 49 this.buffered = [...new TextEncoder().encode(value), 10]; 50 return this.read(); 51 } 52 endInput(run: string, id: number): Reply | undefined { 53 if (run !== this.run || this.reader?.id !== id) return; 54 this.eof = true; return this.read(); 55 } 56 finish(): void { 57 if (this.reader) throw new Error('terminal input still pending'); 58 this.ended = true; this.buffered = []; this.output.finish(); 59 } 60 }