Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md

Large diffs are not rendered by default.

40 changes: 34 additions & 6 deletions designs/agent-chat-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,29 @@ Reply, in order: `opened`, then history as `message` events.
- History is the latest 50 messages / 256 KiB, sent whole (never as deltas),
each with `history: true` and the next `sequence`. Replace anything you cached
with the same `messageId`.
- Older pages: `open` again with `before: "<messageId>"`. This re-opens the
conversation (a new `opened`, `sequence` 0) and sends that older page.
- Older pages: `open` again on the same open conversation with
`before: "<messageId>"` (usually the oldest `messageId` you hold). No
`opened` comes and nothing is reset: the older page (50 messages / 256 KiB
before that message) arrives as `message{history:true}` at the
conversation's next sequence numbers, so keep applying `last + 1`; live
events before and after it keep their own sequence numbers. Insert paged
messages by `messageId` and history order (they are older than what you
have), never by `sequence`. Every page ends with a sequenced `status`
that also carries `page`:
`{"op":"status",…,"state":"ready","since":…,"page":{"before":"m40","count":50,"start":false}}`.
`count` is how many messages the page held; `start: true` means it
reached the oldest message Atem retains, so stop paging. An empty page is
just that marker (`count: 0, start: true`). Treat it as a normal status
update too. Without `control`, approval cards are left out before the
page limits, so you still get older messages after a run of cards.
- Send `generation` (the one from `opened`) with `before`. A `before` Atem
no longer retains (evicted), or one named with another `generation`, is
answered `error{code:"stale_target", text:"that message is no longer in
the history; reopen without before to get the newest page"}`, never an
empty page: "no more history" and "cursor unknown" are different. On an
open conversation the error is sequenced and the conversation stays open.
- `open` without `before` on an open conversation resynchronizes it: a new
`opened` at `sequence` 0 and the newest page; replace your cache.
- `opened.unavailable` lists what this agent's chat can't provide:
`streaming` (no partial text), `reasoning`, `error_details` (no model/API
error behind a failed turn). Hide the matching UI. Unknown names are
Expand All @@ -134,7 +155,7 @@ All carry `generation` and `sequence` (apply only `last + 1`):
| `delta` | `messageId`, `part`: a later part appended to a message already sent (both agents; not a token stream); a part id already present is ignored |
| `status` | `state` (`ready`/`working`/`waiting`/`unknown`), `since`, optional `detail`; re-sent every 15 s with the original `since` |
| `accepted` | `requestId`: your prompt reached the agent |
| `complete` | `turnId`, optional `requestId`, `outcome`: `interrupted` when the turn ended interrupted (Esc, a deny at the agent's prompt, Codex's `turn_aborted`), else `done`; a duplicate submit after a broker restart gets `done` (the receipt only says completed) |
| `complete` | `turnId`, optional `requestId`, `outcome`: `interrupted` when the turn ended interrupted (Esc, a deny at the agent's prompt, Codex's `turn_aborted`), else `done`; for your own submit it comes from the receipt, whose `detail` keeps the outcome, so a duplicate after a broker restart says the same |
| `error` | `text`, optional `requestId`, and `code` on host-made errors: `rejected`/`outcome_unknown` (your submit's receipt), a session-v1 admission code (`stale_target`, `stale_lease`, `lease_held`, `forbidden`, `session_closed`, `busy`, `paused`, ...), `unsupported_version` (malformed message), `busy` (open without chat). The chat's own notices (agent restarted, conversation switched, chat withdrawn) have no `code` |
| `rules` | `approvalRules`: the whole list of active Atem session rules, when it changed (conversations with `control` only) |
| `close` | the conversation ended |
Expand Down Expand Up @@ -174,6 +195,7 @@ marks the cut). Text and summaries pass the credential redactor.
| Another session resumed inside the agent (`/resume`, Codex `/new`) | `error` "the conversation was switched; reopen the chat", `close`; same `generation` | open again; the earlier turns are history only |
| Agent exits | status `unknown`, errors to open conversations, generation retired | history stays readable |
| Chat withdrawn (log format changed) | `error`, `close`; descriptor loses `chat-v1` | fall back to the terminal view |
| Atem dropped chat items before they reached the runtime (a huge burst of agent output) | `error` "the chat history was incomplete (some items were dropped) and was reloaded; reopen the chat", `close`; same `generation` | open again: the history is complete again |

