Files
byre/docs/operations.md
T

245 KiB
Raw Blame History

Operations

Layout

  • cmd/byre: byre serve runs the server; byre run drives one Claude session in the current directory from the terminal.
  • api/openapi.yaml is the API source of truth. go generate ./internal/api regenerates internal/api/api.gen.go, whose types are also the store's model types.
  • internal/store: SQLite (WAL). Every mutation a client sees commits in one transaction with its event, and events are published to SSE subscribers in seq order. Internal bookkeeping writes commit with no event of their own: user rows from EnsureUser, blob rows, a merge's finalizing_commit, finalizing_notes and finalizing_unchanged, and the review_requests counter, whose effect shows only through the task.state it can emit.
  • internal/server: HTTP handlers, identity, SSE, the worktree file browser (see "Files"), the terminal WebSocket (see "Terminals").
  • internal/terminal: each task's shell on a PTY, shared by the terminal WebSocket and the agents' terminal tools (see "Terminals").
  • internal/git: worktrees, the task changes view (see "Changes"), snapshots (see "Snapshots"), unmerged-change summaries, squash merges, merge notes and pushes.
  • internal/agents: the agent session manager. It owns the running agent processes, task agents, lead agents (see "Lead agent") and reviewers (see "Reviews") alike, and records their activity as events.
  • internal/claude: the Claude Code stream-json client (see docs/claude-protocol.md).
  • internal/codex: the codex app-server JSON-RPC client, with types generated from the CLI's schema by go generate ./internal/codex (see docs/codex-protocol.md). One goroutine reads the CLI's stdout and blocks on the bounded Events channel when its consumer falls behind, so responses queued behind those notifications wait too: the goroutine draining Events (codexProcess.drive) therefore never waits on a reply itself. It writes turn/steer in order and waits for the reply on another goroutine, and starts the prose-question nudge and the turn-end Deliver on their own goroutines. Every request gives up after rpcTimeout, or the caller's earlier deadline, and that deadline covers waiting for the write lock, writing the request to stdin and waiting for the reply. Stdin is an os.Pipe, so writes take a deadline. A request whose deadline passes before any of it is written fails and leaves the CLI running. One that times out after part of its line is written has broken the framing, so byre kills the CLI, logging codex stopped reading its stdin mid-line, and the session ends as any exited process does.
  • internal/antigravity: the Antigravity CLI (agy) stream-json client (see docs/antigravity-protocol.md). Each session has its own plugin directory and a 127.0.0.1 listener that serves byre's MCP tools and hooks to the CLI, and restarts the CLI on the same conversation after an interrupt or a model switch.
  • internal/tailscale: tailscale serve for task previews (see "Previews").
  • internal/workflow: validating workflow definitions and reading and writing their YAML files (see "Workflows").
  • hands/ is byre-hands, the Swift MCP server that lets task agents see and drive the Mac byre runs on (see "Screen and app control") and, bundled into the macOS app, the human's Macs (see "Client Macs").
  • internal/clients: the WebSocket connections of the macOS apps that task agents use (see "Client Macs").
  • mac/ is the macOS app, byre's client; docs/macos.md describes how it is built, and "macOS app" below covers building, installing and its storage. It connects to byre's tailscale serve https origin, typed into its connection screen. The server serves only the API; it has no web pages.

Data

State lives in the directory given by byre serve --data, by default the byre directory under Go's os.UserConfigDir() (on macOS, ~/Library/Application Support/byre): byre.db, worktrees/<project-id>/<task-id>, worktrees/<project-id>/<task-id>-media (the task's media folder, see "Screenshots from implementers"), leads/<login> (each lead agent's working directory, the login path-escaped), hands/<session-id>/byre-hands (each running implementer session's copy of byre-hands, see "Screen and app control"), 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 files in internal/store/migrations/, embedded in the binary, each named NNN-name.sql: a three-digit number, then a short kebab-case name saying what it does. On open, byre applies, in file name order, every file whose name (without the number) isn't in the migrations table, each in its own transaction that also records the name. The number only orders: a migration that needs another's tables or columns takes a higher number, and migrations with the same number must not depend on each other. 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. A migration's SQL is fixed once applied anywhere: write it out in full rather than sharing Go constants that later change. byre is deployed from task branches that each add 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.

Worktree setup and ports

Every task worktree is made by createTask (internal/agents/tasks.go): new tasks, the lead's create_task, start_todo, subtasks (create_subtask) and forks alike.

  • Ports. Each active task holds a block of store.PortBlock ports starting at tasks.port_base (port_base on the Task). Store.CreateTask gives a new task the lowest block from byre serve --ports (default store.DefaultPortFirst) up that no active task holds, so a merged or discarded task's block is reused. The agent's claude process gets PORT (the first port, which most dev servers read on their own), and the session guidance names the range, says to run dev servers on it listening on 127.0.0.1, and points at expose_port (see "Previews"). sqlite3 byre.db "SELECT id, port_base FROM tasks WHERE status = 'active'" lists the blocks in use.
  • Setup. byre runs nothing in a new worktree. The session guidance says the worktree starts with only what git tracks and tells the agent to do any setup the project's AGENTS.md describes before other work, so installing dependencies or copying untracked files such as .env is the project's own instruction, carried out by the agent with its own tools. A project's checkout is the parent of git rev-parse --path-format=absolute --git-common-dir in the worktree.

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, summary 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 confirmation, Discard's just below its button like the task picker's switcher and Merge's centred in the right pane, and are disabled while the task's session is running. 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. Merge's confirmation (confirmMerge in mac/Sources/Byre/MergeDialog.swift) is kept brief: the commit title as text with a ✎ button that swaps in an input, a collapsed "N co-authors" line holding the trailers, and one warning line for each thing worth stopping for. Those are the latest snapshot's review still running, failed, missing or with findings that weren't ignored (counted by severity); the task's open inbox questions, which merging dismisses; the worktree differing from the latest snapshot (changes?since= its commit), or no snapshot at all; and "Review status unavailable" when those fetches fail. Earlier snapshots' reviews aren't mentioned. Warnings never block Merge. When the latest snapshot's review is running as the dialog opens, its buttons are Cancel, Merge now and After review, the default (Enter picks it); otherwise Cancel and Merge. The buttons are chosen once at opening, so a review that finishes while the dialog is open leaves After review there and choosing it is refused. The review and question warnings link to that snapshot's review or the oldest open question in the task's transcript; following one closes the dialog and keeps the title, so the next Merge opens with it. While open, the dialog refetches on the task's snapshot.created and review.* events and redraws on inbox changes.

A task's summary is what its agent writes, before the task closes, about the whole task: what it set out to do, what was built or tried, the key decisions and why, and what was left out. The agent sets it with the set_task_summary MCP tool (internal/agents/summary.go), any time while the task is active; each call replaces tasks.summary and records a task.summary event carrying it, which the transcript shows as a note. The Merge turn's prompt and the discard turn's prompt (summaryRequest) both ask for it. Turn summaries don't stand in for it: each covers only its own turn, and a merged task's last one is always the Merge turn's. Tasks closed before the column existed have none. sqlite3 byre.db "SELECT id, status, summary FROM tasks WHERE status != 'active' ORDER BY closed_at DESC LIMIT 5" shows recent ones.

An implementer says its task is ready to merge with the ready_for_merge MCP tool (internal/agents/ready.go), whose overview covers the whole task for a human switching between tasks: a summary, tradeoffs and compromises, what was not done, and future TODOs. Its description tells the agent to call it only once the review of its most recent changes has come back, never in the same turn as a change or while that review is pending. Each call records a task.ready event carrying it, which the lead gets as a signal and the transcript draws as a bubble (ReadyBubble in mac/Sources/Byre/Chat.swift) headed "ready for merge". The latest one, while the task is active and neither a question nor a snapshot other than an answer snapshot has come since it (a question, or newer work the overview doesn't cover), is drawn like an open question (the warn tint and border) and carries small Merge and Discard buttons that act like the header's, confirmation dialogs included; earlier ones are drawn like an answered question (the faint warn tint), without buttons. Recording it changes nothing else. byre asks for it (readyPrompt, sent as a byre message) when a task's state goes to idle or its workflow finishes (Manager.Observe calls promptReady) and the latest non-answer snapshot's review is done or skipped. It checks and sends under the session's delivery lock (the one human messages and answers take), and only to an agent that isn't running, so the prompt never queues behind a turn that could add unreviewed work. tasks.ready_prompt_seq holds the task's last event seq when the prompt's turn started; byre asks again only once a non-answer snapshot or a message from the human (a chat message or a question's answer, not one from byre, the agent or the lead) has come after it, so the agent's own turns that change no files don't ask again, and a human unblocking an agent that said it was waiting does. It doesn't ask while the task has an open question. It doesn't ask for a workflow stage task until its run has a task.workflow_finished event (by then the run's other stage tasks are being discarded, leaving the final one for the human), a task with a merge after review set, or a review that failed, and it skips the reviewing → idle change of a review with findings, since those go to the agent; the turn that answers them asks once it ends without new work. The prompt tells the agent not to call the tool, and to say what's left, when the task isn't ready.

An implementer that thinks its task shouldn't land at all (the approach failed, the task turned out unnecessary or already done) says so with the suggest_discard MCP tool (also internal/agents/ready.go), whose reason explains why; readyPrompt offers it as the alternative to ready_for_merge. Each call records a task.discard_suggested event, which the lead gets as a signal and the transcript draws with the same ReadyBubble, headed "suggests discarding". It takes part in "latest" with the ready-for-merge overviews, so whichever of the two came last is the current one, drawn and cleared under the same rules, and it carries only the Discard button. It only suggests: the task stays open until the human discards it.

A Task's ready (tasks.ready) is merge or discard after the latest of these two calls, set in the same transaction as its event, and is cleared (absent in the API) by the same things that clear the latest bubble: a snapshot other than an answer snapshot, or a new inbox item. The switcher's and the Project tab's task rows show it as a Ready to merge or Ready to discard state, in the warn colour with a warn dot like Needs your answer, when no question, merge or running work outranks it (taskState in mac/Sources/Byre/Store.swift). The app reloads a project's tasks on task.ready, task.discard_suggested, snapshot.created and inbox.created.

Agents find closed tasks with the search_closed_tasks MCP tool, which task agents get for their own project and the lead for one project (project_id) or all: a case-insensitive substring match of query on title, merge title, TODO item, summary and merge commit (Store.SearchClosedTasks), newest closed first, at most limit (default 20, at most 100), each with its id, outcome, merge commit, close time, creator, titles, TODO item, fork source and summary. The lead reads a closed task's transcript with read_transcript. There is no closed-task list in the UI; the human asks the lead.

Knowledge base. How byre features work that agents need only now and then lives in articles they fetch on demand instead of in their guidance: the Markdown files in internal/agents/kb/, embedded in the binary (kb.go). A file's name without .md is its topic, its first line a one-line summary and the rest the article. The read_knowledge MCP tool, which task agents and the lead get, lists every topic with its summary in its description and returns the article for topic. Adding an article is adding a file; the index follows. The articles are signing (turning on signed merge commits), usage-commands (the usage command's JSON format, see "Usage" under "Status"), workflow-files (a workflow's YAML, see "Workflows"; read_workflow and save_workflow point at it) and todo-file (the TODO.md format; the task guidance points at it).

POST /api/tasks/{id}/discard (the Discard button, the lead's discard_task, and byre itself for a finished workflow's other stages) doesn't remove anything at once: it gives the task's agent one dedicated turn to write its summary, and closes the task when that turn ends. The discard is a persisted state of the task, tasks.discard_state, and every step that changes it is one transaction in the store (internal/store/discard.go), which is what keeps it safe against turns starting, ending and byre restarting around it:

discard_state meaning left by
empty no discard RequestDiscard
held requested; the summary turn hasn't started StartDiscardTurn
running the summary turn runs in session discard_session EndTurn of that session, or CloseDiscard
closing the turn is over; byre is removing the task DiscardTask (status discarded)
  • Request. RequestDiscard records the requester in discarding_by, sets held, clears any held merge and emits task.discard_started (a transcript note). The request returns the still-active task with 202. It is refused (409) while the task is merging or its discard is held or running; on a closing task it retries the cleanup. Merge and holding a merge are refused while discard_state is set.
  • Admission. While discard_state is set, no ordinary turn starts in the task: the store's idle-to-running transition (transitionSession) refuses it, so messages, inbox answers, Sync, Compact, queued messages and workflow stage starts all get a conflict and stay queued or held. Queued messages and answers aren't handed over mid-turn either (deliverMidTurn). The one way in is StartDiscardTurn, which needs held and an idle implementer session, and in one transaction sets the session running, the state running and discard_session. byre calls it (startDiscard in internal/agents/summary.go) right after the request, and again whenever a turn of the task ends with the state held; whichever call comes first wins, and a busy session simply leaves the state held until its turn ends. It uses the task's live implementer session, or a new one created by the task's creator when there is none, and sends discardPrompt (actor byre): the task is being discarded, change no files, call set_task_summary saying also why it is being dropped, then end the turn. Open inbox questions don't hold it back. If starting the turn fails, byre gives up on the summary: CloseDiscard sets closing and the task is removed.
  • Turn end. Every implementer turn end, in turnEnded, on an agent process that exits mid-turn (pump), and on a session that can't be resumed or sent its restart notice at startup (failResume, after which startup carries on with the other sessions; a session whose task can't be loaded is logged as resume session: load task and left running), marks the session idle through EndTurn, which in the same transaction reports what the discard needs: closing when the session is discard_session and the state was running (it sets closing), held when a discard is waiting. A turn of the task that ends while a discard is pending takes no snapshot. On held, the ending turn's handoff continuation and inbox delivery are skipped and the summary turn starts instead, in the same session, so a turn that was writing a handoff hands the summary turn its full context. On closing, finishDiscard stops the session, cancels reviews, deletes the snapshot refs, removes the worktree and branch (git.RemoveWorktree, which skips whatever is already gone so the cleanup can be repeated), and only then closes the task with discarding_by as the actor. The summary turn closes the task however it ends (finished, interrupted, an error), so interrupting it discards without a summary; a summary turn whose process dies discards without a summary too, while an ordinary turn whose process dies while a discard is held gets the summary turn started on a new process. A failed cleanup is recorded as an error in the task's transcript (see "Errors") and leaves the state closing.
  • Workflows. FinishStage refuses to mark a stage finished while discard_state is set, so a stage whose turn ends after the discard was requested drops its output and doesn't advance. A stage that finished before the request advances as usual (advanceStage runs before the summary turn starts, since it reads the worktree's HEAD), and a closing task's stage never advances (nextStage clears it). An existing stage task being discarded isn't restarted by advanceTo.
  • Restarts. On startup, a running summary turn's session is resumed with discardRestartPrompt (the discard prompt again, after a line saying byre restarted) instead of the usual restart notice; recoverDiscards then starts the summary turn for each held task whose session isn't running, closes each running one whose session isn't running, and finishes each closing one.
  • Handoffs. A handoff continuation (handoff in internal/agents/compact.go) doesn't start while a discard is pending; it starts the summary turn in the old session instead. It retires the old session only through an idle-to-completed transition, so it can't retire a session the summary turn has just taken, and if a discard arrives between retiring and starting the continuation, the summary turn runs in the new session, without the handoff.

sqlite3 byre.db "SELECT id, discard_state, discarding_by, discard_session FROM tasks WHERE discard_state != '' AND status = 'active'" lists discards in progress. Confirming Discard in the UI points the right pane away from the task at once, while its summary turn runs.

A Task's merging is true while merging_by or finalizing is set, and discarding while the task is active with discard_state set. Clearing merging_by without moving it to finalizing, or clearing finalizing without closing the task, emits task.merge_ended (not drawn); that, task.merge_started, task.discard_started and task.discarded reload the project's tasks in the UI, so the task switcher and the Project tab show the task as Merging or Discarding (see "Task state" under the task switcher).

Task state in the store

A Task's state (the tasks.state column) is what the task is doing, owned by the store: merged or discarded once closed, else discarding, merging, the state of the agent's latest non-completed implementer session when it isn't idle (running), running while that session's subagents count is above zero (see "Subagent count"), reviewing while a review is running, review_pending while tasks.review_requests is above zero, idle, or no_agent when the task has no live session (taskStateExpr in internal/store/store.go, earlier cases winning). Every write transaction (Store.write), after its own changes, recomputes the state of each task its events name and, when it changed, updates the column and emits task.state (from, to; actor that of the task's last event in the transaction) in the same transaction, so the state and the events that changed it commit and publish together, in seq order. The store publishes each transaction's events in one call (onEvent takes the list), which the lead's signals rely on.

Subagent count: Claude Code's subagents can run in the background past the turn.ended of the turn that started them, while the session is idle. A session's subagents (the agent_sessions.subagents column, on AgentSession) counts its running ones: trackSubagents in internal/agents/manager.go adds a task_started system event's task_id when its task_type is local_agent (background shell commands are local_bash and don't count), and drops it on the task_notification naming it. Each change is written with Store.SetSubagents, which emits session.subagents (subagents, the new count). It drops to 0 when the agent's process exits, and on startup for every session left above 0, since a restart ends every process. sqlite3 byre.db "SELECT id, task_id, owner, subagents FROM agent_sessions WHERE subagents > 0" shows the sessions with subagents running.

review_requests covers the gap between a turn's end and its review starting. turnEnded emits turn.ended inside EndTurn's transaction, which also sets the session idle and, when the turn's snapshot will be reviewed (no discard or merge pending, and no subagents running), adds one to review_requests, so the task goes from running to review_pending in the transaction that publishes the turn's summary. The snapshot is requested after that transaction. Each review request ends exactly once with EndReviewRequest, which subtracts one: after afterSnapshot returns (by then the review row is running, so the task is reviewing, or it was skipped or never started, so it is idle), or when the request is dropped because a newer one replaced it or the task's snapshots were closed. On startup recoverReviews zeroes every task's review_requests (ClearReviewRequests), since the requests in flight died with the process.

The UI's labels (see "Task state" under the task switcher) read state for Working, Reviewing and Discarding; task.state reloads the project's tasks. sqlite3 byre.db "SELECT id, title, state, review_requests FROM tasks WHERE status = 'active'" shows them.

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. If resolving the conflicts took substantive changes rather than combining both sides as written, the prompt tells the agent to stop there and end its turn describing them; the turn's snapshot then gets its review like any other (see "Reviews"). byre doesn't inspect the merge itself: whether a change is substantive, and whether to stop, is the agent's call. On a Merge turn the prompt adds that byre squashes and pushes when the turn ends, and tells the agent to call the stop_merge MCP tool first if it stops (see below). 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.

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 until a merge is confirmed: one line of at most 72 characters (maxMergeTitle, checked by cleanMergeTitle), 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. Task titles have no length limit (a TODO item becomes the title whole), so when an implementer session's agent process starts with a merge title over 72 characters, longTitleGuidance (internal/agents/guidance.go) adds a line to its guidance quoting the title and its length and telling the agent to call set_merge_title early in its first turn; a process started once the title fits gets no such line. Malformed titles are rejected with a corrective message, and so is a call once the task is closed or while merging_by, finalizing or merge_queued_by is set. The Merge confirm dialog shows the title in a text field the human can edit (Enter confirms), lists the co-authors, and sends the field's value as merge_title in the body of POST /api/tasks/{id}/merge. Merge validates it like the tool does (400 when malformed) and, when it differs from the stored title, stores it with the human as the event's actor before starting or holding the merge.

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. A fork's participants also include the user who forked it, the actor of its task.created event (whose payload carries forked_from), whose "forked this task" note opens its transcript. They are ordered by their first such event. 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, with the prompt's closing invitation to revise the title left out since the human just confirmed it and a request to call set_task_summary in its place, and records in tasks.merging_by that the task is to be merged by the requesting user (emitting task.merge_started with that user as actor, not drawn), 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), the transaction that ends it (Store.EndTurn) moves the login from merging_by to tasks.finalizing, so the task stays Merging while byre squashes, pushes and closes it. While finalizing is set, the task's implementer session can't start a turn (a message or Sync is a 409; the store refuses the running transition in transitionSession), Discard and merging into a parent are refused as merging, set_merge_title is refused, and the task can't get new subtasks (ErrParentNotOpen). Closing the task clears it; a failed squash or push clears it (emitting task.merge_ended) before filing the error, so the queued inbox responses can be delivered. Just before pushing, byre records the squash commit and its notes commit in tasks.finalizing_commit and tasks.finalizing_notes (Store.SetFinalizingCommit); when the merge changes nothing, it records that in tasks.finalizing_unchanged (Store.SetFinalizingUnchanged) before removing the worktree. A push the remote rejected as non-fast-forward clears the marker as a failed push. Any other push error is ambiguous, since the remote may have taken the push before the connection dropped, so byre runs git fetch origin and checks whether the commit is an ancestor of origin/<default branch>: when it is, it finishes as if the push had succeeded; when it isn't, the push failed; when the check itself fails, the marker stays. A failure after the push was accepted (updating the checkout's notes, fast-forwarding it, removing the worktree, closing the task), or while closing an unchanged merge, keeps the marker and files an error saying byre finishes the merge when it next starts. At startup, recoverFinalizing (internal/agents/mergein.go) takes each task with finalizing set: an unchanged merge is finished by removing the worktree and closing the task with no commit; with a recorded commit, it runs git fetch origin and, when the commit is an ancestor of origin/<default branch>, finishes the merge (closePushed: advance the checkout's refs/notes/byre to the notes commit unless it already contains it, git merge --ff-only, remove the worktree, close the task), each step a no-op when already done. When the commit is not on the remote, or none was recorded, it clears the marker and files an error on the task saying to merge again; when the fetch, the ancestry check or a finishing step fails, it keeps the marker for the next start and files the error. sqlite3 byre.db "SELECT id, finalizing, finalizing_commit FROM tasks WHERE finalizing != ''" lists merges being finished. Then 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 (signed on the merging user's Mac when the checkout's commit.gpgsign is true; see "Signing merges"), 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 together with its note (below), in one atomic push (git push --atomic --porcelain origin <commit>:refs/heads/<default branch> <notes commit>:refs/notes/byre, run in the checkout), so either both land or neither does. Only once the push is accepted does it point the checkout's refs/notes/byre at the notes commit, 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 the agent gets the prompt, Merge runs git fetch origin (every configured refspec, so the notes too) and git merge --ff-only origin/<default branch> in the checkout, so the agent merges in what the remote has. The request doesn't wait for it: it sets merging_by, moves the session to running and returns 202, and a goroutine (launchMerge in internal/agents/mergein.go) starts the agent process if needed, emits the prompt's user.message so the transcript shows it at once, then fetches and only then sends the prompt to the agent. A failure there (the process, or the fetch: network, or a checkout with commits the remote lacks) puts the session back to idle with the error, which the transcript shows as a session idle: … note, and abandons the merge, clearing merging_by and releasing the project's merge. Interrupt during the fetch (live.fetching set) records the interrupt without calling the vendor, since no vendor turn exists yet; the fetch runs to its end, and then the prompt isn't sent and the merge is abandoned the same way, with the error interrupted before the merge started. If the push is still refused, for example because someone pushed during the turn, or fails for any other reason, byre records the error in the transcript 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. The agent calls stop_merge (internal/agents/mergein.go) with a reason when its conflict resolution took substantive changes: it clears merging_by and releases the project's merge at once, and records a task.merge_stopped event carrying the reason, which the transcript shows as a note. The turn then ends without a squash, the task stays active, its snapshot is reviewed, and the human clicks Merge again when satisfied. Called with no merge pending (a Sync turn, say), it changes nothing and tells the agent so. 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; a turn that ends interrupted or with an empty final message also records an error saying the merge didn't happen. A Claude result with num_turns 0, no result text and no error, outside an interrupt, isn't a turn end: the CLI emits one on its own, for example after a resume when it reports a background shell left over from the previous process, possibly after byre has already sent the next message. byre logs ignored result with no model turns, records it as an agent.vendor event, and keeps the turn running, so it can't take merging_by from the Merge turn that follows. Merge (either target, see "Subtasks") and Discard are refused (409, naming them) while the task has active subtasks, whoever asks: the button, the lead or an agent.

