Files
stack/agents/darkwing/work/queue-55/build.patch
T
jason.woltjeandClaude Opus 5.5 fa6d889cb7 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]>
2026-10-11 13:39:05 -05:00

1920 lines
88 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
index b96e9012..5a675611 100644
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -193,6 +193,7 @@ config problem · `4` usage or a required file missing. Details:
## Inbox, decisions and the bus host (`scripts/mosaic`)
```bash
+scripts/mosaic # no argument: the full-screen app
scripts/mosaic inbox | tasks | agents [--business <id>] [--json]
scripts/mosaic decide <decision> <option> [--note <text>] [--yes]
scripts/mosaic trail <task|decision> [--json]
@@ -226,6 +227,10 @@ sends). `stop` sends SIGTERM to a run's runner after checking it in
the business; running sessions keep running. `launches list` reads
`sessions.json` and needs no broker. `talk`, `stop` and `launches off|on`
refuse inside an agent run.
+With no argument on a terminal, `scripts/mosaic` opens the full-screen app:
+Inbox, Tasks, Agents, Trail and Help, deciding through the same flow and
+strings as `decide`. In an agent run it exits 3; without a terminal on
+stdin and stdout it exits 4 with the usage.
Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
`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..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`,
`decide`, `tasks`, `agents` and `trail`, and the bus host `mosaic bus
start|stop|status` with its Discord notifier (row S4, #1521); the session
launcher in the host and `mosaic talk`, `stop` and `launches` (row S6,
-#1523). Brief: `docs/plans/2026-10-04_slice-1.md`, rows S4 and S6. Rulings:
-lead decisions 70 and 77 in `docs/plans/2026-09-26_lead-decisions.md`.
+#1523); and `mosaic` with no argument, the full-screen app (queue row 55,
+#1544). Briefs: `docs/plans/2026-10-04_slice-1.md`, rows S4 and S6, and
+`docs/plans/2026-10-10_design-implementation.md`, "Full-screen terminal
+app". Rulings: lead decisions 70 and 77 in
+`docs/plans/2026-09-26_lead-decisions.md`.
Every command reads through the broker. None opens the SQLite file.
```sh
+scripts/mosaic # the app, on a terminal
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,65 @@ mosaic launches list [--json]
`launches list` does not, because the file is readable to the same user
anyway.
+## The app
+
+```
+mosaic
+```
+
+With no argument, on a terminal, `mosaic` opens a full-screen app over the
+same broker reads as the verbs: Inbox, Tasks, Agents, Trail and Help.
+`src/app.mjs` handles keys and reads; `src/screens.mjs` draws the frames
+and nothing else.
+
+- In an agent run it exits 3 with the same refusal as the verbs, before
+ anything else. Without a terminal on both stdin and stdout it exits 4
+ and prints the seat usage (`mosaic launch`, `mosaic seat task`) and the
+ verbs above. A script never gets the app.
+- The business is the one of the bus host on this data root. With no
+ host the app says so and `:business <id>` picks one.
+- 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. 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. `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
+ no box is open. A failed read keeps the last list on screen and marks
+ it stale. A refused read (exit 3 codes) says refused. Broker text goes
+ through `visible()` from `packages/conversation/src/terminal.mjs`, so
+ control characters and bidi overrides show as `^[` and `<U+202E>`.
+- Talk (`c`) points to `mosaic talk` for now. The conversation pane and
+ `stop` come in a later row.
+- 16 ANSI colours. Under `NO_COLOR` the only escape codes in a frame are
+ 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. 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, 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..3cb3ab71
--- /dev/null
+++ b/packages/cli/src/app.mjs
@@ -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.
+//
+// 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. 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 (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";
+import { formatDecision } from "./format.mjs";
+import { draw } from "./screens.mjs";
+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
+// sequences are dropped. A lone ESC at the end of a chunk is Escape.
+export function parseKeys(data) {
+ const out = [];
+ let i = 0;
+ while (i < data.length) {
+ if (data[i] === "\x1b") {
+ const seq = /^\x1b(?:\[[0-9;?]*[ -/]*[@-~]|O.|[\s\S])?/.exec(data.slice(i))[0];
+ const name = { "\x1b": "esc", "\x1b[A": "up", "\x1bOA": "up", "\x1b[B": "down", "\x1bOB": "down", "\x1b[5~": "pgup", "\x1b[6~": "pgdn" }[seq];
+ if (name) out.push(name);
+ i += seq.length;
+ continue;
+ }
+ const c = String.fromCodePoint(data.codePointAt(i));
+ i += c.length;
+ if (c === "\r" || c === "\n") out.push("enter");
+ else if (c === "\t") out.push("tab");
+ else if (c === "\x7f" || c === "\b") out.push("backspace");
+ else if (c === "\x03" || c === "\x04") out.push("ctrl-c");
+ else if (c >= " ") out.push(c);
+ }
+ return out;
+}
+
+const VIEW_KEYS = { c: "talk", i: "inbox", t: "tasks", a: "agents", r: "trail" };
+const LISTS = ["inbox", "tasks", "agents", "trail"];
+
+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, askMs = ASK_MS }) {
+ this.transport = transport;
+ this.write = write;
+ this.cols = cols;
+ this.rows = rows;
+ 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;
+ this.frame = [];
+ this.keys = [];
+ this.job = null;
+ this.answer = null;
+ this.cancelAsk = null;
+ this.wake = null;
+ this.idle = [];
+ this.pumping = false;
+ this.closed = false;
+ this.timer = refreshMs > 0 ? setInterval(() => this.input("\0refresh"), refreshMs) : null;
+ this.timer?.unref?.();
+ }
+
+ // The first frame and the first read.
+ start() {
+ this.render();
+ return this.input("\0start");
+ }
+
+ resize(cols, rows) {
+ this.cols = cols;
+ this.rows = rows;
+ this.render();
+ }
+
+ render() {
+ if (this.closed) return this.frame;
+ this.frame = draw(this.state, { cols: this.cols, rows: this.rows, color: this.color, now: this.now() });
+ this.write(`\x1b[H\x1b[2J${this.frame.join("\r\n")}`);
+ return this.frame;
+ }
+
+ // Feeds terminal input. Resolves once every key is handled, or when the
+ // app waits for an answer to the decide question.
+ input(data) {
+ if (this.closed) return Promise.resolve();
+ if (data === "\0refresh" || data === "\0start") this.keys.push(data.slice(1));
+ else this.keys.push(...parseKeys(data));
+ const settled = new Promise((r) => this.idle.push(r));
+ if (this.pumping) this.wake?.();
+ else this.#pump();
+ return settled;
+ }
+
+ // One key at a time, in order. A key that starts a read or a decision
+ // runs as a job; later keys wait for it, except the answer to its
+ // question, which goes straight to it.
+ async #pump() {
+ this.pumping = true;
+ for (;;) {
+ if (this.closed) break;
+ if (this.answer && this.keys.length) {
+ // 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) {
+ if (this.answer) this.#settle();
+ await new Promise((r) => (this.wake = r));
+ this.wake = null;
+ continue;
+ }
+ if (!this.keys.length) break;
+ let job;
+ try {
+ job = this.#key(this.keys.shift());
+ } catch (e) {
+ this.crash(e);
+ break;
+ }
+ if (job instanceof Promise) {
+ this.job = job.catch((e) => this.crash(e)).finally(() => {
+ this.job = null;
+ this.wake?.();
+ });
+ }
+ }
+ this.pumping = false;
+ this.#settle();
+ }
+
+ #settle() {
+ for (const r of this.idle.splice(0)) r();
+ }
+
+ // 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 answer = (yes) => {
+ clearTimeout(arm);
+ this.answer = this.cancelAsk = null;
+ this.state.overlay = { ...this.state.overlay, asking: null };
+ this.render();
+ resolve(yes);
+ };
+ this.cancelAsk = () => answer(false);
+ const arm = setTimeout(() => {
+ this.keys = [];
+ this.answer = (k) => answer(k === "y" || k === "Y");
+ this.wake?.();
+ }, this.askMs);
+ });
+ }
+
+ #status(text, style = null) {
+ this.state.status = text;
+ this.state.statusStyle = style;
+ }
+
+ // Handles one key; returns a promise when it starts a read or a decision.
+ #key(k) {
+ const s = this.state;
+ // 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);
+ if (VIEW_KEYS[k]) {
+ s.view = VIEW_KEYS[k];
+ s.open = false;
+ this.#status(k === "c" ? "Talk is a later row; mosaic talk <role> <text> works from a shell" : "");
+ return s.view === "talk" ? void this.render() : this.#read(s.view);
+ }
+ if (k === "q") return this.quit("q");
+ if (k === "?") return this.#overlay({ kind: "help" });
+ if (k === ":") {
+ s.command = "";
+ return void this.render();
+ }
+ if (k === "j" || k === "down") return this.#move(1);
+ if (k === "k" || k === "up") return this.#move(-1);
+ if (k === "pgdn") return this.#move(Math.max(1, this.rows - 10));
+ if (k === "pgup") return this.#move(-Math.max(1, this.rows - 10));
+ if (k === "enter") return this.#open();
+ if (k === "v") return this.#trailOfSelection();
+ if (/^[1-9]$/.test(k)) return this.#choose(Number(k));
+ if (k === "tab") this.#status("Tab moves to the composer once Talk is in the app");
+ else if (k === "esc") this.#status("");
+ return void this.render();
+ }
+
+ #overlay(o) {
+ this.state.overlay = o;
+ this.render();
+ }
+
+ #overlayKey(k) {
+ const o = this.state.overlay;
+ if (o.kind === "unknown") {
+ if (k === "v") {
+ this.state.overlay = null;
+ return this.#trail(o.id);
+ }
+ if (k === "r") {
+ this.state.overlay = null;
+ this.state.view = "inbox";
+ return this.#read("inbox");
+ }
+ if (k === "esc") this.state.overlay = null;
+ return void this.render();
+ }
+ if (k === "esc" || k === "enter" || (o.kind === "help" && (k === "?" || k === "q"))) this.state.overlay = null;
+ this.render();
+ }
+
+ #list(view = this.state.view) {
+ return this.state.data[view]?.list ?? [];
+ }
+
+ #selected(view = this.state.view) {
+ const list = this.#list(view);
+ return list.length ? list[Math.min(this.state.sel[view], list.length - 1)] : null;
+ }
+
+ #move(n) {
+ const view = this.state.view;
+ if (LISTS.includes(view)) {
+ const max = this.#list(view).length - 1;
+ this.state.sel[view] = Math.max(0, Math.min(max, this.state.sel[view] + n));
+ }
+ this.render();
+ }
+
+ // Enter: the selection's detail in a box, whole, at any width.
+ #open() {
+ const view = this.state.view;
+ const item = this.#selected(view);
+ if (!item) return void this.render();
+ const title = view === "inbox" ? `decision ${item.id.slice(0, 8)}` : view === "tasks" ? item.task_ref : view === "agents" ? item.role : `${item.table}#${item.seq}`;
+ const whole = view === "trail" ? `mosaic trail ${this.state.data.trail.subject} --json` : `mosaic ${view} --json`;
+ return this.#overlay({ kind: "detail", title, lines: detailLines(view, item), whole });
+ }
+
+ #trailOfSelection() {
+ const view = this.state.view;
+ const item = this.#selected(view);
+ const subject = !item ? null : view === "inbox" ? item.id : view === "tasks" ? item.task_ref : view === "trail" ? item.task_ref ?? null : null;
+ if (!subject) {
+ this.#status(view === "agents" ? "a role has no trail here; type :trail <task|decision>" : "nothing selected to follow; type :trail <task|decision>");
+ return void this.render();
+ }
+ return this.#trail(subject);
+ }
+
+ #trail(subject) {
+ this.state.view = "trail";
+ this.state.sel.trail = 0;
+ this.state.data.trail = { subject };
+ return this.#read("trail");
+ }
+
+ // A digit: option n of the selected decision, through the decide flow.
+ #choose(n) {
+ if (this.state.view !== "inbox") {
+ this.#status("digits choose in the Inbox; press i");
+ return void this.render();
+ }
+ const d = this.#selected("inbox");
+ if (!d) return void this.render();
+ const option = d.options[n - 1];
+ if (!option) {
+ this.#status(`decision ${d.id.slice(0, 8)} has ${d.options.length} option(s)`, "warning");
+ return void this.render();
+ }
+ return this.#decide(d.id, option.key);
+ }
+
+ async #decide(ref, choice) {
+ if (!this.call) {
+ this.#status("no business; type :business <id>", "warning");
+ return this.render();
+ }
+ this.state.view = "inbox";
+ this.#overlay({ kind: "decide", lines: [], asking: null });
+ let text = "";
+ const write = (s) => {
+ text += s;
+ this.state.overlay = { ...this.state.overlay, lines: text.trimEnd().split("\n") };
+ this.render();
+ };
+ try {
+ const d = await resolveDecision({ call: this.call, ref, choice, write, ask: (q) => this.#ask(q) });
+ this.resolved += 1;
+ this.state.overlay = null;
+ this.#status(`resolved ${d.id}: ${choice}`, "success");
+ return this.#read("inbox", true);
+ } catch (e) {
+ if (!(e instanceof CliError) && !e.code) throw e;
+ const message = e.message;
+ if (message === "not resolved") {
+ this.state.overlay = null;
+ this.#status("not resolved");
+ } else if (/^outcome[ -]unknown/.test(message)) {
+ this.state.overlay = { kind: "unknown", message, id: text.match(/^decision (\S+)/)?.[1] ?? ref };
+ } else this.state.overlay = { kind: "error", title: "decide", message: e.code && message === e.code ? `${(e.exitCode ?? busExit(e.code)) === 3 ? "refused" : "failed"}: ${e.code}` : message };
+ this.render();
+ }
+ }
+
+ // Reads one view. A failed read keeps the last good list and says it is
+ // stale; with no list it shows the failure. `keep` leaves the status line.
+ async #read(view, keep = false) {
+ if (!LISTS.includes(view)) return this.render();
+ if (!this.call) return this.render();
+ const before = this.state.data[view] ?? {};
+ if (view === "trail" && !before.subject) return this.render();
+ this.state.data[view] = { ...before, reading: true };
+ if (!keep) this.#status(`reading ${view}…`);
+ this.render();
+ 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() };
+ 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.
+ const refused = (e.exitCode ?? busExit(e.code)) === 3;
+ this.state.data[view] = { ...before, reading: false, error: e.code ?? e.message, refused };
+ this.#status(`${refused ? "refused" : "read failed"}: ${e.code ?? e.message}`, "danger");
+ }
+ this.render();
+ }
+
+ #commandKey(k) {
+ const s = this.state;
+ if (k === "esc" || k === "ctrl-c") s.command = null;
+ else if (k === "backspace") s.command = Array.from(s.command).slice(0, -1).join("");
+ else if (k === "enter") {
+ const text = s.command.trim();
+ s.command = null;
+ return this.#run(text);
+ } else if ([...k].length === 1 && k >= " ") s.command += k;
+ this.render();
+ }
+
+ // :decide <decision> <option>, :trail <subject>, :business <id>, :q.
+ #run(text) {
+ const [verb, ...args] = text.split(/\s+/);
+ if (verb === "q" || verb === "quit") return this.quit("q");
+ if (verb === "decide" && args.length === 2) return this.#decide(args[0], args[1]);
+ if (verb === "trail" && args.length === 1) return this.#trail(args[0]);
+ if (verb === "business" && args.length === 1) {
+ this.state.business = args[0];
+ this.call = this.transport(args[0]);
+ this.state.data = {};
+ for (const v of LISTS) this.state.sel[v] = 0;
+ return this.#read(this.state.view === "talk" ? "inbox" : this.state.view);
+ }
+ this.#status(verb ? `unknown command :${text}; ? lists the commands` : "", "warning");
+ this.render();
+ }
+
+ // An error the app has no screen for: quit, and runApp rethrows it.
+ crash(error) {
+ this.error = error;
+ this.quit("an error");
+ }
+
+ quit(how) {
+ if (this.closed) return;
+ this.closed = true;
+ if (this.timer) clearInterval(this.timer);
+ this.cancelAsk?.();
+ this.wake?.();
+ this.onQuit({ how, resolved: this.resolved, error: this.error });
+ }
+}
+
+// Every field of the selection, for the Enter box.
+function detailLines(view, item) {
+ if (view === "inbox") return formatDecision(item).trimEnd().split("\n");
+ if (view === "tasks") return [`snapshot ${item.at ?? "?"}, source ${item.source ?? "?"}${item.read_at ? `, read ${item.read_at}` : ""}`, ...Object.entries(item.fields ?? {}).map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)];
+ if (view === "agents") return [`held by ${item.holder_run}`, `harness ${item.harness ?? "-"}`, `since ${item.at}`];
+ return Object.entries(item).map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`);
+}
+
+// The quit line printed after the app restores the screen.
+export function quitLine({ how, resolved }) {
+ return `(app closed with ${how}; ${resolved} decision${resolved === 1 ? "" : "s"} resolved this session)`;
+}
+
+// Runs the app on a terminal until it quits: raw mode, the alternate screen,
+// 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);
+ 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 = () => 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),
+ onQuit: (r) => {
+ restore();
+ if (r.error) reject(r.error);
+ else resolve(r);
+ },
+ });
+ stdout.write("\x1b[?1049h\x1b[?25l");
+ stdin.setRawMode?.(true);
+ stdin.on("data", onData);
+ stdin.on("end", onEnd);
+ stdout.on?.("resize", onResize);
+ for (const [name, on] of onSignal) signals.on(name, on);
+ stdin.resume?.();
+ guard(() => void app.start());
+ });
+}
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
index 0b48c7a7..8cb07c52 100644
--- a/packages/cli/src/cli.mjs
+++ b/packages/cli/src/cli.mjs
@@ -19,6 +19,7 @@ import { humanTransport, refuseInsideAgent } from "./transport.mjs";
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
import { createLauncher } from "./launcher.mjs";
+import { quitLine, runApp } from "./app.mjs";
import { readSessions, stopSession } from "../../seat/src/session.mjs";
import { SeatError } from "../../seat/src/seat.mjs";
@@ -36,6 +37,13 @@ export const USAGE = `usage:
mosaic bus stop
mosaic bus status [--json]`;
+// packages/seat/src/cli.mjs USAGE, which that module does not export (it runs
+// its main on import). tests/app.test.mjs fails if the two drift.
+export const SEAT_USAGE = [
+ "usage: mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]",
+ " mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]",
+].join("\n");
+
const usage = (why) => new CliError(why ? `${why}\n${USAGE}` : USAGE, 4);
function parse(args, { flags = [], values = [] }) {
@@ -110,18 +118,29 @@ async function confirm(io, question) {
async function decide(parsed, call, io) {
if (parsed.positional.length !== 2) throw usage("decide takes a decision and an option");
const [ref, choice] = parsed.positional;
+ const ask = parsed.flags.has("--yes")
+ ? null
+ : (question) => {
+ if (!io.stdin.isTTY) throw usage("stdin is not a terminal; pass --yes to resolve without the prompt");
+ return confirm(io, question);
+ };
+ await resolveDecision({ call, ref, choice, note: parsed.values["--note"], write: (s) => io.stdout.write(s), ask });
+}
+
+// The decide flow, shared by `mosaic decide` and the app's Inbox (src/app.mjs):
+// re-read the inbox, find the decision and the option, write the decision
+// and what the choice means, ask (null skips the question, as --yes does),
+// then resolve. Returns the decision.
+export async function resolveDecision({ call, ref, choice, note, write, ask }) {
const d = findDecision(await call("inbox"), ref);
const option = d.options.find((o) => o.key === choice);
if (!option) throw new CliError(`decision ${shortId(d.id)} has no option ${JSON.stringify(choice)}; its options are ${d.options.map((o) => o.key).join(", ")}`, 2);
- io.stdout.write(formatDecision(d));
+ write(formatDecision(d));
const effect = d.authorization ? (choice === d.authorization.approvalChoice ? "this authorizes the action" : "this declines the action") : null;
- io.stdout.write(`your choice: ${choice} (${option.text})${effect ? `; ${effect}` : ""}\n`);
- if (!parsed.flags.has("--yes")) {
- if (!io.stdin.isTTY) throw usage("stdin is not a terminal; pass --yes to resolve without the prompt");
- if (!(await confirm(io, `resolve ${shortId(d.id)} with ${choice}? [y/N] `))) throw new CliError("not resolved", 1);
- }
+ write(`your choice: ${choice} (${option.text})${effect ? `; ${effect}` : ""}\n`);
+ if (ask && !(await ask(`resolve ${shortId(d.id)} with ${choice}? [y/N] `))) throw new CliError("not resolved", 1);
const args = { id: d.id, choice };
- if (parsed.values["--note"] !== undefined) args.note = parsed.values["--note"];
+ if (note !== undefined) args.note = note;
try {
await call("decision.resolve", args);
} catch (e) {
@@ -131,7 +150,8 @@ async function decide(parsed, call, io) {
if (e.code === "decision-closed") throw new CliError(`decision ${shortId(d.id)} was closed before your answer arrived; see mosaic trail ${d.id}`, 2);
throw e;
}
- io.stdout.write(`resolved ${d.id}: ${choice}\n`);
+ write(`resolved ${d.id}: ${choice}\n`);
+ return d;
}
async function human(verb, rest, io, deps) {
@@ -296,7 +316,23 @@ async function bus(rest, io, deps) {
throw usage();
}
+// `mosaic` with no argument. In an agent run it refuses (exit 3); without a
+// terminal on both stdin and stdout it prints the usage (exit 4); on a
+// terminal it opens the app (src/app.mjs) and prints one line once it quits.
+async function app(io, deps) {
+ refuseInsideAgent(io.env);
+ if (!io.stdin.isTTY || !io.stdout.isTTY) throw new CliError(`the app needs a terminal on stdin and stdout; from a script, use a verb:\n${SEAT_USAGE}\n${USAGE}`, 4);
+ const system = deps.system ?? loadSystem({ env: io.env });
+ const state = readHostState(system.dataRoot);
+ const business = state?.live ? state.business : null;
+ const transport = deps.transport ?? ((b) => humanTransport({ socket: socketPath(system.dataRoot), business: b, env: io.env }));
+ const run = deps.runApp ?? runApp;
+ const result = await run({ io, business, transport, color: !io.env.NO_COLOR });
+ io.stdout.write(`${quitLine(result)}\n`);
+}
+
export async function main(argv, io = { env: process.env, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, deps = {}) {
+ if (argv.length === 0) return app(io, deps);
const [verb, ...rest] = argv;
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "talk") return talk(rest, io, deps);
diff --git a/packages/cli/src/format.mjs b/packages/cli/src/format.mjs
index 381596b4..7bbfe39c 100644
--- a/packages/cli/src/format.mjs
+++ b/packages/cli/src/format.mjs
@@ -66,7 +66,7 @@ export function formatTasks(list) {
.join("\n")}\n`;
}
-function trailSummary(r) {
+export function trailSummary(r) {
switch (r.table) {
case "events":
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..c0194cb2
--- /dev/null
+++ b/packages/cli/src/screens.mjs
@@ -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
+// terminal.
+//
+// Colour is the terminal's own 16 (docs/design/tokens.json `terminal.ansi`).
+// With colour off (NO_COLOR) a frame carries no SGR code except reverse video
+// on the selected row; every state still reads from its word or mark.
+//
+// Every text from the broker goes through the conversation terminal's
+// visible() (control, bidi and invisible characters shown as text), and a
+// line break shows as ^J, so no broker text can move the cursor or split a
+// row.
+
+import { visible } from "../../conversation/src/terminal.mjs";
+import { formatDecision, shortId, trailSummary } from "./format.mjs";
+
+// 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 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 (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) ||
+ (n >= 0xf900 && n <= 0xfaff) ||
+ (n >= 0xfe30 && n <= 0xfe4f) ||
+ (n >= 0xff00 && n <= 0xff60) ||
+ (n >= 0xffe0 && n <= 0xffe6) ||
+ (n >= 0x1f200 && n <= 0x1f2ff) ||
+ (n >= 0x20000 && n <= 0x3fffd)
+ )
+ return 2;
+ return 1;
+}
+
+export function width(s) {
+ let w = 0;
+ let prev = "";
+ for (const c of s) {
+ w += cells(c, prev);
+ prev = c;
+ }
+ return w;
+}
+
+// The first `w` cells of `s`, with … for what was cut; then spaces to `w`.
+export function fit(s, w) {
+ if (w <= 0) return "";
+ 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, prev);
+ if (used + n > w - 1) break;
+ out += c;
+ used += n;
+ prev = c;
+ }
+ return out + "…" + " ".repeat(w - 1 - used);
+}
+
+// Splits `s` into lines of at most `w` cells, at spaces where it can;
+// later lines start with `indent`.
+export function wrap(s, w, indent = "") {
+ const out = [];
+ let cur = "";
+ const push = () => {
+ out.push(cur.trimEnd());
+ cur = indent;
+ };
+ for (const word of s.split(/(?<= )/)) {
+ if (cur.trim() !== "" && width(cur + word.trimEnd()) > w) push();
+ for (const c of word) {
+ if (width(cur + c) > w) push();
+ cur += c;
+ }
+ }
+ if (cur.trim() !== "" || out.length === 0) out.push(cur.trimEnd());
+ return out;
+}
+
+// A line is a list of [text, style] pieces; `sel` reverses the whole row.
+const piece = (text, style = null) => [oneLine(text), style];
+const line = (...pieces) => ({ pieces: pieces.map((p) => (Array.isArray(p) ? piece(...p) : piece(p))) });
+const blank = () => line("");
+const sel = (l) => ({ ...l, sel: true });
+
+// Fits a line to `cols` cells and paints it.
+function paint(l, cols, color) {
+ const out = [];
+ let left = cols;
+ for (const [text, style] of l.pieces) {
+ if (left <= 0) break;
+ let t = text;
+ if (width(t) > left) t = fit(t, left);
+ left -= width(t);
+ out.push(color && style && SGR[style] ? `\x1b[${SGR[style][0]}m${t}\x1b[${SGR[style][1]}m` : t);
+ }
+ const body = out.join("") + " ".repeat(Math.max(0, left));
+ return l.sel ? `\x1b[7m${body}\x1b[27m` : body;
+}
+
+export function strip(s) {
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
+}
+
+const rule = (cols) => line(["─".repeat(cols), "muted"]);
+
+function age(at, now) {
+ const t = Date.parse(at);
+ if (!Number.isFinite(t)) return "?";
+ const s = Math.max(0, Math.round((now - t) / 1000));
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ if (m < 60) return `${m}m`;
+ const h = Math.floor(m / 60);
+ if (h < 48) return `${h}h${String(m % 60).padStart(2, "0")}m`;
+ return `${Math.floor(h / 24)}d`;
+}
+
+// Fixed-width columns; the last one takes what is left.
+function columns(cells_, widths) {
+ return cells_.map((c, i) => (i === cells_.length - 1 ? c : fit(oneLine(c), widths[i] - 1) + " "));
+}
+
+const VIEWS = [
+ ["c", "Talk", "talk"],
+ ["i", "Inbox", "inbox"],
+ ["t", "Tasks", "tasks"],
+ ["a", "Agents", "agents"],
+ ["r", "Trail", "trail"],
+];
+
+// The current view shows in brackets as well as bold, so it reads without
+// colour.
+function nav(state, cols, now) {
+ const narrow = cols < NARROW_BELOW;
+ const l = line([" mosaic", "bold"], " ", [state.business ?? "no business", state.business ? "action" : "warning"], " ");
+ for (const [key, name, view] of VIEWS) {
+ const count = view === "inbox" && state.data.inbox?.list ? ` ${state.data.inbox.list.length}` : "";
+ const label = narrow ? `${key}${count}` : `${key} ${name}${count}`;
+ l.pieces.push(state.view === view ? piece(`[${label}]`, "bold") : piece(` ${label} `), piece(" "));
+ }
+ const d = state.data[state.view];
+ const right = d?.at ? (narrow ? age(d.at, now) : `read ${age(d.at, now)} ago`) : "";
+ const used = l.pieces.reduce((n, [t]) => n + width(t), 0);
+ if (right && used + width(right) + 2 <= cols) l.pieces.push(piece(" ".repeat(cols - used - width(right) - 1)), piece(right, "muted"));
+ return l;
+}
+
+const HINT_WIDE = " c i t a r views ↑↓ or j k move Enter open v trail 1-9 choose : command ? help q quit";
+const HINT_NARROW = " c i t a r views 1-9 choose : command ? help q quit";
+
+// The heading of a view: its name, a count, and what is wrong with the read.
+function heading(name, d, extra = "") {
+ const l = line([` ${name}`, "bold"], extra ? ` ${extra}` : "");
+ if (d?.error && d.list) l.pieces.push(piece(` stale: ${d.error}; showing the read from ${d.at}`, "warning"));
+ return l;
+}
+
+// The body when a view has nothing to list: the reason, then where it would
+// come from.
+function emptyBody(d, what, from, cols) {
+ const say = (text, style) => wrap(text, cols - 2).map((t) => line([` ${t}`, style]));
+ if (d?.error) return [...say(`${d.refused ? "refused" : "read failed"}: ${d.error}`, "danger"), ...say("Nothing was written. Press the view's key to read again.", "muted")];
+ if (!d || d.reading) return [line([" Reading from the bus…", "muted"])];
+ return [...say(what, "bold"), ...say(from, "muted")];
+}
+
+function noBusiness() {
+ return [
+ line([" No bus host runs here, so the app knows no business.", "warning"]),
+ line([" Start one with mosaic bus start <business>, or type :business <id>.", "muted"]),
+ ];
+}
+
+// A list over a detail block: `head` lines, the column header, the rows,
+// a rule, then `detail`. The detail gets at least four lines when it has
+// them; the list scrolls to keep the selection in view.
+function listOver(head, header, rows, selected, detail, room, cols) {
+ const fixed = head.length + 1;
+ const detailRoom = Math.min(detail.length + 1, Math.max(5, room - fixed - rows.length));
+ const listRoom = Math.max(1, room - fixed - detailRoom);
+ const top = Math.max(0, selected - listRoom + 1);
+ return [...head, header, ...rows.slice(top, top + listRoom), line([` ${"─".repeat(Math.max(0, cols - 2))}`, "muted"]), ...detail];
+}
+
+const marker = (i, selected) => (i === selected ? " ›" : "");
+
+function wrapped(text, cols, style = null, indent = "") {
+ return wrap(oneLine(text), cols - 1, indent).map((t) => line([` ${t}`, style]));
+}
+
+function inboxBody(state, room, cols, now) {
+ const d = state.data.inbox;
+ if (!d?.list?.length) return [heading("Inbox", d), blank(), ...emptyBody(d, "Nothing is waiting on you.", "A decision shows here when an agent raises one routed to you through the bus.", cols)];
+ const narrow = cols < NARROW_BELOW;
+ const list = d.list;
+ const selected = Math.min(state.sel.inbox, list.length - 1);
+ const blocking = list.filter((x) => x.blocking).length;
+ const widths = narrow ? [3, 10, 8, 10, 22, 20, 6] : [3, 10, 8, 10, 26, 24, 22, 6];
+ const names = narrow ? ["", "ID", "CLASS", "FLAGS", "ACTION", "TARGET", "AGE"] : ["", "ID", "CLASS", "FLAGS", "ACTION", "TARGET", "RAISED BY", "AGE"];
+ const header = line([columns(["", ...names.slice(1)], widths).join(""), "muted"]);
+ const rows = list.map((x, i) => {
+ const target = x.authorization?.target ?? x.task_ref ?? "-";
+ const cellsOut = narrow
+ ? [marker(i, selected), shortId(x.id), x.class, x.blocking ? "BLOCKING" : "", x.action, target, age(x.at, now)]
+ : [marker(i, selected), shortId(x.id), x.class, x.blocking ? "BLOCKING" : "", x.action, target, x.raised_by_role, age(x.at, now)];
+ const text = columns(cellsOut, widths);
+ const l = line(text.slice(0, 3).join(""), [text[3], x.blocking ? "danger" : null], text.slice(4).join(""));
+ return i === selected ? sel(l) : l;
+ });
+ const detail = [];
+ for (const t of formatDecision(list[selected]).trimEnd().split("\n")) detail.push(...wrapped(t, cols, null, " "));
+ const head = [heading("Inbox", d, `${list.length} for you, ${blocking} blocking`), blank()];
+ return listOver(head, header, rows, selected, detail, room, cols);
+}
+
+function taskState(f) {
+ return f.status ?? f.state ?? (f.done === true || f.done === 1 ? "done" : f.done === false || f.done === 0 ? "open" : "");
+}
+
+function tasksBody(state, room, cols) {
+ const d = state.data.tasks;
+ if (!d?.list?.length) return [heading("Tasks", d), blank(), ...emptyBody(d, "No tasks in this business yet.", "Tasks appear after the PM creates them or the poller reads them from the tracker.", cols)];
+ const list = d.list;
+ const selected = Math.min(state.sel.tasks, list.length - 1);
+ const widths = [3, 22, 12, 0];
+ const header = line([columns(["", "REF", "STATE", "TITLE"], widths).join(""), "muted"]);
+ const rows = list.map((t, i) => {
+ const f = t.fields ?? {};
+ const l = line(columns([marker(i, selected), t.task_ref, taskState(f) || "-", f.title ?? f.name ?? ""], widths).join(""));
+ return i === selected ? sel(l) : l;
+ });
+ const t = list[selected];
+ const f = t.fields ?? {};
+ const detail = [line([` ${t.task_ref}`, "bold"], ` ${f.title ?? f.name ?? ""}`)];
+ detail.push(line([" ", null], [`snapshot ${t.at ?? "?"} · source ${t.source ?? "?"}${t.read_at ? ` · read ${t.read_at}` : ""}`, "muted"]));
+ for (const [k, v] of Object.entries(f)) if (k !== "title" && k !== "name") detail.push(...wrapped(` ${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`, cols, null, " "));
+ detail.push(line([" read-only here; the tracker holds the task", "muted"]));
+ return listOver([heading("Tasks", d, `${list.length} task(s)`), blank()], header, rows, selected, detail, room, cols);
+}
+
+function agentsBody(state, room, cols, now) {
+ const d = state.data.agents;
+ if (!d?.list?.length) return [heading("Agents", d), blank(), ...emptyBody(d, "No role is held right now.", "A role shows here while a run holds its claim on the bus.", cols)];
+ const list = d.list;
+ const selected = Math.min(state.sel.agents, list.length - 1);
+ const widths = [3, 14, 30, 12, 0];
+ const header = line([columns(["", "ROLE", "HOLDER", "HARNESS", "HELD FOR"], widths).join(""), "muted"]);
+ const rows = list.map((c, i) => {
+ const l = line(columns([marker(i, selected), c.role, c.holder_run, c.harness ?? "-", age(c.at, now)], widths).join(""));
+ return i === selected ? sel(l) : l;
+ });
+ const c = list[selected];
+ const detail = [line([` ${c.role}`, "bold"], ` held by ${c.holder_run} since ${c.at}`), line([" stop a run with mosaic stop <run>; see mosaic launches list", "muted"])];
+ return listOver([heading("Agents", d, `${list.length} role(s) held`), blank()], header, rows, selected, detail, room, cols);
+}
+
+function trailBody(state, room, cols) {
+ const d = state.data.trail;
+ if (!d?.subject) return [line([" Trail", "bold"]), blank(), line([" No subject yet.", "bold"]), line([" Press v on a decision or a task, or type :trail <task|decision>.", "muted"])];
+ if (!d.list?.length) return [heading("Trail", d, d.subject), blank(), ...emptyBody(d, "Nothing in the trail for this subject.", "Every step the stack takes on a subject writes a trail row.", cols)];
+ const narrow = cols < NARROW_BELOW;
+ const list = d.list;
+ const selected = Math.min(state.sel.trail, list.length - 1);
+ const header = line([` ${narrow ? "TIME " : "AT "} ROW SUMMARY`, "muted"]);
+ const rows = list.map((r, i) => {
+ const at = String(r.at ?? "");
+ const when = narrow ? at.slice(11, 19) : at.slice(0, 19).replace("T", " ");
+ const l = line(fit(marker(i, selected), 2) + " ", [fit(oneLine(when), narrow ? 8 : 19), "muted"], " ", fit(oneLine(`${r.table}#${r.seq}`), 20), " ", trailSummary(r));
+ return i === selected ? sel(l) : l;
+ });
+ const r = list[selected];
+ const detail = [line([` ${r.table}#${r.seq}`, "bold"], ` ${r.at}`), ...wrapped(` ${trailSummary(r)}`, cols, null, " ")];
+ const follow = r.task_ref && r.task_ref !== d.subject ? r.task_ref : null;
+ if (follow) detail.push(line([" v follows to ", "muted"], follow));
+ return listOver([heading("Trail", d, `${d.subject} · ${list.length} row(s), oldest first`), blank()], header, rows, selected, detail, room, cols);
+}
+
+function talkBody() {
+ return [
+ line([" Talk", "bold"]),
+ blank(),
+ line([" Talk is not in this version of the app.", "bold"]),
+ line([" From a shell, mosaic talk <role> <text> sends a request and waits for the reply.", "muted"]),
+ ];
+}
+
+// Help: what every key does, and what the app never does.
+export const HELP = [
+ "Views Anywhere Inbox",
+ "c Talk (not yet) ↑↓ or j k move 1-9 choose an option,",
+ "i Inbox Enter open then y resolves",
+ "t Tasks v trail v trail of the decision",
+ "a Agents : command",
+ "r Trail ? this help Command line",
+ " q quit :decide <decision> <option>",
+ " :trail <task|decision>",
+ " :business <id>",
+ " :q quit",
+ "",
+ "Decisions resolve only here or with mosaic decide, from a shell you started",
+ "yourself. Inside an agent run, mosaic refuses with exit 3. Esc closes this help.",
+];
+
+// A box over the body: `lines` are [text, style] or a string.
+function box(title, lines, w) {
+ const inner = w - 4;
+ const out = [line([`┌─ ${title} ${"─".repeat(Math.max(0, w - 5 - width(title)))}┐`, "accent"])];
+ for (const l of lines) {
+ 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(Math.max(0, w - 2))}┘`, "accent"]));
+ return out;
+}
+
+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 · any other key cancels (the default is No)", "muted"]);
+ else lines.push("", ["resolving…", "muted"]);
+ 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);
+ }
+ if (o.kind === "detail") return box(o.title, [...o.lines, "", ["Esc back", "muted"]], w);
+ return box(o.title ?? "error", [[o.message, "danger"], "", "Esc back"], w);
+}
+
+// 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()];
+ else if (state.view === "inbox") body = inboxBody(state, room, cols, now);
+ else if (state.view === "tasks") body = tasksBody(state, room, cols);
+ else if (state.view === "agents") body = agentsBody(state, room, cols, now);
+ else if (state.view === "trail") body = trailBody(state, room, cols);
+ else body = talkBody();
+ body = body.slice(0, room);
+ while (body.length < room) body.push(blank());
+ if (state.overlay) {
+ 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 --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));
+ b.forEach((l, j) => (body[top + j] = line(" ".repeat(left), ...l.pieces)));
+ }
+ const status = state.command !== null && state.command !== undefined ? line(":", state.command, ["█", "muted"]) : line([` ${state.status ?? ""}`, state.statusStyle ?? "muted"]);
+ 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..b01abcb6
--- /dev/null
+++ b/packages/cli/tests/app.test.mjs
@@ -0,0 +1,719 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+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, 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";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const REPO = join(here, "..", "..", "..");
+const NOW = Date.parse("2026-10-11T18:00:00.000Z");
+const SIZES = [
+ [120, 36],
+ [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) => {
+ try {
+ await p;
+ return 0;
+ } catch (e) {
+ if (!(e instanceof CliError)) throw e;
+ return e.exitCode;
+ }
+};
+
+// An app on the in-process broker. `wrap` may replace verbs of the transport.
+function app(t, { wrap = (call) => call, business = "demo", ...opts } = {}) {
+ const bus = broker(t);
+ const out = { text: "", quit: null };
+ const a = new App({
+ business,
+ transport: (b) => wrap(bus.transport(b)),
+ write: (s) => (out.text = s),
+ now: () => NOW,
+ refreshMs: 0,
+ askMs: 0,
+ onQuit: (r) => (out.quit = r),
+ ...opts,
+ });
+ return { ...bus, a, out, screen: () => a.frame.map(strip).join("\n") };
+}
+
+// Text a hostile or careless agent could put in a decision: control
+// characters, a bidi override, a newline, wide characters and a long word.
+const HOSTILE = "Push \x1b[31mred\x1b[0m \u202eevil\u202c to 発行 now?\nSecond line " + "x".repeat(150);
+
+// A transport with one of everything, in the broker's shapes, for the
+// screens the broker cannot seed (task snapshots need the tracker adapter).
+function fake(extra = {}) {
+ const decision = (n, more = {}) => ({
+ id: `${String(n).repeat(8)}-aaaa-4bbb-8ccc-${"d".repeat(12)}`,
+ action: "git.push.protected",
+ class: "gated",
+ blocking: n === 1,
+ at: "2026-10-11T17:00:00.000Z",
+ raised_by_role: "coder",
+ raised_by_run: "coder-run-with-a-long-name-0123456789",
+ question: n === 1 ? HOSTILE : "Push the release?",
+ options: [
+ { key: "yes", text: "Allow" },
+ { key: "no", text: "Decline" },
+ ],
+ recommendation: "no",
+ authorization: { action: "git.push.protected", target: "refactor-with-a-very-long-branch-name-indeed", approvalChoice: "yes" },
+ task_ref: "vikunja:1/7",
+ ...more,
+ });
+ const data = {
+ // 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: "発行 ✅", 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: [
+ { at: "2026-10-11T17:00:00.000Z", table: "decisions", seq: 4, id: "x", task_ref: "vikunja:1/7", question: HOSTILE },
+ { at: "2026-10-11T17:05:00.000Z", table: "events", seq: 9, kind: "task.changed.external", body: { changed: ["bucket"] } },
+ ],
+ ...extra,
+ };
+ return () => async (verb) => structuredClone(data[verb] ?? []);
+}
+
+function fits(frame, cols, rows, label) {
+ assert.equal(frame.length, rows, `${label}: ${frame.length} rows`);
+ frame.forEach((l, i) => assert.equal(width(strip(l)), cols, `${label}, row ${i}: ${JSON.stringify(strip(l))}`));
+}
+
+// Every view and overlay, drawn at the given size.
+async function everyScreen(t, cols, rows, color) {
+ const shots = [];
+ const a = new App({ business: "demo", transport: fake(), cols, rows, color, now: () => NOW, refreshMs: 0 });
+ const shot = (label) => shots.push([label, [...a.render()]]);
+ await a.start();
+ shot("inbox");
+ for (const [key, label] of [["t", "tasks"], ["a", "agents"], ["c", "talk"]]) {
+ await a.input(key);
+ shot(label);
+ }
+ await a.input("i");
+ await a.input("v");
+ shot("trail");
+ await a.input("\r");
+ shot("trail detail");
+ await a.input("\x1b");
+ for (const [key, label] of [["t", "tasks detail"], ["a", "agents detail"], ["i", "inbox detail"]]) {
+ await a.input(key);
+ await a.input("\r");
+ shot(label);
+ await a.input("\x1b");
+ }
+ await a.input("?");
+ shot("help");
+ await a.input("\x1b");
+ await a.input(":trail vikunja:1/7");
+ shot("command line");
+ await a.input("\x1b");
+ for (const o of [
+ { kind: "decide", lines: ["decision 11111111", ` question: ${HOSTILE}`, "your choice: yes (Allow); this authorizes the action"], asking: "resolve 11111111 with yes? [y/N] " },
+ { kind: "decide", lines: Array.from({ length: 40 }, (_, i) => `line ${i}`).concat("your choice: yes (Allow)"), asking: "resolve 11111111 with yes? [y/N] " },
+ { kind: "unknown", message: "outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail 11111111-aaaa-4bbb-8ccc-dddddddddddd before trying again", id: "x" },
+ { kind: "error", title: "decide", message: `failed: ${HOSTILE}` },
+ ]) {
+ a.state.overlay = o;
+ shot(`overlay ${o.kind}${o.lines?.length > 10 ? " long" : ""}`);
+ }
+ a.state.overlay = null;
+ // Empty, failed-with-stale-data, refused and no-business states.
+ a.state.data.inbox = { list: [], at: new Date(NOW).toISOString() };
+ shot("inbox empty");
+ a.state.data.inbox = { list: await fake()()("inbox"), at: "2026-10-11T17:59:00.000Z", error: "invalid-response" };
+ shot("inbox stale");
+ a.state.data.inbox = { error: "human-required", refused: true };
+ 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;
+}
+
+test("every screen and overlay fits 120x36, 80x24 and the narrow layout, row for row", async (t) => {
+ for (const [cols, rows] of SIZES) {
+ 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]), /\[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) {
+ const bare = l.replace(/\x1b\[[0-9;]*m/g, "");
+ assert.doesNotMatch(bare, /[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/u, label);
+ }
+ }
+ const shots = await everyScreen(t, 120, 36, true);
+ assert.match(shots.find(([l]) => l === "inbox detail")[1].join("\n"), /\^\[\[31mred\^\[\[0m <U\+202E>evil/);
+});
+
+test("NO_COLOR: no escape codes, except reverse video on the selected row", async (t) => {
+ for (const [label, frame] of await everyScreen(t, 120, 36, false)) {
+ for (const l of frame) assert.doesNotMatch(l.replace(/\x1b\[(7|27)m/g, ""), /\x1b/, label);
+ }
+ // With nothing to select, nothing at all.
+ const a = new App({ business: "demo", transport: fake({ inbox: [], tasks: [], agents: [] }), color: false, now: () => NOW, refreshMs: 0 });
+ await a.start();
+ for (const key of ["t", "a", "c", "r", "?"]) {
+ await a.input(key);
+ 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/);
+});
+
+test("1-9 shows the decide flow's own lines and question; only y resolves", async (t) => {
+ const s = app(t);
+ const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
+ await s.a.start();
+ assert.match(s.screen(), new RegExp(d.id.slice(0, 8)));
+ assert.match(s.a.state.status, /Press 1-2 to choose/);
+ await s.a.input("1");
+ 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 · 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" } }]);
+ assert.deepEqual(s.read("inbox"), []);
+ assert.equal(s.a.state.status, `resolved ${d.id}: yes`);
+ assert.equal(s.a.state.overlay, null);
+ assert.equal(s.a.resolved, 1);
+ assert.match(s.screen(), /Nothing is waiting on you\./);
+ // The second option declines.
+ const e = s.raise("git.push.protected", { target: "next" });
+ await s.a.input("i");
+ await s.a.input("2");
+ assert.match(s.screen(), /your choice: no \(Decline\); this declines the action/);
+ await s.a.input("Y");
+ assert.equal(s.a.state.status, `resolved ${e.id}: no`);
+ s.a.input("q");
+ assert.deepEqual(s.out.quit, { how: "q", resolved: 2, error: undefined });
+ assert.equal(quitLine(s.out.quit), "(app closed with q; 2 decisions resolved this session)");
+ assert.equal(quitLine({ how: "Ctrl-C", resolved: 1 }), "(app closed with Ctrl-C; 1 decision resolved this session)");
+});
+
+test("the app shows the same decide text as mosaic decide", async (t) => {
+ const s = app(t);
+ const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
+ // Without --yes and off a terminal, mosaic decide prints the decision and
+ // the choice, then refuses to ask: the lines before its question.
+ const x = io();
+ assert.equal(await exitOf(main(["decide", d.id, "no", "--business", "demo"], x, { system: { dataRoot: tmp(t) }, transport: s.transport })), 4);
+ await s.a.start();
+ await s.a.input("2");
+ assert.deepEqual(s.a.state.overlay.lines, x.out.text.trimEnd().split("\n"));
+ assert.equal(s.a.state.overlay.asking, `resolve ${d.id.slice(0, 8)} with no? [y/N] `);
+ await s.a.input("n");
+});
+
+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");
+ 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 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, {
+ wrap: (call) => async (verb, args) => {
+ if (verb !== "decision.resolve") return call(verb, args);
+ resolves += 1;
+ const e = new Error("outcome-unknown");
+ e.code = "outcome-unknown";
+ throw e;
+ },
+ });
+ const d = s.raise("git.push.protected", { target: "refactor" });
+ await s.a.start();
+ await s.a.input("1");
+ await s.a.input("y");
+ assert.equal(resolves, 1);
+ assert.equal(s.a.state.overlay.kind, "unknown");
+ assert.equal(s.a.state.overlay.message, `outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail ${d.id} before trying again`);
+ assert.match(s.screen(), /Nothing was retried, and the choice was not sent twice\./);
+ await s.a.input("1y:decide x yes\ry");
+ assert.equal(resolves, 1, "no key sends the choice again");
+ await s.a.input("v");
+ 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) => {
+ let fail = null;
+ const s = app(t, { wrap: (call) => async (verb, args) => {
+ if (fail && verb === "inbox") {
+ const e = new Error(fail);
+ e.code = fail;
+ throw e;
+ }
+ return call(verb, args);
+ } });
+ s.raise("git.push.protected", { target: "refactor" });
+ await s.a.start();
+ fail = "invalid-response";
+ await s.a.input("i");
+ assert.equal(s.a.state.data.inbox.list.length, 1);
+ assert.match(s.screen(), /stale: invalid-response/);
+ assert.equal(s.a.state.status, "read failed: invalid-response");
+ fail = "human-required";
+ await s.a.input("i");
+ assert.equal(s.a.state.status, "refused: human-required");
+ assert.equal(s.a.state.data.inbox.refused, true);
+ fail = null;
+ const decide = app(t, { wrap: (call) => async (verb, args) => {
+ if (verb !== "decision.resolve") return call(verb, args);
+ const e = new Error("unauthenticated");
+ e.code = "unauthenticated";
+ throw e;
+ } });
+ decide.raise("git.push.protected", { target: "refactor" });
+ await decide.a.start();
+ 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"]);
+});
+
+test("keys: views, moving, trail of a selection, the command line, help and quit", async (t) => {
+ const s = app(t);
+ const a = s.raise("git.push.protected", { target: "one" });
+ const b = s.raise("git.push.protected", { target: "two" });
+ await s.a.start();
+ await s.a.input("j");
+ assert.equal(s.a.state.sel.inbox, 1);
+ await s.a.input("\x1b[B\x1b[B");
+ assert.equal(s.a.state.sel.inbox, 1, "the selection stops at the last row");
+ await s.a.input("k");
+ await s.a.input("v");
+ assert.equal(s.a.state.data.trail.subject, s.read("inbox")[0].id);
+ await s.a.input("a");
+ assert.match(s.screen(), /coder-run/);
+ await s.a.input(":trail " + b.id + "\r");
+ assert.equal(s.a.state.data.trail.subject, b.id);
+ await s.a.input(":decide " + a.id.slice(0, 8) + " no\r");
+ assert.equal(s.a.state.overlay.kind, "decide");
+ await s.a.input("y");
+ assert.equal(s.a.state.status, `resolved ${a.id}: no`);
+ await s.a.input(":nope\r");
+ assert.match(s.a.state.status, /unknown command :nope/);
+ await s.a.input(":abc\x7f\x7f\x7fq\r");
+ assert.deepEqual(s.out.quit, { how: "q", resolved: 1, error: undefined });
+ // ? opens help; Esc closes it; Ctrl-C quits.
+ const h = app(t);
+ await h.a.start();
+ await h.a.input("?");
+ assert.match(h.screen(), /Decisions resolve only here or with mosaic decide/);
+ await h.a.input("\x1b");
+ assert.equal(h.a.state.overlay, null);
+ 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) => {
+ const s = app(t, { business: null });
+ s.raise("git.push.protected", { target: "refactor" });
+ await s.a.start();
+ assert.match(s.screen(), /No bus host runs here, so the app knows no business\./);
+ assert.equal(s.calls.length, 0);
+ await s.a.input(":business demo\r");
+ assert.equal(s.a.state.business, "demo");
+ assert.equal(s.a.state.data.inbox.list.length, 1);
+});
+
+test("mosaic with no argument: exit 3 in an agent run, exit 4 with the usage off a terminal, the app on one", async (t) => {
+ const dataRoot = tmp(t);
+ const tty = (env = humanEnv()) => {
+ const x = io(env);
+ x.stdin.isTTY = true;
+ x.stdout.isTTY = true;
+ return x;
+ };
+ // An agent run refuses before anything else, terminal or not.
+ for (const marker of ["CLAUDECODE", "MOSAIC_AGENT_RUN", "PI_AGENT_DIR"]) {
+ const x = tty({ ...humanEnv(), [marker]: "1" });
+ const err = await main([], x, { system: { dataRoot }, runApp: () => assert.fail("opened") }).catch((e) => e);
+ assert.equal(err.exitCode, 3);
+ assert.equal(err.message, `refused: this runs only from a human shell, outside any agent run (found ${marker} in the environment)`);
+ }
+ // Not a terminal on stdin, or on stdout: today's usage, exit 4.
+ for (const [inTty, outTty] of [[false, true], [true, false], [false, false]]) {
+ const x = io();
+ x.stdin.isTTY = inTty;
+ x.stdout.isTTY = outTty;
+ const err = await main([], x, { system: { dataRoot }, runApp: () => assert.fail("opened") }).catch((e) => e);
+ assert.equal(err.exitCode, 4);
+ assert.ok(err.message.endsWith(`\n${SEAT_USAGE}\n${USAGE}`), err.message);
+ }
+ // On a terminal: the app, then the quit line.
+ const x = tty(humanEnv({ NO_COLOR: "1" }));
+ let opened;
+ await main([], x, { system: { dataRoot }, transport: () => async () => [], runApp: async (o) => ((opened = o), { how: "q", resolved: 0 }) });
+ assert.equal(opened.business, null, "no host state, no business");
+ assert.equal(opened.color, false);
+ assert.equal(x.out.text, "(app closed with q; 0 decisions resolved this session)\n");
+});
+
+test("scripts/mosaic with no argument reaches the app entry; the seat usage it printed before has not drifted", () => {
+ const env = humanEnv({ MOSAIC_CONFIG: "/nonexistent/config.json" });
+ const app = spawnSync(join(REPO, "scripts", "mosaic"), [], { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
+ assert.equal(app.status, 4, app.stderr);
+ assert.ok(app.stderr.startsWith("mosaic: the app needs a terminal on stdin and stdout"), app.stderr);
+ assert.ok(app.stderr.endsWith(`${SEAT_USAGE}\n${USAGE}\n`));
+ const agent = spawnSync(join(REPO, "scripts", "mosaic"), [], { env: { ...env, CLAUDECODE: "1" }, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
+ assert.equal(agent.status, 3);
+ const seat = spawnSync(process.execPath, [join(REPO, "packages", "seat", "src", "cli.mjs")], { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
+ assert.equal(seat.status, 4);
+ assert.equal(seat.stderr, `refused: ${SEAT_USAGE}\n`);
+});
+
+test("strings to keep exactly live once, in the decide flow and the transport; the app does not restate them", () => {
+ const src = (f) => readFileSync(join(here, "..", "src", f), "utf8");
+ const cli = src("cli.mjs");
+ for (const s of [
+ "`your choice: ${choice} (${option.text})${effect ? `; ${effect}` : \"\"}\\n`",
+ '"this authorizes the action"',
+ '"this declines the action"',
+ "`resolve ${shortId(d.id)} with ${choice}? [y/N] `",
+ "`resolved ${d.id}: ${choice}\\n`",
+ "`outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail ${d.id} before trying again`",
+ ])
+ assert.ok(cli.includes(s), s);
+ assert.ok(src("transport.mjs").includes("`refused: this runs only from a human shell, outside any agent run (found ${found.join(\", \")} in the environment)`"));
+ for (const f of ["app.mjs", "screens.mjs"]) {
+ for (const s of ["authorizes the action", "declines the action", "[y/N]", "may have recorded it", "runs only from a human shell"]) assert.ok(!src(f).includes(s), `${f} restates ${s}`);
+ }
+});
+
+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..2280cdb2 100644
--- a/packages/queue/tests/dispatch.test.mjs
+++ b/packages/queue/tests/dispatch.test.mjs
@@ -1,8 +1,9 @@
// `scripts/mosaic queue` (A2). The dispatch must leave every other call to
// scripts/mosaic exactly as it was: the seat CLI sees the same argv, working
// directory, environment and stdin under the new script as under the pre-A2
-// copy in fixtures/mosaic-pre-a2.sh. Both CLIs are fakes that record what
-// they got; nothing here runs a seat or touches a queue.
+// copy in fixtures/mosaic-pre-a2.sh. Row 55 (Sage, 2026-10-11) sends the call
+// with no argument to the app in packages/cli instead. The CLIs are fakes that
+// record what they got; nothing here runs a seat or touches a queue.
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
@@ -23,13 +24,13 @@ process.stderr.write("fake err\\n");
process.exitCode = Number(process.env.EXIT);
`;
-// A root holding `script` as scripts/mosaic and both fake CLIs.
+// A root holding `script` as scripts/mosaic and the fake CLIs.
function root(t, script) {
const r = realpathSync(mkdtempSync(join(tmpdir(), "mosaic-queue-dispatch-")));
t.after(() => rmSync(r, { recursive: true, force: true }));
mkdirSync(join(r, "scripts"));
cpSync(script, join(r, "scripts/mosaic"));
- for (const pkg of ["seat", "queue"]) {
+ for (const pkg of ["seat", "queue", "cli"]) {
mkdirSync(join(r, `packages/${pkg}/src`), { recursive: true });
writeFileSync(join(r, `packages/${pkg}/src/cli.mjs`), FAKE);
}
@@ -52,7 +53,6 @@ function call(r, args, exit = 7) {
const SEAT_CALLS = [
["launch", "--repo", "/srv/repo", "--harness", "pi", "darkwing"],
["seat", "task", "darkwing", "text with two spaces", ""],
- [],
[""],
["launch", "queue"],
["Queue", "list"],
@@ -73,6 +73,19 @@ test("every call but `queue` reaches the seat CLI exactly as before A2", (t) =>
}
});
+test("no argument reaches the app entry in packages/cli, and only that call", (t) => {
+ const r = root(t, join(SCRIPTS, "mosaic"));
+ const got = call(r, []);
+ 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, 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) => {
const r = root(t, join(SCRIPTS, "mosaic"));
for (const rest of [["list"], [], ["note", "9", "text with spaces", "--op", "note-9-000001"], ["queue"], [""]]) {
diff --git a/scripts/mosaic b/scripts/mosaic
index b4e3a614..1f43dcb1 100755
--- a/scripts/mosaic
+++ b/scripts/mosaic
@@ -11,6 +11,10 @@
# repository-local and only reachable as scripts/mosaic.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+# No argument: the full-screen app on a terminal (packages/cli/README.md).
+if [ $# -eq 0 ]; then
+ exec node "$REPO/packages/cli/src/cli.mjs"
+fi
if [ "${1:-}" = queue ]; then
shift
exec node "$REPO/packages/queue/src/cli.mjs" "$@"