# 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"), terminals (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/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//`, `worktrees//-media` (the task's media folder, see "Screenshots from implementers"), `leads/` (each lead agent's working directory, the login path-escaped), `blobs/` (uploaded images; the `blobs` table maps each id to its content type and its path relative to this directory). Task branches are `byre/` in the project's repo. Schema changes are the `migrations` slice in `internal/store/store.go`: named entries, each with a short kebab-case name saying what it does. On open, byre applies, in slice order, every entry whose name isn't in the `migrations` table, each in its own transaction that also records the name. Each runs with foreign keys off, so a migration can rebuild a table other tables reference, and `PRAGMA foreign_key_check` must come back empty before it commits. byre is deployed from task branches that each append their own migrations, so after a merge a migration can land before others the database has already applied, and it still runs. Give a new migration a name no other branch will use, and make any data migration safe to run twice. `sqlite3 byre.db 'SELECT * FROM migrations'` lists what has been applied. A database from before the table existed is moved over on open: the first `PRAGMA user_version` entries are recorded as applied, and `user_version` is no longer read or written. ## 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 the Reviews tab at that snapshot or the Inbox tab; 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. 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. `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 filed in the task's inbox 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 **Working** while 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**; `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 ` 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/`: 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/`, 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`, authored and committed by the merging user with no agent attribution, its body one `Co-authored-by: Name ` 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 :refs/heads/ :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/`), 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/` 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 files the error in the inbox and the task stays active; clicking Merge again fetches and goes round once more. A refused push leaves the checkout's default branch where it was, since the squash commit was never moved onto it. If the squash fails, for example because the default branch moved again during the turn, byre also files an error and the task stays active. 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 files an error in the inbox 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=`), 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 ` 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. 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 files the error in the task's inbox 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. `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). 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/` 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/ 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 ". ## Running as a service The listen address is `byre serve --listen`, whose default is 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`. ``` go install ./cmd/byre codesign --force --sign "Apple Development" --identifier run.fc.byre ~/bin/byre swift build --package-path hands --configuration release install hands/.build/release/byre-hands ~/bin/ cp deploy/run.fc.byre.plist ~/Library/LaunchAgents/ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/run.fc.byre.plist tailscale serve --bg --https=443 http:// ``` byre is served at the root of the machine's `ts.net` name on 443 (`https://..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. After rebuilding: step 1 of "Deploying" in `AGENTS.md`. Logs go to `~/Library/Logs/byre.log`. Clients reach `tailscale serve` over HTTP/2; it proxies to byre over HTTP/1.1 and passes SSE through unbuffered. ## 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, `byre-hands` built from `hands/` beside it in `Contents/MacOS` (signed on its own, as `run.fc.byre.hands`, before the bundle), `mac/Info.plist` with a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, 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`, then signs the bundle with the Mac's Apple Development certificate and `mac/byre.entitlements`. It prints the bundle's path. It needs Xcode selected (`xcode-select --print-path` prints `/Applications/Xcode.app/Contents/Developer`), `rsvg-convert` (Homebrew's `librsvg`), an Apple Development certificate for the team (`security find-identity -v -p codesigning` lists them) and a Mac registered in the profile (`security cms -D -i mac/byre.provisionprofile` prints `ProvisionedDevices`); `docs/macos.md`, "Stack", says how to regenerate the profile for a new Mac. 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.** The app runs from where it was built: `open mac/.build/byre.app` in the worktree that built it. Nothing copies it to `/Applications`. A new build replaces the bundle, so quit the running app first (`pkill -x Byre` quits every running copy, whichever worktree built it) and open the new one; step 4 of "Deploying" in `AGENTS.md` does this after a deploy. Every build shares the bundle identifier `run.fc.byre.mac` and the same store, so any build reconnects to the server the last one used. 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`): the server origin, 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 Development environment; the CloudKit console (icloud.developer.apple.com, the container, Development, 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 Change 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/` and installed in `~/bin`, which is on the service's `PATH`, and each session runs its own copy, so a hung or crashed one affects only that agent. 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-.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". `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), at which point a tree walk or `wait_for` stops with an error saying the app isn't answering instead of timing out on every element. **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 Apple Development certificate, which TCC keys grants on, so they carry over from build to build. `codesign -dv ~/bin/byre` shows the signature the running binary has. ## Screenshots from implementers Each implementer session gets a media folder, `-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 `capture`'s `save`), 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:`. Otherwise byre runs `tailscale serve --bg --https= http://127.0.0.1:` (`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://:/` 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= 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 Change Server… connects to its exposed URL. 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 .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:///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 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`). 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. 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`. 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`). 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`), 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 files a `session_error` in the task's inbox 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 two vendors, picked by the model it is created with (`workflow.VendorOf` in `internal/workflow/workflow.go`): `gpt-*` models run on Codex (`codex app-server`), 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 the other vendor's model. Both vendors sit behind `agentProcess` (`internal/agents/process.go`), and both 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`. 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 `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 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), and don't push, touch other branches or run git maintenance. 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`. The guidance names byre's tools vendor-neutrally ("byre's ask_human tool"): Claude sees them as `mcp__byre__`, Codex as `` in the namespace `byre` (Codex reserves the `mcp__` prefix). 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 has a sentence ending in `?` outside code spans, code blocks, quotes and blockquotes, the agent is told to ask through `ask_human` and carries on. For Claude this 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 as a `byre` `user.message` with `mid_turn: true` and starts another Codex turn with it, and only that turn's end ends byre's turn. 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: nothing is filed in the inbox, 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`, the inbox gets a `rate_limited` item carrying the vendor's status instead of a generic error. 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`. ## Status `GET /api/admin/status` reports `running_turns` (task sessions now `running`, as session, task and vendor; lead turns aren't listed); `rate_limits`, the latest status each vendor sent since byre started (Claude's `rate_limit_info`, with `windowMins` added to each `unifiedWindows` entry by `addClaudeWindowMins` in `internal/agents/manager.go` from the window's name, since Claude doesn't send a length; Codex's `account/rateLimits/updated` reshaped by `codexRateLimit` in `internal/agents/codex.go` into the same `status`, `rateLimitType` and `unifiedWindows` fields, `windowMins` from its `windowDurationMins`). Claude only reports during its turns; Codex is 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, and every Codex update broadcasts `admin.health`; `agent_processes`, `zombies`, `stuck_after_seconds` and `protocol_errors` (below); `clients`, one entry per distinct login, `host` and `commit` among the open `GET /api/events` streams, with the oldest stream's `connected_at` (the app opens two streams, so it shows once). `host` and `commit` come from the `Byre-Host` and `Byre-Commit` headers, which the macOS app sends on every request: the Mac's name (`Host.current().localizedName`, else `ProcessInfo.processInfo.hostName`, the name agents' client tools take, see "Client Macs") and the `ByreCommit` that `mac/bundle.sh` stamped into its `Info.plist`; they are empty for a client that sends neither. 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 login, host and commit: 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 hub's memory, so it is absent until that client's first action after byre starts. An action at least a second after the last one from that client that broadcast, and every `GET /api/events` stream opening or closing, 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). 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. - **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 rate-limit status with its reset time and, per window, the share used, the share of the window's time elapsed (from `resetsAt` and `windowMins`), and 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; 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`, `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`. 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 = '' 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. 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), and 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/Reply 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:` 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 with inbox items come first; 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 `VisitRecord`s 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 Inbox tab when it has open inbox items, otherwise on the Transcript tab, scrolled 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`), **Error** (an open `session_error` or `rate_limited` inbox item), **Working** (the task's `state` is `running`, `review_pending`, `reviewing` or `discarding`; see "Task state in the store"), **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 **Working** 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 while `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. Picking such a row (in the switcher, only when the task has no open inbox items) points the right pane at the task's Transcript and scrolls it to that turn's final message (`Store.jump`, applied by the transcript once the event is drawn). 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 the app is active (`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/` or `#/task//`. The lead's guidance tells it to link every task it names that way, using `#/task//inbox` when it points the human at the task's inbox, 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/` 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) 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, an error or rate limit), `inbox.updated` when a human answered a question or error (a question's 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_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. The human switching the right pane is not a signal. A signal caused by the lead itself (actor ` (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 ` (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, 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`, `other` or `dismiss` 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), `create_task` and `start_todo` (each with an optional `workflow` to freeze and run), `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`, 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 ` (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`, `tabs` (the tabs put first, in order) and `tab` (the selected one). 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 applies only its `tabs` order, whether or not the human is in the pane, so the human's selected tab stays selected. Otherwise, while the human is working in the right pane, 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 its selected tab is `inbox` and the task has no open items, 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 an `inbox.updated`, `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: has nothing left in its inbox." (or "is merging."/"is merged."/"is discarded."), even when the event itself makes no signal, such as a dismissed error; `inbox.updated` on a task that is no longer active adds none. The lead's guidance tells it to show the next thing that needs the human on that line, or, 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//`; 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///`, 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. It sends false on deactivating, Change 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, a notification, or the app's own switch to Inbox when a question arrives) 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 still applies its tab order. 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 half 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 Settings tab 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 three tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Status and Settings, and at the end of its tab strip the connection dot and the user's name. Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Status 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, Changes, Files, Terminal, Inbox, Reviews, Workflow and Project, the lead's `tabs` first. Reviews shows only once it has a `review.*` event, Inbox only while the task has open items, 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, and More carries the Inbox highlight and count when Inbox is in it. 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 :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 reads what it needs, ends its turn with its thoughts on the item (its reading, approach, trade-offs and doubts) in its final message rather than `ask_human` questions, carries on the conversation in chat, 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. Each agent error (a turn that ends in error without a human interrupt, a process that dies mid-turn) also files an item. `GET /api/inbox` is one query over open items, oldest first; `project_id` and `task_id` narrow it to a project or task view, and `state` picks another state. The Inbox tab is task-specific: it shows one of the selected task's open items at a time, the oldest, and the next one once that is answered or dismissed; it shows nothing when no task is selected. The tab is hidden while the task has no open items, unless it is selected. While the selected task has open items, the Inbox tab's button is highlighted in the warn colours (`attention` on `PaneTabButton` in `mac/Sources/Byre/Panes.swift`) and carries their count. Answering or dismissing the task's last open item from the Inbox tab switches the pane to the Transcript tab. The other way round, a new question from the selected task arriving while the Transcript tab is shown switches the pane to the Inbox tab, unless a text box in the pane holds unsent text (the `drafting` flag of the pane-focus report); questions already open when the app started don't. `POST /api/inbox/responses` answers any number of items in one submission. Response types and what they do to the item: | type | allowed on | item becomes | |---|---|---| | `option` (the chosen choices' text in `options`: exactly one for pick-one, one or more for pick-any; a pick-any one may also carry `text`, delivered after the checked list) | questions | `answered` | | `other` (free text in `text`: an answer, a question back, or new instructions) | questions, errors | `answered` | | `dismiss` | errors | `dismissed` | 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 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`, 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`. The log traces each answer: `inbox response recorded` when it is stored, `deliver held` with the reason, `deliver sent mid-turn`, `deliver sent` with the session's state and whether its agent process had exited or byre was closing, and `undelivered responses` with a reason wherever a session has held answers when its turn ends in a handoff or merge, its agent process ends, startup resumes it, or startup finds it idle. `sqlite3 byre.db "SELECT * FROM inbox_responses WHERE delivered_at IS NULL"` lists the answers held now. ## Notifications The running macOS app posts a notification for each new inbox item, raised by the app itself from the `inbox.created` events on its SSE stream (`notifyInbox` 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 which item kinds notify the user, returned as `notification_prefs` on `GET /api/me` and set with `PUT /api/me/notifications`: `questions` (on by default), `reports` (task turns that end with `summary: true` on `turn.ended`, labelled "Turn summaries"), `errors` (`session_error`) and `rate_limits`. The app checks them against each item or `turn.ended` event as it arrives. - **Content.** The title is `byre · ` and the body the question (or the turn summary's first line, the error, or "Rate limited"), cut at 200 characters. The notification's identifier is the item's ID, or `turn-` for a turn summary, so a repeat replaces the earlier one. - **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`), and a `summary.read` event removes that task's `turn-` notifications up to its `seq` (`clearTurns`). - **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"). The Inbox tab shows one item at a time, the task's oldest, filling the tab without a card frame; the next appears when it is answered. 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. 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/Inbox.swift`), which shows in the card's answer list 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. 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. An error has a "Reply…" block (`other`) and a Dismiss button. The Other and Reply text boxes send with the Submit button under them or on Enter (Shift+Enter for a newline), like the message box, and fold back into their block when left empty with no images. Pasting or dropping an image into them 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. Clicking a pick-one choice sends no images. 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 `
Title` (or `
` with the `` on the next line), Markdown content, and a line `
` 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]()`; 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 or error 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 client is named by its `Byre-Host` header (required), and a newer connection from the same host replaces the older one. byre keeps the connections in memory (`internal/clients`), pinging each every 30s and dropping one that doesn't answer within 15s. 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 eight 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 connected clients, with each one's login, connection time and app commit, and its `last_action_at` from Status (see "Status") matched on login, host and commit; the most recently active is listed first and marked. - `client_bash`: `op: bash`. The app runs `/bin/zsh -l -c ` 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 -- ` (404 once the call has ended). The app unpacks it into a new `.byre-upload-` 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_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 -C -- ` and `PUT`s 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-` 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/MacOS/byre-hands`, 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. Each `byre-hands` process has its own pending calls, so one that exits fails only the calls sent to it. 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. `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` on a client shows `client request : ` lines, connection drops and `byre-hands` starts and exits; 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-tree`s 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 `, or with `since` when given, using rename detection, giving per-file status and line counts. Work already merged into the default branch therefore drops out. The Changes view toggles between **Since main** and **This turn**, which passes the latest `user.message` event's `head` as `since`; the summary above the inbox always shows since main. `GET /api/tasks/{taskId}/diff` returns one file's patch between a `base` and `tree` from that response. Each request writes the changed blobs and a tree into the repo's object store as loose objects. The Changes view lists the files with their counts, opens a file's diff on click (open files stay open across refreshes), and refreshes when it opens, when the selected task changes, and 1.5s after the selected task's last tool result or turn end. It can also be pinned to a snapshot (see "Snapshots"). 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. The Inbox view shows just its summary line (file count, +/− totals, base) for the selected task at the top. ## Files The Files tab browses the selected task's worktree, read-only (`internal/server/files.go`). `GET /api/tasks/{taskId}/dir?path=` lists one directory, directories first and then by name, without entries named `.git`; `GET /api/tasks/{taskId}/file?path=` returns one regular file. Both take a slash-separated path relative to the worktree root and work only while the task is active (409 otherwise). Every path is opened through an `os.Root` on the worktree, so a path that leaves it, through `..`, an absolute path or a symlink at any depth, is a 400; a path with a `.git` component is also refused. A listing reports each entry as `dir`, `file` (with its size) or `other`, following symlinks that stay inside the worktree (`symlink` marks them); a symlink that leaves the worktree or points nowhere is `other` and can't be opened. Gitignored entries are listed with `ignored` set: one `git check-ignore --stdin` per listing (`git.Ignored`) over the entries' paths, taken from the directory's real path so a listing reached through an in-worktree symlink is checked where it really lives; untracked paths under an ignored directory count as ignored, tracked files never do. The view shows them dimmed with `ignored` beside them, and they open like anything else. A listing stops at `maxDirEntries` entries with `truncated` set. A file read sends at most `maxFileBytes`, cut back to a whole UTF-8 character, with `truncated` set when the file is longer; a file with a NUL byte or invalid UTF-8 in that part is `binary` and comes back with no text. The view shows a breadcrumb from `worktree` down to the current directory or file, the directory's entries (a `../` row to go up), or the file's text with line numbers and a note when only its start is shown. Where you are is kept per task in memory, so switching tasks or tabs returns to it, but a 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. ## 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/server/terminal.go`). `GET /api/tasks/{taskId}/terminal?cols=&rows=` is a WebSocket (`github.com/coder/websocket`), the only WebSocket in byre: it attaches to the task's terminal, starting the shell at that size if none is running, 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. 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: ` 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 ()`, and points `refs/byre/tasks//snap/` 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. 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//` 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=` 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 sets `Store.reviewed` (cleared when the selected task changes) and switches the right pane to the Reviews tab. The Reviews tab shows one snapshot's review at a time: a header with a snapshot picker and ← → steps to the neighbouring reviewed snapshots (all setting `Store.reviewed`), then that snapshot's latest review, or the newest review when nothing is picked. Pinned, the Changes header reads `snapshot · ·