Files
byre/docs/operations.md
T
2026-10-20 22:27:29 -07:00

140 lines
22 KiB
Markdown

# Operations
What reading byre's code won't tell you: external setup, vendor and platform quirks seen live, and the intent behind rules. `api/openapi.yaml` is the API's source of truth; `go generate ./internal/api` regenerates `internal/api/api.gen.go`. The server serves only the API; it has no web pages. `mac/` is the client (`docs/macos.md`).
## Data
State lives in the directory given by `byre serve --data`, by default `~/Library/Application Support/byre`: `byre.db` (SQLite, WAL), task worktrees under `worktrees/`, uploaded images under `blobs/`. Task branches are `byre/<task-id>` in the project's repo.
Migrations are the files in `internal/store/migrations/` (`NNN-name.sql`), plus Go data migrations in `goMigrations`. The number only orders; a migration is identified by its name. A migration's SQL is fixed once applied anywhere: write it out in full rather than sharing Go constants that later change. byre is deployed from task branches that each add their own migrations, so after a merge a migration can land before others the database has already applied, and it still runs. Give a new migration a name no other branch will use, and make any data migration safe to run twice. `sqlite3 byre.db 'SELECT * FROM migrations'` lists what has been applied.
`agent.tool_result` content dominates the event log's size, and the app loads every active task's whole history at startup. `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events GROUP BY kind ORDER BY 3 DESC"` shows the split.
## Worktrees
byre runs nothing in a new worktree. Installing dependencies or copying untracked files such as `.env` is the project's own instruction in its AGENTS.md, carried out by the agent with its own tools.
## Running as a service
The service always runs from the app: `/Applications/Byre.app/Contents/Helpers/byre install`, which the app's Server tab and connection screen run. `byre install` takes `PATH` from the shell it is run in, since the service's `PATH` must find `claude`, `codex`, `agy`, `git`, `curl` and `npx`. When the service is already loaded, `install` hands `launchctl bootout` and `bootstrap` to a detached `sh` and exits: `bootout` kills every process in the service's process groups, which includes `byre install` itself when an agent byre runs is the one running it.
`byre serve` exits when its executable is replaced (logging `executable replaced, exiting`) and launchd starts the new one. A missing path counts as not yet replaced, so the moment in `install.sh` between removing the old bundle and moving the new one in doesn't trigger it. Logs go to `~/Library/Logs/byre.log`; `launchctl print gui/$(id -u)/run.fc.byre` shows the service.
## Tailnet
byre runs its own tailnet node with `tsnet`; it doesn't use the Tailscale app. The API is served only there, at the root of the node's `ts.net` name on 443, not under a path: the API uses absolute paths and the app takes an origin with no path. There is no way to call the API other than as a tailnet user; a tagged node gets 403.
Tailscale identifies the connecting Mac, including requests from a web page opened there; network identity alone doesn't establish the user's intent. Cross-origin browser requests must not be allowed to change state through that identity. Native app and CLI access remains intentional.
A new node needs signing in: `curl <loopback>/api/tailnet` (`byre check` prints the loopback address) shows `NeedsLogin` and the `auth_url`, and the app on the same Mac shows it as **Sign in to Tailscale**. The tailnet must have MagicDNS and HTTPS certificates turned on (the DNS page of Tailscale's admin console); without certificates byre logs `tailnet has no https certificates` and serves only the loopback address until it is restarted.
Each preview gets its own port on byre's node rather than a path under byre's origin, since path-prefix proxying breaks apps that use absolute paths.
## Releases
A release is one notarized disk image, `byre-<version>.dmg`, holding `Byre.app` with the server, `byre-hands` and `byre-update` in `Contents/Helpers`. Images are served from `https://dl.byre.run/`; each release's notes are on `https://g.fc.run/ai/byre/releases`. `mac/package.sh` builds one, `mac/deploy.sh` installs it locally (see `AGENTS.md`, "Deploying"), and `mac/release.sh <notes>` publishes it from a clean checkout at a pushed `v<version>` tag. A deployed build's `CFBundleVersion` is at least the last release's, so the updater never offers to go back to it.
`release.sh` uploads the versioned image first, then `byre.dmg` and last `appcast.xml`, so the appcast never names an image that isn't there yet, and purges the last two from Cloudflare's cache. It creates the g.fc.run release as a draft and publishes it only after attaching the files; a run that fails after the uploads leaves the draft on the releases page; delete it there before running again.
It needs, on that Mac:
- a Developer ID Application certificate in the keychain (`security find-identity -v -p codesigning` lists it), with its key's access answered **Always Allow** for `codesign` (the first signing asks for the login keychain password on the Mac's screen, and a prompt left waiting fails that run with "timestamps differ"), and `mac/byre.provisionprofile` being the Developer ID profile for `run.fc.byre.mac` with iCloud (`security cms -D -i mac/byre.provisionprofile` prints it);
- a `notarytool` keychain profile named `byre-notary`, made with `xcrun notarytool store-credentials byre-notary --key <AuthKey.p8> --key-id <id> --issuer <issuer>` from an App Store Connect API key;
- the Sparkle signing key in the login keychain under account `byre` (`~/Library/Caches/byre/sparkle-<version>/bin/generate_keys --account byre -p` prints its public key, which must match `SUPublicEDKey` in `mac/Info.plist`; `-x <file>` exports it for safekeeping). The script downloads Sparkle's tools to that cache directory when they are missing;
- a g.fc.run token that can write the repo's releases, as the generic password with service `byre-release` (`security add-generic-password -s byre-release -a g.fc.run -w <token>`);
- the `cktool` management token (see "CloudKit schema");
- the Cloudflare token (see "Website").
## CloudKit schema
The app's SwiftData store syncs through CloudKit's Production environment. Production never creates fields itself: a record carrying an attribute its schema lacks is rejected, and that one rejection stalls every later upload from that Mac (its `ANSCKEVENT` table shows exports failing with `CKErrorDomain` 2) until the app relaunches after the schema is fixed. So a change to the SwiftData models must be added by hand before it is deployed or released:
1. `xcrun cktool export-schema --team-id <team> --container-id iCloud.run.fc.byre.mac --environment development --output-file <file>` (`<team>` is `TeamIdentifier` in `security cms -D -i mac/byre.provisionprofile`).
2. Add each new attribute to its `CD_<Model>` record type as `CD_<attribute>` (`Int` is `INT64 QUERYABLE SORTABLE`), and each new model as a `CD_<Model>` record type shaped like the others (`CD_entityName`, the `___` system fields and the grants).
3. `xcrun cktool import-schema` the file back to Development, then Deploy Schema Changes in the CloudKit console.
`export-schema --environment production` shows what Production has, and `mac/package.sh` refuses to build while it lacks a model attribute. `cktool` needs a management token saved with `xcrun cktool save-token --type management --method file` (the console's Tokens & Keys page makes one). The CloudKit console (icloud.developer.apple.com, the container, Production, Private Database, zone `com.apple.coredata.cloudkit.zone`) shows the records. CoreData's own log lines redact sync errors; the app logs them in the clear.
## Website
byre.run and byre.team are registered at Porkbun, with Cloudflare as their DNS, in the Cloudflare account `cloudflare@flamingcow.io`.
- `https://byre.run/` (and `www.byre.run`) is the Cloudflare Pages project `byre`, published by `site/deploy.sh`. Releases don't redeploy it; its download link is `https://dl.byre.run/byre.dmg`, which each release replaces.
- `https://dl.byre.run/` is the R2 bucket `byre`'s custom domain, holding what `mac/release.sh` uploads.
- byre.team, and every path on it, redirects to `https://byre.run/` through a redirect rule on its zone (Rules → Redirect Rules in the dashboard), over proxied `AAAA 100::` records for `byre.team` and `www.byre.team`.
`site/deploy.sh` and `mac/release.sh` read a Cloudflare API token from the generic password with service `byre-cloudflare` (`security add-generic-password -s byre-cloudflare -a cloudflare -w`, which prompts for it); it needs Account → Cloudflare Pages: Edit and Workers R2 Storage: Edit, and Zone → DNS: Edit, Single Redirect: Edit, Zone Settings: Edit and Cache Purge: Purge on both zones. byre.run's Browser Cache TTL is "Respect Existing Headers", so browsers keep the short cache on `byre.dmg` and `appcast.xml`.
## Agent vendors
A model's name picks its vendor: `gpt-*` runs on Codex, `gemini-*` on Antigravity, everything else on Claude Code. Each vendor's protocol is in `docs/claude-protocol.md`, `docs/codex-protocol.md` and `docs/antigravity-protocol.md`.
- Every agent CLI starts in its own process group, and killing one kills the whole group. Otherwise a launcher that runs the real agent as its child, along with the agent's MCP servers, can keep holding byre's stdout pipe, and the kill waits forever for end of file.
- Codex runs with its own home, `codex` under the data directory (`CODEX_HOME`), 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.
- Codex's stdout reader blocks when its event consumer falls behind, so responses queued behind those notifications wait too: the goroutine draining events therefore never waits on a reply itself.
- Antigravity uses the user's own login and config (`~/.gemini`), so its conversations show up in the user's own `agy` history.
- Claude Code records `--append-system-prompt` on a session's first request and reuses it on resume, so a running session keeps the guidance it started with; the `UserPromptSubmit` hook re-attaches the guidance to every turn so existing sessions see changes. On a fork Claude Code can reuse the system prompt recorded for the source conversation, the source's cwd included, so the hook's copy is what reliably tells a fork where it is.
- 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. byre logs `ignored result with no model turns` and keeps the turn running.
- stream-json doesn't expand `@path` the way the interactive CLI does, so the composer has no `@`-file mentions; the agent reads any file a message names with its own tools.
- A snapshot always sits on a turn boundary, the only place a Codex conversation can be forked. Older snapshot rows also carry the triggers `message`, `tool_call` and `timer`.
## Agent behavior
- A turn whose final message asks the human something in prose is bounced once, telling the agent to use `ask_human`. For Claude the bounce is a Stop hook that blocks the stop; Claude Code reports the block as a "Stop hook error" notification, which is expected. An agent told explicitly to ask in plain text will refuse the bounce.
- A turn's final message is its summary. 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.
- Reviews ride along for the human to look at; nothing waits for one. Every review covers the whole task against the default branch, so a newer one makes older ones moot.
- An audit checks only the newest commit of the default branch, one at a time per project, so it doesn't run on every revision but the newest one is always audited eventually. byre never fetches for an audit, so a commit pushed outside byre is audited only once a byre merge has fetched it.
- A workflow definition is never changed once written: a save writes a new one, so freezing a workflow for a run is keeping the definition's id.
- The lead's `show` is refused for a few seconds after the human moves the pane, and only a refusal arranges the "hold is over" signal, so the lead's prompt tells it to call `show` at once whenever it wants to show something, never holding back in expectation of a refusal.
- A team task that comes up with some members failing to start isn't repaired; discard it and create it again.
- There is no closed-task list in the UI; the human asks the lead.
- A project's `TODO.md` is read from its default branch, so it shows what has been merged, not uncommitted edits in the checkout.
- Nothing sets an inbox item's `superseded` state; it stays in the spec because old items hold it.
## git
- Merge notes live under `refs/notes/byre`. byre adds the fetch refspec `refs/notes/byre*:refs/notes/byre*` to each checkout; 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 it with `git config --add remote.origin.fetch 'refs/notes/byre*:refs/notes/byre*'`; `git log --notes=byre` shows the notes.
- `git fetch` and `git push` run non-interactively (`GIT_TERMINAL_PROMPT=0`, ssh `BatchMode=yes`, overriding any `core.sshCommand`), so 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, killed whole on timeout, so an `ssh` it started can't finish a fetch or push after byre has moved on.
- A push error other than a non-fast-forward rejection is ambiguous, since the remote may have taken the push before the connection dropped; byre then fetches and checks whether the commit landed before deciding.
- The project's checkout must have the default branch checked out: a merge fast-forwards it.
- The Changes view builds its tree from a copy of the worktree's real index rather than `read-tree HEAD`, which keeps git's stat cache so unchanged files aren't rehashed.
- byre's `grep` doesn't read the global `core.excludesFile`.
## Signing merges
byre never signs with a key on the machine it runs on. When a project checkout's `commit.gpgsign` is true (`git -C <checkout> config --type=bool --get commit.gpgsign` prints it), the squash commit of a merge is signed on the merging user's most recently active Mac, so a key that never leaves that Mac's Secure Enclave and needs a fingerprint works. The Mac runs its own git signing config, as git does: `gpg.format` (`openpgp` or `x509`; `ssh` is not supported), the program when it isn't `gpg`/`gpgsm`, and `user.signingkey` when the program can't find the key from the user's byre git identity (not the Mac's `user.email`). The program must print `[GNUPG:] SIG_CREATED ` on its status fd, as git requires. A Touch ID prompt left unanswered for 2 minutes fails the merge. The notes commit is not signed.
`byre signing <checkout>` turns it on for a project; agents learn it from the `signing` knowledge-base article. It sets `commit.gpgsign` true in the checkout and false for its task worktrees, which share that config and would otherwise try to sign end-of-turn commits on the server, and fail. It does this with an `includeIf.gitdir:<common dir>/worktrees/**.path` of an `unsigned.gitconfig`. `extensions.worktreeConfig` wouldn't do it, since `git worktree add` copies the checkout's `config.worktree` into each new worktree, nor would the server's global config, which is read before the checkout's. It refuses a checkout that is itself a linked worktree, since the include would turn signing off for it too. No restart is needed; `git -C <checkout> cat-file commit HEAD` shows the `gpgsig` header.
## Client Macs
Task agents use the Macs the human has byre's app open on, through the app's WebSocket to `/api/clients/connect`.
- The app pings every 10s and reconnects after 25s with no pong. That catches a socket byre dropped while the Mac slept: the close never reached the Mac, and without something sent on it the app would wait on it indefinitely.
- Messages are allowed up to 256 MiB each way, so file contents fit in one message; `client_read` refuses files over 64 MiB so the base64 reply stays well under it, past which byre would drop the connection.
- `client_edit` is a read then a write; nothing locks the file between the two, so a write in between is lost.
- An app too old to know the `upload` op can't be updated through the client tools; update it by hand.
- `log show --info --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` on a client shows `client request <id>: <op>` lines and `byre-hands` starts and exits, which are info level and kept only in memory, so they cover the last few minutes at most; connection drops (`client connection closed`, `client connection got no pong`) are notices, kept on disk, so `log show --start` reaches much further back for them. byre's log shows `client connected` and `client disconnected`.
## Screen and app control
`byre-hands` (`hands/`) is the MCP server that lets agents see and drive a Mac. Its tool descriptions in `hands/Sources/byre-hands/Tools.swift` are what agents read.
- **One path per process.** replayd, the daemon behind ScreenCaptureKit, keys its clients by executable path: two processes run from one path keep cancelling each other's connection, and every capture from that path hangs. replayd's log shows it as `accepted client connection PID: <a>` and `invalidated because the current process cancelled the connection` alternating between two pids, thousands of times a second. So each server session runs its own copy of `byre-hands` from its own file, and the app runs one shared by all its connections.
- **Window server.** `NSApplication.shared` connects the process to the window server at startup, without which ScreenCaptureKit asserts. AppleScript needs the main thread with a run loop, so every script runs there.
- **Busy apps.** An app in a menu-tracking loop or otherwise busy answers neither Apple events nor accessibility requests; scripts time out after 10s and accessibility requests after 3s, and the first timeout fails the whole call rather than waiting on every element.
- **Display sleep.** While the display sleeps, ScreenCaptureKit lists no displays. Every call holds a `PreventUserIdleDisplaySleep` assertion and, when the display is asleep, declares user activity and waits for ScreenCaptureKit to list it again. A declared activity doesn't reset the keyboard and mouse idle time, so the display sleeps again after powerd's grace period (`com.apple.powermanagement.delayDisplayOff` in `pmset -g assertions`), not after the full display-sleep delay. `pmset -g log` shows each wake as `Display is turned on` beside `byre-hands` assertions.
- **Orion.** Orion ignores key events that carry only a Unicode string, so typing posts each ASCII character as its key code. Some apps list `confirm` but ignore it, Orion's address bar among them; send Return instead. `do JavaScript` needs Orion's Develop → "Allow JavaScript from Apple Events".
- **Chrome.** Chrome refuses remote debugging on its default profile, so byre's Chrome is its own profile (`~/Library/Application Support/byre/chrome`), a separate Chrome beside any the human runs, and it keeps its logins: the human signs in there once. Chrome comes from `brew install --cask google-chrome`; macOS's "downloaded from the Internet" prompt on its first launch must be answered Open (or `osascript -e 'tell application "System Events" to tell process "CoreServicesUIAgent" to click button "Open" of window 1'`), and until it is, Chrome runs without opening its debugging port.
- **Permissions.** Agents' processes 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. Without Accessibility an untrusted process just sees no windows and no menu bar. Accessibility can list byre switched off; only its switch being on counts. TCC keys grants on the signing identifier and team, and every build is signed with Developer ID, so grants carry over from build to build. On a client Mac the grants belong to byre's app; `NSAppleEventsUsageDescription` in `mac/Info.plist` is what lets macOS ask for the Automation grant. `byre-hands check` prints the grants of the process that runs it; the Server and Settings tabs run it.
## Terminals
Each task has one login shell on a PTY, shared by the Terminal tab and the agents' `terminal_read` and `terminal_type` tools.
- `terminal_type` takes named keys because Claude Code passes tool arguments through as the text the model wrote, so an escape like `\u0003` in `text` reaches the shell as six characters.
- In a new shell it waits for the prompt first: typing earlier would reach the PTY while it still echoes in cooked mode, before zsh's line editor takes over, so the text would show twice.
- It types one byte per write: a program in raw mode that stops reading blocks a PTY write, and the PTY's file doesn't support deadlines, so this keeps the call within its limits while leaving at most one byte to arrive late.
- zsh gets shell integration through a `.zshenv` in `<data>/zsh` (via `ZDOTDIR`) that reports the prompt (OSC 133) and the line being edited, so byre can wake the agent when a line it left is run. It turns off `PROMPT_SP`, which would print a `%` padded to the width the shell started at, so an agent-opened shell (120 columns) replayed in a narrower tab wraps each one onto an extra line. A user `.zshrc` that sets `zle-line-pre-redraw` with `zle -N` rather than `add-zle-hook-widget` replaces the hook, and byre then sees the line as empty. Other shells get no integration and nothing wakes their agents.
- The terminal WebSocket checks that `Origin` matches `Host`, so another site can't open one through the user's Tailscale identity.