queue-55: round 2 candidate packet (darkwing)

Answers Filbert's comment 27251 and Dewey's comment 27253: R1 type-ahead
window, R2 terminal cell widths, B1 cut inside the question, 30 mutants
killed, N1-N8. Candidate manifest sha256 3f2d8f8e. Gate on c506c6ec:
cli 106/106 and every scripts/test-*.sh exit 0. The feature stays
uncommitted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 13:39:05 -05:00
co-authored by Claude Opus 5.5
parent c506c6ec12
commit fa6d889cb7
21 changed files with 1181 additions and 80 deletions
+451 -73
View File
@@ -22,7 +22,7 @@ index b96e9012..5a675611 100644
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 32977afa..3c3255f5 100644
index 32977afa..dbb03138 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -4,12 +4,16 @@ Jason's front door to the bus (slice 1 row S4, #1521): `mosaic inbox`,
@@ -44,7 +44,7 @@ index 32977afa..3c3255f5 100644
scripts/mosaic inbox
scripts/mosaic decide 3f2a9c1e yes --note "ship it"
scripts/mosaic trail 3f2a9c1e-… # then: scripts/mosaic trail vikunja:1/7
@@ -70,6 +74,52 @@ mosaic launches list [--json]
@@ -70,6 +74,65 @@ mosaic launches list [--json]
`launches list` does not, because the file is readable to the same user
anyway.
@@ -68,13 +68,16 @@ index 32977afa..3c3255f5 100644
+- Keys: `c i t a r` switch views (Talk, Inbox, Tasks, Agents, Trail). `↑↓`
+ or `j k` move, PgUp and PgDn page, Enter opens the selection whole, `v`
+ opens its trail, `:` the command line, `?` help, Esc closes a box, `q`
+ or Ctrl-C quits. Commands: `:decide <decision> <option>`, `:trail
+ or Ctrl-C quits. On the command line, Esc or Ctrl-C closes it and the
+ arrows type nothing. Commands: `:decide <decision> <option>`, `:trail
+ <task|decision>`, `:business <id>`, `:q`.
+- Deciding: in the Inbox, digit `n` picks the selected decision's `n`th
+ option and runs `resolveDecision` in `src/cli.mjs`, the code `mosaic
+ decide` runs. The box shows the same lines and the same `[y/N]`
+ question; only `y` resolves. `n`, Esc, Enter and `q` cancel, and other
+ keys wait. After `outcome unknown` the app shows the CLI's sentence and
+ question. `y` or `Y` resolves; any other key answers No, and so does
+ quitting. Keys that arrive within 200 ms of the question being drawn
+ are type-ahead and are dropped: a paste such as `1 yes`, or keys typed
+ while a read held the screen, never answer it. After `outcome unknown` the app shows the CLI's sentence and
+ offers the trail (`v`) or a re-read (`r`). No key sends the choice
+ again.
+- Reads happen on entering a view, after a decision, and every 30 s while
@@ -88,21 +91,31 @@ index 32977afa..3c3255f5 100644
+ reverse video on the selected row (the design's tokens keep selection
+ visible without colour). Frames fit 80x24; the layout is designed at
+ 120x36, and under 100 columns the nav shows keys only and the Inbox
+ drops RAISED BY.
+ drops RAISED BY. Below 40x10 the frame only says the terminal is too
+ small. Widths count terminal cells: CJK and emoji 2, a one-cell emoji
+ base with U+FE0F 2. ZWJ sequences and flags count more than they draw,
+ so such a row can come out short, never too wide.
+- A decide box taller than the screen cuts the question, not the lines
+ after it: the action and target, what the choice authorizes, the task,
+ your choice and the `[y/N]` question stay. The cut line points to
+ `mosaic inbox --json`.
+- The transport is the verbs' `spawnSync` child, so the screen does not
+ redraw while a read is in flight (up to its 30 s timeout).
+- On quit the app restores the screen and the cursor, then prints
+ `(app closed with q; N decisions resolved this session)`.
+- On quit, end of input, SIGTERM, SIGINT, SIGHUP or an error (one in a
+ resize redraw included) the app restores the screen, the cursor and
+ cooked mode. After anything but an error it then prints
+ `(app closed with q; N decisions resolved this session)`; an error
+ goes to stderr instead.
+
## Bus host
```
diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs
new file mode 100644
index 00000000..208bc41f
index 00000000..3cb3ab71
--- /dev/null
+++ b/packages/cli/src/app.mjs
@@ -0,0 +1,447 @@
@@ -0,0 +1,480 @@
+// The full-screen app: `mosaic` with no argument on a terminal (src/cli.mjs
+// main). Inbox, Tasks, Agents, Trail and Help over the same broker reads as
+// the verbs; src/screens.mjs draws the frames.
@@ -110,13 +123,16 @@ index 00000000..208bc41f
+// Reads and the decide flow go through the human transport, like the verbs.
+// A decision resolves only through resolveDecision (src/cli.mjs), the code
+// `mosaic decide` runs: a digit picks an option, the app shows the CLI's
+// lines and its yes/no question, and only y resolves. After "outcome unknown"
+// the app offers the trail and a re-read, never a retry.
+// lines and its yes/no question. y or Y resolves; any other key, or quitting,
+// answers No. Keys that arrive within ASK_MS of the question being drawn are
+// type-ahead and never answer it. After "outcome unknown" the app offers the
+// trail and a re-read, never a retry.
+//
+// Keys: c i t a r views; ↑↓ or j k move; PgUp PgDn page; Enter opens the
+// selection's detail; v the trail of the selection; 1-9 choose; `:` the
+// command line; ? help; Esc back; q or Ctrl-C quits. Talk (c) is a pointer
+// to `mosaic talk`; the conversation pane is a later row.
+// command line (Esc or Ctrl-C closes it); ? help; Esc back; q or Ctrl-C
+// quits. Below 40x10 the frame says the terminal is too small. Talk (c) is a
+// pointer to `mosaic talk`; the conversation pane is a later row.
+
+import { resolveDecision } from "./cli.mjs";
+import { CliError } from "./errors.mjs";
@@ -125,6 +141,8 @@ index 00000000..208bc41f
+import { busExit } from "./transport.mjs";
+
+export const REFRESH_MS = 30000;
+// How long the decide question is up before a key can answer it.
+export const ASK_MS = 200;
+
+// Terminal input to key names: "up", "down", "pgup", "pgdn", "enter", "esc",
+// "tab", "backspace", "ctrl-c", or the character itself. Other escape
@@ -157,7 +175,7 @@ index 00000000..208bc41f
+export class App {
+ // transport(business) returns call(verb, args). business may be null:
+ // the app then says so and waits for :business <id>.
+ constructor({ business = null, transport, write = () => {}, cols = 80, rows = 24, color = true, now = () => Date.now(), onQuit = () => {}, refreshMs = REFRESH_MS }) {
+ constructor({ business = null, transport, write = () => {}, cols = 80, rows = 24, color = true, now = () => Date.now(), onQuit = () => {}, refreshMs = REFRESH_MS, askMs = ASK_MS }) {
+ this.transport = transport;
+ this.write = write;
+ this.cols = cols;
@@ -165,6 +183,7 @@ index 00000000..208bc41f
+ this.color = color;
+ this.now = now;
+ this.onQuit = onQuit;
+ this.askMs = askMs;
+ this.state = { business, view: "inbox", data: {}, sel: { inbox: 0, tasks: 0, agents: 0, trail: 0 }, overlay: null, command: null, status: "", statusStyle: null };
+ this.call = business ? transport(business) : null;
+ this.resolved = 0;
@@ -172,6 +191,7 @@ index 00000000..208bc41f
+ this.keys = [];
+ this.job = null;
+ this.answer = null;
+ this.cancelAsk = null;
+ this.wake = null;
+ this.idle = [];
+ this.pumping = false;
@@ -219,7 +239,14 @@ index 00000000..208bc41f
+ for (;;) {
+ if (this.closed) break;
+ if (this.answer && this.keys.length) {
+ this.answer(this.keys.shift());
+ // A timer tick is not an answer.
+ const k = this.keys.shift();
+ try {
+ if (k !== "refresh") this.answer(k);
+ } catch (e) {
+ this.crash(e);
+ break;
+ }
+ continue;
+ }
+ if (this.job) {
@@ -251,22 +278,28 @@ index 00000000..208bc41f
+ for (const r of this.idle.splice(0)) r();
+ }
+
+ // Asks the decide flow's question; resolves true on y. n, Esc, Enter, q
+ // and Ctrl-C say no; other keys are ignored.
+ // Asks the decide flow's question; resolves true on y or Y and false on
+ // any other key. Only a key typed after the question is up can answer:
+ // keys that arrive within askMs of drawing it are type-ahead (a paste, or
+ // keys typed while a read blocked the screen) and are dropped. Quitting
+ // answers no.
+ #ask(question) {
+ this.state.overlay = { ...this.state.overlay, asking: question };
+ this.render();
+ return new Promise((resolve) => {
+ const take = (k) => {
+ if (k === "y" || k === "Y") resolve(true);
+ else if (["n", "N", "esc", "enter", "q", "ctrl-c"].includes(k)) resolve(false);
+ else return void (this.answer = take);
+ this.answer = null;
+ const answer = (yes) => {
+ clearTimeout(arm);
+ this.answer = this.cancelAsk = null;
+ this.state.overlay = { ...this.state.overlay, asking: null };
+ this.render();
+ resolve(yes);
+ };
+ this.answer = take;
+ this.wake?.();
+ this.cancelAsk = () => answer(false);
+ const arm = setTimeout(() => {
+ this.keys = [];
+ this.answer = (k) => answer(k === "y" || k === "Y");
+ this.wake?.();
+ }, this.askMs);
+ });
+ }
+
@@ -278,7 +311,10 @@ index 00000000..208bc41f
+ // Handles one key; returns a promise when it starts a read or a decision.
+ #key(k) {
+ const s = this.state;
+ if (k === "start" || k === "refresh") return k === "refresh" && (s.overlay || s.command !== null) ? null : this.#read(s.view);
+ // A timer tick keeps a "resolved" status; it reads nothing under an
+ // overlay or the command line.
+ if (k === "start") return this.#read(s.view);
+ if (k === "refresh") return s.overlay || s.command !== null ? null : this.#read(s.view, s.statusStyle === "success");
+ if (s.command !== null) return this.#commandKey(k);
+ if (k === "ctrl-c") return this.quit("Ctrl-C");
+ if (s.overlay) return this.#overlayKey(k);
@@ -437,7 +473,8 @@ index 00000000..208bc41f
+ try {
+ const list = await this.call(view, view === "trail" ? { subject: before.subject } : {});
+ this.state.data[view] = { subject: before.subject, list, at: new Date(this.now()).toISOString() };
+ if (!keep) this.#status(view === "inbox" && list.length ? `Press 1-${Math.min(9, this.#selected("inbox")?.options.length ?? 1)} to choose. You will see the decision again and confirm with y.` : "");
+ const n = Math.min(9, this.#selected("inbox")?.options.length ?? 1);
+ if (!keep) this.#status(view === "inbox" && list.length ? `Press ${n === 1 ? "1" : `1-${n}`} to choose. You will see the decision again and confirm with y.` : "");
+ } catch (e) {
+ if (!(e instanceof CliError) && !e.code) throw e;
+ // Exit 3 codes are refusals (src/transport.mjs), not failed reads.
@@ -456,7 +493,7 @@ index 00000000..208bc41f
+ const text = s.command.trim();
+ s.command = null;
+ return this.#run(text);
+ } else if (k.length <= 2 && k >= " ") s.command += k;
+ } else if ([...k].length === 1 && k >= " ") s.command += k;
+ this.render();
+ }
+
@@ -487,7 +524,7 @@ index 00000000..208bc41f
+ if (this.closed) return;
+ this.closed = true;
+ if (this.timer) clearInterval(this.timer);
+ this.answer?.("q");
+ this.cancelAsk?.();
+ this.wake?.();
+ this.onQuit({ how, resolved: this.resolved, error: this.error });
+ }
@@ -507,30 +544,39 @@ index 00000000..208bc41f
+}
+
+// Runs the app on a terminal until it quits: raw mode, the alternate screen,
+// no cursor; all three restored on quit, on an error and on SIGTERM.
+// Resolves with { how, resolved }.
+export function runApp({ io, business, transport, color, refreshMs }) {
+// no cursor; all three restored on quit, on an error (a render that throws
+// included) and on SIGTERM, SIGINT or SIGHUP. Resolves with { how, resolved }.
+export function runApp({ io, business, transport, color, refreshMs, askMs, signals = process }) {
+ const { stdin, stdout } = io;
+ const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
+ return new Promise((resolve, reject) => {
+ let app;
+ const restore = () => {
+ stdin.off("data", onData);
+ stdin.off("end", onEnd);
+ stdout.off?.("resize", onResize);
+ process.off("SIGTERM", onTerm);
+ for (const [name, on] of onSignal) signals.off(name, on);
+ stdin.setRawMode?.(false);
+ stdin.pause?.();
+ stdout.write("\x1b[?25h\x1b[?1049l");
+ };
+ const guard = (f) => {
+ try {
+ f();
+ } catch (e) {
+ app.crash(e);
+ }
+ };
+ const onData = (c) => void app.input(c.toString("utf8"));
+ const onEnd = () => app.quit("end of input");
+ const onResize = () => app.resize(stdout.columns || 80, stdout.rows || 24);
+ const onTerm = () => app.quit("SIGTERM");
+ const onResize = () => guard(() => app.resize(stdout.columns || 80, stdout.rows || 24));
+ const onSignal = SIGNALS.map((name) => [name, () => app.quit(name)]);
+ app = new App({
+ business,
+ transport,
+ color,
+ refreshMs,
+ askMs,
+ cols: stdout.columns || 80,
+ rows: stdout.rows || 24,
+ write: (s) => stdout.write(s),
@@ -545,9 +591,9 @@ index 00000000..208bc41f
+ stdin.on("data", onData);
+ stdin.on("end", onEnd);
+ stdout.on?.("resize", onResize);
+ process.on("SIGTERM", onTerm);
+ for (const [name, on] of onSignal) signals.on(name, on);
+ stdin.resume?.();
+ void app.start();
+ guard(() => void app.start());
+ });
+}
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
@@ -662,10 +708,10 @@ index 381596b4..7bbfe39c 100644
return [r.kind, r.actor_role ?? null, r.body?.operation ?? null, r.body?.decision ? `decision ${shortId(r.body.decision)}` : null].filter(Boolean).join(" ");
diff --git a/packages/cli/src/screens.mjs b/packages/cli/src/screens.mjs
new file mode 100644
index 00000000..d6e09981
index 00000000..c0194cb2
--- /dev/null
+++ b/packages/cli/src/screens.mjs
@@ -0,0 +1,367 @@
@@ -0,0 +1,405 @@
+// The full-screen app's frames (src/app.mjs). Pure: draw(state, opts) takes
+// the app state and returns exactly `rows` strings, each `cols` cells wide
+// once its escape codes are dropped. Nothing here reads the bus or the
@@ -685,17 +731,24 @@ index 00000000..d6e09981
+
+// Below this width the nav shows keys only and Inbox drops RAISED BY.
+export const NARROW_BELOW = 100;
+// Below this size the frame only says the terminal is too small.
+export const MIN_COLS = 40;
+export const MIN_ROWS = 10;
+
+const SGR = { action: ["34", "39"], accent: ["33", "39"], success: ["32", "39"], warning: ["93", "39"], danger: ["31", "39"], muted: ["90", "39"], bold: ["1", "22"] };
+
+const oneLine = (s) => visible(s ?? "").replace(/\r?\n/g, "^J");
+
+// Terminal cells for one code point: 0 for combining marks, joiners and
+// variation selectors, 2 for East Asian wide characters and emoji, else 1.
+function cells(c) {
+// Terminal cells for one code point after `prev`: 0 for combining marks,
+// joiners and variation selectors, 2 for East Asian wide characters and
+// emoji shown as emoji, else 1. U+FE0F after a one-cell emoji base (🅰️, ❤️)
+// asks for emoji presentation and adds the second cell.
+function cells(c, prev = "") {
+ const n = c.codePointAt(0);
+ if (/\p{M}/u.test(c) || n === 0x200d || n === 0x200c || (n >= 0xfe00 && n <= 0xfe0f)) return 0;
+ if (n === 0xfe0f) return prev && /\p{Emoji}/u.test(prev) && cells(prev) === 1 ? 1 : 0;
+ if (/\p{M}/u.test(c) || n === 0x200d || n === 0x200c || (n >= 0xfe00 && n <= 0xfe0e)) return 0;
+ if (
+ /\p{Emoji_Presentation}/u.test(c) ||
+ (n >= 0x1100 && n <= 0x115f) ||
+ (n >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) ||
+ (n >= 0xac00 && n <= 0xd7a3) ||
@@ -703,7 +756,7 @@ index 00000000..d6e09981
+ (n >= 0xfe30 && n <= 0xfe4f) ||
+ (n >= 0xff00 && n <= 0xff60) ||
+ (n >= 0xffe0 && n <= 0xffe6) ||
+ (n >= 0x1f300 && n <= 0x1faff) ||
+ (n >= 0x1f200 && n <= 0x1f2ff) ||
+ (n >= 0x20000 && n <= 0x3fffd)
+ )
+ return 2;
@@ -712,7 +765,11 @@ index 00000000..d6e09981
+
+export function width(s) {
+ let w = 0;
+ for (const c of s) w += cells(c);
+ let prev = "";
+ for (const c of s) {
+ w += cells(c, prev);
+ prev = c;
+ }
+ return w;
+}
+
@@ -722,11 +779,13 @@ index 00000000..d6e09981
+ if (width(s) <= w) return s + " ".repeat(w - width(s));
+ let out = "";
+ let used = 0;
+ let prev = "";
+ for (const c of s) {
+ const n = cells(c);
+ const n = cells(c, prev);
+ if (used + n > w - 1) break;
+ out += c;
+ used += n;
+ prev = c;
+ }
+ return out + "…" + " ".repeat(w - 1 - used);
+}
@@ -743,7 +802,7 @@ index 00000000..d6e09981
+ for (const word of s.split(/(?<= )/)) {
+ if (cur.trim() !== "" && width(cur + word.trimEnd()) > w) push();
+ for (const c of word) {
+ if (width(cur) + cells(c) > w) push();
+ if (width(cur + c) > w) push();
+ cur += c;
+ }
+ }
@@ -984,18 +1043,32 @@ index 00000000..d6e09981
+ const [text, style] = Array.isArray(l) ? l : [l, null];
+ for (const t of wrap(oneLine(text), inner, " ")) out.push(line(["│ ", "accent"], [fit(t, inner), style], [" │", "accent"]));
+ }
+ out.push(line([`└${"─".repeat(w - 2)}┘`, "accent"]));
+ out.push(line([`└${"─".repeat(Math.max(0, w - 2))}┘`, "accent"]));
+ return out;
+}
+
+function overlayBox(o, cols) {
+const cutLine = (what, inner) => line(["│ ", "accent"], fit(`… (${what} cut to fit; the whole text is in mosaic inbox --json)`, inner), [" │", "accent"]);
+
+// `room` is the body's height. A decide box taller than that loses the end
+// of the question, never the decision line, the options, the
+// recommendation, the authorization lines, the task, the choice, the
+// question to answer or its keys.
+function overlayBox(o, cols, room) {
+ if (o.kind === "help") return box("help", HELP, Math.min(84, cols - 2));
+ const w = Math.min(84, cols - 2);
+ if (o.kind === "decide") {
+ const lines = o.lines.map((t) => (t.startsWith("your choice: ") ? [t, "bold"] : t));
+ if (o.asking) lines.push("", [o.asking, "bold"], "", ["y resolves · n, Esc or Enter cancels (the default is No)", "muted"]);
+ if (o.asking) lines.push("", [o.asking, "bold"], "", ["y resolves · any other key cancels (the default is No)", "muted"]);
+ else lines.push("", ["resolving…", "muted"]);
+ return box("decide", lines, w);
+ const b = box("decide", lines, w);
+ const q = o.lines.findIndex((t) => t.startsWith(" question: "));
+ const end = o.lines.findIndex((t, i) => i > q && t.startsWith(" options: "));
+ if (b.length <= room || q < 0 || end < 0) return b;
+ const head = box("decide", lines.slice(0, q), w).slice(0, -1);
+ const question = box("decide", lines.slice(q, end), w).slice(1, -1);
+ const tail = box("decide", lines.slice(end), w).slice(1);
+ const keep = room - head.length - tail.length - 1;
+ return keep >= 1 ? [...head, ...question.slice(0, keep), cutLine("question", w - 4), ...tail] : b;
+ }
+ if (o.kind === "unknown") {
+ return box("outcome unknown", [[o.message, "warning"], "", "Nothing was retried, and the choice was not sent twice.", "", "v open the trail r re-read the inbox Esc back"], w);
@@ -1006,6 +1079,7 @@ index 00000000..d6e09981
+
+// Draws the frame. opts: cols, rows, color (false under NO_COLOR), now (ms).
+export function draw(state, { cols, rows, color = true, now = Date.now() }) {
+ if (cols < MIN_COLS || rows < MIN_ROWS) return tooSmall(cols, rows, color);
+ const room = rows - 5;
+ let body;
+ if (!state.business && state.view !== "talk") body = [line([` ${VIEWS.find((v) => v[2] === state.view)[1]}`, "bold"]), blank(), ...noBusiness()];
@@ -1017,13 +1091,15 @@ index 00000000..d6e09981
+ body = body.slice(0, room);
+ while (body.length < room) body.push(blank());
+ if (state.overlay) {
+ let b = overlayBox(state.overlay, cols);
+ // Too tall: cut the middle, never the choice, the prompt or the keys.
+ let b = overlayBox(state.overlay, cols, room);
+ // Still too tall (a small screen, or a box with no question): keep the
+ // top and, for decide, everything from the choice on; past that, the
+ // bottom wins, so the question to answer and its keys stay.
+ if (b.length > room) {
+ const at = b.findIndex((l) => l.pieces.some(([t]) => t.startsWith("your choice: ")));
+ const tail = b.slice(at > 0 ? at : -2);
+ const inner = Math.min(84, cols - 2) - 4;
+ b = [...b.slice(0, Math.max(1, room - tail.length - 1)), line(["│ ", "accent"], fit(`… (cut to fit; the whole text is in ${state.overlay.whole ?? "mosaic inbox"})`, inner), [" │", "accent"]), ...tail].slice(-room);
+ b = [...b.slice(0, Math.max(1, room - tail.length - 1)), line(["│ ", "accent"], fit(`… (cut to fit; the whole text is in ${state.overlay.whole ?? "mosaic inbox --json"})`, inner), [" │", "accent"]), ...tail].slice(-room);
+ }
+ const left = Math.max(0, Math.floor((cols - width(b[0].pieces.map(([t]) => t).join(""))) / 2));
+ const top = Math.max(0, Math.min(2, room - b.length));
@@ -1033,21 +1109,31 @@ index 00000000..d6e09981
+ const frame = [nav(state, cols, now), rule(cols), ...body, rule(cols), status, line([cols < NARROW_BELOW ? HINT_NARROW : HINT_WIDE, "muted"])];
+ return frame.map((l) => paint(l, cols, color));
+}
+
+// The whole frame below MIN_COLS x MIN_ROWS: what is wrong, and how to quit.
+function tooSmall(cols, rows, color) {
+ const say = wrap(`The terminal is ${cols}x${rows}; the app needs at least ${MIN_COLS}x${MIN_ROWS}. Make it bigger. Esc closes what is open; q quits.`, Math.max(1, cols));
+ const body = say.slice(0, Math.max(0, rows)).map((t) => line([t, "warning"]));
+ while (body.length < rows) body.push(blank());
+ return body.map((l) => paint(l, Math.max(0, cols), color));
+}
diff --git a/packages/cli/tests/app.test.mjs b/packages/cli/tests/app.test.mjs
new file mode 100644
index 00000000..b88b2230
index 00000000..b01abcb6
--- /dev/null
+++ b/packages/cli/tests/app.test.mjs
@@ -0,0 +1,427 @@
@@ -0,0 +1,719 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+import { EventEmitter } from "node:events";
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { App, parseKeys, quitLine } from "../src/app.mjs";
+import { main, SEAT_USAGE, USAGE } from "../src/cli.mjs";
+import { App, parseKeys, quitLine, runApp } from "../src/app.mjs";
+import { main, resolveDecision, SEAT_USAGE, USAGE } from "../src/cli.mjs";
+import { CliError } from "../src/errors.mjs";
+import { hostDir, hostFile, startTimeOf } from "../src/host.mjs";
+import { draw, strip, width } from "../src/screens.mjs";
+import { broker, humanEnv, io, tmp } from "./helpers.mjs";
+
@@ -1059,6 +1145,14 @@ index 00000000..b88b2230
+ [80, 24],
+ [100, 30],
+ [99, 24],
+ [40, 10],
+];
+// Below 40x10 the frame says the terminal is too small.
+const TOO_SMALL = [
+ [39, 10],
+ [40, 9],
+ [3, 3],
+ [1, 1],
+];
+
+const exitOf = async (p) => {
@@ -1081,6 +1175,7 @@ index 00000000..b88b2230
+ write: (s) => (out.text = s),
+ now: () => NOW,
+ refreshMs: 0,
+ askMs: 0,
+ onQuit: (r) => (out.quit = r),
+ ...opts,
+ });
@@ -1113,10 +1208,15 @@ index 00000000..b88b2230
+ ...more,
+ });
+ const data = {
+ inbox: [1, 2, 3].map((n) => decision(n)),
+ // Emoji and CJK that are two cells wide (P3b in the round 1 review).
+ inbox: [
+ decision(1),
+ decision(2, { action: "✅✅ ship", authorization: { action: "git.push.protected", target: "⚡ main", approvalChoice: "yes" } }),
+ decision(3, { authorization: { action: "git.push.protected", target: "🅰️ branch", approvalChoice: "yes" } }),
+ ],
+ tasks: [
+ { seq: 1, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/7", updated: "x", digest: "d", fields: { title: HOSTILE, status: "in progress" }, source: "poll", via: "board", read_at: "2026-10-11T16:00:00.000Z" },
+ { seq: 2, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/8", updated: "x", digest: "d", fields: { title: "Short", done: true }, source: "self", via: null, read_at: null },
+ { seq: 2, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/8", updated: "x", digest: "d", fields: { title: "発行 ✅", done: true }, source: "self", via: null, read_at: null },
+ ],
+ agents: [{ role: "coder", holder_run: "coder-run-with-a-long-name-0123456789", harness: "pi", at: "2026-10-11T15:00:00.000Z" }],
+ trail: [
@@ -1169,7 +1269,7 @@ index 00000000..b88b2230
+ { kind: "error", title: "decide", message: `failed: ${HOSTILE}` },
+ ]) {
+ a.state.overlay = o;
+ shot(`overlay ${o.kind}`);
+ shot(`overlay ${o.kind}${o.lines?.length > 10 ? " long" : ""}`);
+ }
+ a.state.overlay = null;
+ // Empty, failed-with-stale-data, refused and no-business states.
@@ -1181,6 +1281,7 @@ index 00000000..b88b2230
+ shot("inbox refused");
+ a.state.business = null;
+ shot("no business");
+ assert.equal(a.closed, false, `${cols}x${rows}: ${a.error?.stack}`);
+ a.quit("q");
+ return shots;
+}
@@ -1190,15 +1291,40 @@ index 00000000..b88b2230
+ for (const color of [true, false]) {
+ for (const [label, frame] of await everyScreen(t, cols, rows, color)) fits(frame, cols, rows, `${cols}x${rows} ${color ? "colour" : "NO_COLOR"} ${label}`);
+ }
+ // A decide box taller than the screen keeps its question and keys.
+ const long = (await everyScreen(t, cols, rows, false)).find(([l]) => l === "overlay decide long")[1].join("\n");
+ assert.match(long, /resolve 11111111 with yes\?/, `${cols}x${rows}`);
+ assert.match(long, /y resolves/, `${cols}x${rows}`);
+ if (rows >= 24) assert.match(long, /cut to fit[\s\S]*your choice: yes \(Allow\)/, `${cols}x${rows}`);
+ }
+ // Too small: every screen still fits, and says why.
+ for (const [cols, rows] of TOO_SMALL) {
+ for (const [label, frame] of await everyScreen(t, cols, rows, true)) fits(frame, cols, rows, `${cols}x${rows} ${label}`);
+ }
+ assert.match((await everyScreen(t, 39, 10, false))[0][1].join(""), /The terminal is 39x10; the app needs at least 40x10/);
+ // Designed at 120x36: the inbox shows the raiser; under 100 columns the nav shows keys only.
+ const [wide] = await everyScreen(t, 120, 36, false);
+ assert.match(strip(wide[1][0]), /Inbox/);
+ assert.match(strip(wide[1][0]), /\[i Inbox 3\]/);
+ assert.match(wide[1].map(strip).join("\n"), /RAISED BY/);
+ const [narrow] = await everyScreen(t, 99, 24, false);
+ assert.match(strip(narrow[1][0]), /\[i 3\] t a /);
+ assert.doesNotMatch(narrow[1].map(strip).join("\n"), /RAISED BY/);
+});
+
+test("width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell base 1, marks 0", async (t) => {
+ for (const [s, cells] of [["a", 1], ["発行", 4], ["✅", 2], ["⚡", 2], ["❌", 2], ["⭐", 2], ["😀", 2], ["🅰", 1], ["🅰\uFE0F", 2], ["❤\uFE0F", 2], ["😀\uFE0F", 2], ["e\u0301", 1], ["✓", 1], ["›", 1]]) {
+ assert.equal(width(s), cells, JSON.stringify(s));
+ }
+ // On screen, independent of width(): each wide character takes two cells
+ // for one UTF-16 unit, so a row of `cols` cells is that much shorter in
+ // units. 🅰️ is three units for two cells.
+ const frames = Object.fromEntries(await everyScreen(t, 80, 24, false));
+ const row = (label, text) => strip(frames[label].find((l) => l.includes(text)));
+ assert.equal(row("inbox", "✅✅ ship").length, 80 - 3, "two ✅ and one ⚡");
+ assert.equal(row("inbox", "🅰️ branch").length, 80 + 1, "🅰️");
+ assert.equal(row("tasks", "発行 ✅").length, 80 - 3, "発行 and ✅, not cut");
+});
+
+test("broker text is shown, never interpreted: no raw control character or bidi override reaches the terminal", async (t) => {
+ for (const [label, frame] of await everyScreen(t, 120, 36, true)) {
+ for (const l of frame) {
@@ -1222,8 +1348,12 @@ index 00000000..b88b2230
+ assert.doesNotMatch(a.render().join("\n"), /\x1b/, key);
+ }
+ a.quit("q");
+ // The selected row is reversed, with colour and without.
+ const [[, plain]] = await everyScreen(t, 120, 36, false);
+ assert.equal(plain.filter((l) => l.startsWith("\x1b[7m") && l.endsWith("\x1b[27m")).length, 1);
+ // The colour build does use colour.
+ const [[, coloured]] = await everyScreen(t, 120, 36, true);
+ assert.equal(coloured.filter((l) => l.startsWith("\x1b[7m") && l.endsWith("\x1b[27m")).length, 1);
+ assert.match(coloured.join("\n"), /\x1b\[3[0-9]m/);
+});
+
@@ -1237,7 +1367,7 @@ index 00000000..b88b2230
+ const box = s.screen();
+ assert.match(box, /your choice: yes \(Allow\); this authorizes the action/);
+ assert.match(box, new RegExp(`resolve ${d.id.slice(0, 8)} with yes\\? \\[y/N\\]`));
+ assert.match(box, /y resolves · n, Esc or Enter cancels \(the default is No\)/);
+ assert.match(box, /y resolves · any other key cancels \(the default is No\)/);
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), "nothing is sent before y");
+ await s.a.input("y");
+ assert.deepEqual(s.calls.filter((c) => c.verb === "decision.resolve"), [{ business: "demo", verb: "decision.resolve", args: { id: d.id, choice: "yes" } }]);
@@ -1273,23 +1403,132 @@ index 00000000..b88b2230
+ await s.a.input("n");
+});
+
+test("n, Esc, Enter and q at the question cancel; other keys wait; nothing is sent", async (t) => {
+ for (const key of ["n", "N", "\x1b", "\r", "q"]) {
+test("any key but y or Y at the question answers No; nothing is sent", async (t) => {
+ for (const key of ["n", "N", "\x1b", "\r", "q", "x", " ", "2", "\x03", "\x1b[A"]) {
+ const s = app(t);
+ s.raise("git.push.protected", { target: "refactor" });
+ await s.a.start();
+ await s.a.input("1");
+ await s.a.input("x");
+ assert.ok(s.a.state.overlay?.asking, `${JSON.stringify(key)}: still asking after x`);
+ assert.ok(s.a.state.overlay?.asking, JSON.stringify(key));
+ await s.a.input(key);
+ assert.equal(s.a.state.overlay, null, JSON.stringify(key));
+ assert.equal(s.a.state.status, "not resolved");
+ assert.equal(s.read("inbox").length, 1);
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
+ assert.equal(s.out.quit, null, "q at the question cancels; it does not quit");
+ assert.equal(s.out.quit, null, "q and Ctrl-C at the question answer No; they do not quit");
+ }
+});
+
+test("type-ahead never answers the question: a paste, keys typed before it is drawn, keys inside askMs", async (t) => {
+ const asking = (s) => Boolean(s.a.state.overlay?.asking);
+ const sent = (s) => s.calls.filter((c) => c.verb === "decision.resolve").length;
+ // P2a: "1 yes" pasted on the Inbox.
+ const a = app(t);
+ a.raise("git.push.protected", { target: "refactor" });
+ await a.a.start();
+ await a.a.input("1 yes");
+ assert.ok(asking(a), "P2a");
+ assert.equal(sent(a), 0, "P2a");
+ // P2b: "decide 1a2b yes" typed without the colon.
+ const b = app(t);
+ b.raise("git.push.protected", { target: "refactor" });
+ await b.a.start();
+ await b.a.input("decide 1a2b yes");
+ assert.ok(asking(b), "P2b");
+ assert.equal(sent(b), 0, "P2b");
+ // P2c: 1 and y as separate events, before the question is drawn.
+ const c = app(t);
+ c.raise("git.push.protected", { target: "refactor" });
+ await c.a.start();
+ await Promise.all([c.a.input("1"), c.a.input("y")]);
+ assert.ok(asking(c), "P2c");
+ assert.equal(sent(c), 0, "P2c");
+ // A y that arrives after the question is drawn but within askMs, as keys
+ // typed while a read blocked the screen do: one at once, one 10 ms later.
+ let early;
+ let d;
+ d = app(t, {
+ askMs: 300,
+ write: (text) => {
+ if (!early && text.includes("[y/N]")) {
+ void d.a.input("y");
+ early = new Promise((r) => setTimeout(() => d.a.input("y").then(r), 10));
+ }
+ },
+ });
+ d.raise("git.push.protected", { target: "refactor" });
+ await d.a.start();
+ await d.a.input("1");
+ await early;
+ assert.ok(asking(d), "within askMs");
+ assert.equal(sent(d), 0, "within askMs");
+ while (!d.a.answer) await new Promise((r) => setTimeout(r, 10));
+ // After that, y resolves.
+ for (const s of [a, b, c, d]) {
+ await s.a.input("y");
+ assert.equal(sent(s), 1);
+ assert.match(s.a.state.status, /^resolved /);
+ }
+});
+
+test("quitting at the question answers No and sends nothing, armed or not", async (t) => {
+ for (const askMs of [0, 60000]) {
+ const s = app(t, { askMs });
+ s.raise("git.push.protected", { target: "refactor" });
+ await s.a.start();
+ const pending = s.a.input("1");
+ while (!s.a.state.overlay?.asking) await new Promise((r) => setImmediate(r));
+ s.a.quit("SIGTERM");
+ await pending;
+ while (s.a.job) await new Promise((r) => setImmediate(r));
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), `askMs ${askMs}`);
+ assert.deepEqual(s.out.quit, { how: "SIGTERM", resolved: 0, error: undefined });
+ }
+});
+
+test("a timer tick reads nothing under an overlay, never answers the question, and keeps a resolved status", async (t) => {
+ const s = app(t);
+ const d = s.raise("git.push.protected", { target: "refactor" });
+ s.raise("git.push.protected", { target: "next" });
+ await s.a.start();
+ const reads = () => s.calls.filter((c) => c.verb === "inbox").length;
+ const before = reads();
+ await s.a.input("?");
+ await s.a.input("\0refresh");
+ assert.equal(reads(), before, "help open");
+ await s.a.input("\x1b:");
+ await s.a.input("\0refresh");
+ assert.equal(reads(), before, "command line open");
+ await s.a.input("\x1b");
+ await s.a.input("\0refresh");
+ assert.equal(reads(), before + 1, "nothing open");
+ await s.a.input("1");
+ const asked = reads();
+ await s.a.input("\0refresh");
+ assert.ok(s.a.state.overlay?.asking, "a tick is not an answer");
+ assert.equal(reads(), asked);
+ await s.a.input("y");
+ assert.equal(s.a.state.status, `resolved ${d.id}: yes`);
+ await s.a.input("\0refresh");
+ assert.equal(s.a.state.status, `resolved ${d.id}: yes`, "N4");
+ assert.equal(s.a.state.data.inbox.list.length, 1);
+});
+
+test("a long question is cut inside the question; the action, target, choice and prompt stay (Dewey B1)", async (t) => {
+ const s = app(t, { cols: 80, rows: 24 });
+ const question = Array.from({ length: 90 }, (_, i) => `word${i}`).join(" ");
+ assert.ok(question.length > 530);
+ const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7", question });
+ await s.a.start();
+ await s.a.input("1");
+ const screen = s.screen();
+ for (const want of [`decision ${d.id}`, " question: word0 word1", "options: yes = Allow; no = Decline", "recommended: no", "action: git.push.protected on refactor", 'choosing "yes" authorizes it; any other choice declines', "task: vikunja:1/7", "your choice: yes (Allow); this authorizes the action", `resolve ${d.id.slice(0, 8)} with yes? [y/N]`, "y resolves", "question cut to fit; the whole text is in mosaic inbox --json"]) {
+ assert.ok(screen.includes(want), `${want}\n${screen}`);
+ }
+ assert.ok(!screen.includes("word89"), "the end of the question is what goes");
+ fits(s.a.frame, 80, 24, "long question");
+});
+
+test("outcome unknown: the CLI's sentence, no retry, then the trail or a re-read", async (t) => {
+ let resolves = 0;
+ const s = app(t, {
@@ -1315,6 +1554,21 @@ index 00000000..b88b2230
+ assert.equal(s.a.state.view, "trail");
+ assert.equal(s.a.state.data.trail.subject, d.id);
+ assert.ok(s.a.state.data.trail.list.length > 0);
+ // The transport's own "did not finish" error has no code, only the
+ // hyphenated message; it opens the same box, and Esc closes it.
+ const hung = app(t, {
+ wrap: (call) => async (verb, args) => {
+ if (verb === "decision.resolve") throw new CliError("outcome-unknown: the bus transport did not finish", 1);
+ return call(verb, args);
+ },
+ });
+ hung.raise("git.push.protected", { target: "refactor" });
+ await hung.a.start();
+ await hung.a.input("1");
+ await hung.a.input("y");
+ assert.deepEqual([hung.a.state.overlay.kind, hung.a.state.overlay.message], ["unknown", "outcome-unknown: the bus transport did not finish"]);
+ await hung.a.input("\x1b");
+ assert.equal(hung.a.state.overlay, null);
+});
+
+test("refused and failed reads keep the last list, marked stale; a refused decide says refused", async (t) => {
@@ -1347,7 +1601,8 @@ index 00000000..b88b2230
+ } });
+ decide.raise("git.push.protected", { target: "refactor" });
+ await decide.a.start();
+ await decide.a.input("1y");
+ await decide.a.input("1");
+ await decide.a.input("y");
+ assert.deepEqual([decide.a.state.overlay.kind, decide.a.state.overlay.message], ["error", "refused: unauthenticated"]);
+});
+
@@ -1385,6 +1640,48 @@ index 00000000..b88b2230
+ await h.a.input("\x03");
+ assert.equal(h.out.quit.how, "Ctrl-C");
+ assert.deepEqual(parseKeys("a\x1b[A\x1b[5~\x1bOB\x1b[1;5C\r\x1b"), ["a", "up", "pgup", "down", "enter", "esc"]);
+ // Arrows on the command line move nothing and type nothing.
+ await h.a.start();
+ const c = app(t);
+ await c.a.start();
+ await c.a.input(":ab\x1b[A\x1b[Bc");
+ assert.equal(c.a.state.command, "abc");
+ // v on Tasks opens the trail of the task.
+ const f = app(t, { transport: fake() });
+ await f.a.start();
+ await f.a.input("t");
+ await f.a.input("v");
+ assert.equal(f.a.state.data.trail.subject, "vikunja:1/7");
+ assert.match(f.screen(), /Trail {2}vikunja:1\/7 · 2 row\(s\)/);
+ // :business switches: the old business's lists and selections go.
+ await f.a.input("t");
+ await f.a.input("j");
+ assert.equal(f.a.state.sel.tasks, 1);
+ const calls = [];
+ f.a.transport = (b) => async (verb) => (calls.push([b, verb]), []);
+ await f.a.input(":business other\r");
+ assert.equal(f.a.state.business, "other");
+ assert.equal(f.a.state.sel.tasks, 0);
+ assert.equal(f.a.state.data.trail, undefined);
+ assert.deepEqual(f.a.state.data.tasks.list, []);
+ assert.deepEqual(calls, [["other", "tasks"]]);
+});
+
+test("one option: the status line says Press 1, not 1-1", async (t) => {
+ const base = fake();
+ const s = app(t, {
+ transport: () => async (verb) => {
+ const rows = await base()(verb);
+ if (verb === "inbox") rows.forEach((d) => (d.options = d.options.slice(0, 1)));
+ return rows;
+ },
+ });
+ await s.a.start();
+ assert.match(s.screen(), /Press 1 to choose\./);
+ const two = app(t);
+ two.raise("git.push.protected", { target: "refactor" });
+ await two.a.start();
+ assert.match(two.screen(), /Press 1-2 to choose\./);
+});
+
+test("no business: the app says so and :business picks one", async (t) => {
@@ -1462,12 +1759,91 @@ index 00000000..b88b2230
+ }
+});
+
+test("decide with --note still asks; a No sends nothing", async (t) => {
+ const s = broker(t);
+ const d = s.raise("git.push.protected", { target: "refactor" });
+ const call = s.transport("demo");
+ const asked = [];
+ const err = await resolveDecision({ call, ref: d.id, choice: "yes", note: "a reason", write: () => {}, ask: async (q) => (asked.push(q), false) }).catch((e) => e);
+ assert.equal(err.message, "not resolved");
+ assert.equal(asked.length, 1);
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
+ // Off a terminal, --note without --yes refuses before anything is sent.
+ const x = io(humanEnv());
+ x.stdin.isTTY = false;
+ const off = await main(["decide", d.id, "yes", "--note", "a reason", "--business", "demo"], x, { system: { dataRoot: tmp(t) }, transport: s.transport }).catch((e) => e);
+ assert.equal(off.exitCode, 4);
+ assert.match(off.message, /stdin is not a terminal; pass --yes/);
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
+});
+
+test("the app's business comes from a live bus host only", async (t) => {
+ for (const [startTime, want] of [
+ ["not-this-process", null],
+ [startTimeOf(process.pid), "demo"],
+ ]) {
+ const dataRoot = tmp(t);
+ mkdirSync(hostDir(dataRoot), { recursive: true, mode: 0o700 });
+ writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: process.pid, startTime, business: "demo" }), { mode: 0o600 });
+ const x = io(humanEnv());
+ x.stdin.isTTY = true;
+ x.stdout.isTTY = true;
+ let opened;
+ await main([], x, { system: { dataRoot }, transport: () => async () => [], runApp: async (o) => ((opened = o), { how: "q", resolved: 0 }) });
+ assert.equal(opened.business, want, startTime);
+ }
+});
+
+// A terminal for runApp: raw mode and listeners recorded, writes kept.
+function terminal({ throwOnResize = false } = {}) {
+ const stdin = Object.assign(new EventEmitter(), { raw: [], setRawMode: (on) => stdin.raw.push(on), pause: () => {}, resume: () => {} });
+ const stdout = Object.assign(new EventEmitter(), { writes: [], write: (s) => stdout.writes.push(s), rows: 24 });
+ let resized = false;
+ Object.defineProperty(stdout, "columns", {
+ get: () => {
+ if (resized && throwOnResize) throw new Error("columns failed");
+ return 80;
+ },
+ });
+ const signals = new EventEmitter();
+ const resize = () => ((resized = true), stdout.emit("resize"));
+ return { io: { stdin, stdout }, stdin, stdout, signals, resize };
+}
+
+test("runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error", async () => {
+ const cases = [
+ ["q", (x) => x.stdin.emit("data", Buffer.from("q"))],
+ ["end of input", (x) => x.stdin.emit("end")],
+ ["SIGTERM", (x) => x.signals.emit("SIGTERM")],
+ ["SIGINT", (x) => x.signals.emit("SIGINT")],
+ ["SIGHUP", (x) => x.signals.emit("SIGHUP")],
+ ["an error", (x) => x.resize(), { throwOnResize: true }],
+ ];
+ for (const [how, act, opts] of cases) {
+ const x = terminal(opts);
+ const running = runApp({ io: x.io, business: "demo", transport: () => async () => [], color: false, refreshMs: 0, askMs: 0, signals: x.signals });
+ await new Promise((r) => setImmediate(r));
+ act(x);
+ const result = await running.then(
+ (r) => r,
+ (e) => ({ how: "an error", error: e }),
+ );
+ assert.equal(result.how, how);
+ if (how === "an error") assert.equal(result.error.message, "columns failed");
+ assert.equal(x.stdout.writes[0], "\x1b[?1049h\x1b[?25l", how);
+ assert.equal(x.stdout.writes.at(-1), "\x1b[?25h\x1b[?1049l", how);
+ assert.deepEqual(x.stdin.raw, [true, false], how);
+ for (const name of ["SIGTERM", "SIGINT", "SIGHUP"]) assert.equal(x.signals.listenerCount(name), 0, `${how} ${name}`);
+ assert.equal(x.stdin.listenerCount("data") + x.stdin.listenerCount("end") + x.stdout.listenerCount("resize"), 0, how);
+ }
+});
+
+test("draw is pure: same state, same frame", () => {
+ const state = { business: "demo", view: "inbox", data: { inbox: { list: [], at: new Date(NOW).toISOString() } }, sel: { inbox: 0, tasks: 0, agents: 0, trail: 0 }, overlay: null, command: null, status: "" };
+ assert.deepEqual(draw(state, { cols: 80, rows: 24, color: false, now: NOW }), draw(state, { cols: 80, rows: 24, color: false, now: NOW }));
+});
diff --git a/packages/queue/tests/dispatch.test.mjs b/packages/queue/tests/dispatch.test.mjs
index aa425c0b..ddd341b8 100644
index aa425c0b..2280cdb2 100644
--- a/packages/queue/tests/dispatch.test.mjs
+++ b/packages/queue/tests/dispatch.test.mjs
@@ -1,8 +1,9 @@
@@ -1506,7 +1882,7 @@ index aa425c0b..ddd341b8 100644
[""],
["launch", "queue"],
["Queue", "list"],
@@ -73,6 +73,17 @@ test("every call but `queue` reaches the seat CLI exactly as before A2", (t) =>
@@ -73,6 +73,19 @@ test("every call but `queue` reaches the seat CLI exactly as before A2", (t) =>
}
});
@@ -1516,9 +1892,11 @@ index aa425c0b..ddd341b8 100644
+ assert.deepEqual([got.status, got.out, got.err], [7, "fake out\n", "fake err\n"]);
+ const c = JSON.parse(got.capture);
+ assert.deepEqual([c.cli, c.argv, c.cwd, c.stdin], ["/packages/cli/src/cli.mjs", [], "<ROOT>/work", "stdin line\nsecond\n"]);
+ // Before row 55 the same call went to the seat CLI.
+ // Before row 55 the same call went to the seat CLI, with the same
+ // environment the app now gets.
+ const before = call(root(t, join(HERE, "fixtures", "mosaic-pre-a2.sh")), []);
+ assert.equal(JSON.parse(before.capture).cli, "/packages/seat/src/cli.mjs");
+ assert.deepEqual(c.env, JSON.parse(before.capture).env);
+});
+
test("`queue` reaches the queue CLI with the rest of the arguments", (t) => {