The companion phone client is a separate app in a separate repo —
receptron/mulmoserver, deployed at
https://mulmoserver.web.app. It talks to MulmoTerminal over Firestore command docs, not HTTP,
so nothing in README.md's /api/* table describes it.
This page is the contract between the two: every command the host answers, the shapes it
answers with, and the rules that decide what belongs on which side. Written because three
features in a row (#830, #831, #832) each had to re-derive them by reading handlers.ts.
Adding or changing a command? Update this page in the same commit, and file the matching issue on
receptron/mulmoserver— the phone ignores a field it was never taught about, so a host-only change ships as silence rather than as a feature.
@mulmoclaude/core/remote-host/server owns it. The host signs in with the user's Firebase
account, subscribes to a per-user command channel, and answers each doc it sees.
HOST_ID = "mulmoterminal"(server/backends/remoteHost/index.ts) — distinct from MulmoClaude's, so both can be registered to one account.- The subscription is wrapped in
resilientRunner(#823): core gives up on its listener permanently, so the wrapper re-subscribes and gives up on TIME rather than a retry count.GET /api/remote-host/statusreportshealth(online/reconnecting/offline). - A handler that throws turns into the phone's error message. That is the intended way to refuse — the phone shows the sentence, so write it for a person.
Handlers live in server/backends/remoteHost/handlers.ts; the terminal ones are grouped in
terminalScreenHandlers.
| Command | Params | Answers |
|---|---|---|
listTerminalSessions |
— | { sessions: TerminalSessionSummary[] } |
getTerminalScreen |
sessionId |
SessionScreen |
sendTerminalInput |
sessionId, text |
{ sent: true } |
launchTerminal |
agent, sessionId |
{ ok: true } |
startChat |
message, attachments? |
{ started: true, chatId } |
listFeeds |
— | { feeds } |
getFeed |
slug, offset?, limit? |
the feed page |
listCollections |
— | { collections } (feed-backed ones excluded) |
getCollection |
slug, offset?, limit? |
one page of the collection's items |
listShortcuts |
— | { shortcuts } |
listSkills |
— | { skills } (collection slugs excluded) |
listAccountingBooks |
— | { books: { id, name }[] } |
getRemoteView |
slug, viewId, locale? |
{ view, srcdoc, bytes } |
getRemoteViewItems |
slug, viewId, offset?, limit?, fields? |
{ page, inlined, omitted } |
mutateRemoteViewItem |
slug, viewId, op, id, patch? |
{ op, item } / { op, id } |
The three *RemoteView* commands serve a collection's mobile custom views. The host wraps
each view into its sandboxed srcdoc (CSP + postMessage bootstrap) and the phone renders that
verbatim, so the view's own code never runs with the phone's privileges. A write is authorized
host-side against the view's declared surface (editableFields / allowDelete) — the
sandboxed view is not trusted to police itself. mutateRemoteViewItem answers
{ applied: true, warning } rather than throwing when the write landed but its response blew
the byte budget, so a successful edit is never shown as a failure (#747).
Image fields are not inlined by this host (there is no thumbnail store yet): they come back
as workspace paths, unrenderable on the phone, and count toward omitted.
{ id: string; title: string; cwd: string; live: boolean; agent: "claude" | "codex" | "shell" | null }live: false means the session exists only in tmux — it outlived a restart. Still viewable
(capture-pane doesn't need our process) but not writable, and agent is then null
because the process that knew what it was launched with is gone.
The list is filtered to grid cells: the single-view chat session and any tmux shell that was never a cell are excluded, even while live.
{
screen: string; // the rendered terminal, trailing blank lines trimmed
suggestion: string; // the agent's dim ghost text, "" when none (#563)
quickCommands: { label, text }[]; // the user's saved phrases for THIS session (#830)
cwd?: string; // ─┐
branch?: string; // │ absent when the host cannot answer —
summary?: string; // │ see "absent vs empty" below (#786)
prompt?: string; // │
githubUrl?: string; // ─┘ the repository ROOT, never /tree/<branch> (#832)
}SessionScreen is the wire shape. A field added to it reaches the phone without touching
handlers.ts — that is stated in the handler's own comment and is the intended extension
point. Adding a command is the exception, not the rule.
The host decides; the phone renders. quickCommands is filtered to the session's kind
host-side and the agents scope is stripped, so the phone needs no notion of session kinds.
Same shape as githubUrl: the phone's whole rule is "render it if it's there."
Absent vs empty is load-bearing. definedScreenMeta drops a meta field the host can't
answer, key and all: a Firestore command doc rejects undefined, and the phone renders each
field it receives as its own labelled row, so "" would read as "this session has no branch"
rather than "not known". It calls .trim() on every value, so only strings may live in
SessionScreenMeta — quickCommands is an array and sits on SessionScreen directly,
always present, [] when nothing applies.
The phone never sends a path. launchTerminal takes a session id and the host looks the
directory up (ptys.get(id)?.cwd). A path parameter would let a remote client choose where a
process starts. Apply this to anything new that touches the filesystem.
Authorization is the connected Firebase account, with no per-command gate.
sendTerminalInput already types arbitrary text into a Claude session, which can run anything,
so a command that acts within a session the phone can already drive is not an escalation. A
command that reaches outside one would be, and needs its own thinking.
The grid is browser state. markDevTerminalSession() is reached only from
server/routes/ws-routes.ts — "the single choke point for every grid attach" — so the host
cannot open a cell itself. It publishes to the launch-terminal pub/sub channel and a
connected browser opens it through the ordinary path.
Consequences the phone has to live with:
- A MulmoTerminal tab must be open on the desktop. With none subscribed the host refuses
with
no MulmoTerminal browser is open — the grid opens the terminal, so a tab must be connected. It does not fail silently. - Delivery goes to one subscriber (
publishToOne), not the room: a broadcast would open a terminal per open tab. agentis"shell" | "claude" | "codex"(common/launchAgent.ts) — deliberately not the user's configuredlaunchers, which are arbitrary commands.
| Concern | File |
|---|---|
| Command handlers | server/backends/remoteHost/handlers.ts |
| Session list + screen assembly | server/backends/remoteHost/terminalScreen.ts |
| Typing into a session (sanitize, bracketed paste, Enter timing) | server/backends/remoteHost/terminalInput.ts |
| Quick-command scoping | server/backends/remoteHost/quickCommands.ts |
| Launch validation | server/backends/remoteHost/launchTerminal.ts |
| Reconnect + health | server/backends/remoteHost/resilientRunner.ts, healthNotice.ts |
| Wiring (PTY table, pub/sub, config) | server/index.ts |
| Shared with the UI | common/sessionAgent.ts, common/quickCommands.ts, common/launchAgent.ts |
docs/spawn-architecture.md (how a session is spawned), docs/terminal-notes.md (the terminal
stack). Issues: #435, #445, #563, #572, #781, #786, #823, #830, #831, #832.