Files
byre/docs/operations.md
T

27 KiB
Raw Blame History

Operations

Layout

  • cmd/byre: byre serve runs the server; byre run drives one Claude session in the current directory from the terminal.
  • api/openapi.yaml is the API source of truth. go generate ./internal/api regenerates internal/api/api.gen.go, whose types are also the store's model types.
  • internal/store: SQLite (WAL). Every mutation and its event commit in one transaction; events are published to SSE subscribers in seq order.
  • internal/server: HTTP handlers, identity, SSE.
  • internal/git: worktrees, the task changes view (see "Changes"), unmerged-change summaries and squash merges.
  • internal/agents: the agent session manager. It owns the running Claude processes and records their activity as events.
  • internal/claude: the Claude Code stream-json client (see docs/claude-protocol.md).
  • internal/push: web push notifications for new inbox items (see "Notifications").
  • web/: the SPA, frameworkless TypeScript compiled file-by-file to ES modules (no bundler). web/src/*.ts is the source; web/src/api.gen.ts holds types generated from api/openapi.yaml, and web/src/api.ts is the typed client over them. go generate ./web runs npm ci, regenerates the types and compiles web/dist/, which is committed so go install needs no Node. The binary embeds index.html, style.css, dist/ and src/ (for source maps). byre serve --dev serves ./web from disk instead (run from the repo root); pair it with npm --prefix web exec tsc -- --watch.

Data

State lives in the byre directory under Go's os.UserConfigDir() (on macOS, ~/Library/Application Support/byre): byre.db, worktrees/<project-id>/<task-id>, and blobs/<blob-id> (uploaded images; the blobs table maps each id to its content type and its path relative to this directory). Task branches are byre/<task-id> in the project's repo.

Schema changes are the migrations slice in internal/store/store.go: named entries, each with a short kebab-case name saying what it does. On open, byre applies, in slice order, every entry whose name isn't in the migrations table, each in its own transaction that also records the name. byre is deployed from task branches that each append their own migrations, so after a merge a migration can land before others the database has already applied, and it still runs. Give a new migration a name no other branch will use, and make any data migration safe to run twice. sqlite3 byre.db 'SELECT * FROM migrations' lists what has been applied. A database from before the table existed is moved over on open: the first PRAGMA user_version entries are recorded as applied, and user_version is no longer read or written.

Closing a task

A task ends merged or discarded; either way its sessions are stopped, its worktree and branch are removed, its open inbox items become dismissed (resolved by whoever closed it), and its record and events remain. GET /api/tasks/{id}/unmerged reports what would be lost or merged: commits on the branch that aren't on the default branch, and files, lines and uncommitted paths that differ from the merge base. The UI's Merge and Discard buttons each ask a one-line confirmation and are disabled while the task's session is running or queued.

POST /api/tasks/{id}/merge-in (the transcript's Merge main in button, labelled with the default branch) brings the default branch into the task branch through the agent: it sends mergeInPrompt (internal/agents/mergein.go) as a byre message to the task's session, or starts a session with it when the task has none. The prompt has the agent run git merge --no-ff --no-commit, resolve conflicts, regenerate generated files rather than hand-merging them, check the build and tests, and leave the merge uncommitted; byre's end-of-turn commit concludes it as a merge commit. The prompt ends by showing the task's current merge title and asking the agent to set a better one with set_merge_title if it no longer describes all the work on the branch. It is refused (409) while a session is running or queued.

A task's merge_title is the title of the squash commit Merge makes. It is the task title until the agent calls the set_merge_title MCP tool (internal/agents/mergein.go), which it may do at any point: one line of at most 72 characters (maxMergeTitle), stored in tasks.merge_title (empty there means "use the title"), recorded as a task.merge_title event that the transcript shows as a note. Malformed titles are rejected with a corrective message, and so is a call once the task is closed. The Merge confirm dialog quotes the title the commit will get.

POST /api/tasks/{id}/merge starts the same turn and records, in memory, that the task is to be merged by the requesting user, who must have a git identity (400 otherwise). When that turn ends normally (not interrupted, not an error), byre squashes the worktree, now containing the default branch, onto it with git merge-tree --merge-base, makes one commit titled with the task's merge_title with git commit-tree, authored and committed by the merging user with no agent attribution, runs git merge --ff-only in the project's checkout (which must have the default branch checked out), then closes the task. Nothing is pushed. When the merge changes nothing, no commit is made. If the squash fails, for example because the default branch moved again during the turn, byre files an error in the inbox and the task stays active. An interrupted or failed turn, or a byre restart during it, leaves the task active without squashing. POST /api/tasks/{id}/discard removes everything without merging.

Running as a service

The listen address is listenAddr in cmd/byre/serve.go; it binds loopback only.

go install ./cmd/byre
cp deploy/run.fc.byre.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/run.fc.byre.plist
tailscale serve --bg --https=<port from listenAddr> http://<listenAddr>

byre gets its own HTTPS port on the machine's ts.net name rather than a path under another service: the SPA and API use absolute paths, which break under a path prefix. tailscale serve status shows the URL and what else the machine serves.

After rebuilding: go install ./cmd/byre && launchctl kickstart -k gui/$(id -u)/run.fc.byre. Logs go to ~/Library/Logs/byre.log.

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

Identity

tailscale serve adds Tailscale-User-Login and Tailscale-User-Name to proxied requests. The server accepts API requests only from loopback and only with both headers; anything else gets 401 or 403. The SPA's static files need no identity. Users are created on first request; each sets their commit identity with PUT /api/me.

To call the API locally without Tailscale, send the headers yourself:

curl -H 'Tailscale-User-Login: you@example.com' -H 'Tailscale-User-Name: You' http://<listenAddr>/api/me

Agent sessions

POST /api/tasks/{id}/sessions starts a Claude session in the task's worktree and sends the prompt as its first turn; POST /api/sessions/{id}/messages starts later turns and POST /api/sessions/{id}/interrupt ends the current one. A task has at most one session that isn't completed. A message may carry model; when it differs from the session's, byre writes it to agent_sessions.model, records a session.model event (shown as a note), and sends the live CLI a set_model control request before the message, so the switch applies from that turn and survives a resume (--model) or compaction. The composer's model menu sends model only when the choice differs from the session's model, or from the session.context model when the session has none. The session's commits use the git identity of the user who created it, so that user must have set one with PUT /api/me.

Images: POST /api/blobs takes a raw image body (type sniffed from the bytes, PNG, JPEG, GIF or WebP only, at most 5 MiB, maxBlobBytes in internal/server/blobs.go) and returns its id; GET /api/blobs/{id} serves it. A message or session prompt may carry images, a list of blob ids (unknown ids are a 400); a message may then have empty text. byre reads each blob when the turn is sent and passes it to Claude as a base64 image content block before the text (claude.Session.Send). The user.message event records the ids as images, and a queued turn keeps them in turn_queue.images. In the composer, pasting or dropping an image adds a thumbnail with a × to remove it; images upload when the message is sent, and the transcript shows them on the user's message, each linking to the full image.

Compaction is vendor-agnostic: POST /api/sessions/{id}/compact (the transcript's Compact button, enabled while the session is idle) sends the agent handoffPrompt (internal/agents/compact.go) as a byre message, asking for a handoff covering the goal, decisions and why, current state, dead ends, open questions and files. That turn files no inbox summary and isn't bounced for questions. When it ends normally, its turn.ended carries handoff: true, the session is completed, and a new session on the same task and model starts with handoffPreamble plus the handoff as its first message (actor byre). The context meter tracks the new session. Once the handoff turn ends, the transcript folds the request and everything the agent did in that turn into one collapsed note. If byre restarts during the handoff turn, the continuation is lost and the old session simply goes idle.

Agents spawn parallel work with the create_task MCP tool (internal/agents/tasks.go): title, prompt, base and optional model (default: the caller's session model). It creates a task in the caller's project exactly as POST /api/projects/{id}/tasks does, and starts a session there with prompt as the first message (actor agent), created by the caller's session creator so commits use that user's git identity. base is the agent's choice: default_branch starts from the project's default branch, this_task from the caller's worktree HEAD, which leaves out uncommitted edits from the current turn. The tool returns the new task's id at once; the new session is independent of the caller.

Agents don't commit; byre does. Each user.message event records head, the worktree's HEAD when the turn started. When a turn ends, byre runs git add --all and commits if anything changed (git.CommitAll), as the session's creator, with the first sentence of the turn's final message as the message (commitMessage in internal/agents/manager.go); turn.ended then carries the new sha as commit. A failed commit is logged and the turn still ends.

byre chooses the Claude session ID (vendor_session_id) up front, so every session can be resumed. Processes start on demand: the first message after a restart resumes the session with --resume. Sessions are running from the moment a turn is sent until its result arrives; on startup, every session still marked running was cut off mid-turn, and byre resumes it and sends a message telling the agent to re-check the working tree. Stopping the server kills all agent processes. Merging or discarding a task kills its sessions and marks them completed.

Every session gets byre's guidance (guidance in internal/agents/guidance.go): ask questions with ask_human, end each turn with a final message that summarizes it (including every departure from the plan), don't commit (byre commits each turn), and don't push, touch other branches or run git maintenance. It is passed as --append-system-prompt, which Claude Code records on a session's first request and reuses on resume, so a running session keeps the text it started with; a UserPromptSubmit hook also attaches it as additionalContext to every user turn, which reaches existing sessions too.

A Stop hook (bounceProseQuestion in internal/agents/guidance.go) bounces a turn whose final message asks the human something in prose: if ask_human wasn't called during the turn and the final message (last_assistant_message) has a sentence ending in ? outside code spans, code blocks, quotes and blockquotes, the stop is blocked once with an instruction to ask through ask_human. The per-turn flags reset when the next user message is sent. Claude Code reports the block as a "Stop hook error" notification; that is expected. An agent told explicitly to ask in plain text will refuse the bounce.

A turn's final message is its summary. When a turn ends normally (not an error, not interrupted) with non-empty result text, byre files that text as a report inbox item. There is no report tool: models write a closing recap whatever they are told, so byre uses it rather than asking for a second one.

Running turns are capped globally and per vendor (DefaultLimits in internal/agents/manager.go). A turn that would exceed a cap leaves its session queued, with the message held in the turn_queue table; whenever a turn ends, a process exits, or a task is merged or discarded, the oldest queued turns whose vendor has room start. Interrupting a queued session cancels its queued turn. Idle sessions with a live process don't count against the caps. Whether a turn resumes the Claude conversation is decided by whether the session has ever sent one.

When Claude Code reports a rate-limit status of rejected and the turn then fails, the inbox gets a rate_limited item carrying the vendor's status (including when the limit resets) instead of a generic error.

Admin view

GET /api/admin/status reports running_turns (sessions now running) and queued_turns (rows of turn_queue, in the order they will start), each as session, task and vendor; caps, the global and per-vendor limits from DefaultLimits; rate_limits, the latest rate_limit_info each vendor sent since byre started, unchanged; agent_processes, zombies, stuck_after_seconds and protocol_errors (below); and server counts (start time, agent processes, projects, active tasks, SSE clients, database size including WAL, last event seq). A turn's queue row is written before its session's queued state event, so a refetch triggered by that event sees it.

Agent health is internal/agents/health.go:

  • Processes. agent_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 Admin view (a pane tab, also opened in the right pane by the navbar's Admin button) shows, from the top: a Problems section only when there are zombies or protocol errors (one line per zombie, and per vendor the error count with the latest line folded under it); running turns against each cap, each running turn with the time since its process's last event and stuck or exited when it is; queued turns in order, both linking to their tasks; each vendor's rate-limit status with its reset time and per-window utilization, and the server counts. It refetches the status when a session.created, session.state, task.*, project.created or admin.health event arrives, when an agent.vendor event carries a rate_limit_event, and when the SSE stream reconnects. Other events advance the last-event seq it shows, and any event carrying a process's agent_session_id (stored or not, such as agent.delta) moves that process's last-event time forward, re-rendering at most once a second per process. A re-render keeps the scroll position and which folded lines are open. There is no timer, so "time since last event" and the "in 2h 10m" beside a reset time are as of the last render; a process going quiet long enough to be stuck shows up when the next health check broadcasts admin.health.

Agent activity becomes events with agent_session_id set: user.message, agent.message, agent.thinking, agent.tool_call, agent.tool_result, turn.ended, session.state, session.model, and session.context. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as agent.vendor. The one exception is partial output: each top-level text_delta is broadcast to SSE clients as an agent.delta event (payload.text, seq 0) that is never stored, and the transcript grows a live message from them until the finished agent.message replaces it. A turn.ended event carries inbox_item_id when its final message was filed as a report item. session.context records the context size whenever it changes: used is the last assistant message's input, cache-read, cache-creation and output tokens, and window is the model's context window from the turn's result (modelUsage[model].contextWindow), so it is 0 until the session's first turn after a server start ends. The task header shows the latest one as a percentage of the window, coloured green, amber from 70%, red from 90%.

Messages byre itself sends an agent (actor byre, such as the restart notice) show as a muted note, not a user bubble. The transcript view has an accent border while the session is running, and a braille spinner on its last line. The spinner is pure CSS: six dots laid out as a braille cell, each switched on and off by its own step-end keyframes (braille-1 to braille-6 in web/style.css) so together they cycle ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ every 0.8s. Browsers that freeze animated GIFs (Orion) still run it. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. A turn's final message that is at least half visible in the transcript while the page is visible dismisses its report inbox item, as the viewing user. Selecting a task that has never had a session focuses its start prompt. In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task in memory, so it survives switching tabs or tasks but not a page reload.

The task switcher (⌘K, or the navbar crumb) lists active tasks with their session state and open inbox item count, live while it is open. Tasks with inbox items come first; otherwise the order is the server's (projects by name, tasks newest first). Typing filters them and adds a last row that creates a task with the typed text as its title, in the current task's project (the first project when no task is selected); Enter picks the highlighted row. Picking a task here or in the Tasks view selects it and, if neither pane shows the Transcript tab, switches a pane that isn't showing Tasks to it.

Inbox

Agents ask questions with the ask_human MCP tool. Each question carries a context with the background the human needs to answer without reading the transcript; the UI renders it, the question, and the options as Markdown. A malformed call (no context or why_blocked, fewer than two options, a recommendation that isn't one of the options, an unknown attachment kind, a foreign thread_id) is rejected with a message saying what to fix, and nothing is filed. Each turn's final message (as a report) and each agent error (a turn that ends in error without a human interrupt, a process that dies mid-turn) also files an item.

GET /api/inbox is one query over open, unsnoozed items, oldest first; project_id and task_id narrow it to a project or task view, and state and include_snoozed widen it. Blocking questions and errors are blocked; reports and non-blocking questions are fyi. The inbox view groups items by task, each group headed by project and task: tasks with a blocked item come first, then tasks sort by project name and by their oldest item. Within a task, items are in time order.

POST /api/inbox/responses answers any number of items in one submission. Response types and what they do to the item:

type allowed on item becomes
option questions answered
other (free text in text: an answer, a question back, or new instructions) questions, errors answered
snooze any stays open, hidden until snooze_until
dismiss reports, errors dismissed

Notifications

New inbox items are sent as web push notifications to every browser a user has turned them on in. Web push needs a secure origin and a service worker; byre gets the origin from the tailscale serve certificate, and serves the worker at /sw.js (compiled from web/src/sw.ts with its own web/tsconfig.sw.json, since it needs the WebWorker library instead of the DOM one) so its scope is the whole SPA.

  • Keys. byre generates a VAPID key pair on its first start and keeps it in the vapid_keys table; GET /api/push/key returns the public half, which browsers pass to PushManager.subscribe. Replacing the keys invalidates every subscription.
  • Subscriptions. POST /api/me/push-subscriptions stores a browser's endpoint, p256dh and auth in push_subscriptions under the calling user (re-posting an endpoint replaces it), along with https:// plus the request's Host, which becomes the VAPID sub claim. DELETE /api/me/push-subscriptions?endpoint= removes one. A push service answering 404 or 410 means the subscription expired, and byre deletes it.
  • Preferences. users.notification_prefs holds which item kinds notify the user, returned as notification_prefs on GET /api/me and set with PUT /api/me/notifications: blocking_questions (on by default), questions (non-blocking), reports, errors (session_error) and rate_limits.
  • Sending. internal/push watches the event stream for inbox.created. The first item starts a 3-second window (pushWindow in internal/server/server.go); everything that arrives inside it goes out together, so each subscribed user gets at most one notification per window, built from the items their preferences select. One item reads byre · <task title> with the question (or the report's first line, the error, or "Rate limited") as the body; several read byre · N new inbox items with one <task title>: <text> line each. Encryption and VAPID signing are github.com/SherClockHolmes/webpush-go. Notifications carry a 24-hour TTL and Urgency: high when any item is blocking. Delivery failures are logged, not retried.
  • Clicking a notification opens the first item's task (/#/task/<id>): the worker focuses an open byre window and tells it to switch task, or opens a new one.

The Settings tab turns notifications on or off for the current browser (asking for permission on the way) and edits the preferences. Opening it re-registers the browser's existing subscription, which restores one byre deleted as expired.

In the UI a question offers its options plus Other, whose text box is the only text field; a report has only an OK button, which dismisses it at once; an error offers Dismiss, Reply (other) and Snooze.

Answers reach the agent as one message, listing each answer under the question or error it responds to, once the session is idle and none of its blocking questions are still waiting for the human. An other answer to a question tells the agent that if it asks something back, it should answer with ask_human using the item's thread_id. Every item change is an inbox.created or inbox.updated event carrying the whole item, including who resolved it.

Changes

GET /api/tasks/{taskId}/changes snapshots the task's worktree as if everything were staged: it copies the worktree's index to a temporary file, runs git add --all against that copy (GIT_INDEX_FILE), and git write-trees it, so committed, staged, unstaged and untracked work is included, .gitignore is respected, and the worktree's real index is never touched. Starting from the real index rather than read-tree HEAD keeps git's stat cache, so unchanged files aren't rehashed. The tree is compared with git merge-base HEAD <default branch>, or with since when given, using rename detection, giving per-file status and line counts. Work already merged into the default branch therefore drops out. The Changes view toggles between Since main and This turn, which passes the latest user.message event's head as since; the summary above the inbox always shows since main. GET /api/tasks/{taskId}/diff returns one file's patch between a base and tree from that response. Each snapshot 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. The Inbox view shows just its summary line (file count, +/− totals, base) for the selected task at the top.

Events

GET /api/events is server-sent events: id is the event's seq, data the Event JSON. Reconnecting with Last-Event-ID (EventSource does this itself) or ?after= replays everything after that seq before going live. Unstored events (seq 0, currently only agent.delta) go out live only, without an id line, so they don't move Last-Event-ID. A client that falls further behind than its subscriber buffer (subscribe in internal/server/hub.go) is disconnected and catches up by reconnecting. Every 15s the server sends an event: ping so clients can tell a live stream from a dead one. The web UI (subscribe in web/src/api.ts) tracks the last seq it saw and opens a new stream from it when nothing (event or ping) has arrived for 40s, 2s after the browser gives up on one (as it does when tailscale serve answers 502 while byre restarts), when the browser comes back online, and on becoming visible if the stream is stale. While disconnected the navbar shows a red dot and "reconnecting…"; partial output (agent.delta) sent while disconnected is lost, and the finished agent.message replaces it.