9.1 KiB
Operations
Layout
cmd/byre:byre serveruns the server;byre rundrives one Claude session in the current directory from the terminal.api/openapi.yamlis the API source of truth.go generate ./internal/apiregeneratesinternal/api/api.gen.go, whose types are also the store's model types.internal/store: SQLite (WAL). Every mutation and its event commit in one transaction; events are published to SSE subscribers inseqorder.internal/server: HTTP handlers, identity, SSE.internal/git: worktree create and remove.internal/agents: the agent session manager. It owns the running Claude processes and records their activity as events.internal/claude: the Claude Code stream-json client (seedocs/claude-protocol.md).internal/report: thereportMCP tool and the Stop hook that requires it each turn.web/: the SPA, frameworkless TypeScript compiled file-by-file to ES modules (no bundler).web/src/*.tsis the source;web/src/api.gen.tsholds types generated fromapi/openapi.yaml, andweb/src/api.tsis the typed client over them.go generate ./webrunsnpm ci, regenerates the types and compilesweb/dist/, which is committed sogo installneeds no Node. The binary embedsindex.html,style.css,dist/andsrc/(for source maps).byre serve --devserves./webfrom disk instead (run from the repo root); pair it withnpm --prefix web exec tsc -- --watch.
Data
State lives in the byre directory under Go's os.UserConfigDir() (on macOS, ~/Library/Application Support/byre): byre.db and worktrees/<project-id>/<task-id>. Task branches are byre/<task-id> in the project's repo.
Running as a service
The listen address is listenAddr in cmd/byre/serve.go; it binds loopback only.
go install ./cmd/byre
cp deploy/run.fc.byre.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/run.fc.byre.plist
tailscale serve --bg --https=<port from listenAddr> http://<listenAddr>
byre gets its own HTTPS port on the machine's ts.net name rather than a path under another service: the SPA and API use absolute paths, which break under a path prefix. tailscale serve status shows the URL and what else the machine serves.
After rebuilding: go install ./cmd/byre && launchctl kickstart -k gui/$(id -u)/run.fc.byre. Logs go to ~/Library/Logs/byre.log.
Clients reach tailscale serve over HTTP/2; it proxies to byre over HTTP/1.1 and passes SSE through unbuffered.
Identity
tailscale serve adds Tailscale-User-Login and Tailscale-User-Name to proxied requests. The server accepts API requests only from loopback and only with both headers; anything else gets 401 or 403. The SPA's static files need no identity. Users are created on first request; each sets their commit identity with PUT /api/me.
To call the API locally without Tailscale, send the headers yourself:
curl -H 'Tailscale-User-Login: you@example.com' -H 'Tailscale-User-Name: You' http://<listenAddr>/api/me
Agent sessions
POST /api/tasks/{id}/sessions starts a Claude session in the task's worktree and sends the prompt as its first turn; POST /api/sessions/{id}/messages starts later turns and POST /api/sessions/{id}/interrupt ends the current one. A task has at most one session that isn't completed. The session's commits use the git identity of the user who created it, so that user must have set one with PUT /api/me.
byre chooses the Claude session ID (vendor_session_id) up front, so every session can be resumed. Processes start on demand: the first message after a restart resumes the session with --resume. Sessions are running from the moment a turn is sent until its result arrives; on startup, every session still marked running was cut off mid-turn, and byre resumes it and sends a message telling the agent to re-check the working tree. Stopping the server kills all agent processes. Completing a task kills its sessions and marks them completed.
Running turns are capped globally and per vendor (DefaultLimits in internal/agents/manager.go). A turn that would exceed a cap leaves its session queued, with the message held in the turn_queue table; whenever a turn ends, a process exits, or a task completes, the oldest queued turns whose vendor has room start. Interrupting a queued session cancels its queued turn. Idle sessions with a live process don't count against the caps. Whether a turn resumes the Claude conversation is decided by whether the session has ever sent one.
When Claude Code reports a rate-limit status of rejected and the turn then fails, the inbox gets a rate_limited item carrying the vendor's status (including when the limit resets) instead of a generic error. GET /api/admin/status reports running and queued turns and the latest rate-limit status each vendor sent.
Agent activity becomes events with agent_session_id set: user.message, agent.message, agent.thinking, agent.tool_call, agent.tool_result, agent.report, turn.ended, session.state, and session.context. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as agent.vendor. An agent.report event carries inbox_item_id, the inbox item filed for the same report. session.context records the context size whenever it changes: used is the last assistant message's input, cache-read, cache-creation and output tokens, and window is the model's context window from the turn's result (modelUsage[model].contextWindow), so it is 0 until the session's first turn after a server start ends. The task header shows the latest one as a percentage of the window, coloured green, amber from 70%, red from 90%.
Messages byre itself sends an agent (actor byre, such as the restart notice) show as a muted note, not a user bubble. The transcript view has an accent border while the session is running, and a braille spinner on its last line. The spinner is two GIFs in web/img/, one per colour scheme, rendered by web/img/spinner.go during go generate ./web; its colours are copied from the palette in web/style.css, so a palette change means updating them there too. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. A report card that is at least half visible in the transcript while the page is visible dismisses its inbox item, as the viewing user. In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task in memory, so it survives switching tabs or tasks but not a page reload.
Inbox
Agents ask questions with the ask_human MCP tool. Each question carries a context with the background the human needs to answer without reading the transcript; the UI renders it, the question, and the options as Markdown. A malformed call (no context or why_blocked, fewer than two options, a recommendation that isn't one of the options, an unknown attachment kind, a foreign thread_id) is rejected with a message saying what to fix, and nothing is filed. Each report call and each agent error (a turn that ends in error without a human interrupt, a process that dies mid-turn) also files an item.
GET /api/inbox is one query over open, unsnoozed items, oldest first; project_id and task_id narrow it to a project or task view, and state and include_snoozed widen it. Blocking questions and errors are blocked; reports and non-blocking questions are fyi. The inbox view groups items by task, each group headed by project and task: tasks with a blocked item come first, then tasks sort by project name and by their oldest item. Within a task, items are in time order.
POST /api/inbox/responses answers any number of items in one submission. Response types and what they do to the item:
| type | allowed on | item becomes |
|---|---|---|
option |
questions | answered |
other (free text in text: an answer, a question back, or new instructions) |
questions, errors | answered |
snooze |
any | stays open, hidden until snooze_until |
dismiss |
reports, errors | dismissed |
In the UI a question offers its options plus Other, whose text box is the only text field; a report has only an OK button, which dismisses it at once; an error offers Dismiss, Reply (other) and Snooze.
Answers reach the agent as one message, listing each answer under the question or error it responds to, once the session is idle and none of its blocking questions are still waiting for the human. An other answer to a question tells the agent that if it asks something back, it should answer with ask_human using the item's thread_id. Every item change is an inbox.created or inbox.updated event carrying the whole item, including who resolved it.
Events
GET /api/events is server-sent events: id is the event's seq, data the Event JSON. Reconnecting with Last-Event-ID (EventSource does this itself) or ?after= replays everything after that seq before going live. A client that falls further behind than its subscriber buffer (subscribe in internal/server/hub.go) is disconnected and catches up by reconnecting.