feat(webui,conversation): S5 WebUI views and CHAT-03 follow-ups (row 40, #1522)

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]>
This commit is contained in:
2026-10-09 22:05:43 -05:00
co-authored by Claude Opus 5.5
parent 8cad772282
commit 08b428ecf1
27 changed files with 1935 additions and 80 deletions
+7 -1
View File
@@ -95,9 +95,15 @@ export class ScopeLauncher {
this.startTimeoutMs = startTimeoutMs;
}
// systemd-run --user reaches the user manager through these two; a scope's
// command inherits systemd-run's environment, so the engine sees them too.
// Neither loads code.
static MANAGER_ENV = Object.freeze(["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"]);
async launch({ unitName, socketPath, command, args, cwd, env }) {
const manager = Object.fromEntries(ScopeLauncher.MANAGER_ENV.filter((k) => typeof process.env[k] === "string").map((k) => [k, process.env[k]]));
const proc = spawn("systemd-run", ["--user", "--scope", "-p", "Delegate=yes", `--unit=${unitName}`, "--quiet", "--", process.execPath, this.shimPath, "--socket", socketPath, "--", command, ...args], {
cwd, env, stdio: ["pipe", "pipe", "pipe"],
cwd, env: { ...manager, ...env }, stdio: ["pipe", "pipe", "pipe"],
});
const exited = new Promise((r) => proc.on("exit", (code, signal) => r({ code, signal })));
const end = Date.now() + this.startTimeoutMs;
+33 -8
View File
@@ -30,7 +30,7 @@ import { LineSplitter, encodeLine, parseLine } from "./framing.mjs";
import { LiveSessionGuard, realPath } from "./guard.mjs";
import { ID, fragments, safeId } from "./parts.mjs";
import { parseSnapshot } from "./pi.mjs";
import { ENGINE_PIN_MISMATCH, PI_BIN, UNSEALED_ENGINE, argvDigest, buildPiArgs, checkEnginePin, checkSeal } from "./pi-pin.mjs";
import { ENGINE_PIN_MISMATCH, PI_BIN, UNSEALED_ENGINE, argvDigest, buildPiArgs, checkEnginePin, checkSeal, engineEnv } from "./pi-pin.mjs";
import { ACTOR, conversationId, createReader, rootsFromSpecs } from "./reader.mjs";
import { clone, equal, hash, newId, receiptAllows, record, scopeMatch, sealProof, sha256, targetOf } from "./records.mjs";
import { ControlRefusal, Refusal } from "./safe-fs.mjs";
@@ -66,6 +66,12 @@ export const ALL_CAPABILITIES = Object.freeze(["observe", "send", "take-control"
// CHAT-04 or I4 and refuse `unsupported-capability`.
export const VERIFIED_OPERATIONS = Object.freeze(["observe", "prompt", "takeover", "acquire-recovery-control", "interrupt", "force-stop", "recover", "issue-confirmation", "answer-confirmation"]);
// The engine a test runs instead of Pi: `{ command, preArgs, env }`. A
// symbol key, so no JSON configuration can carry it; the plain `engine`
// option takes only extraArgs, cwd and envKeys (I3, both reviewers on #1507).
export const TEST_ENGINE = Symbol("conversation.test-engine");
const ENGINE_KEYS = new Set(["extraArgs", "cwd", "envKeys"]);
export const TIMEOUTS = Object.freeze({ ack: 5000, state: 5000, start: 5000, clear: 5000, abort: 10000, settle: 10000, grace: 1000, write: 5000, maxRounds: 3 });
const FINAL = new Set(["finished", "failed", "dispatch-refused", "delivery-unknown"]);
@@ -160,17 +166,24 @@ export class Controller {
this.project = project;
this.workspace = workspace;
this.conversation = conversationId(this.root, basename(this.paths.sessionFile));
if (!engine || typeof engine !== "object" || Array.isArray(engine)) throw new ControlRefusal(UNSEALED_ENGINE, "engine is not an object");
const extra = Object.keys(engine).find((k) => !ENGINE_KEYS.has(k));
if (extra !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `engine.${extra.slice(0, 40)} can't be configured; the controller launches the pinned Pi with its own environment`);
const test = opts[TEST_ENGINE] ?? null;
this.engine = {
command: engine.command ?? process.execPath,
preArgs: engine.preArgs ?? [join(pinRoot, PI_BIN)],
sealed: test === null,
command: test ? test.command : process.execPath,
preArgs: test ? test.preArgs : [join(pinRoot, PI_BIN)],
extraArgs: engine.extraArgs ?? [],
cwd: engine.cwd ?? projectRoot,
env: engine.env ?? process.env,
env: test ? test.env : engineEnv(engine.envKeys),
};
for (const k of ["preArgs", "extraArgs"]) {
if (!Array.isArray(this.engine[k])) throw new ControlRefusal(UNSEALED_ENGINE, `engine.${k} is not a list`);
}
if (typeof this.engine.command !== "string" || !this.engine.command) throw new ControlRefusal(UNSEALED_ENGINE, "the engine command is not a path");
this.piArgs = buildPiArgs({ sessionFile: this.paths.sessionFile, extraArgs: this.engine.extraArgs });
this.pinRoot = pinRoot;
this.#checkSeal();
this.launcher = launcher;
this.verifier = verifier;
@@ -178,7 +191,6 @@ export class Controller {
this.barrier = barrier;
this.now = now;
this.T = { ...TIMEOUTS, ...timeouts };
this.pinRoot = pinRoot;
this.policyRevision = policyRevision;
this.sourceRootRef = sourceRootRef;
this.approvedMappings = approvedMappings ?? [sourceRootRef];
@@ -232,7 +244,12 @@ export class Controller {
if (this.barrier) await this.barrier(name, detail);
}
// The seal covers the command: unless a test engine was given, the launch
// is this Node running the pinned Pi's bin, and nothing else.
#checkSeal() {
if (this.engine.sealed && (this.engine.command !== process.execPath || this.engine.preArgs.length !== 1 || this.engine.preArgs[0] !== join(this.pinRoot, PI_BIN))) {
throw new ControlRefusal(UNSEALED_ENGINE, "the engine command is not the pinned Pi");
}
const bad = this.engine.preArgs.find((a) => typeof a !== "string" || a === "-e" || a === "--extension" || a.startsWith("--extension="));
if (bad !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `engine pre-arguments carry ${bad}`);
checkSeal(this.piArgs);
@@ -684,9 +701,17 @@ export class Controller {
if (!this.#checkConfirmation(r, c, op)) return refused("confirmation");
const s = this.#startStop("force-stop", { requestId: r.id, connection: c.id, target: t });
this.escalating = s.id;
this.closers.add("force-stop");
this.#admission();
this.exec?.link?.poison("force-stop");
try {
this.closers.add("force-stop");
this.#admission();
this.exec?.link?.poison("force-stop");
} catch (err) {
// #handle drops `after` on a throw, so #forceStop never runs to clear
// the flag; without this every later force stop is refused `fenced`
// until restart (Darkwing F2 on #1507).
if (this.escalating === s.id) this.escalating = null;
throw err;
}
return { outcome: "force-stop-fenced", stop: s, after: () => this.#forceStop(s, { confirmation: cmd.confirmation }) };
}
if (op === "recover") return this.#recover(c, r);
+30 -8
View File
@@ -7,12 +7,16 @@
// package's bin, and the built-in llama.cpp extension ships inside it.
//
// Seal: the controller builds the launch argv. It always carries
// --no-extensions, --no-prompt-templates and --no-themes, and never an
// --extension argument (cli/args.js; usage.md 224 and 233–236). With
// --no-extensions Pi loads only command-line extension paths
// (resource-loader.js 316–318), so no explicit extension loads. Under the seal
// the Mosaic prompt in the slot is the only thing that can start a run, which
// is the basis for attributing a run to it by order.
// --no-extensions, --no-prompt-templates, --no-themes and --no-approve, and
// never an --extension argument (cli/args.js; usage.md 224 and 233–236).
// --no-approve sets the project trust override to false, so a project's
// .pi/settings.json, SYSTEM.md, APPEND_SYSTEM.md and skills don't load even
// when trust.json under the agent dir trusts it (main.js 574–581;
// usage.md 126; Filbert F2 on #1522). With --no-extensions Pi loads only
// command-line extension paths (resource-loader.js 316–318), so no explicit
// extension loads. Under the seal the Mosaic prompt in the slot is the only
// thing that can start a run, which is the basis for attributing a run to it
// by order.
//
// The seal is an allow-list. Pi's parser (cli/args.js) keeps the last --mode
// and the last --session, reads a bare word as a prompt and an `@` word as a
@@ -28,7 +32,7 @@ export const PI_PACKAGE = "@earendil-works/pi-coding-agent";
export const PI_VERSION = "0.85.1";
export const PI_INTEGRITY = "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==";
export const PI_BIN = join("node_modules", PI_PACKAGE, "dist", "bundle", "cli.js");
export const SEAL_FLAGS = Object.freeze(["--no-extensions", "--no-prompt-templates", "--no-themes"]);
export const SEAL_FLAGS = Object.freeze(["--no-extensions", "--no-prompt-templates", "--no-themes", "--no-approve"]);
export const ENGINE_OPTIONS = Object.freeze(["--model", "--provider", "--thinking"]);
export const ENGINE_PIN_MISMATCH = "engine-pin-mismatch";
@@ -60,7 +64,7 @@ export function buildPiArgs({ sessionFile, extraArgs = [] }) {
return ["--mode", "rpc", ...SEAL_FLAGS, "--session", sessionFile, ...extraArgs];
}
// Refuses any argv that is not `--mode rpc`, the three --no-* flags and
// Refuses any argv that is not `--mode rpc`, the four --no-* flags and
// `--session <absolute path>`, in that order, followed by ENGINE_OPTIONS
// pairs. That covers --extension in either spelling, a second --mode or
// --session, session and output flags (--no-session, --fork, --export, ...)
@@ -87,6 +91,24 @@ export function checkSeal(args) {
return true;
}
// The engine's environment (I3, Darkwing F3 on #1507): built from names,
// never inherited whole. ENGINE_ENV names what Pi needs to run; a launch may
// add provider credentials by name (`engine.envKeys`), and nothing else, so
// NODE_OPTIONS, LD_PRELOAD and the PI_PACKAGE_DIR family can't load code
// around the seal. Values come from the controller's own environment; an
// unset name is left out, not set empty.
export const ENGINE_ENV = Object.freeze(["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TERM", "TMPDIR", "PI_CODING_AGENT_DIR", "PI_OFFLINE", "PI_SKIP_VERSION_CHECK", "PI_TELEMETRY"]);
export const CREDENTIAL_NAME = /^[A-Z][A-Z0-9_]{0,62}_(API_KEY|TOKEN)$/;
export function engineEnv(envKeys = [], source = process.env) {
if (!Array.isArray(envKeys)) throw new ControlRefusal(UNSEALED_ENGINE, "engine.envKeys is not a list");
const bad = envKeys.find((k) => typeof k !== "string" || !CREDENTIAL_NAME.test(k));
if (bad !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `engine.envKeys names ${String(bad).slice(0, 80)}, which is not a provider credential (*_API_KEY or *_TOKEN)`);
const env = {};
for (const k of [...ENGINE_ENV, ...envKeys]) if (typeof source[k] === "string") env[k] = source[k];
return env;
}
export function argvDigest(command, args) {
return createHash("sha256").update(JSON.stringify([command, ...args])).digest("hex");
}
+81 -6
View File
@@ -14,7 +14,9 @@
//
// 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.
// 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.
@@ -26,6 +28,10 @@ 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
@@ -65,6 +71,7 @@ export class Terminal {
this.frame = [];
this.sent = 0;
this.queue = Promise.resolve();
this.held = null;
client.on((m) => this.#onClient(m));
this.transcript.on(() => this.render());
}
@@ -91,7 +98,71 @@ export class Terminal {
}
// 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 = "";
@@ -138,13 +209,17 @@ export class Terminal {
// 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];
} 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();
for (const run of actions) this.queue = this.queue.then(run);
return this.queue;
return actions;
}
async #act(action) {
@@ -272,7 +347,7 @@ async function main() {
term.rows = stdout.rows || 24;
term.render();
});
stdin.on("data", (c) => void term.key(c.toString("utf8")));
stdin.on("data", (c) => void term.input(c.toString("utf8")));
stdin.on("end", quit);
term.render();
}