@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
357 lines • 13.4 kB
JavaScript
// ========= 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