Dewey's round 3 candidate, manifest
agents/dewey/work/queue-40/candidate-manifest-r3.sha256 (d0aa0ded,
27 files, checked OK in the canonical tree).
- WebUI inbox, tasks, agents and trail views, read-only over /api/bus.
The README says the bus proof ends at the Console process.
- CHAT-03 seal: the engine command is fixed, the engine environment is
explicit, SEAL_FLAGS has --no-approve, escalating is cleared on throw.
- Terminal input typed after Ctrl-T or Ctrl-O is held. Only the run whose
own parse set held drains it (T1), and #run catches errors per action.
- DEFERRED keeps N2 and moves F2 to done, citing T1.
Reviews: Filbert approve (comment 27011, rev 260), Darkwing approve
(27013, rev 264). Landing gate on 8cad7722 plus the candidate: webui 22,
conversation 161, control-board 124, every scripts/test-*.sh green,
test-task 98/0. Mutant Mr survives; its flows test is the first
follow-up row.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
356 lines
14 KiB
JavaScript
356 lines
14 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. Input after a
|
|
// Ctrl-T or Ctrl-O waits until that finishes, in the same chunk or a later
|
|
// one, so it is judged as if typed one key at a time.
|
|
//
|
|
// 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~";
|
|
// Keys whose action can change who holds control. Input after one waits until
|
|
// it finishes, so an Enter in the same chunk is judged as the keys would be
|
|
// one at a time (Filbert F2 on #1507).
|
|
const HOLDS = new Set(["takeover", "reload"]);
|
|
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();
|
|
this.held = null;
|
|
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.
|
|
// While a takeover or reload is pending, input is held, not parsed.
|
|
key(data) {
|
|
return this.#feed(data, null);
|
|
}
|
|
|
|
// Feeds terminal input like key(), but an action's error goes to the
|
|
// status line when it happens instead of rejecting, so an unhandled
|
|
// rejection can't end the process (Ctrl-T before the handshake throws "not
|
|
// connected"), and a later status, such as the held Enter's "prompt:
|
|
// admitted", is never overwritten by an earlier error (Filbert N4 on #1522).
|
|
input(data) {
|
|
return this.#feed(data, (err) => {
|
|
this.status = `failed: ${err.refusal ?? err.message}`;
|
|
this.render();
|
|
});
|
|
}
|
|
|
|
// Held input joins the run of the takeover or reload that holds it: its
|
|
// promise settles when that run finishes, and an error there belongs to
|
|
// the call that started the run, not to this one.
|
|
#feed(data, report) {
|
|
if (this.held !== null) {
|
|
this.held += data;
|
|
return this.queue.catch(() => {});
|
|
}
|
|
const actions = this.#parse(data);
|
|
const holds = this.held !== null;
|
|
// A chunk runs after the one before it whether that one finished or
|
|
// threw; the throw belongs to the call that started it, and later input
|
|
// still runs (Filbert N1 on #1522).
|
|
this.queue = this.queue.catch(() => {}).then(() => this.#run(actions, holds, report));
|
|
return report ? this.queue.catch(() => {}) : this.queue;
|
|
}
|
|
|
|
// Runs one chunk's actions in order. If that chunk's parse set `held`
|
|
// (`holds`), it then parses and runs what was held behind its takeover or
|
|
// reload. Only that run drains it: an earlier chunk's run that finishes
|
|
// first leaves it, so the held Enter is judged after the takeover (Darkwing
|
|
// T1 on #1522). An action that throws doesn't stop the ones after it or
|
|
// the held input, as if each key came on its own. With `report` an error
|
|
// is reported when it happens; without, the first one is rethrown at the
|
|
// end.
|
|
async #run(actions, holds, report) {
|
|
let failure = null;
|
|
while (actions) {
|
|
for (const run of actions) {
|
|
try {
|
|
await run();
|
|
} catch (err) {
|
|
if (report) report(err);
|
|
else failure ??= err;
|
|
}
|
|
}
|
|
if (!holds) break;
|
|
const rest = this.held;
|
|
this.held = null;
|
|
actions = this.#parse(rest);
|
|
holds = this.held !== null;
|
|
}
|
|
if (failure) throw failure;
|
|
}
|
|
|
|
// Applies a chunk to the composer and returns the actions it starts. After
|
|
// a HOLDS action the rest of the chunk goes to `held`.
|
|
#parse(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));
|
|
if (HOLDS.has(action)) {
|
|
this.held = s.slice(i + 1);
|
|
break;
|
|
}
|
|
} else if (s[i] >= " ") this.composer += s[i];
|
|
i += 1;
|
|
}
|
|
this.render();
|
|
return actions;
|
|
}
|
|
|
|
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.input(c.toString("utf8")));
|
|
stdin.on("end", quit);
|
|
term.render();
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|