Files
byre/docs/operations.md
T

17 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).
  • 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 and worktrees/<project-id>/<task-id>. 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 shows it in the confirm dialog of both buttons.

POST /api/tasks/{id}/merge squashes the worktree as it stands, uncommitted and untracked files included (captured through a temporary index, so the worktree isn't touched), onto the default branch with git merge-tree --merge-base, makes one commit with git commit-tree authored and committed by the merging user, and runs git merge --ff-only in the project's checkout, which must have the default branch checked out. The commit message is the task title. Nothing is pushed. It is refused (409) while a session is running or queued, on merge conflicts (the message lists the files; the task stays active), and if the default branch moved between the merge and the fast-forward. When the merge changes nothing, no commit is made. 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.

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. GET /api/admin/status reports running and queued turns and the latest rate-limit status each vendor sent.

Agent activity becomes events with agent_session_id set: user.message, agent.message, agent.thinking, agent.tool_call, agent.tool_result, 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. In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task in memory, so it survives switching tabs or tasks but not a page reload.

Inbox

Agents ask questions with the ask_human MCP tool. Each question carries a context with the background the human needs to answer without reading the transcript; the UI renders it, the question, and the options as Markdown. A malformed call (no context or why_blocked, fewer than two options, a recommendation that isn't one of the options, an unknown attachment kind, a foreign thread_id) is rejected with a message saying what to fix, and nothing is filed. Each 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

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.