21 KiB
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. This directory is private to the service's OS user (0700), including on existing installations: it contains transcripts, credentials and local agent listener bearer URLs. Other processes running as that same OS user remain trusted.
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.
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. Previews use the same user-only tailnet trust boundary as the API; tagged nodes are excluded.
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 codesigninglists it), with its key's access answered Always Allow forcodesign(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"), andmac/byre.provisionprofilebeing the Developer ID profile forrun.fc.byre.macwith iCloud (security cms -D -i mac/byre.provisionprofileprints it); - a
notarytoolkeychain profile namedbyre-notary, made withxcrun 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 -pprints its public key, which must matchSUPublicEDKeyinmac/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
cktoolmanagement 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:
xcrun cktool export-schema --team-id <team> --container-id iCloud.run.fc.byre.mac --environment development --output-file <file>(<team>isTeamIdentifierinsecurity cms -D -i mac/byre.provisionprofile).- Add each new attribute to its
CD_<Model>record type asCD_<attribute>(IntisINT64 QUERYABLE SORTABLE), and each new model as aCD_<Model>record type shaped like the others (CD_entityName, the___system fields and the grants). xcrun cktool import-schemathe 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/(andwww.byre.run) is the Cloudflare Pages projectbyre, published bysite/deploy.sh. Releases don't redeploy it; its download link ishttps://dl.byre.run/byre.dmg, which each release replaces.https://dl.byre.run/is the R2 bucketbyre's custom domain, holding whatmac/release.shuploads.- 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 proxiedAAAA 100::records forbyre.teamandwww.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,
codexunder the data directory (CODEX_HOME), so the human's~/.codex/config.toml(itsnotifyprogram, 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 with401 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 ownagyhistory. - Claude Code records
--append-system-prompton a session's first request and reuses it on resume, so a running session keeps the guidance it started with; theUserPromptSubmithook 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
resultwithnum_turns0, noresulttext 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 logsignored result with no model turnsand keeps the turn running. - stream-json doesn't expand
@paththe 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.
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.
- A review covers the whole task against the default branch, so a newer one makes older ones moot. Findings reach the task's agent together, once the whole review workflow finishes; an individual reviewer finishing is not a completed review.
- 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.
- The lead's
showis 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 callshowat 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.
git
- Merge notes live under
refs/notes/byre. byre adds the fetch refspecrefs/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 withgit config --add remote.origin.fetch 'refs/notes/byre*:refs/notes/byre*';git log --notes=byreshows the notes. git fetchandgit pushrun non-interactively (GIT_TERMINAL_PROMPT=0, sshBatchMode=yes, overriding anycore.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 ansshit 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.
- byre's
grepdoesn't read the globalcore.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.
- 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.
log show --info --last 10m --predicate 'subsystem == "run.fc.byre.mac"'on a client showsclient request <id>: <op>lines andbyre-handsstarts 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, solog show --startreaches much further back for them. byre's log showsclient connectedandclient 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 canceling each other's connection, and every capture from that path hangs. replayd's log shows it as
accepted client connection PID: <a>andinvalidated because the current process cancelled the connectionalternating between two pids, thousands of times a second. So each server session runs its own copy ofbyre-handsfrom its own file, and the app runs one shared by all its connections. - Window server.
NSApplication.sharedconnects 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
PreventUserIdleDisplaySleepassertion 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.delayDisplayOffinpmset -g assertions), not after the full display-sleep delay.pmset -g logshows each wake asDisplay is turned onbesidebyre-handsassertions. - Orion. Orion ignores key events that carry only a Unicode string, so typing posts each ASCII character as its key code. Some apps list
confirmbut ignore it, Orion's address bar among them; send Return instead.do JavaScriptneeds 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 frombrew install --cask google-chrome; macOS's "downloaded from the Internet" prompt on its first launch must be answered Open (orosascript -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;
NSAppleEventsUsageDescriptioninmac/Info.plistis what lets macOS ask for the Automation grant.byre-hands checkprints 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_typetakes named keys because Claude Code passes tool arguments through as the text the model wrote, so an escape like\u0003intextreaches 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
.zshenvin<data>/zsh(viaZDOTDIR) 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 offPROMPT_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.zshrcthat setszle-line-pre-redrawwithzle -Nrather thanadd-zle-hook-widgetreplaces 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
OriginmatchesHost, so another site can't open one through the user's Tailscale identity.