A new `open` always restores status and history. Atem keeps the newest 500
messages / 8 MiB per agent process.
Expand Down Expand Up @@ -264,7 +286,10 @@ A draft typed during a running turn is not detected (v1 limit).

Reply `receipts` (<= 64 ids, only those bound to that exact target). Receipts
survive broker restarts. A session-v1 `receipt` has `request_id`, `target`,
`kind`, `state`, `detail?`, `updated_at`. Work executing across a broker restart
`kind`, `state`, `detail?`, `updated_at`. A `completed` `prompt` receipt's
`detail` is the turn's outcome: `"done"` or `"interrupted"` (the same as the
chat `complete.outcome`; the journal keeps only that word, never prompt or
reply text). Work executing across a broker restart
becomes `outcome_unknown` and is never retried. Replay after a reconnect of the
same chat submit is safe because the same `requestId` returns the recorded
outcome, but the right first move is `receipt_query`.
Expand Down Expand Up @@ -545,5 +570,8 @@ Ops: `hello`, `list`, `acquire`, chat `open`/`submit` or session-v1 `submit`
session; deny `message` 512 bytes.
- No semantic turn-completion signal beyond the agent's own turn end; no
reasoning; no Discord adapter in Atem (the bridge above is yours).
- Approvals do not survive a broker restart. Atem session rules do, inactive
until the new host confirms them (section 5.5).
- Approvals do not survive a broker restart. Their cards stay in history; one
still pending becomes `closed` with the note "Atem's runtime restarted;
answer in the terminal". Atem session rules survive, inactive until the new
host confirms them (section 5.5). Chat history (500 messages / 8 MiB)
survives a broker restart whole.
52 changes: 42 additions & 10 deletions designs/agent-chat-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ on the existing authenticated Astation connection.

