Review "changes 8" fix round for org-redact6. - launcher: a harness module's throw names its system code or class, never its message; preflight, loader, bundle, checkout, sandbox and route text is relayed with host paths replaced (relay()). - claude-code and pi preflights name MOSAIC_CLAUDE_BIN and the repository's node_modules, never a path; persona and skill bundle errors and sandboxProblem name their file or the sandbox by label. - seat session: thread store, legacy store, overlap, busy lock and newRun refusals name the place under the data root. - discord: unknown keys, binding names and turn ids are described when not plain; approvals, journal and repository read errors name the file and code; git drops stderr lines that carry a host path and a spawn failure names its code only. - seat, control-board: registration fields and seen keys are described when not plain; invalid seat name and layout state the constraint. - organization: the registry names the config directory and registry file by label; --project is quoted only when it is a plain name; systemConfigProblem moves to its own module. - scripts/mosaic-config.mjs: the config by where it came from, the data root and its marker by role, keys and arguments only when plain, and error codes in place of raw messages. Exit codes unchanged. - webui sso-config: an unknown console key is named only when plain. Sentinel tests in each package's path-redaction-labels file fail on BASE; pins that quoted a path now assert the label and its absence.
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 <id> --from <file> |
S6 integration hook |
| Save organization or theme | mosaic org update <id> --from <file> --if-revision <revision> |
S6 integration hook |
| Initialize organization checkout | mosaic org queue init <id> --local-only --accept-local-loss-risk or --remote <url> |
S6 integration hook |
| Initialize project Git | mosaic org project repo init <id> <project> --local-only --accept-local-loss-risk or --remote <url> |
S6 integration hook |
| Recover a stopped bus with stale host state | mosaic bus recover <id> |
CLI only |
| Inspect or clear a dead lifecycle lock | mosaic org lock show <id> / mosaic org lock clear <id> |
CLI only |
| Start, stop or restart a bus | mosaic bus start <id> / mosaic bus stop <id> / mosaic bus restart <id> |
S6 integration hook |
| Restart (or first start) an organization's bus | mosaic bus restart <id>, or mosaic bus start <id> 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: Settings > Console; the owner only; trailed. Other Consoles answer not-supervised |
| Create chat project linked to a configured repository | mosaic project create <name> --repo <project> --org <id> |
Enabled |
| Create a thread | mosaic thread create <project> <name> --org <id> |
Planned enabled |
| Open a thread | mosaic thread open <thread> --org <id> |
Planned enabled |
| Send a thread message | mosaic talk --thread <thread> <text> --org <id> |
Planned enabled |
| Start the onboarding interview (F2) | mosaic thread create <chat-project> "Onboarding interview" --instance <assistant> --org <id>, then mosaic talk --thread <thread> "$(mosaic project interview <project>)" --org <id> |
Enabled through the thread create and send routes above |
| Approve the north-star draft (F2) | mosaic project north-star <project> --org <id> --approve <draft-commit> --base <spec-commit> |
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 <role> <text> (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 <id> <choice> --org <id> |
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
<dataRoot>/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:
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 <business>).
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)
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 requiredbefore any route runs, so no bus read or board call is made. An Origin, when sent, must behttps://NAME. - A request whose Host is loopback on the Console's port (
127.x,localhostor[::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: NAMEwould otherwise skip the sign-in check. - Every method other than GET and HEAD answers 403
read-only, whatever the Host./api/seenand/api/replynever reach the board, so Seen and Reply fail in the page with that refusal. The one exception is POST/api/bus/messagewhen 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,
localhostincluded, is the operator's path and skips the header check, so a proxy that forwarded a client'sHost: 127.0.0.1:7332would 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_errorswith anerror_page, for example). Under--sendthe page reads a 4xx other than 408 as "not sent", so a proxy that turned the Console's 503outcome-unknowninto 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.
The single update and rollback sequence is 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
<config dir>/secrets/mosaic-stack/<slot>. 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--checkoutdirectory 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/loginat 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./healthzaddssso: { <id>: "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 403public Host required, exceptGET /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 401sign-in requiredand a page load gets a 303 to/auth/login?return=<path>. 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 anAuthorizationheader, a__Host-mosaic_sessionor__Host-mosaic_logincookie, or anx-authentik-*header (403tester listener takes no sign-in). It never serves/auth/*.
Message a role (row 76, #1570)
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 403cross-origin request refused. Sec-Fetch-Siteis sent once and is exactlysame-origin, else 403cross-site request refused. Browsers set it, and page script can't.- Content-Type is
application/jsonand the body is at most 16384 bytes and a JSON object withtoandbody, optionallyin_reply_to, and no other key. Anything else is 400invalid-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=<message id> or error=<code>. 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/<id>: 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 0returns 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.receivereturns 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
waitingOnYouflag 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
deliveredreceipt, 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
themeplugins (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 savedappearanceapplies. 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/<decision>, #/messages, #/tasks,
#/tasks/<vikunja:project/task>, #/agents, #/trail/task/<ref>,
#/trail/decision/<id> and #/trail/message/<id> (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,agentsandtrail. 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 showsmosaic decide <id> <key>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, somosaic decideuses the running bus host's business, as the Console does by default. From a Console started with--businessfor 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--ssoand--sendand the page is signed in as aconsole.ssosubject. The first click POSTs/api/decisions/<id>/confirmationand shows what the answer does; only Confirm POSTs/resolvewith that one-use confirmation (two minutes, bound to the session, business, decision, choice and current context digest). The broker supplies authoritativeoptionsand akindofdecisionorgrant; grants also retainapprove,denyandgrant. Ordinary HTTP confirmations carrykind: decision,action: answerand the selected key/text, so the receipt records that choice without claiming a grant started. Each step spawnspackages/bus/src/console-cli.mjs, which the bus verifies as the Console's child holding the signed-in subject, and the bus records the answervia consolewith ahuman.inputnaming 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--ssothe page has no buttons and says why; under--ssowithout--sendit 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 readsGET /api/requests/<request>(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--sendtoo (otherwise the panel reads and says it is read-only;/api/configreportsrequestsandrequestWritesto the owner only). Five owner writes, eachPOSTwith a single same-originOrigin,Sec-Fetch-Site: same-origin,application/json, a bounded body of exactly these fields and a live session, spawningconsole-cli.mjswith 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 isoutcome-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/<id>/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 attributedhuman.inputlinked 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'srequest.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 isrequest-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 insessionStoragebefore 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/<request>/draftrelays the bus'srequest.draft.consolereply (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'ssnapshot.revision, the project's latest snapshot-event counter, never the request revision. The body limit is 4096 bytes. Stale revisions refuse 409request-revision-conflict; a foreign snapshot refuses 403requirement-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 returnscandidate: null,prd: null,requirements: []andsnapshot: {id: null, revision}; revision is zero before the project's first snapshot event. Changed selected PRD/requirement content refuses 409prd-candidate-stale, changed intent refuses 409stale-spec, and a bus whose verb list lacks the draft read refuses 503draft-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
tasksrow'srequirementandpriority(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'squeue-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 examplehuman-requiredfrom 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/<id>, 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.cssandsrc/public/icons.svgare byte copies ofdocs/design/tokens.cssanddocs/design/icons.svg;tests/shell.test.mjsfails if either differs. Editdocs/design, rebuild there, then copy.- Colours come from
tokens.css, selected bydata-paletteanddata-modeon<html>. 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 removesdata-mode, so the OS light or dark setting applies. The default is Harbor in the dark (tests/shell.test.mjsreadsdata-modeunder a dark OS, which cannot tell the two apart; an earlier version of this line said System). The browser's choice is kept undermosaic-console-appearance:<organization id>, or undermosaic-console-appearancewhen the Console shows no organization. shell.cssloads last and holds the restyle: radii--r6px and--r-lg10px, 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.txthas the URL and sha256 of the archive and each file, andjetbrains-mono-OFL.txtthe 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/<name>/;
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/themesanswers{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'sappearance: {theme, mode}, then Harbor in the dark.modeissystem,light,dimordark. A missingappearanceis 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.
#/queuelists every row with its state, owner, reviewers, brief and last update, filterable by state.#/queue/<id>adds the gate, issues, claim,after, note and review rounds. Rows move only withscripts/mosaic queue movein 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.#/businessshows 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 anot-configuredrefusal with the business module's own text.#/settingsshows 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 <config>, the
data root <dataRoot>, any other absolute or ~/ path <path>, a 17 to 20
digit run <id>, 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/<project>/<path> with
its current commit. The page is a <textarea> that holds the Markdown source and a
preview beside it. Save writes exactly what was typed, as one local commit authored by
the business's human; the page then shows the new commit. Nothing is pushed.
Each save carries the commit the editor opened. If the file's last commit has moved on
since, the save refuses stale-base, nothing is written, and the page says to reload the
file and make the edit again. The draft stays in the box. Reload discards the draft and
reads the file again. A draft survives a refresh and a visit to another page; a file
with no unsaved change is read again when it is opened. The 10 s refresh is off on the
editor so it can't touch a draft. A file with a NUL character never opens: the project's
read refuses it (project-read-content), so the page has no draft to show. A file
with CRLF line ends saves with LF, and the page says so. The page does not create files
or open any other path. A tree with pending changes refuses the save (project-dirty).
The preview is built by markdown-preview.js with createElement and textContent
only. It never calls innerHTML, outerHTML, insertAdjacentHTML or document.write
(a test reads the file for all four), so it needs no sanitizer. Headings, paragraphs,
lists, block quotes, code blocks, inline code, emphasis and links render. Everything
else, raw HTML included, shows as the source text. A link is made only for an http:,
https: or relative target, with rel="noopener noreferrer", and the target is shown
beside the link text. A target with a space, a control or bidirectional character, a
backslash or a leading // stays text. An image shows its alt text and URL and is
never loaded. Each render has a look-ahead budget of 32 characters examined per
character of text (at least 1 Mi): an opener with no closer searches at most 2000
characters, and the budget caps the total over a whole file. Once it is spent, every
later link, image, code span and emphasis shows as its source text, so a hostile file
can slow the editor by a fixed multiple of its length and no more. The preview vendors
nothing and the Content-Security-Policy is unchanged; the choice against a bundled editor
is recorded in agents/researcher/work/queue-148/LIBRARY.md. Row 154 later added one,
for the page's default view; this textarea and preview are now its Source view (see
"Visual editor").
Routes: GET /api/project-files?project= and GET /api/project-file?project=&path=
run the human transport like the other reads (400 for a bad query, 503 with no bus, 502
for an unusable answer). POST /api/project-file exists only under --send and has the
Settings boundary: the signed-in user's header, the exact https://<public host>
Origin, Sec-Fetch-Site: same-origin, a JSON body of at most 1 MiB with exactly the
string keys project, path, base and content. The 16 KiB limit of the message
route is unchanged. GET /api/config now also returns edit, true when saving is
available; without it the source is read-only and Save is off. An answer is
{path, commit, at}, a refusal {error, message, sent:false}, and an answer that is
lost or unusable is sent:"unknown": the page keeps the draft, says the save may have
gone through, and sends nothing again. The log line names the project, path and
outcome, never the content.
Tests: project-file.test.mjs (the routes), markdown-preview.test.mjs (the sink
check, each element, the scheme rules, the look-ahead budget) and docs-browser.test.mjs
(markdown with <script>, onerror and javascript: runs nothing; the draft, each
save receipt, the stale-base message, sign-out and read-only mode). Those tests open the
Source view first (source() in the test file); their assertions are unchanged.
Visual editor (row 154, #1648, Q168)
A file opens in a visual editor (ProseMirror) when the editor can write the file's own
bytes back with no edit; otherwise it opens in the Source view with a note. Visual and
Source are two buttons above the page. They edit one draft: a change in either shows in
the other, and the read, save, stale-base and read-only rules above are the same for
both. The editor is a view over the same {project, path, base, content} save.
The editor is built from reviewable source, not a prebuilt bundle:
third_party/editor/is unmodified upstream TypeScript source of 19 packages (ProseMirror, markdown-it and their dependencies), copied byte for byte from tagged trees. Each package keeps its own license file andpackage.jsonbeside its source;NOTICElists them, andPROVENANCE.jsonrecords the origin, tag, commit, upstream tree and the git blob and sha256 of every file, the tree hash, andlocalChanges(empty).agents/researcher/work/queue-154/repro/import-sources.mjsmakes the directory.editor/is the editor's own code:model.mjs(schema, parse, serialise),view.mjs(toolbar, link bar, keys),entry.mjs,editor.cssandbuild.mjs.node editor/build.mjs(npm run build:editor) bundles them with esbuild 0.28.2, asserted exact, intosrc/public/editor.jsandeditor.css, one unminified script with no source map, and writeseditor/BUILD.jsonwith the inputs and the sha256 of each output. Two builds from different absolute paths give identical bytes. The built files are committed, so a pinned copy needs no build step and no esbuild.
Rules the editor keeps from the preview: no raw HTML runs (it is kept as source text in a
tinted block), a link is made only for a target window.MosaicMarkdown.linkTarget
accepts (http:, https:, relative) and gets rel="noopener noreferrer", and an image
is text ([image: alt] url), never an <img>, on the load path and on paste. The editor
adds no innerHTML-family call (a test reads editor/*.mjs for them; the bundle's
upstream code uses the DOM APIs and is not scanned) and no eval. The Content-Security-
Policy is unchanged: editor.js and editor.css are served from 'self'.
Open and save with no edit writes the same bytes: the editor keeps each top-level block's
source and writes an untouched block back as it was. A block you change is written in
the editor's own form (- bullets, soft breaks as new lines, reference links as inline
links, < and & escaped). Tables, HTML blocks, front matter, indented code, setext
headings, strikethrough, link reference definitions, and a block that holds a link target
the gate refuses are kept as source blocks and not edited visually. Known limits: CRLF
saves as LF (as in row 148); an unedited file keeps Save off; an empty paragraph is
dropped when its neighbour is edited; copying an image inside the editor does not keep
it; two adjacent lists may merge when the file is read again; a redraw or a switch
between Visual and Source starts a new undo history.
Routes: GET /editor.js and GET /editor.css, static like the other public files.
Tests: editor.test.mjs (routes, the round trip over the goals review, a document with
every element and every repository doc, the source rules), editor-provenance.test.mjs
(the imported tree against PROVENANCE.json and its recorded local changes, license
files, the build record, and a rebuild that skips with a reason when esbuild 0.28.2 is
not installed) and editor-browser.test.mjs (hostile content on the load and the paste
path, the Console's CSP, edit and save, the link bar, read-only).
Assistant settings (row 155, #1649)
Jason's ruling Q169 lets the human configure the assistant and its seat name from the
Console. Settings shows an Assistant section with one form per assistant instance, the
ones the Settings read lists under assistants:
- the seat name, read through
/api/bus/names, with Save name. If the read fails the section says the current name could not be read, and the field starts empty; definition,varsandtrackeras text, the last two as JSON, with Save assistant settings. It sends only the fields that changed, under the revision the page read, as POST/api/settingswithassistants(the business README's "Assistant settings"). An empty tracker sendsnull, which removes it.varsandtrackerthat aren't JSON objects are refused in the page and nothing is sent;- Stop its runs, which ends every run of that assistant in the threads the human made.
Without --send every control is off. A refusal shows the bus's message and keeps the
draft; a lost answer says the change may have gone through. Nothing is sent again on its
own.
Routes: POST /api/name with exactly the string keys instance and name calls
name.set, and POST /api/assistant-stop with exactly instance calls assistant.stop.
Both exist only under --send, go through the human transport, and have the Settings
boundary: the signed-in user's header (401 without it), the exact https://<public host>
Origin and Sec-Fetch-Site: same-origin (403), and a JSON object of at most 4 KiB (400).
Any other method is 405. An answer is the bus's result with at. A bus refusal is
{error, message, sent:false} with the code's status, not-held and name-taken 409 and
human-required 403. A lost or unusable answer is 503 with sent:"unknown". The log line
names the route, the user and the instance, and the outcome: done or the code. A Console
started inside an agent run gets human-required from the broker.
Role instances (row 165, #1662)
Q171 (REQ-ROLE-3) lets the human add a role instance and change one's definition, vars and
launch membership from the Console. Settings shows a Role instances section, read through
/api/bus/instances: a table of each instance with its definition, whether it is launched
(or is the launcher) and its vars, then two forms.
- Add an instance: a name, a definition from the ones the writer accepts, vars as JSON and, when the file has a launch block, a Launch it box.
- Change an instance: any instance except an assistant, which changes in the Assistant section. The definition, the vars (replacing them whole) and launch can change; a field left as Keep or empty is not sent.
Definitions that declare credentials are not offered. The section names them, with their
services, as hand edits of the business file, because the Console binds no credentials.
Each form sends one POST /api/instances under the revision the page read. When the answer
says a bus restart is needed, the receipt says so and that the Console does not restart it;
the page has no restart control. Without --send every control is off. A
refusal shows the code and the reason and keeps the draft; a lost answer says to read the
instances again. Nothing is sent again on its own.
Route: POST /api/instances exists only under --send and calls the human-only
instances.set through the human transport. It has the Settings boundary: the signed-in
user's header (401 without it), the exact https://<public host> Origin and
Sec-Fetch-Site: same-origin (403), and a JSON object of at most 16 KiB (400). It takes
exactly revision (64 hex), instance, op (add or change) and any of definition,
vars (an object) and launch (a boolean); any other key, holder, credentials and
tracker included, is 400 before the bus. Any other method is 405. An answer is the bus's
result with at. The bus sends a refusal's code alone, so the route adds the reason:
{error, message, sent:false}, where the message is the code, the reason and "Nothing
changed." revision-conflict and organization-instance-exists are 409,
human-required 403 and the other refusals 400. A lost or unusable answer is 503 with
sent:"unknown". The log line names the user, the instance and the outcome (done restart=true|false or the code), never the vars.
Routing (row 65, #1558, mos-0840)
Settings shows a Routing section, read through /api/bus/routes: a table
with one row per instance in the business file and six selects, harness,
model, thinking, fallback harness, fallback model and fallback thinking.
It replaces the Agent limits form's old Role models fieldset, which set
only the model. The harness cells offer the harnesses plugins.harness
switches on, the model cells the launch.models keys, and each thinking
cell the levels its row's harness applies. A value the file holds that
isn't offered stays selected and says why: "(not switched on)", "(not in
the model table)" or "(not applied by )". A harness change redraws
the row, so its thinking cell offers the new harness's levels. With no
harness switched on, the section says every launch refuses harness-off
and that mosaic plugins set switches one on; the page doesn't change
plugins.
A fallback is set or cleared as a whole. Save sends one POST /api/routes
under the revision the page read, with only the changed rows. A fallback
whose three cells are none is sent as null, which clears it; one with
some cells set goes as it is, and the bus refuses it as partial. A
submit with nothing changed says "Nothing changed." and sends nothing. A
save says which instances it saved and that the next launch of each uses
the new route; running sessions keep theirs. A refusal shows the code and
the reason and keeps the draft. A lost answer says the outcome is unknown
and to read the routes again. "Read current routes and discard draft"
drops the draft. Without --send every select and Save are off. Nothing
is sent again on its own.
Routes:
- GET
/api/bus/routescalls the human-onlyroutesthrough the human transport. A query string is 400, no bus 503, and an answer without a 64-hex revision, theharnessesandmodelslists and thelevelsandinstancesobjects 502. - POST
/api/routesexists only under--sendand calls the human-onlyroutes.set. It has the Settings boundary: the signed-in user's header (401 without it), the exacthttps://<public host>Origin andSec-Fetch-Site: same-origin(403), and a JSON object of at most 16 KiB (400). It takes exactlyrevision(64 hex) and a non-emptyroutesobject; any other key is 400 before the bus. Any other method is 405. The bus sends a refusal's code alone, so the route adds the reason:{error, message, sent:false}, where the message is the code, the reason and "Nothing changed."revision-conflictis 409,human-required403, andharness-off,harness-refused,thinking-unsupported,unknown-model,organization-partial-fallback,organization-unknown-instance,organization-no-changeandinvalid-request400. A lost or unusable answer is 503 withsent:"unknown". The log line names the user, the instances and the outcome (doneor the code), never the routes.
Neither route is in organization.set's keys, so the chief of staff can't
reach them (packages/organization/README.md, "Routes").
Data and boundaries
GET /api/board, /api/conversations and /api/conversation, and POST
/api/seen and /api/reply proxy only those board paths. Only the two
conversation routes carry their query string; the board validates it. POST
bytes and upstream status/JSON are preserved. GET /api/config returns the
configured board URL for the page's error message. No scanner, registration,
session reader or transport is implemented here. The session reader is
packages/conversation, served by the board.
GET /api/bus/inbox, /api/bus/tasks, /api/bus/agents,
/api/bus/trail?subject=<id>, /api/bus/decisions?list=roles|recent,
/api/bus/credentials, /api/bus/names (row 155, each instance's seat name),
/api/bus/instances (row 165, the role instances and the definitions add accepts),
/api/bus/replies?id=<message id> and
/api/bus/messages?limit=50&after=<message id> are the bus
reads (src/bus.mjs). Any other method is 405. The subject and the id must
match the broker's identifier rule, and the list must be roles or
recent, else 400. replies (row 81, #1575) is the messages whose
in_reply_to is that id, for the Sent receipt; the bus refuses an id that
names no message (message-not-found, 400). decisions (row 57, #1546) is for the Inbox tabs "Routed to roles" and
"Recent"; "Human and reader paths" in packages/bus/README.md has what each
list holds. credentials (row 60, #1549) is the bus host's credential
status: service, account, role, date and state for each credential it
loaded, never a token, file path or variable name. No page uses it yet; the
Business view still shows the business file's metadata.
Each read runs the S4 human transport (packages/bus/src/human-cli.mjs) as
a child process, so a slow read doesn't stall the server. The bus checks
that child's ancestry, which ends at the Console process; it never sees the
browser or any other client of the port. So while the Console runs,
anything that can connect to its port (7330 by default) reads what a reader
capability reads ("Human and reader paths" in packages/bus/README.md):
the inbox, tasks, agents, trails, the two decision lists and the credential
status. That includes
a T3 seat using curl and a managed S6 session, since S6 doesn't confine
the network. Without --send no route
writes (Q4), so the exposure is reads only; with it, see "Message a role". Slice 1 doesn't change this
(Filbert F1 on #1522). Answers are { rows, at }; failures are { error, message }
with 403 for a refusal, 503 for no bus or no answer, and 502 for an answer
that can't be used. The bus needs the system config
(~/.config/mosaic-dev/config.json); without it the board still serves and
the bus routes answer not-configured.
GET /api/queue, /api/queue/<id>, /api/business and /api/settings are
row 54's reads (src/reads.mjs), under the same Host, Origin and CSP rules.
Any other method is 405, and a queue id that is not a positive integer is
400. The queue is read in a child process (src/queue-read.mjs) through
rows() from packages/queue/src/store.mjs (lead decision 83): the same
read as queue list, with no write and no network. Like list, it takes
the queue lock only to recheck a disagreement before reporting it. The child
imports the store inside its try, so a store that fails to load reports
"the queue read failed". Only the store's exit 2 (invalid data or refused)
forwards the child's text, as queue-refused; any other exit is the fixed
read-failed message "the queue read failed (exit N)", because that stderr
may be Node's own, with a file:// path and a stack. The business
read uses packages/organization in process. No read writes, calls out or adds a
dependency. Answers carry at; failures are { error, message } with 503
for not-configured, no-bus-host, queue-refused and read-failed, 404
for a row that isn't there, 400 for a bad address and 502 otherwise. Settings
answers 200 even without a system config, so appearance always works; its
notifier part then says not-configured.
GET /api/task-fields is row 61's read (#1550): the task field table,
packages/tasks/fields.json, loaded and checked by loadFields() on each
request. It is repository data, so it answers without --business or a
system config. The answer is the table itself, with no at. A table that
fails its check is refused whole with 503 read-failed and the loader's
message, which names the file or the key at fault, never a path. Any other method is 405.
Console's shared CSS, Console CSS, brand.js and local Manrope fonts were copied
unchanged from agents/dewey/work/wui/. Font license and source URLs accompany
the files under src/public/assets/fonts/. live.css contains the live-page
adaptations; original mockups and unrelated pending design work stay untouched.
Unsupported mockup fields and controls are listed in docs/plans/DEFERRED.md.
The source tree is a project filter; no invented workspace registration tree
or mockup session hierarchy is presented as live data.
Verify
node --test packages/webui/tests/
node --test packages/control-board/tests/ packages/seat/tests/ packages/ledger/tests/ packages/mosaic/tests/
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/browser.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/conversation.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/bus-browser.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/shell.test.mjs
node --test packages/webui/tests/reads.test.mjs packages/webui/tests/views.test.mjs
Node's test runner and installed /usr/bin/chromium are required. Set CHROMIUM
to another installed Chromium path. No npm download is needed. The copied CDP
helper starts a separate temporary browser profile and removes it on exit.
Tests use temporary board session/registration files and stub the board's
transport. They never send to real seats or read real session logs.
Browser tests exercise the rendered real board fixture, exact counts, project filtering, keyboard return focus, Seen/Unsee, success/failure receipts, draft and caret preservation, pending-send exclusion, storage denial, stale registration, loading/empty/malformed/unreachable states and hostile text. Contrast is measured on 330 rendered samples across ten palettes and three modes. Layouts are checked at 320, 390, 768, 1440 and 2560px. Horizontal scrolling is intentional within the dense table; the page itself must not overflow.
Conversation tests run the real board routes over temporary repository-layout session files. They cover full-length answers, collapsed tools and thinking, hostile content rendered inert, malformed-line and reconcile markers, forks, and the return flow: a send from the view, a tool call and a delayed result while a draft is typed, a peer message, a 4.5-million-character answer split into continuation parts, and a relaunch mid-turn.
Row 95's tests (board-held.test.mjs) seed an in-process broker with one
claim that has an address and one that has none. They check the held roles
with the machine sessions off by default and no /api/board request, the
toggle on and off and its persistence across reloads, Message addressing
the send form to each role, no action without send, and that every fetch
asked for redirect: 'manual'. The tests record every 10 s poll timer the
page arms, filed by the script that armed it, and assert that none is armed
for the board while the toggle is off, even across Pause and Resume, and that
one is armed once it is on. Then a redirect answer on each of six
requests, the board read (/api/board), the agents read, /api/conversations,
/api/seen, /api/queue and the send: one line, no NetworkError, nothing
stale left, and no further request or new poll timer on a route change,
Refresh, the toggle, a forced Pause click or a poll tick, and the draft kept. In
that test the poll timers fire after 150 ms, so the check spans several
ticks. browser-edge.test.mjs fails in 5 s, and closes the browser and
servers, if the toggle makes no /api/board request. send-browser.test.mjs
also checks the sign-out after its redirect receipt and that a reload works
again. Since the held roles lead the Board,
the shell test's no-scroll check at 1440px uses a 1000px-tall window.
Bus tests (bus.test.mjs, bus-browser.test.mjs) run the routes and the
views against an in-process broker read through its reader session, so every
row has the broker's own shape. humanCall runs against a stub CLI script.
They cover each view, the gap labels, inert hostile text, Copy, the stale
fallback and each failure title, and that no route writes. They never start
the human CLI against a live bus.
Shell tests (shell.test.mjs) check the copies, the font hashes and the kept
strings, and in the browser at 400px: System mode under an emulated dark and
light OS, the empty, stale and refused states of the board and the bus views
(a failed first read drawn empty, a board refusal with the inspector open and a project picked, then a failed
read, a failed session list, and a refusal with a conversation open and where
focus lands after it), not found, the palette, the toast,
keyboard and visible focus, the section strip at 360 and 400px, no sideways
scroll, no script error and no request other than GET. A second test walks
the Board, Inbox, Tasks, Agents and a decision trail through loading (a held
read), normal, a failed read over kept rows, and a refusal by the bus
(human-required) and for want of one (no-bus-host). It then checks that a
refusal empties the palette and the Inbox count, from a view, from the
palette's own reads and from the board page, and that palette labels are text.
Row 54's tests share the seeded fixture in reads-fixture.mjs: a scratch
queue whose row note names an id and token paths shaped like real notes
(absolute, ~/ and after file:; the store refuses < in a note, so the
<…> case is in the redactor test), and a business,
notifier config and Discord binding that hold token files, an environment
variable name, an agent model var holding a path, and guild, channel, bot
and user ids. Row 9's After entry has the {id, when} shape the real queue
stores. reads.test.mjs checks each
route's body and status, the redactor, the credential state, methods and
Origin, and that no body carries any seeded value or the temporary directory;
that a store.mjs with a syntax error, or none, gives the fixed message with
no path; and that the child's timeout, output cap and non-2 exits hold, over a
fake child script.
views.test.mjs walks Queue, a queue row, Business and Settings through the
five states over a stub read, with each refusal code the view can meet, at
400px; checks that a refusal leaves no queue row in the palette, that no view
has a control but Copy and Read again, and that the row page shows the
queue move command, that the 10 s refresh keeps focus on a Settings mirror
select, and that the palette keeps its queue rows after its own inbox and tasks
reads answer; then, over the seeded fixture, that the Arbiters line reads the
business file's object, that neither the page nor
any response it read carries a seeded value, that a missing or invalid
business file shows the module's text, and that the startup log names no
config path.
assistant.test.mjs (row 155) checks both seat routes: the answer and the log line,
--send off, the Host, user, Origin and Fetch Metadata checks, GET, strict keys, the size
and content type, each refusal's status, the lost answer and the names read.
settings-write.test.mjs checks that an assistants patch reaches the human operation
unchanged and that the log names the user. settings-form.test.mjs walks the Assistant
section in a browser: off without --send, Nothing changed, only the changed field sent,
a client-side refusal, a refused post that keeps the draft with no retry, Save name and
Stop.
No root CI workflow is configured for this package. These local tests are not a claim of CI, deployment, live-seat delivery or user acceptance.
User test and rollback
Gate E belongs to Jason on Monday 2026-09-14. Use Console to find who is waiting, open a registered repo seat, reply and see its next answer after a scan. Mark a completion Seen and find it again in Seen. Record any reason to open the board's own page or a repo seat terminal in DEFERRED.md. Pass is Jason's end-of-day say-so, not a test result. Keep #1507 open pending that ruling.
Rollback requires only stopping the WebUI and using the unchanged board at 7331. Revert the scoped WebUI commit to remove it; there is no data migration to undo. Seen and reply actions already taken belong to the board and are not rolled back.
Messages (row 94, #1588)
The Messages page shows every message addressed to the human, newest first,
including unsolicited messages and late replies. Each lists sender, class,
time, read state, literal body and a trail link. It polls at the existing
10-second cadence; pause and refresh work as on the other pages. Older
messages and Newest messages links page through the history. The nav count
is the whole feed's unread count, including messages beyond the visible
page. If that count cannot be read, it is hidden; a bus refusal clears kept
data as on the other pages. Reading this page marks nothing delivered or
read. mosaic follow or mosaic talk --follow prints messages and marks
them read in the terminal. The Console does not consume them.
The messages HTTP read accepts only limit (1–200, default 50) and after (a message id cursor); malformed, duplicate or unknown query keys refuse 400 invalid-request. It returns rows plus total, unread, next and the Console read time at. Older read routes retain their rows/at response. The broker owns business scoping and cursor authorization.
messages.test.mjs exercises the route's paging/refusals and existing
route response shapes, then the page against a temporary broker with
a scheduled poll accelerated in the browser test. It proves literal
content, late unsolicited arrival, pagination, full unread count and
unchanged delivery receipts. No live bus data or service is used.
Projects, requests and files (#797)
Projects opens the project overview, its conversations and project information. A conversation retains its exact session in the URL. The navigation drawer shows context from the current authorized read. Sidebar display preferences stay in this browser. Inbox cards show the recorded decision origin and receipt details.
Request panels show known request state and reviewed delivery evidence. Their Files area and the conversation Files area read only explicitly observed request IDs. Draft workspace files are names only; reviewed delivery files retain their existing open and download controls. Missing or refused reads remain unavailable.
Project information can show a linked repository's configured URL or, when the owner has write access, save a missing HTTPS URL. Saving configures the URL only; it does not verify or publish it. The server rechecks authorization and refuses replacement of an existing URL. Granted users and tester transports do not gain these owner controls.
Chat projects and threads (row 129, #1623)
Open Chat in the section navigation or command palette. The business project catalog is shared; each project's thread list includes only the current human's threads. Create a named project, optionally linked to a configured repository key, then create a named thread. Names are set at creation; rename is not shipped. No default project or thread is created implicitly. A thread has its own URL, draft and conversation. This is separate from the Board's seat conversations.
The routes are #/chat, #/chat/projects/UUID and #/chat/threads/UUID.
The fixed /api/chat/projects, /api/chat/threads and
/api/chat/threads/UUID/messages endpoints list/create projects, list/create
threads and read/send messages. POST /api/chat/threads/UUID/open is the explicit
Resume assistant action. Reading a thread never opens it or acknowledges a
message. Sending uses thread.send with class REQUEST, as the human CLI does.
The owner's archive controls (#797) live in public/project-archive.js and
src/project-archive-http.mjs: a Show archived toggle on the project list,
Archive (with a confirm) on a project, and Unarchive on archived ones, over
GET /api/chat/projects/archived and POST /api/chat/projects/UUID/archive|unarchive,
fenced exactly like the chat writes above.
All four POST operations need --sso --send, the configured public Host, a
valid OIDC session, the Console's HTTPS Origin, same-origin Fetch
Metadata and a strict JSON object within 16 KiB. The broker binds the business
human and enforces ownership. A same-business thread owned by someone else
returns 403 thread-not-owned; a missing/foreign thread returns 404. No browser
owner, business, capability, arbitrary bus verb or filesystem path is accepted.
Read-only mode disables creation, Resume and Send. Chat uses the existing human
transport; a later channel-module conversion has its own brief (sage-0724).
History starts at the oldest message, reads forward 100 at a time and retains a bounded 200-message display window. Read next messages advances older history; a full page means more may exist. Once caught up, polling uses the last id and de-duplicates replies. An accepted receipt confirms the message only, not that the assistant has answered. An uncertain result never retries: read the list/history before explicitly trying again. Drafts are memory-only and thread-scoped; sign-out clears private chat state. Bodies are inert text.
Tester listener
serve --tester-socket serves tester HTTP at <dataRoot>/console/tester.sock.
It creates or checks console/ as 0700 and the socket as 0600, owned by the instance
uid. It refuses symlinks, unsafe modes, existing entries and overlong Unix paths.
It binds a unique temporary name in that parent and renames onto the final path.
Cleanup removes the final path only when its dev/ino still matches. Hostile
same-uid replacement races remain outside the boundary.
There is no tester TCP listener. --tester-port is retired for this copy.
Mos accepted loss of direct browser navigation (mos-1015). Agents use the socket:
node packages/cli/src/tester-http.mjs GET /api/config
curl --unix-socket "$INSTANCE_DATA/console/tester.sock" http://mosaic-tester.invalid/healthz
The helper selects dataRoot from the instance config, uses fixed Host and Origin
http://mosaic-tester.invalid, and adds same-origin Fetch Metadata and JSON for
POST bodies read from stdin. It never retries an uncertain write. Ordinary
browsers cannot navigate this socket. A later authenticated browser relay behind
SSO requires its own row.
The tester calls TESTER_CLI for reads, instance/route/name writes and its own chat.
It rejects sign-in cookies, Authorization, proxy auth/proof headers and owner-only
writes. Its bus calls still require bus.tester exactly true. A tester cannot
become an owner or user. The main listener rejects x-mosaic-tester for every Host.
Credential and committed-project read restrictions remain in force.
In an authorized window, stack-release.sh activate ID BUSINESS --tester-socket
explicitly converts inherited tester TCP flags in both unit and pointer. Without
that flag a new target refuses inherited TCP before stopping any unit. Rollback
to an older target drops tester enablement and reports it, without reopening TCP.
The reviewed window tool must also be used for rollback. Failed activation may
restore the old saved TCP unit, which restores the old security state and must
be reported as such. Builds and tests never change live units, listeners or tunnels.
Proxy proof (row 178)
Preview mode can additionally require --proof-header NAME --proof-file PATH.
Both flags go together and require the three preview flags. The file must be a
regular, non-symlink file owned by the service uid, mode 0600 or 0400, containing
one line of at least 32 base64url characters (A-Z, a-z, 0-9, _, -),
with at most one final LF. Serve reads it once before listening. Only the header
name and file path go on argv; the value is never printed, returned in config,
or logged. Restarting with the new file is how an operator rotates the proof.
For the public Host, exactly one proof header must match before the auth header
is read. Missing, repeated or incorrect proof gets the same 401 proxy proof required and reaches neither bus nor board. The proof header with any other
Host, including the tester listener, gets 403. Without the flags startup says
proxy proof off; with them it says proxy proof on.
webui-service.sh render accepts the pair, checks file metadata and refuses
paths inside the checkout or configured copies directory. It never opens the
file. stack-release.sh carries the pair unchanged across re-pin and rollback.
This is a partial boundary. It blocks a caller that cannot read the file; it cannot block another process running as the Console uid. Separate service uid is Jason's remaining infrastructure decision on #385. No live proof, middleware or unit flags are installed by this code change. Activation is a separately plan-checked Mos window: proxy middleware first, then Console flags and restart; rollback removes flags before middleware. Do not reuse console-allowed-uid, which is an identity rather than a proxy secret.
Instance processing controls
The persistent Stop processing control and halted banner sit outside the
route-specific views. GET /api/processing remains read-only. POST
/api/processing/halt and /api/processing/resume use the existing human writer,
--send, named same-origin checks and bounded strict JSON; a tester cannot
write them. Resume asks for confirmation and submits the displayed generation.
Unknown outcomes stay explicit and no write is retried.
During Q175's read-only interim both buttons show as unavailable and point to
mosaic halt / mosaic resume in the instance CLI. There is no halt-only send
exception or alternate authentication path. Landing the UI and activating it
remain separately gated.
Row 208 (193d): Chat includes a Shared with me list. Shared threads are
read-only, including for the business owner. On your own writable threads,
Share read-only accepts user:ID, the business owner's name, or * for
everyone with coverage; Unshare ends that target's share. A share grants
no project or instance access of its own.
Viewers can read shared threads and their old own threads. Their pages omit creation, composer, resume, share and session mutation controls. Mixed grants apply separately to each project and instance; a collaborator grant elsewhere does not enable writes here. Grant hints refresh on each user chat render and background refresh, while the bus decides every operation from live grants. The existing user notice names the governance records visible to the owner.
Model proxy page (P4, #1828)
The owner's Settings → Model proxy page (/settings/model-proxy) signs in Claude/Codex accounts, accepts write-only
z.ai API keys, sets account priority, and lists client-key IDs. It uses only
/api/proxy/ routes over the owner's human bus transport. The old /proxy bookmark redirects to this page after the same access checks. A missing instance
proxy refuses not-configured; it does not ask for a management key. That key
stays in the bus. No panel HTML or proxy-port forward is served.
The page and routes refuse granted users, tester listeners and loopback Hosts.
Writes also require --send, the Console's own Origin and same-origin Fetch
Metadata. Account display names use a provider label and stable hashed id;
arbitrary upstream labels and emails are not reflected. Key inputs and pasted
callback URLs are cleared when submitted and never saved in browser storage.
Only an ok status completes OAuth; accepted callbacks remain pending. At the
bus's default300-second timeout, status returns error/login-timeout once and
retires the state. Refreshing the page does not restore a pending login.
Usage is reserved on this page for P5; P4 adds no usage source. PIN changes require the offline upgrade acceptance documented in packages/model-proxy.
Archived project routes use the inclusive project read for the name, saved threads and room history. They offer Unarchive in send mode; thread creation, resume, sending and clear controls are unavailable while archived. Archive styling uses only .project-archive-control and .project-archived-filter.
Recovering local process state
On a named instance, mosaic org lock show ID reports the lifecycle lock's
PID, recorded start timestamp and whether that PID is alive. mosaic org lock clear ID removes it only when the PID is provably dead. A recycled live PID,
malformed lock, or uncertain process state requires inspection. Registry locks
have no holder information and cannot be recovered by this command.
mosaic bus recover ID refuses a live matching host, live writer, listening
socket, mismatched lock or uncertain state. It preserves the old host and lock
information in a bus-host/recovery-*.json receipt before removing the stale
state, dead writer lock and inactive socket. It never sends a signal. Retry
mosaic bus start ID after recovery.
mosaic console start and mosaic console stop move a dead or mismatched
Console state to console.json.stale-<UTC timestamp>-<suffix>, report the
move on stderr, and continue. A live matching Console blocks another start.
mosaic console status prints the recorded state (pid, URL, bind, and
allowHosts for a LAN Console) and whether that process still runs. It moves
nothing. --dev-insecure-bind and --dev-allow-host are in docs/TOOLS.md.