Files
byre/docs/operations.md
T

141 lines
27 KiB
Markdown
Raw Normal View 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.
2026-10-14 19:35:34 -07:00
- `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
2026-10-14 20:07:26 -07:00
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.
2026-10-14 19:35:34 -07:00
## 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`.
2026-10-14 19:35:34 -07:00
`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`.
2026-10-14 21:01:38 -07:00
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.
2026-10-14 19:35:34 -07:00
## 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
2026-10-14 19:35:34 -07:00
`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`.
2026-10-14 20:07:26 -07:00
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.
2026-10-14 19:35:34 -07:00
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.
2026-10-14 19:35:34 -07:00
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`.
2026-10-14 19:35:34 -07:00
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.
2026-10-14 21:06:30 -07:00
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.
2026-10-14 21:06:30 -07:00
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`.
2026-10-14 19:35:34 -07:00
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
2026-10-14 19:35:34 -07:00
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.
2026-10-14 19:35:34 -07:00
## 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 <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.