Agent-First Mail v0.11.0: Nothing Waits for Done

by Agent-First Kit Contributors

Review used to stage a person's answers and commit them all at once when they pressed Done, which meant closing the window threw the work away. Each item now settles the moment it is decided, the workspace is the source of truth rather than the process, and a draft can no longer attach a file it merely points at.

The previous release put review in a window that belongs to someone else. This one changes what happens inside it, because the model underneath had a failure mode nobody should have to think about: the person did the work, closed the tab, and the work was gone.

Done was a transaction nobody asked for

The old shape was a batch. An agent staged proposals, a person answered them, and Done committed everything atomically. Anything else — closing the window, a dropped connection, a laptop lid — discarded every answer given so far, because none of them had happened yet.

That is a reasonable design for a form. It is the wrong one for a person working through a queue on a phone, which is where these windows actually get opened.

Each item settles independently now, the moment it is decided. Closing the window ends the session; it does not roll anything back. Re-deciding an item replaces its record rather than appending another one. And the terminal result is no longer a value carried out of the UI session — it is re-read from the workspace after the session ends, reporting settled and unsettled per item.

That last detail is the one worth keeping: it means kill -9 mid-session loses nothing already settled, because the disk is the source of truth and the process is not.

The vocabulary shrank with the model. Session-level submitted, cancelled and dismissed are gone — a session no longer has an outcome, its items do. On the item side, Deferred is gone and Decided is new: a disposition the agent never proposed, which the old outcome list had no way to express. Three invariants travel with it — Decided carries no agent suggestion id and must be a complete action, Accepted and Changed require one, Rejected requires a reason — and they are checked immediately after deserialization for messages, cases and notifications alike, instead of being a rule each writer had to remember.

Stale-suggestion detection moved the same way. It used to be a gate on the whole session: one item whose underlying message had moved on refused to open any of them. It is a per-item flag now, so the rest of the queue opens.

A draft cannot attach what it merely points at

A draft is Markdown a person edits, which makes its attachments: list caller-controlled text at exactly the same trust level as the mail body.

Three byte-identical copies of a purely lexical check decided what got sent: reject absolute paths, reject anything that is not a normal path component, join, read. None of them looked at what was actually on disk. So attachments: [files/link], with files/link a symlink pointing at an SSH key, was read, hashed, shown in the push plan as a harmless case-relative path, and attached. The size and hash presented to whoever confirmed that push were the key’s.

There is one primitive now, and it opens rather than resolves. The managed range is files/<name> — not a new restriction, but the one draft attach always implied by copying into <case>/files/ and never enforced. The directory and the file are checked separately, non-regular files are refused before any open, and the opened handle’s identity is compared against what was checked, so a swap between those two moments is an error rather than a silent substitution. Callers get the handle and read from it. Draft validation goes through the same primitive, which makes “validates” and “can be sent” the same question asked once.

The delivery words are AFUI’s now

--mode local|lan becomes --mode window|link|session. local was window, and lan was link — the observable behaviour of a link is unchanged: one-shot, fifteen-minute expiry, review_ready carrying review_url, expired afterwards. The fifteen minutes stays afmail’s number, because AFUI offers no default.

What went away is afmail’s own LAN implementation. src/lan.rs is deleted, and both review delivery and api serve’s discovery now use the shared one. The api serve --mode local|lan flag is a different axis — network exposure — and is untouched.

Checked by the engine that serves it

doctor used to render each review template on its own through afmail’s plain template renderer, while review assembles the page through AFUI. Two engines, one promise — so a frontend that doctor called healthy could still fail to serve, because the serving side adds an escaping policy, a rendered-markup guard and an element contract that the checking side did not have.

Doctor renders the real page now, one render per surface, each covering that surface’s whole tree: the page, the message, both editors, the workbench, and the stylesheet, which is inlined rather than linked. The old per-key contexts had only ever exercised the case branch, leaving triage, notification and draft unchecked — and that was not hypothetical. The first run through the real engine failed on a shape those contexts never reached.

The page’s required hooks are declared as well, and checked before a window opens. The docs name twenty-five data-afmail-* hooks without saying which a page cannot do without, and finding the answer meant reading the runtime against what a page actually renders. Four are load-bearing on every surface: the root the runtime returns without, the content node every form binding hangs off, and the workbench/suggestion pair whose refresh path throws if it finds one without the other. The other twenty-one are not required, for good reasons — a settle form exists only where there is a suggestion to settle, and autosave belongs to editors, which a triage page does not have.

Less page, and one head per card

The review page’s routing, its session channel with reply correlation, and its unload guard were afmail’s own copies of things every page in this kit had. They now come from AFUI’s page kernel.

What stayed is what is genuinely afmail’s: which update ends a turn (input_accepted only says the agent heard; response finishes it), flushing dirty editors before a send so the page does not manufacture lock contention with itself, and clamping the selection inside a turn. The net saving was nineteen lines, which is worth stating plainly rather than hiding — the rest of that file is afmail’s model, and sharing it would have meant pushing afmail’s model into AFUI.

In a framed session the page also stops drawing its own head. The frame already says whose program this is and what the session is on; the page was saying both again, one row down, on the screen dimension there is least of. It hides the brand and the saving line and sends up only what a frame cannot know. The local-workspace pill stays, because that pill is not identity.

Smaller things

triage review with no message ids used to imply it covered every current suggestion. It reviews the current queue newest-first up to a bound, and the result now says whether any were left out.

The IMAP push contract is pinned by a test server that records what actually crosses the wire — append, copy, expunge, move, store — so a change in push behaviour has to be deliberate. The per-file locale overlay is pinned too: a frontend overriding only review/page.html.j2 still gets the rest of the active language pack, tested against zh-CN rather than the default so a pass cannot come from falling back to English.

Error events no longer quote a value that came from an argument able to carry a credential, and the local container gates run on Apple’s container runtime as well as Docker.

Breaking changes

Getting it

$ brew install agentfirstkit/tap/afmail
$ cargo install agent-first-mail