Skip to content

feat(chat): chat-v1 host for Claude Code (adapter-based) - #65

Merged
guohai merged 21 commits into
mainfrom
feat/agent-chat
Oct 15, 2026
Merged

guohai merged 21 commits into
mainfrom
feat/agent-chat

Conversation

@guohai

@guohai guohai commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Chat view of the Claude Code session running in Atem's shared terminal (atem agent launch claude), for Astation's browser page (and a mobile app later). Design: designs/agent-chat-host.md; plan: designs/plans/2026-10-15-agent-chat-host.md.

  • Adapter-based (src/chat/adapter.rs): agent-specific code lives in one adapter; v1 ships Claude Code. Codex and others plug in later.
  • Claude adapter (src/chat/claude/): per-launch hooks via --settings (user ~/.claude untouched) write one JSON file per event into a private 0700 directory; --session-id pins the transcript. Claude's transcript JSONL is the only content source. Tools are one-line notes; thinking, tool output and file contents are never sent.
  • Chat session (src/chat/session.rs, agent-neutral): status from real events (ready / working / waiting / unknown), one complete per turn, history, request-id dedupe, redaction through memory::secrets. A chat prompt is pasted (bracketed paste, all-or-nothing) only after six checks: generation, new request id, size and characters, ready, no terminal input since the last turn ended, no viewer holding control. Accepted only when both the hook and the transcript show it; otherwise "delivery unknown" after 10 s, never resent. Prompts starting with / or ! are refused.
  • Wire (src/chat/wire.rs): {"type":"agentChat","data":{"v":1,...}} on the authenticated Astation link, never during re-authentication.
  • Agent registry: real status for Claude, new Unknown status, optional capabilities: ["chat-v1"] (omitted when empty, so the agent-list bytes are unchanged for other agents). Codex is now listed, as Unknown.

Known v1 limit (in the spec): an unfinished draft typed in the terminal during a turn is treated as consumed when the turn ends.

Testing

  • cargo test: 1265 unit tests (incl. 99 chat tests: transcript reader on recorded Claude Code 2.1.299/2.1.300 fixtures, hooks, session state machine, wire, in-process end-to-end with a fake claude and a fake Astation peer) + agent_launch_binary (new: launch passes --settings/--session-id, chat dir removed on quit), key_agent_binary, terminal_ui_binary: all pass.
  • cargo clippy --all-targets: no new warnings.
  • Not yet done: manual acceptance with real Claude Code and Astation's chat page (Astation side pending; handoff in shared memory).

Generated with SMT smt@agora.build

guohai added 21 commits October 15, 2026 03:09
Approved design for the Atem host side of Astation's structured chat:
transcript as the only content source, hooks for lifecycle, honest status,
no approvals or resends from chat, client-neutral message model for web now
and a mobile app later, and the additive contract changes for the Astation
agent.

🤖 Built with SMT <smt@agora.build>
… + token

Each Claude hook writes its event JSON to its own file in the private 0700
session directory (write then rename); Atem reads them in order and deletes
them. No hidden subcommand, socket or token; same trust boundary.

🤖 Built with SMT <smt@agora.build>
🤖 Built with SMT <smt@agora.build>
…ug in)

Agent-specific code (launch arguments, lifecycle source, log parser,
capabilities) moves behind an AgentAdapter with neutral AgentEvent/LogItem
types; the chat state, message model, wire, security and reconnects are
shared. v1 ships the Claude Code adapter; agents without an adapter keep the
shared terminal with status unknown and no chat-v1.

🤖 Built with SMT <smt@agora.build>
…adapter

🤖 Built with SMT <smt@agora.build>
🤖 Built with SMT <smt@agora.build>
…ude Code 2.1.299)

🤖 Built with SMT <smt@agora.build>
Items used only by later tasks are covered by one allow(dead_code) on
mod chat in main.rs, to be removed in Task 6.

🤖 Built with SMT <smt@agora.build>
🤖 Built with SMT <smt@agora.build>
🤖 Built with SMT <smt@agora.build>
…ecks, history)

🤖 Built with SMT <smt@agora.build>
🤖 Built with SMT <smt@agora.build>
🤖 Built with SMT <smt@agora.build>
Each Claude spawn gets a chat directory, --settings hooks and a pinned
--session-id (placed before the user's arguments, for that spawn only)
and a new generation. poll_chat feeds hook events, then transcript
items, then turn ends into the ChatSession each tick, times out
unaccepted prompts, sends a 15 s status heartbeat and broadcasts to open
conversations with per-conversation sequences. The agent registry shows
Unknown until SessionStart, then the chat status, and chat-v1 while the
adapter reports it; the agent list is re-sent on a change. agentChat
open/submit/close is handled only on an authenticated link; disconnect
and re-authentication close conversations. Process exit reports unknown
and removes the chat directory. Codex registers as Unknown without chat.

In-process end-to-end tests drive the App with a fake claude script in a
PTY and a fake Astation peer; the launch binary test checks the hook
arguments and that the chat directory is removed on quit.

🤖 Built with SMT <smt@agora.build>
🤖 Built with SMT <smt@agora.build>
- A Claude respawn first reports the old process's exit, then sends each
  open conversation `error` "the agent was restarted; reopen the chat"
  and `close`, then drops the old chat.
- A fatal withdrawal sends `close` after the `error` (wire::close).
- Opening a conversation restarts the 15 s heartbeat clock.
- Launching Codex sends the agent list when connected.
- Tests: wait for the expected history/status instead of fixed windows;
  offline and suspended links ignore `open` and suspend clears
  conversations; respawn, close-after-withdrawal, heartbeat reset and
  the Codex agent list are covered.
- AGENTS.md and designs/agent-chat-host.md: late SessionStart still
  enables chat, close after withdrawal, restart row, `open` with
  `before` on an open conversation.

🤖 Built with SMT <smt@agora.build>
…l, prompt prefixes)

- SessionStarted carries the hook's source and same_log; compaction or a
  resume on the same transcript keeps the turn, pending prompt and draft mark;
  startup, /clear or another transcript reset to ready.
- Any user entry starting with "[Request interrupted by user" ends the turn as
  interrupted (also deny/Esc at a permission prompt).
- Switching transcripts reads the old one to its end first (the switch waits
  one poll); a resumed session's existing content is backfill: history only,
  then open conversations get "the conversation was switched; reopen the
  chat" and close.
- Chat prompts starting with "/" or "!" are refused.
- Prompt matching trims both ends; a SessionStart whose transcript is not
  <session_id>.jsonl is ignored with a notice; chat dirs carry a pid file and
  stale ones are swept once per App; heartbeat per conversation, at most 8
  conversations per agent; history capped at 8 MiB; live tests use a temp
  chat root. Spec and AGENTS.md updated.

🤖 Built with SMT <smt@agora.build>
The stale-dir sweep records the host next to the pid and never removes a
directory another host owns (a shared home). Resuming a session that was
already read does not add its prompts to history again.

🤖 Built with SMT <smt@agora.build>
@guohai
guohai merged commit 10d3ea8 into main Oct 15, 2026
3 checks passed
@guohai
guohai deleted the feat/agent-chat branch October 15, 2026 04:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant