54 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, the worktree file browser (see "Files").internal/git: worktrees, the task changes view (see "Changes"), snapshots (see "Snapshots"), unmerged-change summaries, squash merges and pushes.internal/agents: the agent session manager. It owns the running Claude processes, task agents and lead agents alike (see "Lead agent"), and records their activity as events.internal/claude: the Claude Code stream-json client (seedocs/claude-protocol.md).internal/push: web push notifications for new inbox items (see "Notifications").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/,src/(for source maps),fonts/andvendor/(Mermaid; see "Inbox").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, worktrees/<project-id>/<task-id>, leads/<login> (each lead agent's working directory, the login path-escaped), and blobs/<blob-id> (uploaded images; the blobs table maps each id to its content type and its path relative to this directory). Task branches are byre/<task-id> in the project's repo.
Schema changes are the migrations slice in internal/store/store.go: named entries, each with a short kebab-case name saying what it does. On open, byre applies, in slice order, every entry whose name isn't in the migrations table, each in its own transaction that also records the name. Each runs with foreign keys off, so a migration can rebuild a table other tables reference, and PRAGMA foreign_key_check must come back empty before it commits. byre is deployed from task branches that each append their own migrations, so after a merge a migration can land before others the database has already applied, and it still runs. Give a new migration a name no other branch will use, and make any data migration safe to run twice. sqlite3 byre.db 'SELECT * FROM migrations' lists what has been applied. A database from before the table existed is moved over on open: the first PRAGMA user_version entries are recorded as applied, and user_version is no longer read or written.
Closing a task
A task ends merged or discarded; either way its sessions are stopped, its snapshot refs, worktree and branch are removed, its open inbox items become dismissed (resolved by whoever closed it), and its record and events remain. GET /api/tasks/{id}/unmerged reports what would be lost or merged: commits on the branch that aren't on the default branch, and files, lines and uncommitted paths that differ from the merge base. The UI's Merge and Discard buttons each ask a one-line confirmation and are disabled while the task's session is running or queued. Merge is also disabled until unmerged reports a commit or a changed file; the transcript fetches it when the task is opened and again shortly after each tool result or turn end.
POST /api/tasks/{id}/merge-in (the transcript's Sync from main button, labelled with the default branch) brings the default branch into the task branch through the agent: it sends mergeInPrompt (internal/agents/mergein.go) as a byre message to the task's session, or starts a session with it when the task has none. The prompt has the agent run git merge --no-ff --no-commit, resolve conflicts, regenerate generated files rather than hand-merging them, check the build and tests, and leave the merge uncommitted; byre's end-of-turn commit concludes it as a merge commit. The prompt ends by showing the task's current merge title and asking the agent to set a better one with set_merge_title if it no longer describes all the work on the branch. It is refused (409) while a session is running or queued.
A task's merge_title is the title of the squash commit Merge makes. It is the task title until the agent calls the set_merge_title MCP tool (internal/agents/mergein.go), which it may do at any point: one line of at most 72 characters (maxMergeTitle), stored in tasks.merge_title (empty there means "use the title"), recorded as a task.merge_title event that the transcript shows as a note. Malformed titles are rejected with a corrective message, and so is a call once the task is closed. The Merge confirm dialog quotes the title the commit will get and lists its co-authors.
A task's participants are the users who put a message bubble with their name in its transcript, derived from its events (Store.Participants): the actors of its user.message events, joined to users so byre and agent drop out. That covers messages sent to the agent and inbox answers, whose delivery message carries the name of the first responder in it (Deliver in internal/agents/inbox.go); dismissing an item puts nothing in the transcript and doesn't count. They are ordered by their first such message. GET /api/tasks/{id}/co-authors returns the participants other than the caller, with their git name and email from PUT /api/me (empty for a user who hasn't set one). The Merge confirm fetches it and lists Name <email> for each co-author, and separately those with no git identity, who get no trailer.
POST /api/tasks/{id}/merge starts the same turn and records in tasks.merging_by that the task is to be merged by the requesting user, who must have a git identity (400 otherwise). Every turn end of the task's session reads and clears it. It survives a byre restart during the turn: startup resumes the session with the restart notice, and the merge happens when that resumed turn ends. When that turn ends normally (not interrupted, not an error), byre squashes the worktree, now containing the default branch, onto it with git merge-tree --merge-base, makes one commit titled with the task's merge_title with git commit-tree, authored and committed by the merging user with no agent attribution, its body one Co-authored-by: Name <email> trailer per co-author with a git identity (squashMessage in internal/agents/mergein.go; the co-authors are computed when the squash runs, relative to the user who clicked Merge), and pushes that commit to the default branch on the origin remote of the project's checkout (git push --porcelain origin <commit>:refs/heads/<default branch>, run in the checkout). Only once the push is accepted does it run git merge --ff-only in the project's checkout (which must have the default branch checked out) and close the task; the task.merged event carries the commit and pushed (origin/<default branch>), and the transcript shows both. When the merge changes nothing, no commit is made and nothing is pushed.
Before starting the turn, Merge runs git fetch origin <default branch> and git merge --ff-only origin/<default branch> in the checkout, so the agent merges in what the remote has; the request waits for the fetch, and a failure (network, or a checkout with commits the remote lacks) is returned as the request's error. If the push is still refused, for example because someone pushed during the turn, or fails for any other reason, byre files the error in the inbox and the task stays active; clicking Merge again fetches and goes round once more. A refused push leaves the checkout's default branch where it was, since the squash commit was never moved onto it. If the squash fails, for example because the default branch moved again during the turn, byre also files an error and the task stays active. An interrupted or failed turn, an agent process that exits mid-turn, or a session that can't be resumed after a restart clears merging_by and leaves the task active without squashing. POST /api/tasks/{id}/discard removes everything without merging.
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. A message may carry model; when it differs from the session's, byre writes it to agent_sessions.model, records a session.model event (shown as a note), and sends the live CLI a set_model control request before the message, so the switch applies from that turn and survives a resume (--model) or compaction. The composer's model menu sends model only when the choice differs from the session's model, or from the session.context model when the session has none. 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.
Images: POST /api/blobs takes a raw image body (type sniffed from the bytes, PNG, JPEG, GIF or WebP only, at most 5 MiB, maxBlobBytes in internal/server/blobs.go) and returns its id; GET /api/blobs/{id} serves it. A message or session prompt may carry images, a list of blob ids (unknown ids are a 400); a message may then have empty text. byre reads each blob when the turn is sent and passes it to Claude as a base64 image content block before the text (claude.Session.Send). The user.message event records the ids as images, and a queued turn keeps them in turn_queue.images. In the composer, pasting or dropping an image adds a thumbnail with a × to remove it; images upload when the message is sent, and the transcript shows them on the user's message, each linking to the full image.
Compaction is vendor-agnostic: POST /api/sessions/{id}/compact (the transcript's Compact button, always shown and enabled only while the task has an idle session) sends the agent handoffPrompt (internal/agents/compact.go) as a byre message, asking for a handoff covering the goal, decisions and why, current state, dead ends, open questions and files. That turn files no inbox summary and isn't bounced for questions. When it ends normally, its turn.ended carries handoff: true, the session is completed, and a new session on the same task and model starts with handoffPreamble plus the handoff as its first message (actor byre). The context meter tracks the new session. The request's user.message carries handoff: true, and from it the transcript puts the request and everything the agent does in that turn into one collapsed note reading "writing a handoff…", without streaming the agent's text; when the turn ends its summary becomes the turn.ended note, or "handoff not written" if the turn ended without one. If byre restarts during the handoff turn, the continuation is lost and the old session simply goes idle.
Agents spawn parallel work with the create_task MCP tool (internal/agents/tasks.go): title, prompt, base and optional model (default: the caller's session model). It creates a task in the caller's project exactly as POST /api/projects/{id}/tasks does, and starts a session there with prompt as the first message (actor agent), created by the caller's session creator so commits use that user's git identity. base is the agent's choice: default_branch starts from the project's default branch, this_task from the caller's worktree HEAD, which leaves out uncommitted edits from the current turn. The tool returns the new task's id at once; the new session is independent of the caller.
Agents don't commit; byre does. Each user.message event that starts a turn records head, the worktree's HEAD when the turn started. When a turn ends, byre runs git add --all and commits if anything changed (git.CommitAll), as the session's creator, with the first sentence of the turn's final message as the message (commitMessage in internal/agents/manager.go); turn.ended then carries the new sha as commit. A failed commit is logged and the turn still ends.
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. Merging or discarding a task kills its sessions and marks them completed.
Every session gets byre's guidance (guidance in internal/agents/guidance.go): ask questions with ask_human, end each turn with a final message that summarizes it (including every departure from the plan), put everything the human needs in a blocking question's context since that turn files no report, don't commit (byre commits each turn), and don't push, touch other branches or run git maintenance. A task started from a TODO.md item gets one more line naming the item and telling the agent to remove it from TODO.md in its worktree when the work is done (taskGuidance; see "TODO"). It is passed as --append-system-prompt, which Claude Code records on a session's first request and reuses on resume, so a running session keeps the text it started with; a UserPromptSubmit hook also attaches it as additionalContext to every user turn, which reaches existing sessions too.
A Stop hook (bounceProseQuestion in internal/agents/guidance.go) bounces a turn whose final message asks the human something in prose: if ask_human wasn't called during the turn and the final message (last_assistant_message) has a sentence ending in ? outside code spans, code blocks, quotes and blockquotes, the stop is blocked once with an instruction to ask through ask_human. The per-turn flags reset when the next user message is sent. Claude Code reports the block as a "Stop hook error" notification; that is expected. An agent told explicitly to ask in plain text will refuse the bounce.
A turn's final message is its summary. When a turn ends normally (not an error, not interrupted) with non-empty result text, byre files that text as a report inbox item, unless the turn called ask_human with blocking true. The final message comes after the question, so a report would land in the inbox after the question it explains; such a turn's final message stays in the transcript only, and the guidance tells agents to put everything the human needs in the question's context. There is no report tool: models write a closing recap whatever they are told, so byre uses it rather than asking for a second one.
Running turns of task agents are capped globally and per vendor (DefaultLimits in internal/agents/manager.go); lead agents' turns always start and don't count. 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 is merged or discarded, 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.
Status
GET /api/admin/status reports running_turns (task sessions now running; lead turns aren't capped and aren't listed) and queued_turns (rows of turn_queue, in the order they will start), each as session, task and vendor; caps, the global and per-vendor limits from DefaultLimits; rate_limits, the latest rate_limit_info each vendor sent since byre started, unchanged; agent_processes, zombies, stuck_after_seconds and protocol_errors (below); and server counts (start time, agent processes, projects, active tasks, SSE clients, database size including WAL, last event seq). A turn's queue row is written before its session's queued state event, so a refetch triggered by that event sees it.
Agent health is internal/agents/health.go:
- Processes.
agent_processeslists every process byre is tracking (idle sessions keep theirs), with session, task, vendor, pid, the session's state,last_event_at(when the process last wrote any line to stdout, or when it started;claude.Session.LastLine) andhealth.healthisexitedwhen the stream has closed orkill(pid, 0)says the pid is gone,stuckwhen the session isrunningand nothing has arrived forStuckAfter, otherwiseok. The stream does mark long tool calls: while a tool runs, Claude Code sends atool_progressline with"heartbeat": trueevery 30 seconds, so a longgo testkeepslast_event_atfresh and a silent stream means nothing is happening. - Zombies.
running_without_process: a session markedrunningwhose process byre isn't tracking or hasexited.process_without_session: aclaudechild of the byre process (fromps -A -o pid=,ppid=,comm=) whose pid belongs to no tracked process, or a tracked process still alive for acompletedsession. A live process for anidlesession is normal and not a zombie. - Watching.
Manager.Watchrecomputes health everyhealthInterval. When the set of problems (non-okprocesses and zombies) changes, it logs each new one asagent health problemand broadcasts an unstoredadmin.healthevent (seq0). Nothing is killed or repaired automatically. - Protocol errors.
internal/claudereports a stdout line that isn't a JSON object, doesn't parse, or has atypeoutsideknownTypes(the line is still passed on), and the manager adds lines whose content it fails to decode.protocol_errors.countscounts them per vendor since byre started;recentholds the lastkeptBadLines, newest first, each with session, time, reason, the raw line cut tobadLineBytesand its fullsize. They live in memory only, and each one also broadcastsadmin.health.
The Status modal (the navbar's Status button; Esc, ×, a click outside it or following a task link closes it) shows, from the top: a Problems section only when there are zombies or protocol errors (one line per zombie, and per vendor the error count with the latest line folded under it); running turns against each cap, each running turn with the time since its process's last event and stuck or exited when it is; queued turns in order, both linking to their tasks; each vendor's rate-limit status with its reset time and per-window utilization, and the server counts. It refetches the status when a session.created, session.state, task.*, project.created or admin.health event arrives, when an agent.vendor event carries a rate_limit_event, and when the SSE stream reconnects. Other events advance the last-event seq it shows, and any event carrying a process's agent_session_id (stored or not, such as agent.delta) moves that process's last-event time forward, re-rendering at most once a second per process. A re-render keeps the scroll position and which folded lines are open. There is no timer, so "time since last event" and the "in 2h 10m" beside a reset time are as of the last render; a process going quiet long enough to be stuck shows up when the next health check broadcasts admin.health.
Agent activity becomes events with agent_session_id set: user.message, agent.message, agent.thinking, agent.tool_call, agent.tool_result, turn.ended, session.state, session.model, and session.context. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as agent.vendor. The one exception is partial output: each top-level text_delta is broadcast to SSE clients as an agent.delta event (payload.text, seq 0) that is never stored, and the transcript grows a live message from them until the finished agent.message replaces it. A turn.ended event carries inbox_item_id when its final message was filed as a report item. 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's Compact button shows the latest one as a background fill across the button, proportional to the window, tinted amber from 70% and red from 90%; the exact count is in its tooltip.
Messages byre itself sends an agent (actor byre: the restart notice, Sync and Merge prompts, Compact's handoff request and the continuation's handoff) show collapsed like a tool call, labelled byre with the first line as the brief; expanding it shows the full text. The transcript view has an accent border while the session is running, and a braille spinner on its last line, cycling ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ every 0.8s. It is one element with one CSS animation: the ten frames are drawn side by side in an inline SVG used as the .spinner mask over an accent background, and the braille keyframes in web/style.css step the mask position through them. Orion freezes animated GIFs and per-dot step-end opacity animations partway through a turn, so it stays a single element with a single animation. 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 turn's final message that is at least half visible in the transcript while the page is visible dismisses its report inbox item, as the viewing user. Selecting a task that has never had a session focuses the composer. 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. The composer is the same whether or not the task has a live session: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a "sending…" bubble, replaced by the server's user.message event with the same text when it arrives, and removed (text and images back in the composer) if the request fails.
The task switcher (⌘K, or the navbar crumb) lists active tasks with their session state and open inbox item count, live while it is open. Tasks with inbox items come first; within each group, the task least recently visited in this browser comes first (never-visited tasks before all others, ties in the server's order: projects by name, tasks newest first). Visit times are kept in the browser's localStorage under byre.visits, stamped when a task is selected and again when it is left. After the tasks come the projects, one row each; picking one points the right pane at the project with no task, on the Project tab. Typing filters both and adds a last row that creates a task with the typed text as its title, in the right pane's project (the first project when there is none); Enter picks the highlighted row. Picking a task points the right pane at it on the Transcript tab (see "Lead agent").
Lead agent
Each user has one long-running lead agent, shown in the left pane. It is not tied to a project: it watches every task, tells the human what needs them, points the right pane at it, and acts on the other agents. Its code is internal/agents/lead.go (sessions, held input, signals) and internal/agents/leadtools.go (its MCP tools).
- Sessions. A lead session is an
agent_sessionsrow withownerset to the user's login,roleleadand notask_id. Its events carryownerand no project or task. It runs inleads/<login>withleadGuidance(passed and re-attached the same way as a task agent's guidance) and the lead tools instead of the task tools. It has no Stop-hook bounce, no snapshots, no end-of-turn commit and no inbox report; a turn that ends in error records the error on itssession.stateevent, which the pane shows as a note. Its turns don't count against the caps. - Messages.
POST /api/me/lead/messagessends the caller's message to their lead, starting a lead session when there is none. While the lead is mid-turn the message is held inlead_inputsand becomes its next turn; the composer stays enabled.GET /api/me/lead/sessionslists the caller's lead sessions; interrupt and compact use the usual session endpoints. - Signals. Every published event goes through
Manager.Observe. These become signals inlead_inputsfor every user who has a lead session:inbox.created(a question with its choices and recommendation, a report's text cut to 1500 characters, an error or rate limit),inbox.updatedwhen a human answered a question or error,task.created(naming the TODO item it was started from, if any),task.mergedandtask.discarded. The human switching the right pane is not a signal. A signal caused by the lead itself (actor<login> (lead)) is skipped, as is the update from an answer it gave. A task'stask.createdevent carries the actor that created it, separately from itscreated_bylogin: tasks the lead creates withcreate_taskorstart_todokeep the human's login ascreated_bybut have actor<login> (lead). Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as onebyremessage headed "Signals since your last turn (n)". Its guidance tells it not to respond to the human's own actions, and to make its final message exactlyNO_REPLYwhen a batch needs nothing from it. A transcript (quietReplyinweb/src/task.ts) never draws anagent.messagethat is exactlyNO_REPLY, hides the live partial message while its text is still a prefix of it, and when that turn ends removes the whole turn, from itsbyremessage through anything the lead did, so nothing of it is drawn. - Tools.
list_tasks,read_transcript(a task's events in brief, the lastlimitor those afterafter_seq),read_inbox,answer_question(anoption,otherordismissresponse recorded as the user, through the same checks asPOST /api/inbox/responses),send_message(starts the task's agent if it has none; refused while it is mid-turn),create_task,start_todo,interrupt,compact_task,merge_task,discard_task,showandcompact_self. The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor<login> (lead), so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks. - Focus.
users.focusholds what the user's right pane shows:project_id,task_id,tabs(the tabs put first, in order) andtab(the selected one). With atask_id,project_idis always that task's project, filled in by the server; with only aproject_id, the pane shows that project with no task, which only the Project tab has anything for.GET /api/me/focusreads it;PUT /api/me/focusand the lead'sshowset it, refusing an unknown tab, a task that isn't active or an unknown project, and record afocus.updatedevent owned by the user. The page applies the focus on load and on everyfocus.updatedit didn't cause itself; the human's own changes (⌘K, the navbar crumb, clicking a tab, a task link, a notification) are sent withPUT /api/me/focus. The URL follows the focus:#/task/<id>,#/project/<id>, or#/for neither. - Pane lock. While the human is working in the right pane, the lead's
showis refused with an error telling it to say in chat what it would have shown, unless the pane is finished (Manager.paneFinished): its task is merged or discarded, or its selected tab isinboxand the task has no open items. When aninbox.updated,task.mergedortask.discardedevent leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: has nothing left in its inbox." (or "is merged."/"is discarded."), even when the event itself makes no signal, such as a dismissed report;inbox.updatedon a task that is no longer active adds none. The lead's guidance tells it to show the next thing that needs the human on that line, or the project's Project tab withNO_REPLYwhen nothing does. The page sendsPUT /api/me/pane-focuswithfocusedwhenever that changes: true while the browser window has focus and the focused element is inside the right pane. The pane itself is focusable (tabIndex-1), so a click anywhere in it counts, not only on inputs and buttons; a re-render that removes the focused element hands focus back to the pane rather than counting as leaving. The page sends false onpagehide(withkeepalive) and resends true when its event stream reconnects. byre keeps the flag in memory per user (Manager.paneFocused), so it starts false after a restart until the page resends it. - Compaction. The pane's Compact button asks the lead for a handoff (
leadHandoffPrompt). The lead can also callcompact_selfat a logical point; when that turn ends normally, byre sends the same request. The handoff turn ends the session, and a new lead session starts fromleadHandoffPreambleplus the handoff; held input follows once that turn ends. - Restarts. On shutdown, byre marks each running lead session idle with "byre stopped mid-turn" and holds a restart notice for it, so a lead is never left
runningfor a build without lead support to find. After a crash, startup resumes a running lead like a task session. Startup then delivers any held input.
The UI is two panes: the left is the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), the right shows the focused task, or project with no task, with the Transcript, Changes, Files, Inbox and Project tabs, the lead's tabs first.
TODO
A project's TODO.md is read from the root of its default branch in the project's checkout (git cat-file blob <default branch>:TODO.md), so it shows what has been merged, not uncommitted edits in the checkout. GET /api/projects/{id}/todo returns exists and the parsed items (internal/todo): every top-level list item (-, *, +, 1. or 1) at the start of a line) is an item. Its first line, with any [ ] checkbox removed, is its title; the lines indented under it (and lines continuing it without a blank line between) are its body, with the common indentation removed; section is the nearest heading above it. Checked items ([x]) are skipped, list markers inside fenced code don't count, and paragraphs between items are ignored.
The title is the item's identity. POST /api/projects/{id}/todo/tasks with a title re-reads TODO.md, refuses an unknown title (404) or one an active task already has (409), creates a task from the default branch with that title and todo_item (the tasks.todo_item column, empty for other tasks), and starts its session as the requesting user with todoPrompt (internal/agents/tasks.go): the item as written, and an instruction to remove it from TODO.md in the worktree when the work is done. The agent's guidance names the item too (taskGuidance, see "Agent sessions"), so the instruction survives compaction and resumes. The agent removes the item on its own branch, so the removal lands on the default branch in the same squash commit as the work.
The Project tab is the dashboard of the right pane's project (the focused task's, or the project focused with no task). Under Tasks it lists the project's active tasks in the server's order (newest first), each with its session state and open inbox item count, as links that point the right pane at the task's transcript; the focused task is highlighted. Under TODO it lists the TODO.md items, grouped by section, with each body rendered as Markdown. An item with an active task whose todo_item equals its title shows that task's session state as a link to the task instead of a Start button. Start posts the item with the composer's default model and points the right pane at the new task's transcript. The task list follows byre's live state; the tab fetches the project's TODO.md when it opens, when the right pane moves to another project, when the project list changes, when the SSE stream reconnects, and on task.merged; a change pushed to the default branch outside byre shows the next time one of those happens.
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, and its choices are written inline in that context as Markdown list items: - ( ) text for pick-one choices, with exactly one - (*) text marking the recommendation, or - [ ] text for pick-any choices, with - [x] text marking the recommended ones. There is no separate list of options. byre parses the choices out of context (internal/agents/choices.go; list items inside code fences and blockquotes don't count) and stores their text in the payload as options, with multiple (pick-any) and recommended. The UI renders the context as Markdown with a radio button or checkbox in place of each choice's marker. A malformed call (no context or why_blocked, fewer than two choices, pick-one and pick-any choices mixed, a pick-one question without exactly one recommendation, two choices with the same text, an unknown attachment kind, a foreign thread_id) is rejected with a message saying what to fix, and nothing is filed. The call returns the item's id as question_id. An agent withdraws a question it no longer needs answered (the human answered it in a chat message, say) with the cancel_question MCP tool and that id: an open question of its own session becomes dismissed with no resolved_by (its inbox.updated event has actor agent), which also stops a blocking one from holding back delivery; anything else is rejected with a message saying why. Each turn's final message (as a report) 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 items, oldest first; project_id and task_id narrow it to a project or task view, and state picks another state. Blocking questions and errors are blocked; reports and non-blocking questions are fyi. The Inbox tab is task-specific: it shows the selected task's open items in time order, and nothing when no task is selected.
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 (the chosen choices' text in options: exactly one for pick-one, one or more for pick-any; a pick-any one may also carry text, delivered after the checked list) |
questions | answered |
other (free text in text: an answer, a question back, or new instructions) |
questions, errors | answered |
dismiss |
reports, errors | dismissed |
Answers reach the agent as one message holding every undelivered option and other response of the session (deliveryMessage in internal/agents/inbox.go), never while one of its blocking questions is still open; until then they stay held (inbox_responses.delivered_at empty). While a turn is running, the task session's PostToolUse hook (afterTool in internal/agents/snapshots.go, through deliverMidTurn) hands them over after the agent's next tool call as the hook's additionalContext, so the agent reads them alongside that tool's result without being interrupted; the user.message event recording it carries mid_turn: true and no head, and the transcript doesn't treat it as a turn start. A turn that makes no further tool call leaves them to Deliver, which runs after each submission and when a turn ends, and sends them as a new turn once the session is idle. The log traces each answer: inbox response recorded when it is stored, deliver held with the reason, deliver sent mid-turn, deliver sent with the session's state and whether its agent process had exited or byre was closing, and undelivered responses with a reason wherever a session has held answers when its turn ends in a handoff or merge, its agent process ends, startup resumes it, or startup finds it idle. cancelled queued turn carried inbox responses logs the text of a delivery that a cancelled queued turn discards. sqlite3 byre.db "SELECT * FROM inbox_responses WHERE delivered_at IS NULL" lists the answers held now.
Notifications
New inbox items are sent as web push notifications to every browser a user has turned them on in. Web push needs a secure origin and a service worker; byre gets the origin from the tailscale serve certificate, and serves the worker at /sw.js (compiled from web/src/sw.ts with its own web/tsconfig.sw.json, since it needs the WebWorker library instead of the DOM one) so its scope is the whole SPA.
- Keys. byre generates a VAPID key pair on its first start and keeps it in the
vapid_keystable;GET /api/push/keyreturns the public half, which browsers pass toPushManager.subscribe. Replacing the keys invalidates every subscription. - Subscriptions.
POST /api/me/push-subscriptionsstores a browser's endpoint,p256dhandauthinpush_subscriptionsunder the calling user (re-posting an endpoint replaces it), along withhttps://plus the request'sHost, which becomes the VAPIDsubclaim.DELETE /api/me/push-subscriptions?endpoint=removes one. A push service answering 404 or 410 means the subscription expired, and byre deletes it. - Preferences.
users.notification_prefsholds which item kinds notify the user, returned asnotification_prefsonGET /api/meand set withPUT /api/me/notifications:blocking_questions(on by default),questions(non-blocking),reports,errors(session_error) andrate_limits. - Sending.
internal/pushwatches the event stream forinbox.created. The first item starts a 3-second window (pushWindowininternal/server/server.go); everything that arrives inside it goes out together, so each subscribed user gets at most one notification per window, built from the items their preferences select. One item readsbyre · <task title>with the question (or the report's first line, the error, or "Rate limited") as the body; several readbyre · N new inbox itemswith one<task title>: <text>line each. Encryption and VAPID signing aregithub.com/SherClockHolmes/webpush-go. Notifications carry a 24-hour TTL andUrgency: highwhen any item is blocking. Delivery failures are logged, not retried. - Clicking a notification opens the first item's task (
/#/task/<id>): the worker focuses an open byre window and tells it to switch task, or opens a new one.
The Settings modal (the navbar's Settings button) turns notifications on or off for the current browser (asking for permission on the way) and edits the preferences. Opening it re-registers the browser's existing subscription, which restores one byre deleted as expired.
In the UI each card is answered on its own; there is no batch submission. A pick-one question's choices are clickable blocks, and clicking one sends that answer at once. A pick-any question's choices are checkboxes, sent with the Submit button at the bottom of the card. Below the choices, an "Other…" block in the same style opens the only text field. A card has at most one Submit button: on a pick-any question it sends the checked choices with the Other text alongside (one option response), or the Other text alone as other when nothing is checked. A report has only an OK button, which dismisses it at once; an error has a "Reply…" block (other) and a Dismiss button. The Other and Reply text boxes send with the Submit button under them or on Enter (Shift+Enter for a newline), like the message box, and fold back into their block when left empty. The list updates in place: a card stays attached while its item is unchanged, so task and inbox updates from other agents don't pull focus out of a text box being typed in.
There is no snoozing. The snoozed_until and snooze_until columns are still in the schema, always empty, so builds from older branches keep working against the same database; nothing reads them.
Every Markdown view (inbox items and the transcript) renders a line <details><summary>Title</summary> (or <details> with the <summary> on the next line), Markdown content, and a line </details> as a collapsed section (web/src/markdown.ts; sections nest) showing only the title; session guidance and the ask_human schema tell agents to put long supporting detail in them.
Markdown images (, or ; the plain form also accepts spaces, since worktree paths under Application Support have one) render inline, linking to the full image. A src that is an absolute path, a path relative to the worktree or a file:// URL is loaded from GET /api/tasks/{taskId}/image?path= for the item's or event's task; a data:image/ URL of PNG, JPEG, GIF or WebP is used as is; any other scheme, http(s) included, shows as a link with the alt text and is never fetched. An image that fails to load is replaced by "image not available: ". An inbox image attachment renders the same way from its image (data URL) or path.
A code block fenced as mermaid, and an inbox diagram attachment, render as a Mermaid diagram (web/src/mermaid.ts). The library is vendored: web/package.json pins mermaid as a devDependency, and go generate ./web copies node_modules/mermaid/dist/mermaid.min.js and its LICENSE into web/vendor/, which is committed and embedded. To upgrade, change the pinned version, npm --prefix web install, and regenerate. Nothing loads it until the first diagram is drawn; that injects one <script src="/vendor/mermaid.min.js"> and initializes Mermaid with securityLevel: "strict" and the base theme, coloured from the custom properties in web/style.css as they resolve at that moment (so a light/dark switch applies from the next page load). Each diagram shows its source until its SVG is ready; source Mermaid rejects stays as source with "diagram not rendered: " under it. The endpoint (internal/server/images.go) serves PNG, JPEG, GIF, WebP and SVG by extension, resolves symlinks on both the worktree and the file and answers 403 for anything outside the worktree, 404 for a missing file, 400 for another extension or a non-file, and 409 once the task is closed (its worktree is gone). Responses carry X-Content-Type-Options: nosniff and a sandboxing Content-Security-Policy, so an SVG opened directly runs no script, and Cache-Control: no-cache with Last-Modified, so a changed file is refetched.
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.
Changes
GET /api/tasks/{taskId}/changes snapshots the task's worktree as if everything were staged: it copies the worktree's index to a temporary file, runs git add --all against that copy (GIT_INDEX_FILE), and git write-trees it, so committed, staged, unstaged and untracked work is included, .gitignore is respected, and the worktree's real index is never touched. Starting from the real index rather than read-tree HEAD keeps git's stat cache, so unchanged files aren't rehashed. The tree is compared with git merge-base HEAD <default branch>, or with since when given, using rename detection, giving per-file status and line counts. Work already merged into the default branch therefore drops out. The Changes view toggles between Since main and This turn, which passes the latest user.message event's head as since; the summary above the inbox always shows since main. GET /api/tasks/{taskId}/diff returns one file's patch between a base and tree from that response. Each request writes the changed blobs and a tree into the repo's object store as loose objects.
The Changes view lists the files with their counts, opens a file's diff on click (open files stay open across refreshes), and refreshes when it opens, when the selected task changes, and 1.5s after the selected task's last tool result or turn end. It can also be pinned to a snapshot (see "Snapshots"). The Inbox view shows just its summary line (file count, +/− totals, base) for the selected task at the top.
Files
The Files tab browses the selected task's worktree, read-only (internal/server/files.go). GET /api/tasks/{taskId}/dir?path= lists one directory, directories first and then by name, without entries named .git; GET /api/tasks/{taskId}/file?path= returns one regular file. Both take a slash-separated path relative to the worktree root and work only while the task is active (409 otherwise). Every path is opened through an os.Root on the worktree, so a path that leaves it, through .., an absolute path or a symlink at any depth, is a 400; a path with a .git component is also refused. A listing reports each entry as dir, file (with its size) or other, following symlinks that stay inside the worktree (symlink marks them); a symlink that leaves the worktree or points nowhere is other and can't be opened. Gitignored entries are listed with ignored set: one git check-ignore --stdin per listing (git.Ignored) over the entries' paths, taken from the directory's real path so a listing reached through an in-worktree symlink is checked where it really lives; untracked paths under an ignored directory count as ignored, tracked files never do. The view shows them dimmed with ignored beside them, and they open like anything else. A listing stops at maxDirEntries entries with truncated set. A file read sends at most maxFileBytes, cut back to a whole UTF-8 character, with truncated set when the file is longer; a file with a NUL byte or invalid UTF-8 in that part is binary and comes back with no text.
The view shows a breadcrumb from worktree down to the current directory or file, the directory's entries (a ../ row to go up), or the file's text with line numbers and a note when only its start is shown. Where you are is kept per task in memory, so switching tasks or tabs returns to it, but a page reload starts at the root. It refetches what it shows 1.5s after the selected task's last tool result or turn end, keeping the scroll position.
Snapshots
byre snapshots each task's worktree as it changes (internal/agents/snapshots.go, internal/git/snapshot.go). A snapshot builds the tree the same way as the Changes view (a copy of the real index, git add --all, git write-tree), commits it with git commit-tree whose parent is the worktree's HEAD, authored as the session's creator with the message byre snapshot <n> (<trigger>), and points refs/byre/tasks/<task-id>/snap/<n> at it (git update-ref with an empty old value, so an existing ref is never overwritten). A row in the snapshots table records id, task, session, n (from 1 per task), commit and tree shas, event_seq (the task's latest event seq read before the tree was built, so the tree holds at least the work up to that event), the trigger and the time, and the same transaction records a snapshot.created event carrying the row.
Triggers (trigger):
tool_call: aPostToolUsehook on every tool, shell commands included. The hook returns at once; the snapshot runs in the background.turn_end: after byre's end-of-turn commit.ask_human: when a question is filed.timer: everysnapshotInterval(internal/agents/snapshots.go) while the session isrunning, as a backstop for long commands.
A snapshot whose tree equals the task's latest snapshot's tree is skipped. Snapshots run one at a time per task: a trigger that arrives while one is running replaces any already waiting one, and the waiting one runs when the current one finishes, so a burst of tool calls costs at most two. Failures are logged and dropped.
Merging or discarding a task stops new snapshots for it, waits for one in progress, and deletes its refs/byre/tasks/<task-id>/ refs; the rows stay. GET /api/tasks/{taskId}/snapshots lists a task's rows, oldest first. git for-each-ref refs/byre/ in the project's repo lists the live refs.
GET /api/tasks/{taskId}/changes?snapshot=<n> reports a snapshot instead of the live worktree: its tree is the snapshot's tree, and base is the merge-base of the snapshot's commit and the default branch, or with step=true the previous snapshot's tree (the snapshot commit's parent for snapshot 1). It reads the stored shas, so it works only while the task is active and its refs keep the objects; since with snapshot is a 400. GET /api/tasks/{taskId}/diff takes that base and tree as usual.
The transcript is the timeline. The UI learns a task's snapshots from the snapshot.created events in its transcript (snapshots in web/src/state.ts). Each snapshot is marked #n at the right of the transcript row it belongs to: for tool_call, timer and ask_human, the last tool call, ask_human note or turn end whose seq is at most the snapshot's event_seq (the PostToolUse hook fires before the tool's result is recorded, so that is the call that made it); for turn_end, that turn's end line, which may be the turn after event_seq since the snapshot and turn.ended race. A row with several snapshots shows #a–#b, and a collapsed group of tool calls shows the range of all its calls' snapshots. Clicking a mark pins the latest snapshot it covers (state.pinned, cleared when the selected task changes) and switches the right pane to the Changes tab. Pinned, the Changes header reads snapshot <n> · <trigger> · <time> with ← and → to step to the neighbouring snapshots, and the range buttons are Since main and This step (step=true, the default when pinning) plus Live, which unpins. A pinned view doesn't refresh on tool results, since a snapshot never changes.
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. ?task_id= limits the stream, replay and live, to one task's events (404 for an unknown task); ?lead=true to the events of the caller's lead agent sessions. An event with an owner (a lead agent's events, a user's focus.updated) goes only to that user's streams; the others never see it. Unstored events (seq 0, currently only agent.delta) go out live only, without an id line, so they don't move Last-Event-ID. A client that falls further behind than its subscriber buffer (subscribe in internal/server/hub.go) is disconnected and catches up by reconnecting. Every 15s the server sends an event: ping so clients can tell a live stream from a dead one. The web UI (subscribe in web/src/api.ts) tracks the last seq it saw and opens a new stream from it when nothing (event or ping) has arrived for 40s, 2s after the browser gives up on one (as it does when tailscale serve answers 502 while byre restarts), when the browser comes back online, and on becoming visible if the stream is stale. While disconnected the navbar shows a red dot and "reconnecting…"; partial output (agent.delta) sent while disconnected is lost, and the finished agent.message replaces it.
The web UI keeps three streams: one for all events from the page's load onward, which drives the task, session, inbox and lead session lists, the right pane's focus and the navbar's connection dot; one per selected task (selectTask in web/src/state.ts), opened with ?task_id= and after=0 so it replays the task's whole history and then stays live; and one for the lead (startLeadStream), opened with ?lead=true and after=0. Each transcript is drawn only from its own stream, so a reconnect after a byre restart fills in both the history and anything missed. Selecting another task closes the old task stream. Events arriving together are drawn in one batch.