CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# Changelog
All notable changes to Reemoat are recorded here.
The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
**Under 0.x the minor is the breaking one.** Before 1.0.0 a minor bump may change
the daemon's HTTP surface, the relay's tunnel protocol version, or the control
plane's schema. Pin a tag. There are deliberately no rolling `0.1` or `0` image
tags, because on a 0.x project those names would mean "may break without warning"
while reading like stability.
**The shape of this file is a contract, not a convention.** `pnpm pincheck`
asserts that the newest released heading is the version in `package.json`, and
`deploy/ci-release.sh` extracts a release's notes by reading from its heading to
the next one. So a released heading is exactly `## [x.y.z] - YYYY-MM-DD`,
`## [Unreleased]` carries no date, and a section ends where the next `##` begins.
One rule that is easy to miss: **no `Q<n>.<m>` citations here.** Everywhere else
in this repository a decision can be cited by number and `pnpm docscheck` proves
it resolves. This file is deliberately outside that corpus — adding it would let a
stale symbol in `docs/DECISIONS.md` "resolve" against prose that merely mentions
it — so a citation here would be the one kind nothing checks.
## [Unreleased]
## [0.13.0] - 2026-10-04
### Added
- **An agent can send you a file.** Ask for a file and the agent puts it in the conversation as a card with its name and size; a tap saves it, and an image is shown in place. It works for a file anywhere on the machine, not only one inside the session's folder, and the agent may send one unasked when what it made for you is a file. The copy is taken when it is sent, so it still opens after the agent changes or deletes the original. Up to 100 MB each; a session keeps the newest hundred. Claude Code, Codex and Grok send without asking permission first, as does Cursor; opencode sends too. Update the app before the machines, or the file is sent and an older app shows only the tool's name.
### Changed
- **Your messages sit in a softer bubble**: lighter, closer to the page, with even corners, as in Claude Code.
- **Agents are no longer handed tools that only work in their own terminal.** Claude Code loses the ones that address its other sessions and its remote control, its feedback and onboarding cards, switching to a worktree of its own (the session's folder is the one Reemoat gave it), and publishing to claude.ai. Codex loses its plugin-install prompt. What each can do here is unchanged, and its context is a little smaller. Scheduling tools are left as they were.
- **Switching agent messaging off no longer takes the Reemoat tools away entirely**: sending you a file is not a message to another agent, so it stays.
- **Subagents are listed under Background tasks**, as Claude Code lists them: a running one in Agents with its kind, its time, its steps and what it is doing now, and a finished one under Completed beside finished shells. Claude now runs most subagents in the background, so they had never appeared there. Its card in the conversation says it is running in the background rather than showing a check mark the moment it started. The line at the foot counts them.
- **Stop appears the moment you send.** A session that had been quiet for a while kept Send on screen, with a spinner, while its agent was brought back, and only then turned into Stop; pressing Stop during that wait now stops the message once the machine has it.
- **An agent's file sends say which file they read**, so a relative path taken from the wrong folder shows where it went, and a file behind a folder the machine will not open is reported as that rather than as missing. Claude Code's permission prompt for sending a file, which appears only where your own Claude settings ask for one, is now left to you.
- **File names in an agent's reply are plain text again**, rather than download buttons. To get a file, ask the agent to send it: it arrives as a card that saves the copy it sent.
- **Claude's effort says what level it really runs at.** The choice that was called "Adaptive" is "Auto", as Claude Code calls it, and shows the level Claude Code itself picks for the current model: "Auto · Medium" on Opus 5.5, "Auto · High" on Fable, "Auto · Xhigh" on Opus 4.7. It follows the model when you switch, and nothing is pinned. The level appears once the machine is updated; until then the choice reads "Auto".
### Fixed
- **A table in an agent's reply squeezed its short columns to a letter per line** whenever another column held longer text. Short columns now keep their words whole, and a column of short values, like sizes or names, keeps each value on one line.
- **After the computer woke from sleep, "Connecting…" could stay on screen while everything worked**, until the next wake. It now clears within seconds of the server answering again. Waking also no longer reconnects the same conversation two or three times over.
- **Stop could leave a session stuck for hours.** When the agent froze and ignored Stop, the turn ended only after half a minute and the next message went to the same frozen agent, which froze again. Now an agent that has not stopped 15 seconds after Stop is restarted where the conversation left off, and waiting messages go to the new one — unless it has work running in the background, which a restart would end. Ending the session in those seconds ends it for good.
- **A message sent right after Stop appeared above "cancelled"** with *Waiting for the agent to finish* under it. It now waits until the stop has gone through and appears after it.
- **Commands a stopped turn never ran still showed as running**, with the spinner on their row. They now show as stopped.
- **The controls under the message box flickered when a quiet session woke up**, showing the agent's own defaults for a moment — Manual, default effort — before your choices came back. They now keep your choices throughout. This needs the machine updated.
- **A file larger than about a megabyte could not be downloaded from a machine reached through the relay.** Pressing a sent file's card, or a changed file's download, did nothing for two minutes and then failed. The file now arrives in pieces, and the card shows a spinner while it does. This needs the machine updated; against an older one the app downloads as before.
- **"working…" was a button into Background tasks in sessions with nothing running in the background.** It is a button only while something is.
- **Stopping a session while it was copying a file the agent sent kept an invisible copy**, and in a session already holding a hundred sent files it could drop one the conversation still showed. Nothing is kept now unless the card appears with it.
- **A file the agent sent from a network folder that stopped answering** was tried again on every request, each one holding a thread; the folder is now remembered as not answering. A request waiting behind another file gives up when it is cancelled, rather than when the other finishes.
- **A long file name could be cut through the middle of an emoji**, leaving the conversation and the stored copy with different names.
## [0.12.0] - 2026-10-01
### Added
- **Cursor is the sixth agent.** It has a tile on New session like Claude Code, runs on the model your Cursor account last chose, and offers its own modes (Agent, Plan, Ask), its models and each model's effort in the strip under the message box. Sign in with a Cursor API key under Settings → Machines, or with the sign-in page it prints; Install fetches it with Cursor's own installer, and the daily refresh keeps it current. Its questions, its plan approval and its to-do list are drawn as the other agents' are, a subagent's steps are nested under the step that started it, and a Cursor session comes back after a restart like any other. Cursor keeps one configuration for your terminal and this app: a model chosen here, or "Allow always" on one of its cards, is remembered in `~/.cursor/cli-config.json` for both. It edits files in the session's folder without asking, and asks before a shell command its allowlist does not name, as it does in a terminal.
- **Cursor can ask you a question with buttons.** Cursor gives its model no way to ask one when an app drives it, so Reemoat hands Cursor sessions a question tool of its own. The question appears as the usual card, and the conversation shows it exactly as it does another agent's: the answered card, with no extra rows. Answer within a minute and the agent carries on in the same turn. Answer later — even after a restart — and it reaches the agent as your next message, which the conversation does not draw a second time. Skip tells the agent to carry on; ✕ dismisses the card. Reemoat allows that tool's own permission request itself, since the card is where you decide.
- **Sessions have nicknames, and `@` names one.** New session gives every session a short nickname — filled in from a list, with a dice to deal another, or your own — and the list shows it as `@mira` under what the session is about. Agents address each other by it. In the message box, `@` lists every session this one's agent can reach, on any of your machines; pick one and the agent is told which session you mean and how to message it, beside your words rather than in them. A nickname is unique on its machine, and sessions from before this release are given one when their daemon restarts. `pnpm client new --nickname` and `pnpm client nickname` set one from a terminal.
- **Agents can message each other, on one machine and across your machines.** Every session can list the others and write to one (`send_message`), whichever harness either runs and whichever of your machines it is on. Every message is acted on: it wakes a session that is idle and reaches one that is working inside its turn, and the answer comes back the same way, so nothing polls. A sender can also ask to be told once if the other session stops without answering. claude sessions no longer carry Claude Code's own session list, which never showed any of these. A message for a machine that is off is kept trying for a day. Messages between machines are encrypted end to end, as the app's are: the relay carries them without being able to read them. A message from another agent is drawn as one, never as your own, and a session stops taking work from agents after twenty turns in a row without a word from you. The app links your machines by itself. Machines of other people, or on another server, cannot be reached yet. `REEMOAT_PEER_MESSAGES=off` removes the tools.
- **Agent messaging can be switched off: for every machine, for one machine, or for one conversation.** Settings has a new **Permissions** section with one switch for all the machines you own. Each machine's settings have their own, which is kept while the first is off, and a session's menu has **Agent messaging** for that conversation alone. Off takes effect at once. A running agent's messaging tools answer that messaging is off, and messages still waiting to be delivered are dropped, with a line in the sender's conversation. The link between two machines is cut at the relay, even while one of them is offline. Switching back on reaches conversations started or resumed afterwards. A machine can also be isolated: its sessions still message each other, but nothing reaches them from your other machines and they reach none. A machine whose daemon predates this is cut from your other machines, but its own sessions can still message each other until it is updated. `REEMOAT_PEER_MESSAGES=off` still turns messaging off on a machine, whatever the switches say.
- **A dark theme.** The menu has a **Dark theme** switch under everything else; the app stays light until you turn it on. The choice belongs to this device rather than an account: every account on it shares it, and signing out keeps it. Every colour was re-chosen for a dark background rather than inverted, and text, borders, the diff and the question card keep the contrast they have in the light theme. The page is dark from its first frame, and in the desktop app the window's title bar follows the switch too.
### Changed
- **Closing the window no longer quits the app, on macOS and Windows.** The close button — and ⌘W on a Mac — puts Reemoat away instead, so a stray click never stops your agents. On a Mac it stays in the Dock, and a click on its icon brings the window back. On Windows it stays in the notification area: a click on its icon brings the window back, and opening the app again shows the one already running. To end it, quit: ⌘Q or the app menu on a Mac, **Quit Reemoat** on the icon's menu on Windows. Quitting still stops the daemons the app started. Linux is unchanged.
- **A colour of its own.** Send, the main button of every decision, the machine you are on, and everything that says a session is waiting on you — its dot, the counts on machines, the bell — are now lake teal, from the painting on reemoat.com. Everything else stays as calm as it was.
- **Code blocks are highlighted in colour.** Keywords, function names, strings, numbers and types each have their own colour, in both themes; comments stay grey.
- **Code and links in colour.** In what an agent writes, inline code is orange on a soft, borderless chip, as Claude Code marks it, and a link is blue. Both themes have their own shades, and the app's own links stay as they were.
- **A waiting session keeps its line.** A session waiting on you is still marked by its bold title and its dot, and the line under the title keeps its nickname, its agent and its machine instead of being replaced by the question's title. The icons on that line sit further apart, and the gap grows with the text on a phone.
- **Tapping an answer answers.** When an agent asks a question with one answer to pick, tapping it moves on to the next question, or sends it on the last; you no longer press Next or Submit after it. Choosing several answers, or writing your own, still ends with Next or Submit. Answers no longer shrink under your finger.
- **Sessions can be reordered under All**, by dragging, as they can inside a machine.
- **Three clicks on the conversation's header select one thing**, the folder or the branch under the pointer, instead of painting the whole header.
- **On a phone, the answer you are writing comes to the front.** Once you start typing your own answer to an agent's question, the ready-made answers fade back so your words are what you read; tap one and it is chosen as before.
- **Folder headers no longer count the sessions waiting in them.** The session's dot, the count on its machine and the bell still say so.
- **Triple-clicking inline code selects all of it**, `origin/dev` rather than the paragraph around it, ready to copy, as in Claude Code, and further clicks leave it selected. A double-click still selects a word.
- **The settings menu marks the screen you are on with a rounded highlight inset from its edges**, rather than a band across the whole column. The plugin market's menu matches.
### Security
- **The key that lets an agent use the messaging tools is no longer on a command line.** Claude Code was handed it in its arguments, where any other account on the same computer could read it and send messages as that conversation. It now travels in the agent's own environment, which only you can read; the other agents never had it on a command line. A key also stops working the moment its agent's process ends.
- **A message from another agent can no longer pass as Claude Code's or Codex's own text.** More of the tags those harnesses write are neutralised inside a message, and what another machine says when it refuses a message is checked, shortened and shown only as a quotation.
- **Continuous deployment no longer trusts the deploy host's key on first use.** `deploy/ci-deploy.sh` pins the new `DEPLOY_KNOWN_HOSTS` secret with `StrictHostKeyChecking=yes` and refuses to deploy without it. Add the secret before the next dispatch: the host's keys as `ssh-keyscan -H <DEPLOY_HOST>` prints them, checked against the host's own fingerprints.
### Fixed
- **A message between machines is delivered once, even across a restart.** A retry that reached a machine after it restarted could be delivered a second time; delivered messages are now remembered on disk for a day. A message a shutdown cut off is held and retried rather than reported as ended.
- **A bare name never reaches the wrong session.** When one of your machines did not answer, `send_message` to a nickname shared with a session on it went to the one here; it now asks for the full address. A session on a newer machine whose state this one does not know is still listed.
- **Cursor never approves a plan by itself.** An unreadable plan request, or one that arrived while a conversation was being reopened, was answered in a way Cursor reads as approval; it is now declined.
- **Switching agent messaging off also stops a message that was being handed to a busy agent at that moment**, a stopped conversation can no longer send messages, and a plugin answering a question no longer counts as you.
- **Removing a saved agent key asks first**, since it restarts the machine's open chats. The nickname field on New session is a proper form field, and a routing key is overridden on its own screen.
- **The app no longer says "local is unreachable" for its first quarter-minute.** On every launch it asked for this computer's daemon before the daemon had finished starting, and then waited fifteen seconds to ask again. It now says *Connecting…* while the daemon starts and reaches it the moment it answers.
- **Saving a file an agent names works again in the desktop app, and on Android for the first time, and a failed save says why.** The file was handed to the app's save panel in a form that one of the app's two internal channels drops, and a page switches to that channel for good after a single hiccup — on Android it is the only one. The press spun briefly and saved nothing, with no message. On Android the save panel also returned a location the app could not write to. A failed save now shows why on every platform, and a file an agent names relative to the folder it made it in — `slides.html` for `presentation/slides.html` — is offered for download too.
- **Choosing a model on a Cursor session that had gone to sleep shows that model's effort at once.** The choice used to be kept for the next message, so the effort control stayed empty until then; it now wakes the session, and a level chosen for the previous model is no longer carried over to the new one.
- **Sign-ins says whether you are signed in.** A provider signed in through its agent's own sign-in used to read "sign in" whenever no key was saved, which looked like a state and was only a button; the row now says what the agent itself reports — signed in, not signed in, not installed, would not start, or cannot check — and "key saved" where a saved key stands for it.
- `install.sh` printed the id of the default control-plane image rather than the one `REEMOAT_CP_IMAGE` in `control-plane.env` named.
- A control plane whose `mail.public_url` is its own origin no longer warns that it serves no browser UI: it serves `/confirm`, `/reset` and `/verify` from the gate bundle. The warning remains for a control plane running without that bundle.
- A mail failure from TLS or the socket is stored truncated and stripped of CR/LF, as SMTP replies already were, so it can no longer stretch or split the Email settings banner.
- The login throttle key no longer cuts the tail of a long address when the name is a maximal email address: its ceiling is derived from the longest key any builder writes.
- **"Use another account" appears on the forced password change again.** It was never drawn there, so someone with several accounts could only sign out.
- A message sent while an earlier send on the same session was still in flight could have its echo cleared early by the earlier one's late answer or refusal.
- The server log view keeps following new lines while you are at the bottom, however many lines one refresh adds. It now opens on the newest line.
- A tool call whose only argument is its content (`content`, `text`, `new_string`) is shown with that content rather than as a call with no input.
- A one-shot agent ask (a capability or model read) keeps its concurrency slot until the agent process has actually exited, and daemon shutdown waits for it; the slot used to free during the agent's teardown.
- The idle sweep's wedged-turn reap runs even when parking an idle session throws in the same tick.
- Raising `REEMOAT_SESSION_CREATE_BURST` takes effect at once instead of leaving the old ceiling in place until the bucket refilled; lowering it still clamps.
- An unreachable machine no longer flickers into view every 15 seconds while it is re-checked: a re-check keeps the last answer until the new one arrives.
- **grok's questions, plan approvals and MCP forms reach the question card** instead of failing with "Method not found": its own `_x.ai/ask_user_question`, `_x.ai/exit_plan_mode` and `_x.ai/mcp/elicit` requests are answered in the shapes grok accepts, and grok is started with its 30-minute question timeout switched off, so a question waits for you.
- **A question or plan the agent raises between turns is no longer cancelled on arrival.** When claude kept working after its turn ended (after a background workflow finished), every question and plan approval it raised was answered "no turn to answer into" instantly, and one still waiting when a turn ended was cancelled too. A request now waits until you answer it, dismiss it, press Stop, or the agent goes away. A plan raised between turns offers only the grant that works there: claude cannot restart into a cleared context outside a turn.
- **The working line shows whenever the agent is working**, including work claude starts by itself after background tasks finish, and counts the tokens streamed since the last tool call (`working… · 3m · ↓ 1.2k tokens`). Stop works there too.
- **Finished background tasks survive an effort change.** Switching effort away from ultracode restarts the agent, and the list of finished tasks went with it. It is now kept across an agent restart, a park and a clean daemon restart; a task still running when its agent is replaced is shown as stopped.
- **A message is sent and shown exactly as typed.** The macOS app no longer turns quotes into «» or “”, `--` into a dash, or text-replacement shortcuts into their expansions, and the message box and the question card's own-answer box switch off spelling substitution. Your own messages are drawn as plain text, so a numbered list stays text you can select, and the first line's indentation is no longer trimmed.
- **Sending a message always brings the conversation to the bottom**, even while the agent is streaming; the conversation no longer drops off the bottom by itself, and a line wrapping in the message box no longer shifts it.
- **The box for your own answer on a question card no longer draws a dark rectangle** sticking out past its row.
- **Bottom sheets close with a swipe on Android**, including a quick flick and a fully expanded picker, and leave from where your finger let go instead of jumping back first. Every sliding panel shares one gesture and one animation.
- **A sliding panel no longer flickers or stutters while it moves**: the drag is one composited transform per frame, so the grab bar stays crisp. New session can be dragged from anywhere on it, as the pickers can.
- **Your own answer on a question card can hold line breaks**: Shift+Enter on a keyboard (Enter moves the card on), Enter on a phone.
- **Sending while the agent is answering no longer makes the conversation jump or blink**: the message is never drawn twice, and the working line keeps its place when a queued message is handed over or a turn ends.
- **Send and Stop cross-fade instead of swapping instantly**, and a tap during the swap always lands on the button that is appearing.
- **The Android launcher icon has the same margin as the macOS one** instead of filling the whole circle.
- **Swiping between machines on a phone turns the page like Telegram's folders**: the neighbouring machine's list slides in beside the current one and the release carries both on, instead of springing back and then jumping to the new machine.
- **The machine names in the phone's top strip are one pixel smaller** (15px).
- **Flicking quickly through several machines on a phone keeps moving**: a flick made while the previous page is still settling carries the pages on to the next machine instead of being lost.
- **The selected machine is marked by a pill that travels with the page**, stretching from one tab to the next as you swipe, tap or return to All, instead of an underline that jumped.
- **On a phone, swiping right on the All page pulls out the side menu**, following your finger, as Telegram does on its first folder; the left edge stays the system's Back.
- **Pulling the session list down refreshes it**: every machine is re-dialled and re-listed while a gap with the working mark holds open, and it closes when the answers are in.
- **Connection trouble is a small spinner at the bottom-left** that expands into its words on hover or tap, instead of banners above the list and the conversation. A reconnect under a second shows nothing, and a machine that is simply switched off does not hold it on the All tab.
- **A drag that starts on the dimmed area beside an open panel moves the panel**: the side menu slides back with your finger, and a bottom sheet slides down, the same as dragging the panel itself. Tapping a picker's dimmed area no longer also taps what is under it.
- **On a phone, dragging a conversation to the right takes you back to the list**: the conversation follows your finger with the list revealed underneath, and lands on the same list the back button gives. A code block that can still scroll keeps the drag, and a field, a selection or an open menu never starts one.
- **Resizing the window while the agent streams keeps the conversation at the bottom** instead of dropping off it, and the scroller is re-synced after a resize so what is painted and what the mouse hovers stay in the same place.
- **The desktop machine column is wider (80px, Telegram's width) with larger buttons**, and its menu button is tall enough that the menu bar macOS slides over a full-screen window no longer covers it.
- **Renaming a session no longer shifts the header**: the box is drawn where the name stands, hugs the text, and has no heavy focus ring. The session name shows the text caret on hover.
- **Text on a phone or tablet is two pixels larger throughout** (conversation and session titles 16px instead of 14px); a desktop keeps its size.
- **The menu button at the top left of the phone list is larger** (a 44px button with a 20px glyph), as is the back button at the top of a conversation.
- The app no longer gives up on a slow request before the machine's own budget for it runs out. Each slow route waits for its daemon's budget plus 30 seconds (a new session up to 215s, a prompt 150s, an agent capability read 290s) instead of a flat 90s, which drew a healthy machine as unreachable.
## [0.11.0] - 2026-09-23
### Added
- **Several accounts in one app.** The menu now opens on your face, your name and
the server the account is on; pressing the name lists every account on this
computer, the current one ringed, then **Add account**. Tapping another account
switches to it — on a Mac at once and exactly as you left it, with the work in the
account you left still running; on Windows, Linux and Android the app reloads
onto it. **Add account** signs in to another account, on the same server or
another one, while the others stay signed in — up to ten.
- **Every account gets this computer as a machine of its own**: its own daemon,
database, sessions and working copies, running from when the app opens until it
quits whichever account is on screen, and using one of that account's machine
slots. The first account on a server keeps that server's folder; each further one
lives in `~/.reemoat/servers/<server>@<user id>/`.
- **The server step opens on the server the app was built for**, greyed, with a
pencil beside it to change it. An app built without one opens on an empty box, as
before.
### Removed
- **The "Rent a machine" link.** An instance whose control plane set
`REEMOAT_CP_MACHINES_OFFER_URL` drew it under the one-line installer — on the
home screen of an empty fleet and in Settings → Machines. It is gone from the app
and from `GET /v1/instance`, and the variable no longer does anything: a control
plane that still finds it in its environment says so once at startup, and the
line can be deleted. Adding a machine is unchanged — the one-line installer is
still on all three screens, and so is the sentence shown in its place when the
machine limit is reached. An app from an earlier release stops drawing the link
as soon as its control plane is updated.
### Changed
- **New session no longer installs or signs in to an agent.** When nothing on a
machine can start, the screen says why in one sentence and offers one button,
**Agent settings**, which opens that machine's Agents list. Coming back returns
to New session with the folder and the chosen agent as they were.
- **The Agents list can finish the job.** A row that is not installed, not signed
in or would not start now has **Set up <agent>** in its menu. It opens that
agent's own screen: install with the installer's output, then sign in. The
list's own Install, which ran with no output, is gone.
- **Settings → Account → Server address shows your account's server and no longer
changes it.** Another server is another account: add it from the menu. A wrong
address typed while signing in is still corrected from **‹ Server** on the
sign-in screen.
- **Signing out signs out of the account on screen and takes it off this
computer**, stopping its daemon; the app moves to the account you used before it,
or to the sign-in for that server if it was the last. Signing in as that person
again later brings back the same device and the same machine. An account whose
session ends by itself stays in the menu, marked *signed out*, and its sign-in
screen offers a way back to your other accounts and a way to remove it.
- **Updating keeps you signed in.** The first launch asks the server whose sign-in
this computer holds and moves it to that account; the device and the machine
beside it move only where the server confirms they are that account's. Going
back to an earlier release afterwards asks you to sign in again. ⚠ **Where the
one-line installer set this computer up, update with its daemon running.** If it
has not answered by the time the app asks, the app sets the computer up again as
a second machine, and the first — with its sessions — stays offline.
### Fixed
- **The computer the app runs on is called "local" and comes first in the machine
list.** The machine strip, the desktop rail and New session named it by its
host name ("MacBook-Pro…") and sorted it among the others by name. On this
computer they now say "local" and list it first — from the moment the app
opens, before its daemon has started, and without changing back while the
daemon restarts — and New session picks it by default whenever it is reachable.
Everywhere else — your phone,
another computer, anybody you share it with — it keeps its real name, and
Settings → Machines still shows the real name with "this device" beside it, so it
can be renamed there as before. Another machine that is itself named "local",
such as one an earlier version of the app set up, is shown with its id added
("local-2405b5ea…") so only this computer reads "local". If you have dragged it
somewhere in the list, it stays where you put it; if you had rearranged the
machines before this release, it stays where it was until you move it.
- **The app can set a computer up for more than one server, and for more than one
account on each.** Signed in to a second server it used to refuse ("This
computer could not be set up"), because the daemon settings in `~/.reemoat` named
the first. Each account now gets its own daemon, database, sessions and working
copies — `~/.reemoat` stays with the server its `daemon.env` names, a server's
first account otherwise lives under `~/.reemoat/servers/<server>/`, and each
further account on a server under `~/.reemoat/servers/<server>@<user id>/`, on a
port the system picks. Every account that is set up has its daemon started when
the app opens, and all of them stop together when it quits. Switching accounts
interrupts nothing: the other account's daemon, its running turns and pending
approvals, and a phone's way to this computer through it stay up while the app
runs. A new account's daemon starts empty — no plugins, system keys, custom agents
or pasted keys of its own — while the agent CLIs' sign-ins are shared by every
account. `REEMOAT_AGENT_UPDATES`, `REEMOAT_AGENT_SOURCE` and
`REEMOAT_AGENT_CHANNEL` in `~/.reemoat/daemon.env` do not yet reach the daemons
for other servers and accounts.
- **A daemon running here for this server that the app did not start is no longer
met with silence.** One whose machine is in your list is adopted as before; one
your account cannot see is named in the setup notice.
- **The app no longer creates a second machine for a daemon already running on
this computer** with its settings file somewhere else (`REEMOAT_ENV_FILE`,
`pnpm daemon` from a checkout); it adopts it.
- **A daemon's clean stop no longer deletes another daemon's announcement.** Every
daemon removed `daemon.json` on its way out, whoever had written it; now only the
one that wrote it does.
- `install.sh --uninstall --purge` names the desktop app's daemons for other
servers and accounts before deleting them, and `--uninstall` lists them among the
data it keeps.
- **The model chip read "Newer version availa…" after Claude Code moved its
`opus` alias.** Claude Code 2.1.280 describes a conversation resumed on a model
an alias has since moved past with a notice instead of the model's name, and the
chip showed the start of that notice. It now shows the model the row names,
"Opus 5". The notice stays in the menu under that row, where it tells you to pick
Opus for Opus 5.5.
- **A Claude Code update the daemon did not install was picked up only ten minutes
later.** When something other than the daemon updated the CLI (its own updater,
another daemon, or `deploy/deploy.sh`), the new-agent screen kept naming the
previous build, and listing its models, for up to ten minutes. The daemon now
checks which file it would run each time it uses it, so the new build's version
and models appear the next time you open the new-agent screen. A session that is
asleep still shows its old list until it wakes.
- **The Android app would not install on a OnePlus 13 by tapping the APK.** The
phone's own installer — OxygenOS, Android 16 — answered "App not installed as
package appears to be invalid", while the same file installed on a Pixel, and
over `adb` on that very phone. The APK carried only the signature Android has
checked since 7.0; it now carries the older JAR signature beside it, which
Android ignores and which that installer *may* have wanted. That is a
hypothesis rather than a finding: the message is the one Android's own
installer shows when the platform refuses a package, and the platform never
reads a JAR signature beside the newer one. This release installing on a
OnePlus would not settle it either, since the download and the build change
too; the same APK signed twice with one key, with and without the JAR
signature, and tapped on that phone, does. A release now also refuses to
publish an APK missing either signature.
- **Starting a session in the macOS app put blank "exec" icons in the Dock.**
Every MCP server an agent started through `npx` appeared there as an application
of its own — a blank tile labelled "node" — because macOS counted the Node
runtime inside the app as part of Reemoat itself. The runtime now lives in a
small helper inside the app that macOS keeps out of the Dock, so MCP servers run
exactly as before and the Dock shows Reemoat alone. The app is no larger: it
still carries one copy of the runtime.
## [0.10.1] - 2026-09-22
### Added
- **The app, for five platforms, on the release page.** macOS for Apple silicon
and for Intel, Linux as a `.deb` and an AppImage, Windows as an installer, and
Android as a signed APK. Each is built on every push by a check that builds
exactly the bundle the release publishes, and a platform cannot be added to a
release without one.
### Fixed
- **0.10.0 has no release page, and this is the release it should have been.**
0.10.0 was tagged and its control-plane image published, but its release failed
while building the Linux app, so none of the apps were published and there are
no 0.10.0 downloads. The code is the same: everything listed under 0.10.0 below
reaches the apps here for the first time.
- **A release that failed still published its image.** The image tags people
pull — the version's own and `latest` — were created as soon as the image was
built, whether or not the apps built, which is how 0.10.0 ended up with an image
and nothing else. They are now created only after every app has built, so a
release that fails leaves nothing behind and can simply be run again.
- **The Linux app could not be built by a release.** The check that builds it on
every push installed the system libraries it links against; the release job that
builds the same app did not. Both now install them from one list.
## [0.10.0] - 2026-09-22
### Added
- **Grok, as a fifth harness and an eighth provider.** `grok agent stdio` is xAI's
own ACP entry point, so this is the first agent that needs no adapter of any
kind — `deploy/agents.sh` installs `@xai-official/grok` from the npm registry
under either `REEMOAT_AGENT_SOURCE`, which is the door the daemon can refresh on
a timer with nobody watching. A new `xAI` provider sits beside it, reached by the
CLI that ships for it; a routed arm, letting Claude Code be pointed at Grok, is
deliberately not included until one call with a real key shows xAI's
`/v1/messages` answering in Anthropic's shape.
- **ACP `authenticate` is sent, for the harnesses that need it — and only where
there is a key to spend.** A machine holding a pasted xAI key needs that call
for the key to be spent at all, so the daemon makes it once per agent process,
between the handshake and the first session. The method id is written down
rather than read off the agent, because the one the agent advertises opens a
browser and waits.
- **Agents are installed when you ask for them, not when the daemon updates.**
A harness you have never used is no longer downloaded onto your machine: a fresh
install brings none at all — several minutes and about 700 MB lighter — and a
daemon update moves only the copies that are already there. To add one, open
Settings → Agents, press **Install**, and watch it go; signing in comes after.
A machine set up by a script can still name what it wants up front, with
`--install-agents claude,codex`.
### Changed
- The model and reasoning-effort controls, the permission cards, resume and every
other per-agent surface needed no new code for Grok: it publishes its controls
under the same categories the existing agents do, and they were already read by
category rather than by name.
- Grok is always spawned with `--no-auto-update`. It updates itself in the
background otherwise, and when a build moves on a machine is this daemon's
decision — it deliberately keeps the build a live session is running on.
- **A tag can publish the app, and the machinery is in place before any platform
uses it.** `deploy/ci-release.sh` grows a fifth verb, `app`: it builds the
native app for one target, refuses a target no check has built, refuses a
client build with a daemon payload left on the runner, refuses a bundle that
was not produced or was produced twice, and names the artifact it copied out.
`publish` puts every one of them on the same `gh release create` call as
`install.sh` and **refuses a release missing an artifact its own list named** —
by name, never by count. Thirty-six new assertions in `pnpm deploycheck`
drive all of it with no forge, no registry and no bundler.
- **Which platforms carry a daemon inside them is written down per platform.**
`tauri.linux.conf.json`, `tauri.windows.conf.json`, `tauri.android.conf.json`
and `tauri.ios.conf.json` remove `externalBin` and `resources`, so every
platform but macOS ships a client. Measured rather than assumed: `tauri-build`
reads those overlays at **compile time**, which is what makes a client build a
configuration file with no Rust in it. `pnpm nativecheck` holds an overlay to
an exact allowlist of keys, because it reads one configuration file and Tauri
reads five.
- The release notes now carry the AGPL §6 source offer for the tag being
released. `bundle.licenseFile` is read by the `dmg` and `nsis` bundlers and by
nothing that builds a macOS `.app`, so the artifact most people download would
otherwise carry neither a licence nor an offer.
### Fixed
- **A new agent appeared on machines that did not have it, offering to sign you
in.** Adding a coding agent to Reemoat put it on every machine in a fleet on the
next daemon update, and the button under it led to a screen that could only
report that the program was missing. Nothing installs an agent by itself any
more, and where one is genuinely absent the screen offers to install it rather
than to sign in to it.
- **Grok could not run a turn on a machine signed in with `grok login`.** The
first message came back as a bare `Internal error`. The `authenticate` above was
being sent unconditionally, and with no API key behind it that call does not
fail — it *selects* an API-key sign-in, after which Grok stops consulting the
credential it already has and calls its own service as nobody. It is now sent
only when there is a key to spend, so a machine signed in the ordinary way uses
the credential it has.
- **Grok's tile offered "Sign in" on a machine that was already signed in.** The
daemon had no way to ask Grok about its own sign-in, so every machine answered
"cannot check". It asks now, and tells the three states apart: signed in through
the browser flow, running on a saved key, or signed in nowhere.
- **A greyed *Mode* control on Grok said the agent was "not offering this control
at the moment".** Grok has no modes at all and never will, so the sentence was
describing a permanent fact in words that promised a temporary one. The slot
still holds its place — the control row is the same shape on every agent, which
is deliberate — and now says the agent has none.
- **A provider whose models come from a coding CLI vanished from the model picker
in silence when that CLI could not be reached.** Five of the eight providers get
their model list from the harness that ships for them, so a CLI that is missing
or will not start took its whole provider off the screen with nothing said, and
the only available conclusion was that the product had dropped it. The picker
now says which providers it could not read, and why.
- **Android is compiled and assembled by CI now, and both halves were dark.**
`check.yml` grows `native-android`, which compiles the Rust for
`aarch64-linux-android` — measured green, with the negative control that an
error inside `cfg(target_os = "android")` fails it while `cargo clippy
--all-targets` on the host stays green — and `android-apk`, which runs Gradle,
assembles a release APK and then reads `classes.dex` to prove the TLS
verifier's Kotlin half survived R8. That last one is the assertion no regex can
stand in for: a keep rule that is present and ineffective looks identical from
the outside. `android-apk` needs `gen/android` committed, which `.gitignore`
has always said it should be.
- **The Android arm of the Rust was compiled by nothing, and a shipped APK could
not do TLS.** `cargo clippy --all-targets` is every *crate* target on the host,
never another platform, so `credential.rs`'s Android half and its JNI export
were checked only by `nativecheck` regexes. R8 then stripped
`org.rustls.platformverifier` — the class the Rust TLS stack reaches over JNI
by name — out of the signed release APK: measured against the built artifact,
where `usage.txt` listed all five classes removed and `classes.dex` carried
none, while the `.so` still carried the name it would look up. Debug builds
were unaffected, which is what made it invisible. There is a keep rule now,
`nativecheck` asserts it paired with the Gradle dependency that puts the class
in the APK at all, and a `native-android` job compiles the arm on every push.
- **A typo in `RELEASE_APP_TARGETS` published a release with that platform
silently missing.** `app_artifacts` refuses a name the table does not know by
returning 1 — inside a pipeline, with no `pipefail`, so `set -eu` never saw it
and the name contributed no asset at all. `plan`'s collision gate and
`publish`'s completeness gate then had nothing to find. Every name is checked
against the table now, by `plan` and `publish` directly rather than from inside
the `$( )` that made the first attempt at this equally silent.
- **A mobile build would have compiled and then silently never kept a sign-in.**
`keyring`'s `v1` feature has no credential store on iOS or Android — it refuses
at run time having compiled perfectly — so an APK or an `.ipa` built today would
have asked for the password on every launch. Android now reaches past that
façade to `keyring-core` with `android-native-keyring-store`; iOS is refused at
compile time until its own arm is written, because everything else a mobile
build is missing already fails loudly and this one would not.
- **The bundled payload's `node` shim could re-exec itself for ever.** Its last
line was `exec node "$@"`, and the daemon's own `PATH` puts that shim's
directory first — so on any layout where its two relative probes miss, a PATH
lookup for `node` found the shim again. It says what happened and exits 127
now. Reachable on a `.deb` or an AppImage, where it would have read as a
daemon that never starts.
- **Selecting a message selected the empty space around it too.** Dragging through
a conversation painted one solid rectangle: the unused half of every short line,
the gap between paragraphs, and the whole blank column beside your own message.
Only the text is painted now — every line, every gap, both sides of the
conversation — and what gets copied is unchanged to the byte.
- **A line break you typed was thrown away when the message was sent.** It was
never lost on the way out; a single newline is a *soft* break in the markdown
everything here is rendered as, so it was collapsed into a space when the
message was drawn. Your own messages keep their breaks now, and every message
already in a conversation gets its breaks back on the next draw. An agent still
gets CommonMark, which is what it writes.
- **The ✕ in the background-tasks panel lit up before the pointer reached it.**
Several controls grew an invisible margin so a finger could hit them, and that
margin was there on a desktop too — where it is not a bigger target but a
control that reacts to the wrong place. It is now added only where the pointer
is coarse.
- **A failure count sat in the same fill as the message above it.** `1 failed`
was drawn as a pill in exactly the colour a person's own message uses, so it
read as part of it. It is quiet text aligned to the message now.
- **A session could say "working" for hours after its agent had finished.**
`claude-agent-acp` can start a turn on its own coming back from background work,
and a turn nobody asked for has nothing to end it — so the daemon sat on a
request that would never be answered. Three hours of silence from the agent now
ends such a turn, and a message you send does not reset that clock.
## [0.9.1] - 2026-09-18
### Changed
- **Traffic to a remote daemon is end-to-end encrypted, and there is no other
mode.** The app and the daemon run `Noise_IK_25519_ChaChaPoly_BLAKE2s` between
themselves: the app's static is a per-installation **device key** kept in the
operating system's keyring and used from Rust, the daemon's is a **machine key**
it generates on first start and announces on its tunnel dial. The relay
authorizes the connection and then carries bytes it holds no key for — prompts,
diffs, file contents and terminal output are ciphertext to it. Every capability
names the device it was minted for, so one stolen off the wire or out of a log is
worth nothing from anywhere else.
- **The relay's plaintext proxy is deleted.** It used to serialize each request
onto the tunnel with Node's own HTTP client and copy the answer back, which meant
it held the plaintext of everything in the fleet. Both handlers are refusals now.
- **This is a flag day, taken deliberately.** `RELAY_PROTOCOL_MIN_VERSION` is 2, so
a daemon that has not been updated stops dialling in and is refused with a `426`
naming what to do. Its machine draws as offline until `deploy/deploy.sh` runs on
that host. No range can span "plaintext HTTP" and "ciphertext", which is what the
version range normally exists to avoid.
- **The shell is the shape of a chat client now, on both axes.** Below `lg` the
machines are a horizontal strip of underline tabs above the session list; at `lg`
and wider they are a 72px column of folders down the left, drawn from the same
`machineTabs`/`allTab` source and carrying none of the strip's three cues. The
rail is the two of them on one `--rail-w`, draggable, bounded in one place.
- **`ProfileMenu` is gone and a menu drawer replaces it.** Who you are, where you
can go and what build you are running, in a panel that **covers** the app rather
than docking beside it — registered as a sheet, so `j`/`k` cannot walk the list
behind it. The visible wordmark left the chrome with it: the drawer's foot draws
the build, and the `<h1>` on the list column stays `sr-only` for the heading
order `Header.tsx` rests on. The build string is read from `packages/web`'s own
manifest at build time rather than written down an eighth time.
- **The agent's controls survive a reload.** The daemon deliberately restores no
`agentConfig` from disk and empties it while an agent is away, so `F5` used to
cost you a strip of three dashes on a session whose model and effort were on
screen a second earlier. This tab now remembers the last set a *running* agent
published — the selected choice only, 120 sessions, bounded on write — and draws
it dimmed and untappable. ⚠ **It does not put the settings back on the agent**: a
restart still comes back on the agent's own defaults, and nothing read back from
the memory is ever sent. It is cleared on sign-out, both ways out.
### Removed
- **The browser branch of the app.** A browser holds no device key, so it cannot
open an encrypted channel to a daemon — it could load the client and reach no
machine at all. Gone with it: the Telegram mini app, and `REEMOAT_CP_WEB`, which
named a built copy of the app for the control plane to serve. The **gate** —
sign-up, the mailed-link screens, the legal documents and the handoff page — is
unaffected and still served to a browser, because every one of those flows begins
in a mail client and has nowhere else to land.
- **`pnpm client` can no longer reach a machine through the relay.** Opening a
channel needs a device key and a capability bound to it; `REEMOAT_TOKEN` is a
long-lived bearer capability with no binding, which is exactly what the binding
makes worthless. It refuses with a sentence naming the remedy — the app for a
remote machine, `REEMOAT_URL` for a daemon on this computer.
### Added
- **Reemoat runs as a native macOS application.** `packages/native` is a Tauri 2
window around the existing web client, built once and **embedded in the binary** —
so the control plane it supervises serves it no JavaScript and cannot replace any.
It asks which server to connect to, signs in through the same routes the browser
uses, and keeps the session in the operating system's credential store rather than
in browser storage, keyed on the server's origin so two servers can never share
one sign-in. Windows and Linux are structurally supported and iOS and Android are
prepared; `docs/NATIVE.md` has the prerequisites, what signing and notarization
would take, and what is deliberately not built.
- **The same bundle, not a second copy.** There is one `packages/web` and no
`@tauri-apps` dependency anywhere in it: the shell injects one function and the
app reads it through a hand-written bridge.
Every screen, every retry rule, the WebSocket, the cursor and the upload progress
are the code the browser client runs. One leg differs — `/v1/*` goes through the
host process, because the control plane mounts no CORS and adding one to serve a
client that does not need it would be the wrong repair.
- `pnpm nativecheck`, and a `native` CI job for the parts that need a Rust
toolchain. Nothing about the native app builds, signs or publishes on a push.
- **`pnpm protocolcheck`, and it is the only driver here whose subject somebody
else wrote.** Every other check asserts a decision this repository made; this one
asserts that our handshake produces the bytes the Noise Protocol Framework says it
should, driven against the published cross-implementation vectors in both roles
with the ephemerals pinned. An implementation that only ever talks to itself
round-trips perfectly while interoperating with nothing, and would go on doing so
through a nonce written big-endian or a protocol name hashed where it should have
been padded — none of which a self-test can see. It also covers the two things the
specification leaves to us: the reserved top of the nonce range, where the guard
was `>` and had to be `>=`, and this repository's own frame table. With it and
`nativecheck`, the drivers that run offline in one process go from eight to
**ten**.
- **The app reaches a daemon on the same computer without going out to the relay
and back.** A daemon that has been enrolled writes where it is listening into
`~/.reemoat/daemon.json` when it starts — its machine id, a loopback address and
the port it actually bound — at `0600` inside a `0700` directory, and removes it
on a clean stop. The app reads that through the host process and proves the
daemon is the machine it wants with one authenticated request before it sends
anything else. Nothing to configure: a daemon on a custom port, or on one the
kernel picked, is found the same way, and a daemon that has not been enrolled
announces nothing at all.
It is on by default and switched off per machine in Settings → Machines → *This
device*, which is also where the one cost is stated: the relay is what checks a
grant is still live before each request, so on this path a grant the owner takes
away keeps working from that computer for up to about six minutes. Everywhere
else it stops at once. Only a program running as the user who owns the daemon can
take the path at all — which is a user who already has that daemon's database.
Nothing above the transport changed. The same session API answers on both paths,
no screen knows which one replied, and a browser cannot take the local one.
- **An instance serves the API, the relay and the gate, and no app interface at
all** — now the only shape rather than a documented option. Every `/v1` route,
`/health`, `/install.sh`, the relay and the tunnel behave as they always did, and
an address outside the gate's nine gets the error envelope every other refusal
answers in rather than a page. `docs/API.md` has the table and `deploy/README.md`
has the operator's version.
### Fixed
- **A security assertion had gone quiet over exactly the commands it guards.**
`webcheck.devices.ts` swept `#[tauri::command]` bodies by matching that literal
including its closing bracket, so the twelve commands that gained `(async)` fell
out of it — among them every one that touches a device key. The check that the
browser-facing page cannot obtain the X25519 private key was being evaluated over
seven commands that were never going to touch it. Its floor, `> 5` against an
actual 7, could not notice: a skipped body does not lower a count, it fails to
raise it. The pattern takes both spellings now and the floor is replaced by a
**census** — bodies differenced against attribute occurrences — so a third
spelling is a red build rather than a silent gap, plus a named list of the
device-key commands that goes red naming whichever went missing. The same sweep
also found three source slices whose `indexOf` end anchor, when absent, made
`String.slice` read `-1` as counting from the end: the slice did not empty, it
ran to one character short of the file, so `writeStored` was 4,078 characters
becoming 32,958 with its own length floor still printing `ok`.
- **An unreadable `server.json` was still overwritten, and the comment above it
said otherwise.** The quarantine added for a corrupt config ran only on the
*parse* branch; a file that exists and cannot be read — a permission, an I/O
error, a damaged volume — answered `Default` and the next write renamed a fresh
empty config over it. On a keyring-less host that is the only copy of the device
private key. A write that cannot preserve what is already there now refuses
before creating anything, and the refusal names the path.
- **A remote transcript stalled for good on a large session, and nothing logged
it.** `StreamConnection.flush` cut its outbound batch on `estimateBytes`, which
charges `String.length` — UTF-16 units of the *unescaped* string — while the wire
carries `JSON.stringify` as UTF-8. `MAX_SOCKET_MESSAGE_BYTES` (1 MiB) is enforced
by the receiver and nothing enforced it at the sender, so a batch charged under
the 512 KiB ceiling could be several times that: measured 3,026,371 bytes for 21
events of escape-heavy text, which is what a coding CLI's stderr is made of (one
charged unit escapes to `\u001b`, six bytes). The far end refused the message,
the channel failed, and the app reconnected with its cursor unchanged onto the
same batch — for ever. `flush` now encodes each event once and cuts on
`Buffer.byteLength`, keeping the unconditional first event so a single oversized
event is still sent alone rather than wedging the queue. Held by two new
`daemoncheck` sections driven on the *direct* path, because over a channel the
overflow is answered by `fail()`, which ends the stream — so one frame crosses
either way and no count taken at the peer could tell a refusal from health.
- **A control frame too large to send wedged the attach before it started.** The
batch ceiling above is about a transcript stalling partway; `hello` is the first
frame of every attach and carries the session snapshot, so a frame over the
reassembler's bound means the transcript never starts and the reconnect rebuilds
the same frame. It is now fitted by a two-rung ladder — blobs emptied, then the
pending lists halved, each rung re-measured on real UTF-8 bytes — and a frame
neither rung can shrink is still **sent**, because a `hello` that never arrives
is the stall rather than the cure. `fitSnapshotFrame` is exported for the driver
and for nothing else: every rung is reached only by a snapshot no offline fixture
can assemble, and measured, deleting the function outright left every driver in
this repository green.
- **The sign-up bundle shipped a Noise implementation it is structurally incapable
of using.** One import edge — `gate-main.tsx → store.ts → machine.ts → e2ee.ts →
`@reemoat/protocol`` — put the handshake, the cipher state and the frame codec on
all nine gate addresses: a registration form, four mailed-link screens a mail
client opens (typically on mobile data), three legal documents and the handoff
page. A browser holds no device key and `dist-gate` has no session view, so not
one of those pages can open a channel. It had grown from 266 kB to 335,745 bytes
(106.31 kB gzipped) in two days with nothing measuring it. The gate reads a
narrower `gateStore` now, and `ui/SignIn.tsx` — the one shared box that also has
to *act* — reaches its store through `signInAuth.ts` rather than naming either:
232,490 bytes, 72.91 kB gzipped, with no Noise string and no ed25519 constant in
any emitted asset. `webcheck` walks the **value** import graph from each entry and
refuses a transport module in the gate's; it has to be the value graph, because
`ui/bits.tsx` type-imports `OfflineReason` from `machine.ts` and
`verbatimModuleSyntax` erases that, so the broad walk reported a chain the bundle
does not contain.
- **A refused encrypted session went on acting on the peer's next frame.**
`fail()` guarded on `closed`, which it deliberately does not set — it leaves the
session up long enough for the `FAILED` frame to flush — and `consume`'s loop
guard read the same flag. So every frame already pulled out of the same TCP chunk
was still decrypted and dispatched after the refusal. Measured: a `REQUEST_BODY`
the daemon refused, followed in the same chunk by an `OPEN`, upgraded a real
WebSocket on a session the daemon had just refused to carry.
- **A daemon that answered before reading a body tore down the app's session.**
`REQUEST_BODY`/`REQUEST_END` were the only frame arms that never consulted
`carrying`, so a peer could write into a finished request's orphaned
`ClientRequest` — which on a keep-alive socket whose server has already answered
is what a pipelined request is made of. Closing that by refusing the frame broke
an ordinary client instead: the app's send loop exits on a failure or a close and
`RESPONSE_END` is neither, so `uploads.ts`'s per-session cap, its rate check and
any 401/404/405 on a `body: true` request left the app writing frames into a
connection whose `carrying` had already gone back to `"none"`. Reachable by
uploading one attachment past the cap with a body over 65518 bytes. A late body
frame is dropped now, the way a `MESSAGE` that raced the daemon's own `CLOSE`
already was, and the handle is `destroy()`ed at the end of the response so there
is nothing writable left for the bytes to be smuggled onto. A body frame on a
connection carrying a *socket* is still refused.
- **A cancelled upload went on encrypting the whole remaining file and ran its
progress bar to 100%.** `close()` deliberately does not set `failure`, and
`failure` was the send loop's only exit, so an aborted or timed-out request kept
iterating — `drain()` returns at once once closed, so it ran flat out, sealing
every remaining 65518-byte chunk with ChaCha20-Poly1305. On the phone this client
is shaped around.
- **The app→daemon socket direction had no backpressure, and it defeated the
ceiling that was already there.** On the direct path a stalled client raises
`bufferedAmount` past `SOCKET_HIGH_WATER` and `MAX_QUEUE_BYTES` eventually
collapses the socket; on the encrypted path the loopback `ws` client drains
greedily, so that ceiling never fired and the bytes piled up in the relay
Duplex's unbounded buffer instead, inside the process that owns the machine's
live agents.
- **A request whose answer came back before its body ended leaked a loopback
socket for two minutes.** `response.on("end")` released the handle, putting it
beyond `destroy()`'s reach with nothing else coming to collect it — one orphan
per 404, 401, 405 or early 413 on a `body: true` request, on a connection the
app's pool had already been told was idle.
- **Two daemons starting at once could mint two machine keys, which is a permanent
409.** `claimDaemonLock` was a non-transactional read-then-write, and the
`ON CONFLICT DO NOTHING` that looked like it absorbed the race could not: the
conflict key hashes the *freshly generated* key, so two racers produce two keys,
two thumbprints and no conflict. `active()` orders by `created_at DESC`, so the
next start announced the later key, disagreed with what the Authority had pinned,
and was refused at every dial — repairable only by `cpctl admin clearkey`. The
claim is a compare-and-swap now, and a partial unique index over
`retired_at IS NULL` is what lets the loser learn it lost and adopt the winner's
row. The index is created by `migrate()` rather than by `schema.sql`, after a
repair, because databases holding two live rows exist and a `CREATE UNIQUE INDEX`
at schema load would stop those daemons from ever starting.
- **The device key could still be lost whole on a crash, and a corrupt config was
destroyed rather than kept.** The temporary file was `sync_all`ed and renamed, but
the parent directory never was, so the new directory entry was not durable — on a
keyring-less host that is the only copy of the X25519 private key, and the
installation comes back as a first run. An unparseable `server.json` answered
`Default` and was silently overwritten by the next write; it is moved aside first
now, and a field another build wrote survives a read-modify-write instead of being
dropped. Read-modify-write is serialized, so adding `(async)` to a config command
— which the module's own docblock encourages — cannot lose the key.
- `packages/web`'s three "add a machine" screens built their install command out of
the page's own origin. In a browser that is the control plane and is right; under
a custom scheme it printed a `curl` line naming the app itself. They ask where the
control plane is now, and one of the three had never been asserted.
- **The native window would navigate to a control plane on loopback.** Its
navigation guard allowed `http://localhost` and `http://127.0.0.1` in every build,
for the Vite dev server — and a Reemoat control plane on loopback is the ordinary
self-hosted shape, serving its own page at `/`. A script assigning `location.href`
could therefore have replaced the running app with the backend's page, inside the
window holding the sign-in: the one thing bundling the interface exists to make
impossible. Those two are development-build only now, and every navigation the
client itself makes is asserted to be a path rather than an address.
- `REEMOAT_CP_INSTALL` is documented for the first time — it appeared in no example
file anywhere. It is also the only variable left whose value is *either* a boolean
*or* a path: the other one answered `=1` with a directory called `1`, ENOENT, and
a 404 indistinguishable from an image built without the interface, and it is
deleted rather than fixed.
- **The device key's fallback file was world-readable, and two docblocks plus
`.claude/rules/e2ee.md` said it was not.** Where the operating system's keyring will not hold a key —
a shared Linux host, a session with no unlocked collection — the native shell
keeps the X25519 private key in `server.json` instead, and that file was written
with `fs::write`, which creates at `0644` under the usual umask and, on a file
that already exists, keeps whatever mode it already had. So the protection was
described in three places and implemented in none, on precisely the machines that
have somebody else logged into them. It is written through a temporary file
created with `OpenOptions::mode(0o600)` and renamed over the target now: the mode
at creation closes the window in which the bytes exist at the umask's, and the
rename is what narrows installations that already exist — a fix that only set a
mode at creation would have looked green in a test starting from an empty
directory while leaving every machine in the field as it was. The same write also
stops truncating in place, which on those hosts could lose the only copy of the
key, the server origin and the device id in one act. `cargo test` asserts the
mode, the upgrade path and the directory's `0700`.
- **The documentation described the release before this one.** `README.md`'s
overview — the first screen anybody reads — still offered the web UI as an
optional second client, which this release deletes; `docs/API.md` still pointed
HTTP clients at the plaintext relay proxy, which now answers `426` to every
request, and still credited `pnpm client` with driving routes whose relay arm went
with it. Both are rewritten around what is actually there: the app, the encrypted
channel at `/__relay/channel`, and the gate as the whole of what a browser
reaches. `POST /v1/tokens` gains the two things a client cannot open a channel
without — `machine.key` and the `409 device_key_required` refusal — neither of
which was written down anywhere a caller would look.
- **The rules table had thirty rows for thirty-one rule files, and the missing one
was `e2ee.md`.** A rule arrives when a file matching its globs is opened, so the
table is not an index of them — but it is how anyone deciding what to read finds
out an area has a rule at all, and the area it was silent about was the newest and
the most security-critical. `.claude/rules/compatibility.md` is rewritten around
the fact that replaced its premise: the client used to ship inside the control
plane's image, so skew ran one way and a weekly deploy retired the oldest clients
in the fleet by itself. Nobody can push a client any more. It also now records an
open question rather than answering it — `packages/protocol/src/frames.ts` is
spoken between two independently shipped artifacts, carries no version between
them (the suite string on the CONNECT is written by the relay, which speaks none
of that protocol), and treats an unknown frame as fatal at both ends.
- **`.claude/rules/e2ee.md` credited a check to the wrong driver, in the direction
that gets a check deleted.** `RELAY_CHANNEL_PATH` is a literal in the relay and a
second literal in the app — they cannot be one import — and a client dialling a
path the relay does not serve is a fleet where no machine is reachable. Three
docblocks said `relaycheck` compares them. It does not: it imports the relay's own
constant and never opens the app's file, so every path it dials is one constant
agreeing with itself. `packages/web/scripts/webcheck.e2ee.ts` is the only
comparison there has ever been, and the rule says so now — the danger was never
the missing check, it was that believing in a second one makes the first read as
redundancy.
## [0.9.0] - 2026-09-14
### Added
- **A message sent while the agent is working is taken rather than refused.**
Correcting an agent mid-run no longer means pressing Stop and losing whatever
the turn had half-done. Where the agent supports it — claude and codex, over
the `_session/steering` extension they advertise on `initialize` — the message
goes straight into the turn already running. Where it does not, the daemon
holds the message and hands it over the moment the turn ends, and the
transcript says so under the message. The queue is the daemon's, so it survives
a closed tab, a sleeping phone and a dropped connection; it does not survive a
daemon restart, and a session stopped with messages still waiting records that
they never arrived.
- `POST /sessions/:id/prompt` answers `202` for a message sent mid-turn, carrying
`steered` or `queued` beside the usual `seq`, where it previously answered
`409 turn_in_flight`. A daemon that has not been updated still answers the
`409`, and the web client keeps its old behaviour against one.
- **Work an agent leaves running after the turn that started it is visible, and
can be stopped.** A backgrounded shell, a monitor or a workflow used to end
with the tool call that started it — the card read `completed` while the
command ran on for minutes, and nothing in the app could say what was still
going. The transcript's still-working row is a control now, and it opens a
`Background` panel: a bottom sheet over the conversation on a phone, docked
against the right edge on a wide screen, with a card per task carrying what it
is, how long it has been going, what it has spent, and Stop wherever the agent
says the task may be stopped. A tool call that detached says `Running in the
background` instead of reading as finished.
It rides `jetbrains.air`, which is a **vendor `_meta` extension rather than
core ACP** — the draft surface for `agent-client-protocol#1992` — declared on
`initialize` and answered by claude alone. Measured 2026-09-11 against claude
2.1.268 under claude-agent-acp 0.73.0; **the other three agents send nothing
here.** kimi backgrounds shells, agents and cron jobs and maps its own
terminated event nowhere, codex's `unified_exec` PTY outlives the call it was
made in with no push at all, and opencode's `bash` tool has no background flag.
So an empty panel says *which* emptiness it is: `No tasks currently running` is
only sayable about an agent that would have told us, and the other three get a
sentence naming the silence instead.
Three limits, stated because each of them is somebody's build. A session
**claude reports live work in** is not parked by the idle sweep and is not the
one released when a machine hits its ceiling — the work outlives the turn, so
the half-hour that is enough for an idle conversation is not enough for this
one. That protection is exactly as wide as the signal it rides: the other three
agents report nothing, and even under claude a backgrounded *subagent* is
announced as nothing at all, so for those the only thing standing between a
build and a released agent is still the floor on how young a session may be and
still be taken at the ceiling — two minutes. **Nothing here survives a daemon
restart**: a resumed session starts with an empty set, and the transcript
carries a row saying how many tasks were running when the agent was shut down,
because the tasks themselves went with the process. And a `/clear` closes the
ACP session those tasks belonged to, which is what ends the shells it was
holding — a rung below that nothing here can see, so the transcript claims only
that the work was going and that this daemon can no longer say anything about
it. The rows go with the conversation, and a row of its own says how many were
going, since otherwise the only trace of a build somebody was waiting on
vanishes with the panel.
- `POST /sessions/:id/async-tasks/:taskId/stop`, and `backgroundTasks` and
`reportsBackgroundTasks` on the session snapshot. Stopping answers `200` with
`stopped: false` when the task ended on its own between the tap and the request
— that is an ordinary lost race, not an error — `404` for an id this session
never announced, which is a different sentence, and `502` where the agent could
not be asked at all, which is *nobody knows* rather than *it was already over*;
the card says so where it was pressed. Both snapshot fields are optional on the
wire, so a client reading a daemon that has not been updated draws exactly what
it drew before any of this existed.
### Changed
- **The composer's send slot follows what you have typed rather than what the
agent is doing.** With the box empty it is Stop, as before; with anything in it
worth sending it is Send. Whitespace does not count, so a stray space or tab
leaves Stop where it was.
- **The transcript's "still working" row opens the background panel rather than
unfolding a list beneath itself.** It used to be a disclosure drawing the
agent's outstanding delegations inline; two surfaces listing one set is how
they come to disagree, and the inline one could not grow to hold a task card
without pushing the composer down the screen every time an agent backgrounded
a shell. The row keeps the job it was always good at — saying, in the
conversation, that something is still going — and is pressable.
## [0.8.0] - 2026-09-11
### Added
- **Terms of Use, an Acceptable Use Policy and a Privacy Policy, at `/terms`,
`/acceptable-use` and `/privacy`.** Readable with no account, because the sign-up
form links to them and because they are the URLs somebody is given when they ask
what the terms are. Signing up now needs a ticked box, which gates the form and
is refused by `POST /v1/register` without an `acceptedTerms` field — but nothing
is stored, so this instance still cannot prove what anybody agreed to and does
not claim to. All of it is off unless `REEMOAT_CP_LEGAL_DOCUMENTS` says
otherwise: an instance that has not claimed the documents draws no pages, no
box, and refuses nobody. The
documents are adapted from the 37signals policies (CC BY 4.0, credited on the
page) and `github/site-policy` (CC0), and they name **one operator** — a fork
must replace the `OPERATOR` block in `packages/web/src/legal/operator.ts`. English
only.
- **An idle agent is now shut down and the conversation kept.** A session nobody has
touched for `REEMOAT_IDLE_PARK_MINUTES` — 30 by default, on by default, `0` to
switch it off — is stopped with a new `parked` exit reason: the process goes, the
conversation, the worktree and the branch stay, and **the next message brings it
back**. There is no Resume control for one, deliberately; the composer is the whole
affordance. Measured before it was built: a resident agent is ~397 MB and comes
back in ~1.3s at the median, so a machine holding three sessions nobody had opened
in 48 hours was holding 1 384 MB to save that. It draws as an ordinary idle session
and says nothing, Stop still works on it, and its model and mode chips stay live —
a tap is recorded and applied when the agent returns.
- **`GET` and `PATCH /settings` on the daemon, and a control on the machine's
settings screen** for the number above. A saved value **overrides**
`REEMOAT_IDLE_PARK_MINUTES`, which is therefore the default for a machine nobody
has set; it takes effect without a restart. The daemon's configuration is still env
only — this is the narrower class of setting whose owner is the person using the
machine rather than the one who deployed it.
- **`parked` joins the `SessionStatus` and `ExitReason` unions**, and it is neither
`interrupted` (the daemon owes it back by itself) nor `exited` (somebody ended it).
A client older than this release has never heard of it; it keeps the composer and
reads the session as live, which is the safe direction, but it will label the exit
`ended: parked`. Deploy the control plane, which carries the web client, before the
daemons.
- **Give up a share somebody made to you** — `DELETE /v1/machines/:id/grants/me`,
driven by `cpctl leave <machineId>`. Sharing writes a permanent row for any user
id with nothing asked of the person named, and the three verbs beside it all
resolve through ownership — so until now the only account that could undo a share
was the one that made it. Your own grant only, and never on a machine you own:
the machine list is a join over `grants`, so an owner without one would own a
machine that appears in no list. Retiring it is the verb for that.
- **Share a machine you own** — `GET` · `PUT` · `DELETE /v1/machines/:id/grants`,
driven by `cpctl shares` / `share` / `unshare`. The other person is named by
**user id**, which they read off `cpctl me` and tell you: there is no directory
an ordinary account may read, and a name lookup here would be a way for anyone
signed in to test whether an account exists. Your own grant is refused on both
writes — narrowing it would take `machine:admin` off your own hardware, and
removing it would hide the machine from its owner. Retiring the machine is the
verb for giving up your own access.
- **`enrolledBy` on `GET /v1/machines`**, and drawn on the machine row: whose
enrollment code a machine enrolled with, when that was not yours. It is the
disclosure for a composition no single refusal closes — revoke somebody's
machine, register a new one under the name that frees, enroll it on your own
hardware, and their list draws the name they lost, owned and online. Every step
has to stay, so the composition is made visible instead. A name is something to
recognise rather than an alarm: the installer's wizard enrolls on an admin's
code too.
Machines that enrolled **before** this column existed — which on the day of the
upgrade is all of them — say *somebody this control plane did not record* rather
than nothing, because nothing is what a machine you enrolled yourself draws and
folding them together would have left the disclosure silent for exactly the
population it is for. There is no backfill: the table it would read from is swept
seven days after a code is used.
Two limits, stated because a disclosure nobody has bounded gets trusted past what
it says. It names who **minted** the code, never who redeemed it — `POST
/v1/enroll` is public and a daemon presents no account, so a code of your own
that leaks and is redeemed elsewhere reports you. And it moves when the machine
list is read, on a wake or a reload, not on the four-second poll.
- The session rail is ordered by you. Grab a chat with the mouse and drag it up or
down inside its folder; on a touch screen hold it briefly first, so the gesture
and scrolling the list stay apart. Drop it in Pinned to pin it, and drag it back
out to unpin. `Alt`+`↑`/`↓` on a focused row does the same from a keyboard. The
order is stored per machine on the daemon, so one set from a laptop is the one a
phone opens.
- **Up one folder** in the New session directory picker, beside the path — a 44px
square with a ground of its own, sized for a thumb. Always drawn and greyed out
at the top of the tree rather than appearing when it becomes usable; the path
itself is still tappable segment by segment.
- A way back from **Import code**. Its ✕, Escape and a tap outside now return to
New session with the machine, agent and folder still chosen, and there is a
chevron that says where it goes. Closing it mid-upload also stops the upload,
which used to hold the machine's import lock.
- A second control on the error screen: **Go to sessions**, beside Reload. A
screen that throws every time it renders made Reload a loop.
- An instance can point somebody who has no machine at somewhere to get one.
`REEMOAT_CP_MACHINES_OFFER_URL` in the control plane's environment takes an
`https://` address, and the three screens that already print the one-line
installer draw a second, quieter link beside it. Environment-only on purpose:
it names one particular shop, and a runtime setting would draw it on the
Server settings screen of every instance. **Empty by default**, and an instance that never sets it
looks exactly as it did before. The signed-in person's email address travels in
the link as `?email=`, so a checkout on the far side can prefill its own form;
it goes only when they tap it, and every response here already carries
`referrer-policy: no-referrer`, so nothing else about the instance goes with it.
The address is configured rather than compiled in because this is AGPL software
and forks run their own control planes.
The offer is drawn only where a machine may still be added: at or over the
machine limit a bought host would be refused at the dial, so offering one there
would sell something this control plane will not connect.
### Changed
- **A machine at its session ceiling now releases an idle agent instead of refusing.**
`POST /sessions` and any wake take the least recently used **idle** slot rather than
answering `429`, and a turn in flight, an unanswered permission and an unanswered
question are never taken at any ceiling. A create is still refused when every live
session is genuinely busy, and the sentence says so. The ceiling therefore counts
agents resident rather than conversations held.
- **An admin may no longer mint an enrollment code over a live one somebody else
made.** `POST /v1/admin/machines/:id/enrollments` answers `409 code_outstanding`.
Minting supersedes the machine's current code, so on a machine that is owned but
has not enrolled yet — which is what an install in progress looks like — an admin
minting here killed whatever the owner was holding: their install failed against
the deliberately undifferentiated `409 code_unusable`, nothing on their screen
said why, and it could be repeated for as long as somebody cared to, because
`enrolled_at` never left NULL and the guard beside it never started applying.
The two uses that stay are the two it was for: `install.sh`'s wizard mints on a
row that has no code at all, and an admin re-minting their own supersedes only
what they are replacing.
- **Deleting an account no longer strands a machine it was the last person on.**
`DELETE /v1/admin/users/:id` already revoked the machines the account *owned*; a
machine it merely held a grant on was left ownerless, enrolled, dialling the
relay and in nobody's list. That is the failure user-owned machines exists to
remove, and it was also a way to *manufacture* the state the two admin guards
protect — delete the last grantee, and an ownerless enrolled row becomes
adoptable with every scope. Scoped to machines the deleted account was actually
on: a legacy row that was already grantless is left exactly as it was.
- **An admin may no longer take a machine off the person who has it.** `PUT
/v1/admin/machines/:id/owner` answers `403 machine_owned` for a machine with a
live owner other than the target, and `403 machine_granted` when adopting an
ownerless machine somebody already holds a grant on unless they are the one
being handed it. What it still does is what it was written for: adopting a row
registered before ownership existed, and re-labelling a machine for the owner it
already has. Adopting now also burns that machine's outstanding enrollment codes
— the guard below protects minting, and a code kept from before an adoption
would otherwise still replace the daemon afterwards.
- **`POST /v1/admin/machines/:id/enrollments` answers `409 machine_enrolled`** for
a machine that is enrolled and has an owner or grantees. Redeeming a code
retires the running daemon's tunnel credential, so minting one for somebody
else's live machine does not read it — it replaces it, and every grant-holder's
traffic lands in the new process while the owner's list still says owned and
online. Its owner mints their own. A machine that has never enrolled is
untouched, which is what the installer's wizard does.
- **Nothing in the rail reorders itself any more.** Rows used to sort by their most
recent event, and a chat waiting on you jumped to the top of its folder — so the
list moved under your thumb on every poll. A chat waiting on you still says so
three ways: the ring on its status dot, its title going semibold, and the count
on its folder's header. A new session appears at the top of its folder, which is
now the only thing that moves by itself.
- The back and close controls in every pop-up are bigger — 32px of ink instead of
24px, with the same 44px target they always had.
- The directory picker writes `~` instead of your home directory in full, and the
path reads as one string rather than one with wider gaps between some of its
slashes. The session header cuts the same prefix.
- The `in <folder>` line at the foot of New session is gone. It named the folder
the picker above it was already showing.
- A row you press down on says so straight away, instead of waiting out the hold
in silence — holding still is what starts a drag, and nothing on screen was
saying so. It now lifts under a shadow rather than a change of tone, which is
the cue a thumb covering the row does not hide, and a phone gives a short
vibration at the moment the chat comes off the list.
- **The ⋮ menu is drawn on every row in the rail.** It used to appear on hover,
except on pinned rows, which drew it always — so two rows a few pixels apart,
alike in every other way, had a different number of controls. A row's only menu
should not be hidden until you are already pointing at the row.
### Removed
- **`PUT` and `DELETE /v1/admin/grants`, and with them `cpctl admin grant` and
`cpctl admin ungrant`.** A grant is full access to a machine that runs coding
agents as its owner, with no sandbox — and these wrote one for *any* machine on
an admin credential alone, with no consent from the person whose machine it was
and nothing on any screen afterwards. Sharing is the owner's verb now (below).
The two `cpctl` verbs answer with the replacement rather than "unknown command",
because the old spellings are in scripts and in shell history. `GET
/v1/admin/grants` is kept: seeing who holds what is not the power that was
removed, and an operator who cannot read that table cannot answer "why can this
person reach that machine".
- The context-window ring is gone from the web client. It reported how full an
agent's window was and reported nothing at all on kimi, which never sends the
notification it was built on, and nothing on any session waiting for its agent —
a control that was blank for most agents most of the time, in a row where
everything else changes what the next turn does. Nothing changed on the daemon:
the reading is still measured, still on every session snapshot, and still
printed by `pnpm client`.
### Fixed
- **A chat crossing into or out of Pinned jumped.** Making that group a row taller
pushes the folder below it — and the chat being carried — down by exactly one
row, which the drag did not know about because it measured from where the row had
been when the gesture started. It measures from where the row actually is now,
every frame. The room a group makes also animates on the same clock the rows do,
instead of appearing in one jump under rows that were still sliding.
- **The last place in Pinned could not be reached.** Aiming at the end of the group
unpinned the chat instead: "still pinned, at the end" was a band half a row tall
with unpinning on the other side of it. Carrying a chat out of Pinned now takes a
deliberate movement past the group, and says **Release to unpin** at the pointer
while you are out there — it is the one outcome of a drag that dragging back does
not undo. (Fixed twice: the first attempt widened the boundary only for chats
already pinned, which left the same slot unreachable for one arriving from a
folder. The boundary between two groups is now simply whichever is nearer.)
- **`Alt`+`↑`/`↓` on a pinned chat could move a chat on another machine.** The
keyboard walked every pin in the fleet while the rail draws only the selected
machine's, so the keypress computed a position among rows that are not on screen
— usually looking like nothing happened, and occasionally rewriting the positions
of another machine's pins.
- **Moving a chat down by exactly one place did nothing**, in silence — which is
why the last slot of a group could not be reached from the row directly above
it, and why "I still cannot put anything in the last place in Pinned, I can only
carry the last one higher" was reported after the boundary was fixed. The drop
compared two positions counted in two different ways, one of them counting the
chat being dragged and the other not, and treated a genuine one-place move as
landing where it started.
- **Pinned rode on top of the sessions under it** while a chat was carried into it
from a folder. Moving rows aside does not make room for one; the group being
joined now takes the height and the group being left gives it back, so nothing
past either of them moves.
- **The ⋮ menu on a row stopped opening.** The drag took the pointer at the press
and the button never heard the click meant for it. The row is the drag surface
everywhere except where it already carries a control.
- **Dragging on a phone did nothing, through four attempts.** The first two treated
a cancelled pointer as the gesture ending, which is right for a mouse and wrong
for a finger: on a touch screen it means the browser has decided the gesture is
its own, which it does the moment it commits to a scroll. The third moved the
drag itself onto touch events, which the browser goes on delivering. All three
left the *setup* — the hold, the listeners, the refusal of the platform's own
long press — in the pointer press, and that is the assumption none of them
questioned: that the pointer press arrives before the browser has decided what
the touch is for. Nothing requires that, and on a browser that decides first,
every one of those fixes was one event too late, every time. A finger's gesture
now begins, moves and ends on the touch stream, and the pointer handlers say in
their own text that they are a mouse's.
- **A drag showed a line instead of moving anything.** The rows now step aside as
you go, exactly as they do on the agent list, and the drop changes nothing you
can see — which is what tells you it landed where you left it.
- **Dragging a chat out of Pinned now unpins it** even when the folder it belongs
to is collapsed or hidden by the filter. It was refused when there was no list on
screen to drop into.
- **"There is no room between those two rows"** is gone. A drop is never refused
for arithmetic; the positions around it are re-spaced and the drop happens.
- **A drop at the top of a group sent the chat to the bottom.** Two branches of the
position arithmetic were the wrong way round, and the test covering them had been
written from the code rather than from what a drop means, so it agreed.
- **Clicking a chat in the rail with the mouse stopped opening it**, which arrived
with the mouse drag and had not been noticed. Taking the pointer at the press
hands every later event to the row's wrapper, the click included, so the button
inside it that does the opening was never in the click's path. The pointer is
taken when the drag actually starts instead. Found by driving a real browser
through the debugging protocol rather than by reading.
- **Dragging a session with the mouse did nothing.** A mouse was being asked to
long-press, which is a touch idiom: a finger has to be told apart from scrolling
the list, and a button does not. A press and 4px of movement start the drag now.
- **And on a daemon that had not been restarted it could not have worked, silently.**
The column that stores the order arrives with the daemon's own migration. Trying
to drag a row on such a machine now says so. **Restart the daemon once after
updating.**
- The composer is one box. The message field, the attachment chips, the paperclip,
the agent's controls and Send now sit inside a single rounded container instead
of a bordered field, a bordered send button and a separate strip of bordered
pills below it — seven outlines in two rows at the bottom of a phone. The chips
lost their borders with it: the box is what says a control is there, and each
chip's chevron is what says a list opens. A chip that cannot be tapped now dims
by dropping its ink rather than by fading the whole control, which is a
correction as much as a restyle — at 40% opacity the border it used to rely on
was already below the contrast it existed to hold. The rule that used to run
above the composer is gone; so is the backdrop blur, which was blurring a
backdrop nothing ever scrolled under.
- Send is a circle with an arrow in it. A paper plane is a mail metaphor for
something that is not mail, and a filled hard-cornered square is the shape a
**stop** control has — in the one slot where Stop appears a second after a turn
starts. All four things that occupy that slot take the circle, so it never
changes shape under a thumb.
- The mode chip says what it is set to and no longer also says "Mode". The glyph
beside it, its fixed position and its accessible name already said so, and the
word was spending width on the narrowest screen next to the value it was pushing
into a truncation. A chip now draws its own name exactly where no glyph does.
- The controls below the message field rest a shade quieter, and are grouped by
spacing: a wider gap where the kind of control changes — the paperclip acts on
the message, the chips describe the turn, Send is the action — and a narrower one
inside each group.
- Send sits on that row too, level with the attach control and the agent's
settings, which is what makes the composer read as one object rather than as a
field with a toolbar under it. On a narrow phone the row is now over its width
and the chip values truncate, which is what they have always done there under
pressure; there is no arrangement in which three pills and two icon buttons fit
a 390px line.
- The empty composer says `Type / for commands` instead of `message…`, because
`/` is the one thing in the box that nothing on screen advertised and an empty
box already reads as somewhere to write. It still says just `message…` on a
session whose agent is away, where that key would open nothing.
- Send is smaller: the same 32px box as the controls beside it, so the row is one
height end to end and the filled circle is no longer the loudest object in the
composer. It still reaches the 44px tap minimum, the same way the chips do.
- The picker sheet slides back down when it is dismissed instead of vanishing
between two frames, its section headings carry the same icon as the chip that
opened them, and the check beside the chosen row is heavier and now sits on the
line of the name rather than above it.
- That sheet has two heights and a grab bar that stays put, and it moves with your
finger rather than after it. It opens at about three fifths of the screen with
its options not scrolling; dragging it, or dragging the list, takes it exactly as
far as you drag, and it settles onto whichever height is nearer when you let go —
full, where the options scroll, or back to where it started. Pulled far enough
below that, it closes. The bar used to scroll away with the first screenful of a
long model list and did nothing when you pulled on it, and every control opens to
full height now, not only the ones with more rows than fit. The sheet also stops
moving when it arrives: it used to spring back past the height it had settled on
and wind round to it a second time.
- Less room above the grab bar at the top of that sheet, and a little more between
a control's icon and the word beside it — at four pixels the two were reading as
one shape.
- On a phone the agent's settings open as a bottom sheet over a scrim instead of
as a small panel above the box, and the model control folds into the mode
picker rather than keeping a chip of its own — so the row is the attach button,
mode, effort and Send. On a desktop nothing changes: the same panel, in the same
place, with the model chip still on the row. Both are drawn and the browser
chooses, so a window dragged across the boundary can never show a picker that is
not there. The sheet is closed by Escape, by the scrim, or by choosing a row;
the platform Back button does not close it.
- A chip is as wide as what it says again, capped at 128px and clipped past that,
and the space inside it between the glyph, the value and the chevron came down
from six pixels to four. Each chip used to hold open the width of the longest
value its control could ever show, which is why nothing moved when a value
changed — and why three chips sat side by side mostly empty. That trade is
reversed: they hug their content, and a value that grows moves its neighbours
again.
## [0.7.0] - 2026-09-06
### Changed
- The fleet's `claude` follows the `latest` release channel by default, and the
channel is a setting: `REEMOAT_AGENT_CHANNEL=stable|latest` in the daemon's env
file, `--agent-channel` on the one-line installer, `--channel` on
`deploy/agents.sh`. Both daemon hosts were on `stable` at 2.1.236, a build that
had never heard of the newest model, while `latest` was 2.1.261. The refresh
runs `claude install <channel>` rather than `claude update` — `update` follows
whichever channel the last install wrote into claude's own settings, so a host
installed on `stable` would have stayed there whatever the env file said. A
change of channel moves the machine on the next run, down as well as up, with
the previous build kept on disk and no session interrupted.
- `PUT /v1/me/email` asks an API-key caller with a password for `currentPassword`
— `400 bad_request` without it, `401 invalid_password` with a wrong one, and
nothing is written or mailed until it verifies. A session still changes the
address alone, and an account with no password row is still let through on
its key. The address is the reset channel; a key can leak from a disk with no
person anywhere in the chain and no admin reset behind it, where a session is
a person signed in, listed under Devices and one tap to end.
- `cpctl key` no longer prompts for a password the route never read. `cpctl
email` still does when the shell holds an API key, and asks nothing when
`REEMOAT_CP_KEY` came from `cpctl login`, since the route ignores a password
from a session. `cpctl keys` prints when each key was last used.
- Every two-step confirmation on a settings row is one control, `TwoStep`. The
first tap still replaces the row's buttons with the question, the act and
Cancel — Cancel last, on the same pixels, the question standing until the
server has answered — held in one place and pinned once rather than
re-derived on each of fourteen sites. The two centred confirmations keep
their shape; the question reads in the text colour everywhere, with its
consequence muted under it. Signing an agent out on a machine that has left
the list is refused, greyed, rather than answered with a toast.
- Settings say less, again: the New session strip's lede, the two limit
consequences, the not-enrolled line, the agent card's unknown-sign-in line,
codex's two caveats and the plugin consent's `http` caveat are cut to the caps
the plan set, with the same facts in fewer words.
### Removed
- An admin's view of anybody else's API keys. `GET` and `DELETE
/v1/admin/users/:id/keys[/:keyId]` are gone, the fleet list no longer counts a
person's live keys, and the "API keys" item in a user row's menu — with the
panel it opened — is gone with them. A key is listed and retired by the
person holding it, on their own API keys screen or with `cpctl keys`; an
admin's reach over an account is disable and delete.
### Fixed
- A settings pop-up drew two scrollbars it had no use for on a desktop: a
horizontal one along its foot and a vertical one down its right edge, on a
screen that fit. A sheet's body was a padded scroller whose every screen
cancelled the padding with negative margins, and a scroll container counts
its own end padding past the content, so each axis had one padding of range
nothing could show. The body no longer scrolls or pads — every pop-up scrolls
in a box of its own — and inside the settings and plugins pop-ups the section
rail and the pane scroll with no bar drawn at all.
- Every row of the API keys table is the same height. A row with a Revoke
button was the button's height plus padding and a revoked row was its text
plus the same padding, a third shorter.
- The startup prune deletes only inactive sessions, never below fifty, and says
what it removed. It read `created_at`, so a conversation older than seven days
from the day it was *opened* was deleted at the next restart however much it
was in use — on 2026-09-04 one deploy's restart deleted five of the six
conversations it had just stopped, with their transcripts (~50 MB), and the
journal held nothing but `restored 1 session(s)`. A session is now swept only
when it was ended by a person or by the agent, never started, or given up on
because the agent no longer holds the conversation (only a manual Resume
tries again) — never a live one, and never one the daemon ended on its own
restart or shutdown and is still coming back to, at any age and under any
cap — and only once untouched for seven days by its last write; a prune
never leaves fewer than fifty rows (`REEMOAT_MIN_SESSIONS`) — the ones it
never sweeps first, then pins, then the most recently touched, whatever
their age; the two-hundred cap is on the rows nobody is coming back to and
takes the least recently touched of them, pins last; and every id that went
is printed at startup on its own `store:` line.
- A valid API-key request no longer answers a plain-text 500 when the
`last_used_at` bookkeeping write meets a busy database: the write is guarded
the way the session one already was, so a request that could not record its
own use still succeeds.
- Reset on an Email field survives the next Save. Save sends every SMTP field
from the draft, and a Reset re-synced the draft only while the form had no
other edits — so edit Host, Reset From, Save wrote the old From straight back
under a "Saved." toast.
- Public URL is filled in with the page's own origin on a fresh server, since
mail cannot be sent without it and the field drew the origin only as a
placeholder. Save is live at once; the provenance line says "not set" until
it lands. A value already stored or set in the environment is left alone.
- New key waits for the key list to load rather than opening the leaf during
the skeleton only to be told the ceiling. A list that failed to load still
lets you mint.
- Every Revoke on the keys screen and under a user's keys names its key to a
screen reader.
- Three controls no longer stay live during the write they belong to: a
field's Reset while a Save is in flight, the strip's Remove while its delete
is out, and the device-code box, which empties after the code is written so a
failed send leaves it in the box beside the toast rather than gone.
- The machines list no longer jumps on load: the loading row is the height of
the machine row it stands in for. And a retired machine leaves the list at
once rather than a round trip later.
- Revoking the key this browser holds still signs the tab out when browser
storage is blocked. The one-shot notice for the sign-in screen was written
unguarded, and a browser with storage disabled threw there before the
credential was cleared — so the tab kept a dead key and the next request said
"Your session expired" about an act the person had just chosen.
## [0.6.0] - 2026-09-04
### Added
- `users.password_changed_at` and `api_keys.last_used_at` on the control plane,
both additive: `GET /v1/me` answers `passwordChangedAt` and both keys routes
answer `lastUsedAt`, touched on an accepted bearer lookup at most once a minute
and never on a revoked key. An older client ignores the fields and an older
control plane answers without them.
- `deploy/ci-freshness.sh`, run weekly by `.github/workflows/freshness.yml`,
compares each ACP adapter pin in `package.json` with what the npm registry
serves and writes the answer into the job summary. Being behind is a report
and never a failure (`FRESHNESS_MAX_BEHIND` is the margin that makes it one);
a pinned version the registry no longer lists fails the job, since it would
fail `pnpm install --frozen-lockfile` on the next machine the one-line
installer sets up; and a registry that could not be asked exits with its own
code rather than a verdict about the tree. `pnpm deploycheck` drives every
outcome offline through the `NPM_VIEW` seam.
- `renovate.json`: pull requests proposing dependency bumps, the two ACP
adapters one each and the rest of the workspace grouped weekly, with exact
pins and no automerge — a person merges, and each machine still takes the
change through `deploy/deploy.sh`.
- The daemon keeps the coding-agent CLIs current by itself. `deploy/agents.sh`
installs what is missing — three with each vendor's own installer into the
vendors' own directories (`~/.local/bin`, `~/.local/share/claude`, `~/.codex`,
`~/.opencode`), kimi from the npm registry into `~/.reemoat/toolchain` — and
refreshes what is there; the one-line installer runs it once, and the daemon
runs it five minutes after start and then daily. On by default —
`REEMOAT_AGENT_UPDATES=off` switches it off, and `deploy/agents.sh --check` says
what a run would do without doing it. `--uninstall` removes the toolchain, and
the npm-installed CLIs with it, and leaves the vendors' directories in place
because they hold sign-ins of your own.
- `GET /agents/capabilities` rows carry `cli`: which build of the harness's own
CLI published the model list, and whether it was an operator's override
(`override`) or the copy found on PATH (`path`). The model picker draws it under
the provider heading. Absent from older daemons; `null` where nothing was
spawned.
- The daemon announces which build of each agent CLI it would launch on the
tunnel handshake (`x-reemoat-agent-clis`, `claude=2.1.259;codex=0.153.1;kimi=-`),
beside its own version and under the same rule: recorded against the machine,
never acted on. `GET /v1/admin/fleet` answers it as `agents` per machine and
`cpctl admin fleet` prints it, offline machines included — so "which machines
are running a July claude" is answered without touching one. As fresh as the
machine's last dial; a daemon older than the header is listed with `null`, and
a value the relay cannot read is refused to `null` rather than costing the
tunnel.
- `CLAUDE_CODE_EXECUTABLE` and `CODEX_PATH` are documented in `.env.example`; a
harness named there runs as named, and the daily refresh leaves it alone.
- `REEMOAT_AGENT_SOURCE=npm`, and the one-line installer's `--agent-source npm`:
all four CLIs from the npm registry rather than the vendors' own hosts, for a
machine that cannot reach them. Your mirror is named the way npm is pointed
anywhere (`~/.npmrc`, `npm_config_registry`); everything lands under
`~/.reemoat/toolchain`. A choice rather than a fallback — a vendor outage never
switches a machine to a differently built binary on its own. It decides only
how a missing CLI is installed: one already on the machine keeps being refreshed
the way it was installed, and under `npm` a vendor-installed copy is reported on
every run until it is removed.
- A model id can be **typed** under a routed provider in the agent builder — a
field at the foot of Moonshot's, Z.ai's, MiniMax's and OpenRouter's rows — for
an id the written-down list does not hold. It becomes a row like any other, with
the same key and pairing rules, and picking it names the agent after it. The
written-down lists are a starting set and were refreshed against each vendor's
documentation: Moonshot's three had all been retired (`kimi-k3`,
`kimi-k2.7-code`, `kimi-k2.6` now), Z.ai gains `glm-5.3` and `glm-4.7`, MiniMax
gains `MiniMax-M3` and `MiniMax-M2.7`. The same mechanism lets an assembled
agent whose model has since left every list be renamed and saved rather than
drawing an empty Model field.
### Changed
- Settings is six sections — Account · API keys · Machines, then under an
"Admin" heading Server · Email · Users. API keys are a table on a screen of
their own, with when each was made and last used and which one this browser
holds; New key is one tap, and the key is shown once on `/settings/keys/new`.
Email (SMTP, the test send, delivery trouble) is split out of Server;
registration is a badge and a verb button, reminting the provisioning key is
two-step. Password and email changes are their own screens rather than forms
opening inside a row — nothing on a settings screen expands in place. Every
confirmation names its subject with Cancel last; help prose is cut to the caps
the plan set, and the machine row marks a machine that is not yours with a
`shared` badge. Revoking the key this browser holds signs the tab out on
purpose, and the sign-in screen says so.
- `POST /v1/me/keys` and `PUT /v1/me/email` take the session alone: neither asks
for the current password any more, and a `currentPassword` in the body is
ignored rather than verified. `POST /v1/me/password` still asks. The cost of
the email half is written at the route.
- `@agentclientprotocol/claude-agent-acp` 0.63.0 → 0.73.0 and
`@agentclientprotocol/codex-acp` 1.1.9 → 1.8.0. Re-measured against `claude`
2.1.259 and `codex` 0.153.1: the model chip reads the model claude's `Default
(recommended)` stands for rather than the placeholder (the bullet below is the
rule that makes it so); codex answers `openai` as its provider id where 1.1.9
answered `custom-gateway`, and nothing had that written down; codex publishes a
collaboration-mode control and an `ultra` effort level, and claude an `Agent`
persona control wherever `.claude/agents/` has a file — each drawn as a plain
labelled control. `node_modules` is 230 MB on darwin-arm64 after the bump.
- The model picker also collapses claude's `Default (recommended)` row onto the
model it stands for when the placeholder's description is that row's *name*,
which is what claude-agent-acp 0.73.0 publishes; 0.63.0 copies the row's blurb
instead, and that case still collapses. Without the second rule the placeholder
came back after the adapter bump and the chip read "Default (recommended)".
- A session whose harness has no CLI on the machine yet is left waiting rather
than given up on: the boot pass spends no attempt on it, runs the agent
installer at once instead of in five minutes, and tries again when it
completes. The daemon's log shows every completed agent update, with the
script's own notes under it. Only a missing CLI is treated that way — a missing
adapter package or a plugin harness that is gone spends its attempts and settles
as before, since the installer cannot put either back — and only the first run
is pulled forward; after it the daily run is the retry. The session row says
"<agent> is not installed on <machine> — waiting for it to be installed" for
the wait, rather than the plain restart line.
- An empty fleet is set up from the content pane on a wide screen: the one-line
installer and one line about it draw beside the rail rather than inside it, at
a width the command can be read at. The command box wraps instead of scrolling
sideways, on every screen that draws one.
- A machine is added by running the one-line installer on it, and only that way.
The by-name form on Settings → Machines, which handed back a setup code to
carry to the host, is gone, and so is the "Add a machine" button that led to
it; `cpctl enroll` still mints a code for an operator who needs one. Since
nothing in the tab adds a machine any more, an empty fleet is re-listed on
every poll, so the machine the script enrols appears by itself.
- Settings say less. Sentences that restated the control beside them, explained
a mechanism nobody acts on, or printed a URL already on screen were cut
across Account, Server, Users, Machines, Systems, Agents and Plugins; every
consequence a person cannot see stays.
- The one-line installer unsets `REEMOAT_API_KEY` as soon as it has read it, so
the account key no longer reaches `pnpm install`'s lifecycle scripts or the
vendor installers `deploy/agents.sh` downloads and runs.
- The coding-agent CLIs are no longer installed by `pnpm install`. The two ACP
adapters stay pinned; the CLI platform packages they used to bring with them are
excluded through `pnpm-workspace.yaml` overrides, and `node_modules` went from
907 MB to 217 MB on darwin-arm64 (230 MB after the adapter bump above).
`deploy/agents.sh` installs the only copies
there are, and the daemon runs an operator's override, else the first copy on
PATH, then in the directories that script installs into — re-decided every ten
minutes, so a refresh under a running daemon is picked up. A harness with no CLI
on the machine is refused with a sentence naming the script rather than started.
- `deploy/deploy.sh` installs or refreshes the agent CLIs before it restarts the
daemon, so a machine upgraded from a release that vendored them comes back with
its harnesses rather than waiting on the daemon's first daily run.
- `--source` and `REEMOAT_AGENT_SOURCE` decide only how a missing CLI is
installed; one already present is refreshed the way it was installed — a
vendor-installed copy by the vendor's own updater, an npm-installed one from the
registry — and the one thing a switch to `npm` cannot refresh, a vendor-installed
copy, is named on every run until it is removed.
- The one-line installer refuses `--agent-source` on a machine that is already
set up, naming `REEMOAT_AGENT_SOURCE` in the env file rather than accepting a
flag that would change nothing.
- The one-line installer's questions are arrow-key menus, and a password or API
key no longer reaches `curl`'s command line.
- `controlPlaneUrl` on `POST /v1/machines`, `POST /v1/machines/:id/enrollments`,
`POST /v1/provision` and the admin mint honours `x-forwarded-proto` behind
`REEMOAT_CP_TRUSTED_PROXY_HOPS`, so a daemon enrolled through a TLS proxy dials
`https://` rather than the plaintext origin.
- `--uninstall` exits non-zero and leaves the private node in place when the
service could not be stopped, rather than deleting the node a still-installed
unit runs. `--purge` always confirms and names the database and checkout it is
about to delete, not only the worktrees; `--yes` still answers.
## [0.5.0] - 2026-09-01
### Added
- **One command puts a machine in the app.** `curl -fsSL <release asset> | sh`
installs the daemon, asks which control plane to join and who you are on it —
sign in, sign up, or paste a key — creates the machine and starts the service.
No `sudo`, nothing written to a shell profile, and nothing outside `~/.reemoat`
and the checkout, which is what makes `--uninstall` complete rather than
approximate. It hands the last third to `deploy/install.sh daemon
--non-interactive` rather than reimplementing unit rendering, PATH computation
and the health probe.
**Where the software comes from and which fleet it joins are two questions,
and the command keeps them apart.** The README downloads from a release asset
on this repository; a control plane serves the same file at `GET /install.sh`
with its own origin substituted in, which is what Settings → Machines prints
and why there is nothing to type there; `--url` or `REEMOAT_CONTROL_PLANE`
says it outright. Fetched from anywhere neutral it **asks**, with no default —
letting a download URL also decide which fleet a machine joins is how somebody
who wanted their own control plane arrives in one they do not run.
Three things about it are load-bearing rather than tidy. Everything runs from
one `main "$@"` on the last line, because `curl … | sh` executes bytes as they
arrive and a truncated download otherwise runs a *prefix* of the file with
`set -e` silent. Every question is asked on `/dev/tty`, because stdin is the
download. And the origin substituted into the served copy is shell-quoted: a
`Host` of ``a`id`b`` reaches `URL.origin` intact, and `imagecheck` sends one
through a real container to prove the quoting is on the path a request takes.
- **A host can pull the control-plane image instead of building it.**
`REEMOAT_CP_IMAGE` decides — a registry-qualified ref pulls, a bare one builds
— and `deploy.sh` prints which on every run. `REEMOAT_CP_SOURCE` overrides the
derivation, and an unrecognised value is refused rather than defaulted.
In pull mode `CP_IMAGE_INPUTS` does not apply: a git diff is a guess at what a
build would produce, and a registry ref names exact bytes. Everything
downstream is untouched — `cp_image_fingerprint` inspects the local image
either way, so `CP_IMAGE_MOVED` and the `RELAY_INPUTS` rule that decides
whether the fleet's tunnels drop behave exactly as they do after a build.
### Fixed
- **The documented way to run the published image had never worked.**
`deploy/README.md` said to put `REEMOAT_CP_IMAGE=ghcr.io/…` in the control
plane's env file. It could not win: compose gives the shell environment
precedence over `--env-file` for `${…}` interpolation, and `compose.sh`
exported its own default before compose ever ran — measured, `compose.sh
config` with a registry ref in that file still printed
`image: reemoat/control-plane:current`.
The half with no symptom is worse. `deploy.sh` calls `cp_image_fingerprint`
from a process where that variable may be unset while compose's child had it
from the file, so the fingerprint would inspect a *different name*, answer
empty on both sides, conclude the image had not moved, and recreate nothing —
a deploy that printed success while the old bytes kept serving. There is one
resolver now (`cp_image_ref`), every script reads it, and `deploycheck`
asserts no script holds a second copy of the default.
- **`GET /install.sh` answers with the scheme a browser actually used.**
`publicUrl` takes it from `socket.encrypted`, and the service runs plain HTTP
behind a proxy that terminates TLS — so it answered `http://`, and the plain
form of a deployed origin is a `301` the installer deliberately does not
follow. `x-forwarded-proto` is read, gated on `trustedProxyHops` exactly as
`callerAddressOf` is. ⚠ The same defect on `controlPlaneUrl` (four
code-minting routes) is known and **not** fixed here: changing `publicUrl`
itself changes what every enrollment paste has said since the first release.
## [0.4.0] - 2026-09-01
### Fixed
- **A pasted key no longer expires on its own, and a machine left alone over a
holiday comes back signed in.** There was a sweep in `prune()` that deleted a
saved agent credential once it was older than the session horizon *and* no
sessions were left. Both halves had to be true, which read like a narrow rule and
was not: `updated_at` moves only when a key is **pasted** — nothing touches it on
read — so the age half was permanently true of every key in real use, and what
was actually left binding was "no sessions left", which unpinned sessions reach
after seven days. Eight idle days and a restart, and the paste was gone, with
nothing on any screen connecting the two. It is removed: a key in
`agent_credentials` or `system_credentials` now goes when you clear it, when a
paste replaces it, or when you uninstall the plugin that added the harness or
provider it belongs to — and at no other time.
⚠ **A daemon that ran an earlier build may already have deleted keys this way,
and there is nothing to restore** — the sweep was a plain `DELETE`. If a machine
that has been quiet for over a week now reports an agent as signed out, that is
this bug rather than anything at the vendor: paste the key again under Settings →
Machines → this machine, and it will stay. Deleting a local copy never revoked
anything at the vendor, which is the largest reason the sweep was not worth its
cost. `docs/DECISIONS.md` Q7.124 carries the whole argument.
- **A harness that will not start stops being offered, and stops costing a
worktree.** Reported with a screenshot: New session drew a tile for an agent a
plugin had added, Start answered *"rejected session/new: authentication
required"*, and the tile was still there for the next press — and the one after
that. The daemon remembers a refused start now, so the tile goes, the model
picker greys the row, and a second press is refused before a worktree, a branch
and a session row are made for it. It is remembered for ten minutes rather than
written down: an agent's refusal is an observation, and this project has once
already turned one into a standing verdict and stranded conversations under it.
A successful start forgets it, so does pasting a key, so does finishing a sign-in
in the app, so does switching the plugin off and on — and where none of those
happened there is **Check again**, on the machine's agent list and on the agent's
own card, for after you have signed in on the machine itself.
`pnpm client agents recheck <agent>` is the same thing from a terminal.
- **A settings card called an agent a plugin added by its identifier.** Every
sentence on that card named the harness with this product's own table, which
answers the bare id for anything it does not ship — so the card read *"byo:gemini
needs no sign-in"*. It uses the manifest's name now, bounded, exactly as the
tiles and the refusal sentences already did.
- **A resumed session no longer reports a demotion that did not happen.** An agent
that comes back on the model it was started with, and has since stopped *listing*
that model, drew an error row contradicting itself in one sentence — *"has no
model called X … resumed anyway, running X"* — because the pin asked whether the
model was **offered** rather than whether the session was **on** it. Worse, the
same refusal made `POST /sessions` answer a permanent `502` for a preset whose
model the agent was running. Both gone: the pin answers "already there" before it
reads the list.
### Changed
- **Settings → Machines → *Systems* is now *Sign-ins*, and it holds both halves.**
Signing in on a machine was never only about inference: a harness can read a key
of its own, and until now the only place that key could be typed was a
*provider's* card — reached when that provider named the harness. Every harness
this product ships is named that way, so all four had one; a harness a plugin
added had one only if that plugin also contributed a provider naming it, and
nothing required that. A key box could be declared in a manifest and drawn on no
screen at all. Such a harness has a row of its own now, after the providers,
saying whether a key is saved. Nothing changes on a machine with no plugins —
adding every harness would have put Claude Code beside Anthropic, which is two
rows for one account.
### Added
- **A plugin can add a harness, or a provider.** Two declarative blocks in
`plugin.json` — `contributes.harnesses` and `contributes.systems` — put an ACP
program and an inference endpoint on a machine, and both then behave as though
this product had shipped them: the harness is in the agent builder's harness row
and in Settings → Machines with a paste box, and the provider is a heading in the
model picker and a key box beside the built-in ones. The two are assembled into a
named agent exactly as Claude Code on OpenRouter is, and **that** is what gets a
tile on New session — a plugin adds a harness, never an agent, because whether a
harness is a whole answer on its own is a claim about the *model* that nothing on
the machine can check. No plugin code runs for either and nothing is fetched at
runtime; the daemon reads the manifest and answers its own routes with it.
A machine that has just been told about a harness asks it whether it starts,
rather than leaving that to whoever presses Start first: installing, updating or
switching a plugin on runs the same capability read the agent builder does, so
the answer is on screen before anybody taps anything.
Both need plugin API 5, and a daemon that speaks less refuses the block rather
than ignoring it — a plugin whose whole reason to exist is a harness has no useful
degraded form, so its owner is told to update the machine instead of installing
something inert. Two new scopes say what is at stake, and the consent screen also
draws the **command line** and the **whole base URL**, because a line in a list is
where somebody learns a capability exists and not where they can judge one. What
is drawn is exactly what the daemon compares, so a commit asking for more than was
shown is refused before the plugin runs — including from a browser too old to have
drawn the rows at all.
A provider's endpoint may be `https` anywhere, or `http` to this machine or this
network, which is what makes Ollama, vLLM and LM Studio reachable; where it is
`http` the consent screen says the key travels in the clear.
`rends-east/reemoat-plugin-byo` is a working example — Gemini CLI as the agent,
DeepSeek as the provider, both measured — and it holds no scope that gates a
method, so its consent card shows the two things it adds and nothing else. A
contributed harness
has no sign-in wizard and will not get one — everything a wizard needs is a
measurement about somebody else's CLI — so it is opencode's shape: a paste box and
nothing else. Uninstalling a plugin now takes its saved keys with it, out of both
credential tables; switching one off keeps every session, preset and position it
had, and putting it back brings them all back.
- **The agent row on New session is yours to arrange.** The gear at the end of it
opens a per-machine **Agents** screen — reachable from the machine's own settings
too — where every agent that row can offer is a full-width row you can drag,
hide, edit and remove, with the `+` that used to sit in the strip now at the foot
of the list it adds to.
The order and the hidden set live **on the daemon, per machine**, so they are the
same from a phone and a laptop and survive the reload a phone performs on its
own. What is stored is a *partial* record — a position for what somebody actually
moved — merged over whatever the machine currently reports: an agent it has never
heard of appends at the end and is visible, and an agent that is signed out or
gone keeps its place for when it comes back rather than losing it.
Every row is draggable **and** movable from the keyboard, with `↑`/`↓`/`Home`/`End`
on the handle — a 44px target that keeps the sheet from scrolling under a thumb.
Everything a row can do is behind one menu, on every row, and **every** row can be
edited and removed: a built-in agent and one you assembled are the same thing from
the picker's side — the built-in one is just the one that is there by default.
Editing it opens the builder already pointed at that harness. Removing signs nothing out. A built-in row stays where it is,
dimmed, offering to add it back; an assembled one is deleted and rebuilt from *Add
an agent*, and sessions started on it keep resuming on the bare harness. There is
no confirmation, because neither is a thing you cannot redo.
Opened from the gear, the screen's ◀ goes back to the New session sheet you left —
folder walked and agent chosen — rather than further into settings.
A machine whose agents are *all* hidden says so, and points at the gear.
**The first agent in that order is the one a new session opens on, and its row
says `default`.** Drag another to the top and it becomes the default. "First"
skips a row that cannot be started — a hidden one, a harness that is signed out,
an agent assembled on a harness that has since been uninstalled — because those
have rows here on purpose and none of them is what a new session can open on. It
also fixes the picker: the last of those three used to be selected on arrival and
then refused, leaving New session with no agent chosen and **Start** dead until
you tapped one.
- **A built-in agent's tile says which system it runs on.** Claude Code ·
Anthropic, Codex · OpenAI, Kimi Code · Moonshot — on the New session tiles and on
the rows of the Agents screen. It used to say `signed in`, which is true of every
agent you can actually start and therefore says nothing; a status only appears
now when there is one worth reporting, and it displaces the vendor.
- **The agent row says when it is cut off.** A gradient at its right edge, on
exactly while there is more to the right of it — the same fade the transcript
draws under a session's name. The scrollbar under the row reports where you are;
this is the part that says there is somewhere to go before you have touched
anything.
- **opencode is a fourth agent, and it needs no signing in.** `opencode acp`,
vendored and pinned like the other adapters, and it needs no new machinery
either: it publishes a model control under `category: "model"` and answers
`session/set_config_option`, which is exactly the call an assembled agent was
already pinned with.
Measured: with nothing configured at all it runs on **OpenCode Zen's free
models**. So there is no sign-in wizard, no sign-out button, and no status under
it either — an agent with nothing to report shows nothing, rather than a
`cannot check` that reads as a fault. The settings card still carries one
sentence saying nothing is missing and what a key buys.
It has **no tile of its own** on the new-session screen, and that is the same
fact from the other side: the other three harnesses *are* the model they run,
and this one is a router. Started bare it picks `opencode/big-pickle` off that
free tier — a model nobody chose, under a tile that names none — and a saved
OpenRouter key widens its catalogue to 362 without moving that default one row.
It is a harness everywhere else: build an agent with it, pick a model, and it
behaves like any other.
- **Assembling an agent refuses a pairing before you can make one.** Choosing a
harness first now collapses every provider it cannot be pointed at to a single
greyed line in the model list — so `opencode` no longer offers Claude's models
and then refuses to save. Each of the two fields has a `Clear` beside it, and
the reason a pair is refused is drawn on the row it is about rather than only at
the foot of the sheet.
- **OpenCode Zen is a provider in its own right**, beside OpenRouter — both
reached by opencode, which publishes them in one list that the picker now
divides by provider. Its free models are the six you get with no key; an
`OPENCODE_API_KEY` opens the rest, 93 in all. Rows are named as the provider
names them — the `OpenCode Zen/` the CLI puts on the front is dropped, since the
heading over the row already says it.
- **OpenRouter is a sixth provider, with its whole catalogue in the picker.**
Reached two ways at once, like Moonshot: opencode runs it natively, and Claude
Code can be pointed at it. The catalogue is read **by the browser** from
OpenRouter's own host rather than written down here or proxied by the daemon —
289 models under one heading. Two things are left out and for one reason —
each would only fail confusingly at somebody else's endpoint: a model that
cannot call tools, and the `:batch` rows, which are the pricing tier of an
asynchronous API with a 24-hour completion window that nothing here can submit
to or poll. A model both opencode and the catalogue name is one row, runnable by
either harness.
⚠ **If you serve the UI yourself, `connect-src` now has to name
`https://openrouter.ai`.** Without it the picker's OpenRouter section never
fills. See `packages/control-plane/.env.example`.
### Changed
- **The model picker puts the providers you can actually use first.** Any provider
this machine can start a model on floats above every provider it cannot — so the
ones whose rows would read *"No … key on this machine."* sink to the bottom
instead of sitting between you and the one you use. "Can use" is the same test
that greys the rows, so the order and the greying always agree: a provider whose
models are published by a signed-in harness counts, even with no key of its own
pasted anywhere.
Under that, the default order changed too: Anthropic, OpenAI, **OpenRouter**,
Moonshot, Z.ai, MiniMax, OpenCode Zen. OpenRouter is the widest catalogue and the
commonest reason to scroll at all, and it used to sit below one vendor's four
models. Zen is last by default and floats like anything else on a machine that
holds its key.
- **The `+` at the end of the agent row is a gear, and the `Edit <agent>` line
under the row is gone.** Both acts moved onto the Agents screen the gear opens,
where an agent is a row with room for its own controls rather than a 112px tile
in a strip you drag sideways. Nothing below the picker appears and disappears as
you tap along the row any more.
### Fixed
- **`touch-action: none` did nothing, on every button in the app.** `index.css`
declared the `touch-action` default for `button` outside any cascade layer, and an
unlayered rule beats every Tailwind utility regardless of specificity — so the
`touch-none` class was dead wherever it was used. The visible cost was that the
agent list could not be dragged on a phone at all: the browser took the gesture
for the scroller before the first `pointermove`. A mouse is not gated by
`touch-action`, which is why it only showed up on a touchscreen.
- **A fourth harness would have drawn a blank tile.** `AgentGlyph`'s comment had
claimed for four releases that a missing arm was a compile error; it was not —
the function answers `ReactNode` and `undefined` inhabits it, so a `switch`
falling off the end returned exactly that. It ends in a `never` arm now, and a
missing glyph really is a compile error.
- **The composer strip said three different things about a fourth agent.** The
mode control read `Session Mode` where the other three read `Mode`; its modes
read `build` and `plan` where the others read `Plan Mode` and `YOLO`; and the
effort control was simply not there. `labelFor` reconciles the first, a new
`choiceLabel` — the one place a choice is named, capitalising a mode's first
letter and nothing else — reconciles the second, and `drawnControls` now keeps
the effort slot even for an agent that never published one, so it opens and says
the model offers no levels rather than being absent.
- **A control the agent published with nothing to choose from** drew as a working
chip that opened onto an empty panel. It is drawn as unavailable now, like one
that was withdrawn.
- **A turn that ended in an error never said it had ended.** Four prompts, three
`turn_end`s, in a log anybody could read: a rejected `session/prompt` became an
`error` event and the turn was simply over. The daemon writes the end now, with
a stop reason of its own (`agent_error`) because none of ACP's five means
"failed". Nothing was stuck — what was broken is quieter: a turn that failed
mid-delegation left "waiting for 1 task" under the transcript for the rest of
the session, the `turn.ended` plugin hook never fired, and the turn's origin
claim was never spent, so a plugin that started it had the *next* turn's hook
suppressed instead. Nothing new is drawn: the agent's own error is already the
row above it.
- **The assemble-an-agent screen was a spinner for five seconds.** It waited for
`GET /agents/capabilities`, which starts an agent per harness — measured at
5286 ms cold — although only the model list needs the answer. The screen now
draws as soon as the cheap table read lands and one row says what it is waiting
for; nothing on it can claim a pairing is possible, or refused, before the
answer arrives.
The read itself went from **5286 ms to 2159 ms**, in two steps. It asks every
harness at once rather than one at a time — the daemon's concurrency bound is
untouched, because the cap now queues instead of refusing, and a plugin's own
model request is still refused rather than parked. And it no longer holds the
answer while it tears the agent down: codex's model list is ready at 383 ms and
its teardown took 2011 ms more, because it does not answer `session/close` and
the close waits its full budget. The daemon still cleans up, and still counts the
process against its own limit while it does — the caller just stopped waiting
for it.
- **The agent builder asked the slow question first.** Its two rows are Harness
and Model, and only the model list waits on the machine — the read that starts an
agent per harness to find out what each can run. It was on top, so the first
thing on the screen was a row saying *Reading models…* that could not be opened,
above a row that was ready immediately. They are swapped: choose the harness,
which costs nothing, and the catalogue lands while you do. It is also the order
the model list is built for — with a harness chosen, every provider it cannot be
pointed at collapses to one greyed line, so a refusal now arrives before the
choice it is about rather than after it. Neither field is required first and
either can still be cleared.
- **A model that cannot call a tool was offered as an agent.** The catalogue
filter that drops those had only ever been applied to the list this browser
fetches — and opencode publishes its own copy of OpenRouter's, unfiltered, image
models and all, which got merged straight past it. Assembling one produced a
session that failed on its first turn with OpenRouter's own accurate sentence.
A refusal the catalogue has already made now outranks whatever the harness
lists, and a catalogue that could not be read refuses nothing.
- **The agent strip is a row you can actually move, and the `+` is in it.** The
layout always scrolled; a mouse simply has no gesture for a horizontal box, and
this strip hid the one bar that says so. A wheel scrolls it now, handing the
gesture back to the page at either end. The `+` is the row's last item — an
ordinary one you scroll to, not a button pinned to the right edge in front of the
last tile.
The bar under it is the app's own, not the browser's: it appears with the first
notch and fades a second after you stop, the way an overlay bar does. It had to
be drawn by hand, because a real scrollbar in this app cannot be animated at all
— Chrome ignores every `::-webkit-scrollbar` rule on an element that sets
`scrollbar-width`, which this app sets on everything, and what is left switches
between two frames.
- **The new-session picker offers the agents you can start, and nothing else.**
A harness that is not installed, or is installed and signed out, had a tile of
its own explaining why — so a machine with one working agent showed three tiles,
two of which were labels. Those are gone, and with them the `signed in` line
under the ones that remain: it was a fact true of every tile in the row. An agent
that cannot say whether it is signed in still has a tile, because that is kimi's
permanent answer and a probe that timed out is not a sign-out. Signing in is
still on this screen — on a machine with nothing to start, it is what the screen
offers instead of an empty row — and the settings card is unchanged.
- **A session pinned to one provider was offered another one's models.** opencode
publishes one model control holding both its catalogues, so an OpenRouter
session's own picker carried six OpenCode Zen rows — and choosing one left the
session running a model its preset does not name, with nothing on screen saying
so. The snapshot's model list is now the session's own system's; the transcript
still records everything the agent published, and the model the session is
actually on is never filtered out of its own picker.
- **opencode's model names were mostly the provider's.** It publishes one model
control holding 356 `OpenRouter/…` rows and six `OpenCode Zen/…`, so the chip —
which has room for about eleven characters — spent all of them saying
`OpenRouter…` instead of which model. A word every row repeats is taken out of
every row, in the chip, in its menu and in the `/model` list, and the values sent
to the agent are untouched. No heading replaces it: with a session's list now
narrowed to the provider it actually routes through, a heading would sit over
every row and tell you nothing. Where a control really does hold two providers
nothing is shortened at all, so no two rows can be read for each other. The
tooltip over a truncated chip said "Model"; it says the model.
## [0.3.0] - 2026-08-22
### Added
- **Sign in with your username or your confirmed email address.** One field, one
answer to every refusal, and the address is accepted only once it has been
confirmed — an unconfirmed claim can be written by anyone from the anonymous
sign-up route, so it reserves nothing and opens nothing. The name is resolved
first, which settles the one ambiguous case (a legacy name holding an `@`) without
a new error code an unauthenticated caller could read as an existence oracle. One
account named two ways spends two guessing counters, deliberately: folding them
would key the counter on the account, which is the lockout weapon the throttle was
built to remove.
- **Plugins.** A machine can be given a plugin: a `.tar.gz` holding a manifest and
a file of JavaScript, installed by whoever owns that machine. A plugin can draw
a screen, draw its own settings pane, put an action on a session's menu, and run
code when a session starts, a turn ends, a session ends or an agent asks a
question. It reads sessions, transcripts, diffs and workspace files, creates and
prompts and stops sessions, keeps its own data, and reaches host names it
declared — each behind a scope it names in its manifest and that is shown to
whoever installs it. `docs/PLUGINS.md` is the author's guide and
`plugins/board/` is a working one.
- **`pnpm client plugins`, and `plugin install | remove | enable | disable | view`.**
Installing an id that is already there updates it, and what the plugin stored
survives that — if the new version will not start, the old one keeps running and
nothing on disk changes. That holds for a reinstall of the version already
installed, and for a plugin that is switched off: the build is started long
enough to prove it runs and stopped again before the row is written, so an
update nobody could have noticed was broken is refused rather than committed. Seven new daemon routes under `/plugins`.
- **Plugins on the machine's settings screen**, beside its agents, with each
plugin's scopes written out as sentences on its row. A plugin that draws a
screen gets a launcher in the rail's footer and a route of its own at
`/p/:machineId/:pluginId`.
- **A plugin's row can go somewhere, and a screen can keep up.** A row names a
session on the machine or the plugin's own screen — a destination this app has,
never a URL — and tapping it opens that. A view can ask to be re-read on an
interval, floored at two seconds and spent only while somebody is looking; the
old view stays until the new one arrives, and a failed tick says nothing.
- **A row can say what it means** — `ok`, `warn`, `danger` — and the app picks the
ink. This is the answer to "let a plugin send CSS", which it may not: naming
meaning survives a refactor and cannot put a value below the contrast floor.
- **`permission.resolved`, `sessions.answerElicitation` and `agents.list`**, each
closing an asymmetry: a plugin could learn a question was asked and not how it
ended, could answer a permission but not a form, and could start a session
without being able to ask which agents this machine has.
- **A plugin market**, read by the browser and never by the daemon. The catalogue
is its own service on its own host, named by `REEMOAT_CP_PLUGIN_CATALOGUE_URL`
and reported to the client as `plugins.catalogue` on `GET /v1/instance`; an
instance that sets none has no market and says so. Installing from it is
`POST /plugins/source`, which hands the daemon a repository and a **commit**
rather than an archive, so what arrives is what the catalogue pinned. The daemon
still discovers nothing and polls nothing.
- Plugin API **4**, in three rungs that all land here. The floor stays at 1, so a
plugin written for 1 runs untouched; one that needs more declares the rung it
needs and is refused by an older daemon with a sentence rather than losing its
navigation quietly. **v2** added `open`, `refreshMs` and `tone`; **v3** added the
`model` scope; **v4** added `model.list` and the optional `model` on
`model.complete`. Declaring `2` and using the `model` scope is refused, so the
rung to declare is the highest one whose features a plugin actually uses.
### Fixed
- **Nothing an agent asks you is shortened any more.** The daemon clipped a
question's prose at 512, 100 and 300 characters and a permission's title and
option names at 200; the browser deleted an answer whose label would not fit a
button, and clipped the collapsed question bar with CSS. All of it is gone. What
bounds a form now is one 32 KiB refusal over the whole thing, and what bounds a
permission is one 8 KiB refusal over its title and options together — a card that
large is declined *to the agent*, which is a sentence it can act on, rather than
delivered silently shortened. Measured on a real log, one option description was
318 characters against a 300 cap, and five of fifteen option labels were over the
button ceiling.
- **An answer the agent offered is never removed to make the buttons fit.** Past the
label ceiling the card lays its options out as full-width rows instead — the same
arrangement it already uses for a question — keeping the refusal first and the
reversible approval filled. This mattered most where it was least visible: when a
question arrives down the permission channel and cannot be classified, the options
are the model's own written answers, and two of four were being deleted with
nothing said.
- **A question you have already answered still says what was asked.** The transcript
showed the adapter's preamble — "Please answer the following questions." — over the
values you picked, because the questions themselves live in the tool call's
arguments and nothing was reading them. Each question is now drawn over its own
answer, matched by the words you tapped rather than by any adapter's field naming.
- **Menu rows line their text up with their icons.** Seven rows across the account
menu, the chat filter, the settings kebab and the plugin menu asked to be centred
and none of them was: Tailwind emits its utilities alphabetically, so the shared
row's `items-start` outranked every `items-center` a call site appended, whichever
order they were written in. The shared string states no alignment now — the caller
does — and a check sweeps every call site for the same class of silent override.
- **A plugin's lifecycle now knows which child it is talking about.** Every launch
carries a generation and every late callback is gated on its own, which closes
three separate defects that shared one cause: a dead child's exit could null the
reference to its live replacement and leave an unreachable process behind; a
child's call ids restart at 1 each launch, so a slow `net.fetch` from a crashed
plugin could resolve a *different* call in its replacement with the wrong data;
and timers left over from a crash could stop the healthy child that replaced it.
- **`plugin install` and `plugin remove` no longer leave a child nobody holds.**
A stop cancels a scheduled restart on every call rather than only when it does
real work, and a stop that supersedes one still in flight waits for both — so
`shutdown` no longer returns while a process is still being killed.
- **A refusal from the auth or scope check on a streaming route releases the
request body.** The three routes that stream their own bodies cancel them on
every refusal *they* make, but the middlewares above them answered without
releasing anything, and an unread body parks the sender against the relay's
window. The obligation now hangs off the same guard that grants the exemption,
so a fourth streaming route gets both halves by adding one string.
- **The plugin data quota counts bytes.** It counted UTF-16 units on one side and
SQLite characters on the other, so ten emoji were charged 12 or 22 against the
42 they actually occupy — roughly three times the ceiling was reachable.
- **An oversized message no longer looks like a hang.** Anything past the 256 KiB
IPC bound was dropped in silence and charged to the plugin as a timeout, so three
large form submissions from a `session:write` caller would stop a plugin that had
done nothing wrong. It now fails at once and says what happened.
- `ctx.agents.list()` and `ctx.sessions.answerElicitation()` are reachable from a
plugin, and `ctx.files.list` — which never had a host method — is gone.
- **`ctx.store.entries(prefix)`**, a paged batched read, because the reference
plugin's own screen was a thousand round trips per redraw with no other option.
### Security
- **Nothing is sent until you have read what the plugin asks for.** Choosing a file
no longer uploads it: the manifest is read where you are — in the browser, and by
`pnpm client plugin install` at a terminal — and its scopes, the hosts it named and
the events it asks to be told about are drawn before anything crosses the network.
This is what `SECURITY.md` meant by "named before somebody consents to it", which
until now was not true: the archive was unpacked, the row written and the plugin
started, and the scopes appeared afterwards on the row of something already
running. Neither reader validates — the machine still refuses authoritatively —
and an archive that cannot be read says so rather than guessing, with a separate
named press as the way past.
- **A plugin runs as you, and the browser runs none of it.** A plugin is a child
process of the daemon with your uid and your files — the same trade an agent
already makes on the same machine. Its declared scopes are hygiene rather than a
fence, and `SECURITY.md` says so in those words. What is a real boundary: a
plugin returns a *description* of a screen and the app draws it, so the origin
holding your credential executes nothing a plugin author wrote. There is no
registry, nothing downloads a plugin, and nothing updates one by itself.
## [0.2.0] - 2026-08-21
### Added
- **Signing out is a state of the machine.** A credential is read once, at spawn,
so an agent started while signed in kept answering for an account somebody had
just revoked. Signing out now ends every conversation on that agent
(`agent_signed_out`), a prompt is refused before it can reach a signed-out agent
— a sign-out done in a terminal, or an OAuth session that simply expired, is
reported by the agent itself (`errorKind: authentication_failed`) and ends the
conversation the same way — and signing back in resumes **exactly** the sessions the sign-out
ended, leaving hand-stopped ones alone. Refused only on a CLI's explicit
"signed out". There is deliberately no probe on the prompt path: one there cost
a spawn per message and made the offline drivers depend on whether the person
running them was signed in. Q7.100.
- **A signed-out agent says so in the conversation, with a Sign in button** that
goes to that machine's own agent screen — instead of a toast, which carried the
one refusal on this screen with a real remedy in the place that has no room for
one. The session is ended rather than merely refused, so "signed out" is a
single state; the draft is still restored, and signing in brings the
conversation back with it waiting. Q7.102.
### Fixed
- **Settings stopped flickering on every tab switch.** A wake deliberately forgets
each machine's route, and the re-probe published `probing` for a machine already
known to be online — so machine dots blinked and the agents panel *unmounted and
remounted*, restarting its fetch and discarding anything half-typed into a
credential box. A re-probe now keeps the answer it already has. Q7.101.
- **Import my code** — `POST /fs/import` takes a `.zip` or `.tar.gz` and unpacks it
into one new folder inside the directory the new-session picker is standing in,
then moves the picker into it. The archive comes from a Claude Code skill the web
UI hands over as a single paste, run in a session on the machine the code actually
lives on, so the code and the context around it are collected by an agent rather
than guessed at by a glob. Format is decided by magic bytes, never by filename.
`.claude/rules/code-import.md` is the whole argument; Q2.107–Q2.110 are the
measurements.
- **Containment is rebuilt from scratch for archive members**, because every
member path is a string a remote party wrote. `safeMemberPath` is pure and
refuses first — absolute paths, any `..` segment (refused, never normalised),
backslashes, control characters, symlinks, hardlinks, devices and `.git` — and
both readers go through it so the two formats cannot disagree. Every write is
`O_EXCL`.
- **Nothing in the target is touched until one `rename`**, so a failed import
leaves the folder exactly as it was. A destination that already exists is
refused rather than replaced, `rename` onto an empty directory included.
- Bounds: 50 MiB on the wire, 500 MiB unpacked (charged against bytes actually
produced, not declared), 20 000 members, one import at a time per daemon.
- **A read-only grant is now asserted to be read-only on a mutating route.**
`daemoncheck` grew `tokenWith`, and the scope refusal it exercises had no
assertion anywhere before.
### Changed
- The 1 MiB request-body bound now exempts routes through one named predicate
(`isStreamingRoute`) rather than an inline regex, since there are two of them.
## [0.1.0] - 2026-08-18
First public release. The repository was published with fresh history as a single
commit, so there is nothing before this and no diff to link.
### Added
- **A protocol version *range* on the relay tunnel**, so a bump is no longer a flag
day. `RELAY_PROTOCOL_MIN_VERSION`/`RELAY_PROTOCOL_VERSION` bound what a relay
speaks and `negotiateProtocolVersion` takes the newest both ends know: a daemon
ahead of the relay is negotiated **down** rather than refused, one behind keeps
working until the floor is deliberately raised. The relay answers the agreed
number on the 101 as `x-reemoat-relay-agreed`, and every stream down that tunnel
carries it — not the relay's own maximum. `.claude/rules/compatibility.md` is the
order to make a breaking change in.
- **`x-reemoat-daemon-version` on the tunnel handshake**, and `src/version.ts`'s
`DAEMON_VERSION` behind it. Advisory: recorded, reported, and branched on by
nothing. The relay stores it against the machine on dial.
- **`GET /v1/admin/fleet`** and `cpctl admin fleet` — what every machine last
dialled in as, **offline ones included**, because the machine that decides
whether the floor can move is the one that has been dark for a month.
- **`version` and `protocol` on the daemon's `GET /health`**, unauthenticated like
the clock beside them: a client that cannot get a token yet is the one that most
needs to know whether the daemon it is pointed at is older than it is.
- **A `migrate()` for the control plane's SQLite**, additions only, with
`CP_SCHEMA_VERSION` deliberately not moving for one — so yesterday's image still
starts against today's database and a rollback stays a rollback. `deploycheck`
asserts that shape rather than trusting the comment.
- **A release pipeline**: `.github/workflows/release.yml` on a tag push, with every
refusal in `deploy/ci-release.sh` and driven by `deploycheck` with no registry,
no forge and no network. Plus a gitleaks job and a `.gitleaks.toml`
that names the exact secrets rather than the files holding them.
- **The daemon**, **the control plane** and **the web UI**, as one AGPL-3.0-only
workspace. The daemon owns coding-agent sessions and exposes them over HTTP and
WebSocket; the control plane issues identity and relays every request; the web
UI supervises the fleet from a phone. `README.md` is the overview and
`deploy/README.md` is the deployment surface in full.
- **`claude`, `kimi` and `codex` over ACP**, normalized into one event stream.
Sessions, permissions, questions, commands, file changes and uploads are the
same shapes whichever agent is behind them.
- **A published container image for the control plane and the relay** —
`ghcr.io/rends-east/reemoat/control-plane`, `linux/amd64`. Two services from one
image, which is how `deploy/docker/compose.yml` already ran them. Running it is
optional: `deploy/deploy.sh` still builds on the host by default.
- **`GET /v1/instance` serves the AGPL section 13 source offer** — the source URL
and this version string — to callers who have not signed in, because they are
the users that clause is owed to.
- **Ten checking drivers** and no test framework. Eight run offline in one
process with no fleet, no agent and no deploy; `imagecheck` builds and starts a
container; `harness` drives a real agent.
### Changed
- **The `reemoat-v` stream header is now enforced by the daemon.** It was written
by the relay on every stream and read by nobody. A stream whose version
disagrees with what the tunnel negotiated costs that one request (`501`), never
the tunnel.
- **`endedWithDaemon` asks "is this a *final* reason?" rather than "is this a
daemon reason?"** An exit reason a client has never heard of now reads as
"coming back" and keeps the composer on screen, instead of taking it away from a
conversation that was going to return.
### Not in this release
These are the three things most likely to be assumed about a self-hosted service
at 0.1.0, and none of them is true yet.
- **No sandbox.** An agent is a child of the daemon, with your uid, your files,
your `~/.ssh` and your other repositories. Git hooks run as you, deliberately.
This is the product rather than an oversight — `SECURITY.md` is the whole of
what it does and does not promise.
- **No upgrade path from anything**, because there is no earlier version. The
first upgrade this project has to get right is the one after this release.
- **No published daemon artifact.** The daemon is a checkout and a supervisor
unit — `deploy/install.sh daemon` — and no binary, npm package or image is
produced for it. Only the control plane ships as an image, because only the
control plane is a thing that may be confined.
### Known and unmeasured
Written down because a release is where somebody decides whether to run this, and
these are open questions rather than hidden ones. `docs/DECISIONS.md` group Q7
holds them in full, with what would settle each.
- `linux/arm64` is not published. Nothing has built or started this image on
arm64, and shipping a manifest entry the checks have never exercised is not a
trade worth taking on the process that holds the fleet's signing key.
- The upload route's body-cancel behaviour under the auth and scope middlewares
is unmeasured, and `SECURITY.md` says so rather than implying it was checked.
- Three agent-login questions on macOS are written but unmeasured, all settled by
one real device-code login.
[Unreleased]: https://github.com/rends-east/reemoat/compare/v0.9.1...HEAD
[0.9.1]: https://github.com/rends-east/reemoat/releases/tag/v0.9.1
[0.9.0]: https://github.com/rends-east/reemoat/releases/tag/v0.9.0
[0.8.0]: https://github.com/rends-east/reemoat/releases/tag/v0.8.0
[0.2.0]: https://github.com/rends-east/reemoat/releases/tag/v0.2.0
[0.1.0]: https://github.com/rends-east/reemoat/releases/tag/v0.1.0