# Mosaic Console Piece 4, #1507. The control board is the first and only WebUI screen. ## Console actions This is the write inventory for the organizations release. A CLI name in the third column identifies an existing or planned operation; it does not by itself establish equivalent authorization, revision checks, or receipts. Unless marked enabled, Console mutations remain unavailable until that parity is verified. The `--org` forms below are release contracts, not claims about the baseline CLI. | Console write | CLI operation | Organization-release status | | --- | --- | --- | | Create organization | `mosaic org create --from ` | S6 integration hook | | Save organization or theme | `mosaic org update --from --if-revision ` | S6 integration hook | | Initialize organization checkout | `mosaic org queue init --local-only --accept-local-loss-risk` or `--remote ` | S6 integration hook | | Initialize project Git | `mosaic org project repo init --local-only --accept-local-loss-risk` or `--remote ` | S6 integration hook | | Recover a stopped bus with stale host state | `mosaic bus recover ` | CLI only | | Inspect or clear a dead lifecycle lock | `mosaic org lock show ` / `mosaic org lock clear ` | CLI only | | Start, stop or restart a bus | `mosaic bus start ` / `mosaic bus stop ` / `mosaic bus restart ` | S6 integration hook | | Restart (or first start) an organization's bus | `mosaic bus restart `, or `mosaic bus start ` for a bus never started | Enabled (F3): the owner's **Restart bus** control, offered where a page says the bus is down or a change needs a restart; trailed | | Restart the Console | Stop and run `mosaic console start` again | Enabled (F3) when `mosaic console start` runs it in the foreground, or with `--dev-local-owner --detach` under a detached supervisor (D1): Settings > Console; the owner only; trailed. Other Consoles answer `not-supervised` | | Save, review, confirm or reverse an organization's autonomy | Save only: edit the autonomy block and run `mosaic org update ORG --from FILE --if-revision N`; no autonomy verb yet (readiness P1-3) | Enabled (D1) on a `--dev-local-owner` Console: the owner only, on the selected organization's bus, behind a fence that passes only the four console verbs `autonomy.set.console`, `autonomy.review.console`, `decision.confirm.console` and `decision.reverse.console` | | Create chat project linked to a configured repository | `mosaic project create --repo --org ` | Enabled | | Create a thread | `mosaic thread create --org ` | Planned enabled | | Open a thread | `mosaic thread open --org ` | Planned enabled | | Send a thread message | `mosaic talk --thread --org ` | Planned enabled | | Start the onboarding interview (F2) | `mosaic thread create "Onboarding interview" --instance --org `, then `mosaic talk --thread "$(mosaic project interview )" --org ` | Enabled through the thread create and send routes above | | Approve the north-star draft (F2) | `mosaic project north-star --org --approve --base ` | Enabled: copies the reviewed draft into `spec/north-star.md` through the human save (`project.file.save`) | | Archive or unarchive a chat project | `mosaic project archive` / `mosaic project unarchive` | Unavailable pending parity | | Open a room or send a room message | No CLI equivalent identified | Unavailable | | Share or unshare a thread | No CLI equivalent identified | Unavailable | | Create, clear or cancel a thread session | `mosaic session create`, `mosaic session clear`, `mosaic session cancel` | Unavailable pending parity | | Send a role message | `mosaic talk ` (different message class) | Unavailable pending parity | | Mark a board session seen/unseen; reply to a board seat | No CLI equivalent identified | Unavailable | | Approve or deny a decision | `mosaic decide --org ` | Owner-only confirmation and resolution on the selected organization bus | | Answer, attach, change mode, cancel or revoke a request | No equivalent CLI identified | Unavailable | | Edit or reset launch/assistant limits | `mosaic limits set` / `mosaic limits reset` | Enabled (F3) for the owner on a selected organization's bus; unavailable on other Consoles pending parity | | Rename an instance | `mosaic name` | Enabled (F3) for the owner on a selected organization's bus; unavailable elsewhere pending parity | | Stop an assistant | No equivalent CLI identified | Enabled (F3) for the owner on a selected organization's bus; unavailable elsewhere | | Add, change or delete an instance; launch, regenerate or terminate an agent; change a veto | `mosaic org instance add`, `set`, `delete`, `launch`, `regenerate`, `terminate`; `mosaic org veto` (old spelling `mosaic business instance`, `mosaic business veto`) | Enabled (F3) for the owner on a selected organization's bus, with **Restart NAME now** (terminate, then launch) after a route change; unavailable elsewhere pending parity | | Change routes or enabled harnesses/providers | `mosaic org instance set`; `mosaic plugins set` | Enabled (F3) for the owner on a selected organization's bus; the page lists how the change reaches each saved agent; unavailable elsewhere pending parity | | Halt or resume processing | `mosaic halt` / `mosaic resume` | Unavailable pending parity | | Save a project file or revert library content | No equivalent CLI identified | Unavailable | | Change a project remote | No equivalent CLI identified | Unavailable | | Start, submit or cancel proxy sign-in; add an API key; set account priority or models | No equivalent CLI identified | Enabled (F3) for the owner on a selected organization's bus once its proxy is set up; setup itself stays `mosaic proxy init` in a terminal | Browser navigation, local appearance preferences and unsaved drafts are local state, not server writes. Signing out is a session action. F3 writes (Console restart, bus restart, and the owner's routing, agent and instance changes on an organization) append one line each to `/console/actions.jsonl` (0600): who (provider, issuer, subject, name), when, what and the outcome. A restart that cannot write its line is refused. On a Console with sign-in, no tester, granted user or API token reaches these controls: each needs the signed-in owner, the page's exact Origin and `Sec-Fetch-Site: same-origin`. Those headers stop another site's page, not a local program, which can send any header it likes. A dev local owner Console (`--dev-local-owner`) has no sign-in. Every request that passes the Host and Origin checks acts as the owner, and this includes the F3 controls. An agent launched on the same machine runs as the same OS user, so it can restart the Console or a bus and change routing, agents and settings just as the owner's page can. The startup warning says so. Use a dev instance for UI testing only. The real boundary is sign-in (`--sso`); a refusal for local agent peers in dev mode is a recorded follow-up. ### Route registration A module may export `registerRoutes(table)` and be included with one entry in `startServer({ routes: [registerRoutes] })`. It calls `table.add('GET' | 'POST', absolutePathOrAnchoredRegExp, handler)`. A handler receives `(req, res, { path, search, session, orgId, origin })`. The server checks sign-in and, for writes, exact Origin and same-origin fetch metadata; organization-scoped hooks also resolve the organization before dispatch. Handlers must validate their own input, enforce their own service authorization and return bounded responses. Scoped handlers receive only an organization ID, never a client-supplied socket or path. ## Run Start the existing board in one terminal, then the WebUI in another: ```sh node packages/control-board/src/cli.mjs serve node packages/webui/src/cli.mjs serve ``` Open http://127.0.0.1:7330/. Optional `--port N` and `--board http://127.0.0.1:7331` select another port or loopback board origin. `--business ID` picks the business the bus views read; without it they read the running bus host's business (`mosaic bus start `). Port 0 picks a free port. Ctrl-C stops each foreground server. No daemon, installation, account, login or deployment is added. The preview mode below checks one header a sign-in proxy sets; it signs nobody in itself. Only HTTP loopback board origins are accepted. The WebUI binds to IPv4 loopback by default, rejects a repeated Host (400), nonlocal Host and cross-origin requests, sends no CORS headers, refuses redirects and accepts only JSON object POST bodies up to 4096 bytes (16 KiB for row 76's send, below). Do not put the board behind a public proxy. Put the Console behind one only in the preview mode below, with a proxy that meets its contract. ## Preview behind a sign-in proxy (row 74, #1568) ```sh node packages/webui/src/cli.mjs serve --port 7332 \ --public-host NAME --auth-header HEADER --allow-user ID ``` The three flags go together; one or two refuse at start, before any config is read. NAME is a DNS name, no scheme or port. HEADER is a header name other than Host or Origin; Node lowercases incoming header names, so it matches without regard to case (`X-Example-Uid` on the flag finds `x-example-uid`). ID is the one user id, 1 to 256 visible ASCII characters without a comma, and the value compare is exact. The Console still binds to loopback only. It names no identity provider and has no login code: the proxy signs the user in and limits sign-in to the right group. In preview mode: - A request whose Host is exactly NAME must carry HEADER once, with a value equal to ID. Missing, different or repeated answers 401 `sign-in required` before any route runs, so no bus read or board call is made. An Origin, when sent, must be `https://NAME`. - A request whose Host is loopback on the Console's port (`127.x`, `localhost` or `[::1]`) is checked as before and needs no HEADER, so the operator's browser on the host still works. Any other Host is 403. - A request that carries HEADER with any Host but NAME answers 403 `proxy Host refused`. The operator's browser never sends HEADER; a proxy that rewrote Host does, and it fails on its first signed-in request instead of reaching the loopback path unchecked. - More than one Host header answers 400 before any other check, in this mode or not. Node keeps the first, so a loopback Host followed by `Host: NAME` would otherwise skip the sign-in check. - Every method other than GET and HEAD answers 403 `read-only`, whatever the Host. `/api/seen` and `/api/reply` never reach the board, so Seen and Reply fail in the page with that refusal. The one exception is POST `/api/bus/message` when the Console was started with `--send` (next section). - The GET routes are unchanged: the bus views, the Queue, Business and Settings reads, the board panel and the assets. If the board is down, its panel shows the existing error and the bus views still answer. What the proxy must do. The Console trusts HEADER only because the proxy controls it: - It terminates TLS for NAME and is the only thing that reaches the port from off the host. - It strips HEADER from every client request and sets it only after the identity provider signs the user in. - It forwards only requests whose Host is NAME, and forwards that Host unchanged. nginx rewrites Host to the upstream by default; set `proxy_set_header Host $host;` (or the literal NAME). Caddy and Traefik keep the client's Host by default. - Any loopback Host on the Console's port, `localhost` included, is the operator's path and skips the header check, so a proxy that forwarded a client's `Host: 127.0.0.1:7332` would let anyone through without signing in. A tunnel whose local end uses the same port number sends such a Host and reaches the Console unchecked. Anything that reaches a tunnel's local end can send a loopback Host without HEADER, so bind that end to loopback. - Every hop on this host that reaches the loopback port is bound by this contract too, not only the edge. - It passes the Console's status and body through and doesn't rewrite upstream errors (nginx's `proxy_intercept_errors` with an `error_page`, for example). Under `--send` the page reads a 4xx other than 408 as "not sent", so a proxy that turned the Console's 503 `outcome-unknown` into a 4xx or a redirect would report a message that may exist as not sent. The local exposure doesn't change ("Data and boundaries"): any process on the host can still connect to the port and send `Host: NAME` with HEADER and ID, or just a loopback Host, and read what a reader capability reads. The user id is not a secret; the sign-in at the edge is what keeps others out. Header preview is read-only. A configured proxy proof does not permit writes; `--send` requires the Console's verified OpenID Connect `--sso` mode. To run the Console as a systemd user unit, `scripts/webui-service.sh` renders and installs one with these flags (row 75; `docs/TOOLS.md` "Console service"). Jason starts and restarts it, and so may Mos under lead decision 102, which accepts that sends from a Console Mos started enter the trail as Jason. Any other agent starting it works around the human proof (lead decisions 62 and 94). Without `--send` the unit is read-only. The script takes `--send` (row 83) only with `--sso`; otherwise it exits 1 and writes nothing. With it, the unit's ExecStart ends with `--send`, and the Console needs the system config at start: without it serve refuses with `--send needs the bus configured` and exits 2, which the unit doesn't restart. `status` says whether the installed unit sends. The unit can run a read-only shared stack copy (rows 77 and 85), with serve's --checkout naming the canonical checkout so the queue stays current. webui-service.sh copy calls stack-copy.sh copy: the default directory is mosaic-stack/copies/, and commits must be on both local and pushed refactor. The copy includes bus/CLI/Console code, adapters and pinned npm dependencies. install --copy ID refuses unless the bus's effective ExecStart and WorkingDirectory name that same copy, including drop-ins. It also refuses while the Console runs. With --auth-header X-authentik-uid, allow-user must be the 64-character Authentik uid (decision 101). Status names the copy and refactor drift. Files and directories are read-only; copies are retained. Existing Console-only copies are not migrated or pruned. With `--instance NAME` (row 190) every verb works on `mosaic-webui-NAME.service`, which sets NAME's `MOSAIC_CONFIG`; install checks NAME's `mosaic-bus-NAME@` units for the copy. render and install then need `--port`, and 7330, 7332 or 7333 as the main or legacy tester port exits 4 with port-reserved. For every instance, install refuses a `--port` or legacy tester port that another installed Console unit in the unit directory already takes. That refusal is port-claimed, exit 1, and writes nothing. A unit with no `--port` holds 7332. See [TOOLS.md, Stack instances](../../docs/TOOLS.md#stack-instances---instance-name-row-190). The single update and rollback sequence is [TOOLS.md, Pinned stack update](../../docs/TOOLS.md#pinned-stack-update). A bus restart ends managed sessions, so no PM turn may be in flight. Switching the Console, bus or wrapper, changing any unit, and starting or stopping either service requires Mos's go for that live step. Agents build and test fixtures; they stop before the live sequence. ## Sign-in in the Console (Q175 phase 1, #1690) `serve --sso [--send] [--tester-socket]` makes the Console an OpenID Connect relying party. It reads `console.sso` from the system config (`publicOrigin` and one or more providers, each with its issuer, client id, a client secret slot, the signing algorithms it accepts and the subjects allowed in). The design is `agents/sage/work/q175-sso/DESIGN.md`. `--sso` can't be combined with `--public-host`, `--auth-header`, `--allow-user` or `--proof-*`; `--send` requires `--sso`. Header-preview mode cannot enable the human writer. Phase M3 sign-in requires an active account for account authority. Run `mosaic accounts init` before the first accounts sign-in; configured SSO subjects without accounts are refused. Legacy access.json users can still reach the root business under their live grants. A session records account, legacy user and root ownership separately. Only an active operator whose subject is configured as a root owner gets the root human transport; an account alone gets 404 on root data routes. Restarting the Console requires the instance operator role. Authentication generations are checked on every request: disable/re-enable, role or subject changes and `user signout` invalidate earlier account sessions. Legacy root grants remain independent. There are at most 512 sessions and 128 pending logins, with eight sessions per account; a ninth evicts that account's oldest. Sign-out removes the current session entirely. Every stack copy carries a read-only `.stack-build.json` naming its commit. A stamped Console requires `--sso`, a valid mode-0444 `.deployed` naming its instance, and a loadable accounts file. A malformed stamp refuses every start. Stamped Console and bus starts refuse dev flags. Checkout development still supports the named local dev owner, but refuses it if accounts.json, console.sso or `.deployed` is present. Accounts instances use `case2-unit.sh` system units; `webui-service.sh` user units remain a checkout development path. - The client secret is read once at start from `/secrets/mosaic-stack/`. The start refuses a slot that is a symbolic link, not a regular file, owned by another user, has any group or other mode bit, or sits in the repository, the `--checkout` directory or a stack copy. The value is never printed. - At start the Console fetches each provider's discovery document and JWKS. A provider that can't be reached (a network error, a timeout, a status other than 200, or a body that isn't JSON) leaves the Console up with that provider's `/auth/login` at 503 and a retry every 5 s, doubling, capped at 5 minutes. A document that fails a check (issuer, algorithms, S256, a usable key) refuses the start. `/healthz` adds `sso: { : "ready" | "unreachable" | "refused" }`. - The Console listener serves only the Host of `publicOrigin`, whatever port it listens on. Any other Host, a loopback one included, gets 403 `public Host required`, except `GET /healthz`, which stays open on loopback for the status and release gates. The loopback operator path in the preview section above does not exist under `--sso`. - Without a session, `/api/*` gets 401 `sign-in required` and a page load gets a 303 to `/auth/login?return=`. The page then shows the signed-out line with a link back to sign-in. A session lasts at most 8 hours and ends after 1 hour without a page load or a write; a restart ends every session. - The page shows Sign out only under `--sso`. It posts to `/auth/logout`, which ends the session and goes to the provider's end-session endpoint when it has one, or to `/auth/signed-out`. - With `--tester-socket`, the tester listener refuses any request carrying an `Authorization` header, a `__Host-mosaic_session` or `__Host-mosaic_login` cookie, or an `x-authentik-*` header (403 `tester listener takes no sign-in`). It never serves `/auth/*`. ## Message a role (row 76, #1570) ```sh node packages/webui/src/cli.mjs serve --port 7332 --sso --send ``` `--send` is off by default. It requires verified OIDC through `--sso` and refuses header-preview configurations, including those with proxy proof. It also needs the system config, including `console.sso`, where a read-only Console would still serve the board. A unit that sends comes from `scripts/webui-service.sh install` with `--send` (above). With `--send` the Inbox has a "Message a role" box: a role picker filled from the agents read, a text box and Send. The message reaches the role as a message from the human, class INFO, through `message.send` on the human transport. It goes the same way as one sent from the CLI, with class INFO where `mosaic talk` uses REQUEST (lead decision 95). Decisions are still answered in a terminal. There are no other message classes, no corrections and no seat conversations here. With `--send`, the Console authenticates each user through its OIDC session and uses that user's authorized transport. Caller-supplied identity headers and a loopback connection do not authenticate a writer. The request is POST `/api/bus/message` with a JSON object `{to, body, in_reply_to?}`. Nothing is sent unless all of these hold, each checked on every request: - Host is the configured OIDC public Host and the request has a valid signed-in session. There is no loopback operator path under `--sso`. - Origin is exactly `https://NAME`, else 403 `cross-origin request refused`. - `Sec-Fetch-Site` is sent once and is exactly `same-origin`, else 403 `cross-site request refused`. Browsers set it, and page script can't. - Content-Type is `application/json` and the body is at most 16384 bytes and a JSON object with `to` and `body`, optionally `in_reply_to`, and no other key. Anything else is 400 `invalid-request`. The Console doesn't judge `to` or the body beyond that; the broker does, with `message.send`'s own rules. Its body limit, 32768 characters, is past the request limit, so the 16 KiB limit binds first. A broker refusal means nothing was written. It answers `{ error, message, sent: false }` with the read routes' statuses: 400 for `unknown-role`, `invalid-request`, `message-not-found` and `request-too-large`, 403 for `human-required` and `read-only`, 503 for `not-configured` and `no-bus-host`, 502 otherwise. When the transport can't say what happened (a timeout, a child that exits without a code, an answer that can't be read or that has no message id), the message may have been written. The answer is 503 `outcome-unknown` with `sent: "unknown"`. Nothing retries, in the server or in the page. Each send that gets past the sign-in check writes one line to the Console's stdout: the time, `send`, the user, the `to` value (JSON-quoted, at most 64 characters), then `id=` or `error=`. The body is never logged. A request refused at sign-in or by the shared Host and Origin checks writes no line. On the page: - The draft, role and text, survives the ten-second refresh, kept in page memory like a Reply draft. Reloading the page loses it. Send is disabled while a send is pending. - Sent shows the message id and links its trail, `#/trail/message/`: the message and its deliveries. A reply is not in the trail; it shows under the receipt (below). The text is cleared unless it was edited during the send. - Not sent shows the broker's code and keeps the draft. A redirect counts as not sent: the Console never redirects this route, so only the sign-in proxy does, when its session has expired. The page asks for a reload to sign in again, and signs out (row 95, below). - Outcome unknown says the message may have been sent, keeps the draft, and has no trail to link, since no id came back. Check the role's side, or the bus trail from a terminal, before sending again. A 408 counts as unknown, here and on the board's Reply, since a proxy can answer it after forwarding the request (Envoy's `stream_idle_timeout`). The role's reply comes back as a message to the human with `in_reply_to` set to the sent message's id. Since row 81 (#1575) the Sent receipt lists those replies, oldest first, each with its role, time, class, body and a trail link. It reads them with the bus `replies` read on every render, so a reply shows on the next ten-second refresh or Read again. "No reply yet" means the read returned none; "could not be read" means it failed, and the next refresh tries again. The bus returns at most 50; at 50 the receipt says only the first 50 show. The list belongs to the last Sent receipt: a new send starts its own, and reloading the page drops it. The Console only shows replies. It never receives a message or marks one delivered or read: `replies` writes no `deliveries` row. So Jason's next `mosaic talk` in a terminal still prints each reply, within talk's limits: - talk receives only after it sends its own message; - `--wait 0` returns straight after the send and receives nothing; - otherwise each receive returns every undelivered message to the human, up to 100 a call. The broker records each one delivered when `message.receive` returns it, so the next receive doesn't return it again; talk prints each one and marks it read. ## Use - Waiting on you uses the board's `waitingOnYou` flag across all projects. - Select a project on the left to filter the session table. Counts show visible rows out of the total when Hide offline or Hide seen hides anything. - Select an agent to open its inspector. Arrow keys move between table agent buttons, Enter opens, and Close or Escape in the inspector returns focus. - Seen clears the row from Waiting on you until the board detects new activity. The collapsed Seen section keeps those rows available; Unsee returns them. - Reply appears only where the board's registration permits it. A failure shows the board's stderr and keeps the draft. Transport success is the board's `delivered` receipt, not proof that the seat processed the message. - Refresh runs the existing board scan. Automatic refresh uses the board page's ten-second interval; Pause stops it. Failed refreshes keep the last snapshot with a warning. There are no automatic action retries. - Palette and appearance are the installed `theme` plugins (see "Themes" below). Harbor in the dark is built in and is the default. This browser keeps its own choice per organization when local storage is available; with none, the organization's saved `appearance` applies. Settings (row 54) shows the same control and nothing else that changes. - History opens a read-only conversation view for a seat, from its Waiting card, its table row or its inspector (#1507, CHAT-02). It shows the whole branch with nothing clipped. Tool calls, tool results and thinking start collapsed. Session text is always shown as text: Markdown stays as source, and terminal controls, bidi controls and marks show as visible symbols. Reply in the view uses the same board reply path as the inspector. - The view checks for new entries on the same ten-second refresh, and Pause stops it. It never switches on its own. A fork, a newer session for the seat or a rewritten file each shows a marker with a button to open the other history. Reloading after a rewrite keeps the branch the view was on; if that branch is gone, the view opens the latest one and says so. Session lists older sessions for the seat, and is empty while the list is read or when that read fails. Only repository Pi seats have history; other harnesses say so. Drafts and receipts stay in page memory, including across refresh, inspector changes and a stale registration. Reloading or closing the page loses them. A pending send stays disabled across refresh; new text typed during a send is not cleared by the earlier send's success. If the proxy loses the response, delivery may be unknown: inspect the seat before sending again. ## Inbox, tasks, agents and trails (slice 1 S5, #1522) The Console sidebar adds Inbox, Tasks and Agents next to the control board, which stays at `#/`. Routes: `#/inbox`, `#/inbox/`, `#/messages`, `#/tasks`, `#/tasks/`, `#/agents`, `#/trail/task/`, `#/trail/decision/` and `#/trail/message/` (row 76), with `?kind=` filters on a trail. The design note is `agents/dewey/work/wui/SLICE1-VIEWS.md`. - Every view reads the four verbs of the Q1 module (`packages/bus/src/views.mjs`, lead decision 56): `inbox`, `tasks`, `agents` and `trail`. The CLI reads the same functions, so both show the same broker data. - Nothing writes but the "Message a role" box, the Settings update, the Assistant section's three buttons and the spec and docs editor's Save, all under `--send` (above, "Assistant settings" and "Spec and docs editor"), and the decision answers below. A decision shows `mosaic decide ` for each choice, with Copy, which only puts the command on the clipboard. This optional terminal fallback remains available (REQ-DEC-3, Q4). Seen is not set from the browser. The command has no `--business`, so `mosaic decide` uses the running bus host's business, as the Console does by default. From a Console started with `--business` for another business the copied command refuses (no bus host, or no such decision) rather than answering elsewhere. - #797 / Gitea #1839 stage 1 (REQ-WEB-2), extending row 252 (#1753): an open decision routed to the user offers one answer button per option, including ordinary two-to-nine-option decisions with non-yes/no keys. A grant request (`credential.mint`, raised with a grant and two options) retains Approve and Deny with its concrete rights summary. Answers are enabled only with `--sso` and `--send` and the page is signed in as a `console.sso` subject. The first click POSTs `/api/decisions//confirmation` and shows what the answer does; only Confirm POSTs `/resolve` with that one-use confirmation (two minutes, bound to the session, business, decision, choice and current context digest). The broker supplies authoritative `options` and a `kind` of `decision` or `grant`; grants also retain `approve`, `deny` and `grant`. Ordinary HTTP confirmations carry `kind: decision`, `action: answer` and the selected key/text, so the receipt records that choice without claiming a grant started. Each step spawns `packages/bus/src/console-cli.mjs`, which the bus verifies as the Console's child holding the signed-in subject, and the bus records the answer `via console` with a `human.input` naming that subject. Nothing retries; an unknown outcome says to refresh. Cancellation, navigation and page reload discard the page's confirmation; a refresh that finds a closed decision or a changed context drops it too. The broker rechecks eligibility, digest and choice in the resolve transaction. Missing or invalid grant bindings cannot fall through to ordinary answers. Without `--sso` the page has no buttons and says why; under `--sso` without `--send` it says the Console is read-only for decisions. The tester listener has no decision routes. Decisions routed to agent roles remain with those roles, using their real role/run attribution. - The project remote module can display the configured HTTPS repository URL for an owner session and save a missing URL when writes are enabled. A configured URL is not verified and does not publish, set a Git origin, grant credentials or contact a forge. Project information integration supplies the authorized chat project to repository-key mapping. - #797 / Gitea #1839 Task 3, the request intake panel (`src/public/requests.js`, `src/request-http.mjs`): under each of the signed-in owner's latest three chat messages that opened a request, the thread shows the request's phase, mode, project and what it waits on, with buttons for what the owner decides. All of it is escaped data (control and bidi characters show as `\uXXXX`). The panel reads `GET /api/requests/` (state) and `/projects` (the existing-project picker, only the projects the bus authorizes for this request), re-reading on a ten-second beat while a thread is shown, and never writes by mounting. It needs `--sso`; its writes need `--send` too (otherwise the panel reads and says it is read-only; `/api/config` reports `requests` and `requestWrites` to the owner only). Five owner writes, each `POST` with a single same-origin `Origin`, `Sec-Fetch-Site: same-origin`, `application/json`, a bounded body of exactly these fields and a live session, spawning `console-cli.mjs` with the signed-in subject (the page never names a business, subject or verb): `/answer {revision, question, op, text}` (64 KB body, 8000 characters of text), `/attach {revision, project, op}`, `/mode {revision, mode_revision, mode, op}`, `/cancel {revision, op}`, and Task 4's `/revoke {project, snapshot, revision, reason, op}`. The first four return the new request state; revoke returns a bound snapshot revocation receipt. A bus refusal maps to a named code and any unrecognised outcome is `outcome-unknown`, never retried. Choosing the mode (new autonomous, new guided, or an existing project) answers the selection decision through the same confirmation endpoints as the Inbox (`/api/decisions//confirmation`, then `/resolve`). Picking an existing project records the pick and the PM then stages a confirmation decision; nothing is mapped until the owner confirms that. A text answer to the PM's question is recorded as an attributed `human.input` linked to the question and the request; it is never a PRD approval, and the original spec is not rewritten. A mode change names the expected request and mode revisions and is the bus's `request.mode.console` (the project's mode, shared by every request attached to it): it withdraws a PRD approval waiting in the old mode and returns each such request to discovery. A stale mode revision is `request-mode-revision-conflict` (409). Each of answer, attach and cancel also needs the owner-to-PM messaging edge, read when the control runs. Cancel asks first, appends cancellation and keeps any project already created. An answer is stored in `sessionStorage` before it is sent. After a lost response the page reads the request first, and only the owner's click sends the stored answer again with the same operation id, so the bus records it once. Sign-out clears the panels and the stored answers. `GET /api/requests//draft` relays the bus's `request.draft.console` reply (`candidate`, `prd`, `requirements`, `snapshot`). Task 4 displays the exact PRD version, full PRD and requirement text, selected paths/blob IDs, records commit, confirmed intent commit and mode revision. Text is inert, including control/bidi characters. A missing or mismatched draft offers no approval or revoke action. Guided PRD decisions use the existing confirmation/resolve endpoints; their confirmation names the displayed version and records commit. A changed candidate or request drops the confirmation. Autonomous decisions stay with their eligible arbiter. The panel shows the approved snapshot and offers **Revoke approved snapshot** to a writable owner. Revocation requires a reason (1–1024 characters) and an explicit confirmation; it keeps existing evidence and makes pending work wait for replacement authority. The route checks project and snapshot against the URL's current request before writing. Its revision is the draft read's `snapshot.revision`, the project's latest snapshot-event counter, never the request revision. The body limit is 4096 bytes. Stale revisions refuse 409 `request-revision-conflict`; a foreign snapshot refuses 403 `requirement-snapshot-refused`. A refusal is reported, and a lost or malformed receipt remains unknown; neither retries. The page reads again after a revoke. With no candidate (including after revocation), the draft read returns `candidate: null`, `prd: null`, `requirements: []` and `snapshot: {id: null, revision}`; revision is zero before the project's first snapshot event. Changed selected PRD/requirement content refuses 409 `prd-candidate-stale`, changed intent refuses 409 `stale-spec`, and a bus whose verb list lacks the draft read refuses 503 `draft-unavailable`. These routes remain owner-only; without SSO, request GETs refuse 503 and POSTs refuse 403. Sign-out clears the draft panel and ignores late replies. - What no Q1 verb returns is labelled where it would show, for example "Bot names: not in the Q1 module" (lead decision 63). Nothing is guessed. - A task's list row can only say its current state came from a Vikunja read, since the list has no trail. The task page reads the trail and tells a task the stack never created from one changed in Vikunja after the stack wrote it. A stale poll read is shown in the snapshots and never as current. - The task page's "Who may write" section lists each field with the verbs that write it and the writer each verb checks: pm, the assigned role or any role (row 61, lead decision 113). Whether a write also needs a resolved decision is the role's authority, on the Business page. If the table read fails, the section says why and the rest of the page still shows. - The Tasks table has Requirement and Priority columns, from the `tasks` row's `requirement` and `priority` (row 58, #1547). Either one shows "none" when the row has none. A tombstone's page shows the priority of the last snapshot that read the task. - All bus- and agent-authored text is rendered inert: controls show as control pictures or `[U+XXXX]`, and Markdown stays as source. - Views refresh on the board's ten-second interval and stop on Pause. Focus, open sections and the copy status survive a refresh. - A failed read keeps the last good answer for that read and says it is old, with the time it was read. A refusal (403, `not-configured`, `no-bus-host`, or row 54's `queue-refused`) never shows kept rows (row 53), and it drops every kept read: the Inbox count and the palette's decisions, tasks and queue rows go too, until a read succeeds. A page never read, or refused, shows the failure instead, titled "Bus refused the read" (403: the bus refused the Console's read, for example `human-required` from a Console started inside an agent run), "No bus to read" (no system config, or no bus host and no `--business`) or "The read failed". ## Agent state (row 59, #1548) `#/agents` adds State and Attention columns. State is the board's mark (● working, ◐ waiting, – idle) from the bus `agents` verb, with the number of messages and since when for a working role. Attention shows "Input needed:" with the first line of the role's first open decision, linked to `#/inbox/`, a blocking chip, and "+N more" to the Inbox when there are others. The page says what each state means. It writes nothing; the server passes the rows through unchanged. How the bus derives the state and its limits: `packages/bus/README.md`, "Agent state". The same view has a separate **Thread claims** section (row 278, #1783). It reads `thread.claims` and shows thread id, holder run, harness and claim time, in the bus's order. Thread names, messages, instance and address are not shown. An empty read has an empty-state line. A refused or failed read clears only this section's kept claims and shows its reason; the held roles and their count stay intact. Sign-out clears the claims with the other bus reads. The read needs no `--send`. The bus permits human and reader sessions; the tester listener refuses it with HTTP 403 `tester-refused` before any bus call. ## Board: held roles and signing out (row 95, #1589) The Board (`#/`) opens with **Held roles**: each role a run holds on the bus, from the same bus `agents` read as `#/agents`, including a claim with no address ("no address"). Each row shows the role, its state and attention (as in "Agent state"), the address, the holding run with its harness, and since when. With send on (`--send`, "Message a role"), each row has a Message action: it opens `#/inbox` with the send form addressed to that role and the caret in the message. Without send there is no action, and the section says so. A refused or failed first read says why and shows no roles rather than a guess; a failed later read keeps the last roles under a stale warning. The machine scan's sessions (Waiting on you, Seen, All sessions and the project list) sit behind **Show machine sessions**, off by default. While it is off the page makes no `/api/board` request and polls nothing for it. The choice is kept in this browser's local storage under `mosaic-console-board` (`{"machine":true}`), like the appearance; where storage is refused it starts off on each load. Turning it off drops the board data and closes an open conversation. Every Console fetch the page makes uses `redirect: 'manual'`. The Console never redirects, so a redirect answer (`opaqueredirect`) is the sign-in proxy's: its session has ended. The first one, on a board read, a bus read, a page read or a send, signs the page out: - One line says "Signed out. Reload the page to sign in." No NetworkError, failure banner or stale warning is shown for it. - Everything drawn from a Console read is removed (the board, the held roles, the bus views, an open conversation and the inspector), because none of it can be refreshed. A read that was in flight is dropped when it lands. - Refresh and Pause are disabled; nothing polls and no route change reads. - The send form, if open, keeps its draft and receipt so the text isn't lost; a further Send is refused in the page ("signed out"). A board Reply that meets the redirect is not sent, and its inspector closes with the rest of the board, so its draft isn't shown again. - Reloading the page goes through the sign-in proxy again. How long a sign-in lasts is the edge's setting (ops-01), not the Console's. ## Shell (row 53, #1542) The Console is restyled to `docs/design` (`IMPLEMENTING.md`). It is a restyle only: no new data, route or write. - `src/public/tokens.css` and `src/public/icons.svg` are byte copies of `docs/design/tokens.css` and `docs/design/icons.svg`; `tests/shell.test.mjs` fails if either differs. Edit `docs/design`, rebuild there, then copy. - Colours come from `tokens.css`, selected by `data-palette` and `data-mode` on ``. For the built-in default, Harbor, the page sets no colour inline. A theme other than Harbor adds the fourteen colours of its module through the CSSOM (`setProperty`), only after the page has checked each is `#rrggbb`; see "Themes". Appearance System removes `data-mode`, so the OS light or dark setting applies. The default is Harbor in the dark (`tests/shell.test.mjs` reads `data-mode` under a dark OS, which cannot tell the two apart; an earlier version of this line said System). The browser's choice is kept under `mosaic-console-appearance:`, or under `mosaic-console-appearance` when the Console shows no organization. - `shell.css` loads last and holds the restyle: radii `--r` 6px and `--r-lg` 10px, the type scale, tabular numbers, the command bar, section list, dense table, inspector, status marks, class chips, toast, command palette and not-found page. The earlier CSS files are unchanged. - JetBrains Mono 400, 500 and 700 come from the official JetBrains release (v2.304); `assets/fonts/jetbrains-mono-sources.txt` has the URL and sha256 of the archive and each file, and `jetbrains-mono-OFL.txt` the licence. Manrope has no 800 file here, so 800 headings render at 700. - Ctrl+K (or the search field) opens the palette over views, inbox decisions, tasks and (row 54) queue rows from the last queue read; arrows move, Enter opens, Esc closes and returns focus. Arrow keys also move between task links in the bus tables. - Copy reports in a toast. If the clipboard is unavailable, the toast stays until dismissed and the command is selected for Ctrl+C. When no toast shows, the toast is an empty live region, visually hidden but still in the accessibility tree, so the first copy is announced. - The command bar shows, in UTC, when the board was scanned and the bus read, and says when the last attempt failed. A board refusal drops the last scan and everything drawn from it: the cards, the table, the inspector, the project filter and tree, the counts, the footer and the scan line all say the board was not read. A failed read with no scan held, after a refusal or before the first scan, shows the board empty and says the read failed; it never brings back rows from a refused scan. A refusal also closes an open conversation and clears its history. If that removes what had focus, focus moves to the page. - From 760px the footer is a one-line status bar and the side column fits between it and the command bar. Below 760px the section list is one strip that scrolls inside itself. ## Themes (stream S4, organizations plan C5) The ten palettes are installed `theme` plugins (`packages/plugins/theme//`; the module contract is in `packages/plugins/README.md`). The Console lists the folder on each request, so there is no list to keep. - `GET /api/themes` answers `{themes:[{id, displayName, tokens:{light,dim,dark}}]}` for the installed, valid themes, sorted by id. A refused module is left out and logged once per reason. The route reads no body, header or query. - Which theme shows, one field at a time (`theme`, `mode`): this browser's override for the organization, then the organization file's `appearance: {theme, mode}`, then Harbor in the dark. `mode` is `system`, `light`, `dim` or `dark`. A missing `appearance` is valid. - Clearing the browser's choice (Settings, Appearance) returns to the organization's. The browser never writes the organization file. - A saved theme that is not installed, or not valid, renders Harbor with a banner that names it. The saved value and the browser's override are not changed, so installing the module again brings the theme back. The Settings picker shows it as a disabled "Not installed or not valid" tile. - Whenever the organization's theme is not Harbor, a line under the command bar names the organization, the theme and who chose it, so colour is never the only cue. - `window.MosaicAppearance.organization(id, saved, label)` is the call the organization switcher makes when it changes the organization shown. Until then the Console calls it once, from `/api/business`. - The browser-wide record kept before this change (`mosaic-console-appearance`, `{palette, mode}`) goes to the first organization this browser shows, once, and is then removed. A record equal to the default (Harbor in the dark) is not carried over: it cannot be told from a page nobody changed. - Tests: `tests/appearance.test.mjs` (the rules), `tests/themes-http.test.mjs` (the route), `tests/appearance-browser.test.mjs` (the page). ## Queue, Business and Settings (row 54, #1543) Three read-only views, in the section list and the palette. Each has the same five states as the bus views: loading (the skeleton names the source), normal, empty, a failed read over kept data (labelled stale, with its time), and a refusal that shows nothing kept. The command bar names the source: "Queue read …", "Business not read: refused". A refusal shows the code in mono and the module's own message, redacted by the server. - `#/queue` lists every row with its state, owner, reviewers, brief and last update, filterable by state. `#/queue/` adds the gate, issues, claim, `after`, note and review rounds. Rows move only with `scripts/mosaic queue move` in a terminal; the row page shows that command with a Copy button and has no button that moves a row. The brief is shown as its path and anchor, because Console serves no repository files. - `#/business` shows the business named by `--business`, else the running bus host's business: the human, the arbiters as instance and kind ("pm (delivery), cto (technical)"), projects, launch rules, the vars on an allowlist, each role instance with its holder and harness, the authority of each instance for each action as the organization package classifies it (`resolveInstance`, `classify`), and credentials as metadata only: service, account, role, expiry or rotate-by date, and a state worked out from the date alone. A missing or invalid business file is a `not-configured` refusal with the business module's own text. - `#/settings` shows appearance (the row 53 control, kept in this browser), the notifier binding by name only, and about: the Console's loopback address, `RELEASE`, the business and the board URL. A Settings refusal still shows appearance, because it needs no read. Console never reads a token, a token file or a credential variable, and no response, page or log carries one, a file path or a Discord channel, guild or user id. The server redacts every message and string field before it answers (`redactor` in `src/reads.mjs`): the config directory becomes ``, the data root ``, any other absolute or `~/` path ``, a 17 to 20 digit run ``, and a JSON parse error's quote of the file is dropped. The startup log goes through the same redactor. A path starts at the beginning of the text or after a space, an opening bracket, a quote, `=`, `,`, `<` or `:`, so `file:/x` and `file:///x` are redacted and `https://host/x` is not. Known gap: a path stops at the first space, so a path containing a space is redacted only up to that space and the rest shows. A bare `~` or `~user/` is not treated as a path, and neither is a relative path. ## Spec and docs editor (row 148, #1642) The Docs page (`#/docs`) lists, for each project of the business, spec/north-star.md and the Markdown files directly under docs/. A file opens at `#/docs//` with its current commit. The page is a `