The command registry in packages/protocol/src/commands/index.ts carries an
exposure record per row. exposure.commandMenu decides whether the ⌘K command
menu lists that command; exposure.agent decides whether the session thread holds
it as an app_* tool. This page is the row-by-row inventory behind both flags:
every registered command, its verdict, and why.
Neither flag is derived from a blanket rule; both are decided per command. The
third consumer of the same table, the dispatch map, is covered by
surfacing and routing.
The menu invokes a selected command with no input and shows no result — a
boolean flag has no input channel, and the dialog has no result surface. So a
command earns commandMenu: true only when all four hold:
Its schema accepts {}. Nothing required that the menu cannot supply.
19 of the 114 commands pass this; the rest need a review, session, project,
span, host, or path the menu has no way to name. A protocol test asserts the
invariant, so an exposed row can never be one that only fails.
It is an action, not a read the UI already drives.settings.get,
session.list, board.read, harness.hosts, daemon.status and friends are
wire the surfaces fetch for themselves. Running one from the menu changes
nothing a reader would see.
Its output is not the point.github.connectStart mints a device code and
pairing.mint mints a pairing code; both must be displayed, and the menu would
throw them away.
It means something outside the surface that owns it.github.connectCancel only makes sense mid-device-flow.
Under-exposure is honest. A menu entry that appears to run and visibly does
nothing is a broken row, so a command stays false whenever the verdict is
uncertain. Widening the set means giving the menu a way to supply context and show
a result — new UI, deliberately not built.
No raw protocol command is exposed today — no row carries commandMenu: true. Command
mode leads with app actions, settings, projects, and sessions.
One of those app actions dispatches a protocol command directly rather than opening a
dialog: Replay the first-run welcome runs settings.resetWelcome. It is authored in
packages/app-ui/src/shell/command-menu-entries.ts with a readable title, because the
registry’s label is the command id (#465) and an id makes a poor menu row. The dispatch
still goes through the one seam; only the label is hand-written.
The path comes from the in-app directory browser; an empty input reaches the daemon’s fallback chooser, not the user’s pick.
repository.identify
The headless rennet review CLI’s checkout-to-owner/name resolver (D11). It needs a host path the menu cannot name, and it is a CLI-internal read, not agent-exposed either.
Mints a device code that must be displayed; the menu discards output.
github.connectPoll
Poll wire inside the connect flow.
github.connectCancel
Only meaningful mid-device-flow, which the connect card owns.
github.setToken
Needs the pasted token.
github.disconnect
Removes only Rennet’s fallback credential. Settings → Environments → GitHub account exposes it after proving the live source is the fallback; a context-free command could visibly do nothing while gh remains connected.
Read the settings surface drives for a project and repository.
settings.setAppearance
Needs the scheme being set.
settings.setKeybinding
Needs the command id and the chord.
settings.setCoachmarks
Needs the coach-mark state being written.
settings.setBenchmarkRecording
Needs the on/off state being written; the toggle owns it.
settings.setRoleAssignment
Needs the role and its assignment.
settings.setRepoVisibility
Needs the repository and the visibility.
settings.resetRepoValue
Needs the repository and the key.
settings.pinRepoValue
Needs the repository, key, and value.
settings.setProjectValue
Needs the project, key, and value.
settings.setGuidance
Needs the guidance text and its scope.
settings.setThemePack
Needs the pack being selected.
settings.setLastProject
Needs the project being remembered; the app writes it as you navigate.
settings.completeWelcome
Only means something inside the welcome wizard, whose Ready step runs it.
settings.resetWelcome
Reachable from the menu, but as the Replay the first-run welcome action row, not a raw registry row: the registry label is the command id, and settings.resetWelcome is not a thing to read in a menu.
Read the run view drives; needs the review it belongs to.
session.list
Read the sidebar drives.
session.mint
Needs the project, and the branch or pull request being claimed — New Chat’s picker is where a reviewer names them. A parameterless mint could only guess a target, and guessing one would claim it.
session.cancelPreparation
Needs the session whose active capture or board generation should stop.
session.retryPreparation
Needs the failed or cancelled session and a fresh command id.
session.rename
Needs the session and the new title.
session.setPinned
Needs the session and the pin state.
session.archive
Needs the session being archived or restored.
session.landWorkBranch
Needs the session whose work branch is being landed; the action is offered on that session’s round card, beside the branch it fast-forwards.
session.workBranchState
Read the review workspace drives beside the session’s branch; needs the session whose work branch is being measured. It answers how far the sibling is ahead of the branch, how far the branch is behind the remote-tracking ref a push updated, and whether the branch already contains the work — all read from git at request time, so running it from the menu would change nothing a reader would see.
Read the Settings → Projects → Worktrees card drives; needs the repository path whose workspaces it lists.
worktrees.remove
Needs the repository and the opaque id of the row being removed, which only a worktrees.list answer carries. A parameterless removal could only pick a workspace for the reviewer.
exposure.agent decides whether a command reaches the review’s session thread as an
app_* tool. The daemon stands up one loopback MCP listener, rennet_app, whose
tool list is buildAppTools(dispatch) — a projection of this flag and never a
hand-kept list, so flipping a row here adds or removes a tool with no other edit.
The thread is the reviewer’s conversation inside Rennet. It already has everything
the harness has: the checkout, a shell, the file tools. What it does not have is the
review — which patchset, what the boards concluded, what is staged, what rounds
ran. So a row earns agent: true when it answers one of two questions:
What is the reviewer looking at? The reads below turn a sentence like “the
Decisions board on the auth branch” into the session, the review and the board it
names. A workspace maps many repositories onto one identity and that mapping is
not invertible, so the thread lists sessions and loads one rather than assuming
the session it is bound to.
Which path does Rennet track? A staged ask carries a receipt, lands in the
reviewer’s composer, and goes out through the exits the reviewer clicks. The acts
below are those paths. The briefing steers the thread toward them; it forbids the
thread nothing (Rule Zero), and an unexposed row is a tool the thread does not
have, never a capability it is denied — it keeps its shell either way.
A row stays out when the act belongs to the reviewer’s own hand. Every publish.*
row is out because nothing another human can see gets published without Rai clicking
post. review.regenerate and review.refine are out because they are model spend the
reviewer commissions. ask.retire, ask.restore and ask.dismissFinding are out
because they are the reviewer’s verdict on a finding, not the thread’s.
Which reviews exist. The thread resolves “the auth branch” to a session id from this list instead of guessing from the one it is bound to.
review.load
The review behind a session id: its patchset, branch, generations and repository presence. A pure read that appends no event.
board.read
What a lens concluded. The boards on disk are event logs; this is the readable projection, and it is how the thread answers a question about a finding instead of re-deriving one from the diff.
patchset.readSpan
The cited lines of a board span, served from the captured patchset — so an answer quotes what was reviewed, never a working tree that has moved on.
patchset.readEvidence
The hunks, reviewed source and test counterparts behind a citation, for a question the span alone cannot answer.
ask.read
What is already staged, retired and commented. Without it the thread re-raises an ask the reviewer already has.
session.rounds
Which coding rounds ran on this review and what they landed.
session.transcript
The rounds’ own turns, for a question about what a round actually did.
review.deltaDigest
What moved between generations, in the successor account’s own words.
review.symbolLookup
An identifier’s definition and reference sites from the review’s model-free symbolic surface. Deterministic, no model spend.
A tool result is billed like a prompt and re-read on every remaining round trip of
the turn, so every result that carries a collection declares a cap, an honest marker
naming how many were elided, and a cursor to ask for the next page:
board.read at 200 elements, session.list at 50 sessions, session.transcript at
100 rows, and patchset.readEvidence at 16 kB of patch text. These four caps run
first, when they apply.
Behind them, every one of the 29 exposed commands answers to one further, universal
ceiling: the COMPLETE serialised result of any app_* call — paged or not — is
bounded to APP_TOOL_RESULT_MAX_BYTES (8 kB). Under it, a result rides unchanged;
over it, the full result (its page and marker included, when it has them) is
written to a file and the reply becomes an honest envelope naming what happened:
"head": "…", "note": "Result exceeded the inline ceiling; read the file at `path` with your own tools." }
The file lands under the calling session’s own context directory
(.rennet/context/<sessionId>/tool-results/) when the thread’s session and bound
workspace root both resolve — the same directory a turn’s prompt already names, so
it is swept the same way when the session archives. When neither resolves yet (a
call before any session is bound, or a bare test harness), the file falls back to a
plain tool-results/ directory under the daemon’s state dir, swept by age (24
hours) at daemon start instead, since it carries no session lifecycle to piggyback
on.
The per-command caps exist because paging a large collection into several
readable pages is more useful to a model than one opaque spill file would be; the
universal ceiling exists because a per-command cap is easy to add to four commands
and easy to forget on the other twenty-five — ask.read on a 400-ask projection
reproduced at 144,611 B with no cap of its own before this ceiling existed. Both
are executable — packages/server/src/board/board-tool-surface.measure.test.ts
iterates every exposed command against large fixtures, asserts each per-call result
is at or under the universal ceiling, and asserts the spilled file (when a result
spills) holds the complete, untruncated result.
Edit MENU_EXPOSED or AGENT_EXPOSED in
packages/protocol/src/commands/index.ts, mirror the change in MENU_INVENTORY or
AGENT_INVENTORY in commands.test.ts, and add the row and its rationale here.
Three protocol tests hold the boundary: menu exposure equals the menu inventory
exactly, agent exposure equals the agent inventory exactly, and every menu-exposed
row’s schema accepts an empty input. A new agent row that carries a collection in
its result and would benefit from its own page-and-cursor shape earns one, plus a
row in the measurement test above — but every row, capped or not, already answers to
the universal ceiling with no further wiring needed.