Each squash commit gets a git note under refs/notes/byre, the durable record of the task (mergeNote in internal/agents/notes.go): a Markdown heading with the merge title; the task's id, title, creator, merging user, TODO item and fork source; a pointer to the full transcripts as the task's events in byre (GET /api/events?task_id=<id>), which the note doesn't copy; the task summary, when the agent wrote one; every handoff its sessions wrote on compaction (the result of each turn.ended with handoff: true); each review's state, error, change and summary, by snapshot; and each answered question with its context and the responses to it. Plans and eval results aren't recorded yet, since byre has neither. git.NoteCommit builds the notes commit on top of the checkout's current refs/notes/byre in a temporary index, authored as the merging user, without moving the ref, so a refused push leaves the checkout's notes as they were. Notes don't fetch by default, so registering a project (POST /api/projects), and every byre startup for each existing project, adds the refspec refs/notes/byre*:refs/notes/byre* to the checkout's remote.origin.fetch if it isn't there (git.ConfigureNotesFetch); it's a pattern because git refuses a fetch whose exact refspec names a ref the remote doesn't have yet, as it doesn't before the first note is pushed. Another clone adds the same refspec with git config --add remote.origin.fetch 'refs/notes/byre*:refs/notes/byre*'. git log --notes=byre shows the notes, and git notes --ref=byre show <commit> one commit's.

git's network commands (git fetch and git push, through runNetwork in internal/git/git.go) are bounded by networkTimeout and by the caller's context, and run non-interactively: GIT_TERMINAL_PROMPT=0, and GIT_SSH_COMMAND set to ssh with BatchMode=yes and a connect timeout (networkEnv), which replaces any core.sshCommand the checkout configures. A remote needing a password or an unknown host key fails at once instead of waiting for input. Every git command runs in its own process group, and cancelling one kills the whole group, so an ssh or remote helper it started can't outlive the deadline and finish a fetch or push after byre has moved on. A timed-out fetch fails the Merge like any other fetch error; a timed-out push is ambiguous and goes through the landed check above.

One merge runs per project at a time. The Manager keeps, per project, a lock and the task holding that project's merge in memory (projectMerge in internal/agents/mergein.go, in Manager.merges), from when merging_by is set until the merge closes the task, fails, or is abandoned as above, or the task is discarded; on startup it is rebuilt from the active tasks with merging_by set. The fetch runs after the lock is released but while the task holds the merge, so a stalled remote delays merges in that project alone, and Merge requests in the same project meanwhile see its holder. Merge on another task of that project while one is held doesn't fetch or start a turn: it records the request in tasks.merge_queued_by and merge_queued_at (merge_queued_at on the Task), emits task.merge_queued (a transcript note), and returns the task with 202. It then gives the task a head start (headStartMerge): when the task's agent is idle (or it has none), it sends the same merge-in of the default branch as the merge-in action, from the project checkout's local default branch without a fetch, so the later Merge turn has less left to resolve; a running agent skips it. It moves the session to running under the project's merge lock, as a direct Merge does, so a held merge can't start in between and find the session taken. The head-start prompt tells the agent to call stop_merge if resolving the conflicts took substantive changes; on a task with no merging_by but a held merge, stop_merge clears the held request (TakeQueuedMerge) instead, so nothing is pushed before the review. A held merge whose head-start turn is still running waits for that turn to end, like any running task. A second Merge on a held or merging task is a 409. Whenever a project's merge is released, any task turn in it ends, and on startup, byre starts the oldest held merge in the project whose task session is not running: it clears the request (emitting task.merge_unqueued, not drawn) and goes through the same fetch and turn as a direct Merge by the user who asked, so it merges in what the earlier merge pushed. If that start fails before the turn begins (the user has no git identity, or the session isn't idle), byre records the error in the task's transcript and tries the next held merge; a fetch failure after that ends the turn like a direct Merge's, as a transcript note. The Merge button reads Merge held, disabled, while the task has merge_queued_at; discarding the task drops the request. sqlite3 byre.db "SELECT id, merge_queued_by, merge_queued_at FROM tasks WHERE merge_queued_by != ''" lists held merges.

A task agent lands its work so far without closing the task through the merge_to_main MCP tool (internal/agents/mergemain.go), with a title checked like set_merge_title's. It runs inside the tool call, holding the task's lock (lockTask) and the project's merge (projectMerge.holder, in memory only), and is refused (409) while another task holds the project's merge, while this task is merging, held, merging into its parent or being discarded, and from a Merge or Sync turn that owns the worktree's merge. It fetches and fast-forwards the checkout as Merge does, commits the worktree (git.CommitAll, the title as message, which also concludes a git merge --no-commit the agent left, even one whose resolution changes no files), squashes it onto the default branch titled title with the same co-author trailers, note and atomic push as Merge, as the session's creator, then advances the checkout's notes and fast-forwards it. Finally it merges the squash commit back into the task branch (git.Merge, git merge --no-ff), which can't conflict since the squash already combined both sides, so the branch's merge base moves to the squash commit and later changes, snapshots and the task's final Merge carry only what came after. It records a task.merged_to_main event with the commit, pushed and the title, drawn as a note; the task's own merge_title is unchanged. A conflict with the default branch is returned to the agent with the files and the git merge --no-ff --no-commit to run before calling again; so is a push the remote refused as moved. Other push errors are returned as is, without Merge's landed check. When the squash changes nothing, nothing is pushed, and byre merges the default branch into the task branch instead, which can't conflict either since the default branch already has all the branch's work; so a call retried after a push that landed but reported failure, or after byre restarted between the push and the merge-back, still moves the branch's merge base past the landed work.

