UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

357 lines • 13.4 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { concat } from "../../utils/bytes.js"; import { CHUNK_SIZE } from "../../io/cooperative.js"; import { checkMemory } from "../../io/memory.js"; import { JobConsole } from "./job_console.js"; import { Channel } from "./types.js"; /** * A shell's screen: what its lines and its background jobs wrote, in the * order it arrived, until a line takes it. * * A line writes here through the sink it runs with, a background job * through `jobs`, the line's own `JobOutput`. A job that writes while no * line runs waits here for the next one, as bash's job prints to the * terminal whenever it likes. With a reader attached (a caller streaming * the line), every chunk goes straight on to it; what it keeps must fit in * what the workspace's `max_memory` has left. A session has one (`tty`), * shared by every fork of it. */ export class Terminal extends JobConsole { chunks = []; held = 0; reader = null; jobs = new JobOutput(new JobSide(this)); ended = 0; attaching = null; attached = () => undefined; prefix = new Uint8Array(); prefixSize = 0; errPrefix = new Uint8Array(); errPrefixSize = 0; // The chunks at the head of `chunks` the line's history entries took, // and the bytes of each channel they took in all. taken = 0; marked = [0, 0]; /** A bounded prefix of stdout handed to the current line's reader. */ get stdoutPrefix() { return this.prefix.subarray(0, this.prefixSize); } /** A bounded prefix of stderr handed to the current line's reader. */ get stderrPrefix() { return this.errPrefix.subarray(0, this.errPrefixSize); } remember(channel, data) { if (channel === Channel.STDOUT) { const part = data.subarray(0, CHUNK_SIZE - this.prefixSize); this.prefix.set(part, this.prefixSize); this.prefixSize += part.byteLength; } else { const part = data.subarray(0, CHUNK_SIZE - this.errPrefixSize); this.errPrefix.set(part, this.errPrefixSize); this.errPrefixSize += part.byteLength; } } /** Take what the line wrote. */ async emit(channel, data) { await this.put(channel, data, false); } /** * Pass a chunk to the reader, or keep it for the line; while a reader * takes what waited (`attach`), keep it and wait. What was handed back to * an attached reader (`handOver`) goes to it first. `job` says a job wrote it. */ async put(channel, data, job) { if (data.byteLength === 0) return; const reader = this.reader; if (reader !== null && this.chunks.length === 0) { this.remember(channel, data); await reader.emit(channel, data); return; } checkMemory(this.held + data.byteLength); this.chunks.push([channel, data, job]); this.held += data.byteLength; if (reader !== null) { this.reader = null; await this.pass(reader); } else if (this.attaching !== null) await this.attaching; } /** * Start a line, handing a streaming caller (`reader`) what waited for it. * * The reader takes the chunks one at a time and becomes the line's only * once none is left. A job that writes meanwhile lands behind what waited * and waits until the reader is attached, so each writer adds at most one * chunk and a noisy job cannot hold the line back; a line ended before * that never attaches the reader. */ async attach(reader) { this.prefix = reader === null ? new Uint8Array() : new Uint8Array(CHUNK_SIZE); this.prefixSize = 0; this.errPrefix = reader === null ? new Uint8Array() : new Uint8Array(CHUNK_SIZE); this.errPrefixSize = 0; this.taken = 0; this.marked = [0, 0]; if (reader === null) { this.reader = null; return; } await this.pass(reader); } /** Hand `reader` what waits, one chunk at a time, and make it the line's once none is left. */ async pass(reader) { const ended = this.ended; let attached = () => undefined; const attaching = new Promise((resolve) => { attached = resolve; }); this.attaching = attaching; this.attached = attached; try { let chunk = this.chunks.shift(); while (chunk !== undefined) { this.held -= chunk[1].byteLength; this.taken = Math.max(0, this.taken - 1); this.remember(chunk[0], chunk[1]); await reader.emit(chunk[0], chunk[1]); if (this.ended !== ended) return; chunk = this.chunks.shift(); } this.reader = reader; } finally { if (this.attaching === attaching) this.attaching = null; attached(); } } /** * Once its last line has ended, give `to` what is left here, ahead of what * reached it meanwhile, and send there what jobs write from now on. A * reader attached to `to` takes it before anything written after. */ handOver(to) { to.chunks = [...this.chunks, ...to.chunks]; to.held += this.held; this.chunks = []; this.held = 0; this.prefix = new Uint8Array(CHUNK_SIZE); this.prefixSize = 0; this.errPrefix = new Uint8Array(CHUNK_SIZE); this.errPrefixSize = 0; this.reader = to.jobs; } /** What reached the terminal so far, taken out to be bounded and put back. */ drain() { const chunks = this.chunks; this.chunks = []; this.held = 0; this.taken = 0; const out = concat(chunks.filter(([c]) => c === Channel.STDOUT).map(([, data]) => data)); const err = concat(chunks.filter(([c]) => c === Channel.STDERR).map(([, data]) => data)); return [out, err]; } /** * What reached the terminal for one history entry of the line, since the * entry before it: a line bash's reader reads as several commands * records each with what it wrote. With a reader attached, what it was * handed, its prefix starting again for the next. */ entry() { if (this.reader !== null) { const shown = [this.stdoutPrefix.slice(), this.stderrPrefix.slice()]; this.prefixSize = 0; this.errPrefixSize = 0; return shown; } const fresh = this.chunks.slice(this.taken); this.taken = this.chunks.length; const out = concat(fresh.filter(([c]) => c === Channel.STDOUT).map(([, data]) => data)); const err = concat(fresh.filter(([c]) => c === Channel.STDERR).map(([, data]) => data)); this.marked = [this.marked[0] + out.byteLength, this.marked[1] + err.byteLength]; return [out, err]; } /** * Return drained output ahead of whatever arrived meanwhile, what the * line's history entries took of it first. */ putBack(out, err) { const from = Math.min(this.marked[0], out.byteLength); const fromErr = Math.min(this.marked[1], err.byteLength); const taken = [ [Channel.STDOUT, out.subarray(0, from), false], [Channel.STDERR, err.subarray(0, fromErr), false], ]; const rest = [ [Channel.STDOUT, out.subarray(from), false], [Channel.STDERR, err.subarray(fromErr), false], ]; const returned = [...taken, ...rest].filter(([, data]) => data.byteLength > 0); this.chunks = [...returned, ...this.chunks]; this.held += out.byteLength + err.byteLength; this.taken = taken.filter(([, data]) => data.byteLength > 0).length; this.marked = [from, fromErr]; } /** End a line: its stdout and stderr, jobs' output among them. */ take() { this.end(); return this.drain(); } /** End an abandoned line: what it wrote goes, its jobs' stays. */ dropLine() { this.chunks = this.chunks.filter(([, , job]) => job); this.held = this.chunks.reduce((size, [, data]) => size + data.byteLength, 0); this.end(); } /** Detach the line's reader and let go the writers waiting on its attach. */ end() { this.reader = null; this.prefix = new Uint8Array(); this.prefixSize = 0; this.errPrefix = new Uint8Array(); this.errPrefixSize = 0; this.taken = 0; this.marked = [0, 0]; this.ended += 1; this.attaching = null; this.attached(); } } /** Where the background jobs write on a terminal. */ export class JobSide extends JobConsole { terminal; constructor(terminal) { super(); this.terminal = terminal; } /** Take what a job wrote. */ async emit(channel, data) { await this.terminal.put(channel, data, true); } } /** * Where the background jobs one shell (a line, a subshell, a job, a * substitution, a pipe stage) starts write. * * Into the statement that shell is running, among what the statement * writes, so the two keep the order they were written in; when it runs * none (between statements, or once it has ended), on to where the shell * itself writes (`target`). A session's `jobOutput` is null in a typed * line, whose jobs write through its terminal's `jobs`. */ export class JobOutput extends JobConsole { target; recorder = null; taken = []; tail = null; turns = 0; constructor(target) { super(); this.target = target; } /** * Keep what jobs write from now behind `after`, in the order they write: * a statement that stops recording them still lands what it recorded * first. Mirrors Python's hold_until. */ holdUntil(after) { void this.turn(() => after); } /** Run `write` once every write queued before it has run. */ turn(write) { const before = this.tail ?? Promise.resolve(); this.turns += 1; const done = before.then(write); this.tail = done.then(() => { this.turned(); }, () => { this.turned(); }); return done; } turned() { this.turns -= 1; if (this.turns === 0) this.tail = null; } /** * Send what jobs write into `recorder` until the returned call puts back * what was there before. That call also ends every recording begun * inside this one: a run its caller abandoned (timeout's) can end after * the caller has, and then has nothing left to put back. */ record(recorder) { const held = this.recorder; const depth = this.taken.push(recorder) - 1; this.recorder = recorder; return () => { if (this.taken[depth] !== recorder) return; this.taken.length = depth; this.recorder = held; }; } /** Write what a job wrote where it belongs now, behind what is held. */ async emit(channel, data) { if (this.tail === null) await (this.recorder ?? this.target).emit(channel, data); else await this.turn(() => (this.recorder ?? this.target).emit(channel, data)); } /** Write what a job wrote to a stream a level owns, keeping the stream. */ async emitTo(stream, data) { if (this.tail === null) await (this.recorder ?? this.target).emitTo(stream, data); else await this.turn(() => (this.recorder ?? this.target).emitTo(stream, data)); } /** * Which of the writes of the shell this leads to (`target`), or the * streams above it, a job's streams reach: a shell passes them on as * they are, a redirect (`JobRoute`) through its descriptors. */ passes(streams) { return new Set(streams); } } /** * A job's output, kept in its own console and copied to where the shell * that started it writes. */ export class Tee extends JobConsole { console; copy; constructor(console, copy) { super(); this.console = console; this.copy = copy; } /** Write to both. */ async emit(channel, data) { await this.console.emit(channel, data); await this.copy.emit(channel, data); } /** Write to both, the copy keeping the stream. */ async emitTo(stream, data) { await this.console.emit(stream.channel, data); await this.copy.emitTo(stream, data); } } //# sourceMappingURL=terminal.js.map