| op | Direction | Fields |
|---|---|---|
| `open` | Astation → Atem | `conversationId`, `agentId`, optional `before` (messageId) |
| `open` | Astation → Atem | `conversationId`, `agentId`, optional `before` (messageId) and `generation` (the one `before` came from) |
| `opened` | Atem → Astation | `generation`, `sequence: 0`, `status {state, since}`, `unavailable` (array of strings, below), `approvalRules` (the session's active Atem session rules `[{rule_id, label, scope}]`; `[]` without `control`), then history as `message` events (latest 50 or 256 KB, older pages with `before`) |
| `submit` | Astation → Atem | `generation`, `requestId` (UUID), and one of `text`, `approval` (§6a answer) or `approval_rule_revoke` (`{rule_id}`, r7.2) |
| `rules` | Atem → Astation | `approvalRules` (the whole list, when it changed; conversations with `control` only) |
Expand All @@ -360,19 +360,49 @@ on the existing authenticated Astation connection.
| `error` | Atem → Astation | `text` (safe, no secrets), optional `requestId`, and `code` on every `error` the session host makes: `rejected` or `outcome_unknown` (a submit's receipt), a session-v1 code from admission (`stale_target`, `stale_lease`, `lease_held`, `forbidden`, …), `unsupported_version` (a malformed chat message) or `busy` (`open` without chat-v1, below); the chat's own errors (the agent exited or was restarted, the conversation was switched, chat withdrawn) carry no `code`; an `error` sent before a conversation is open (unknown agent, chat unavailable, bad request) carries no `generation`/`sequence` |
| `close` | either | `conversationId` |

- `open` with `before` on an open conversation reopens it (`opened`,
sequence 0) and sends that older page.
- History pages (since 2026-10-17). `open` with `before` on a conversation
this attachment already has open pages it: no `opened`, nothing reset.
The older page (up to 50 messages / 256 KiB strictly before `before`)
arrives as `message{history:true}` at that conversation's next sequence
numbers, continuing its live sequence, so a live event never collides
with or overtakes a page and the client keeps applying `last + 1`. The
page holds older messages than ones already sent: place messages by
`messageId` (a whole message replaces an earlier copy) and order them by
`createdAt`/history position, not by `sequence`. Every such page ends
with a sequenced `status` (the current status) carrying
`page: {before, count, start}`: `count` is the number of messages the
page held, `start: true` means it reached the oldest retained message (no
older page to ask for). An empty page is that marker alone
(`count: 0, start: true`), never silence. Approval cards are left out of
a view-only attachment's pages before the 50-message / 256 KiB limits,
so a stretch of cards never yields an empty page while older messages
exist (the same holds for the newest page on `opened`). `open` without
`before` on an open conversation is a resynchronization: a new `opened`
at 0 and the newest page; replace the cache. `open` with `before` on a
conversation that isn't open opens it (`opened` at 0) with that page.
- `open` may carry the optional `generation` its `before` came from. A
`before` that isn't in the retained history (evicted, never sent), or
named with another `generation`, is refused with `error{code:"stale_target"}`,
text "that message is no longer in the history; reopen without before to
get the newest page", never an empty page. On an open conversation the
error is sequenced and the conversation stays open; on a new one it
carries no `generation`/`sequence` and opens nothing. `stale_target` is
the contract's code for a reference to something the host no longer
holds (§7: a closed approval, a revoked rule); no other §7 code fits.
- `open` on a running session whose chat-v1 is off (Claude before its
SessionStart or after chat was withdrawn, Codex without Atem's hooks or
before its first hook) is answered `error{code:"busy"}` with the text
"chat is not available for this agent" (`: <reason>` when the adapter
gave one) and opens nothing. An exited agent's history stays readable.
SessionStart or after chat was withdrawn, Codex without Atem's hooks) is
answered `error{code:"busy"}` with the text "chat is not available for
this agent" (`: <reason>` when the adapter gave one) and opens nothing.
Codex with Atem's hooks offers chat-v1 from launch: `open` before its
first hook succeeds with status `unknown` and the detail "type a first
prompt in the terminal: …", and a `submit` is refused with that text
until the first hook arrives. An exited agent's history stays readable.
- `complete.outcome` is the adapter's turn end: `interrupted` when the turn
ended interrupted (Esc, a deny at a permission prompt, Codex's
`turn_aborted`), else `done`. A remote prompt's `complete` follows its
completed receipt and carries the outcome the broker remembered for that
request; a duplicate `submit` after a broker restart (the receipt says
only "completed") gets `done`.
completed receipt, whose `detail` is the outcome (`"done"` or
`"interrupted"`, journaled as that word only), so a duplicate `submit`
after a broker restart gets the real outcome too.
- `opened.unavailable` names what this agent's chat can't provide, so a
client says so instead of waiting for it: `streaming` (no partial text:
messages arrive whole), `reasoning`, `error_details` (no model/API error
Expand Down Expand Up @@ -431,6 +461,8 @@ on the existing authenticated Astation connection.
| Transcript format changed (required fields missing) | chat withdrawn for this agent, with the reason (open conversations get `error` and `close`); terminal unaffected |
| No acceptance within 10 s of a paste | `error` "delivery unknown"; never resent |
| Astation disconnects / re-authenticates | conversations close; Claude keeps running; a new `open` restores status and history |
| The worker dropped chat items before they reached the broker (its backlog overflowed in one read, `chat.gap > 0`) | the broker asks the worker for its whole retained history (`request_chat_history` → `chat_history_part`s + `chat_history`), replaces its own with it (keeping its newer approval card states) and catches up with the worker's chat state carried in `chat_history` (status, current and last completed turn with its outcome, input mark: a turn end it missed completes then, and so does its prompt's receipt), then open conversations get `error` "the chat history was incomplete (some items were dropped) and was reloaded; reopen the chat" (no `code`, like the chat's other own errors) and `close`; a reopen gets the complete history. Without a worker to ask, when the worker goes away before answering, or when the reload exceeds 1000 messages (all its parts are dropped, the history stays as it was, never with a hole), they get the same `error` and `close` |
| Broker restart or adoption | the worker's `chat_snapshot` carries its whole retained history (500 messages / 8 MiB), older messages first in `chat_history_part` lines of at most 1 MiB each, approval cards included (the broker copies every card to the worker, `chat_card`); a card still `pending` becomes `closed` with the note "Atem's runtime restarted; answer in the terminal" (the new broker released its approval). Prompt acceptance in flight is not carried |
| Atem exits | the terminal and Claude end with it (as today); a new Atem has a new generation |

## Testing
Expand Down
28 changes: 27 additions & 1 deletion designs/protocol/atem-host-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,30 @@ chat-v1:
session receipt.
- At most 8 open conversations per session: a ninth `open` closes the oldest
(`close`).
- History pages (fix/chat-history, 2026-10-17): `open{before}` on a
conversation the attachment already has open sends no `opened` and
resets nothing; the older page arrives as `message{history:true}` at the
conversation's next sequence numbers (the live sequence continues). Merge
paged messages by `messageId`/history order, not by `sequence`. Every
page ends with a sequenced `status` carrying `page: {before, count,
start}` (`start: true`: the oldest retained message was reached; an empty
page is the marker alone, `count: 0, start: true`). Cards are left out of
a view-only attachment's pages before the limits. `open` may carry the
`generation` of its `before`. A `before` that isn't retained, or named
with another `generation`, is `error{code:"stale_target"}` ("that message is no
longer in the history; reopen without before to get the newest page"):
sequenced on an open conversation (it stays open), without
`generation`/`sequence` on a new one (nothing opened). `open` without
`before` on an open conversation still resynchronizes it (`opened` at 0).
- When the worker dropped chat items before the broker got them, the broker
reloads the worker's whole history and chat state (a turn end it missed
completes then, with its receipt), then open conversations get `error`
"the chat history was incomplete (some items were dropped) and was
reloaded; reopen the chat" and `close`. Reopen: the history is complete.
- After a broker restart or adoption the chat history is whole (500
messages / 8 MiB) and keeps its approval cards; a card still pending
becomes `closed` with the note "Atem's runtime restarted; answer in the
terminal".
- Claude: `capabilities: ["terminal-v1","chat-v1"]`. Codex has chat-v1
only with Atem's Codex hooks on (`[codex] hooks = true`; else
`["terminal-v1"]`, and a `prompt` there is `rejected` with "chat is not
Expand All @@ -174,7 +198,9 @@ Receipts (session-v1 `submit`), per kind:

- `prompt`: pasted into the agent once its chat accepts it (status
`ready`, no local draft), completed when the chat reports the turn's end
(`turn_id`). On a session without chat (Codex with its hooks off) it is
(`turn_id`), with `detail` `"done"` or `"interrupted"` (the turn's
outcome, as chat `complete.outcome`; journaled, so it survives a broker
restart). On a session without chat (Codex with its hooks off) it is
`rejected` with no effect.
- `interrupt`: writes Esc only while the chat status says a turn runs
(`working`/`waiting`) and completes when it ends; in `ready` or `unknown`
Expand Down
7 changes: 5 additions & 2 deletions designs/protocol/atem-session-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ process and answers a later byte-equal request itself (`auto_allowed`, no
approval, no push, no lease). Attachments with `control` get the active rules
(`approval_rules`) and revoke one with a `kind:"approval_rule_revoke"`
submission. New fixture: `approval-rule-revoke-needs-the-lease-and-an-active-rule`.
Clarified the same day (no new field): a `completed` `prompt` receipt's
`detail` is its turn's outcome, `"done"` or `"interrupted"` (§6, §7).
r7.1 (2026-10-17) lets a second `hello` on a welcomed attachment
replace it instead of refusing it `unauthorized` (§3, compatibility mode):
after a client reset (e.g. the Mac slept) the client says `hello` again on
Expand Down Expand Up @@ -323,7 +325,7 @@ Kinds and payloads (v1):

| kind | payload | completes when |
|---|---|---|
| `prompt` | `{"text": string}` | the provider confirms the turn ended (chat-v1 `complete`) |
| `prompt` | `{"text": string}` | the provider confirms the turn ended (chat-v1 `complete`); `detail` is the turn's outcome, `"done"` or `"interrupted"` |
| `interrupt` | `{}` | the provider reports the turn interrupted, or the turn was already over (`detail`) |
| `stop` | `{}` | the agent process exits |
| `approval` | `{"approval_id", "decision", "rule_id"?, "message"?}` (§6a) | the agent reports it applied the decision |
Expand Down Expand Up @@ -648,7 +650,8 @@ Errors are answered in the family of the request (ruling i):
A chat `submit` hears about its request only through chat-v1 ops mapped
from its receipt, never a session `receipt`: `accepted`/`executing` →
`accepted{requestId}`; `completed` → `complete{requestId, turnId?,
outcome:"done"}`; `rejected` → `error{requestId, code:"rejected", text:
outcome}` (`outcome` is the receipt's `detail` for a prompt, `"done"`
or `"interrupted"`; `"done"` for the other kinds); `rejected` → `error{requestId, code:"rejected", text:
detail}`; `outcome_unknown` → `error{requestId, code:"outcome_unknown",
text: detail}`. Refusals at admission use the §7 code.
- Removed forms (§0) and requests that don't decode — of any family — get a
Expand Down
Loading