POST /api/tasks/{id}/merge with after_review: true (the dialog's After review) starts nothing (MergeAfterReview in internal/agents/afterreview.go). It checks what the chosen target's Merge would (git identity and title for main, a parent for parent, no subtasks, not already merging, held or merging into the parent), stores the title like Merge does, and records the requesting user, the running review of the latest snapshot and the target in tasks.merge_after_review_by, merge_after_review_id and merge_after_review_target (merge_after_review on the Task; a task.merge_after_review event, drawn as a note). It is a 409 when that review isn't running. Asking again replaces the request. Whenever a review finishes (submitted, failed, interrupted, or failed by a restart or the task closing), reviewFinished takes the request if it names that review (emitting task.merge_after_review_ended, not drawn) and makes the merge as that user, through the same Merge or MergeIntoParent as a direct request, when the review is done with no findings, its snapshot is still the latest, the task is active and not being discarded, and no turn is running. Otherwise, or when that merge is refused, it drops the request with a task.merge_after_review_dropped event carrying the reason, drawn as a note. On startup, recoverMergeAfterReviews runs the same check for every request whose review is no longer running, so one left by a restart between the review finishing and its request being taken still merges or is dropped. A direct Merge (either target) clears the request; so do Discard and closing the task. DELETE /api/tasks/{id}/merge-after-review withdraws it (404 when none is waiting), emitting task.merge_after_review_cancelled, drawn as a note naming who cancelled. The transcript's task.merge_after_review note carries a Cancel button that calls it; only the last such note in the transcript shows the button, and only while the task has merge_after_review (NoteView and lastMergeAfterReview in mac/Sources/Byre/Chat.swift and TranscriptBuilder.swift). The Merge button reads Merge after review, still enabled so the dialog can merge now, and the task state label is Merge after review. sqlite3 byre.db "SELECT id, merge_after_review_by, merge_after_review_id FROM tasks WHERE merge_after_review_by != ''" lists them.

Subtasks

A task's agent starts a subtask with the create_subtask MCP tool (internal/agents/subtasks.go): title, prompt and optional model (default: the caller's session model; gpt-* runs on Codex, gemini-* on Antigravity). It is a real task, with its own branch, worktree, port block, snapshots and reviews, made by createTask from the parent worktree's HEAD (the parent's last end-of-turn commit), with tasks.parent_task_id (parent_task_id on the Task) naming the parent. Its session is created by the caller's session creator, so commits use that user's git identity, and gets prompt as its first message (actor agent). A fork of a subtask is a subtask of the same parent. Store.CreateTask refuses a subtask (409, and the new worktree is removed) unless, in the same transaction, the parent is active and not being discarded, and Store.RequestDiscard refuses a task that has active subtasks or is merging into its parent in the transaction that starts the discard, so a parent's discard and its subtasks never overlap. The lead's create_task, POST /api/projects/{id}/tasks and start_todo make top-level tasks. sqlite3 byre.db "SELECT id, title, parent_task_id, parent_merge_by FROM tasks WHERE parent_task_id != '' AND status = 'active'" lists active subtasks.

  • Control. The parent's agent has list_subtasks, read_subtask (the same transcript text as the lead's read_transcript), message_subtask (through Manager.Message, so it is queued and delivered mid-turn while the subtask works, and starts the subtask's agent when it has none), interrupt_subtask, merge_subtask (target parent or main) and discard_subtask. Each refuses a task that isn't the caller's own subtask. Merges and discards are done as the parent session's creator.
  • Reporting. When a subtask's turn ends with a summary (turn.ended with summary: true, see "Agent sessions"), byre sends the turn's final message to the parent's agent as a byre message headed "Your subtask … finished a turn" (reportToParent), queued like any message if the parent is mid-turn, or starting the parent's agent if it has none. Nothing is sent once the parent is closed, or while the subtask is merging into it.
  • Merging into the parent. POST /api/tasks/{id}/merge with target: parent (the Merge dialog's "Into " choice, the lead's merge_task with target: parent, or merge_subtask) is refused when the task isn't a subtask (400), while its session is running, while it is merging into the default branch, when its parent is closed, when it has subtasks of its own, or (checked in Store.SetParentMerge's transaction) while it is being discarded or already merging into its parent (409). Otherwise it records the requesting user in tasks.parent_merge_by (merging_into_parent on the Task; a task.parent_merge_started event, drawn as a note) and sends the parent's agent mergeInPrompt for the subtask's branch as a byre message, so the parent runs git merge --no-ff --no-commit byre/<subtask> and byre's end-of-turn commit concludes it. If sending fails, the flag is cleared (task.parent_merge_stopped). After every turn end of the parent's session, closeMergedSubtasks checks each of its subtasks with the flag set: once git merge-base --is-ancestor byre/<subtask> HEAD holds in the parent's worktree, byre removes the subtask like any closed task and marks it merged as that user, with the parent's HEAD as merge_commit; its task.merged event carries parent_task_id instead of pushed. A subtask that gets more commits after the request stays open until the parent merges those too. The Merge button reads Merging into parent, disabled, while the flag is set.
  • Merging into the default branch. target: main (the default) is the ordinary Merge, squashing the subtask onto the default branch and pushing it.
  • UI. The Project tab's task list draws each subtask under its parent, indented by depth with a ↳ mark whose tooltip names the parent; the ⌘K switcher prefixes a subtask's title with " ↳". Both show Merging while the flag is set, with a tooltip saying it waits for the parent's agent. A subtask's transcript opens with the note "subtask of ", linking the parent while it is active. The Merge dialog for a subtask whose parent is active offers "Into " (selected first; the commit title and co-authors are hidden, since the parent's own commit holds the merge) and "Into ".

Teams

A team task has several agents working at once in its one worktree, branch and port block, each on its own model, talking through a shared board (internal/agents/team.go). It is opt-in: the lead's create_task with agents, or a workflow stage with agents (see "Workflows"). Everything else (snapshots, reviews, merge, discard, subtasks, the inbox) stays per task.

  • Members. Each agent is an implementer session with agent_sessions.member (member on the AgentSession) set to its name; a task with one agent has an empty member. agents is a list of at least two TeamMembers (name, model, prompt), checked by workflow.TeamProblems: unique one-line names, a model and a prompt each. startTeam creates every session first, so no post can miss a member, then starts each with the shared brief (the create_task prompt, or the stage's message) followed by ## Your part: <name> and its own prompt. A task has at most one session that isn't completed per member; a Compact handoff's new session keeps the member. sqlite3 byre.db "SELECT task_id, member, model, state FROM agent_sessions WHERE member != ''" lists team sessions.
  • First agent. The member of the task's earliest session (teamLead) is the team's first agent: whatever byre sends "the task's agent" goes to it (liveSession): Merge, Sync, the discard summary turn, review findings, a subtask's reports and merge-ins, and for a stage task finish_stage, which only the first agent gets. Only the first agent's turn end can claim a merge's finalize in EndTurn, and only its turn ends, failed resumes and process exits clear a pending merge (leadsTask); stop_merge is refused to the other members. startTeam starts every member even when one fails, and reports the ones that didn't start; the retry for a team that came up incomplete is to discard the task and create it again.
  • Task mutex. Members run in parallel; byre's own operations on the worktree are what can't, so every task has one in-memory mutex (lockTask in internal/agents/critical.go) around them: each turn-end commit and the stage record that goes with it (turnEnded), each snapshot (runSnapshots, taken before the snapshot state's lock), removing the task's worktree when it is discarded (discardNow) or merged into its parent (closeMergedSubtask, which holds the subtask's mutex across checking that its branch is in the parent's history and removing it), and every merge turn. A turn that is handed a merge prompt (isMergeInPrompt: Merge, Sync, or a subtask's merge-in, at the start of a turn in prepareMessage or mid-turn in recordQueued) takes the mutex for its session (takeMergeTurn) and holds it until that turn ends, so no other member's commit can conclude its unresolved merge; the squash, push and worktree removal of a finishing merge run before it is released (finishMerge). A merge turn that ends unfinished, or whose process exits mid-turn, runs git merge --abort first, so the merge is either concluded or undone. A member whose turn ends meanwhile waits on the mutex, then commits, or skips the commit when the task was closed while it waited. The mutex is in memory; on startup, before resuming sessions, recoverMergeTurns gives it back to each running first agent whose worktree has a merge in progress (git.MergeInProgress), since merge prompts only go to the first agent, and a resume that fails aborts that merge.
  • Board. Members get post_to_board (text) and read_board (after_seq). A post is a board.posted event on the task (actor agent with member and model in the payload and the poster's session; or the human's login; or the lead's actor) and is sent to every other member's live session as "<name>" posted on the board: and the text, through Manager.Message with actor board: an idle member starts a turn with it, a working one gets it after its next tool call (headed "While you were working, …"). Posts are delivered in order, one member after another, before the post call returns (deliverPost). The roster is every member that ever had a session (teamSessions, each member's live session or else its latest), so a post for a member whose first turn hasn't started yet (SessionStarted), whose session was just completed by a Compact handoff, or whose delivery fails (a conflict such as the task finishing a merge or being discarded, or its process not starting), is queued in task_inputs for that member instead, and every turn end of a team member delivers to the other idle members (deliverTeam). POST /api/tasks/{id}/board posts as the caller (204; 400 for a task without a team), and the lead's send_message to a team task posts as the lead. Each member's guidance names the team and its models, tells it to post only what adds something, that its final message goes to the human, to re-read files before editing them since the others change the same ones, and who finishes a stage. sqlite3 byre.db "SELECT seq, actor, payload FROM events WHERE kind = 'board.posted' AND task_id = '<task>'" lists a board.
  • State and signals. The task's state takes the running member's session first (taskStateExpr). A member's turn.ended carries member, and the lead's signal reads "'s agent "" finished a turn"; list_tasks lists each member's state and model, and read_transcript prefixes a member's lines with [<name>] and shows posts as board post by … lines, leaving out the board deliveries.
  • UI. The Transcript tab of a team task shows one member at a time: its header has a button per member (● while it runs), the pick kept in memory per task (Store.pickedMembers, the first member by default), and the transcript draws only that member's events and events with no session (ChatModel.shows), the board deliveries that woke it included. Compact and Interrupt act on the picked member. The composer posts to the board (postToBoard; text only, no model picker), and Merge, Sync and Discard are disabled while any member runs. The Board tab, shown only on a team task, lists the task's board.posted events from its event feed, oldest first, each with its author, model and time.
  • Forks. A fork of a team task continues the conversation of the member whose snapshot it was cut from, as a task with one agent.

Running as a service

The listen address is byre serve --listen, whose default is defaultListen in cmd/byre/serve.go (go run ./cmd/byre serve --help prints it); keep it on loopback, since identity relies on requests arriving from tailscale serve.

byre check (cmd/byre/check.go) prints, as one line of JSON, what the server needs from the Mac it runs on, found through the PATH it was run with: each check's name, need (required: git, counted only when /usr/bin/git's Command Line Tools are installed (xcode-select -p), and Tailscale, which must be installed at /Applications/Tailscale.app, signed in (BackendState Running in tailscale status --json) and have HTTPS certificates (non-empty CertDomains); agent: claude, codex and agy, at least one of which is required; optional: npx and Google Chrome in /Applications), what it is for, whether it was found, the shell command that installs it (command, empty for Tailscale) and any further step (fix, such as signing in, or what is wrong with Tailscale), and whether its absence blocks the server, with ready true when nothing blocks. byre install (cmd/byre/install.go) runs the same checks first and, when one blocks, prints each blocking one with how to install it and exits 1 without changing anything. Otherwise it sets the service up for the binary it is run as, with byre-hands beside it: it writes ~/Library/LaunchAgents/run.fc.byre.plist running <that binary> serve, with a PATH of that binary's directory (so byre-hands beside it is found) followed by the PATH of the shell it was run from (which must find claude, codex, agy, curl, git and npx, curl for Antigravity's hooks and npx for the Playwright MCP server, which also needs Google Chrome) and logs to ~/Library/Logs/byre.log; runs tailscale serve --bg --https=443 http://<default listen address> through the Tailscale app's CLI (tailscale.ServeRoot, see "Previews"), printing byre is at https://<machine>.<tailnet>.ts.net; and bootstraps the service. When the service is already loaded, it hands launchctl bootout and bootstrap to a detached sh in its own session and exits, so the new plist is read: bootout kills every process in the service's process groups, which includes byre install itself when an agent byre runs is the one running it. The service always runs from the app, /Applications/Byre.app/Contents/Helpers/byre install, which the app's Server tab and connection screen run for you (see "UI").

byre is served at the root of the machine's ts.net name on 443 (https://<machine>.<tailnet>.ts.net), not under a path: the API uses absolute paths, and the macOS app takes an origin with no path. tailscale serve status shows the URL and what else the machine serves. launchctl print gui/$(id -u)/run.fc.byre shows the service and the program it runs.

A new build reaches the service by replacing /Applications/Byre.app: mac/deploy.sh (see "Releases"), the app's updater, or byre-update. byre serve runs byre-update (found on its PATH, beside it in Contents/Helpers) when it starts and every 24 hours after, logging its output; it installs the newest release with Sparkle and no prompt unless the app is running from that bundle, which then updates itself (see "Updates" in docs/macos.md). byre serve stats its own executable (os.Executable) every 5 seconds (watchExecutable in cmd/byre/serve.go); once that path holds a different file, it logs executable replaced, exiting and shuts down as on SIGTERM, and launchd (KeepAlive) starts the new binary. A missing path counts as not yet replaced, so the moment in install.sh between removing the old bundle and moving the new one in doesn't trigger it. Logs go to ~/Library/Logs/byre.log.

Releases

A release is one notarized disk image, byre-<version>.dmg, holding Byre.app: the macOS app, with the server (Contents/Helpers/byre), byre-hands (Contents/Helpers/byre-hands) and byre-update (Contents/Helpers/byre-update) inside it. Every Mac installs the same app; the server's Mac also runs byre install from it (see "Running as a service"). Release images are served from https://dl.byre.run/ (see "Website"), and each release's notes are on the repo's releases page on g.fc.run, https://g.fc.run/ai/byre/releases.

mac/package.sh builds one for whatever the checkout holds, into mac/.build/release/<version>/, <version> being the bundle's CFBundleShortVersionString, and prints the image's path. It builds with mac/bundle.sh (signed with Developer ID, see docs/macos.md), zips the app and notarizes it with xcrun notarytool submit --keychain-profile byre-notary --wait, staples the ticket, builds the disk image with hdiutil (the app and an /Applications link), signs, notarizes and staples that too, and writes its SHA-256 beside it. It then signs the image for Sparkle with sign_update --account byre and writes appcast.xml, a feed with that one release.

A deploy is that image installed rather than published: mac/deploy.sh runs package.sh, installs the image's app with mac/install.sh <dmg> (mounts the image, copies Byre.app to /Applications/Byre.app.new, removes the old bundle and moves the new one into place, so nothing running from the old bundle has its files written over; it prints the installed ByreCommit); the service sees its executable replaced and restarts itself (see "Running as a service"). Client Macs get the same image through the client tools and install.sh (AGENTS.md, "Deploying"). A deployed build's CFBundleVersion is at least the last release's, so the updater never offers to go back to it.

mac/release.sh <notes> publishes one, run on byre's host from a checkout whose HEAD carries a v<version> tag already pushed to origin, with a clean tree; it fetches the tag from origin and stops unless it names HEAD, then runs package.sh. It uploads to the R2 bucket byre with wrangler r2 object put (run through npx): byre-<version>.dmg and its .sha256, cached for a year, then the same image as byre.dmg, the site's download link, and last appcast.xml, both cached for 5 minutes, so the appcast never names an image that isn't there yet, and then purges those two from Cloudflare's cache so the new release shows at once. Last, it creates the release on g.fc.run as a draft through the Gitea API (POST /api/v1/repos/ai/byre/releases), its notes the Markdown file <notes>, which the releasing agent writes from the diffs since the previous tag, as AGENTS.md, "Releasing", describes, attaches copies of the image and its .sha256 (not the appcast), and only then publishes it (PATCH with draft: false). A run that fails after the uploads leaves the draft on the releases page; delete it there before running again. Everything lands in mac/.build/release/<version>/, and the script prints the release page.

It needs, on that Mac:

  • a Developer ID Application certificate in the keychain (security find-identity -v -p codesigning lists it), with its key's access answered Always Allow for codesign (the first signing asks for the login keychain password on the Mac's screen, and a prompt left waiting fails that run with "timestamps differ"), and mac/byre.provisionprofile being the Developer ID profile for run.fc.byre.mac with iCloud (security cms -D -i mac/byre.provisionprofile prints it);
  • a notarytool keychain profile named byre-notary, made with xcrun notarytool store-credentials byre-notary --key <AuthKey.p8> --key-id <id> --issuer <issuer> from an App Store Connect API key;
  • the Sparkle signing key in the login keychain under account byre (~/Library/Caches/byre/sparkle-<version>/bin/generate_keys --account byre -p prints its public key, which must match SUPublicEDKey in mac/Info.plist; -x <file> exports it for safekeeping). The script downloads Sparkle's tools to that cache directory when they are missing;
  • a g.fc.run token that can write the repo's releases, as the generic password with service byre-release (security add-generic-password -s byre-release -a g.fc.run -w <token>);
  • the Cloudflare token (see "Website").

The app checks https://dl.byre.run/appcast.xml for updates (see "Updates" in docs/macos.md); each release's appcast replaces the last.

Website

byre.run and byre.team are registered at Porkbun, with Cloudflare as their DNS, in the Cloudflare account cloudflare@flamingcow.io.

  • https://byre.run/ (and www.byre.run) is the Cloudflare Pages project byre: site/index.html, with mac/icon.svg, icon.png (rendered from it at 180×180 with rsvg-convert, the favicon for browsers without SVG favicons and the apple-touch-icon) and the fonts from mac/fonts/. site/deploy.sh stages those files and publishes them with wrangler pages deploy. Releases don't redeploy it; its download link is https://dl.byre.run/byre.dmg, which each release replaces.
  • https://dl.byre.run/ is the R2 bucket byre's custom domain, holding what mac/release.sh uploads (see "Releases").
  • byre.team, and every path on it, redirects to https://byre.run/ through a redirect rule on its zone (Rules → Redirect Rules in the dashboard), over proxied AAAA 100:: records for byre.team and www.byre.team.

site/deploy.sh and mac/release.sh read a Cloudflare API token from the generic password with service byre-cloudflare (security add-generic-password -s byre-cloudflare -a cloudflare -w, which prompts for it); it needs Account → Cloudflare Pages: Edit and Workers R2 Storage: Edit, and Zone → DNS: Edit, Single Redirect: Edit, Zone Settings: Edit and Cache Purge: Purge on both zones. byre.run's Browser Cache TTL is "Respect Existing Headers", so browsers keep the 5-minute cache on byre.dmg and appcast.xml. The account ID is in both scripts.

Clients reach tailscale serve over HTTP/2; it proxies to byre over HTTP/1.1 and passes SSE through unbuffered.

macOS app

Building. mac/bundle.sh, from any directory, builds the release configuration with swift build in ~/Library/Caches/byre/build, one build directory every worktree shares and takes turns in (docs/macos.md, "Stack", says why), and assembles mac/.build/Byre.app: the Byre executable in Contents/MacOS; the server, built with go build ./cmd/byre, byre-hands, built from hands/, and byre-update, the mac/ package's second executable, in Contents/Helpers (not beside Byre: the default APFS volume ignores case, so byre and Byre would be one file); Sparkle's framework in Contents/Frameworks; mac/Info.plist with a ByreCommit key added holding the worktree's git rev-parse HEAD, CFBundleShortVersionString its git describe --tags --always without the leading v (0.1.0 on a release tag, 0.1.0-12-gabc1234 after it) and CFBundleVersion its git rev-list --count HEAD (the About panel shows them as "Version ()", and Sparkle compares the count); the mac/fonts/ woff2 files, an icon rendered from mac/icon.svg with rsvg-convert and iconutil (cached as mac/.build/byre.icns until the SVG changes) and mac/byre.provisionprofile. It signs each piece with the Developer ID Application certificate, a secure timestamp and the hardened runtime, innermost first: byre-hands as run.fc.byre.hands and the server as run.fc.byre, both with mac/automation.entitlements (Apple Events, which the hardened runtime otherwise refuses), Sparkle's Autoupdate, Updater.app and framework (its XPC services, only needed by sandboxed apps, are removed), byre-update as run.fc.byre.update, then the bundle with mac/byre.entitlements. It prints the bundle's path. It needs Xcode selected (xcode-select --print-path prints /Applications/Xcode.app/Contents/Developer), Go, rsvg-convert (Homebrew's librsvg), the team's Developer ID Application certificate and network access for the timestamp, and it stops first unless mac/byre.provisionprofile has ProvisionsAllDevices (a Developer ID profile; a development profile can't authorize the Developer ID signature); docs/macos.md, "Signing", covers the certificate and profile. Debug builds are refused by an #error in ByreApp.swift. The first build on a Mac fetches the Swift packages pinned in mac/Package.resolved and compiles them and the generated API client; later builds, from any worktree, recompile only the targets whose sources differ from the last build's, usually just Byre. The API client is generated from api/openapi.yaml at build time. The palette is mac/Sources/Byre/Palette.swift, one light and one dark value per colour.

Installing. A release is installed from its disk image into /Applications (see "Releases") and updates itself (docs/macos.md, "Updates"). A deploy installs its build there the same way (see "Releases"). A build from a checkout can also run from where it was built, open mac/.build/Byre.app in the worktree that built it, after quitting the running app (pkill -x Byre quits every running copy). Every build shares the bundle identifier run.fc.byre.mac and the same store, so any build reconnects to the servers the last one saved, switched between with the window's server picker in its compact toolbar, which ends with the Away button (see "Away"). Closing the window leaves the app running for notifications; ⌘Q quits it.

Storage. The app keeps three kinds of record in SwiftData (mac/Sources/Byre/Persistence.swift): each saved server's origin and window colour, when each task was last selected, and composer and answer drafts as JSON, one record per server and login (docs/macos.md, "Persistence" and "Drafts"). The store is ~/Library/Application Support/run.fc.byre.mac/byre.store, with its -shm and -wal files and CloudKit's working files beside it in that directory; the app isn't sandboxed, so it names its own file rather than SwiftData's default default.store, which every unsandboxed app shares. The store syncs through the iCloud private database of container iCloud.run.fc.byre.mac, in CloudKit's Production environment (com.apple.developer.icloud-container-environment in mac/byre.entitlements), whose schema is deployed from Development in the CloudKit console before a release that changes the SwiftData models; the CloudKit console (icloud.developer.apple.com, the container, Production, Private Database, zone com.apple.coredata.cloudkit.zone) shows the records. Deleting the local files while the app isn't running makes the next launch download the records again from iCloud; deleting the records in the console as well starts from the connection screen. Pasted images stay in memory and are dropped on Forget Server and quit. The app logs through os.Logger: log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"' prints it.

Screen and app control

Implementer sessions get byre-hands as a stdio MCP server named hands (handsMCP in internal/agents/hands.go), passed the same way as reviewers' Playwright server (see "Reviews"); reviewers and lead agents don't get it. It is a Swift executable built from hands/, installed beside the byre binary (in ~/bin from a checkout, in Contents/Helpers of the app from a release), and found on the service's PATH, which byre install starts with that directory. Each session runs its own process from its own file: when the session starts, byre copies the byre-hands it finds on PATH to hands/<session-id>/byre-hands in the data directory and runs that, and it removes the directory when the session's agent process ends. A hung or crashed one affects only that agent. The separate files matter because replayd, the daemon behind ScreenCaptureKit, keys its clients by executable path: two processes run from one path keep cancelling each other's connection, and every capture from that path hangs. replayd's log shows it as accepted client connection PID: <a> and invalidated because the current process cancelled the connection alternating between two pids, thousands of times a second. It speaks MCP's JSON-RPC over stdin and stdout, one message per line, and returns captures as inline PNG image content. Its tools, whose descriptions in hands/Sources/byre-hands/Tools.swift are what agents read:

  • screen_list: running apps, on-screen windows (front to back) and displays.
  • capture: one window, or a display, through ScreenCaptureKit. A window is captured as its rectangle of its display with only its own app's windows included, so other apps covering it don't hide it and its menus, popups and sheets show; capturing the window alone fails while a popup is attached to it. The image is scaled to max_edge (default 1568, as much as a model sees; 16 to 8192) and the reply gives the pixel-to-point scale. With save, it is also written as capture-<random>.png in the directory byre-hands was started with as --media-dir, and the reply gives the path; a byre-hands started without one (the macOS app's, which client_hands reaches) refuses save.
  • ax_tree: a window's or an app's menu bar's accessibility tree, one line per element with text or actions, each with a ref (e12) that act takes. Refs live in the process and each tree replaces them. They remember the window, or the app whose menu bar, they came from, and act refuses a ref that isn't from its window or its app's menu bar. Unlabelled containers are left out with their children shown in their place, and static text already contained in its labelled ancestor's text is dropped.
  • act: a list of steps in one window: accessibility actions on refs (press, set value, focus, confirm, any named action), then real mouse and keyboard events posted with CGEvent (click, double and right click, drag, scroll, typing, key combinations), Before each of those steps it reads the window's current frame, for [x, y], and makes sure the window is in front: its app frontmost and the app's focused window (through accessibility, so the app's own popups don't count against it). When it isn't, it activates the app and raises the window, and stops with an error, sending nothing, if the window still isn't in front. Every session's byre-hands drives the same desktop, so a call that can change what is on screen or focused holds an exclusive flock on byre-hands-input.lock in the user's temporary directory ($TMPDIR): an act call with steps and every browser call except a tabs without capture, each for the whole call, its waiting, tree and capture included, so what it returns shows its own work. Another such call waits up to 30s for it, then fails without doing anything. The lock is released when the steps end or the process exits. Typing posts each ASCII character as its key code, with shift where needed, since Orion ignores key events that carry only a Unicode string. Afterwards it can wait (wait_ms, then wait_for) and return the tree and a capture, so one call clicks, waits and looks. wait_for counts the window's elements whose title, description or value contains the text, ignoring case, once before the steps and then every 200ms, and ends when the count rises above the first one (above zero with no steps), so text already on screen, such as another tab's title, doesn't end the wait. Some apps list confirm but ignore it, Orion's address bar among them; the tool description says to send Return instead.
  • browser: Orion through AppleScript: list tabs, navigate (then poll document.readyState), run JavaScript in a tab (do JavaScript), select and close tabs, with wait_for (the tab's document.title and document.body.innerText, ignoring case) and capture. Capturing a call that names a tab selects that tab first, since only the selected tab is on screen, and waits 300ms for it to draw. The Orion window is found by its AppleScript id, which is its window server window id. Durations (wait_ms, timeout_ms, act's wait steps) must be 0 to 600000 ms. do JavaScript needs Orion's Develop → "Allow JavaScript from Apple Events".
  • chrome: Google Chrome through the DevTools protocol (Chrome.swift). The first call finds Chrome by GET http://127.0.0.1:9222/json/version; when nothing answers it runs open -na "Google Chrome" --args --user-data-dir=~/Library/Application Support/byre/chrome --remote-debugging-port=9222 --no-first-run --no-default-browser-check and polls for 15s. Chrome refuses remote debugging on its default profile, so byre's Chrome is its own profile, a separate Chrome beside any the human runs, and it keeps its logins: the human signs in there (passkeys included) and every later call, from any agent on that Mac, uses the session. The process holds one WebSocket to the browser endpoint for its life, reconnecting (and relaunching Chrome) on the next call after it drops, and attaches to each tab it touches with Target.attachToTarget (flattened sessions), enabling Page, Network, Runtime and Log there; concurrent calls for a tab share one attachment, and none uses the tab until all four are enabled. From then on it keeps that tab's requests (the last 5000) and console messages, exceptions and log entries (the last 2000) in memory; navigate clears both, network and console read them (each redirect hop is its own row, and the raw headers of requestWillBeSentExtraInfo and responseReceivedExtraInfo, which carry cookies, are paired with a request's hops in order, as DevTools does, with a hop whose response says hasExtraInfo (or redirectHasExtraInfo) false, such as a cached redirect, holding an empty place), and a request's body comes from Network.getResponseBody when asked for, shown up to 64 KiB. js is Runtime.evaluate with awaitPromise and replMode. trace_start and trace_stop run Tracing on the tab's session with the DevTools timeline categories, stream the trace to byre-trace-<random>.json in the temporary directory (a client's own, on a client Mac) and summarise it from the trace events: FCP, LCP, DOMContentLoaded and load of the tab's main frame (whose trace frame id is the tab's target id, so other tabs and iframes don't count) after its last navigationStart, and the RunTasks of 50ms or more on its CrRendererMain thread, each with the longest script, parse, style, layout, paint or GC event inside it. cdp sends any method to the tab's session. Capture is Page.bringToFront then Page.captureScreenshot, scaled to max_edge; with save it is also written to the media folder as capture's save does, through the same code. It drives no mouse or keyboard, but select, close, navigate with new_tab, capture, starting Chrome and any cdp method (some, such as Page.bringToFront, do) can change which page is in front, so those calls hold the input lock (see act) for the whole call, and starting Chrome holds it while it waits for the port; the other ops take no lock. Chrome comes from brew install --cask google-chrome; macOS's "downloaded from the Internet" prompt on its first launch must be answered Open (or osascript -e 'tell application "System Events" to tell process "CoreServicesUIAgent" to click button "Open" of window 1'), and until it is, Chrome runs without opening its port.

byre-hands reads stdin on a background thread and starts each request as its own detached task as soon as it arrives, so one call stuck on an app doesn't hold up the others; responses are written whole, one line each, under a lock, in the order they finish. Ref names come from one counter for the life of the process, so no two trees ever share a name, even trees built at the same time; the ref table is swapped in under a lock once a tree is complete, so a ref from any tree but the last published one fails as unknown instead of naming another tree's element. Each act call takes the table as it stands when the call arrives and resolves every step's refs from that copy, so a tree finishing during its waits can't point a ref at a different element. AppleScript needs the main thread with a run loop, so every script runs there (osa in Browser.swift, DispatchQueue.main.sync, while the main thread runs RunLoop.main), one at a time, wrapped in with timeout of 10 seconds; NSApplication.shared connects the process to the window server at startup, without which ScreenCaptureKit asserts. An app that is in a menu-tracking loop or otherwise busy answers neither Apple events nor accessibility requests: a script fails with AppleScript's timeout error after 10s, and accessibility requests time out after 3s (the messaging timeout set on the system-wide element at startup), and every accessibility read (attr in AX.swift, and the action-name and settable checks in the tree walk) throws on the first timeout, so a tree walk, wait_for, a ref's position or the front-window check stops with an error saying the app isn't answering instead of waiting 3s on every attribute of every element.

Display sleep. While the display sleeps, ScreenCaptureKit lists no displays and capture finds nothing to capture. So every tool call runs inside withDisplayAwake (Display.swift). It holds a PreventUserIdleDisplaySleep assertion for the whole call. When the main display is asleep (CGDisplayIsAsleep, or zero bounds), it also declares user activity (IOPMAssertionDeclareUserActivity), which turns the display on. It then waits, polling every 100ms for up to 10s, until ScreenCaptureKit lists the display again. If ScreenCaptureKit refuses (no Screen Recording grant), it stops waiting and runs the call, so tools that don't capture still work and capture reports the refusal itself. A declared activity doesn't reset the keyboard and mouse idle time, so once the call's assertion is gone, the display sleeps again after powerd's grace period (com.apple.powermanagement.delayDisplayOff in pmset -g assertions), not after the full display-sleep delay. pmset -g log shows each wake as Display is turned on beside byre-hands assertions.

Permissions. Agents' processes, byre-hands included, run as byre's descendants, so macOS privacy controls (TCC) hold byre responsible for them: the Screen Recording, Accessibility and Automation grants in System Settings → Privacy & Security are byre's, made by clicking at the Mac's screen, and each app scripted for the first time asks for its own Automation grant there. ax_tree and act check AXIsProcessTrusted first and fail saying byre lacks Accessibility when it isn't granted, since an untrusted process otherwise just sees no windows and no menu bar. Accessibility can list byre switched off; only its switch being on counts. codesign gives byre the identifier run.fc.byre and the team's certificate (Apple Development from a checkout, Developer ID in a release), which TCC keys grants on, so they carry over from build to build, but not between a checkout's build and a release's. codesign -dv <binary> shows the signature a binary has, and launchctl print gui/$(id -u)/run.fc.byre which binary the service runs.

byre-hands check (hands/Sources/byre-hands/Check.swift) tests the grants of whichever process started it and prints a JSON array, one row per grant with name, status (granted, missing or unchecked), detail and settings (the grant's x-apple.systempreferences: pane URL), then exits. Screen Recording lists the shareable displays and windows (waking the display first, as tool calls do), and on failure calls CGRequestScreenCaptureAccess. Accessibility calls AXIsProcessTrustedWithOptions with the prompt option and, when trusted, reads Finder's menu bar. Both requests ask macOS to show its prompt and put byre in the pane's list, switched off. Automation of Orion is unchecked while Orion isn't running, since asking it would launch it; otherwise AEDeterminePermissionToAutomateTarget, asking the user if needed (which waits for the answer), then count windows through AppleScript. Mouse and keyboard events need only Accessibility, and chrome uses Chrome's debugging port, which needs no grant. POST /api/admin/permissions runs it as the service: CheckPermissions in internal/agents/hands.go copies byre-hands from PATH to a new check-* directory under hands/ in the data directory (its own path, for replayd), runs it with a 50s limit, under the app's 60s request timeout, and removes the directory.

Screenshots from implementers

Each implementer session gets a media folder, <worktree>-media (MediaDir in internal/agents/hands.go), created when the session starts and removed when the task is merged or discarded (RemoveTask). It sits outside the worktree, so nothing in it is committed. byre-hands gets it as --media-dir (for save on capture and chrome), and implementers also get the reviewers' Playwright server (playwrightMCP, see "Reviews") with it as --output-dir. The guidance (screenshotGuidance) tells implementers that when a turn changes something visible they can embed screenshots in the final message as Markdown images with the paths those tools return; GET /api/tasks/{taskId}/image serves the media folder as well as the worktree, and refuses (409) a closed task, so a closed task's screenshots show as "image not available".

Previews

An agent shows the human a server it runs with the expose_port MCP tool (internal/agents/preview.go): name labels it, port is where it listens. The agent starts the server itself, usually as a background command; byre doesn't supervise it, so it dies with the agent's claude process (a byre restart, compaction, the task closing) and the agent starts it again when asked. The call is refused when port is outside the task's block or nothing accepts a connection on 127.0.0.1:<port>. Otherwise byre runs tailscale serve --bg --https=<port> http://127.0.0.1:<port> (internal/tailscale, which runs the macOS CLI at /Applications/Tailscale.app/Contents/MacOS/Tailscale, since byre's environment has no tailscale on its PATH, with TAILSCALE_BE_CLI=1, without which the app executable launched from the service exits 0 without running the subcommand), then reads tailscale serve status --json and fails the call if no Web host for that port is listed, so the server is at https://<byre's ts.net name>:<port>/ with Tailscale's identity headers, and records a row in the previews table (task, name, port, time) with a preview.exposed event, which the transcript shows as a note linking the URL. Each port gets its own HTTPS port rather than a path under byre's, since path-prefix proxying breaks apps that use absolute paths. Exposing a name or port the task already has replaces that row, and a port it no longer uses is turned off with tailscale serve --https=<port> off. A server must listen on 127.0.0.1: while tailscale serve holds a port, binding 0.0.0.0 on it fails with "address already in use". When the task is merged or discarded, byre turns off each of its ports (Manager.Unexpose); the rows stay. GET /api/tasks/{taskId}/previews lists a task's rows by name, and tailscale serve status shows what is served.

The right pane's top row shows the task's previews at its right end as links, one per name, each opening its URL in the default browser (PreviewLink in mac/Sources/Byre/Panes.swift). It fetches the list when the selected task changes and on each preview.exposed in the task's stream. Previews are never framed inside byre.

Previewing byre

A task's build of byre runs alongside the service with its own empty state, from the worktree root, on the task's port, and the agent exposes it with expose_port:

go run ./cmd/byre serve --data "$PWD/.preview" --listen 127.0.0.1:$PORT --ports 51000

It must never share the service's data directory: nothing locks the database, and startup resumes every session marked running in its recorded worktree. --ports puts the preview's own tasks' port blocks in a range the service's tasks don't use; each instance only knows its own tasks, so with the default both would hand out blocks from the same first port, starting with the one the preview listens on. .preview/ is gitignored, so the end-of-turn commit leaves it out. The macOS app's Add Server… connects to its exposed URL, beside the service in the server picker. The preview starts with no users, projects or sessions; set a git identity with PUT /api/me and add a project to try things. A project's Merge pushes to its checkout's origin, so a preview project should be a clone whose origin is a local bare repository (git clone --bare <checkout> .preview/origin.git, then clone that), never the real checkout.

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. 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://<listen address>/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 message sent while a task session's turn is running, from the app, the lead's send_message, a parent's message_subtask or byre's review findings, is queued (Manager.Message in internal/agents/queue.go) in task_inputs, keyed by task and team member (member, empty on a task with one agent) so it survives a handoff to a new session, and recorded, in the same transaction, as a user.queued event (its text, images and task_inputs id, the sender as actor), and the request returns the running session with 202. DELETE /api/tasks/{id}/queue/{input id} takes a queued message back (Manager.Withdraw): under the task's queue lock (queueLockKey), which both hand-over paths (deliverQueued, takeQueuedMidTurn) hold from reading the queue until the message is sent or taken, whichever session of the task is delivering, it deletes the row and records a user.unqueued event with its id, or answers 409 once the message has been handed over (the row is gone). A model it carries is applied to the session at once and takes effect from the next turn. The agent gets queued messages after its next tool call, the same way as inbox answers (see "Inbox"): each is recorded as its own user.message with the sender as actor and mid_turn: true, and the agent reads them, headed " sent you this message while you were working", in the hook's additionalContext (Codex: turn/steer; Antigravity: a user message injected by its PreInvocation hook before the model's next call, agyBeforeModel in internal/agents/antigravity.go). Messages are handed over oldest first, stopping at the first one with images, since mid-turn delivery carries only text; none are handed over during a handoff turn. Whatever is still queued when the turn ends goes out one message per turn, ahead of held inbox answers, from Deliver; startup does the same for idle sessions. An interrupt holds all of that: after a turn ends interrupted, byre starts no turn on its own until someone sends the session a message (Manager.interrupted, true while the live session's interrupted flag is set, which the next user.message clears). The turn end delivers nothing, the ready-for-merge prompt waits, a message from byre, an agent (message_subtask) or the board is queued as if the session were running instead of starting a turn, and a lead holds its signals (a human's queued lead message still goes out). Whatever was held goes out mid-turn during, or at the end of, the next turn the human (or the lead's send_message) starts; an inbox answer starts a turn as usual. The flag is in memory only, so a byre restart releases what was held. Closing the task deletes its queued messages. sqlite3 byre.db 'SELECT * FROM task_inputs' lists them. A task has at most one session that isn't completed per team member, so a task with one agent has at most one (see "Teams"). 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.

users.default_model is the model the app starts new work on: ⌘K's Start and New task (whose composer then offers it first), the Project tab's and TODO links' start, suggestion links' start and discuss (when there is no live lead session), and the composer of a task or lead with no live session. It is returned as default_model on GET /api/me, set with PUT /api/me/default-model from the Settings tab's Default model menu, and records user.updated, so every open app reloads it. The server itself never reads it: the lead's create_task and start_todo default to the lead's own model, and create_subtask to the parent session's.

Images: POST /api/blobs takes a raw image body (type sniffed from the bytes, PNG, JPEG, GIF or WebP only, at most 5 MiB; ImageTypes and MaxBlobBytes in internal/store/blob.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 before the text: to Claude as a base64 image content block (claude.Session.Send), to Codex as an image input with a data: URL (codex.Session.Send). Antigravity's stream-json input takes only text, so antigravity.Session.Send writes each image under the session's directory and adds a line naming its path for the agent to open with view_file. The user.message event records the ids as 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 starts with handoffPreamble plus the handoff as its first message (actor byre; its user.message carries continuation: true). The request's optional model (the button's long-press menu, compact_task's model) picks the new session's model, on either vendor; without it the new session keeps the old one's. The old session still writes the handoff, so this can't get past a vendor that is rate-limited. The chosen model is held in memory (Manager.handoffModel) until the handoff turn ends. 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. The transcript never draws the new session's first message (continuation: true), so that note, which holds the handoff, is the only marker of the switch. If byre restarts during the handoff turn, the continuation is lost and the old session simply goes idle.

Task agents spawn parallel work as subtasks (see "Subtasks").

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, under the task's mutex, see "Teams"), 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 records a session.error in the task's transcript and the turn ends unfinished: no summary, no handoff, no merge finalize, and a workflow stage neither finishes nor records its output, so the next stage never branches from a HEAD that lacks the turn's work.

A session runs on one of three vendors, picked by the model it is created with (workflow.VendorOf in internal/workflow/workflow.go): gpt-* models run on Codex (codex app-server), gemini-* models on Antigravity (agy), every other model on Claude Code. The UI's model list is models in mac/Sources/Byre/Composer.swift; a live session's picker offers only its own vendor's models, and the API refuses (400) a switch to another vendor's model. All vendors sit behind agentProcess (internal/agents/process.go), and all end a turn through the same turnEnded, so commits, turn summaries, snapshots, handoffs, merges and deliveries don't depend on the vendor.

For Claude, byre chooses the session ID (vendor_session_id) up front. Codex picks its own thread id: a Codex session is created with an empty vendor_session_id, and byre stores the thread id when its first process starts (startCodex); a session with an id resumes it with thread/resume. Antigravity does the same with its conversation id, which agy reports in its init line at launch (startAntigravity), resumed with --conversation. Processes start on demand: the first message after a restart resumes the session (--resume for Claude). Sessions are running from the moment a turn is sent until it ends (Claude's and Antigravity's result, Codex's turn/completed); 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 agent CLI starts in its own process group, and killing one sends SIGKILL to the whole group. That way a launcher that runs the real agent as its child, along with the agent's MCP servers, can't keep holding byre's stdout pipe, which would leave the kill waiting forever for end of file.

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 question's context since that turn has no summary, don't commit (byre commits each turn), don't push, touch other branches or run git maintenance, and write TODO.md items the way byre parses them (see "TODO"): a top-level - line holding only the title, with the description indented under it. A task agent's guidance also names its port range, tells it to do the setup AGENTS.md describes in a new worktree (see "Worktree setup and ports"), and quotes the task's title, which may be all the human typed when creating the task. 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"). A subtask gets one naming its parent and saying that its final messages go to the parent's agent (subtaskGuidance; see "Subtasks"). For Claude 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. For Codex it is the thread's developerInstructions, and every turn/start also carries it as additionalContext under the key byre. For Antigravity it is an always-on rule in the session's plugin (rules/AGENTS.md, with the notes in rules/notes.md), written when byre starts the session's process, and every message also starts with it in <system-reminder> tags. The guidance names byre's tools vendor-neutrally ("byre's ask_human tool"): Claude sees them as mcp__byre__<name>, Codex as <name> in the namespace byre (Codex reserves the mcp__ prefix), Antigravity as <name> on the MCP server byre_byre, called through its call_mcp_tool (recorded as mcp__byre__<name>).

A turn whose final message asks the human something in prose is bounced once (takeBounce in internal/agents/guidance.go): if ask_human wasn't called during the turn and the final message's last paragraph has a sentence ending in ? outside code spans, code blocks, quotes, blockquotes, table rows and <details> sections, the agent is told to ask through ask_human and carries on. Questions earlier in the message (quoted, answered, or listed for someone else) don't count. A task started as a TODO discussion (its first user.message holds discussPrompt from internal/agents/tasks.go) is never bounced, since that prompt lets the agent talk it over in chat as well as through ask_human, and neither is a task with an open question already in the inbox, since the prose then points at that question. A bounce is recorded as a byre user.message with mid_turn: true and bounce: true carrying the nudge; the transcript draws neither it nor the agent message before it, so the rewritten message takes the blocked one's place. For Claude the bounce is a Stop hook (bounceProseQuestion) that blocks the stop; Claude Code reports the block as a "Stop hook error" notification, which is expected. For Codex, byre doesn't end its turn on turn/completed: it records the nudge and starts another Codex turn with it, and only that turn's end ends byre's turn. Antigravity works the same way on its result, since its Stop hook isn't given the final message. The per-turn flags reset when the next user message is sent. An agent told explicitly to ask in plain text will refuse the bounce.

A turn's final message is its summary, and it lives only in the transcript, so the human reads it once. When a task turn ends normally (not an error, not interrupted, not a handoff) with non-empty result text, didn't call ask_human, and isn't a Merge turn or discard turn (the task closes right after one, so its final message is never left to read), its turn.ended event carries summary: true. A Merge turn that called stop_merge keeps the task open and does carry it. That flag drives the lead's "finished a turn" signal and the "Turn summaries" notification (see "Lead agent" and "Notifications"). A turn that asked a question has no summary, since the final message comes after the question it explains; 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.

Every turn starts as soon as it is sent; byre doesn't cap how many run at once. 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, or a Codex turn fails with codexErrorInfo usageLimitExceeded or rateLimitExceeded, byre records a session.rate_limited event carrying the vendor's status instead of a generic error (see "Errors").

Codex runs with its own home, codex under the data directory (CODEX_HOME, set by startCodex), so the human's ~/.codex/config.toml (its notify program, MCP servers and plugins) doesn't apply and byre's threads stay out of the ChatGPT app's history. That home needs its own login, once: CODEX_HOME="$HOME/Library/Application Support/byre/codex" codex login. Until then a Codex turn fails with 401 Unauthorized, filed as a session error. The codex binary comes from PATH; see docs/codex-protocol.md.

Antigravity runs agy from PATH with the user's own Antigravity login and config (~/.gemini), signed in once by running agy interactively; agy models lists the models that login offers. byre's tools, guidance and hooks reach it through a per-session plugin directory, antigravity/<session-id> under the data directory, removed when the session's process ends. Its conversations show up in the user's own agy history. Antigravity reports no context window, so its sessions have no session.context; its usage comes from agy --output-format json --print /usage (ReadUsage in internal/antigravity/usage.go), which answers without starting a turn (see "Usage" under "Status"). See docs/antigravity-protocol.md.

Status

GET /api/admin/status reports running_turns (task sessions now running, as session, task and vendor; lead turns aren't listed); rate_limits, each vendor's latest usage, see "Usage" below; agent_processes, zombies, stuck_after_seconds and protocol_errors (below); clients, one entry per command channel byre holds (the /api/clients/connect WebSockets in internal/clients, see "Client Macs"), oldest first, so it lists exactly the Macs list_clients and the client_* tools can reach; a Mac whose event streams are open but whose command channel is down isn't listed. id, host and commit come from the Byre-Client, Byre-Host and Byre-Commit headers, which the macOS app sends on every request: a UUID the app makes up each time it starts, the Mac's name (Host.current().localizedName, else ProcessInfo.processInfo.hostName, the name agents' client tools take) and the ByreCommit that mac/bundle.sh stamped into its Info.plist. To check that a rebuilt app is running, compare each client's commit with the commit it was built from. last_action_at is when byre last received a request the human made from that client id: any request other than a GET, except the two the app sends on its own (PUT /api/me/pane-focus and PUT /api/tasks/{taskId}/summary-read, listed in automaticRequests in internal/server/server.go), and every binary message on a terminal WebSocket, which includes the replies SwiftTerm sends by itself to queries in the output (cursor position and the like). The identify middleware and terminalInput record it in the client registry's memory (Registry.Acted in internal/clients), so it is absent until that client's first action after byre starts. The registry keeps each client by id across command-channel drops, so a Mac that sleeps and reconnects keeps its last_action_at, and forgets a client 24 hours after it was last connected or acted, since an app that quit never comes back under that id. An action at least a second after the last one from that client that broadcast, every GET /api/events stream opening or closing, and every command channel connecting or disconnecting broadcasts an unstored admin.clients event (clientsChanged). And server counts (start time, agent processes, projects, active tasks, SSE clients, database size including WAL, last event seq).

Usage. Each vendor's entry in rate_limits is a Usage (api/openapi.yaml): updated_at, an optional rejected (why the vendor is refusing work, such as Claude's rateLimitType when its status is rejected or Codex's rateLimitReachedType, which covers depleted credits and caps no window shows) and windows, each with label, used, an optional limit, unit (usd, tokens, requests or percent), and optional resets_at and window_seconds. It lives in memory only (Manager.rateLimits) and comes from one of two places, both in internal/agents/usage.go:

  • A usage command. usage_commands holds an optional shell command per vendor, read with GET /api/admin/usage-commands and set with PUT /api/admin/usage-commands (which records admin.usage_commands) from the Server tab. When a vendor has one, byre runs it with $SHELL -l -c in byre's own environment, in its own process group, killed after 10 seconds (runUsageCommand), one run at a time per vendor (Manager.usageMu), so a later run's result always lands last: at startup, after every turn of that vendor's sessions ends (turnEnded, leads and reviewers included), and when it is saved. It prints one JSON object of the shape above on stdout and exits 0; a window may give resets_in_seconds, relative to updated_at (now when absent), in place of resets_at, and unknown fields are ignored (parseUsage). A non-zero exit, a timeout or output that doesn't parse removes the vendor's entry and logs usage command. The vendor's own reports are then ignored.
  • The vendor's own reports, when its command is empty, each window a percent with limit 100. Claude's rate_limit_event during its turns, one window per unifiedWindows entry with window_seconds from its name (claudeUsage), since Claude doesn't send a length. Codex's account/rateLimits/updated, its primary and secondary windows (codexUsage), also asked once at startup (readCodexRateLimit, a short-lived codex app-server that calls account/rateLimits/read without opening a thread), so its entry is there before any Codex turn runs. Antigravity's agy --output-format json --print /usage, one window per bucket labelled by its id (agyUsage), asked at startup and after every Antigravity turn ends.

Every change broadcasts admin.health. A Claude rejected status still drives session.rate_limited (see "Errors") whichever source the Status tab shows.

Agent health is internal/agents/health.go:

  • Processes. agent_processes lists 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) and health. health is exited when the stream has closed or kill(pid, 0) says the pid is gone, stuck when the session is running and nothing has arrived for StuckAfter, otherwise ok. The stream does mark long tool calls: while a tool runs, Claude Code sends a tool_progress line with "heartbeat": true every 30 seconds, so a long go test keeps last_event_at fresh and a silent stream means nothing is happening.
  • Zombies. running_without_process: a session marked running whose process byre isn't tracking or has exited. process_without_session: a claude child of the byre process (from ps -A -o pid=,ppid=,comm=) whose pid belongs to no tracked process, or a tracked process still alive for a completed session. A live process for an idle session is normal and not a zombie. While a session's process is starting (in Manager.starting, between its turn marking it running and its process being tracked), neither kind is reported: that session isn't a running_without_process, and no untracked claude child is a process_without_session, since the starting one can't be told apart from an orphan until it is tracked. A real orphan shows at the next check with nothing starting. Manager.Health reads the running sessions and the claude children first and the tracked and starting processes after them, so a start seen in either is already registered by then.
  • Watching. Manager.Watch recomputes health every healthInterval. When the set of problems (non-ok processes and zombies) changes, it logs each new one as agent health problem and broadcasts an unstored admin.health event (seq 0). Nothing is killed or repaired automatically.
  • Protocol errors. internal/claude reports a stdout line that isn't a JSON object, doesn't parse, or has a type outside knownTypes (the line is still passed on), and the manager adds lines whose content it fails to decode. protocol_errors.counts counts them per vendor since byre started; recent holds the last keptBadLines, newest first, each with session, time, reason, the raw line cut to badLineBytes and its full size. They live in memory only, and each one also broadcasts admin.health.

The left pane's Status tab 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); each running turn, linking to its task, with the time since its process's last event and stuck or exited when it is; each vendor's usage, headed rejected (with the rejected reason when there is one) when rejected is set or any window's used has reached its limit (else allowed) and the time since its updated_at, and per window its label, the amount used formatted by unit ($150.94 / $200.00 · 75% for usd, 36 / 40 requests · 90%, 1.2M tokens without a limit, 42% for percent), the share of the window's time elapsed (from resets_at and window_seconds, — without both), a pace bar whose fill is the share used and whose vertical line is the share elapsed, the fill warn-coloured when usage is ahead of the line, all vendors in one table so their columns and pace bars line up; a Clients table, one row per entry in clients, with the Mac, user, the build's commit (cut to 8 characters, the full one in its tooltip), the time since it connected and the time since its last action; and the server counts. It refetches the status when a session.created, session.state, task.*, project.created, admin.health or admin.clients 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.subagents, session.model, and session.context. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as agent.vendor. Codex items map onto the same kinds (handleCodex in internal/agents/codex.go): an agentMessage becomes agent.message, a reasoning item's summary agent.thinking, and commandExecution (as Bash), fileChange (as apply_patch), dynamicToolCall (as mcp__<namespace>__<tool>, so byre's tools carry the same names as Claude's), mcpToolCall and webSearch items an agent.tool_call when they start and an agent.tool_result when they complete. Other completed items and the error and warning notifications are recorded as agent.vendor; the remaining notifications (thread status, MCP startup and the like) aren't recorded. item/agentMessage/delta becomes agent.delta, and thread/tokenUsage/updated session.context. Antigravity steps map the same way (handleAntigravity in internal/agents/antigravity.go): an agent_response step's text_deltas become agent.delta and its finished text agent.message, and a tool step an agent.tool_call when it starts and an agent.tool_result when it is done, with run_command recorded as Bash and call_mcp_tool as mcp__<server>__<tool> with its Arguments as input; user_input steps aren't recorded, and other step types are recorded 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 summary: true when its result is the turn's summary (see above). Activity inside a subagent (agent.message, agent.thinking, agent.tool_call, agent.tool_result) carries payload.parent_tool_use_id, the ID of the tool call that started the subagent, and never updates session.context. 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.

Event payloads are stored whole in events.payload, and agent.tool_result content (full command output, file reads, diffs) dominates the log's size; the app loads every active task's whole history when it starts (see "Events"), so this is what its startup costs. sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events GROUP BY kind ORDER BY 3 DESC" shows the split, and sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events WHERE task_id = '<task-id>' GROUP BY kind ORDER BY 3 DESC" one task's.

Messages byre itself sends an agent (actor byre: the restart notice, Sync and Merge prompts, Compact's handoff request) show collapsed like a tool call, labelled byre with the first line as the brief; expanding it shows the full text. Review findings forwarded to the agent come this way too, so the transcript shows them separately: a review.finished event with findings draws its counts note with the review's summary (one bullet per finding) expanded under it, behind a border-coloured rule, and then each finding as its own row (see "Reviews"). The transcript view has a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is running and its transcript has an open turn (open in TranscriptBuilder: a drawn turn-starting user.message, not mid-turn, with no turn.ended after it) or the task's state is review_pending or reviewing (see "Task state in the store"), and, only in the first case, a Cylon-style scanner in the same colour: a row of 2pt dots, 5pt apart along an 84pt track, where a glow sweeping back and forth every 1.2s lights up the dots under it, drawn below the last message, in the transcript's bottom padding, so it scrolls with the transcript and sits just above the composer's top border when scrolled to the bottom. It is an overlay 8pt below the transcript's last row, so showing or hiding it never changes the transcript's scroll height or position, and it is drawn with Core Animation (ScannerView in mac/Sources/Byre/Chat.swift: a gradient layer masked by the dots, its position animated by a repeating CABasicAnimation), so the render server animates it and SwiftUI never redraws the transcript for it. The session list (GET /api/me/lead/sessions or the task's sessions) is fetched separately from the event stream, so it says running a little before the turn's user.message arrives and a little after its turn.ended; requiring the open turn as well keeps the scanner and border from flashing on in those gaps. Consecutive tool calls collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. Thinking output appears as expanded Markdown text with a small muted "thinking" label and ends the current tool-call group just like an agent message, including inside subagent rows. While the scanner shows, a tool call still waiting for its result, and any group or subagent row holding one, gets a border in the task's colour, so a running tool reads apart from waiting on the model without expanding anything (working in mac/Sources/Byre/Chat.swift). The braille spinner the Project tab shows for task states is drawn with Core Animation too (SpinnerView in mac/Sources/Byre/Format.swift: its ten glyphs rendered once into images in the surrounding font and colour, cycled every 80ms by a repeating discrete CAKeyframeAnimation on the layer's contents), so it never makes SwiftUI redraw or lay out the views around it. When a turn ends, or a turn-starting user.message (not mid-turn) arrives, any call still without a result, other than those inside an active subagent row (below), reads "no result" instead of "running…", so an interrupted call never spins during a later turn. The second case covers a turn a restart cut off: it has no turn.ended, and the restart notice starts the next turn (endTools in mac/Sources/Byre/TranscriptBuilder.swift). byre's own tools show without their mcp__byre__ prefix, in title case (mcp__byre__create_task shows as Create Task; toolLabel in mac/Sources/Byre/TranscriptBuilder.swift); other tools show under the name the agent used. A Claude Code subagent call (the Agent or Task tool) stands on its own, collapsed, labelled with its subagent_type and description; expanding it shows the prompt and the call's result. Its summary carries a status from the agent.vendor system events whose tool_use_id names it (task_started: running, or running in the background; task_progress: its description; task_notification: its status, red unless completed). From its task_started to its task_notification the row is active, and an active row gets the task-coloured border while the scanner shows or while the session's subagents count is above zero, as do the waiting tool calls inside it in the latter case; so a background subagent still reads as running after the turn that started it has ended. A turn's end leaves the waiting calls inside an active row waiting; the row's end (its task_notification, or a session.subagents event with 0, which ends every still-active row as "ended with its process", replayed history included) marks the calls inside it still waiting "no result". The subagent's own messages, thinking and tool calls (events whose parent_tool_use_id names the call) go inside that call's row, between the prompt and the result, so parallel subagents don't interleave in the main stream; its consecutive tool calls group there the same way, its messages get an accent rule down their left edge, and a subagent it starts nests inside it in turn. An event naming a subagent call the transcript never saw stays in the main stream marked ↳ subagent. A subagent call ends the main agent's run of tool calls, so it never joins a group. A subagent's messages never end the main agent's live message, count as a turn's final message or hide a quiet turn. Claude Code streams a background subagent's activity, but for a foreground one only its prompt, so a foreground subagent shows only as its call, its status and its result. Selecting a task that has never had a session focuses the composer, and so does picking any task in the ⌘K switcher when it lands on the transcript (Store.composerFocus). In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task (see "Drafts" below), so it survives switching tabs or tasks and relaunching the app. The composer has no @-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand @path the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session, and stays enabled while a turn runs: 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 bubble reading "queued…" while the session is running and "sending…" otherwise, 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. A queued message's user.queued event takes over its bubble (the oldest local one with the same text), and the transcript shows a "queued…" bubble below everything else for every user.queued not yet followed by a user.message with the same text, each user.message clearing the oldest such bubble first (queued messages are delivered oldest first), then a local one, and task.merged or task.discarded clearing them all, since closing the task deletes its queue, so a queued message stays visible across switching tasks, relaunching the app, and on every view. A bubble from a user.queued event has a × that withdraws the message (ChatModel.withdraw): it first downloads the message's images, so a failed download leaves the message queued, then sends the DELETE; on success the bubble goes, its text goes back into that task's composer ahead of whatever is there, separated by a blank line, and its images are added back as composer attachments. A user.unqueued event removes the bubble with its id on every other view. A refusal (the agent already has the message) shows as an alert and the bubble stays until the user.message clears it.

Drafts. Unsent composer text (task and lead) and unsent inbox answers (checked choices and Other text) are kept in memory by Drafts (mac/Sources/Byre/Drafts.swift) and saved to a SwiftData DraftRecord per server and login (docs/macos.md, "Drafts"), one JSON object with composer (keyed by message:<task id> or message:lead, each {text, images}), checked and texts (both keyed by inbox item id), and comments, the Changes view's pending line comments keyed by task id, each {anchor: {path, line, old, quote}, text}. A comment shows only under a diff line whose path, line number, side and text all still match, so after the agent moves lines a comment never attaches to a different line. Every pending comment not shown under a line (its file closed or not loaded, or its line gone from the diff) is listed at the top of the Changes view with its path:line, the quoted line, its editable text and remove, so nothing is sent unseen. Writes are debounced (400 ms) and flushed at once when a composer or answer box loses focus, on send and on quit. A composer draft is cleared only once its send succeeds, and an inbox answer only once its response is accepted; each send in flight is tracked on its own and the stored draft holds the text of every unconfirmed send (oldest first, then the composer's current text, separated by blank lines) and counts its images, so a relaunch mid-send brings back everything not yet confirmed. A failed send's text goes back into the composer ahead of whatever is there now (text typed since, or earlier failures), separated by a blank line, rather than replacing it. The composer follows its Drafts text as it changes, so text put back from outside it (a withdrawn queued message, a draft synced in from another Mac) shows at once. Drafts for tasks that are no longer active and for inbox items no longer open are pruned whenever the task list or inbox reloads. A record that doesn't decode is logged and ignored, as is a write that fails; drafts then live only in memory. Pending images are not stored, only their count: after a relaunch the composer shows how many need re-attaching, until dismissed or the next successful send.

The task switcher (⌘K, or the project ▸ task picker centred in the right pane's top row) lists active tasks with their task state and open inbox item count (the Project tab shows the same state), live while it is open. Tasks are grouped by state (taskRank in mac/Sources/Byre/Store.swift, which also orders the Project tab's task list, ties in the server's order, subtasks sorted among their siblings under their parent): with inbox items first, then Ready to merge or Ready to discard, then idle, then working or reviewing, and merging, held or discarding last; within each group, the task least recently visited comes first (never-visited tasks before all others, the right pane's own task after all others, ties in the server's order: projects by name, tasks newest first), so ⌘K Enter repeated cycles through the tasks. Visit times are SwiftData VisitRecords synced through iCloud (docs/macos.md, "Persistence"), 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. Text before a first colon that starts at least one project's name (ignoring case, no spaces) picks those projects instead: by: fix lists only their tasks whose titles contain "fix", their project rows that match, and one row per project creating the task titled "fix" there. When no project name starts with it, the whole text is the title, so "Fix: login" stays as typed. A create row shows the user's default model (see "Agent sessions"), the model the composer offers first for the new task's first message; the task itself starts with no session. Below each create row, a "Start in …" row creates the same task and starts its agent on the default model with the title as its first message, through POST /api/projects/{id}/tasks with prompt set to the title. Picking a task points the right pane at it on the Transcript tab, scrolled to its oldest open question when it has open inbox items, otherwise to its latest summary when that is unread (see below and "Lead agent").

Task state. The switcher and the Project tab show one label per task in place of its raw session state, the first that applies (taskState in mac/Sources/Byre/Store.swift), with a dot and a tooltip saying why: Needs your answer (an open question inbox item), Merging (merging or merging_into_parent), Merge held (merge_queued_at), Merge after review (merge_after_review), Reviewing (the task's state is review_pending or reviewing), Working (running), Discarding (discarding; see "Task state in the store" for both), Idle (anything else, including no session). The Project tab, in its task list and TODO items, draws a spinner in place of the dot for Reviewing, Working, Discarding and Merging. The label follows the tasks and inbox state the app already holds, so it needs no extra fetch.

Unread summaries. A Task's summary_seq is the seq of its latest turn.ended event with summary: true (0 for none), read through the partial index events_summary, so the app knows of unread summaries without loading transcripts; a turn.ended with summary: true on the global stream raises it in the app. The switcher's and the Project tab's task rows show a new summary state label in place of Idle while the task is idle and summary_seq is above the seq the user has read (unreadSummary in mac/Sources/Byre/Store.swift), except on tasks of a workflow run, which close too soon for their summaries to be read. A summary isn't an attention item: picking such a row opens the Transcript at the end, like any task without open questions, findings or a ready bubble. Read state is per user on the server: summary_reads holds one highest-read seq per user and task. GET /api/me/summary-reads returns the caller's, for active tasks, as a map from task id to seq, and PUT /api/tasks/{taskId}/summary-read with {seq} raises it, emitting summary.read (task_id, seq) owned by the user only when it went up, so every Mac the user has byre open on clears the label. A summary counts as read only when the summary turn's end row is on screen in a transcript while that server's window is the key window of the active app (summaryVisible in mac/Sources/Byre/Chat.swift), so selecting the task on another tab leaves it unread. sqlite3 byre.db 'SELECT * FROM summary_reads' shows the read seqs.

Task colours. Each task has a colour, tasks.color (color in the API), an index into taskPalette (mac/Sources/Byre/Format.swift), the Palette.task0 … Palette.taskN colours in Palette.swift, each with a light and a dark value, all at least 4.5:1 against the backgrounds so they work as text. CreateTask assigns it in the same transaction as the task's row: of the store.TaskColors colours, one no active task holds, and among those the one given out longest ago (the lowest max(rowid) of tasks with that colour, never-used first), so a new task doesn't take the colour of one just closed. Active tasks have distinct colours until more than TaskColors are active at once. TaskColors must match the number of taskPalette entries. The right pane sets its accent (the paneAccent environment value, the palette's accent when no task is selected) to the selected task's colour, which tints the picker's row, colours the task picker's text, the selected tab's underline, the composer's border and the running border and scanner. Everywhere a task is named it shows in its colour: task titles in the switcher, the Project tab and the Status tab, and task links, including any Markdown link to #/task/<id> or #/task/<id>/<tab>. The lead's guidance tells it to link every task it names that way, using #/task/<id>/questions when it points the human at a question, and clicking one points the right pane at the task, selecting the named tab if there is one.

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_sessions row with owner set to the user's login, role lead and no task_id. Its events carry owner and no project or task. It runs in leads/<login> with leadGuidance (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 turn summary; a turn that ends in error records the error on its session.state event, which the pane shows as a note.
  • Messages. POST /api/me/lead/messages sends 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 in lead_inputs; the lead's PostToolUse hook (Codex: turn/steer after each completed tool item; Antigravity: the PreInvocation hook) hands held messages over after its next tool call, like a task's queued messages (see "Agent sessions"), and any still held become its next turn. Signals are never handed over mid-turn. The composer stays enabled. GET /api/me/lead/sessions lists the caller's lead sessions; interrupt and compact use the usual session endpoints.
  • Scrollback. GET /api/me/lead/events returns a page of the caller's lead events in whole turns, turns of them (default 50): those after the turns+1'th turn.ended before before (or before the latest event, so the page also holds a turn still running), with more saying whether earlier events exist. The lead pane loads only the last page (see "Events"); when its top scrolls into view (an empty row kept first in the transcript), it fetches the page before its first event (loadOlderLead in mac/Sources/Byre/Store.swift), puts its rows in front (TranscriptBuilder.prepend) and scrolls back to the row that was first. A page starts just after a turn.ended, so rendering it from scratch matches how it drew as part of the whole history. Live events still append without limit until the app is relaunched.
  • Signals. Every published event goes through Manager.Observe. These become signals in lead_inputs for every user who has a lead session: inbox.created (a question with its choices and recommendation), session.error and session.rate_limited on a task, inbox.updated when a human answered a question (the signal also carries its text and the agent's recommendation, so the lead sees where the human departed from it), task.created (naming the TODO item it was started from, or a subtask's parent, if any), task.merge_queued ("'s merge was requested by and is held until the merge in progress in its project is done."), task.merge_after_review (" was set by to merge once the review of snapshot finishes with no findings."), task.merge_after_review_cancelled ("'s merge after review was cancelled by ."), task.merge_after_review_dropped (" didn't merge after the review: "), task.merge_started (" is merging, started by ."), task.merged, task.discarded, turn.ended with summary: true (" finished a turn:" with its result cut to 1500 characters), task.workflow_stage (" started stage "" of workflow "".") and task.workflow_finished (" finished workflow "":" with its output cut to 1500 characters), review.started (" started a review of snapshot ."), review.finished (" review of snapshot :" with the review's summary, change and findings, or why it failed or was skipped; reviewLine in internal/agents/review.go), user.message on a task from a human (see "Pane lock"), and task.state (" state: → .", or " state: ." for a new task; see "Task state in the store"). The store hands Observe all of one transaction's events together, and they become one signal, its task.state lines first, so a turn's summary always arrives with the state its turn end left the task in. A batch whose lines are all quiet is dropped instead of held (quietSignal in internal/agents/lead.go): task.state, review.started, and task.created, task.merge_started, task.merged and task.discarded by a human, unless the line carries "Your right pane is free" (see "Pane lock"). Quiet lines still ride along in a batch with anything else, and the "Tasks now" list that follows every batch shows each task's current state. 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's task.created event carries the actor that created it, separately from its created_by login: tasks the lead creates with create_task or start_todo keep the human's login as created_by but 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 one byre message headed "Signals since your last turn (n)", followed by "Tasks now:" with the list_tasks text and a line saying what the lead's right pane shows (task or project, and selected tab) and whether the human is working in it (leadStateText in internal/agents/lead.go). If collecting that fails, for example because a project's repository is gone, the batch is still sent, ending instead with "The task list and right pane state are unavailable:" and the error, which is also logged as lead state. Its guidance tells it not to respond to the human's own actions, and to make its final message exactly NO_REPLY when a batch needs nothing from it. A transcript (quietReply in mac/Sources/Byre/TranscriptBuilder.swift) never draws an agent.message whose last line is NO_REPLY (the model sometimes writes a sentence before it), and hides the live partial message while its text is still a prefix of it, so an empty one never takes up space. A lead turn that byre starts without a handoff (a signal batch or a handoff continuation) is held (ctx.held): its byre message is never drawn, the turn isn't opened, so the scanner stays off, its partial message stays hidden, and its tool calls, subagents and thinking are built into a held row list instead of the transcript. The turn is released, inserting those rows where the turn belongs, when it draws anything else: a real message, a note, or its own turn.ended when it ends without NO_REPLY. When it ends in NO_REPLY while still held, the held rows are dropped, so nothing of the turn ever appears; a turn that draws a real message anywhere in it keeps all of it, even when a later message is NO_REPLY.
  • Tools. list_tasks (each project's active tasks with their state, each subtask noting its parent, a held merge (merge_queued_at) and a merge after review (merge_after_review) each noted, then a line listing the titles of its TODO.md items on the default branch that no active task was started from), read_transcript (a task's events in brief, the last limit or those after after_seq; closed tasks too), search_closed_tasks (see "Closing a task"), read_inbox, read_status (the GET /api/admin/status body as JSON, built by the same Server.status, which the server hands agents.New), answer_question (an option or other response recorded as the user, through the same checks as POST /api/inbox/responses), send_message (starts the task's agent if it has none; while it is mid-turn the message is queued through Manager.Message like the human's, see "Agent sessions", and the tool result says it was queued; on a team task it posts to the board, see "Teams"), create_task and start_todo (each with an optional workflow to freeze and run; create_task also takes agents to start a team), create_project (name and repo_path, created as the user by the same Server.createProject as POST /api/projects, which the server hands agents.New, so it publishes the same project.created event, and returned as JSON), interrupt, compact_task, take_snapshot (see "Snapshots"), merge_task (optional target: main, or parent for a subtask, see "Subtasks"), discard_task, show, compact_self, read_knowledge (see "Knowledge base"), the note tools (see "Notes") and the workflow tools (see "Workflows"). 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.focus holds what the user's right pane shows: project_id, task_id and tab (the selected one). The tabs' order is fixed; neither the lead nor the human can change it. With a task_id, project_id is always that task's project, filled in by the server; with only a project_id, the pane shows that project with no task, which only the Project tab has anything for. GET /api/me/focus reads it; PUT /api/me/focus and the lead's show set it, refusing an unknown tab, a task that isn't active or an unknown project, and record a focus.updated event owned by the user. The app applies the focus at startup and on every focus.updated it didn't cause itself; the human's own changes (⌘K, the right pane's task picker, clicking a tab, a task link, a notification) are sent with PUT /api/me/focus.
  • Pane lock. A show of what the pane already shows (the same task, or the same project with no task, on the same selected tab; sameView) tells the lead so and changes nothing. Otherwise, while the human is working in the right pane (the app reports focused or drafting, below), the lead's show is refused with an error telling it to say in chat what it would have shown, unless its selected tab is project (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (Manager.paneFinished): its task is merged, discarded or merging (merging_by or finalizing set), or the human is waiting on the task's agent (Manager.paneWaiting): the selected tab is transcript, the task's implementer session is running, the app reports no unsent text in the pane, and either the user.message that started the running turn came from the user after the pane last moved, or the pane last moved 10 seconds ago or more (watchHold). The pane's last move is the last PUT /api/me/focus or show that set it (Manager.paneMoved, in memory, so a restart counts as long ago). For the user's own message, unsent text reported before the turn started doesn't count, since the app's report of the composer emptying on send can arrive after the message does. A user.message on a task from a human (not byre, an agent or a lead) signals every lead " sent a message; its agent is working on it.", and the sender's own lead, when its pane shows that task and is waiting that way, also gets "Your right pane is free: the human is waiting on 's agent." Each PUT /api/me/focus from the user starts a 10-second timer (endWatchHold); when it fires, if the pane hasn't moved since, the app still reports the human in it, the user hasn't messaged the task since the move (that already freed it, above), and the pane is waiting that way, the lead gets the signal "Your right pane is free: the human has watched 's agent work for 10 seconds without typing." The lead's own show starts no timer. When a task.merge_started, task.merged or task.discarded event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: is merging." (or "is merged."/"is discarded."). The lead's guidance tells it to show the next thing that needs the human on that line, or, when nothing does, the project's Project tab with a short status in chat: one line per active task, then up to three TODO items it would start next, each with a reason, starting one only when the human picks it. It writes each item as a link to #/todo/<project_id>/<URL-encoded title>; Markdown views draw that (todo in mac/Sources/Byre/MarkdownView.swift) as the title followed by start and discuss actions, as accent link text so the line stays selectable, that call POST /api/projects/{id}/todo/tasks with the user's default model, as the Project tab's do, or, once an active task was started from that item, the task's colour, dot and state, linking to it. When the lead suggests new work that isn't in TODO.md, its guidance tells it to write a link to #/suggest/<project_id>/<URL-encoded title>/<URL-encoded brief>, drawn by the same code with start and discuss buttons: start calls POST /api/projects/{id}/tasks with the title, the brief as prompt and the user's default model, which checks the user's git identity, creates the task and starts its agent in one request, with no lead turn in between; discuss sends the lead POST /api/me/lead/messages asking to discuss the suggestion, quoting its title and brief (with the user's default model only when the user has no live lead session). Once an active task in that project has the suggestion's title, it shows that task's state the same way. A chat view refills them whenever its transcript grows or tasks or sessions change. When that status says nothing new since its last one, it replies NO_REPLY instead. The app sends PUT /api/me/pane-focus with focused and drafting whenever either changes (PaneFocus in mac/Sources/Byre/PaneFocus.swift): focused is true while the window is key, the app is active and the keyboard focus or the last click is inside the right pane, so a click anywhere in it counts, not only on inputs and buttons; drafting while any text field in the right pane other than the terminal holds non-blank text. The window reports to the server it shows. It sends false on deactivating, switching to another server, Forget Server, closing the window and quit, and resends true when its event stream reconnects. byre keeps both flags and the time of the last report in memory per user (Manager.paneFocus, Manager.paneReported), so they start false after a restart until the app resends them.
  • Move hold. Each PUT /api/me/focus from the user (switching task or tab, by any means: the switcher, a tab, a link or a notification) records the time in Manager.humanMoved, and for 10 seconds after it (moveHold) every show that would change the view is refused, whatever the pane lock above would allow, with an error giving how long ago the move was. A show of what the pane already shows changes nothing and says so. A refusal also marks the user in Manager.moveRefused, which each move by the user clears. When the 10 seconds pass without another move by the user (endMoveHold, on a timer), byre clears both and, only if a show was refused since that move, sends the lead the signal "The move hold is over: …"; its guidance tells it to show what it held back if the pane's other rules allow, or reply NO_REPLY. Because only a refusal arranges the signal, the lead's system prompt and the show tool's description tell it to call show at once whenever the user asks to see something or it wants to show something, never holding back in expectation of a refusal. It is in memory, so a restart clears it.
  • Start hold. Start on a TODO item in the Project tab leaves the pane where it is. When the user starts one themselves (StartTodo with the user as actor, not the lead's start_todo), byre records the time in Manager.startedTodo for them, and for 10 seconds after it (startHold), while the pane's selected tab is project, every show that would change the view is refused, focused or not, with an error telling the lead the human is likely to start more. A refusal also marks the user in Manager.startRefused. The user's own PUT /api/me/focus (switching task or tab) or a message to the lead clears both, and another Start restarts the hold and clears the mark. The refusal and the show tool's description both tell the lead the hold lasts 10 seconds. When the 10 seconds pass without it being cleared or restarted (endStartHold, on a timer), byre clears both and, only if a show was refused since that Start and the pane's selected tab is still project, sends the lead the signal "The start hold is over: …" so it can show what it held back; its guidance tells it to reply NO_REPLY when that no longer matters. It is in memory, so a restart clears it.
  • Compaction. The pane's Compact button asks the lead for a handoff (leadHandoffPrompt). The lead can't see its own context size, so the first time one of its turns ends (other than a handoff or a compact_self turn) with the latest session.context used at a quarter of its window or more, byre holds a signal for it giving the percentage and asking it to call compact_self at the next logical point (warnLeadContext in internal/agents/lead.go); it is sent once per live process, so a byre restart can send it again once the window is known. The lead calls compact_self; when that turn ends normally, byre sends the same request. The handoff turn ends the session, and a new lead session starts from leadHandoffPreamble plus the handoff; held input follows once that turn ends.
  • Notes. The lead keeps notes on how its user works (priorities, preferences, the reasoning behind choices) in lead_notes, one row per note keyed by owner, through its remember (add, or replace the text and project of an id), forget and list_notes tools (internal/agents/leadnotes.go). A note has a project_id when it applies to one project and none (the column empty) when it applies to the user in every project; remember takes project_id and refuses one that names no project, and the guidance tells the lead to file a note about one project's code, UI, docs or process under it and to apply it only to that project's tasks. Each change records a lead.note_saved or lead.note_deleted event owned by the user. When a lead's agent process starts, notesText lists every note with its id after leadGuidance, the every-project notes first and then each project's under its name and id, in the session's instructions (--append-system-prompt for Claude, which keeps what the session's first request carried; developerInstructions for Codex), but not in the per-turn guidance reminder, so a new or compacted lead session starts from the current notes and notes written mid-session are already in its context. The guidance tells the lead when to write a note (an answer that departs from a recommendation, a correction, a stated preference), to cite a note's id when it shapes a recommendation, and that it may answer a task agent's question itself when a note settles it outright. GET /api/me/lead/notes lists the caller's notes and DELETE /api/me/lead/notes/{noteId} deletes one (Manager.DeleteLeadNote), and, when the user has a live lead session, holds a message for it from byre naming the note's id and text and telling it to stop applying it, since the running session still has the note in its instructions. It is held as a message, not a signal, so it keeps its place in line with the human's own messages: it goes out ahead of any message they send after the delete, as its own turn (drawn like a signal batch's, and folded away on NO_REPLY) or mid-turn after the lead's next tool call; the Notes tab (mac/Sources/Byre/Notes.swift) shows one set at a time, picked from a dropdown of Every project and each project by name with its note count (the pick is kept in memory across tab switches), each note with a small delete button styled like the transcript's, refetching on either event. sqlite3 byre.db "SELECT owner, project_id, id, text FROM lead_notes ORDER BY created_at" shows them.

Task agents get notes too. An implementer session's instructions end with taskNotesGuidance and notesText for the task's creator, limited to the every-project notes and the task's project's (startLive in internal/agents/manager.go), fixed when the agent process starts like the lead's. Reviewers get none. Implementers have a suggest_note tool (text, evidence) that queues a lead signal for the task's creator naming the task, the suggested text, the evidence and the project id, and refuses when the creator has no live lead session. The lead's guidance tells it to save a suggestion with remember, choosing its project, only when it holds and isn't already covered, and to tell the human nothing either way. A suggestion leaves no record of its own beyond the signal; a saved one shows as the lead's lead.note_saved.

  • 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 running for 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 filling the window, with no navbar. The left has six tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Notes, the lead's notes (see "Notes" under the lead), Workflows, Status, Server and Settings, and at the end of its tab strip the connection dot and the user's name. Workflows (mac/Sources/Byre/Workflows.swift) lists GET /api/workflows by name with each description, refetching on workflow.updated and workflow.deleted; a workflow's run button doesn't start anything itself, since a run needs a project and a brief: it appends Run the "<name>" workflow: to the lead's composer draft, selects the Lead tab and focuses the composer, for the human to finish and send. Server is about the byre service on the Mac the app runs on, whatever server the app is connected to (mac/Sources/Byre/Server.swift), so its headings name the Mac (Host.current().localizedName, as in Byre-Host): Server on is On when launchd has the service loaded (launchctl print gui/<uid>/run.fc.byre succeeds), showing the address from tailscale serve status --json and Turn off (launchctl bootout), and Off otherwise, showing Turn on, which runs the bundle's byre install as the connection screen's Run byre on this Mac does. Below that, Tools on lists every check from the bundle's byre check (see "Running as a service"), each Found or Missing, with how to install a missing one: its command in a box with Copy and Run in Terminal, which writes the command to a .command file in the temporary directory, run by the user's login shell, and opens it in Terminal, so prompts like sudo's work and the list updates when the app becomes active again; Missing is in the danger colour only when it blocks the server. Run byre on this Mac runs byre check first, and when something blocks it lists the missing checks in the same form instead of installing. Both Turn on and Run byre on this Mac use the running service's address instead of reinstalling when it is already on, since a reinstall restarts it and the old service could answer the first status check. The app runs both helpers through the user's login shell, so they see the PATH the service is given. It reads the state and checks again each time it shows and when the app becomes active. While the service is on, Agents' permissions has Check permissions, which calls POST /api/admin/permissions on the service at that address (see "Permissions" under "Screen and app control") and lists each grant as Granted, Missing (with Open System Settings, which opens the grant's pane) or Not checked. Below it, Usage commands has one field per vendor, empty meaning the vendor's own reports, loaded from and saved to that service's /api/admin/usage-commands (see "Usage" under "Status"). Settings holds the user's and this client's preferences; its This Mac's permissions has the same Check permissions for the app's own grants, the ones client_hands uses, running the bundle's byre-hands check from a copy in the temporary directory (checkLocalPermissions in Server.swift). Which left tab is selected is kept only in memory (Overlays); the Lead view stays mounted while another tab shows, and Notes, Workflows, Status, Server or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Workflow and Project, always in that order. Board shows only on a team task (see "Teams") and Workflow only on a task with a workflow_run (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a More ▾ menu (MoreButton in mac/Sources/Byre/Panes.swift); the selected tab never moves there. Questions and reviews have no tabs: both live in the transcript (see "Inbox" and "Reviews"), and the transcript's attention buttons, at its top right, count what needs the user there (see "Attention" under "Inbox"). The Go menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled. ⌘/ opens a dialog listing every keyboard shortcut; the list is shortcuts in mac/Sources/Byre/Switcher.swift, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open.

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. With discuss: true the prompt adds that the human wants to discuss the item first: the agent zooms out first, reads what it needs and gives its view on whether the item should be built at all and in what shape (the problem it solves, alternatives including a smaller change or nothing, its approach, trade-offs and doubts), talks it over in its final messages and in as many ask_human questions as help, each about those broad choices rather than build details and free to build on earlier answers, and changes nothing until told to build it. 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 task state (see "Task 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 state as a link to the task instead of the Start and Discuss buttons. Start posts the item with the composer's default model; Discuss posts it the same way with discuss: true. Neither moves the right pane (see "Start hold"). 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. The tool's description tells agents to keep everything about one choice (what it does, its example, code, trade-offs) in lines indented beneath that choice rather than in text above the list; those lines render inside the option, and only the marker line's text is the option. When every item of a choice list has exactly one non-blank indented line and that line is only a Markdown image, the list renders as a grid of tiles (grid, set in parseList in mac/Sources/Byre/Markdown.swift): four across, or two across when the context is at most 480pt wide. The tool's description tells agents to write image choices this way. 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. All questions are alike: the tool's description and its result tell the agent to end its turn after asking, and the answer arrives as its next message. 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 it from holding back delivery; anything else is rejected with a message saying why. The inbox holds only questions; errors go in the transcript (see "Errors").

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. The right pane shows a task's questions in its transcript, where they were asked, and they are answered there (below).

Attention. At the top right of a task's transcript, over its content, a column of buttons (AttentionBar in mac/Sources/Byre/Chat.swift, counted by ChatModel.attention) shows what in the transcript needs the user, one button per kind that has any, in the warn colours: "N questions" for its open questions, "N findings" for the findings of the task's latest review (the highest-numbered snapshot with a review.* event, once it is done) that have neither a state from the user nor a response from the agent, and "ready to merge" while the latest ready-for-merge overview is current (see "Ready for merge"), or "suggests discard" when that latest one is a discard suggestion. Each click scrolls to the next one of that kind, top-anchored, and after the last back to the first. Where the transcript would follow the bottom, it follows only until the first of these rows in transcript order (ChatModel.held) reaches the top, and stays there as rows arrive; so a transcript opens at that row, and one that a new question arrives in stops with the question at the top. Scrolling by hand or sending a message lets that row go (ChatModel.release), and the transcript follows the bottom again until a different row becomes the first. A button goes away once nothing of its kind is left. When one of these rows stops needing the user (a question answered, a finding answered, the overview no longer current) and others remain, the transcript scrolls to the next remaining one after it in transcript order, wrapping to the first (ChatModel.closed). Elsewhere, the switcher and the Merge confirmation's question warning jump to the task's oldest open question (Store.showQuestion), and the review warning to that snapshot's review (Store.jumpTo with .review): each selects the task's Transcript tab and scrolls there once the row is in the transcript.

POST /api/inbox/responses answers any number of questions in one submission, each becoming answered. Response types:

type carries
option the chosen choices' text in options: exactly one for pick-one, one or more for pick-any; either may also carry text and images, the human's notes, delivered after the choice
other free text in text: an answer, a question back, or new instructions

An other response, or a pick-any option one, may carry images, blob ids from POST /api/blobs (unknown ids are a 400; see "Images"); an other with images may have empty text. They are stored in inbox_responses.images and sent to the agent as images with the delivery message, whose text says how many each response attached. Since mid-turn delivery carries only text, answers with images are never handed over mid-turn (deliver held mid-turn: response has images); Deliver sends them as a new turn once the session is idle.

A submission is all or nothing. Store.Respond resolves each item with an UPDATE conditioned on state = 'open' in one transaction, so when any item was answered, dismissed or withdrawn by someone else in the meantime (a second tab, the lead's answer_question, the agent's cancel_question), the whole submission is rolled back and refused with 409, and no response is recorded.

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 questions is still open and unanswered (AwaitingHuman in internal/store/inbox.go); 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, after any queued messages (see "Agent sessions"), so the agent reads them alongside that tool's result without being interrupted (for Antigravity, the PreInvocation hook hands them over as a user message before the model's next call after the first; for Codex, after each completed tool item, deliverMidTurn hands them over with turn/steer instead; when the steer fails, restoreMidTurn puts the taken queued messages back under their original ids and clears the answers' delivered_at and snapshot_n, so Deliver sends them when the turn ends, and logs mid-turn delivery restored); 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. Just before either hands answers over, byre snapshots the task's worktree (snapshotForAnswer in internal/agents/snapshots.go, trigger answer), synchronously and under the task's mutex, and records the snapshot's n on each answer it hands over (inbox_responses.snapshot_n, snapshot_n in the API), so the snapshot's tree and event_seq give the worktree and the conversation as they stood before the agent read the answer. A new snapshot is taken for every hand-over, even when the tree is unchanged, so each one's event_seq and session are the conversation boundary just before that answer and POST /api/tasks/{taskId}/snapshots/{n}/fork on it continues from there. An answer snapshot is never reviewed, nor is a fork one (see "Forking"). Everything that looks for the task's latest snapshot to judge review state skips both (LastReviewable): the dedupe of the other triggers, which skip a snapshot only when its tree equals the last non-answer one's, so work an answer snapshot caught mid-turn is still snapshotted and reviewed when the turn ends; Merge after review; and the Merge confirmation's review warnings. A Merge turn already holds the task's mutex, so mid-turn delivery in it snapshots without taking it again; a worktree with merge conflicts snapshots with the conflict markers as they stand, and its real index is left untouched. No snapshot is taken once the task is closing, or when taking it fails; the answers then have no snapshot_n. A failure is logged as take snapshot and recorded as a session.error in the transcript, and the answers are still handed over: holding them would leave an idle agent waiting with nothing to retry the delivery. The log traces each answer: inbox response recorded when it is stored, deliver held with the reason, deliver sent mid-turn with the snapshot, deliver sent with the session's state, the snapshot 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. sqlite3 byre.db "SELECT * FROM inbox_responses WHERE delivered_at IS NULL" lists the answers held now.

Errors

An agent error (a turn that ends in error without a human interrupt, a process that dies mid-turn, a failed commit, merge, cleanup or workflow step) is recorded as a session.error event on the session (recordError in internal/agents/inbox.go, actor byre, payload error), and a rate-limited turn as session.rate_limited (recordRateLimited, payload vendor, rate_limit and error). The transcript draws each as an error note, a rate limit with its vendor, limit type and reset time. Nothing answers or dismisses them: the human reacts by messaging the agent. On a task they signal the lead and can notify (below).

Away

A user who is away has byre answer their agents' questions instead of waiting (internal/agents/away.go). users.away, away on GET /api/me, is set with PUT /api/me/away (Manager.SetAway), which records user.updated. While it is on, every question filed by ask_human in a task the user created is answered at once, as that user, with an other response of awayText: pick the most likely answer and carry on; fork with the fork tool (see "Forking") only when strongly convinced both answers are worth following; don't guess at what only the user can give (a test result, a password, a permission), and carry on with other work or end the turn instead; and no authorization is granted. The tool's result says so, and the answer is delivered like any other, usually mid-turn right after the ask_human call, so it gets an answer snapshot and the user can Answer again with their own answer when back. Turning it on answers the user's tasks' questions already open the same way; turning it off lets new questions wait again. The app's Away button, at the end of the server picker in the window's toolbar, shows on (accent) when the user is away on any connected server and sets all of them to the opposite on click, so a mixed state is always cleared by one click. sqlite3 byre.db "SELECT login FROM users WHERE away = 1" lists who is away.

Notifications

The running macOS app posts notifications for events on its SSE stream, raised by the app itself (notifyInbox, notifyError, notifyTurn and notifyTask in mac/Sources/Byre/Notifications.swift, through UNUserNotificationCenter). With the app not running, nothing notifies; closing its window leaves it running.

  • Preferences. users.notification_prefs holds what notifies the user, returned as notification_prefs on GET /api/me and set with PUT /api/me/notifications: questions (inbox.created, on by default), reports (task turns that end with summary: true on turn.ended, labelled "Turn summaries"), ready (task.ready), discard (task.discard_suggested), merge_problems (task.merge_stopped and task.merge_after_review_dropped), workflows (task.workflow_finished), errors (session.error) and rate_limits (session.rate_limited). A key missing from the stored JSON reads as off. The app checks them against each event as it arrives.
  • Content. The title is byre · <task title> and the body the first line of the question, turn summary, ready-to-merge overview, discard or merge-problem reason, workflow output, or error (or "Rate limited"), cut at 200 characters. The notification's identifier is the question's ID, turn-<seq> for a turn summary, workflow-<seq> for a finished workflow, error-<seq> for an error or rate limit, and task-<task id> for ready to merge, suggests discard and merge problems, so each task shows at most one of those three at a time.
  • Clearing. A delivered notification leaves Notification Center once it is dealt with anywhere, on this Mac, another one or by the lead: an inbox.updated event whose item is no longer open removes the item's notification (clearInbox), a summary.read event removes that task's turn-<seq> notifications up to its seq (clearTurns), and task.merged, task.discarded, snapshot.created (unless its trigger is answer) and inbox.created remove the task's task-<task id> notification (clearTask); the last two are the events that clear the task's ready state on the server.
  • Clicking a notification activates the app, shows its window and points the right pane at the item's task.

The left pane's Settings tab asks macOS for notification permission and edits the preferences, and also holds the Default model menu (see "Agent sessions") and the two Review models menus (see "Reviews").

Each question shows in the task's transcript where it was asked, as a bubble (QuestionBubble in mac/Sources/Byre/Chat.swift). While it is open, the bubble, in the warn colours, holds the whole question card (QuestionCard in mac/Sources/Byre/Questions.swift), answered in place. Once it is answered or dismissed, the bubble shrinks, on a faint yellow background (Palette.warnFaint) that keeps past questions apart from the rest of the stream, to its question text and who chose which options or the first line of who answered what, "dismissed by" whoever dismissed it, or "withdrawn by the agent". Its ▸ disclosure expands it to the rest of the card (QuestionDetails in mac/Sources/Byre/Questions.swift): the context with its choices, chosen ones marked and none clickable, the "Blocked on:" line, attachments and the response thread. The transcript builds it from the item's inbox.created event and updates it from each later inbox.updated event of the same item.

Each item 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, with whatever is in the Other text box (and its images) as notes. A choice's block (or a pick-any choice's checkbox label) holds the whole list item: its first line, which is the choice's text, and everything under it, such as an example's paragraph or code block. 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. Under it, every question has one submit row: a "Let the agent decide" button at its left, away from the choices so it isn't hit by accident, and the Submit button at its right. Let the agent decide sends at once an other answer reading "Use your judgment for this decision and continue within the existing task instructions." (delegateText in mac/Sources/Byre/Questions.swift), which shows on the answered bubble like any other; the ask_human description tells agents that this answer settles only that decision and never grants an authorization the owner must give explicitly. Beside it, "Ignore" (POST /api/inbox/{id}/dismiss) dismisses the question unanswered, for one the agent moved past without calling cancel_question: it becomes dismissed with the human as resolved_by, collapses to "dismissed by " and drops out of the attention bar, the agent isn't told, and answers held behind it are delivered. ArrowDown and ArrowUp move between the choice blocks, the checkboxes and the Other block. A question has exactly one Submit button, enabled once there is something to send: on a pick-one question it sends the Other text as other; 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. The Other text box sends with the Submit button or on Enter (Shift+Enter for a newline), like the message box, and folds back into its block when left empty with no images. Pasting or dropping an image into it adds a thumbnail, as in the composer (both keep pending images in Store.images, an ImageDrafts); images upload when the answer is submitted and are put back if the submission fails. Pending answer images are not kept across a relaunch.

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. For the same reason inbox_items keeps its priority and blocking columns, which nothing reads or writes; new rows take their defaults (0 and 1, which older builds read as a blocking question).

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 (mac/Sources/Byre/Markdown.swift; sections nest) showing only the title; session guidance and the ask_human schema tell agents to put long supporting detail in them.

A status mark that is the whole of a table cell, paragraph line, list item or heading, optionally in bold, renders coloured (marks in mac/Sources/Byre/Markdown.swift): checks (✓, ✅ and the like) in Palette.ok, ~, ≈ and ⚠ in Palette.warn, x, ×, ✗, ❌ and the like in Palette.danger, and -, – and — in Palette.muted. A table cell whose first word is a mark, alone or followed by text (✓ cross-vendor), colours that mark and gets the matching tint background (Palette.okTint and the like), Palette.surface for the dashes; the rest of its text stays plain. A mark inside other text, or in code, stays plain.

Markdown images (![alt](src), or ![alt](<src>); the plain form also accepts spaces, since worktree paths under Application Support have one) render inline; clicking one opens it full size in an in-window overlay. 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, which refuses (403) a path that resolves outside both the task's worktree and its media folder (see "Screenshots from implementers"), so the ask_human description tells agents to copy a scratch image into the worktree first; 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 diagram drawn natively when the source is a flowchart (mac/Sources/Byre/Diagram.swift; docs/macos.md, "Diagrams", lists what it parses); any other source stays as source with "diagram not rendered: " under it. The image 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 it responds to, once the session is idle and none of its 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. thread_id only groups items: filing an item never changes any other, so several questions on one thread stay open until each is answered, withdrawn or dismissed. Nothing sets the superseded state; it stays in the spec because old items hold it. Every item change is an inbox.created or inbox.updated event carrying the whole item, including who resolved it.

Client Macs

Task agents use the Macs the human has byre's app open on directly, with no approval, as they use the machine byre runs on. While it runs, the app (mac/Sources/Byre/Remote.swift, started with the global event stream and stopped with it) keeps a WebSocket open to GET /api/clients/connect, reconnecting 2s after it drops. The connection is keyed by its Byre-Client header and named by its Byre-Host header (both required); a newer connection with the same Byre-Client replaces the older one, while two apps open on one Mac each keep their own. The client tools take the host name, or the id from list_clients, which they need when more than one app is connected from that host (the call fails listing the ids). byre keeps the connections in memory (internal/clients), pinging each every 30s and dropping one that doesn't answer within 15s. The app pings too, every 10s, and reconnects when it has had no pong for 25s. That catches a socket byre dropped while the Mac slept: the close never reached the Mac, and without something sent on it the app would wait on it indefinitely. Over it byre sends ClientRequest text messages, each with a fresh id, and the app answers each with a ClientReply carrying that id (error when it couldn't carry it out), in any order, handling requests concurrently. A request whose client disconnects before replying fails with " disconnected before replying".

Implementer sessions get nine MCP tools for it (internal/agents/clients.go). The app sets its WebSocket's maximumMessageSize to 256 MiB, the same as byre's read limit, so file contents fit in one message each way.

  • list_clients: the status's clients (see "Status"), with each one's host, login, id, connection time, app commit and last_action_at; the most recently active is listed first and marked.
  • client_bash: op: bash. The app runs /bin/zsh -l -c <command> in the human's home directory through Process, which starts it in its own process group, stdin from /dev/null and stdout and stderr into one temporary file, and kills the group with SIGKILL after timeout_ms (default 120000, at most 600000), replying exit_code -1. Otherwise exit_code is the exit status, or 128 plus the signal. output is the file's last 64 KiB. The agent's call waits the timeout plus 30s for the reply. Background children the command leaves are not waited for or killed, and a command that quits the app keeps running.
  • client_upload: op: upload. path is checked against the worktree through an os.Root; byre registers a transfer naming the path's directory and base name under a random id for as long as the call waits (at most 10 minutes), and the app downloads GET /api/clients/transfers/{transferId}, which streams tar -czf - -C <dir> -- <name> (404 once the call has ended). The app unpacks it into a new .byre-upload-<uuid> directory beside dest (~/ expanded, parent directories created), removes whatever is at dest, moves the archive's one entry there and removes the temporary directory.
  • client_open: a client_upload of path to ~/Library/Caches/byre/open/<random>/<path's base name>, then an op: bash running open on it, so the file opens in that Mac's default app for its type. The folder is left in place for the human to keep or for macOS to clear.
  • client_download: op: download, the reverse. byre makes dest's parent directories through an os.Root on the worktree and registers a receiving transfer (Transfer.Receive) for a fresh temporary directory; the app runs tar -czf <temp file> -C <path's parent> -- <path's name> and PUTs it to /api/clients/transfers/{transferId}, which writes it once (O_EXCL) and answers 204, and 404 for a sending transfer, one already received or one whose call has ended (GET likewise answers 404 for a receiving one). byre unpacks it into a new .byre-download-<transfer id> directory beside dest, removes whatever is at dest, renames the archive's one entry there and removes the temporary directories, all inside the os.Root.
  • client_read: op: read_file; the app replies with the file's bytes in data, reading on a detached task, off the main actor. It resolves symlinks, refuses anything but a regular file (a FIFO would block the open), and reads at most 64 MiB plus one byte, refusing a longer file, so the base64 reply stays well under byre's 256 MiB read limit, past which byre would drop the connection. A .png, .jpg, .jpeg, .gif or .webp path comes back as an image result; other files must be UTF-8 and come back as cat -n style numbered lines from offset (default 1) for limit lines (default 2000), with a closing line naming the next offset when lines remain.
  • client_write: op: write_file with path and data; on a detached task, off the main actor, the app resolves symlinks, refuses an existing path that isn't a regular file (a FIFO would block the write), creates the parent directories and writes the file in place (Data.write without .atomic), so an existing file keeps its permissions.
  • client_edit: a read_file, then a write_file of the text with old_string replaced by new_string, refused unless old_string appears exactly once. Nothing locks the file or checks it is unchanged between the two, so a write in between, whether another agent's client_edit of the same file or the human's or an app's, is lost.

client_read, client_write and client_edit wait at most 2 minutes for each reply, and their paths, like client_download's path, are absolute or start with ~/, which the app expands.

  • client_hands: op: hands, with tool and arguments as the hands tools take them (see "Screen and app control"). The app runs one byre-hands, bundled at Contents/Helpers/byre-hands, shared by its connections to every server (two run from that one path would cancel each other's ScreenCaptureKit connection, see "Screen and app control"), started on first use and again after it exits, writes each call to its stdin as a tools/call line and replies with the line's result, which byre returns to the agent as the tool's result, images included (mcp.Tool.Rich; Codex gets them as inputImage data URLs). The macOS privacy grants it needs (Screen Recording, Accessibility, Automation of Orion) belong to byre's app on that Mac, since the app is the responsible process; NSAppleEventsUsageDescription in mac/Info.plist is what lets macOS ask for the Automation grant. When byre-hands exits, the calls waiting on it fail. The agent's call waits for the reply 2 minutes plus the call's wait_ms, twice its timeout_ms (which browser and chrome spend once on the load and again on wait_for) and every act step's wait, then fails.

A byre restart drops every connection; each app reconnects 2s later, and calls in flight at the restart fail as disconnected. Updating a client's app goes through the tools themselves: client_upload the new Byre.app, then client_bash a command that quits the old app and opens the new one, which outlives the app and reconnects. An app that doesn't know the upload op can't be updated this way, so its update is by hand.

Signing merges

byre never signs with a key on the machine it runs on. When a project checkout's commit.gpgsign is true (git -C <checkout> config --type=bool --get commit.gpgsign prints it), the squash commit of a Merge or merge_to_main is signed on the merging user's Mac, so a key that never leaves that Mac's Secure Enclave and needs a fingerprint works. SquashCommit (internal/git/git.go) makes the unsigned commit with git commit-tree, reads its object with git cat-file commit, and hands those bytes to the signer (internal/agents/sign.go). The signer sends op: sign with the bytes in data, ident (Name <email> from the user's git identity) and timeout_ms 120000 to the user's most recently active connected client, the same order list_clients uses, and fails the merge when the user has none connected. The app writes the bytes to a temporary file and runs a script under /bin/zsh -l in the home directory that reads that Mac's own git config: gpg.format (default openpgp), its program (gpg.openpgp.program or gpg.program, default gpg, for openpgp; gpg.x509.program, default gpgsm, for x509; any other format fails), and user.signingkey, or ident when unset. It runs the program as git does, <program> --status-fd=2 -bsau <key> with the commit on stdin, with the signature written to a second temporary file, since login shell startup files can print to stdout. The reply carries the exit status, the program's stderr in output and the signature in data. byre fails the merge unless the status is 0 and output has a [GNUPG:] SIG_CREATED line, as git requires, and otherwise inserts the signature as a gpgsig header before the commit's blank line, each line after the first indented by one space, and writes the signed object with git hash-object -t commit -w --stdin. The merge waits up to 2 minutes 30 seconds for the reply, and a Touch ID prompt the human doesn't answer within the 2 minutes fails it. The notes commit is not signed.

byre signing <checkout> (cmd/byre/signing.go) turns it on for a project, and agents learn it from the signing knowledge-base article (see "Knowledge base"). It sets commit.gpgsign true in the checkout's git config, and false for its task worktrees, which share that config and would otherwise try to sign their end-of-turn and agents' own commits on the server, and fail. It writes commit.gpgsign false to unsigned.gitconfig in the checkout's git common directory (git rev-parse --path-format=absolute --git-common-dir, which resolves symlinks, as git's gitdir: matching does) and includes it with includeIf.gitdir:<common dir>/worktrees/**.path, which git config appends after commit.gpgsign so it wins. It prints both values, and fails unless the checkout's reads true. It refuses a checkout that is itself a linked worktree, since the include would turn signing off for it too. extensions.worktreeConfig wouldn't do it, since git worktree add copies the checkout's config.worktree into each new worktree, nor would the server's global config, which is read before the checkout's. The client Mac needs git's own signing config, the same that signs the user's local commits: gpg.format (openpgp or x509; ssh is not supported), the program when it isn't gpg/gpgsm, and user.signingkey when the program can't find the key from ident. The program must print [GNUPG:] SIG_CREATED on its status fd. The signature is made for the user's git identity in byre, not the Mac's user.email.

No restart is needed; the next Merge or merge_to_main reads the checkout's config. It needs the merging user's app connected on a configured Mac, and the squash commit then carries a gpgsig header (git -C <checkout> cat-file commit HEAD shows it; the server may have no program to verify it with).

log show --info --last 10m --predicate 'subsystem == "run.fc.byre.mac"' on a client shows client request <id>: <op> lines and byre-hands starts and exits, which are info level and kept only in memory, so they cover the last few minutes at most; connection drops (client connection closed, client connection got no pong) are notices, which macOS keeps on disk, so log show --start reaches much further back for them; byre's log shows client connected and client disconnected.

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. 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"). Clicking a diff line (added, removed or context) opens a comment box under it and marks the line with an accent bar; clicking the line again closes an empty box, and remove drops the comment. Pending comments are drafts (see "Drafts" below), kept per task and pruned when it closes. Once any has text, the header shows Send N comments, which posts them as one ordinary message to the task's live session, or starts a session when there is none (Store.messageTask, also used by a review finding's Fix), in the order they were made, each as `path:line` (the new file's line; a removed line gives the old file's line and adds "(removed line)"), the diff line quoted with its +, - or space, and the comment, separated by blank lines (commentsMessage in mac/Sources/Byre/Changes.swift). Empty boxes stay open; a failed send puts the comments back.

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. A file whose name ends in .png, .jpg, .jpeg, .gif, .webp or .svg is shown as an image, fetched whole from GET /api/tasks/{taskId}/image (see "Screenshots from implementers") again on each refetch, fitted to the pane width and opening full size on click. A file whose name ends in .md is rendered as Markdown with the transcript's renderer (MarkdownView) instead, with relative image paths taken from the file's own directory. Where you are is kept per task in memory (Store.filePlaces), so switching tasks or tabs returns to it, but a relaunch 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.

Markdown rendered with a task (its transcript, questions, reviews, Markdown files in Files) links the worktree paths it mentions to Files (InlineRenderer in mac/Sources/Byre/MarkdownView.swift): a relative Markdown link ([x](docs/a.md), from the file's own directory inside Files), a code span holding a path with a / that ends in a file name with an extension or in / (`mac/Sources/`), and the same kind of path in plain text, file names only. An absolute path counts when it starts with the task's worktree. A :line, :line:col or #L12 suffix is dropped. The link is byre-app://file?task=&path=, which sets the task's place in Files to that file (or directory, for a trailing /) and focuses the task's Files tab. Nothing checks that the path exists; one that doesn't opens Files on its 404.

Terminals

Each active task can have one terminal: a login shell ($SHELL -l, from byre's environment) on a PTY (github.com/creack/pty), started in the task's worktree with byre's environment plus TERM=xterm-256color, COLORTERM=truecolor and the task's PORT (internal/terminal, which the server creates and hands agents.New). GET /api/tasks/{taskId}/terminal?cols=&rows= is a WebSocket (github.com/coder/websocket, internal/server/terminal.go), the only WebSocket in byre: it attaches to the task's terminal, starting the shell at that size if none is running and resizing it to that size if one is, and works only while the task is active (409 otherwise). The server first sends the last terminalBacklog bytes the terminal printed, then its output as it comes, as binary messages; the client sends keystrokes as binary messages and a TerminalSize JSON text message on every resize. Any number of sockets can attach to one terminal: they all get its output and all type into it, and the last resize wins. A socket that falls terminalClientBuf reads behind is closed and the app reconnects. When the shell exits, every socket is closed with code 4000 and the exit status (exit status 0, signal: hangup) as the reason, and the next attach starts a new shell. Merging or discarding the task, and stopping byre, sends the shell's process group SIGHUP and closes the PTY, so terminals don't survive a restart. The WebSocket handshake checks that Origin matches Host, so another site can't open one through the user's Tailscale identity. The log records terminal started and terminal ended with the task, pid and exit status.

A task's agents use the same terminal through two MCP tools (internal/agents/terminal.go). terminal_read returns the backlog as plain text; it starts no shell, and says so when none is running. terminal_type writes text to the PTY as keystrokes, starting the shell at 120×40 if none is running and leaving a running one's size alone (refused once the task is closed). Within a shell's first 10s, it first waits for the prompt: until the shell has printed something and then gone 500ms without output (Terminal.Activity gives the start and last output times), or until 10s after the start. Typing before that would reach the PTY while it still echoes in cooked mode, before zsh's line editor takes over, so the text would show twice. The startup output, prompt included, is part of the reply. It then returns, as plain text, the output that followed, once none has arrived for 500ms or after 10s, whichever comes first, holding at most the last 512 KiB of raw output while it waits. It types on its own goroutine one byte per write and stops between bytes once the call returns. A program in raw mode that stops reading blocks a PTY write, and the PTY's file doesn't support deadlines, so typing one byte at a time keeps the call within its limits while leaving at most one byte to arrive late. The reply then says how many bytes were typed. Typing from an agent doesn't count as the human acting (last_action_at). The plain text comes from terminal.Text (internal/terminal/text.go). It replays the output one line at a time, keeping track of the cursor column, so a carriage return overwrites the line (progress bars, zsh's \r\r\n after a command) instead of duplicating it, and a backspace moves the cursor back. It applies erase-to-end-of-line (CSI K) and drops every other escape sequence and control character. It keeps the last 64 KiB, cut at a line start. Full-screen programs come out garbled, since it isn't a screen emulator.

A terminal_type whose text doesn't end in a newline (\n or \r) leaves a line for the human to run, and wakes its agent when the shell next returns to its prompt. A zsh terminal gets shell integration for this: terminal.New writes a .zshenv (the zshenv constant in internal/terminal/terminal.go) into <data>/zsh, and the shell starts with ZDOTDIR pointing there and the user's own ZDOTDIR (empty when unset) in BYRE_ZDOTDIR. That .zshenv puts ZDOTDIR back (unsetting it when it was empty), sources the user's .zshenv, and in an interactive shell appends a precmd hook that prints ESC ] 133 ; D ; <exit status> BEL (the OSC 133 "command finished" mark) before every prompt; the rest of the user's startup files then load from their own ZDOTDIR as usual. Terminals ignore OSC sequences they don't know, and terminal.Text drops them, so nothing shows. Other shells get no integration (Terminal.Prompts is false), and nothing wakes their agents. The reply to such a terminal_type ends with a line saying which: byre will message the agent, or, in another shell, the agent must ask the human to say when they have run it. Once all of the text is typed, Terminal.WakeOnPrompt arms a one-shot wake that collects the output from then on and fires at the first mark; every terminal_type first cancels any armed wake (CancelWake), so the agent pressing Enter itself, or typing more, doesn't wake it. A zsh line editor runs precmd after Enter on an empty line and after Ctrl-C too (exit status 130), so either wakes the agent. On the mark, byre sends that agent's session a byre message (Manager.wakeOnPrompt, through Manager.Message, so it starts a turn when idle, reaches it mid-turn, and is held while the human has interrupted the agent) with the exit status and the output as plain text. The log records woke agent on terminal prompt with the session, task and exit status.

For commands that don't need the human to see them, agents have the bash MCP tool (internal/agents/bash.go), for agents whose own shell tool runs in a sandbox that gets in the way. byre runs $SHELL -l -c <command> itself, as the service user and outside the agent's sandbox, in the task's worktree with byre's environment plus the task's PORT, stdin empty, stdout and stderr into one buffer, in its own process group. The group gets SIGKILL after timeout_ms (default 120000, at most 600000) or when the call is cancelled (in a Claude session, an MCP call's context ends when Claude sends control_cancel_request for it, as on an interrupt, or when the session's output ends), and the reply says killed after <timeout>; otherwise it starts with the exit status (exit status 1, signal: killed). The output is the last 64 KiB, as raw text. Background children holding the output open are waited for at most 5s after the command exits, and the reply then says the output was cut off.

The Terminal tab (mac/Sources/Byre/Terminal.swift) draws it with SwiftTerm's TerminalView. The terminal uses the UI font and is coloured from the palette (background, text, accent cursor, accent-tint selection); ANSI colours are SwiftTerm's defaults. The colours are set once per light/dark scheme (TerminalSession.scheme), never on other SwiftUI updates, since each colour set makes SwiftTerm redraw the whole screen and the tab re-renders on every task change in the store. It fits the pane and resizes with it. Opening the tab, or selecting another task while it is open, opens one socket for the selected task; switching tab or task closes it, and the shell keeps running, so coming back replays its recent output. A socket that drops for any other reason reconnects after 2s while the task is active; after a byre restart that starts a new shell. When the shell exits the header shows shell ended: <status> and a New shell button; when it can't be started (the socket closes with 1011, the error in byre's log as start terminal), the header says so, with the same button to try again. Ctrl key combinations typed in the terminal go to the shell, not to byre's Ctrl-K, Ctrl-P, Ctrl-L and Ctrl-S shortcuts; ⌘K, ⌘P, ⌘L and ⌘S still work.

Snapshots

byre snapshots each task's worktree at the end of every turn that has no subagents running (internal/agents/snapshots.go, internal/git/snapshot.go), so a snapshot always sits on a turn boundary, the only place a Codex conversation can be forked. 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 when the snapshot started, at or after the turn's turn.ended, 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.

The trigger is turn_end: the snapshot starts after byre's end-of-turn commit and the turn.ended event. A turn that ends while the session's subagent count (see "Subagent count") is above zero takes no snapshot and requests no review, since its work is half done; the CLI starts another turn when each background subagent finishes, and the first of those to end with none running takes the snapshot and its review. An interrupted turn likewise takes no snapshot and requests no review, so the next turn to end uninterrupted snapshots its work and has it reviewed even when that turn changed no files. The trigger answer is a snapshot taken just before answers from the human are handed to the agent (see "Inbox"); it is taken even when its tree is unchanged, is never reviewed (its transcript row has no review button), and doesn't wait in the queue below but runs at once under the task's mutex. Snapshots taken by earlier builds also carry message (mid-turn, when the agent wrote text after tool calls), tool_call, ask_human and timer. A snapshot whose tree equals the task's latest snapshot's tree is skipped, so a turn that changed no files leaves none. 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. Failures are logged and dropped.

The take_snapshot MCP tool asks for a snapshot now (RequestSnapshot and takeSnapshotTool in internal/agents/snapshots.go): a task's agent calls it with no arguments for its own task, and the lead with a task_id. The snapshot has trigger requested, is taken even when its tree equals the latest snapshot's (so it retries a failed review of unchanged work), and is reviewed like any other (see "Reviews"); it counts as a review request (BeginReviewRequest) from the call until the reviewer starts. It is attributed to the task's latest implementer session, authored as that session's creator, and can be taken mid-turn, uncommitted work included. A requested snapshot that is replaced while waiting passes its trigger and its taken-even-when-unchanged to the one that replaces it. Refused for a closed task, while the task is merging or being discarded, and for a task with no agent session.

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 (for snapshot 1, the same merge-base: the snapshot commit's parent is the worktree's HEAD, which already holds whatever byre committed at the end of the turn). 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 mac/Sources/Byre/Store.swift). A snapshot is a point between the agent's events, not part of one, so each gets its own row in the transcript, a divider reading snapshot #n (its trigger and time in the tooltip), placed where its snapshot.created event falls in the task's event order: just after the turn end it belongs to (after the triggering text for an old message snapshot). A snapshot row ends the current group of tool calls, so calls after it start a new group. The row's changes button pins the snapshot (Store.pinned, cleared when the selected task changes) and switches the right pane to the Changes tab; its fork button forks it (see "Forking") and points the right pane at the new task; its review button scrolls the transcript to that snapshot's review, and is disabled until the review has finished; while it runs, a stop button sits beside it. 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.

Forking

POST /api/tasks/{taskId}/snapshots/{n}/fork (the fork button on a snapshot's transcript row) starts a new task from a snapshot (internal/agents/fork.go). Its branch and worktree start from the snapshot's commit, so the fork's worktree holds exactly the snapshot's tree, uncommitted work of the time included, as the branch's first commit. The task is titled <title> (fork of #n), keeps the source's merge_title and todo_item, and records forked_from (the source task) and forked_at (n) in the tasks columns of those names. It gets an idle session, created by the requesting user (who needs a git identity), on the snapshot session's model, with forked_from set to that session's vendor_session_id and forked_at to the cut point below. Nothing runs until the first message is sent to it; that turn starts Claude Code with --resume=<forked_from> --resume-session-at=<forked_at> --fork-session --session-id=<new id>, or Codex with thread/fork (threadId forked_from, lastTurnId forked_at) whose new thread id byre then stores, so the new conversation is the source's up to the cut, under its own id, and later starts resume the new id as usual. A forked Codex thread keeps the source's byre tools. Refused (409) while the source task is closed (its snapshot refs are gone), when there is no cut point, and when the snapshot's session runs on Antigravity, which can't fork a conversation; 404 for an unknown snapshot.

The cut point comes from vendor_uuid: every top-level agent.message, agent.thinking, agent.tool_call and agent.tool_result event (not a subagent's) carries the uuid of the Claude Code stream-json line it came from, which is the id of that entry in the CLI's own session transcript. forkPoint takes the last such event of the snapshot's session at or before the snapshot's event_seq, then extends the cut through the following tool calls and results until every tool call it has kept has its result, so the fork never resumes on an unanswered tool call. When the events run out with calls still open, as when the agent forks from inside its own fork call, the cut goes back to just before the message holding the first of them (every block of one Claude message carries the same vendor_uuid). For a turn_end snapshot the cut is the turn's last message. A Codex session's events carry no vendor_uuid except its turn.ended, which carries the turn id, so for Codex forkPoint picks the last turn that ended at or before the snapshot. Snapshots whose session recorded no vendor_uuid by then, such as those from before byre recorded it, can't be forked.

A fork of a workflow stage task gets a copy of the source's frozen workflow, brief, run and stage, not the library's current workflow, so the fork is another attempt at that stage of the same run in its own worktree; when it finishes, the stages after it start from it unless they already have tasks (see "Workflows").

The fork's session guidance gets one more line (forkGuidance in internal/agents/guidance.go) naming the source task, the snapshot, the fork's branch and worktree and the source's worktree, telling the agent that everything before the fork happened in the other worktree and to work only in its own. Claude Code can reuse the system prompt recorded for the source conversation on a fork, the source's cwd included, so the UserPromptSubmit hook's copy is what reliably tells it. The fork's transcript starts with a note from its task.created event, "forked this task from at snapshot #n", linking the source while it is active; the lead's signal for it reads " was forked by from at snapshot n", and list_tasks notes the source. The fork's transcript shows only its own events, not the source's history.

An answered question can be answered again. Expanding an answered question's bubble in the transcript shows an Answer again button when the question is answered (the transcript's copy of the question comes from its inbox.* events, which carry no snapshot_n, so the server is what checks for one); it reopens the card's choices, Other box and "Let the agent decide", with Cancel in place of Ignore, and sending posts the new answer, an InboxResponseCreate, to POST /api/inbox/answer-again (AnswerAgain in internal/agents/fork.go) instead of respondInbox. byre checks the answer against the question as if it were open, forks the question's task at the answer's snapshot_n exactly as the fork button does, and files in the fork a copy of the question (same payload, its own id and thread) already answered with the new response, in one transaction (Store.CreateAnsweredItem), whose inbox.created event carries the answered item and the human as actor. Deliver then hands that answer to the fork's idle session as its first turn, the usual delivery message, so the forked conversation, cut just before the original answer reached the agent, continues with the new one. The app opens the fork; the original task and its question are untouched. An inbox.created for an item that isn't open raises no notification, and the lead's signal for it reads as an answer, not a question. Refused (409) when the question isn't answered or its answer has no snapshot_n, and for anything the fork refuses; 400 for an answer that doesn't fit the question.

An implementer on Claude Code forks its own task with the fork MCP tool (forkTool in internal/agents/fork.go), title and prompt, for when two ways forward are both worth finishing; its description asks the agent to fork only when it strongly believes both are worth following. byre snapshots the worktree there and then (trigger fork, synchronously under the task's mutex like an answer snapshot, and like one never reviewed), forks the task at that snapshot as the fork button does but titled title, as the session's creator, and sends prompt to the fork's idle session as its first message, actor agent. Codex and Antigravity sessions don't get it, since neither can cut a conversation mid-turn. The fork's conversation is cut just before the fork call, so its agent never sees the call and takes the prompt's path; the calling agent gets the fork's id, branch and worktree, and carries on with its own.

Reviews

Every new snapshot of a task gets a review: a separate agent session on the vendor the task's agent doesn't use reads the change and reports on it (internal/agents/review.go, internal/store/review.go). A turn that changes no files makes no snapshot and so no review, an interrupted turn makes no snapshot (see "Snapshots"), and a snapshot whose change (the diff below) has no files runs no reviewer: it gets a reviews row in state skipped, with no session, vendor or model. The snapshot at the end of a Merge turn that finishes into the squash (the turn ended cleanly with merging_by still set, read by turnEnded before it asks for the snapshot) gets no review and no reviews row at all, since the task closes a moment later; a Merge turn that errors or calls stop_merge is reviewed as usual. Besides take_snapshot (see "Snapshots"), nothing else starts a review, and nothing waits for one: reviews ride along for the human to look at when they want.

  • Starting. After takeSnapshot creates a snapshot, afterSnapshot runs startReviewer: it picks the model from the task creator's users.review_model and users.second_review_model (reviewerModel): the second review model when the task's latest implementer session runs exactly the review model, the review model otherwise, and the vendor follows from the model. They are returned as review_model and second_review_model on GET /api/me, set together with PUT /api/me/review-models from the Settings tab's Review models menus, and record user.updated. A model whose vendor's CLI isn't on the service's PATH fails the review with the start error. It then checks the snapshot's commit out detached at worktrees/<project-id>/review-<review-id>, creates an agent_sessions row with role reviewer on the task (created by the task's creator), a reviews row (running, one per snapshot), and sends reviewPrompt as its first turn (actor byre): all of the task's work as git diff from the merge-base of the snapshot and the default branch, with its changed files, and the task's first message to its agent. Every review covers the whole task against the default branch, not only what changed since the previous snapshot, and a snapshot with no changes against it is skipped.
  • Running. Reviewer sessions get reviewGuidance and only the submit_review tool, whose arguments (reviewSchema: change, a few sentences of Markdown on what the change does, required and non-empty; a Markdown summary, which the guidance and schema require to be exactly "No significant findings." when there are no findings and otherwise a bulleted list with one line per finding; findings, each with severity, category, title, detail, path, line, end_line and images; and top-level images) become the review's result and mark it done. The guidance encourages, without requiring, Mermaid blocks for a data flow, call graph, state machine or schema the change alters, and screenshots when the change alters something visible.
  • Forwarding. When a submitted review has findings, byre sends them to the task's live agent as a byre message (forwardFindings and findingsMessage in internal/agents/review.go): each finding's number, severity, title, location and detail, under a preamble asking the agent to check each one against the code and its brief and, before changing anything, answer each with respond_to_finding (see "Agent responses"). It goes through Manager.Message, so it is queued like any message while the agent is mid-turn. A task with no live agent session gets nothing, and the log says review findings not forwarded. Forwarding doesn't set any finding's state; fix and ignore stay available until the agent or the user answers.
  • Screenshots. Reviewer sessions also get the Playwright MCP server (playwrightMCP in internal/agents/review.go: npx @playwright/mcp at a pinned version, headless Chrome, isolated profile, absolute file paths), passed to Claude as a stdio entry in --mcp-config and to Codex as mcp_servers in the thread's config. Its automatically named files go to worktrees/<project-id>/review-<review-id>-media, removed with the checkout. Each images entry is {path, caption}, the path absolute or relative to the checkout; at submit_review byre reads every one, refuses the whole submission (nothing recorded) for a missing file or one that isn't one of store.ImageTypes within store.MaxBlobBytes, then stores each as a blob and replaces its path with blob, since the files are deleted with the checkout. They run without the Stop-hook bounce, mid-turn delivery, end-of-turn commit, snapshots or turn summaries, and their user.message events carry no head. When the reviewer's turn ends byre completes its session, kills its process and removes the worktree; a review still running then fails with why (no submission, with the final message; an error; an interrupt). A process that exits mid-turn fails the review the same way. StopReview ends the reviewer the same way and fails the review; Watch calls it for a reviewer whose process the health check finds stuck (no stream event for StuckAfter), which is how a reviewer whose connection died while the machine slept gets cleared. Reviews are of all the task's work, so a newer one makes older ones moot: once a snapshot's review starts, or is skipped for having no diff, byre fails the task's running reviews of earlier snapshots ("superseded by snapshot N") and ends their reviewers (failRunningReviews). Each snapshot's review starts on its own goroutine under reviewMu, so they can run out of order; afterSnapshot starts no review for a snapshot when the task already has a review of a later one. Merging or discarding the task fails its running reviews ("the task was closed") and ends their reviewers. On startup, a running reviewer resumes like any session, and a running review whose reviewer isn't running fails.
  • Events. review.started, review.finished (state done, failed or skipped; a skipped review emits only review.finished) and review.finding (a finding's state set, with the user as actor) carry the whole review, with the task's ids and no agent_session_id. A reviewer's own events carry its session id and the task's ids: the transcript skips every event of a session whose session.created has role reviewer, and draws each review.finished as a note with its outcome; the lead's read_transcript leaves the reviewer's events out and shows each review.finished with its summary and findings (reviewLine). GET /api/tasks/{taskId}/sessions and everything that looks for the task's agent (ListSessions) see only implementer sessions.
  • UI. Reviews live in the task's transcript (renderReview in mac/Sources/Byre/TranscriptBuilder.swift, rows in mac/Sources/Byre/Reviews.swift). A finished review is a note at its review.finished event, "review of snapshot #n:" with its state or severity counts or error; with findings, the note carries the summary and screenshots, and each finding follows as its own row, numbered from 1 as in the message to the agent. The change field is not shown; it goes to the lead and into notes. A finding with path and line shows, under its title, the lines line to end_line plus 3 either side from that file's diff for the snapshot against the default branch (changes?snapshot=<n>, the diff the reviewer was given), with new-file line numbers and the finding's own lines marked; it shows nothing when those lines aren't in the diff, and "diff not available" with the error once the task is closed. Clicking a screenshot opens it full size. A finding with no answer from the user or the agent has fix and ignore buttons. fix sends the finding's number, severity, title, location and detail to the task's agent as a message from the user: through POST /api/sessions/{id}/messages to its latest live session (queued like any message while the agent is mid-turn, see "Agent sessions"), or starting a session when it has none; the view stays where it is, so several findings can be sent in a row. Then it, or ignore on its own, records the finding's state (fixed or ignored) with PUT /api/reviews/{reviewId}/findings/{index}, the index being the finding's position in result.findings; the review's finding_states (the reviews.finding_states column, a JSON object from index to state) holds them, and the buttons give way to "fix sent" or "ignored". Only a done review's findings take a state; the user can't clear one, and only the agent's answer clears a fixed one (below).
  • Agent responses. The message forwarding findings to the agent numbers them and tells it to answer each with the task agent's respond_to_finding MCP tool (respondToFindingTool in internal/agents/review.go) before changing anything: snapshot, finding (from 1), response (fixing or ignoring) and a one-line reason. byre records it in the review's finding_responses (the reviews.finding_responses column, a JSON object from index to {response, reason}; a later call for the same finding replaces it) and emits review.finding with actor agent. The answer also clears the finding's fixed state, since it answers that fix request: an ignore after the user's fix brings fix back so the user can overrule it again. A user's ignored stays. A call naming a snapshot with no done review, or a finding it doesn't have, is refused. On the finding, an answer shows as "agent fixing: …" or "agent ignoring: …" and dims the finding's diff, detail and screenshots. Fixing takes away both buttons; ignoring keeps fix, so the user can overrule it, and drops ignore. The tool call itself is its own transcript row (not folded into a group of tool calls): what the agent answered for which finding, its title and reason, a show button that scrolls to the finding, and, for an ignore, the same fix button while the user hasn't answered the finding. A call that fails is hidden. The Merge confirmation's review warning counts findings the user hasn't ignored, whatever the agent answered.
  • Snapshot row. Each snapshot row's review button shows that snapshot's latest review.* event: a border in the task's colour while the review runs, and otherwise a mark over its top-right corner: a grey ● when it was skipped, a green ✓ when it finished with no open findings (none, or each answered by the user or the agent), and, when it has open findings, the button highlighted with their count in a badge. A failed review leaves the button unmarked. While any of a task's reviews is running and its agent is idle, the task's state is reviewing (see "Task state in the store"), which the task switcher and the Project tab show as Reviewing (see "Task state"). GET /api/tasks/{taskId}/reviews lists a task's reviews oldest first. sqlite3 byre.db "SELECT id, task_id, snapshot_n, vendor, state, error FROM reviews ORDER BY created_at DESC LIMIT 20" shows recent ones.

Workflows

A workflow is a named graph of stages, each a stage name, the stages it comes after, where its branch starts, a model and the stage agent's prompt written out in full. Every stage runs as its own task, once all the stages it comes after have finished, so one stage's output can go to several stages (fan-out) and one stage can wait for several (fan-in). Workflows live in byre's database, in the workflows table (name, and the definition's JSON as definition), shared by every project and never in a repo. byre stores, validates, exports, imports and freezes them, and runs each run's frozen copy stage by stage (internal/agents/workflows.go; the graph helpers After, Next, Starts and FromMain are in internal/workflow/workflow.go). The human starts one from the task switcher (see "Starting"), or from the left pane's Workflows tab, which lists them and hands a run to the lead (see "UI"); there is no UI for managing them: the lead does that with its tools, and draws a workflow for the human as a Mermaid flowchart of its stage names, which its chat renders (see "Inbox"). A run's progress is shown read-only in the stage tasks' Workflow tab (see "Progress" below).

  • Names. The name is the workflow's key and is shown to the human on buttons and pickers, so it is free text: one line of 1 to 100 characters, trimmed (workflow.CleanName). It isn't part of the definition, so the file doesn't carry it. In URLs it is a path-escaped segment.
  • File. One workflow per file: the WorkflowDefinition (api/openapi.yaml) as YAML, an optional description and its stages. A stage's after lists the stages whose outputs it gets; absent, it means the previous stage in the list (none for the first), so a file without after is a chain; [] means the stage starts from the brief. Its base is parent (the default: the commit its one after stage finished at, or for a stage with none, where the run was started from) or main (the project's default branch); a stage with more than one after stage always starts from the default branch. In place of model, a stage may list agents (each name, model and prompt) to run as a team in its one task (see "Teams"): each agent gets the stage's message and then its own prompt, and the first agent calls finish_stage. workflow.Format writes it with multi-line prompts as literal blocks; workflow.Parse reads YAML or JSON and refuses unknown fields. The download is named after the workflow with /\:*?"<>| replaced by _ (workflow.Filename).
  • Validation. workflow.Validate refuses a definition (400, everything wrong listed) with no stages, a stage name that isn't a valid one-line name or repeats another stage's, an empty model without agents or a model with them, agents that fail workflow.TeamProblems, an empty prompt, a base other than parent or main, a base on a stage with more than one after stage, an after naming the stage itself, a name twice or a name that isn't a stage, a cycle, or anything but exactly one final stage (one no other stage comes after). A stage's vendor follows from its model (workflow.VendorOf, the same rule sessions use: gpt-* is Codex, gemini-* Antigravity, everything else Claude Code); a file with a vendor key is refused as an unknown field.
  • API. GET /api/workflows lists every workflow's name and description. GET /api/workflows/{name} downloads its file; PUT takes a file as the body and creates or replaces the workflow; DELETE removes it. Each save or delete records a workflow.updated or workflow.deleted event carrying the name. sqlite3 byre.db 'SELECT name FROM workflows' lists them. Saves and reads go through the Manager (internal/agents/workflows.go) so the HTTP API and the lead's tools share them.
  • Lead tools. list_workflows, read_workflow (name, or task_id for a task's frozen copy), save_workflow (name and definition, the file as YAML) and delete_workflow, with the same checks and errors as the API. Task agents don't get them.
  • Starting. The lead's create_task and start_todo take an optional workflow (404 when there is none of that name) and then start a run of it instead of an agent on the prompt (Manager.StartWorkflow); model together with workflow is refused, since each stage names its own. POST /api/projects/{id}/tasks takes the same optional workflow, with the title as the run's brief, and returns the run's first stage task. The task switcher (mac/Sources/Byre/Switcher.swift) offers it as a "Start workflow…" row after each "New task in …" and "Start in …" pair: picking that lists GET /api/workflows, filtered by what is typed next (Esc goes back to the task list), and picking a workflow starts the run with the typed title and opens its first stage task. A run gets a new id and creates a task for every stage with no after stages, branched from the default branch, or for a stage with base: parent, from the lead's from_task_id HEAD when given. Each stage task copies the workflow's name and definition as they are when the run starts into tasks.workflow_name and tasks.workflow_definition, so later saves or deletes don't change it; its brief into tasks.workflow_brief (the lead's create_task prompt, optional with a workflow and may be empty, or for start_todo the TODO item as todoPrompt words it); the run's title into tasks.workflow_title; the run id into tasks.workflow_run; and its stage's name into tasks.workflow_stage. Stage tasks are titled <title>: <stage>, with the run's title as their merge title, and carry the run's TODO item. The Task's workflow, workflow_run and workflow_stage are those; GET /api/tasks/{id}/workflow returns the copy as {name, definition} with the definition as YAML (404 for a task without one). A task agent's create_subtask, POST /api/projects/{id}/tasks without workflow and the Project tab's Start create tasks without one. A fork of a stage task copies its frozen workflow, brief, title, run and stage, so it is another attempt at the same stage of the same run. sqlite3 byre.db "SELECT id, status, workflow_stage, stage_output != '' FROM tasks WHERE workflow_run = '<run>'" lists a run's tasks and which have finished.
  • Stages. A stage task's agent is an implementer session on the stage's model (for a team stage, one per agent, see "Teams"), created by the user who started the run; any later session of the task (a Compact handoff's, one started by a message) runs the same stage, since the stage belongs to the task. Starting a stage records a task.workflow_stage event on its task (workflow, run, stage the name, after the ids of the tasks whose outputs it gets; actor the lead for the stages a run starts with, byre after) and sends the session its first message, actor byre, so the transcript shows it collapsed under its first line: Workflow "<name>", stage "<stage>", then the stage's prompt, a ## Brief section when the brief isn't empty, a ## From stage "<stage>" (task <id>, branch <branch>) section with each after stage's output, and a line saying where the task starts: from the default branch, with the branches above to git merge or read as it needs, or from the commit its one after stage finished at. Nothing else of earlier stages is passed on; their branches carry their work.
  • Advancing. A stage session gets the finish_stage tool (output, required) and a guidance line naming the workflow, its stage and the stages after it. The call stores the output in agent_sessions.stage_output, replacing any earlier one; it is refused once the task's stage has finished. When a turn of that session ends normally (not an error or interrupt, not a handoff, not a Merge turn that squashes) with an output stored, byre sets agent_sessions.stage_finished before marking the session idle, and then advances (nextStage): it records the output and the worktree's HEAD in tasks.stage_output and tasks.stage_commit, then, holding workflowMu so two stages finishing at once can't both start the one after them, goes through each stage after this one. A stage starts once every stage it comes after has a finished task in the run (the latest one, when a fork made several); its task branches from that task's stage_commit for base: parent with one after stage, else from the default branch. Once every stage after it has been dealt with, byre clears stage_output and stage_finished on the session. The session stays idle in its task for more messages, and queued messages and held answers are delivered to it once advancing is done. When the final stage finishes, byre starts discarding every other active task of the run (as byre, through the same Discard as the button, so each gets its summary turn first and closes when that turn ends; one already merging or being discarded is skipped and logged as stage task not discarded), then records task.workflow_finished (workflow, output) on the final task and clears the flags in the same transaction, leaving the final task for the human to merge. A turn that ends without the call leaves the task in its stage. The output belongs to the turn that stored it: when that turn ends any other way (an error, an interrupt, a handoff, a Merge turn that squashes, an agent process that exits mid-turn, or a session that can't be resumed after a restart), byre clears stage_output before marking the session idle, so a later turn can't hand off a stale output; the agent calls finish_stage again. A turn cut off by a byre restart and resumed keeps it. If a stage can't start, byre logs advance workflow stage and records the error in the finishing task's transcript, and the flag stays set. On startup, before resuming running sessions, recoverStages advances every session with stage_finished set, skipping the steps already done: the recorded output isn't overwritten; a stage that already has a task in the run isn't created again, but when that task is active and has no session, its sessions are started, and each of its sessions that never got a message (SessionStarted) is sent the stage's first message (a team member's with its own prompt), and the restart notice isn't sent to them. sqlite3 byre.db 'SELECT id, task_id FROM agent_sessions WHERE stage_finished = 1' lists stages waiting to advance. list_tasks shows each stage task's workflow and stage "<name>", with (finished) once its stage has finished and workflow finished once any task of the run has a task.workflow_finished event. Nothing reads or writes agent_sessions.workflow_stage; it stays in the schema so builds from older branches keep working against the same database.
  • Progress. GET /api/tasks/{id}/workflow/run (Manager.WorkflowRun) returns the run of a stage task (404 for a task without one): the definition that task froze (typed, not YAML), name, title, brief, finished (any task of the run has task.workflow_finished), and per stage in definition order its resolved after, waiting_on (the after stages with no finished attempt), its attempts and a state. An attempt is every task of the run at that stage, closed ones included, oldest first (the first and its forks), with its output and commit once tasks.stage_output is set, forked_from, discarded_by (the task.discarded actor) and inputs: the tasks named in the after of its latest task.workflow_stage event, each with its stage, so it says which attempt fed it; a fork has no such event and takes its source's. An attempt's state is merged, cleaned_up (discarded after finishing; byre as discarded_by is the final stage's cleanup), discarded (before finishing), finished, running (a session is running) or idle. A stage is waiting with no attempts, else it takes the state of its latest finished attempt, or its latest attempt when none finished. The Workflow tab (mac/Sources/Byre/Workflow.swift) draws the graph natively with the flowchart layout from Diagram.swift, each stage's node coloured by its state (stateColors, from the palette's tint and accent pairs: waiting grey, running accent, idle warn, finished, merged and cleaned up ok, discarded danger) and redrawn whenever a refetch changes a state; then a card per stage with its state, its attempts linking to their tasks, each attempt's inputs ("started from") and the attempts it fed, and its output collapsed as Markdown. It refetches on any stored task.* or session.* event of a task in the run, and on a task.created whose payload carries the run, so a new stage or fork shows up.

Events

GET /api/events is server-sent events: id is the event's seq, data the Event JSON. Reconnecting with Last-Event-ID 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, lead.note_saved and lead.note_deleted) goes only to that user's streams; the others never see it. Unstored events (seq 0, sent with Store.Broadcast: agent.delta, admin.health and admin.clients) 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. Once the replay is written, before anything live, the server sends an event: ready.

The macOS app keeps two streams (EventStream in mac/Sources/Byre/EventStream.swift). The global one (startGlobalStream in mac/Sources/Byre/Store.swift), opened after the last_event_seq of GET /api/admin/status, carries every event the user may see from the app's startup onward; handleEvent (Store.swift) drives the task, session, inbox and lead session lists, the right pane's focus and the Status tab from it, and hands each event with a task_id to that task's feed. The lead's (startLeadStream) first fetches the last 50 turns from GET /api/me/lead/events, retrying every 2s until it succeeds, then opens with ?lead=true and after the last seq of that page, replays what arrived since and stays live. There is no per-task stream: selecting a task opens nothing. Only the global stream drives the connection dot.

The app keeps every active task's events in memory, one Feed (mac/Sources/Byre/Feed.swift) per task (taskFeeds in Store.swift), so switching tasks makes no request. Each loadTasks creates a feed for any active task without one, which pages through GET /api/tasks/{taskId}/events (1000 at a time) for its history; selecting a task that has no feed (a closed one) does the same. Live events reach every feed through the global stream; a feed holds back what arrives while its history loads and drops the duplicates by seq. A task feed keeps unstored events (agent.delta) only while its history has loaded and its task is selected, and passes them straight to the transcript's streaming view without storing them; a stored event is drawn only from the feed, so a reconnect after a byre restart fills in what was missed through the global stream's replay. Feeds of closed tasks are dropped once they aren't selected. A failed history load is logged and leaves the feed waiting for its history; the next loadTasks or selection of that task loads it again into the same feed. A feed takes its history, then the live events held back during the load, skipping each event whose seq is at or below the last one it kept, so an event in both is kept once. The lead's feed holds back what its ?lead=true stream sends until that stream's first ready, then takes the fetched page and those events together. Either way the history reaches the transcript at once, so it draws in one pass, already scrolled to the bottom; until then the transcript shows "loading…". EventStream tracks the last seq it saw and opens a new stream from it when nothing (event or ping) has arrived for 40s, 2s after one fails (as it does when tailscale serve answers 502 while byre restarts), when the network comes back, and on the app becoming active if the stream is stale. While disconnected the left pane's tab strip shows a red dot and "reconnecting…"; partial output (agent.delta) sent while disconnected is lost, and the finished agent.message replaces it. A feed collects live events and flushes them on the next main-actor turn, so events arriving together are drawn in one batch.