Files
stack/packages/conversation/src/terminal.mjs
T
jason.woltjeandClaude Opus 5.5 243e153c8b feat(conversation): CHAT-03 I1, mediated control of a sealed headless Pi (#1507)
Controller, claim store, live-session guard, engine link and seal,
turn tracker, cohort force stop and recovery, client library,
transcript and mediated terminal, with the fake engine and tests.
Fixtures only; no live cutover.

Dewey built it. Darkwing (comment 26690) and Filbert (comment 26694)
approved round 2. Manifest I1-r2-manifest.sha256 (2b48e333, 27 files).
Suites on an export: conversation 152/152, control-board 124, webui 14,
seat 19, chat-00/01/01c checks, and all nine scripts/test-*.sh green.
Follow-ups for I3 are in DEFERRED. Gate E stays with Jason.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 15:47:53 -05:00

281 lines
11 KiB
JavaScript

// The mediated terminal (#1507, CHAT-03 §1, §4, §7, §9).
//
// node packages/conversation/src/terminal.mjs --socket <path> [--grant <id>]
//
// A thin view over the client library. It renders the same Transcript the
// library offers (E7), so it shows what any other client shows. It holds no
// engine-side state.
//
// The composer is a local buffer (§4). It is empty at start, cleared after
// each submit and whenever the controller changes, and it submits only while
// this connection is the controller. An observer's submit is refused here,
// "not admitted: controller", and sends nothing (S5). A bracketed paste is
// inserted literally, newlines included; it never submits by itself.
//
// Keys: Enter submits; Ctrl-J or Alt-Enter adds a newline; Ctrl-T takes
// control; Ctrl-G interrupts; Ctrl-O reconnects if needed and re-reads the
// page; PageUp and PageDown scroll; Ctrl-C or Ctrl-D quits.
//
// Engine text is shown with control characters made visible, so transcript
// content can't drive the operator's terminal.
import { pathToFileURL } from "node:url";
import { ConversationClient, OUTCOME_UNKNOWN } from "./client.mjs";
import { Transcript } from "./transcript.mjs";
export const NOT_CONTROLLER = "not admitted: controller";
const PASTE_START = "\x1b[200~";
const PASTE_END = "\x1b[201~";
const KEYS = Object.freeze({ "\r": "submit", "\n": "newline", "\x7f": "backspace", "\b": "backspace", "\x14": "takeover", "\x07": "interrupt", "\x0f": "reload", "\x03": "quit", "\x04": "quit" });
// Control characters, line and paragraph separators, bidi controls, invisible
// characters that can hide or spoof text (zero-width space, word joiner and
// invisible operators, BOM, tag characters) shown as text. ZWJ and ZWNJ pass:
// emoji sequences and joining scripts need them.
export function visible(s) {
return String(s).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u061c\u200b\u200e\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]|\t/gu, (c) => {
if (c === "\t") return " ";
const n = c.codePointAt(0);
if (n < 0x20) return "^" + String.fromCharCode(n + 64);
if (n === 0x7f) return "^?";
return `<U+${n.toString(16).toUpperCase().padStart(4, "0")}>`;
});
}
// For lines that must stay one line (head, status, notices): LF shown too.
const oneLine = (s) => visible(s).replace(/\n/g, "^J");
export class Terminal {
constructor({ client, write = () => {}, rows = 24, onQuit = () => {} }) {
this.client = client;
this.write = write;
this.rows = rows;
this.onQuit = onQuit;
this.transcript = new Transcript({ client });
this.composer = "";
this.inPaste = false;
this.carry = "";
this.scroll = 0;
this.status = "";
this.unknownCount = 0;
this.dialogs = [];
this.notices = [];
this.lastReceipt = null;
this.controller = client.binding?.controllerConnection ?? null;
this.frame = [];
this.sent = 0;
this.queue = Promise.resolve();
client.on((m) => this.#onClient(m));
this.transcript.on(() => this.render());
}
#onClient(m) {
if (m.type === "welcome" || (m.type === "push" && m.kind === "binding")) {
const next = this.client.binding?.controllerConnection ?? null;
// §4: the composer clears on every control transfer (mutant 10).
if (next !== this.controller) this.composer = "";
this.controller = next;
} else if (m.type === "push" && m.kind === "unknown") {
this.unknownCount = m.count;
} else if (m.type === "push" && m.kind === "dialog") {
this.dialogs.push(m.dialog);
} else if (m.type === "push" && m.kind === "receipt") {
if (m.outcomeUnknown) this.notices.push(`prompt ${m.receipt.id}: ${OUTCOME_UNKNOWN}`);
if (m.receipt.id === this.lastReceipt?.id) this.lastReceipt = m.receipt;
} else if (m.type === "outcome-unknown") {
this.notices.push(`${m.operation}: ${OUTCOME_UNKNOWN}`);
} else if (m.type === "status" && m.status === "disconnected") {
this.status = "disconnected; Ctrl-O reconnects";
}
this.render();
}
// Feeds raw terminal input. Resolves when the actions it started finish.
key(data) {
const actions = [];
let s = this.carry + data;
this.carry = "";
let i = 0;
while (i < s.length) {
if (this.inPaste) {
const end = s.indexOf(PASTE_END, i);
if (end === -1) {
const keep = partialSuffix(s.slice(i), PASTE_END);
this.composer += s.slice(i, s.length - keep);
this.carry = s.slice(s.length - keep);
break;
}
this.composer += s.slice(i, end);
this.inPaste = false;
i = end + PASTE_END.length;
continue;
}
if (s.startsWith(PASTE_START, i)) {
this.inPaste = true;
i += PASTE_START.length;
continue;
}
if (s[i] === "\x1b") {
// A trailing ESC, alone or with more of the paste-start marker, waits
// for the next chunk. A lone Escape has no action here, so holding it
// costs nothing.
if (s.length - i < PASTE_START.length && PASTE_START.startsWith(s.slice(i))) {
this.carry = s.slice(i);
break;
}
const seq = /^\x1b(?:\[[0-9;?]*[ -/]*[@-~]|O.|[\s\S])?/.exec(s.slice(i))[0];
if (seq === "\x1b[5~") this.scroll += Math.max(1, this.rows - 4);
else if (seq === "\x1b[6~") this.scroll = Math.max(0, this.scroll - Math.max(1, this.rows - 4));
else if (seq === "\x1b\r") this.composer += "\n";
i += seq.length;
continue;
}
const action = KEYS[s[i]];
if (action === "newline") this.composer += "\n";
else if (action === "backspace") this.composer = Array.from(this.composer).slice(0, -1).join("");
else if (action === "submit") {
// The composer is taken at the Enter, so text after it in the same
// chunk starts the next message instead of joining this one.
const text = this.#take();
if (text !== null) actions.push(() => this.#send(text));
} else if (action) actions.push(() => this.#act(action));
else if (s[i] >= " ") this.composer += s[i];
i += 1;
}
this.render();
for (const run of actions) this.queue = this.queue.then(run);
return this.queue;
}
async #act(action) {
if (action === "takeover") return this.#show("takeover", await this.client.takeover());
if (action === "interrupt") return this.#show("interrupt", await this.client.interrupt());
if (action === "reload") {
if (this.client.closed) {
try {
await this.client.connect();
} catch (err) {
this.status = `connect: ${err.refusal ?? err.message}`;
}
} else await this.transcript.reload("manual");
return this.render();
}
if (action === "quit") return this.onQuit();
}
// Sends the composer as one prompt, only as the controller. A submit clears
// the composer whatever its outcome; an observer's Enter is refused before
// that and leaves the buffer for the operator.
async submit() {
const text = this.#take();
if (text === null) {
this.render();
return { sent: false, refusal: "controller" };
}
return this.#send(text);
}
// Empties the composer and returns its text, or refuses an observer and
// returns null, leaving the buffer.
#take() {
if (!this.client.isController) {
this.status = NOT_CONTROLLER;
return null;
}
const text = this.composer;
this.composer = "";
return text;
}
async #send(text) {
if (!text) return { sent: false, refusal: null };
this.sent += 1;
const r = await this.client.prompt(text);
if (r.receipt) this.lastReceipt = r.receipt;
this.#show("prompt", r);
return { sent: true, reply: r };
}
#show(op, r) {
if (r.outcome === "outcome-unknown") this.status = `${op}: ${OUTCOME_UNKNOWN}`;
else if (r.refusal) this.status = `not admitted: ${r.refusal}`;
else this.status = `${op}: ${r.outcome}`;
this.render();
}
// The frame: a header, the transcript window, dialogs, notices, the status
// line and the composer.
render() {
const b = this.client.binding;
const head = `${oneLine(b?.state ?? "disconnected")} | ${this.client.isController ? "controller" : "observer"} | unknown events: ${this.unknownCount}`;
const body = [];
for (const line of this.transcript.lines()) body.push(...visible(line.replace(/\r\n/g, "\n")).split("\n"));
for (const d of this.dialogs) body.push(oneLine(`[dialog ${d.method} disabled: ${d.reason}]`));
for (const n of this.notices) body.push(oneLine(n));
const receipt = this.lastReceipt ? `last prompt: ${this.lastReceipt.state}${this.lastReceipt.reasonCode ? ` (${this.lastReceipt.reasonCode})` : ""}` : "";
const status = oneLine([this.status, receipt].filter(Boolean).join(" | "));
const composer = (this.composer ? this.composer.split("\n") : [""]).map((l, i) => (i ? " " : "> ") + visible(l));
const room = Math.max(1, this.rows - 2 - composer.length);
const end = Math.max(0, body.length - this.scroll);
this.frame = [head, ...body.slice(Math.max(0, end - room), end), status, ...composer];
this.write("\x1b[H\x1b[2J" + this.frame.join("\r\n"));
return this.frame;
}
}
// How many trailing characters of `s` begin `marker`.
function partialSuffix(s, marker) {
for (let n = Math.min(s.length, marker.length - 1); n > 0; n--) if (marker.startsWith(s.slice(-n))) return n;
return 0;
}
function parseArgs(argv) {
const out = { socket: null, grant: "grant-local" };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--socket") out.socket = argv[++i];
else if (argv[i] === "--grant") out.grant = argv[++i];
else throw new Error(`unknown argument: ${argv[i]}`);
}
if (!out.socket) throw new Error("usage: terminal.mjs --socket <path> [--grant <id>]");
return out;
}
async function main() {
let args;
try {
args = parseArgs(process.argv.slice(2));
} catch (err) {
process.stderr.write(err.message + "\n");
process.exit(2);
}
const { stdin, stdout } = process;
const client = new ConversationClient({ socketPath: args.socket, grant: args.grant });
const restore = () => {
stdout.write("\x1b[?2004l\r\n");
if (stdin.isTTY) stdin.setRawMode(false);
};
const quit = () => {
restore();
client.close();
process.exit(0);
};
const term = new Terminal({ client, write: (s) => stdout.write(s), rows: stdout.rows || 24, onQuit: quit });
try {
await client.connect();
} catch (err) {
process.stderr.write(`could not connect: ${err.refusal ?? err.message}\n`);
process.exit(1);
}
if (stdin.isTTY) stdin.setRawMode(true);
stdout.write("\x1b[?2004h");
stdout.on("resize", () => {
term.rows = stdout.rows || 24;
term.render();
});
stdin.on("data", (c) => void term.key(c.toString("utf8")));
stdin.on("end", quit);
term.render();
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();