8 Commits
Author SHA1 Message Date
Ian Gulliver 6ee31477b3 Deploy main 2026-10-18 16:41:31 -07:00
Ian Gulliver 2374b0f7a8 test terminal typing 2026-10-18 16:26:02 -07:00
Ian Gulliver d0bfd4f657 graphic issues 2026-10-18 16:24:58 -07:00
Ian Gulliver 603a0eda6b no gemini 2026-10-18 16:24:11 -07:00
Ian Gulliver c4d7a67dad Tag v0.1.0 2026-10-18 16:14:03 -07:00
Ian Gulliver 543d1289e3 Add Antigravity (agy) as a third agent backend for gemini-* models 2026-10-18 16:05:50 -07:00
Ian Gulliver a3680cd416 graphic bananas 2026-10-18 16:04:04 -07:00
Ian Gulliver 58930e59bd prompt shouldn't be required with an image 2026-10-18 16:02:23 -07:00
32 changed files with 1779 additions and 427 deletions
+12 -8
View File
@@ -4,23 +4,27 @@ These are requirements, not preferences. Changing one needs the owner's agreemen
## Workflow
- Never deploy unless the owner asks for it in the current turn. A finished change is reported undeployed; the owner deploys it, or asks you to. Never run `go install ./cmd/byre` or restart the service on your own initiative, not even to check that a change works.
- Never deploy unless the owner asks for it in the current turn. A finished change is reported undeployed; the owner deploys it, or asks you to. Never run `mac/deploy.sh` or restart the service on your own initiative, not even to check that a change works.
- `docs/operations.md` describes the layout, data, service and APIs; keep it current with every change.
- Never add tests. byre has no test suite; check a change with `go vet ./...`; when `mac/` or `api/openapi.yaml` changed, `mac/bundle.sh` (release only; debug builds are refused); and when `hands/` changed, `swift build --package-path hands --configuration release`.
## Deploying
When asked to deploy, do exactly this, from the worktree root:
A deploy is a release that isn't uploaded: the same notarized disk image, installed on the owner's Macs instead of published. When asked to deploy, do exactly this, from the worktree root:
1. `go install ./cmd/byre && codesign --force --sign "Apple Development" --identifier run.fc.byre ~/bin/byre && swift build --package-path hands --configuration release && install hands/.build/release/byre-hands ~/bin/ && launchctl kickstart -k gui/$(id -u)/run.fc.byre`, always as that one command, never `go install` on its own, and always the last action of the turn. The signature gives byre a stable identity, so the macOS privacy grants it holds (Screen Recording, Accessibility, Automation) survive the rebuild; `go install`'s own ad-hoc signature changes with every build and loses them. `install` replaces `byre-hands` with a new file rather than writing over the one running agents have open.
1. Run `mac/deploy.sh`, always as the last action of the turn. It runs `mac/package.sh` (the build, both notarizations and the disk image, as for a release, into `mac/.build/release/<version>/`), installs the image's app as `/Applications/Byre.app` with `mac/install.sh`, and restarts the service with `launchctl kickstart -k`. The service runs `/Applications/Byre.app/Contents/Helpers/byre`, so the kickstart starts the new server and `byre-hands`. Everything is signed with Developer ID, so the macOS privacy grants (Screen Recording, Accessibility, Automation) survive.
2. When you resume, confirm it came up: read the tail of `~/Library/Logs/byre.log`, and `GET /api/admin/status` through the `tailscale serve` URL (`tailscale serve status` shows it). Report what you deployed and what you checked.
3. Once the service is confirmed healthy, rebuild and relaunch the macOS app: run `mac/bundle.sh`, then update every Mac whose app is running: on iso, `pkill -x Byre` and `open mac/.build/Byre.app`, only when an app was running there (never start one that wasn't); on each client Mac, replace and relaunch the installed app through the client tools without asking first. The app reads its server address from its iCloud-synced SwiftData store, shared by every build, so the new build reconnects to prod with no setup. Report each Mac's app commit.
3. Once the service is confirmed healthy, update each client Mac whose app is running, through the client tools and without asking first: upload the disk image (`mac/.build/release/<version>/byre-<version>.dmg`) and `mac/install.sh`, quit the app, run `install.sh <dmg>`, and `open /Applications/Byre.app`. The app reads its server address from its iCloud-synced SwiftData store, so it reconnects to prod with no setup. Report each Mac's app commit (`install.sh` prints it).
What to expect:
- `go install` does not restart the service. If byre restarts after your install without your kickstart, another agent deployed its own branch, and the running binary is theirs, not yours.
- You run inside byre, so the kickstart kills your own turn partway through the command. That is expected, not a reason to skip it. You resume with a "byre restarted while you were mid-turn" message; do the checks then, and confirm the service started after your kickstart.
- The restart can cut off the record of the last tool call before the kickstart, or the kickstart itself, so you may resume with no memory of having deployed. Before concluding that another agent restarted byre, compare the service's start time (`started_at` in `/api/admin/status`, or the last `listening` line in the log) with your most recent `go install`: a restart right after your own install is most likely your kickstart.
- The notarizations take a minute or two each; `notarytool` prints "In Progress" until "Accepted". A rejection stops the script before anything is installed; `xcrun notarytool log <id> --keychain-profile byre-notary` says why.
- You run inside byre, so the kickstart kills your own turn partway through the script. That is expected, not a reason to skip it. You resume with a "byre restarted while you were mid-turn" message; do the checks then, and confirm the service started after your kickstart.
- The restart can cut off the record of the last tool call, so you may resume with no memory of having deployed. Before concluding that another agent restarted byre, compare the service's start time (`started_at` in `/api/admin/status`, or the last `listening` line in the log) with when you ran `mac/deploy.sh`: a restart a few minutes after it, once the notarizations are done, is most likely your kickstart. If byre restarts without your kickstart, another agent deployed its own branch, and the running build is theirs.
## Releasing
A release is a deploy that is uploaded, and goes to every Mac through the app's updater. Make one only when the owner asks. It needs a `v<version>` tag on a commit of main, pushed by itself (`git push origin v<version>`, never moving or recreating a tag), and a clean checkout at that tag on iso, where `mac/release.sh` checks the tag, runs `mac/package.sh`, and publishes the image, its `.sha256` and `appcast.xml` as the release on g.fc.run. `docs/operations.md`, "Releases", has the details.
When the owner tells the lead "deploy byre", or anything that means the same, that is the owner asking for both the deploy and the discard below. The lead:
@@ -32,7 +36,7 @@ When the owner tells the lead "deploy byre", or anything that means the same, th
- Exactly two panes, side by side, each exactly half the width, at every window size. Never three or more columns, never a sidebar, never a resizable or collapsible split.
- No navbar. The panes fill the window, below only the identity banner when it shows.
- The left pane has three tabs: Lead (the user's lead agent), Status and Settings, with the connection dot and the user's name at the end of its tab strip. Nothing else goes in it. The right pane always shows one task, through tabs of that task's views; the lead sets the task, which tabs come first and which one is selected, and the human can switch the task (⌘K) and tabs too. A new task view is a new tab in the right pane, never a new column or region.
- The left pane has four tabs: Lead (the user's lead agent), Status, Server (the byre service on this Mac) and Settings (the user's and this client's preferences), with the connection dot and the user's name at the end of its tab strip. Nothing else goes in it. The right pane always shows one task, through tabs of that task's views; the lead sets the task, which tabs come first and which one is selected, and the human can switch the task (⌘K) and tabs too. A new task view is a new tab in the right pane, never a new column or region.
- Overlays (the task switcher, confirmation dialogs) are allowed; they sit above the panes and don't change the layout.
## UI look
+4 -10
View File
@@ -1,6 +1,6 @@
# byre
A server and macOS app for running many coding agents (Claude Code and Codex) in parallel on your own Mac, each on its own branch, with a lead agent that keeps track of them for you.
A server and macOS app for running many coding agents (Claude Code, Codex and Antigravity) in parallel on your own Mac, each on its own branch, with a lead agent that keeps track of them for you.
## Features
@@ -20,19 +20,13 @@ A server and macOS app for running many coding agents (Claude Code and Codex) in
## Requirements
macOS, [Tailscale](https://tailscale.com) on every Mac, and on the Mac that runs the server, `git`, the `claude` and `codex` CLIs, Node.js (agents' Playwright browser tools run through `npx`) and Google Chrome (`brew install git node --cask google-chrome`). Identity comes from Tailscale: byre only serves requests that arrive through `tailscale serve`.
macOS, [Tailscale](https://tailscale.com) on every Mac, and on the Mac that runs the server, `git`, the `claude` and `codex` CLIs (and for Gemini models the Antigravity CLI, `brew install --cask antigravity-cli`, signed in once by running `agy`), Node.js (agents' Playwright browser tools run through `npx`) and Google Chrome (`brew install git node --cask google-chrome`). Identity comes from Tailscale: byre only serves requests that arrive through `tailscale serve`.
## Installing
1. Download the latest `byre-<version>.dmg` from [the releases page](https://g.fc.run/ai/byre/releases) and drag `Byre.app` to Applications, on every Mac you'll use byre from.
2. On the Mac that runs the server, from a terminal whose `PATH` finds `claude`, `codex`, `git` and `npx`:
```
/Applications/Byre.app/Contents/Helpers/byre install
```
It installs the server as a launchd service and serves it through `tailscale serve`, printing the URL.
3. Open byre on each Mac and enter that URL.
2. On the Mac that runs the server, open byre and click **Run byre on this Mac**. It installs the server as a launchd service, using your login shell's `PATH` to find `claude`, `codex`, `agy`, `git` and `npx`, serves it through `tailscale serve`, and connects to it. The Server tab shows its URL and turns it off and on.
3. Open byre on each other Mac; it picks up the server's URL through iCloud, or enter that URL.
The app updates itself, and the server along with it on the server's Mac. To build from source instead, see "Running as a service" and "macOS app" in [docs/operations.md](docs/operations.md); that needs Go and Xcode.
+3 -3
View File
@@ -1298,7 +1298,7 @@ components:
Where the stage's task branches from. parent (the default): the commit its one after stage finished at, or
for a stage with none, where the workflow was started from. main: the project's default branch. Refused on
a stage with more than one after stage, which always branches from the default branch.
model: { type: string, x-go-type-skip-optional-pointer: true, description: "The stage agent's model, which also picks its vendor: gpt-* models run on Codex, every other model on Claude Code. Required without agents, refused with them." }
model: { type: string, x-go-type-skip-optional-pointer: true, description: "The stage agent's model, which also picks its vendor: gpt-* models run on Codex, gemini-* models on Antigravity, every other model on Claude Code. Required without agents, refused with them." }
prompt: { type: string, description: "The stage agent's instructions; with agents, the instructions every agent of the team gets before its own." }
agents:
type: array
@@ -1528,9 +1528,9 @@ components:
task_id: { type: string, description: Empty for a lead session. }
member: { type: string, description: "The agent's name in its task's team; empty on a task with one agent, and for leads and reviewers." }
owner: { type: string, description: "The login whose lead agent this is; empty for a task's session." }
vendor: { type: string, enum: [claude, codex], description: "Follows from the model the session was created with: gpt-* models run on Codex, every other model on Claude Code." }
vendor: { type: string, enum: [claude, codex, antigravity], description: "Follows from the model the session was created with: gpt-* models run on Codex, gemini-* models on Antigravity, every other model on Claude Code." }
model: { type: string, description: "Requested model; empty means the vendor's default. A session only switches between its own vendor's models." }
vendor_session_id: { type: string, description: "Claude Code's session id, chosen by byre; or the Codex thread id, empty until the session's first turn starts its thread." }
vendor_session_id: { type: string, description: "Claude Code's session id, chosen by byre; or the Codex thread id or Antigravity conversation id, empty until the session first starts." }
state:
type: string
enum: [idle, running, completed]
+17 -8
View File
@@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"syscall"
"g.fc.run/ai/byre/internal/tailscale"
)
@@ -69,19 +70,27 @@ func installCmd() {
}
fmt.Println("wrote", plistPath)
domain := fmt.Sprintf("gui/%d", os.Getuid())
service := domain + "/" + serviceLabel
if exec.Command("launchctl", "print", service).Run() == nil {
run("launchctl", "bootout", service)
}
run("launchctl", "bootstrap", domain, plistPath)
fmt.Println("started", service, "running", exe)
url, err := tailscale.ServeRoot(context.Background(), "http://"+defaultListen)
if err != nil {
fatal("tailscale serve", "err", err)
}
fmt.Println("byre is at", url)
domain := fmt.Sprintf("gui/%d", os.Getuid())
service := domain + "/" + serviceLabel
if exec.Command("launchctl", "print", service).Run() != nil {
run("launchctl", "bootstrap", domain, plistPath)
fmt.Println("started", service, "running", exe)
return
}
// bootout kills every process the service started, which includes this one when an agent runs it.
reload := exec.Command("/bin/sh", "-c", `launchctl bootout "$1"; launchctl bootstrap "$2" "$3"`, "sh", service, domain, plistPath)
reload.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
err = reload.Start()
if err != nil {
fatal("restart service", "err", err)
}
fmt.Println("restarting", service, "on", exe)
}
func run(name string, args ...string) {
+68
View File
@@ -0,0 +1,68 @@
# Antigravity CLI protocol
How `internal/antigravity` drives the Antigravity CLI, `agy` (`brew install --cask antigravity-cli`; `agy --version` prints the version, `agy changelog` the release notes, and `agy --help` the flags). The CLI is signed in once, interactively, by running `agy` with no arguments as the user byre runs as; `agy models` lists the models that login offers, and fails with "Please sign in" until then. The CLI updates itself.
## Launch
```
agy --input-format stream-json --output-format stream-json \
--dangerously-skip-permissions --add-dir <session dir> \
[--model M] [--conversation ID] --print ""
```
- `--print ""` puts the CLI in print mode; with `--input-format stream-json` it reads one message per line from stdin and runs a turn for each, in one conversation, until stdin closes. A bare `-p` takes the next flag as its prompt, so the empty prompt is passed explicitly.
- `--dangerously-skip-permissions` sets the permission mode to `always-proceed`; without it the mode is `request-review` and tool calls wait for approvals print mode can't give.
- `--conversation ID` resumes a conversation with its history; the model comes from `--model`, not from the conversation.
- Runs in the session's working directory, with `GIT_AUTHOR_*`/`GIT_COMMITTER_*` set to the commit identity and anything in `Options.Env`. Commands the agent runs (`run_command`) inherit the environment.
## The session directory
There is no flag for a system prompt or MCP servers, so byre hands them over as a plugin. Each session has its own directory (`Options.Dir`, `antigravity/<session-id>` under the data directory), passed as `--add-dir`. The CLI treats it as a second workspace and loads the plugin in its `.agents/plugins/byre/`:
- `plugin.json`: `{"name": "byre"}`.
- `rules/AGENTS.md`: byre's guidance (`Options.Guidance`), always on. `rules/notes.md` holds the notes (`Options.Notes`) behind `trigger: always_on` frontmatter. The CLI cuts each rule file at 24,000 bytes, so `Start` refuses guidance or notes longer than that.
- `mcp_config.json`: one entry per server. Each `mcp.Server` in `Options.MCPServers` is a Streamable HTTP `url` on the session's listener (below); each `Options.StdioMCP` entry a `command` and `args`. The CLI namespaces plugin servers as `<plugin>_<server>`, so byre's own server is `byre_byre`.
- `hooks.json`: one flat handler per entry in `Options.Hooks`, keyed by hook event, each a `curl` that posts the hook's stdin to the session's listener and prints the reply. Only flat events (`SessionStart`, `PreInvocation`, `PostInvocation`, `Stop`) fit this shape.
Attached images are written under `images/` in the same directory. The directory is removed when the session ends.
## The session's listener
`Start` listens on `127.0.0.1` on a port the OS picks. Every path starts with a random token made per session (`crypto/rand.Text`), written only into the plugin's `mcp_config.json` and `hooks.json`, so a page in a browser or another local process that can reach the port can't call byre's tools without reading those files; any other path is a 404. It serves:
- `POST /<token>/mcp/<server>`: Streamable HTTP MCP for that `mcp.Server`. A request is answered with its JSON-RPC response as `application/json`; a notification gets `202 Accepted`. There is no SSE stream.
- `POST /<token>/hook/<event>`: runs the `Options.Hooks` function for the event on the posted hook input and answers its output as JSON. A failed hook is logged and answers `{}`.
The CLI loads MCP tool schemas lazily. The model reads a tool's schema from `~/.gemini/antigravity-cli/mcp/<server>/<tool>.json` with `view_file`, then calls it through the built-in `call_mcp_tool` tool with `ServerName`, `ToolName` and `Arguments`.
## Messages
User turn, written to stdin: `{"event":"user","message":{"role":"user","content":[{"type":"text","text":"..."}]}}`. `content` may also be a plain string. Only `text` blocks are accepted: an `image` block fails the line with `stream input content block type "image" is not supported`, so byre saves each image to a file and adds a text block naming its path. With `Options.Reminder` set, every message starts with a text block holding it inside `<system-reminder>` tags. A line without `event` or with `event` other than `user` fails or is ignored; `control_request` is "not supported yet", so there is no control channel.
Stdout, one JSON object per line, each `{"event": E, E: {...}}`:
- `init` arrives at launch, before any input, with `conversation_id`, `model`, `cwd`, the built-in `tools` and `permission_mode`. `Start` waits for it and takes the conversation id from it.
- `step_update`: one step of the conversation, by `step_index`, with `state` (`ACTIVE` while it runs, then `DONE`) and `step_type`:
- `user_input`: a user message, including one a hook injected.
- `agent_response`: model output. `text_delta` streams its text across `ACTIVE` updates; the `DONE` update carries `duration_seconds` and `usage` (`input_tokens`, `output_tokens`, `thinking_tokens`, `cache_read_tokens`, `total_tokens`). A response that only calls tools has no text. Thinking is counted but never streamed.
- `tool`: a tool call, `tool_name` and `tool_info` (`name`, `parameters`, and on `DONE` `output`). A shell command is `run_command` with `CommandLine`.
- `system_message`: a system note, such as the one after a resume.
- `result` ends a turn: `status` (`SUCCESS` or `ERROR`), `response` (the turn's last text), `error`, `duration_seconds`, `num_turns` and `usage` totals for the whole conversation, earlier processes included.
Lines that aren't a JSON object, don't parse, or carry an `event` outside `init`, `step_update` and `result` go to `Options.OnBadLine`; lines that parse are still delivered as events.
## Interrupts and model switches
SIGINT ends the running turn: the CLI prints `error: interrupted` on stderr, sends a `result` with `status` `ERROR` and `error` `interrupted`, and exits. A tool step cut off this way gets no `DONE` update. `Session.Interrupt` sends SIGINT and starts a new process on the same conversation (`--conversation`) once the old one exits, so the session stays usable.
There is no way to switch the model of a running process, so `Session.SetModel` closes the idle process's stdin, waits for it to exit, and starts a new one with the new `--model` on the same conversation.
A process that exits when byre didn't ask it to ends the session: `Events` closes, and the listener and session directory go away.
## Hooks
Hook input and output are camelCase JSON. byre uses `PreInvocation`, which runs before every model call with `invocationNum` (0 for a turn's first call) and `initialNumSteps`; an output of `{"injectSteps":[{"userMessage":"..."}]}` adds a user message the model reads on that call. The `Stop` hook gets no final message, so byre doesn't use it.
## Forking
The CLI can't fork a conversation at a given point, so Antigravity sessions can't be forked.
+3 -3
View File
@@ -20,7 +20,7 @@ The project is a Swift package under `mac/`, built with `swift build`, not an Xc
## Signing
Every build is signed for distribution outside the App Store: the team's Developer ID Application certificate (`codesign --sign "Developer ID Application"`), `--options runtime` (the hardened runtime notarization requires) and `--timestamp`, with the entitlements in `mac/byre.entitlements` (the team-prefixed application identifier, the iCloud container with CloudKit in its Production environment, production push for sync, and Apple Events for driving Orion) and `mac/byre.provisionprofile` embedded as `Contents/embedded.provisionprofile`. iCloud outside the App Store needs that profile: a Developer ID profile for the App ID `run.fc.byre.mac` with iCloud (CloudKit, container `iCloud.run.fc.byre.mac`) enabled, made at developer.apple.com under Certificates, Identifiers & Profiles → Profiles → Developer ID. It names no devices, so the app runs on any Mac. `security cms -D -i mac/byre.provisionprofile` prints it, `DeveloperCertificates` included, and `security find-identity -v -p codesigning` lists the certificates on this Mac. Only the team's Account Holder can create a Developer ID Application certificate (Xcode → Settings → Accounts → Manage Certificates → + → Developer ID Application, signed in as the Account Holder); a Mac that builds the app needs that certificate and its private key in its keychain. A build that isn't notarized still runs on a Mac that didn't download it, such as one it was copied to with `client_upload`, since only downloaded files are quarantined; `mac/release.sh` notarizes releases (see "Releases" in `docs/operations.md`).
Every build is signed for distribution outside the App Store: the team's Developer ID Application certificate (`codesign --sign "Developer ID Application"`), `--options runtime` (the hardened runtime notarization requires) and `--timestamp`, with the entitlements in `mac/byre.entitlements` (the team-prefixed application identifier, the iCloud container with CloudKit in its Production environment, production push for sync, and Apple Events for driving Orion) and `mac/byre.provisionprofile` embedded as `Contents/embedded.provisionprofile`. iCloud outside the App Store needs that profile: a Developer ID profile for the App ID `run.fc.byre.mac` with iCloud (CloudKit, container `iCloud.run.fc.byre.mac`) enabled, made at developer.apple.com under Certificates, Identifiers & Profiles → Profiles → Developer ID. It names no devices, so the app runs on any Mac. `security cms -D -i mac/byre.provisionprofile` prints it, `DeveloperCertificates` included, and `security find-identity -v -p codesigning` lists the certificates on this Mac. Only the team's Account Holder can create a Developer ID Application certificate (Xcode → Settings → Accounts → Manage Certificates → + → Developer ID Application, signed in as the Account Holder); a Mac that builds the app needs that certificate and its private key in its keychain. A build that isn't notarized still runs on a Mac that didn't download it, such as one it was copied to with `client_upload`, since only downloaded files are quarantined; `mac/package.sh` notarizes deploys and releases (see "Releases" in `docs/operations.md`).
Only the release configuration is built. A debug build stops at an `#error` in `ByreApp.swift`, and even a failed one leaves the next release build in the same build directory recompiling every target, since the two configurations share it. The generated API client is its own target, `ByreAPI`, so an edit to the app recompiles only the `Byre` target. SwiftPM builds release targets whole-module, so that is one compile of the whole `Byre` target, and a one-file change still costs that. The app keeps its server origin, drafts and visits in SwiftData (see "Persistence").
@@ -84,7 +84,7 @@ Focus lives in `Store`. In-app `#/` links go through `Store.route`: `#/task/<id>
One window. The panes follow the "UI layout" rules in `AGENTS.md`: an `HStack` of two panes of exactly equal width at every size, no sidebar, no navbar, nothing above them but the identity banner when the user has no git identity. The title bar is the standard one; no byre controls go in it or in a toolbar.
- **Left pane:** tabs Lead, Status, Settings, with "reconnecting…", the connection dot and the user's name at the end of the strip.
- **Left pane:** tabs Lead, Status, Server, Settings, with "reconnecting…", the connection dot and the user's name at the end of the strip.
- **Right pane:** the task bar (task picker `<project> ▸ <title> ▾` and the preview links), then the task's tabs in the server's focus order, hiding Questions, Reviews and Workflow when they have nothing, with a **More ▾** menu for tabs that don't fit, carrying their badges. The pane's accent is the selected task's colour.
Overlays (the switcher, alerts, confirmations, the merge dialog, keyboard help) are SwiftUI views drawn above the panes inside the window, not system sheets or alerts. The switcher and the Discard confirmation sit just below their anchor; the merge dialog is centred in the right pane.
@@ -151,7 +151,7 @@ The app menu has About, Check for Updates…, Change Server…, Hide, Hide Other
## Connection
On first launch the window shows one field, Server URL (an `https` origin with no path), and Connect. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is Tailscale connected?"), 401 (no Tailscale identity: use the `ts.net` HTTPS address), 403 (the request didn't come through `tailscale serve`); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is kept as the app's one `ServerRecord` (see "Persistence") and startup runs; later launches start against it directly. While the connection screen shows with an empty field, each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) looks for a `ServerRecord` again and starts against it, so on a new Mac the address another Mac saved connects without typing.
On first launch the window shows one field, Server URL (an `https` origin with no path), Connect, and Run byre on this Mac. Run byre on this Mac runs the bundle's `Contents/Helpers/byre install` through the user's login shell (`pw_shell -l -c 'exec "$BYRE_HELPER" install'`, which sh, zsh, bash and fish all accept), since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`; it takes the address from the `byre is at` line, polls `GET /api/admin/status` for up to 10 s while the service starts, then connects to it as Connect does. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is Tailscale connected?"), 401 (no Tailscale identity: use the `ts.net` HTTPS address), 403 (the request didn't come through `tailscale serve`); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is kept as the app's one `ServerRecord` (see "Persistence") and startup runs; later launches start against it directly. While the connection screen shows with an empty field, each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) looks for a `ServerRecord` again and starts against it, so on a new Mac the address another Mac saved connects without typing.
## Updates
+27 -28
View File
@@ -11,6 +11,7 @@
- `internal/agents`: the agent session manager. It owns the running agent processes, task agents, lead agents (see "Lead agent") and reviewers (see "Reviews") alike, and records their activity as events.
- `internal/claude`: the Claude Code stream-json client (see `docs/claude-protocol.md`).
- `internal/codex`: the `codex app-server` JSON-RPC client, with types generated from the CLI's schema by `go generate ./internal/codex` (see `docs/codex-protocol.md`). One goroutine reads the CLI's stdout and blocks on the bounded `Events` channel when its consumer falls behind, so responses queued behind those notifications wait too: the goroutine draining `Events` (`codexProcess.drive`) therefore never waits on a reply itself. It writes `turn/steer` in order and waits for the reply on another goroutine, and starts the prose-question nudge and the turn-end `Deliver` on their own goroutines. Every request gives up after `rpcTimeout`, or the caller's earlier deadline, and that deadline covers waiting for the write lock, writing the request to stdin and waiting for the reply. Stdin is an `os.Pipe`, so writes take a deadline. A request whose deadline passes before any of it is written fails and leaves the CLI running. One that times out after part of its line is written has broken the framing, so byre kills the CLI, logging `codex stopped reading its stdin mid-line`, and the session ends as any exited process does.
- `internal/antigravity`: the Antigravity CLI (`agy`) stream-json client (see `docs/antigravity-protocol.md`). Each session has its own plugin directory and a `127.0.0.1` listener that serves byre's MCP tools and hooks to the CLI, and restarts the CLI on the same conversation after an interrupt or a model switch.
- `internal/tailscale`: `tailscale serve` for task previews (see "Previews").
- `internal/workflow`: validating workflow definitions and reading and writing their YAML files (see "Workflows").
- `hands/` is `byre-hands`, the Swift MCP server that lets task agents see and drive the Mac byre runs on (see "Screen and app control") and, bundled into the macOS app, the human's Macs (see "Client Macs").
@@ -88,7 +89,7 @@ One merge runs per project at a time. The Manager keeps, per project, a lock and
## Subtasks
A task's agent starts a subtask with the `create_subtask` MCP tool (`internal/agents/subtasks.go`): `title`, `prompt` and optional `model` (default: the caller's session model; `gpt-*` runs on Codex). It is a real task, with its own branch, worktree, port block, snapshots and reviews, made by `createTask` from the parent worktree's `HEAD` (the parent's last end-of-turn commit), with `tasks.parent_task_id` (`parent_task_id` on the Task) naming the parent. Its session is created by the caller's session creator, so commits use that user's git identity, and gets `prompt` as its first message (actor `agent`). A fork of a subtask is a subtask of the same parent. `Store.CreateTask` refuses a subtask (409, and the new worktree is removed) unless, in the same transaction, the parent is active and not being discarded, and `Store.RequestDiscard` refuses a task that has active subtasks or is merging into its parent in the transaction that starts the discard, so a parent's discard and its subtasks never overlap. The lead's `create_task`, `POST /api/projects/{id}/tasks` and `start_todo` make top-level tasks. `sqlite3 byre.db "SELECT id, title, parent_task_id, parent_merge_by FROM tasks WHERE parent_task_id != '' AND status = 'active'"` lists active subtasks.
A task's agent starts a subtask with the `create_subtask` MCP tool (`internal/agents/subtasks.go`): `title`, `prompt` and optional `model` (default: the caller's session model; `gpt-*` runs on Codex, `gemini-*` on Antigravity). It is a real task, with its own branch, worktree, port block, snapshots and reviews, made by `createTask` from the parent worktree's `HEAD` (the parent's last end-of-turn commit), with `tasks.parent_task_id` (`parent_task_id` on the Task) naming the parent. Its session is created by the caller's session creator, so commits use that user's git identity, and gets `prompt` as its first message (actor `agent`). A fork of a subtask is a subtask of the same parent. `Store.CreateTask` refuses a subtask (409, and the new worktree is removed) unless, in the same transaction, the parent is active and not being discarded, and `Store.RequestDiscard` refuses a task that has active subtasks or is merging into its parent in the transaction that starts the discard, so a parent's discard and its subtasks never overlap. The lead's `create_task`, `POST /api/projects/{id}/tasks` and `start_todo` make top-level tasks. `sqlite3 byre.db "SELECT id, title, parent_task_id, parent_merge_by FROM tasks WHERE parent_task_id != '' AND status = 'active'"` lists active subtasks.
- **Control.** The parent's agent has `list_subtasks`, `read_subtask` (the same transcript text as the lead's `read_transcript`), `message_subtask` (through `Manager.Message`, so it is queued and delivered mid-turn while the subtask works, and starts the subtask's agent when it has none), `interrupt_subtask`, `merge_subtask` (`target` `parent` or `main`) and `discard_subtask`. Each refuses a task that isn't the caller's own subtask. Merges and discards are done as the parent session's creator.
- **Reporting.** When a subtask's turn ends with a summary (`turn.ended` with `summary: true`, see "Agent sessions"), byre sends the turn's final message to the parent's agent as a `byre` message headed "Your subtask … finished a turn" (`reportToParent`), queued like any message if the parent is mid-turn, or starting the parent's agent if it has none. Nothing is sent once the parent is closed, or while the subtask is merging into it.
@@ -112,25 +113,21 @@ A team task has several agents working at once in its one worktree, branch and p
The listen address is `byre serve --listen`, whose default is `defaultListen` in `cmd/byre/serve.go` (`go run ./cmd/byre serve --help` prints it); keep it on loopback, since identity relies on requests arriving from `tailscale serve`.
`byre install` (`cmd/byre/install.go`) sets the service up for the binary it is run as, with `byre-hands` beside it: it writes `~/Library/LaunchAgents/run.fc.byre.plist` running `<that binary> serve`, with a `PATH` of that binary's directory (so `byre-hands` beside it is found) followed by the `PATH` of the shell it was run from (which must find `claude`, `codex`, `git` and `npx`, the last for the Playwright MCP server, which also needs Google Chrome) and logs to `~/Library/Logs/byre.log`; boots out the service if it is loaded, so the new plist is read; bootstraps it; and runs `tailscale serve --bg --https=443 http://<default listen address>` through the Tailscale app's CLI (`tailscale.ServeRoot`, see "Previews"), printing `byre is at https://<machine>.<tailnet>.ts.net`. Running it again after an update restarts the service on the new binary. From a release, that is `/Applications/Byre.app/Contents/Helpers/byre install`. From a checkout:
```
go install ./cmd/byre
codesign --force --sign "Apple Development" --identifier run.fc.byre ~/bin/byre
swift build --package-path hands --configuration release
install hands/.build/release/byre-hands ~/bin/
~/bin/byre install
```
`byre install` (`cmd/byre/install.go`) sets the service up for the binary it is run as, with `byre-hands` beside it: it writes `~/Library/LaunchAgents/run.fc.byre.plist` running `<that binary> serve`, with a `PATH` of that binary's directory (so `byre-hands` beside it is found) followed by the `PATH` of the shell it was run from (which must find `claude`, `codex`, `agy`, `curl`, `git` and `npx`, `curl` for Antigravity's hooks and `npx` for the Playwright MCP server, which also needs Google Chrome) and logs to `~/Library/Logs/byre.log`; runs `tailscale serve --bg --https=443 http://<default listen address>` through the Tailscale app's CLI (`tailscale.ServeRoot`, see "Previews"), printing `byre is at https://<machine>.<tailnet>.ts.net`; and bootstraps the service. When the service is already loaded, it hands `launchctl bootout` and `bootstrap` to a detached `sh` in its own session and exits, so the new plist is read: `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. The service always runs from the app, `/Applications/Byre.app/Contents/Helpers/byre install`, which the app's Server tab and connection screen run for you (see "UI").
byre is served at the root of the machine's `ts.net` name on 443 (`https://<machine>.<tailnet>.ts.net`), not under a path: the API uses absolute paths, and the macOS app takes an origin with no path. `tailscale serve status` shows the URL and what else the machine serves. `launchctl print gui/$(id -u)/run.fc.byre` shows the service and the program it runs.
After rebuilding from a checkout: step 1 of "Deploying" in `AGENTS.md`, which restarts whatever the plist runs, so it only takes effect when the plist points at `~/bin/byre`. Logs go to `~/Library/Logs/byre.log`.
A new build reaches the service by replacing `/Applications/Byre.app` and restarting the service: `mac/deploy.sh` (see "Releases"), or the app's updater, which restarts the service when it launches as a new version. Logs go to `~/Library/Logs/byre.log`.
## Releases
A release is one notarized disk image, `byre-<version>.dmg`, holding `Byre.app`: the macOS app, with the server (`Contents/Helpers/byre`) and `byre-hands` (`Contents/Helpers/byre-hands`) inside it. Every Mac installs the same app; the server's Mac also runs `byre install` from it (see "Running as a service"). Releases are on the repo's releases page on g.fc.run, `https://g.fc.run/ai/byre/releases`.
`mac/release.sh` makes one, run on byre's host from a checkout whose `HEAD` carries a `v<version>` tag already pushed to `origin`, with a clean tree; it fetches the tag from `origin` and stops unless it names `HEAD`. It builds with `mac/bundle.sh` (signed with Developer ID, see `docs/macos.md`), zips the app and notarizes it with `xcrun notarytool submit --keychain-profile byre-notary --wait`, staples the ticket, builds the disk image with `hdiutil` (the app and an `/Applications` link), signs, notarizes and staples that too, and writes its SHA-256 beside it. It then signs the image for Sparkle with `sign_update --account byre` and writes `appcast.xml`, a feed with that one release. Last, it creates the release as a draft through the Gitea API (`POST /api/v1/repos/ai/byre/releases`, notes the commit subjects since the previous tag), uploads the image, its `.sha256` and `appcast.xml` as assets, and only then publishes it (`PATCH` with `draft: false`), so the `latest` release always has its appcast. A run that fails part way leaves the draft on the releases page; delete it there before running again. Everything lands in `mac/.build/release/<version>/`, and the script prints the release page.
`mac/package.sh` builds one for whatever the checkout holds, into `mac/.build/release/<version>/`, `<version>` being the bundle's `CFBundleShortVersionString`, and prints the image's path. It builds with `mac/bundle.sh` (signed with Developer ID, see `docs/macos.md`), zips the app and notarizes it with `xcrun notarytool submit --keychain-profile byre-notary --wait`, staples the ticket, builds the disk image with `hdiutil` (the app and an `/Applications` link), signs, notarizes and staples that too, and writes its SHA-256 beside it. It then signs the image for Sparkle with `sign_update --account byre` and writes `appcast.xml`, a feed with that one release.
A deploy is that image installed rather than published: `mac/deploy.sh` runs `package.sh`, installs the image's app with `mac/install.sh <dmg>` (mounts the image, copies `Byre.app` to `/Applications/Byre.app.new`, removes the old bundle and moves the new one into place, so nothing running from the old bundle has its files written over; it prints the installed `ByreCommit`), and restarts the service with `launchctl kickstart -k`. Client Macs get the same image through the client tools and `install.sh` (`AGENTS.md`, "Deploying"). A deployed build's `CFBundleVersion` is at least the last release's, so the updater never offers to go back to it.
`mac/release.sh` publishes one, run on byre's host from a checkout whose `HEAD` carries a `v<version>` tag already pushed to `origin`, with a clean tree; it fetches the tag from `origin` and stops unless it names `HEAD`, then runs `package.sh`. Last, it creates the release as a draft through the Gitea API (`POST /api/v1/repos/ai/byre/releases`, notes the commit subjects since the previous tag), uploads the image, its `.sha256` and `appcast.xml` as assets, and only then publishes it (`PATCH` with `draft: false`), so the `latest` release always has its appcast. A run that fails part way leaves the draft on the releases page; delete it there before running again. Everything lands in `mac/.build/release/<version>/`, and the script prints the release page.
It needs, on that Mac:
@@ -147,7 +144,7 @@ Clients reach `tailscale serve` over HTTP/2; it proxies to byre over HTTP/1.1 an
**Building.** `mac/bundle.sh`, from any directory, builds the release configuration with `swift build` in `~/Library/Caches/byre/build`, one build directory every worktree shares and takes turns in (`docs/macos.md`, "Stack", says why), and assembles `mac/.build/Byre.app`: the `Byre` executable in `Contents/MacOS`; the server, built with `go build ./cmd/byre`, and `byre-hands`, built from `hands/`, in `Contents/Helpers` (not beside `Byre`: the default APFS volume ignores case, so `byre` and `Byre` would be one file); Sparkle's framework in `Contents/Frameworks`; `mac/Info.plist` with a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, `CFBundleShortVersionString` its `git describe --tags --always` without the leading `v` (`0.1.0` on a release tag, `0.1.0-12-gabc1234` after it) and `CFBundleVersion` its `git rev-list --count HEAD` (the About panel shows them as "Version <version> (<count>)", and Sparkle compares the count); the `mac/fonts/` woff2 files, an icon rendered from `mac/icon.svg` with `rsvg-convert` and `iconutil` (cached as `mac/.build/byre.icns` until the SVG changes) and `mac/byre.provisionprofile`. It signs each piece with the Developer ID Application certificate, a secure timestamp and the hardened runtime, innermost first: `byre-hands` as `run.fc.byre.hands` and the server as `run.fc.byre`, both with `mac/automation.entitlements` (Apple Events, which the hardened runtime otherwise refuses), Sparkle's `Autoupdate`, `Updater.app` and framework (its XPC services, only needed by sandboxed apps, are removed), then the bundle with `mac/byre.entitlements`. It prints the bundle's path. It needs Xcode selected (`xcode-select --print-path` prints `/Applications/Xcode.app/Contents/Developer`), Go, `rsvg-convert` (Homebrew's `librsvg`), the team's Developer ID Application certificate and network access for the timestamp, and it stops first unless `mac/byre.provisionprofile` has `ProvisionsAllDevices` (a Developer ID profile; a development profile can't authorize the Developer ID signature); `docs/macos.md`, "Signing", covers the certificate and profile. Debug builds are refused by an `#error` in `ByreApp.swift`. The first build on a Mac fetches the Swift packages pinned in `mac/Package.resolved` and compiles them and the generated API client; later builds, from any worktree, recompile only the targets whose sources differ from the last build's, usually just `Byre`. The API client is generated from `api/openapi.yaml` at build time. The palette is `mac/Sources/Byre/Palette.swift`, one light and one dark value per colour.
**Installing.** A release is installed from its disk image into `/Applications` (see "Releases") and updates itself (`docs/macos.md`, "Updates"). A build from a checkout runs from where it was built: `open mac/.build/Byre.app` in the worktree that built it. A new build replaces the bundle, so quit the running app first (`pkill -x Byre` quits every running copy, whichever worktree built it) and open the new one; step 4 of "Deploying" in `AGENTS.md` does this after a deploy. Every build shares the bundle identifier `run.fc.byre.mac` and the same store, so any build reconnects to the server the last one used. Closing the window leaves the app running for notifications; ⌘Q quits it.
**Installing.** A release is installed from its disk image into `/Applications` (see "Releases") and updates itself (`docs/macos.md`, "Updates"). A deploy installs its build there the same way (see "Releases"). A build from a checkout can also run from where it was built, `open mac/.build/Byre.app` in the worktree that built it, after quitting the running app (`pkill -x Byre` quits every running copy). Every build shares the bundle identifier `run.fc.byre.mac` and the same store, so any build reconnects to the server the last one used. Closing the window leaves the app running for notifications; ⌘Q quits it.
**Storage.** The app keeps three kinds of record in SwiftData (`mac/Sources/Byre/Persistence.swift`): the server origin, when each task was last selected, and composer and answer drafts as JSON, one record per server and login (`docs/macos.md`, "Persistence" and "Drafts"). The store is `~/Library/Application Support/run.fc.byre.mac/byre.store`, with its `-shm` and `-wal` files and CloudKit's working files beside it in that directory; the app isn't sandboxed, so it names its own file rather than SwiftData's default `default.store`, which every unsandboxed app shares. The store syncs through the iCloud private database of container `iCloud.run.fc.byre.mac`, in CloudKit's Production environment (`com.apple.developer.icloud-container-environment` in `mac/byre.entitlements`), whose schema is deployed from Development in the CloudKit console before a release that changes the SwiftData models; the CloudKit console (icloud.developer.apple.com, the container, Production, Private Database, zone `com.apple.coredata.cloudkit.zone`) shows the records. Deleting the local files while the app isn't running makes the next launch download the records again from iCloud; deleting the records in the console as well starts from the connection screen. Pasted images stay in memory and are dropped on Change Server… and quit. The app logs through `os.Logger`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it.
@@ -198,11 +195,11 @@ curl -H 'Tailscale-User-Login: you@example.com' -H 'Tailscale-User-Name: You' ht
## 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 message sent while a task session's turn is running, from the app, the lead's `send_message`, a parent's `message_subtask` or byre's review findings, is queued (`Manager.Message` in `internal/agents/queue.go`) in `task_inputs`, keyed by task and team member (`member`, empty on a task with one agent) so it survives a handoff to a new session, and recorded, in the same transaction, as a `user.queued` event (its `text`, `images` and `task_inputs` `id`, the sender as actor), and the request returns the running session with 202. `DELETE /api/tasks/{id}/queue/{input id}` takes a queued message back (`Manager.Withdraw`): under the task's queue lock (`queueLockKey`), which both hand-over paths (`deliverQueued`, `takeQueuedMidTurn`) hold from reading the queue until the message is sent or taken, whichever session of the task is delivering, it deletes the row and records a `user.unqueued` event with its `id`, or answers 409 once the message has been handed over (the row is gone). A model it carries is applied to the session at once and takes effect from the next turn. The agent gets queued messages after its next tool call, the same way as inbox answers (see "Inbox"): each is recorded as its own `user.message` with the sender as actor and `mid_turn: true`, and the agent reads them, headed "<sender> sent you this message while you were working", in the hook's `additionalContext` (Codex: `turn/steer`). Messages are handed over oldest first, stopping at the first one with images, since mid-turn delivery carries only text; none are handed over during a handoff turn. Whatever is still queued when the turn ends goes out one message per turn, ahead of held inbox answers, from `Deliver`; startup does the same for idle sessions. Closing the task deletes its queued messages. `sqlite3 byre.db 'SELECT * FROM task_inputs'` lists them. A task has at most one session that isn't `completed` per team member, so a task with one agent has at most one (see "Teams"). 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`.
`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 message sent while a task session's turn is running, from the app, the lead's `send_message`, a parent's `message_subtask` or byre's review findings, is queued (`Manager.Message` in `internal/agents/queue.go`) in `task_inputs`, keyed by task and team member (`member`, empty on a task with one agent) so it survives a handoff to a new session, and recorded, in the same transaction, as a `user.queued` event (its `text`, `images` and `task_inputs` `id`, the sender as actor), and the request returns the running session with 202. `DELETE /api/tasks/{id}/queue/{input id}` takes a queued message back (`Manager.Withdraw`): under the task's queue lock (`queueLockKey`), which both hand-over paths (`deliverQueued`, `takeQueuedMidTurn`) hold from reading the queue until the message is sent or taken, whichever session of the task is delivering, it deletes the row and records a `user.unqueued` event with its `id`, or answers 409 once the message has been handed over (the row is gone). A model it carries is applied to the session at once and takes effect from the next turn. The agent gets queued messages after its next tool call, the same way as inbox answers (see "Inbox"): each is recorded as its own `user.message` with the sender as actor and `mid_turn: true`, and the agent reads them, headed "<sender> sent you this message while you were working", in the hook's `additionalContext` (Codex: `turn/steer`; Antigravity: a user message injected by its `PreInvocation` hook before the model's next call, `agyBeforeModel` in `internal/agents/antigravity.go`). Messages are handed over oldest first, stopping at the first one with images, since mid-turn delivery carries only text; none are handed over during a handoff turn. Whatever is still queued when the turn ends goes out one message per turn, ahead of held inbox answers, from `Deliver`; startup does the same for idle sessions. Closing the task deletes its queued messages. `sqlite3 byre.db 'SELECT * FROM task_inputs'` lists them. A task has at most one session that isn't `completed` per team member, so a task with one agent has at most one (see "Teams"). 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`.
`users.default_model` is the model the app starts new work on: ⌘K's Start and New task (whose composer then offers it first), the Project tab's and TODO links' `start`, suggestion links' `start` and `discuss` (when there is no live lead session), and the composer of a task or lead with no live session. It is returned as `default_model` on `GET /api/me`, set with `PUT /api/me/default-model` from the Settings tab's Default model menu, and records `user.updated`, so every open app reloads it. The server itself never reads it: the lead's `create_task` and `start_todo` default to the lead's own model, and `create_subtask` to the parent session's.
Images: `POST /api/blobs` takes a raw image body (type sniffed from the bytes, PNG, JPEG, GIF or WebP only, at most 5 MiB; `ImageTypes` and `MaxBlobBytes` in `internal/store/blob.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 before the text: to Claude as a base64 `image` content block (`claude.Session.Send`), to Codex as an `image` input with a `data:` URL (`codex.Session.Send`). The `user.message` event records the ids as `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.
Images: `POST /api/blobs` takes a raw image body (type sniffed from the bytes, PNG, JPEG, GIF or WebP only, at most 5 MiB; `ImageTypes` and `MaxBlobBytes` in `internal/store/blob.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 before the text: to Claude as a base64 `image` content block (`claude.Session.Send`), to Codex as an `image` input with a `data:` URL (`codex.Session.Send`). Antigravity's stream-json input takes only text, so `antigravity.Session.Send` writes each image under the session's directory and adds a line naming its path for the agent to open with `view_file`. The `user.message` event records the ids as `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, always shown and enabled only while the task has an idle session) 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 starts with `handoffPreamble` plus the handoff as its first message (actor `byre`; its `user.message` carries `continuation: true`). The request's optional `model` (the button's long-press menu, `compact_task`'s `model`) picks the new session's model, on either vendor; without it the new session keeps the old one's. The old session still writes the handoff, so this can't get past a vendor that is rate-limited. The chosen model is held in memory (`Manager.handoffModel`) until the handoff turn ends. The context meter tracks the new session. The request's `user.message` carries `handoff: true`, and from it the transcript puts the request and everything the agent does in that turn into one collapsed note reading "writing a handoff…", without streaming the agent's text; when the turn ends its summary becomes the `turn.ended` note, or "handoff not written" if the turn ended without one. The transcript never draws the new session's first message (`continuation: true`), so that note, which holds the handoff, is the only marker of the switch. If byre restarts during the handoff turn, the continuation is lost and the old session simply goes idle.
@@ -210,13 +207,13 @@ Task agents spawn parallel work as subtasks (see "Subtasks").
Agents don't commit; byre does. Each `user.message` event that starts a turn 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`, under the task's mutex, see "Teams"), 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 records a `session.error` in the task's transcript and the turn ends unfinished: no summary, no handoff, no merge finalize, and a workflow stage neither finishes nor records its output, so the next stage never branches from a `HEAD` that lacks the turn's work.
A session runs on one of two vendors, picked by the model it is created with (`workflow.VendorOf` in `internal/workflow/workflow.go`): `gpt-*` models run on Codex (`codex app-server`), every other model on Claude Code. The UI's model list is `models` in `mac/Sources/Byre/Composer.swift`; a live session's picker offers only its own vendor's models, and the API refuses (400) a switch to the other vendor's model. Both vendors sit behind `agentProcess` (`internal/agents/process.go`), and both end a turn through the same `turnEnded`, so commits, turn summaries, snapshots, handoffs, merges and deliveries don't depend on the vendor.
A session runs on one of three vendors, picked by the model it is created with (`workflow.VendorOf` in `internal/workflow/workflow.go`): `gpt-*` models run on Codex (`codex app-server`), `gemini-*` models on Antigravity (`agy`), every other model on Claude Code. The UI's model list is `models` in `mac/Sources/Byre/Composer.swift`; a live session's picker offers only its own vendor's models, and the API refuses (400) a switch to another vendor's model. All vendors sit behind `agentProcess` (`internal/agents/process.go`), and all end a turn through the same `turnEnded`, so commits, turn summaries, snapshots, handoffs, merges and deliveries don't depend on the vendor.
For Claude, byre chooses the session ID (`vendor_session_id`) up front. Codex picks its own thread id: a Codex session is created with an empty `vendor_session_id`, and byre stores the thread id when its first process starts (`startCodex`); a session with an id resumes it with `thread/resume`. Processes start on demand: the first message after a restart resumes the session (`--resume` for Claude). Sessions are `running` from the moment a turn is sent until it ends (Claude's `result`, Codex's `turn/completed`); 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`.
For Claude, byre chooses the session ID (`vendor_session_id`) up front. Codex picks its own thread id: a Codex session is created with an empty `vendor_session_id`, and byre stores the thread id when its first process starts (`startCodex`); a session with an id resumes it with `thread/resume`. Antigravity does the same with its conversation id, which `agy` reports in its `init` line at launch (`startAntigravity`), resumed with `--conversation`. Processes start on demand: the first message after a restart resumes the session (`--resume` for Claude). Sessions are `running` from the moment a turn is sent until it ends (Claude's and Antigravity's `result`, Codex's `turn/completed`); 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), put everything the human needs in a question's context since that turn has no summary, don't commit (byre commits each turn), and don't push, touch other branches or run git maintenance. A task agent's guidance also names its port range, tells it to do the setup AGENTS.md describes in a new worktree (see "Worktree setup and ports"), and quotes the task's title, which may be all the human typed when creating the task. A task started from a `TODO.md` item gets one more line naming the item and telling the agent to remove it from `TODO.md` in its worktree when the work is done (`taskGuidance`; see "TODO"). A subtask gets one naming its parent and saying that its final messages go to the parent's agent (`subtaskGuidance`; see "Subtasks"). For Claude 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. For Codex it is the thread's `developerInstructions`, and every `turn/start` also carries it as `additionalContext` under the key `byre`. The guidance names byre's tools vendor-neutrally ("byre's ask_human tool"): Claude sees them as `mcp__byre__<name>`, Codex as `<name>` in the namespace `byre` (Codex reserves the `mcp__` prefix).
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), put everything the human needs in a question's context since that turn has no summary, don't commit (byre commits each turn), and don't push, touch other branches or run git maintenance. A task agent's guidance also names its port range, tells it to do the setup AGENTS.md describes in a new worktree (see "Worktree setup and ports"), and quotes the task's title, which may be all the human typed when creating the task. A task started from a `TODO.md` item gets one more line naming the item and telling the agent to remove it from `TODO.md` in its worktree when the work is done (`taskGuidance`; see "TODO"). A subtask gets one naming its parent and saying that its final messages go to the parent's agent (`subtaskGuidance`; see "Subtasks"). For Claude 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. For Codex it is the thread's `developerInstructions`, and every `turn/start` also carries it as `additionalContext` under the key `byre`. For Antigravity it is an always-on rule in the session's plugin (`rules/AGENTS.md`, with the notes in `rules/notes.md`), written when byre starts the session's process, and every message also starts with it in `<system-reminder>` tags. The guidance names byre's tools vendor-neutrally ("byre's ask_human tool"): Claude sees them as `mcp__byre__<name>`, Codex as `<name>` in the namespace `byre` (Codex reserves the `mcp__` prefix), Antigravity as `<name>` on the MCP server `byre_byre`, called through its `call_mcp_tool` (recorded as `mcp__byre__<name>`).
A turn whose final message asks the human something in prose is bounced once (`takeBounce` in `internal/agents/guidance.go`): if `ask_human` wasn't called during the turn and the final message's last paragraph has a sentence ending in `?` outside code spans, code blocks, quotes, blockquotes, table rows and `<details>` sections, the agent is told to ask through `ask_human` and carries on. Questions earlier in the message (quoted, answered, or listed for someone else) don't count. A task started as a TODO discussion (its first `user.message` holds `discussPrompt` from `internal/agents/tasks.go`) is never bounced, since that prompt tells the agent not to use `ask_human`, and neither is a task with an open question already in the inbox, since the prose then points at that question. A bounce is recorded as a `byre` `user.message` with `mid_turn: true` and `bounce: true` carrying the nudge; the transcript draws neither it nor the agent message before it, so the rewritten message takes the blocked one's place. For Claude the bounce is a Stop hook (`bounceProseQuestion`) that blocks the stop; Claude Code reports the block as a "Stop hook error" notification, which is expected. For Codex, byre doesn't end its turn on `turn/completed`: it records the nudge and starts another Codex turn with it, and only that turn's end ends byre's turn. The per-turn flags reset when the next user message is sent. An agent told explicitly to ask in plain text will refuse the bounce.
A turn whose final message asks the human something in prose is bounced once (`takeBounce` in `internal/agents/guidance.go`): if `ask_human` wasn't called during the turn and the final message's last paragraph has a sentence ending in `?` outside code spans, code blocks, quotes, blockquotes, table rows and `<details>` sections, the agent is told to ask through `ask_human` and carries on. Questions earlier in the message (quoted, answered, or listed for someone else) don't count. A task started as a TODO discussion (its first `user.message` holds `discussPrompt` from `internal/agents/tasks.go`) is never bounced, since that prompt tells the agent not to use `ask_human`, and neither is a task with an open question already in the inbox, since the prose then points at that question. A bounce is recorded as a `byre` `user.message` with `mid_turn: true` and `bounce: true` carrying the nudge; the transcript draws neither it nor the agent message before it, so the rewritten message takes the blocked one's place. For Claude the bounce is a Stop hook (`bounceProseQuestion`) that blocks the stop; Claude Code reports the block as a "Stop hook error" notification, which is expected. For Codex, byre doesn't end its turn on `turn/completed`: it records the nudge and starts another Codex turn with it, and only that turn's end ends byre's turn. Antigravity works the same way on its `result`, since its `Stop` hook isn't given the final message. The per-turn flags reset when the next user message is sent. An agent told explicitly to ask in plain text will refuse the bounce.
A turn's final message is its summary, and it lives only in the transcript, so the human reads it once. When a task turn ends normally (not an error, not interrupted, not a handoff) with non-empty `result` text, didn't call `ask_human`, and isn't a Merge turn or discard turn (the task closes right after one, so its final message is never left to read), its `turn.ended` event carries `summary: true`. A Merge turn that called `stop_merge` keeps the task open and does carry it. That flag drives the lead's "finished a turn" signal and the "Turn summaries" notification (see "Lead agent" and "Notifications"). A turn that asked a question has no summary, since the final message comes after the question it explains; the guidance tells agents to put everything the human needs in the question's context. 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.
@@ -226,9 +223,11 @@ When Claude Code reports a rate-limit status of `rejected` and the turn then fai
Codex runs with its own home, `codex` under the data directory (`CODEX_HOME`, set by `startCodex`), 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. The `codex` binary comes from `PATH`; see `docs/codex-protocol.md`.
Antigravity runs `agy` from `PATH` with the user's own Antigravity login and config (`~/.gemini`), signed in once by running `agy` interactively; `agy models` lists the models that login offers. byre's tools, guidance and hooks reach it through a per-session plugin directory, `antigravity/<session-id>` under the data directory, removed when the session's process ends. Its conversations show up in the user's own `agy` history. Antigravity reports no context window, so its sessions have no `session.context`; its rate limits come from `agy --output-format json --print /usage` (`ReadUsage` in `internal/antigravity/usage.go`), which answers without starting a turn. See `docs/antigravity-protocol.md`.
## Status
`GET /api/admin/status` reports `running_turns` (task sessions now `running`, as session, task and vendor; lead turns aren't listed); `rate_limits`, the latest status each vendor sent since byre started (Claude's `rate_limit_info`, with `windowMins` added to each `unifiedWindows` entry by `addClaudeWindowMins` in `internal/agents/manager.go` from the window's name, since Claude doesn't send a length; Codex's `account/rateLimits/updated` reshaped by `codexRateLimit` in `internal/agents/codex.go` into the same `status`, `rateLimitType` and `unifiedWindows` fields, `windowMins` from its `windowDurationMins`). Claude only reports during its turns; Codex is also asked once at startup (`readCodexRateLimit`, a short-lived `codex app-server` that calls `account/rateLimits/read` without opening a thread), so its entry is there before any Codex turn runs, and every Codex update broadcasts `admin.health`; `agent_processes`, `zombies`, `stuck_after_seconds` and `protocol_errors` (below); `clients`, one entry per command channel byre holds (the `/api/clients/connect` WebSockets in `internal/clients`, see "Client Macs"), oldest first, so it lists exactly the Macs `list_clients` and the `client_*` tools can reach; a Mac whose event streams are open but whose command channel is down isn't listed. `id`, `host` and `commit` come from the `Byre-Client`, `Byre-Host` and `Byre-Commit` headers, which the macOS app sends on every request: a UUID the app makes up each time it starts, the Mac's name (`Host.current().localizedName`, else `ProcessInfo.processInfo.hostName`, the name agents' client tools take) and the `ByreCommit` that `mac/bundle.sh` stamped into its `Info.plist`. To check that a rebuilt app is running, compare each client's `commit` with the commit it was built from. `last_action_at` is when byre last received a request the human made from that client `id`: any request other than a `GET`, except the two the app sends on its own (`PUT /api/me/pane-focus` and `PUT /api/tasks/{taskId}/summary-read`, listed in `automaticRequests` in `internal/server/server.go`), and every binary message on a terminal WebSocket, which includes the replies SwiftTerm sends by itself to queries in the output (cursor position and the like). The `identify` middleware and `terminalInput` record it in the client registry's memory (`Registry.Acted` in `internal/clients`), so it is absent until that client's first action after byre starts. The registry keeps each client by `id` across command-channel drops, so a Mac that sleeps and reconnects keeps its `last_action_at`, and forgets a client 24 hours after it was last connected or acted, since an app that quit never comes back under that `id`. An action at least a second after the last one from that client that broadcast, every `GET /api/events` stream opening or closing, and every command channel connecting or disconnecting broadcasts an unstored `admin.clients` event (`clientsChanged`). And server counts (start time, agent processes, projects, active tasks, SSE clients, database size including WAL, last event seq).
`GET /api/admin/status` reports `running_turns` (task sessions now `running`, as session, task and vendor; lead turns aren't listed); `rate_limits`, the latest status each vendor sent since byre started (Claude's `rate_limit_info`, with `windowMins` added to each `unifiedWindows` entry by `addClaudeWindowMins` in `internal/agents/manager.go` from the window's name, since Claude doesn't send a length; Codex's `account/rateLimits/updated` reshaped by `codexRateLimit` in `internal/agents/codex.go` into the same `status`, `rateLimitType` and `unifiedWindows` fields, `windowMins` from its `windowDurationMins`; Antigravity's `/usage` buckets reshaped by `agyRateLimit` in `internal/agents/antigravity.go`, one `unifiedWindows` entry per bucket keyed by its `id`, `utilization` from `1 - remaining_fraction` and `windowMins` from its `window`, `rejected` when a bucket has none left). Claude only reports during its turns; Codex is also asked once at startup (`readCodexRateLimit`, a short-lived `codex app-server` that calls `account/rateLimits/read` without opening a thread), so its entry is there before any Codex turn runs, and every Codex update broadcasts `admin.health`. Antigravity is asked at startup and after every Antigravity turn ends (`readAgyRateLimit`), each read broadcasting `admin.health`; `agent_processes`, `zombies`, `stuck_after_seconds` and `protocol_errors` (below); `clients`, one entry per command channel byre holds (the `/api/clients/connect` WebSockets in `internal/clients`, see "Client Macs"), oldest first, so it lists exactly the Macs `list_clients` and the `client_*` tools can reach; a Mac whose event streams are open but whose command channel is down isn't listed. `id`, `host` and `commit` come from the `Byre-Client`, `Byre-Host` and `Byre-Commit` headers, which the macOS app sends on every request: a UUID the app makes up each time it starts, the Mac's name (`Host.current().localizedName`, else `ProcessInfo.processInfo.hostName`, the name agents' client tools take) and the `ByreCommit` that `mac/bundle.sh` stamped into its `Info.plist`. To check that a rebuilt app is running, compare each client's `commit` with the commit it was built from. `last_action_at` is when byre last received a request the human made from that client `id`: any request other than a `GET`, except the two the app sends on its own (`PUT /api/me/pane-focus` and `PUT /api/tasks/{taskId}/summary-read`, listed in `automaticRequests` in `internal/server/server.go`), and every binary message on a terminal WebSocket, which includes the replies SwiftTerm sends by itself to queries in the output (cursor position and the like). The `identify` middleware and `terminalInput` record it in the client registry's memory (`Registry.Acted` in `internal/clients`), so it is absent until that client's first action after byre starts. The registry keeps each client by `id` across command-channel drops, so a Mac that sleeps and reconnects keeps its `last_action_at`, and forgets a client 24 hours after it was last connected or acted, since an app that quit never comes back under that `id`. An action at least a second after the last one from that client that broadcast, every `GET /api/events` stream opening or closing, and every command channel connecting or disconnecting broadcasts an unstored `admin.clients` event (`clientsChanged`). And server counts (start time, agent processes, projects, active tasks, SSE clients, database size including WAL, last event seq).
Agent health is `internal/agents/health.go`:
@@ -239,7 +238,7 @@ Agent health is `internal/agents/health.go`:
The left pane's **Status** tab 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); each running turn, linking to its task, with the time since its process's last event and `stuck` or `exited` when it is; each vendor's rate-limit status with its reset time and, per window, the share used, the share of the window's time elapsed (from `resetsAt` and `windowMins`), and a pace bar whose fill is the share used and whose vertical line is the share elapsed, the fill warn-coloured when usage is ahead of the line; a Clients table, one row per entry in `clients`, with the Mac, user, the build's commit (cut to 8 characters, the full one in its tooltip), the time since it connected and the time since its last action; and the server counts. It refetches the status when a `session.created`, `session.state`, `task.*`, `project.created`, `admin.health` or `admin.clients` 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.subagents`, `session.model`, and `session.context`. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as `agent.vendor`. Codex items map onto the same kinds (`handleCodex` in `internal/agents/codex.go`): an `agentMessage` becomes `agent.message`, a `reasoning` item's summary `agent.thinking`, and `commandExecution` (as `Bash`), `fileChange` (as `apply_patch`), `dynamicToolCall`, `mcpToolCall` and `webSearch` items an `agent.tool_call` when they start and an `agent.tool_result` when they complete. Other completed items and the `error` and `warning` notifications are recorded as `agent.vendor`; the remaining notifications (thread status, MCP startup and the like) aren't recorded. `item/agentMessage/delta` becomes `agent.delta`, and `thread/tokenUsage/updated` `session.context`. 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 `summary: true` when its `result` is the turn's summary (see above). Activity inside a subagent (`agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`) carries `payload.parent_tool_use_id`, the ID of the tool call that started the subagent, and never updates `session.context`. `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's **Compact** button shows the latest one as a background fill across the button, proportional to the window, tinted amber from 70% and red from 90%; the exact count is in its tooltip.
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.subagents`, `session.model`, and `session.context`. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as `agent.vendor`. Codex items map onto the same kinds (`handleCodex` in `internal/agents/codex.go`): an `agentMessage` becomes `agent.message`, a `reasoning` item's summary `agent.thinking`, and `commandExecution` (as `Bash`), `fileChange` (as `apply_patch`), `dynamicToolCall`, `mcpToolCall` and `webSearch` items an `agent.tool_call` when they start and an `agent.tool_result` when they complete. Other completed items and the `error` and `warning` notifications are recorded as `agent.vendor`; the remaining notifications (thread status, MCP startup and the like) aren't recorded. `item/agentMessage/delta` becomes `agent.delta`, and `thread/tokenUsage/updated` `session.context`. Antigravity steps map the same way (`handleAntigravity` in `internal/agents/antigravity.go`): an `agent_response` step's `text_delta`s become `agent.delta` and its finished text `agent.message`, and a `tool` step an `agent.tool_call` when it starts and an `agent.tool_result` when it is done, with `run_command` recorded as `Bash` and `call_mcp_tool` as `mcp__<server>__<tool>` with its `Arguments` as input; `user_input` steps aren't recorded, and other step types are recorded 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 `summary: true` when its `result` is the turn's summary (see above). Activity inside a subagent (`agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`) carries `payload.parent_tool_use_id`, the ID of the tool call that started the subagent, and never updates `session.context`. `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's **Compact** button shows the latest one as a background fill across the button, proportional to the window, tinted amber from 70% and red from 90%; the exact count is in its tooltip.
Event payloads are stored whole in `events.payload`, and `agent.tool_result` content (full command output, file reads, diffs) dominates the log's size; the app loads every active task's whole history when it starts (see "Events"), so this is what its startup costs. `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events GROUP BY kind ORDER BY 3 DESC"` shows the split, and `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events WHERE task_id = '<task-id>' GROUP BY kind ORDER BY 3 DESC"` one task's.
@@ -260,7 +259,7 @@ The task switcher (⌘K, or the project ▸ task picker centred in the right pan
Each user has one long-running lead agent, shown in the left pane. It is not tied to a project: it watches every task, tells the human what needs them, points the right pane at it, and acts on the other agents. Its code is `internal/agents/lead.go` (sessions, held input, signals) and `internal/agents/leadtools.go` (its MCP tools).
- **Sessions.** A lead session is an `agent_sessions` row with `owner` set to the user's login, `role` `lead` and no `task_id`. Its events carry `owner` and no project or task. It runs in `leads/<login>` with `leadGuidance` (passed and re-attached the same way as a task agent's guidance) and the lead tools instead of the task tools. It has no Stop-hook bounce, no snapshots, no end-of-turn commit and no turn summary; a turn that ends in error records the error on its `session.state` event, which the pane shows as a note.
- **Messages.** `POST /api/me/lead/messages` sends the caller's message to their lead, starting a lead session when there is none. While the lead is mid-turn the message is held in `lead_inputs`; the lead's PostToolUse hook (Codex: `turn/steer` after each completed tool item) hands held messages over after its next tool call, like a task's queued messages (see "Agent sessions"), and any still held become its next turn. Signals are never handed over mid-turn. The composer stays enabled. `GET /api/me/lead/sessions` lists the caller's lead sessions; interrupt and compact use the usual session endpoints.
- **Messages.** `POST /api/me/lead/messages` sends the caller's message to their lead, starting a lead session when there is none. While the lead is mid-turn the message is held in `lead_inputs`; the lead's PostToolUse hook (Codex: `turn/steer` after each completed tool item; Antigravity: the `PreInvocation` hook) hands held messages over after its next tool call, like a task's queued messages (see "Agent sessions"), and any still held become its next turn. Signals are never handed over mid-turn. The composer stays enabled. `GET /api/me/lead/sessions` lists the caller's lead sessions; interrupt and compact use the usual session endpoints.
- **Scrollback.** `GET /api/me/lead/events` returns a page of the caller's lead events in whole turns, `turns` of them (default 50): those after the `turns`+1'th `turn.ended` before `before` (or before the latest event, so the page also holds a turn still running), with `more` saying whether earlier events exist. The lead pane loads only the last page (see "Events"); when its top scrolls into view (an empty row kept first in the transcript), it fetches the page before its first event (`loadOlderLead` in `mac/Sources/Byre/Store.swift`), puts its rows in front (`TranscriptBuilder.prepend`) and scrolls back to the row that was first. A page starts just after a `turn.ended`, so rendering it from scratch matches how it drew as part of the whole history. Live events still append without limit until the app is relaunched.
- **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation), `session.error` and `session.rate_limited` on a task, `inbox.updated` when a human answered a question (the signal also carries its text and the agent's recommendation, so the lead sees where the human departed from it), `task.created` (naming the TODO item it was started from, or a subtask's parent, if any), `task.merge_started` ("<task> is merging, started by <login>."), `task.merged`, `task.discarded`, `turn.ended` with `summary: true` ("<task> finished a turn:" with its `result` cut to 1500 characters), `task.workflow_stage` ("<task> started stage "<stage>" of workflow "<workflow>".") and `task.workflow_finished` ("<task> finished workflow "<workflow>":" with its output cut to 1500 characters), `review.started` ("<task> started a review of snapshot <n>."), `review.finished` ("<task> review of snapshot <n>:" with the review's summary, `change` and findings, or why it failed or was skipped; `reviewLine` in `internal/agents/review.go`), `user.message` on a task from a human (see "Pane lock"), and `task.state` ("<task> state: <from> → <to>.", or "<task> state: <to>." for a new task; see "Task state in the store"). The store hands `Observe` all of one transaction's events together, and they become one signal, its `task.state` lines first, so a turn's summary always arrives with the state its turn end left the task in. The human switching the right pane is not a signal. A signal caused by the lead itself (actor `<login> (lead)`) is skipped, as is the update from an answer it gave. A task's `task.created` event carries the actor that created it, separately from its `created_by` login: tasks the lead creates with `create_task` or `start_todo` keep the human's login as `created_by` but have actor `<login> (lead)`. Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as one `byre` message headed "Signals since your last turn (n)", followed by "Tasks now:" with the `list_tasks` text and a line saying what the lead's right pane shows (task or project, and selected tab) and whether the human is working in it (`leadStateText` in `internal/agents/lead.go`). If collecting that fails, for example because a project's repository is gone, the batch is still sent, ending instead with "The task list and right pane state are unavailable:" and the error, which is also logged as `lead state`. Its guidance tells it not to respond to the human's own actions, and to make its final message exactly `NO_REPLY` when a batch needs nothing from it. A transcript (`quietReply` in `mac/Sources/Byre/TranscriptBuilder.swift`) never draws an `agent.message` whose last line is `NO_REPLY` (the model sometimes writes a sentence before it), and hides the live partial message while its text is still a prefix of it, so an empty one never takes up space. A lead turn that byre starts without a handoff (a signal batch or a handoff continuation) is held (`ctx.held`): its `byre` message is never drawn, the turn isn't opened, so the scanner stays off, its partial message stays hidden, and its tool calls, subagents and thinking are built into a held row list instead of the transcript. The turn is released, inserting those rows where the turn belongs, when it draws anything else: a real message, a note, or its own `turn.ended` when it ends without `NO_REPLY`. When it ends in `NO_REPLY` while still held, the held rows are dropped, so nothing of the turn ever appears; a turn that draws a real message anywhere in it keeps all of it, even when a later message is `NO_REPLY`.
- **Tools.** `list_tasks` (each project's active tasks with their state, each subtask noting its parent, then a line listing the titles of its `TODO.md` items on the default branch that no active task was started from), `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`; closed tasks too), `search_closed_tasks` (see "Closing a task"), `read_inbox`, `read_status` (the `GET /api/admin/status` body as JSON, built by the same `Server.status`, which the server hands `agents.New`), `answer_question` (an `option` or `other` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; while it is mid-turn the message is queued through `Manager.Message` like the human's, see "Agent sessions", and the tool result says it was queued; on a team task it posts to the board, see "Teams"), `create_task` and `start_todo` (each with an optional `workflow` to freeze and run; `create_task` also takes `agents` to start a team), `create_project` (`name` and `repo_path`, created as the user by the same `Server.createProject` as `POST /api/projects`, which the server hands `agents.New`, so it publishes the same `project.created` event, and returned as JSON), `interrupt`, `compact_task`, `take_snapshot` (see "Snapshots"), `merge_task` (optional `target`: `main`, or `parent` for a subtask, see "Subtasks"), `discard_task`, `show`, `compact_self`, the note tools (see "Notes") and the workflow tools (see "Workflows"). The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor `<login> (lead)`, so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks.
@@ -274,7 +273,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie
Task agents get notes too. An implementer session's instructions end with `taskNotesGuidance` and `notesText` for the task's creator, limited to the every-project notes and the task's project's (`startLive` in `internal/agents/manager.go`), fixed when the agent process starts like the lead's. Reviewers get none. Implementers have a `suggest_note` tool (`text`, `evidence`) that queues a lead signal for the task's creator naming the task, the suggested text, the evidence and the project id, and refuses when the creator has no live lead session. The lead's guidance tells it to save a suggestion with `remember`, choosing its project, only when it holds and isn't already covered, and to tell the human nothing either way. A suggestion leaves no record of its own beyond the signal; a saved one shows as the lead's `lead.note_saved`.
- **Restarts.** On shutdown, byre marks each running lead session idle with "byre stopped mid-turn" and holds a restart notice for it, so a lead is never left `running` for a build without lead support to find. After a crash, startup resumes a running lead like a task session. Startup then delivers any held input.
The UI is two panes filling the window, with no navbar. The left has three tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Status and Settings, and at the end of its tab strip the connection dot and the user's name. Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Status or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Questions, Reviews, Workflow and Project, the lead's `tabs` first. Board shows only on a team task (see "Teams"), Reviews only once it has a `review.*` event, Inbox only while the task has open items, and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there, and More carries the Questions highlight and count when Questions is in it. The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open.
The UI is two panes filling the window, with no navbar. The left has four tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Status, Server and Settings, and at the end of its tab strip the connection dot and the user's name. Server is about the byre service on the Mac the app runs on, whatever server the app is connected to (`mac/Sources/Byre/Server.swift`): it is On when launchd has the service loaded (`launchctl print gui/<uid>/run.fc.byre` succeeds), showing the address from `tailscale serve status --json` and Turn off (`launchctl bootout`), and Off otherwise, showing Turn on, which runs the bundle's `byre install` as the connection screen's Run byre on this Mac does. Both Turn on and Run byre on this Mac use the running service's address instead of reinstalling when it is already on, since a reinstall restarts it and the old service could answer the first status check. It reads the state again each time it shows and when the app becomes active. Settings holds the user's and this client's preferences. Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Status, Server or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Questions, Reviews, Workflow and Project, the lead's `tabs` first. Board shows only on a team task (see "Teams"), Reviews only once it has a `review.*` event, Inbox only while the task has open items, and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there, and More carries the Questions highlight and count when Questions is in it. The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open.
## TODO
@@ -301,7 +300,7 @@ An `other` response, or a pick-any `option` one, may carry `images`, blob ids fr
A submission is all or nothing. `Store.Respond` resolves each item with an `UPDATE` conditioned on `state = 'open'` in one transaction, so when any item was answered, dismissed or withdrawn by someone else in the meantime (a second tab, the lead's `answer_question`, the agent's `cancel_question`), the whole submission is rolled back and refused with 409, and no response is recorded.
Answers reach the agent as one message holding every undelivered `option` and `other` response of the session (`deliveryMessage` in `internal/agents/inbox.go`), never while one of its questions is still open and unanswered (`AwaitingHuman` in `internal/store/inbox.go`); until then they stay held (`inbox_responses.delivered_at` empty). While a turn is running, the task session's PostToolUse hook (`afterTool` in `internal/agents/snapshots.go`, through `deliverMidTurn`) hands them over after the agent's next tool call as the hook's `additionalContext`, after any queued messages (see "Agent sessions"), so the agent reads them alongside that tool's result without being interrupted (for Codex, after each completed tool item, `deliverMidTurn` hands them over with `turn/steer` instead; when the steer fails, `restoreMidTurn` puts the taken queued messages back under their original ids and clears the answers' `delivered_at` and `snapshot_n`, so `Deliver` sends them when the turn ends, and logs `mid-turn delivery restored`); the `user.message` event recording it carries `mid_turn: true` and no `head`, and the transcript doesn't treat it as a turn start. A turn that makes no further tool call leaves them to `Deliver`, which runs after each submission and when a turn ends, and sends them as a new turn once the session is `idle`. Just before either hands answers over, byre snapshots the task's worktree (`snapshotForAnswer` in `internal/agents/snapshots.go`, trigger `answer`), synchronously and under the task's mutex, and records the snapshot's `n` on each answer it hands over (`inbox_responses.snapshot_n`, `snapshot_n` in the API), so the snapshot's tree and `event_seq` give the worktree and the conversation as they stood before the agent read the answer. A new snapshot is taken for every hand-over, even when the tree is unchanged, so each one's `event_seq` and session are the conversation boundary just before that answer and `POST /api/tasks/{taskId}/snapshots/{n}/fork` on it continues from there. An `answer` snapshot is never reviewed. Everything that looks for the task's latest snapshot to judge review state skips `answer` ones (`LastSnapshotNot`): the dedupe of the other triggers, which skip a snapshot only when its tree equals the last non-`answer` one's, so work an `answer` snapshot caught mid-turn is still snapshotted and reviewed when the turn ends; Merge after review; and the Merge confirmation's review warnings. A Merge turn already holds the task's mutex, so mid-turn delivery in it snapshots without taking it again; a worktree with merge conflicts snapshots with the conflict markers as they stand, and its real index is left untouched. No snapshot is taken once the task is closing, or when taking it fails; the answers then have no `snapshot_n`. A failure is logged as `take snapshot` and recorded as a `session.error` in the transcript, and the answers are still handed over: holding them would leave an idle agent waiting with nothing to retry the delivery. The log traces each answer: `inbox response recorded` when it is stored, `deliver held` with the reason, `deliver sent mid-turn` with the snapshot, `deliver sent` with the session's state, the snapshot and whether its agent process had exited or byre was closing, and `undelivered responses` with a reason wherever a session has held answers when its turn ends in a handoff or merge, its agent process ends, startup resumes it, or startup finds it idle. `sqlite3 byre.db "SELECT * FROM inbox_responses WHERE delivered_at IS NULL"` lists the answers held now.
Answers reach the agent as one message holding every undelivered `option` and `other` response of the session (`deliveryMessage` in `internal/agents/inbox.go`), never while one of its questions is still open and unanswered (`AwaitingHuman` in `internal/store/inbox.go`); until then they stay held (`inbox_responses.delivered_at` empty). While a turn is running, the task session's PostToolUse hook (`afterTool` in `internal/agents/snapshots.go`, through `deliverMidTurn`) hands them over after the agent's next tool call as the hook's `additionalContext`, after any queued messages (see "Agent sessions"), so the agent reads them alongside that tool's result without being interrupted (for Antigravity, the `PreInvocation` hook hands them over as a user message before the model's next call after the first; for Codex, after each completed tool item, `deliverMidTurn` hands them over with `turn/steer` instead; when the steer fails, `restoreMidTurn` puts the taken queued messages back under their original ids and clears the answers' `delivered_at` and `snapshot_n`, so `Deliver` sends them when the turn ends, and logs `mid-turn delivery restored`); the `user.message` event recording it carries `mid_turn: true` and no `head`, and the transcript doesn't treat it as a turn start. A turn that makes no further tool call leaves them to `Deliver`, which runs after each submission and when a turn ends, and sends them as a new turn once the session is `idle`. Just before either hands answers over, byre snapshots the task's worktree (`snapshotForAnswer` in `internal/agents/snapshots.go`, trigger `answer`), synchronously and under the task's mutex, and records the snapshot's `n` on each answer it hands over (`inbox_responses.snapshot_n`, `snapshot_n` in the API), so the snapshot's tree and `event_seq` give the worktree and the conversation as they stood before the agent read the answer. A new snapshot is taken for every hand-over, even when the tree is unchanged, so each one's `event_seq` and session are the conversation boundary just before that answer and `POST /api/tasks/{taskId}/snapshots/{n}/fork` on it continues from there. An `answer` snapshot is never reviewed. Everything that looks for the task's latest snapshot to judge review state skips `answer` ones (`LastSnapshotNot`): the dedupe of the other triggers, which skip a snapshot only when its tree equals the last non-`answer` one's, so work an `answer` snapshot caught mid-turn is still snapshotted and reviewed when the turn ends; Merge after review; and the Merge confirmation's review warnings. A Merge turn already holds the task's mutex, so mid-turn delivery in it snapshots without taking it again; a worktree with merge conflicts snapshots with the conflict markers as they stand, and its real index is left untouched. No snapshot is taken once the task is closing, or when taking it fails; the answers then have no `snapshot_n`. A failure is logged as `take snapshot` and recorded as a `session.error` in the transcript, and the answers are still handed over: holding them would leave an idle agent waiting with nothing to retry the delivery. The log traces each answer: `inbox response recorded` when it is stored, `deliver held` with the reason, `deliver sent mid-turn` with the snapshot, `deliver sent` with the session's state, the snapshot and whether its agent process had exited or byre was closing, and `undelivered responses` with a reason wherever a session has held answers when its turn ends in a handoff or merge, its agent process ends, startup resumes it, or startup finds it idle. `sqlite3 byre.db "SELECT * FROM inbox_responses WHERE delivered_at IS NULL"` lists the answers held now.
### Errors
@@ -371,7 +370,7 @@ The view shows a breadcrumb from `worktree` down to the current directory or fil
Each active task can have one terminal: a login shell (`$SHELL -l`, from byre's environment) on a PTY (`github.com/creack/pty`), started in the task's worktree with byre's environment plus `TERM=xterm-256color`, `COLORTERM=truecolor` and the task's `PORT` (`internal/terminal`, which the server creates and hands `agents.New`). `GET /api/tasks/{taskId}/terminal?cols=&rows=` is a WebSocket (`github.com/coder/websocket`, `internal/server/terminal.go`), the only WebSocket in byre: it attaches to the task's terminal, starting the shell at that size if none is running and resizing it to that size if one is, and works only while the task is active (409 otherwise). The server first sends the last `terminalBacklog` bytes the terminal printed, then its output as it comes, as binary messages; the client sends keystrokes as binary messages and a `TerminalSize` JSON text message on every resize. Any number of sockets can attach to one terminal: they all get its output and all type into it, and the last resize wins. A socket that falls `terminalClientBuf` reads behind is closed and the app reconnects. When the shell exits, every socket is closed with code 4000 and the exit status (`exit status 0`, `signal: hangup`) as the reason, and the next attach starts a new shell. Merging or discarding the task, and stopping byre, sends the shell's process group SIGHUP and closes the PTY, so terminals don't survive a restart. The WebSocket handshake checks that `Origin` matches `Host`, so another site can't open one through the user's Tailscale identity. The log records `terminal started` and `terminal ended` with the task, pid and exit status.
A task's agents use the same terminal through two MCP tools (`internal/agents/terminal.go`). `terminal_read` returns the backlog as plain text; it starts no shell, and says so when none is running. `terminal_type` writes `text` to the PTY as keystrokes, starting the shell at 120×40 if none is running and leaving a running one's size alone (refused once the task is closed). It then returns, as plain text, the output that followed, once none has arrived for 500ms or after 10s, whichever comes first, holding at most the last 512 KiB of raw output while it waits. It types on its own goroutine one byte per write and stops between bytes once the call returns. A program in raw mode that stops reading blocks a PTY write, and the PTY's file doesn't support deadlines, so typing one byte at a time keeps the call within its limits while leaving at most one byte to arrive late. The reply then says how many bytes were typed. Typing from an agent doesn't count as the human acting (`last_action_at`). The plain text comes from `terminal.Text` (`internal/terminal/text.go`). It replays the output one line at a time, keeping track of the cursor column, so a carriage return overwrites the line (progress bars, zsh's `\r\r\n` after a command) instead of duplicating it, and a backspace moves the cursor back. It applies erase-to-end-of-line (`CSI K`) and drops every other escape sequence and control character. It keeps the last 64 KiB, cut at a line start. Full-screen programs come out garbled, since it isn't a screen emulator.
A task's agents use the same terminal through two MCP tools (`internal/agents/terminal.go`). `terminal_read` returns the backlog as plain text; it starts no shell, and says so when none is running. `terminal_type` writes `text` to the PTY as keystrokes, starting the shell at 120×40 if none is running and leaving a running one's size alone (refused once the task is closed). Within a shell's first 10s, it first waits for the prompt: until the shell has printed something and then gone 500ms without output (`Terminal.Activity` gives the start and last output times), or until 10s after the start. Typing before that would reach the PTY while it still echoes in cooked mode, before zsh's line editor takes over, so the text would show twice. The startup output, prompt included, is part of the reply. It then returns, as plain text, the output that followed, once none has arrived for 500ms or after 10s, whichever comes first, holding at most the last 512 KiB of raw output while it waits. It types on its own goroutine one byte per write and stops between bytes once the call returns. A program in raw mode that stops reading blocks a PTY write, and the PTY's file doesn't support deadlines, so typing one byte at a time keeps the call within its limits while leaving at most one byte to arrive late. The reply then says how many bytes were typed. Typing from an agent doesn't count as the human acting (`last_action_at`). The plain text comes from `terminal.Text` (`internal/terminal/text.go`). It replays the output one line at a time, keeping track of the cursor column, so a carriage return overwrites the line (progress bars, zsh's `\r\r\n` after a command) instead of duplicating it, and a backspace moves the cursor back. It applies erase-to-end-of-line (`CSI K`) and drops every other escape sequence and control character. It keeps the last 64 KiB, cut at a line start. Full-screen programs come out garbled, since it isn't a screen emulator.
The **Terminal** tab (`mac/Sources/Byre/Terminal.swift`) draws it with SwiftTerm's `TerminalView`. The terminal uses the UI font and is coloured from the palette (background, text, accent cursor, accent-tint selection); ANSI colours are SwiftTerm's defaults. The colours are set once per light/dark scheme (`TerminalSession.scheme`), never on other SwiftUI updates, since each colour set makes SwiftTerm redraw the whole screen and the tab re-renders on every task change in the store. It fits the pane and resizes with it. Opening the tab, or selecting another task while it is open, opens one socket for the selected task; switching tab or task closes it, and the shell keeps running, so coming back replays its recent output. A socket that drops for any other reason reconnects after 2s while the task is active; after a byre restart that starts a new shell. When the shell exits the header shows `shell ended: <status>` and a **New shell** button; when it can't be started (the socket closes with 1011, the error in byre's log as `start terminal`), the header says so, with the same button to try again. Ctrl key combinations typed in the terminal go to the shell, not to byre's Ctrl-K, Ctrl-P, Ctrl-L and Ctrl-S shortcuts; ⌘K, ⌘P, ⌘L and ⌘S still work.
@@ -391,7 +390,7 @@ The transcript is the timeline. The UI learns a task's snapshots from the `snaps
## Forking
`POST /api/tasks/{taskId}/snapshots/{n}/fork` (the **fork** button on a snapshot's transcript row) starts a new task from a snapshot (`internal/agents/fork.go`). Its branch and worktree start from the snapshot's commit, so the fork's worktree holds exactly the snapshot's tree, uncommitted work of the time included, as the branch's first commit. The task is titled `<title> (fork of #n)`, keeps the source's `merge_title` and `todo_item`, and records `forked_from` (the source task) and `forked_at` (n) in the `tasks` columns of those names. It gets an idle session, created by the requesting user (who needs a git identity), on the snapshot session's model, with `forked_from` set to that session's `vendor_session_id` and `forked_at` to the cut point below. Nothing runs until the first message is sent to it; that turn starts Claude Code with `--resume=<forked_from> --resume-session-at=<forked_at> --fork-session --session-id=<new id>`, or Codex with `thread/fork` (`threadId` `forked_from`, `lastTurnId` `forked_at`) whose new thread id byre then stores, so the new conversation is the source's up to the cut, under its own id, and later starts resume the new id as usual. A forked Codex thread keeps the source's byre tools. Refused (409) while the source task is closed (its snapshot refs are gone) and when there is no cut point; 404 for an unknown snapshot.
`POST /api/tasks/{taskId}/snapshots/{n}/fork` (the **fork** button on a snapshot's transcript row) starts a new task from a snapshot (`internal/agents/fork.go`). Its branch and worktree start from the snapshot's commit, so the fork's worktree holds exactly the snapshot's tree, uncommitted work of the time included, as the branch's first commit. The task is titled `<title> (fork of #n)`, keeps the source's `merge_title` and `todo_item`, and records `forked_from` (the source task) and `forked_at` (n) in the `tasks` columns of those names. It gets an idle session, created by the requesting user (who needs a git identity), on the snapshot session's model, with `forked_from` set to that session's `vendor_session_id` and `forked_at` to the cut point below. Nothing runs until the first message is sent to it; that turn starts Claude Code with `--resume=<forked_from> --resume-session-at=<forked_at> --fork-session --session-id=<new id>`, or Codex with `thread/fork` (`threadId` `forked_from`, `lastTurnId` `forked_at`) whose new thread id byre then stores, so the new conversation is the source's up to the cut, under its own id, and later starts resume the new id as usual. A forked Codex thread keeps the source's byre tools. Refused (409) while the source task is closed (its snapshot refs are gone), when there is no cut point, and when the snapshot's session runs on Antigravity, which can't fork a conversation; 404 for an unknown snapshot.
The cut point comes from `vendor_uuid`: every top-level `agent.message`, `agent.thinking`, `agent.tool_call` and `agent.tool_result` event (not a subagent's) carries the `uuid` of the Claude Code stream-json line it came from, which is the id of that entry in the CLI's own session transcript. `forkPoint` takes the last such event of the snapshot's session at or before the snapshot's `event_seq`, then extends the cut through the following tool calls and results until every tool call it has kept has its result, so the fork never resumes on an unanswered tool call. For a `turn_end` snapshot the cut is the turn's last message. A Codex session's events carry no `vendor_uuid` except its `turn.ended`, which carries the turn id, so for Codex `forkPoint` picks the last turn that ended at or before the snapshot. Snapshots whose session recorded no `vendor_uuid` by then, such as those from before byre recorded it, can't be forked.
@@ -403,7 +402,7 @@ The fork's session guidance gets one more line (`forkGuidance` in `internal/agen
Every new snapshot of a task gets a review: a separate agent session on the vendor the task's agent doesn't use reads the change and reports on it (`internal/agents/review.go`, `internal/store/review.go`). A turn that changes no files makes no snapshot and so no review, an interrupted turn makes no snapshot (see "Snapshots"), and a snapshot whose change (the diff below) has no files runs no reviewer: it gets a `reviews` row in state `skipped`, with no session, vendor or model. The snapshot at the end of a Merge turn that finishes into the squash (the turn ended cleanly with `merging_by` still set, read by `turnEnded` before it asks for the snapshot) gets no review and no `reviews` row at all, since the task closes a moment later; a Merge turn that errors or calls `stop_merge` is reviewed as usual. Besides `take_snapshot` (see "Snapshots"), nothing else starts a review, and nothing waits for one: reviews ride along for the human to look at when they want.
- **Starting.** After `takeSnapshot` creates a snapshot, `afterSnapshot` runs `startReviewer`: it picks the vendor opposite the task's latest implementer session (`reviewVendor`) and that vendor's model in `reviewModels`, checks the snapshot's commit out detached at `worktrees/<project-id>/review-<review-id>`, creates an `agent_sessions` row with `role` `reviewer` on the task (created by the task's creator), a `reviews` row (`running`, one per snapshot), and sends `reviewPrompt` as its first turn (actor `byre`): all of the task's work as `git diff` from the merge-base of the snapshot and the default branch, with its changed files, and the task's first message to its agent. Every review covers the whole task against the default branch, not only what changed since the previous snapshot, and a snapshot with no changes against it is `skipped`.
- **Starting.** After `takeSnapshot` creates a snapshot, `afterSnapshot` runs `startReviewer`: it picks the vendor opposite the task's latest implementer session (`reviewVendor`: Claude Code for a Codex implementer, Codex for the others) and that vendor's model in `reviewModels`, checks the snapshot's commit out detached at `worktrees/<project-id>/review-<review-id>`, creates an `agent_sessions` row with `role` `reviewer` on the task (created by the task's creator), a `reviews` row (`running`, one per snapshot), and sends `reviewPrompt` as its first turn (actor `byre`): all of the task's work as `git diff` from the merge-base of the snapshot and the default branch, with its changed files, and the task's first message to its agent. Every review covers the whole task against the default branch, not only what changed since the previous snapshot, and a snapshot with no changes against it is `skipped`.
- **Running.** Reviewer sessions get `reviewGuidance` and only the `submit_review` tool, whose arguments (`reviewSchema`: `change`, a few sentences of Markdown on what the change does, required and non-empty; a Markdown `summary`, which the guidance and schema require to be exactly "No significant findings." when there are no findings and otherwise a bulleted list with one line per finding; `findings`, each with `severity`, `category`, `title`, `detail`, `path`, `line`, `end_line` and `images`; and top-level `images`) become the review's `result` and mark it `done`. The guidance encourages, without requiring, Mermaid blocks for a data flow, call graph, state machine or schema the change alters, and screenshots when the change alters something visible.
- **Forwarding.** When a submitted review has findings, byre sends them to the task's live agent as a `byre` message (`forwardFindings` and `findingsMessage` in `internal/agents/review.go`): each finding's severity, title, location and detail, under a preamble asking the agent to check each one against the code and its brief, act on those that hold up, and say in its final message which it acted on and which it left and why. It goes through `Manager.Message`, so it is queued like any message while the agent is mid-turn. A task with no live agent session gets nothing, and the log says `review findings not forwarded`. Forwarding doesn't set any finding's state; **fix** and **ignore** stay available.
- **Screenshots.** Reviewer sessions also get the Playwright MCP server (`playwrightMCP` in `internal/agents/review.go`: `npx @playwright/mcp` at a pinned version, headless Chrome, isolated profile, absolute file paths), passed to Claude as a `stdio` entry in `--mcp-config` and to Codex as `mcp_servers` in the thread's `config`. Its automatically named files go to `worktrees/<project-id>/review-<review-id>-media`, removed with the checkout. Each `images` entry is `{path, caption}`, the path absolute or relative to the checkout; at `submit_review` byre reads every one, refuses the whole submission (nothing recorded) for a missing file or one that isn't one of `store.ImageTypes` within `store.MaxBlobBytes`, then stores each as a blob and replaces its `path` with `blob`, since the files are deleted with the checkout. They run without the Stop-hook bounce, mid-turn delivery, end-of-turn commit, snapshots or turn summaries, and their `user.message` events carry no `head`. When the reviewer's turn ends byre completes its session, kills its process and removes the worktree; a review still `running` then fails with why (no submission, with the final message; an error; an interrupt). A process that exits mid-turn fails the review the same way. `StopReview` (`POST /api/reviews/{reviewId}/stop`, the Stop button on a running review's card) ends the reviewer the same way and fails the review ("the reviewer was stopped"), and `Watch` calls it for a reviewer whose process the health check finds `stuck` (no stream event for `StuckAfter`), which is how a reviewer whose connection died while the machine slept gets cleared. Merging or discarding the task fails its running reviews ("the task was closed") and ends their reviewers. On startup, a running reviewer resumes like any session, and a running review whose reviewer isn't `running` fails.
@@ -416,7 +415,7 @@ A workflow is a named graph of stages, each a stage name, the stages it comes af
- **Names.** The name is the workflow's key and is shown to the human on buttons and pickers, so it is free text: one line of 1 to 100 characters, trimmed (`workflow.CleanName`). It isn't part of the definition, so the file doesn't carry it. In URLs it is a path-escaped segment.
- **File.** One workflow per file: the `WorkflowDefinition` (`api/openapi.yaml`) as YAML, an optional `description` and its `stages`. A stage's `after` lists the stages whose outputs it gets; absent, it means the previous stage in the list (none for the first), so a file without `after` is a chain; `[]` means the stage starts from the brief. Its `base` is `parent` (the default: the commit its one `after` stage finished at, or for a stage with none, where the run was started from) or `main` (the project's default branch); a stage with more than one `after` stage always starts from the default branch. In place of `model`, a stage may list `agents` (each `name`, `model` and `prompt`) to run as a team in its one task (see "Teams"): each agent gets the stage's message and then its own prompt, and the first agent calls `finish_stage`. `workflow.Format` writes it with multi-line prompts as literal blocks; `workflow.Parse` reads YAML or JSON and refuses unknown fields. The download is named after the workflow with `/\:*?"<>|` replaced by `_` (`workflow.Filename`).
- **Validation.** `workflow.Validate` refuses a definition (400, everything wrong listed) with no stages, a stage name that isn't a valid one-line name or repeats another stage's, an empty model without `agents` or a model with them, `agents` that fail `workflow.TeamProblems`, an empty prompt, a `base` other than `parent` or `main`, a `base` on a stage with more than one `after` stage, an `after` naming the stage itself, a name twice or a name that isn't a stage, a cycle, or anything but exactly one final stage (one no other stage comes after). A stage's vendor follows from its model (`workflow.VendorOf`, the same rule sessions use: `gpt-*` is Codex, everything else Claude Code); a file with a `vendor` key is refused as an unknown field.
- **Validation.** `workflow.Validate` refuses a definition (400, everything wrong listed) with no stages, a stage name that isn't a valid one-line name or repeats another stage's, an empty model without `agents` or a model with them, `agents` that fail `workflow.TeamProblems`, an empty prompt, a `base` other than `parent` or `main`, a `base` on a stage with more than one `after` stage, an `after` naming the stage itself, a name twice or a name that isn't a stage, a cycle, or anything but exactly one final stage (one no other stage comes after). A stage's vendor follows from its model (`workflow.VendorOf`, the same rule sessions use: `gpt-*` is Codex, `gemini-*` Antigravity, everything else Claude Code); a file with a `vendor` key is refused as an unknown field.
- **API.** `GET /api/workflows` lists every workflow's name and description. `GET /api/workflows/{name}` downloads its file; `PUT` takes a file as the body and creates or replaces the workflow; `DELETE` removes it. Each save or delete records a `workflow.updated` or `workflow.deleted` event carrying the name. `sqlite3 byre.db 'SELECT name FROM workflows'` lists them. Saves and reads go through the Manager (`internal/agents/workflows.go`) so the HTTP API and the lead's tools share them.
- **Lead tools.** `list_workflows`, `read_workflow` (`name`, or `task_id` for a task's frozen copy), `save_workflow` (`name` and `definition`, the file as YAML) and `delete_workflow`, with the same checks and errors as the API. Task agents don't get them.
- **Starting.** The lead's `create_task` and `start_todo` take an optional `workflow` (404 when there is none of that name) and then start a run of it instead of an agent on the prompt (`Manager.StartWorkflow`); `model` together with `workflow` is refused, since each stage names its own. `POST /api/projects/{id}/tasks` takes the same optional `workflow`, with the title as the run's brief, and returns the run's first stage task. The task switcher (`mac/Sources/Byre/Switcher.swift`) offers it as a "Start workflow…" row after each "New task in …" and "Start in …" pair: picking that lists `GET /api/workflows`, filtered by what is typed next (Esc goes back to the task list), and picking a workflow starts the run with the typed title and opens its first stage task. A run gets a new id and creates a task for every stage with no `after` stages, branched from the default branch, or for a stage with `base: parent`, from the lead's `from_task_id` HEAD when given. Each stage task copies the workflow's name and definition as they are when the run starts into `tasks.workflow_name` and `tasks.workflow_definition`, so later saves or deletes don't change it; its brief into `tasks.workflow_brief` (the lead's `create_task` `prompt`, optional with a workflow and may be empty, or for `start_todo` the TODO item as `todoPrompt` words it); the run's title into `tasks.workflow_title`; the run id into `tasks.workflow_run`; and its stage's name into `tasks.workflow_stage`. Stage tasks are titled `<title>: <stage>`, with the run's title as their merge title, and carry the run's TODO item. The Task's `workflow`, `workflow_run` and `workflow_stage` are those; `GET /api/tasks/{id}/workflow` returns the copy as `{name, definition}` with the definition as YAML (404 for a task without one). A task agent's `create_subtask`, `POST /api/projects/{id}/tasks` without `workflow` and the Project tab's Start create tasks without one. A fork of a stage task copies its frozen workflow, brief, title, run and stage, so it is another attempt at the same stage of the same run. `sqlite3 byre.db "SELECT id, status, workflow_stage, stage_output != '' FROM tasks WHERE workflow_run = '<run>'"` lists a run's tasks and which have finished.
+254
View File
@@ -0,0 +1,254 @@
package agents
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"g.fc.run/ai/byre/internal/antigravity"
"g.fc.run/ai/byre/internal/api"
)
type agySteps struct {
mu sync.Mutex
text map[int]*strings.Builder
tools map[string]bool
}
func newAgySteps() *agySteps {
return &agySteps{text: map[int]*strings.Builder{}, tools: map[string]bool{}}
}
func (m *Manager) handleAntigravity(ctx context.Context, l *live, p antigravityProcess, ev antigravity.Event) error {
switch ev.Kind {
case "init":
return nil
case "step_update":
su, err := ev.StepUpdate()
if err != nil {
return err
}
return m.agyStep(ctx, l, p, ev, su)
case "result":
res, err := ev.Result()
if err != nil {
return err
}
return m.agyTurnEnded(ctx, l, p, res)
}
raw := map[string]any{}
err := json.Unmarshal(ev.Raw, &raw)
if err != nil {
return err
}
return m.emit(ctx, l, agentActor, "agent.vendor", raw)
}
func (m *Manager) agyStep(ctx context.Context, l *live, p antigravityProcess, ev antigravity.Event, su antigravity.StepUpdate) error {
switch su.StepType {
case "user_input":
return nil
case "agent_response":
p.steps.mu.Lock()
b := p.steps.text[su.StepIndex]
if b == nil {
b = &strings.Builder{}
p.steps.text[su.StepIndex] = b
}
b.WriteString(su.TextDelta)
done := su.State != "ACTIVE"
if done {
delete(p.steps.text, su.StepIndex)
}
p.steps.mu.Unlock()
if su.TextDelta != "" {
m.store.Broadcast(m.event(l, agentActor, "agent.delta", map[string]any{"text": su.TextDelta}))
}
text := strings.TrimSpace(b.String())
if !done || text == "" {
return nil
}
m.mu.Lock()
l.lastText = text
m.mu.Unlock()
return m.emit(ctx, l, agentActor, "agent.message", map[string]any{"text": text})
case "tool":
id := fmt.Sprintf("%s/%d", su.ConversationID, su.StepIndex)
info := antigravity.ToolInfo{Name: su.ToolName}
if su.ToolInfo != nil {
info = *su.ToolInfo
}
p.steps.mu.Lock()
called := p.steps.tools[id]
p.steps.tools[id] = su.State == "ACTIVE"
if su.State != "ACTIVE" {
delete(p.steps.tools, id)
}
p.steps.mu.Unlock()
if !called {
name, input, err := agyToolCall(su.ToolName, info.Parameters)
if err != nil {
return err
}
err = m.emit(ctx, l, agentActor, "agent.tool_call", map[string]any{"id": id, "name": name, "input": input})
if err != nil {
return err
}
}
if su.State == "ACTIVE" {
return nil
}
content := info.Output
if info.Error != "" {
content = info.Error
}
return m.emit(ctx, l, agentActor, "agent.tool_result", map[string]any{"tool_use_id": id, "is_error": su.State != "DONE" || info.Error != "", "content": content})
}
raw := map[string]any{}
err := json.Unmarshal(ev.Raw, &raw)
if err != nil {
return err
}
return m.emit(ctx, l, agentActor, "agent.vendor", raw)
}
func agyToolCall(name string, params json.RawMessage) (string, any, error) {
switch name {
case "run_command":
p := struct {
CommandLine string
}{}
err := json.Unmarshal(params, &p)
return "Bash", map[string]any{"command": p.CommandLine}, err
case "call_mcp_tool":
p := struct {
ServerName string
ToolName string
Arguments json.RawMessage
}{}
err := json.Unmarshal(params, &p)
if err != nil {
return "", nil, err
}
args, err := decode(p.Arguments)
return "mcp__" + strings.TrimPrefix(p.ServerName, "byre_") + "__" + p.ToolName, args, err
}
input, err := decode(params)
return name, input, err
}
func (m *Manager) agyTurnEnded(ctx context.Context, l *live, p antigravityProcess, res antigravity.Result) error {
m.mu.Lock()
final := l.lastText
m.mu.Unlock()
go m.readAgyRateLimit()
success := res.Status == "SUCCESS"
bounce := false
if success && l.implementer() {
var err error
bounce, err = m.takeBounce(ctx, l, final)
if err != nil {
return err
}
}
if bounce {
err := m.emit(ctx, l, "byre", "user.message", map[string]any{"text": proseQuestionNudge, "mid_turn": true, "bounce": true})
if err != nil {
return err
}
m.mu.Lock()
l.lastText = ""
m.mu.Unlock()
go func() {
err := p.s.Send(proseQuestionNudge)
if err != nil {
slog.Error("send prose question nudge", "session", l.id, "err", err)
}
}()
return nil
}
r := endResult{
subtype: strings.ToLower(res.Status),
isError: !success,
result: final,
}
if !success {
r.result = res.Error
}
r.payload = map[string]any{
"subtype": r.subtype,
"is_error": r.isError,
"result": r.result,
}
return m.turnEnded(ctx, l, r)
}
var agyWindowMins = map[string]int{"5h": 300, "weekly": 7 * 24 * 60}
func (m *Manager) readAgyRateLimit() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
groups, err := antigravity.ReadUsage(ctx)
if err != nil {
slog.Error("read antigravity rate limits", "err", err)
return
}
m.mu.Lock()
m.rateLimits[api.Antigravity] = agyRateLimit(groups)
m.mu.Unlock()
m.store.Broadcast(api.Event{Kind: "admin.health", Actor: "byre", Payload: map[string]any{}})
}
func agyRateLimit(groups []antigravity.UsageGroup) map[string]any {
info := map[string]any{"status": "allowed"}
windows := map[string]any{}
for _, g := range groups {
for _, b := range g.Buckets {
window := map[string]any{"utilization": 1 - b.RemainingFraction, "resetsAt": b.ResetTime.Unix()}
if mins := agyWindowMins[b.Window]; mins != 0 {
window["windowMins"] = mins
}
windows[b.ID] = window
if b.RemainingFraction <= 0 {
info["status"] = "rejected"
info["rateLimitType"] = b.ID
}
}
}
info["unifiedWindows"] = windows
return info
}
func (m *Manager) agyBeforeModel(l *live) antigravity.HookFunc {
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
in := struct {
InvocationNum int `json:"invocationNum"`
}{}
err := json.Unmarshal(input, &in)
if err != nil {
return nil, err
}
if in.InvocationNum == 0 || l.reviewID != "" {
return map[string]any{}, nil
}
mt := m.deliverMidTurn(ctx, l)
if mt.text == "" {
return map[string]any{}, nil
}
return map[string]any{"injectSteps": []map[string]any{{"userMessage": mt.text}}}, nil
}
}
+3
View File
@@ -25,6 +25,9 @@ func (m *Manager) Fork(ctx context.Context, src api.Task, project api.Project, n
if err != nil {
return api.Task{}, err
}
if srcSess.Vendor == api.Antigravity {
return api.Task{}, fmt.Errorf("%w: snapshot %d of task %s is from an Antigravity session, and agy can't fork a conversation", ErrConflict, n, src.Id)
}
events, err := m.store.TaskEvents(ctx, src.Id, 0, -1)
if err != nil {
return api.Task{}, err
+44 -39
View File
@@ -35,15 +35,16 @@ const agentActor = "agent"
const restartNotice = "byre restarted while you were mid-turn, so your last turn was cut off. Re-check the working tree (git status and the files you were editing) before continuing where you left off."
type Manager struct {
store *store.Store
worktreeRoot string
leadRoot string
handsRoot string
codexHome string
status func(context.Context) (api.Status, error)
addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error)
clients *clients.Registry
terminals *terminal.Terminals
store *store.Store
worktreeRoot string
leadRoot string
handsRoot string
codexHome string
antigravityRoot string
status func(context.Context) (api.Status, error)
addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error)
clients *clients.Registry
terminals *terminal.Terminals
mu sync.Mutex
live map[string]*live
@@ -125,40 +126,42 @@ func (l *live) implementer() bool {
return l.owner == "" && l.reviewID == ""
}
func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome string, status func(context.Context) (api.Status, error), addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error), clientRegistry *clients.Registry, terminals *terminal.Terminals) *Manager {
func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome, antigravityRoot string, status func(context.Context) (api.Status, error), addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error), clientRegistry *clients.Registry, terminals *terminal.Terminals) *Manager {
m := &Manager{
store: st,
worktreeRoot: worktreeRoot,
leadRoot: leadRoot,
handsRoot: handsRoot,
codexHome: codexHome,
status: status,
addProject: addProject,
clients: clientRegistry,
terminals: terminals,
live: map[string]*live{},
starting: map[string]*start{},
deliverLocks: map[string]*sessionLock{},
rateLimits: map[api.AgentSessionVendor]map[string]any{},
leadAnswered: map[string]bool{},
paneFocus: map[string]api.PaneFocus{},
paneReported: map[string]time.Time{},
paneMoved: map[string]time.Time{},
humanMoved: map[string]time.Time{},
moveRefused: map[string]bool{},
startedTodo: map[string]time.Time{},
startRefused: map[string]bool{},
finishing: map[string]bool{},
handoffModel: map[string]string{},
mergeTurns: map[string]mergeTurn{},
signals: make(chan []api.Event, 1024),
snaps: map[string]*snapState{},
merges: map[string]*projectMerge{},
bad: newBadLines(),
watchStop: make(chan struct{}),
store: st,
worktreeRoot: worktreeRoot,
leadRoot: leadRoot,
handsRoot: handsRoot,
codexHome: codexHome,
antigravityRoot: antigravityRoot,
status: status,
addProject: addProject,
clients: clientRegistry,
terminals: terminals,
live: map[string]*live{},
starting: map[string]*start{},
deliverLocks: map[string]*sessionLock{},
rateLimits: map[api.AgentSessionVendor]map[string]any{},
leadAnswered: map[string]bool{},
paneFocus: map[string]api.PaneFocus{},
paneReported: map[string]time.Time{},
paneMoved: map[string]time.Time{},
humanMoved: map[string]time.Time{},
moveRefused: map[string]bool{},
startedTodo: map[string]time.Time{},
startRefused: map[string]bool{},
finishing: map[string]bool{},
handoffModel: map[string]string{},
mergeTurns: map[string]mergeTurn{},
signals: make(chan []api.Event, 1024),
snaps: map[string]*snapState{},
merges: map[string]*projectMerge{},
bad: newBadLines(),
watchStop: make(chan struct{}),
}
go m.signalLoop()
go m.readCodexRateLimit()
go m.readAgyRateLimit()
return m
}
@@ -814,6 +817,8 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api
switch l.vendor {
case api.Codex:
l.sess, err = m.startCodex(sctx, sess, l, g, servers, stdio, env)
case api.Antigravity:
l.sess, err = m.startAntigravity(sctx, sess, l, g, servers, stdio, env)
default:
l.sess, err = m.startClaude(sctx, sess, l, g, servers, stdio, env)
}
+87 -1
View File
@@ -5,8 +5,11 @@ import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"g.fc.run/ai/byre/internal/antigravity"
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/claude"
"g.fc.run/ai/byre/internal/codex"
@@ -32,7 +35,7 @@ type agentProcess interface {
}
func initialVendorSessionID(vendor api.AgentSessionVendor, id string) string {
if vendor == api.Codex {
if vendor != api.Claude {
return ""
}
return id
@@ -238,3 +241,86 @@ func (p codexProcess) drive(m *Manager, l *live) {
}
}
}
type antigravityProcess struct {
s *antigravity.Session
steps *agySteps
}
func (m *Manager) startAntigravity(ctx context.Context, sess api.AgentSession, l *live, g string, servers []*mcp.Server, stdio []mcp.Stdio, env []string) (agentProcess, error) {
opts := antigravity.Options{
Dir: filepath.Join(m.antigravityRoot, sess.Id),
Cwd: l.dir,
Model: sess.Model,
Resume: sess.VendorSessionId,
GitName: l.gitName,
GitEmail: l.gitEmail,
Env: env,
Guidance: g,
Notes: strings.TrimSpace(l.notes),
Reminder: g,
MCPServers: servers,
StdioMCP: stdio,
Hooks: map[string]antigravity.HookFunc{"PreInvocation": m.agyBeforeModel(l)},
OnBadLine: func(line []byte, reason string) { m.recordBadLine(l, line, reason) },
}
s, err := antigravity.Start(ctx, opts)
if err != nil {
return nil, fmt.Errorf("start agy: %w", err)
}
if sess.VendorSessionId == "" {
err = m.store.SetVendorSessionID(ctx, sess.Id, s.ID())
if err != nil {
s.Kill()
return nil, err
}
}
return antigravityProcess{s: s, steps: newAgySteps()}, nil
}
func (p antigravityProcess) send(ctx context.Context, text string, images []image) error {
imgs := []antigravity.Image{}
for _, img := range images {
imgs = append(imgs, antigravity.Image{MediaType: img.mediaType, Data: img.data})
}
return p.s.Send(text, imgs...)
}
func (p antigravityProcess) interrupt(ctx context.Context) error {
return p.s.Interrupt()
}
func (p antigravityProcess) setModel(ctx context.Context, model string) error {
return p.s.SetModel(model)
}
func (p antigravityProcess) kill() { p.s.Kill() }
func (p antigravityProcess) pid() int { return p.s.Pid() }
func (p antigravityProcess) lastLine() time.Time { return p.s.LastLine() }
func (p antigravityProcess) exited() bool { return p.s.Exited() }
func (p antigravityProcess) discard() {
for range p.s.Events() {
}
}
func (p antigravityProcess) drive(m *Manager, l *live) {
ctx := context.Background()
for ev := range p.s.Events() {
start := time.Now()
if wait := start.Sub(ev.Read); wait > 200*time.Millisecond {
slog.Info("agent event waited", "session", l.id, "event", ev.Kind, "wait", wait)
}
err := m.handleAntigravity(ctx, l, p, ev)
if err != nil {
slog.Error("record agent event", "session", l.id, "event", ev.Kind, "err", err)
}
if isDecodeError(err) {
m.recordBadLine(l, ev.Raw, fmt.Sprintf("decode %s: %s", ev.Kind, err))
}
if took := time.Since(start); took > 200*time.Millisecond {
slog.Info("agent event slow", "session", l.id, "event", ev.Kind, "took", took)
}
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ func (m *Manager) subtaskTools(l *live) []mcp.Tool {
`{"type": "object", "properties": {
"title": {"type": "string", "description": "Short task title, shown in the task list."},
"prompt": {"type": "string", "description": "The new agent's first message: a complete, self-contained brief."},
"model": {"type": "string", "description": "Model for the new agent: gpt-* models run on Codex, every other model on Claude Code. Defaults to this session's model."}
"model": {"type": "string", "description": "Model for the new agent: gpt-* models run on Codex, gemini-* models on Antigravity, every other model on Claude Code. Defaults to this session's model."}
}, "required": ["title", "prompt"]}`,
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
args := struct {
+50 -7
View File
@@ -50,7 +50,7 @@ type terminalTypeArgs struct {
func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "terminal_type",
Description: "Type into your task's terminal: the shell in this task's worktree that the human uses from the Terminal tab, starting it when none is running. " +
Description: "Type into your task's terminal: the shell in this task's worktree that the human uses from the Terminal tab, starting it when none is running and waiting for its prompt before typing. " +
"text goes to the shell exactly as keystrokes, so end a command with \"\\n\" to run it, and send control characters for keys (\"\\u0003\" is Ctrl-C). " +
"The human sees everything you type and its output. " +
"Returns the output that followed, as terminal_read does, once the terminal has been quiet for half a second or after 10 seconds; read the rest of a longer command's output with terminal_read.",
@@ -84,13 +84,22 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
}
func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (string, error) {
_, out := term.Subscribe()
backlog, out := term.Subscribe()
if out == nil {
exit, _ := term.ExitStatus()
return "", fmt.Errorf("the shell ended (%s); type again to start a new one", exit)
}
defer term.Unsubscribe(out)
got := []byte{}
if promptWait(term) > 0 {
var err error
got, err = awaitPrompt(ctx, term, out, keepTail(backlog))
if err != nil {
return "", err
}
}
stop := atomic.Bool{}
defer stop.Store(true)
typed := atomic.Int64{}
@@ -108,7 +117,6 @@ func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (str
written <- nil
}()
got := []byte{}
result := func() string {
res := terminal.Text(got)
exit, exited := term.ExitStatus()
@@ -140,11 +148,46 @@ func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (str
if !open {
return result(), nil
}
got = append(got, b...)
if len(got) > 2*typeKeep {
got = bytes.Clone(got[len(got)-typeKeep:])
}
got = keepTail(append(got, b...))
quiet.Reset(typeQuiet)
}
}
}
func keepTail(b []byte) []byte {
if len(b) > 2*typeKeep {
return bytes.Clone(b[len(b)-typeKeep:])
}
return b
}
func promptWait(term *terminal.Terminal) time.Duration {
started, last := term.Activity()
wait := time.Until(started.Add(typeMaxWait))
if !last.IsZero() {
wait = min(wait, time.Until(last.Add(typeQuiet)))
}
return wait
}
func awaitPrompt(ctx context.Context, term *terminal.Terminal, out chan []byte, got []byte) ([]byte, error) {
for {
wait := promptWait(term)
if wait <= 0 {
return got, nil
}
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
case b, open := <-out:
timer.Stop()
if !open {
return got, nil
}
got = keepTail(append(got, b...))
}
}
}
+1 -1
View File
@@ -652,7 +652,7 @@ const workflowFileHelp = "A workflow's file is YAML: an optional description, th
"after (the names of the stages whose outputs it gets; it starts once all of them have finished; absent means the previous stage in the list, and for the first stage none; [] means it starts from the brief), " +
"base (parent, the default: branch from the commit its one after stage finished at, or for a stage with none, from where the workflow was started; main: branch from the default branch; " +
"refused on a stage with more than one after stage, which always branches from the default branch and is told the branches it can merge), " +
"model (which also picks the vendor: gpt-* models run on Codex, every other model on Claude Code) and prompt (the stage agent's instructions). " +
"model (which also picks the vendor: gpt-* models run on Codex, gemini-* models on Antigravity, every other model on Claude Code) and prompt (the stage agent's instructions). " +
"In place of model, agents (a list of at least two, each with name, model and prompt) runs the stage as a team: those agents work in the stage's one task and worktree and talk through a shared board; " +
"each gets the stage's prompt and then its own; the first agent finishes the stage. " +
"Every stage runs as its own task. Exactly one stage is final (no other stage comes after it), and cycles are refused. " +
+581
View File
@@ -0,0 +1,581 @@
package antigravity
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"g.fc.run/ai/byre/internal/mcp"
)
const maxRuleBytes = 24000
type HookFunc func(ctx context.Context, input json.RawMessage) (map[string]any, error)
type Options struct {
Dir string
Cwd string
Model string
Resume string
GitName string
GitEmail string
Env []string
Guidance string
Notes string
Reminder string
MCPServers []*mcp.Server
StdioMCP []mcp.Stdio
Hooks map[string]HookFunc
OnBadLine func(line []byte, reason string)
}
type Image struct {
MediaType string
Data []byte
}
type Event struct {
Kind string
Raw json.RawMessage
Read time.Time
}
type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
ThinkingTokens int `json:"thinking_tokens"`
CacheReadTokens int `json:"cache_read_tokens"`
TotalTokens int `json:"total_tokens"`
}
type ToolInfo struct {
Name string `json:"name"`
Parameters json.RawMessage `json:"parameters"`
Output string `json:"output"`
Error string `json:"error"`
}
type StepUpdate struct {
ConversationID string `json:"conversation_id"`
StepIndex int `json:"step_index"`
State string `json:"state"`
StepType string `json:"step_type"`
TextDelta string `json:"text_delta"`
ToolName string `json:"tool_name"`
ToolInfo *ToolInfo `json:"tool_info"`
DurationSeconds float64 `json:"duration_seconds"`
Usage *Usage `json:"usage"`
}
type Result struct {
ConversationID string `json:"conversation_id"`
Status string `json:"status"`
Response string `json:"response"`
Error string `json:"error"`
DurationSeconds float64 `json:"duration_seconds"`
NumTurns int `json:"num_turns"`
Usage Usage `json:"usage"`
}
var knownEvents = map[string]bool{"init": true, "step_update": true, "result": true}
func (e Event) payload(v any) error {
fields := map[string]json.RawMessage{}
err := json.Unmarshal(e.Raw, &fields)
if err != nil {
return err
}
return json.Unmarshal(fields[e.Kind], v)
}
func (e Event) StepUpdate() (StepUpdate, error) {
su := StepUpdate{}
return su, e.payload(&su)
}
func (e Event) Result() (Result, error) {
res := Result{}
return res, e.payload(&res)
}
type proc struct {
cmd *exec.Cmd
stdin io.WriteCloser
done chan struct{}
expected bool
}
type Session struct {
opts Options
mu sync.Mutex
p *proc
model string
killed bool
images int
idMu sync.Mutex
convID string
events chan Event
dead chan struct{}
dieOnce sync.Once
started chan struct{}
initOnce sync.Once
srv *http.Server
base string
servers map[string]*mcp.Server
lastLine atomic.Int64
}
func Start(ctx context.Context, opts Options) (*Session, error) {
if len(opts.Guidance) > maxRuleBytes || len(opts.Notes) > maxRuleBytes {
return nil, fmt.Errorf("guidance (%d bytes) and notes (%d bytes) must each fit agy's %d-byte rule limit", len(opts.Guidance), len(opts.Notes), maxRuleBytes)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, err
}
token := rand.Text()
s := &Session{
opts: opts,
model: opts.Model,
convID: opts.Resume,
events: make(chan Event, 256),
dead: make(chan struct{}),
started: make(chan struct{}),
base: "http://" + ln.Addr().String() + "/" + token,
servers: map[string]*mcp.Server{},
}
for _, srv := range opts.MCPServers {
s.servers[srv.Name] = srv
}
s.lastLine.Store(time.Now().UnixNano())
mux := http.NewServeMux()
mux.HandleFunc("POST /"+token+"/mcp/{server}", s.serveMCP)
mux.HandleFunc("POST /"+token+"/hook/{event}", s.serveHook)
s.srv = &http.Server{Handler: mux}
go s.srv.Serve(ln)
err = s.writePlugin()
if err != nil {
s.die()
return nil, err
}
s.mu.Lock()
err = s.spawnLocked()
s.mu.Unlock()
if err != nil {
s.die()
return nil, err
}
select {
case <-s.started:
return s, nil
case <-s.dead:
return nil, errors.New("agy exited before it started a conversation")
case <-ctx.Done():
s.Kill()
return nil, ctx.Err()
}
}
func (s *Session) pluginDir() string {
return filepath.Join(s.opts.Dir, ".agents", "plugins", "byre")
}
func (s *Session) writePlugin() error {
dir := s.pluginDir()
err := os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
if err != nil {
return err
}
servers := map[string]any{}
for _, srv := range s.opts.MCPServers {
servers[srv.Name] = map[string]any{"url": s.base + "/mcp/" + srv.Name}
}
for _, srv := range s.opts.StdioMCP {
servers[srv.Name] = map[string]any{"command": srv.Command, "args": srv.Args}
}
hooks := map[string]any{}
for event := range s.opts.Hooks {
hooks[event] = []map[string]any{{
"type": "command",
"command": fmt.Sprintf("curl -sS --max-time 25 --data-binary @- %s/hook/%s", s.base, event),
"timeout": 30,
}}
}
files := map[string]any{
"plugin.json": map[string]any{"name": "byre"},
"mcp_config.json": map[string]any{"mcpServers": servers},
"hooks.json": map[string]any{"byre": hooks},
}
for name, v := range files {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
err = os.WriteFile(filepath.Join(dir, name), b, 0o644)
if err != nil {
return err
}
}
err = os.WriteFile(filepath.Join(dir, "rules", "AGENTS.md"), []byte(s.opts.Guidance), 0o644)
if err != nil {
return err
}
if s.opts.Notes == "" {
return nil
}
return os.WriteFile(filepath.Join(dir, "rules", "notes.md"), []byte("---\ntrigger: always_on\n---\n"+s.opts.Notes), 0o644)
}
func (s *Session) serveMCP(w http.ResponseWriter, r *http.Request) {
srv := s.servers[r.PathValue("server")]
if srv == nil {
http.NotFound(w, r)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
msg := struct {
ID json.RawMessage `json:"id"`
}{}
err = json.Unmarshal(body, &msg)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(msg.ID) == 0 {
w.WriteHeader(http.StatusAccepted)
return
}
resp, err := srv.Handle(r.Context(), body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(resp)
}
func (s *Session) serveHook(w http.ResponseWriter, r *http.Request) {
event := r.PathValue("event")
fn := s.opts.Hooks[event]
if fn == nil {
http.NotFound(w, r)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
out, err := fn(r.Context(), body)
if err != nil {
slog.Error("agy hook failed", "event", event, "err", err)
out = map[string]any{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
func (s *Session) spawnLocked() error {
args := []string{
"--input-format", "stream-json",
"--output-format", "stream-json",
"--dangerously-skip-permissions",
"--add-dir", s.opts.Dir,
}
if s.model != "" {
args = append(args, "--model", s.model)
}
if id := s.ID(); id != "" {
args = append(args, "--conversation", id)
}
args = append(args, "--print", "")
cmd := exec.Command("agy", args...)
cmd.Dir = s.opts.Cwd
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME="+s.opts.GitName,
"GIT_AUTHOR_EMAIL="+s.opts.GitEmail,
"GIT_COMMITTER_NAME="+s.opts.GitName,
"GIT_COMMITTER_EMAIL="+s.opts.GitEmail,
)
cmd.Env = append(cmd.Env, s.opts.Env...)
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
err = cmd.Start()
if err != nil {
return fmt.Errorf("start agy: %w", err)
}
p := &proc{cmd: cmd, stdin: stdin, done: make(chan struct{})}
s.p = p
go s.readLoop(p, stdout)
return nil
}
func (s *Session) readLoop(p *proc, stdout io.Reader) {
r := bufio.NewReader(stdout)
for {
line, err := r.ReadBytes('\n')
line = bytes.TrimRight(line, "\r\n")
if len(line) > 0 {
s.lastLine.Store(time.Now().UnixNano())
s.handleLine(line)
}
if err != nil {
if err != io.EOF {
slog.Error("read agy stdout", "err", err)
}
break
}
}
p.cmd.Wait()
close(p.done)
s.mu.Lock()
expected := p.expected && !s.killed
s.mu.Unlock()
if !expected {
s.die()
}
}
func (s *Session) handleLine(line []byte) {
read := time.Now()
if line[0] != '{' {
s.badLine(line, "not a JSON object")
return
}
env := struct {
Event string `json:"event"`
ConversationID string `json:"conversation_id"`
}{}
err := json.Unmarshal(line, &env)
if err != nil {
s.badLine(line, err.Error())
return
}
if !knownEvents[env.Event] {
s.badLine(line, fmt.Sprintf("unrecognised event %q", env.Event))
}
if env.Event == "init" {
s.idMu.Lock()
if s.convID == "" {
s.convID = env.ConversationID
}
s.idMu.Unlock()
s.initOnce.Do(func() { close(s.started) })
}
s.events <- Event{Kind: env.Event, Raw: append(json.RawMessage{}, line...), Read: read}
}
func (s *Session) die() {
s.dieOnce.Do(func() {
close(s.events)
close(s.dead)
s.srv.Close()
err := os.RemoveAll(s.opts.Dir)
if err != nil {
slog.Error("remove agy session dir", "dir", s.opts.Dir, "err", err)
}
})
}
func (s *Session) restartLocked() error {
p := s.p
select {
case <-p.done:
case <-time.After(10 * time.Second):
p.cmd.Process.Kill()
<-p.done
}
return s.spawnLocked()
}
func (s *Session) Events() <-chan Event {
return s.events
}
func (s *Session) ID() string {
s.idMu.Lock()
defer s.idMu.Unlock()
return s.convID
}
func (s *Session) Pid() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.p.cmd.Process.Pid
}
func (s *Session) LastLine() time.Time {
return time.Unix(0, s.lastLine.Load())
}
func (s *Session) Exited() bool {
select {
case <-s.dead:
return true
default:
return false
}
}
type block struct {
Type string `json:"type"`
Text string `json:"text"`
}
func (s *Session) Send(text string, images ...Image) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.killed {
return errors.New("agy session was killed")
}
if s.p.expected {
err := s.restartLocked()
if err != nil {
s.die()
return err
}
}
content := []block{}
if s.opts.Reminder != "" {
content = append(content, block{Type: "text", Text: "<system-reminder>\n" + s.opts.Reminder + "\n</system-reminder>"})
}
for _, img := range images {
s.images++
path := filepath.Join(s.opts.Dir, "images", fmt.Sprintf("%d.%s", s.images, strings.TrimPrefix(img.MediaType, "image/")))
err := os.MkdirAll(filepath.Dir(path), 0o755)
if err != nil {
return err
}
err = os.WriteFile(path, img.Data, 0o644)
if err != nil {
return err
}
content = append(content, block{Type: "text", Text: "The human attached an image, saved at " + path + "; view it with view_file."})
}
content = append(content, block{Type: "text", Text: text})
b, err := json.Marshal(map[string]any{
"event": "user",
"message": map[string]any{"role": "user", "content": content},
})
if err != nil {
return err
}
_, err = s.p.stdin.Write(append(b, '\n'))
return err
}
func (s *Session) Interrupt() error {
s.mu.Lock()
p := s.p
p.expected = true
err := p.cmd.Process.Signal(syscall.SIGINT)
s.mu.Unlock()
if err != nil {
return err
}
go func() {
<-p.done
s.mu.Lock()
defer s.mu.Unlock()
if s.killed {
s.die()
return
}
if s.p != p {
return
}
err := s.spawnLocked()
if err != nil {
slog.Error("restart agy after interrupt", "err", err)
s.die()
}
}()
return nil
}
func (s *Session) SetModel(model string) error {
s.mu.Lock()
defer s.mu.Unlock()
if model == s.model {
return nil
}
s.model = model
s.p.expected = true
s.p.stdin.Close()
err := s.restartLocked()
if err != nil {
s.die()
}
return err
}
func (s *Session) Kill() {
s.mu.Lock()
s.killed = true
p := s.p
s.mu.Unlock()
if p != nil {
p.cmd.Process.Kill()
<-p.done
}
s.die()
}
func (s *Session) badLine(line []byte, reason string) {
slog.Warn("bad agy line", "reason", reason, "line", string(line))
if s.opts.OnBadLine != nil {
s.opts.OnBadLine(line, reason)
}
}
+41
View File
@@ -0,0 +1,41 @@
package antigravity
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"time"
)
type UsageBucket struct {
ID string `json:"id"`
Name string `json:"name"`
Window string `json:"window"`
RemainingFraction float64 `json:"remaining_fraction"`
ResetTime time.Time `json:"reset_time"`
}
type UsageGroup struct {
Name string `json:"name"`
Buckets []UsageBucket `json:"buckets"`
}
func ReadUsage(ctx context.Context) ([]UsageGroup, error) {
out, err := exec.CommandContext(ctx, "agy", "--output-format", "json", "--print", "/usage").Output()
if err != nil {
return nil, fmt.Errorf("agy /usage: %w", err)
}
resp := struct {
Command struct {
Data struct {
Groups []UsageGroup `json:"groups"`
} `json:"data"`
} `json:"command"`
}{}
err = json.Unmarshal(out, &resp)
if err != nil {
return nil, fmt.Errorf("agy /usage: %w", err)
}
return resp.Command.Data.Groups, nil
}
+241 -238
View File
@@ -86,13 +86,16 @@ func (e AgentSessionState) Valid() bool {
// Defines values for AgentSessionVendor.
const (
Claude AgentSessionVendor = "claude"
Codex AgentSessionVendor = "codex"
Antigravity AgentSessionVendor = "antigravity"
Claude AgentSessionVendor = "claude"
Codex AgentSessionVendor = "codex"
)
// Valid indicates whether the value is a known member of the AgentSessionVendor enum.
func (e AgentSessionVendor) Valid() bool {
switch e {
case Antigravity:
return true
case Claude:
return true
case Codex:
@@ -596,10 +599,10 @@ type AgentSession struct {
// TaskId Empty for a lead session.
TaskId string `json:"task_id"`
// Vendor Follows from the model the session was created with: gpt-* models run on Codex, every other model on Claude Code.
// Vendor Follows from the model the session was created with: gpt-* models run on Codex, gemini-* models on Antigravity, every other model on Claude Code.
Vendor AgentSessionVendor `json:"vendor"`
// VendorSessionId Claude Code's session id, chosen by byre; or the Codex thread id, empty until the session's first turn starts its thread.
// VendorSessionId Claude Code's session id, chosen by byre; or the Codex thread id or Antigravity conversation id, empty until the session first starts.
VendorSessionId string `json:"vendor_session_id"`
}
@@ -609,7 +612,7 @@ type AgentSessionRole string
// AgentSessionState defines model for AgentSession.State.
type AgentSessionState string
// AgentSessionVendor Follows from the model the session was created with: gpt-* models run on Codex, every other model on Claude Code.
// AgentSessionVendor Follows from the model the session was created with: gpt-* models run on Codex, gemini-* models on Antigravity, every other model on Claude Code.
type AgentSessionVendor string
// Attachment defines model for Attachment.
@@ -1090,7 +1093,7 @@ type Stage struct {
// Base Where the stage's task branches from. parent (the default): the commit its one after stage finished at, or for a stage with none, where the workflow was started from. main: the project's default branch. Refused on a stage with more than one after stage, which always branches from the default branch.
Base *StageBase `json:"base,omitempty"`
// Model The stage agent's model, which also picks its vendor: gpt-* models run on Codex, every other model on Claude Code. Required without agents, refused with them.
// Model The stage agent's model, which also picks its vendor: gpt-* models run on Codex, gemini-* models on Antigravity, every other model on Claude Code. Required without agents, refused with them.
Model string `json:"model,omitempty"`
// Name One line of 1 to 100 characters, unique within the workflow.
@@ -3798,238 +3801,238 @@ var swaggerSpec = []string{
"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",
"ewA=",
"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",
"YssYaXgUony5VOY7NGNvqefefX6vIk0fP/7vAQA=",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
+8 -8
View File
@@ -69,7 +69,7 @@ func New(dataDir string, portFirst int) (*Server, error) {
}
}
s.agents = agents.New(s.store, filepath.Join(dataDir, "worktrees"), filepath.Join(dataDir, "leads"), filepath.Join(dataDir, "hands"), filepath.Join(dataDir, "codex"), s.status, s.createProject, s.clients, s.terminals)
s.agents = agents.New(s.store, filepath.Join(dataDir, "worktrees"), filepath.Join(dataDir, "leads"), filepath.Join(dataDir, "hands"), filepath.Join(dataDir, "codex"), filepath.Join(dataDir, "antigravity"), s.status, s.createProject, s.clients, s.terminals)
err = s.agents.Recover(context.Background())
if err != nil {
s.store.Close()
@@ -761,8 +761,13 @@ func (s *Server) CreateSession(w http.ResponseWriter, r *http.Request, taskID st
return
}
if strings.TrimSpace(req.Prompt) == "" {
writeError(w, http.StatusBadRequest, errors.New("prompt is required"))
images, ok := s.images(w, r, req.Images)
if !ok {
return
}
if strings.TrimSpace(req.Prompt) == "" && len(images) == 0 {
writeError(w, http.StatusBadRequest, errors.New("prompt or images are required"))
return
}
@@ -783,11 +788,6 @@ func (s *Server) CreateSession(w http.ResponseWriter, r *http.Request, taskID st
model = *req.Model
}
images, ok := s.images(w, r, req.Images)
if !ok {
return
}
sess, err := s.agents.Create(r.Context(), t, u, req.Prompt, model, images...)
if err != nil {
writeAgentError(w, err)
+16 -6
View File
@@ -9,6 +9,7 @@ import (
"os/exec"
"sync"
"syscall"
"time"
"github.com/creack/pty"
@@ -30,11 +31,13 @@ type Terminal struct {
ptmx *os.File
cmd *exec.Cmd
mu sync.Mutex
backlog []byte
clients map[chan []byte]bool
exit string
exited bool
mu sync.Mutex
backlog []byte
clients map[chan []byte]bool
exit string
exited bool
started time.Time
lastOutput time.Time
}
func New() *Terminals {
@@ -68,7 +71,7 @@ func (ts *Terminals) Attach(t api.Task, size api.TerminalSize) (*Terminal, error
return nil, fmt.Errorf("start %s: %w", shell, err)
}
term = &Terminal{ptmx: ptmx, cmd: cmd, clients: map[chan []byte]bool{}}
term = &Terminal{ptmx: ptmx, cmd: cmd, clients: map[chan []byte]bool{}, started: time.Now()}
ts.byTask[t.Id] = term
slog.Info("terminal started", "task", t.Id, "shell", shell, "pid", cmd.Process.Pid)
go func() {
@@ -144,6 +147,7 @@ func (term *Terminal) output(b []byte) {
term.mu.Lock()
defer term.mu.Unlock()
term.lastOutput = time.Now()
term.backlog = append(term.backlog, b...)
if len(term.backlog) > 2*backlogSize {
term.backlog = bytes.Clone(term.backlog[len(term.backlog)-backlogSize:])
@@ -192,6 +196,12 @@ func (term *Terminal) ExitStatus() (string, bool) {
return term.exit, term.exited
}
func (term *Terminal) Activity() (started, lastOutput time.Time) {
term.mu.Lock()
defer term.mu.Unlock()
return term.started, term.lastOutput
}
func (term *Terminal) Write(b []byte) error {
_, err := term.ptmx.Write(b)
return err
+4 -1
View File
@@ -15,8 +15,11 @@ var ErrInvalid = errors.New("invalid workflow")
const maxNameRunes = 100
func VendorOf(model string) api.AgentSessionVendor {
if strings.HasPrefix(model, "gpt-") {
switch {
case strings.HasPrefix(model, "gpt-"):
return api.Codex
case strings.HasPrefix(model, "gemini-"):
return api.Antigravity
}
return api.Claude
}
+13 -1
View File
@@ -18,6 +18,12 @@ let models = [
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gemini-4-argon-high",
"gemini-4-argon-medium",
"gemini-4-argon-low",
"gemini-3.8-flash-high",
"gemini-3.8-flash-medium",
"gemini-3.8-flash-low",
]
struct PendingImage: Identifiable {
@@ -69,7 +75,13 @@ func uploadImages(_ api: API, _ imgs: [PendingImage]) async throws -> [String]?
var modelChoices: [String: String] = [:]
func vendorOf(_ model: String) -> String {
model.hasPrefix("gpt-") ? "codex" : "claude"
if model.hasPrefix("gpt-") {
return "codex"
}
if model.hasPrefix("gemini-") {
return "antigravity"
}
return "claude"
}
@MainActor
+1 -1
View File
@@ -37,7 +37,7 @@ struct OverlayAlert: Identifiable {
}
enum LeftTab {
case lead, status, settings
case lead, status, server, settings
}
@MainActor
+1
View File
@@ -815,6 +815,7 @@ private struct TableBlock: View {
grid.fixedSize(horizontal: true, vertical: false)
grid
}
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
+5
View File
@@ -138,6 +138,9 @@ private struct LeftPane: View {
PaneTabButton(label: "Status", active: tab == .status) {
overlays.leftTab = .status
}
PaneTabButton(label: "Server", active: tab == .server) {
overlays.leftTab = .server
}
PaneTabButton(label: "Settings", active: tab == .settings) {
overlays.leftTab = .settings
}
@@ -169,6 +172,8 @@ private struct LeftPane: View {
.accessibilityHidden(tab != .lead)
if tab == .status {
StatusView()
} else if tab == .server {
ServerView()
} else if tab == .settings {
SettingsView()
}
+161
View File
@@ -0,0 +1,161 @@
import Foundation
import SwiftUI
struct LaunchAgent: Decodable {
let programArguments: [String]
enum CodingKeys: String, CodingKey {
case programArguments = "ProgramArguments"
}
}
let agentPlist = FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/LaunchAgents/run.fc.byre.plist")
let serverHelper = Bundle.main.bundleURL.appending(path: "Contents/Helpers/byre").path
private let service = "gui/\(getuid())/run.fc.byre"
struct ServerState {
var on = false
var address: String?
}
struct ProcessFailure: LocalizedError {
let errorDescription: String?
}
private func execute(_ path: String, _ args: [String], env: [String: String] = [:]) async throws -> (status: Int32, output: String) {
try await Task.detached {
let p = Process()
p.executableURL = URL(filePath: path)
p.arguments = args
p.environment = ProcessInfo.processInfo.environment.merging(env) { $1 }
let out = Pipe()
p.standardOutput = out
p.standardError = out
try p.run()
let data = out.fileHandleForReading.readDataToEndOfFile()
p.waitUntilExit()
return (p.terminationStatus, String(decoding: data, as: UTF8.self))
}.value
}
private func launchctl(_ args: String...) async throws {
let r = try await execute("/bin/launchctl", args)
if r.status != 0 {
throw ProcessFailure(errorDescription: "launchctl \(args.joined(separator: " ")): \(r.output.trimmingCharacters(in: .whitespacesAndNewlines))")
}
}
enum Server {
static func state() async -> ServerState {
guard let r = try? await execute("/bin/launchctl", ["print", service]), r.status == 0 else {
return ServerState()
}
return ServerState(on: true, address: await address())
}
private static func address() async -> String? {
guard let r = try? await execute("/Applications/Tailscale.app/Contents/MacOS/Tailscale", ["serve", "status", "--json"], env: ["TAILSCALE_BE_CLI": "1"]), r.status == 0,
let json = try? JSONSerialization.jsonObject(with: Data(r.output.utf8)) as? [String: Any],
let web = json["Web"] as? [String: Any],
let host = web.keys.first(where: { $0.hasSuffix(":443") })
else {
return nil
}
return "https://" + host.dropLast(4)
}
// byre install copies its PATH into the service, which needs the login shell's to find claude, codex, agy and npx.
static func install() async throws -> URL {
let shell = unsafe String(cString: getpwuid(getuid()).pointee.pw_shell)
let r = try await execute(shell, ["-l", "-c", "exec \"$BYRE_HELPER\" install"], env: ["BYRE_HELPER": serverHelper])
let prefix = "byre is at "
guard r.status == 0, let line = r.output.split(separator: "\n").first(where: { $0.hasPrefix(prefix) }), let url = URL(string: String(line.dropFirst(prefix.count))) else {
throw ProcessFailure(errorDescription: "byre install failed: \(r.output.trimmingCharacters(in: .whitespacesAndNewlines))")
}
return url
}
// Reinstalling a running service restarts it, so an address read right after could still be answered by the old one.
static func turnOn() async throws -> URL {
let state = await state()
if state.on, let address = state.address, let url = URL(string: address) {
return url
}
return try await install()
}
static func turnOff() async throws {
try await launchctl("bootout", service)
}
}
struct ServerView: View {
@Environment(Overlays.self) private var overlays
@ViewState private var state: ServerState?
@ViewState private var busy = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
Text("Server on this Mac")
.font(Fonts.font(13, weight: 700))
.padding(.top, 16)
if let state {
details(state)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Palette.bg)
.task {
await refresh()
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
Task {
await refresh()
}
}
}
@ViewBuilder
private func details(_ state: ServerState) -> some View {
if state.on {
Text("On").foregroundStyle(Palette.ok)
if let address = state.address {
Text(address).textSelection(.enabled)
}
button("Turn off", Server.turnOff)
} else {
Text("Off").foregroundStyle(Palette.muted)
button("Turn on") {
_ = try await Server.turnOn()
}
}
}
private func button(_ label: String, _ action: @escaping () async throws -> Void) -> some View {
Button(label) {
busy = true
Task {
do {
try await action()
} catch {
overlays.showAlert(errorText(error))
}
await refresh()
busy = false
}
}
.buttonStyle(WebButtonStyle())
.disabled(busy)
.padding(.top, 4)
}
private func refresh() async {
state = await Server.state()
}
}
+1 -1
View File
@@ -131,7 +131,7 @@ private struct PrefsBox: View {
.padding(.top, 8)
.padding(.bottom, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Palette.border))
.background(RoundedRectangle(cornerRadius: 8).stroke(Palette.border))
.padding(.top, 16)
}
}
+51 -2
View File
@@ -86,6 +86,46 @@ final class Connection {
await start(origin)
}
func installHere() async {
connecting = true
problem = nil
let gen = generation
do {
let origin = try await Server.turnOn()
serverText = origin.absoluteString
try await waitForServer(origin)
} catch {
if gen == generation {
connecting = false
problem = errorText(error)
}
return
}
guard gen == generation else {
return
}
await connect()
}
private func waitForServer(_ origin: URL) async throws {
let api = API(origin: origin)
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
while (try? await api.client.getStatus().ok) == nil {
try await Task.sleep(for: .milliseconds(500))
}
}
group.addTask {
try await Task.sleep(for: .seconds(10))
throw ProcessFailure(errorDescription: "byre was installed but \(origin.absoluteString) didn't answer within 10 s.")
}
defer {
group.cancelAll()
}
try await group.next()
}
}
func changeServer() {
generation += 1
if let store {
@@ -175,8 +215,11 @@ struct ConnectView: View {
TextField("https://byre.example.ts.net", text: $connection.serverText)
.textFieldStyle(.roundedBorder)
.onSubmit(connect)
Button("Connect", action: connect)
.disabled(connection.connecting)
HStack(spacing: 10) {
Button("Connect", action: connect)
Button("Run byre on this Mac", action: installHere)
}
.disabled(connection.connecting)
if let problem = connection.problem {
Text(problem)
.foregroundStyle(Palette.danger)
@@ -193,6 +236,12 @@ struct ConnectView: View {
await connection.connect()
}
}
private func installHere() {
Task {
await connection.installHere()
}
}
}
struct FatalView: View {
+2 -12
View File
@@ -1,14 +1,6 @@
import Foundation
import Sparkle
private struct LaunchAgent: Decodable {
let programArguments: [String]
enum CodingKeys: String, CodingKey {
case programArguments = "ProgramArguments"
}
}
private let lastVersionKey = "lastLaunchedVersion"
@MainActor
@@ -31,11 +23,9 @@ final class Updater {
}
private func restartServer() {
let agent = FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/LaunchAgents/run.fc.byre.plist")
let server = Bundle.main.bundleURL.appending(path: "Contents/Helpers/byre").path
guard let data = try? Data(contentsOf: agent),
guard let data = try? Data(contentsOf: agentPlist),
let plist = try? PropertyListDecoder().decode(LaunchAgent.self, from: data),
plist.programArguments.first == server
plist.programArguments.first == serverHelper
else {
return
}
Executable
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
mac=$(cd "$(dirname "$0")" && pwd)
"$mac/package.sh"
version=$(plutil -extract CFBundleShortVersionString raw "$mac/.build/Byre.app/Contents/Info.plist")
dmg=$mac/.build/release/$version/byre-$version.dmg
"$mac/install.sh" "$dmg"
launchctl kickstart -k "gui/$(id -u)/run.fc.byre"
Executable
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
dmg=$1
mount=$(mktemp -d)
hdiutil attach -nobrowse -readonly -mountpoint "$mount" "$dmg"
rm -rf /Applications/Byre.app.new
ditto "$mount/Byre.app" /Applications/Byre.app.new
hdiutil detach "$mount"
rm -rf /Applications/Byre.app
mv /Applications/Byre.app.new /Applications/Byre.app
plutil -extract ByreCommit raw /Applications/Byre.app/Contents/Info.plist
Executable
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
set -eu
mac=$(cd "$(dirname "$0")" && pwd)
repo=ai/byre
sparkle_version=2.10.0
sparkle=$HOME/Library/Caches/byre/sparkle-$sparkle_version
identity="Developer ID Application"
if [ ! -x "$sparkle/bin/sign_update" ]; then
mkdir -p "$sparkle"
curl -fsSL "https://github.com/sparkle-project/Sparkle/releases/download/$sparkle_version/Sparkle-$sparkle_version.tar.xz" | tar -xJ -C "$sparkle"
fi
"$mac/bundle.sh"
app=$mac/.build/Byre.app
version=$(plutil -extract CFBundleShortVersionString raw "$app/Contents/Info.plist")
tag=v$version
out=$mac/.build/release/$version
rm -rf "$out"
mkdir -p "$out"
ditto -c -k --keepParent "$app" "$out/byre.zip"
xcrun notarytool submit "$out/byre.zip" --keychain-profile byre-notary --wait
xcrun stapler staple "$app"
rm "$out/byre.zip"
dmg=byre-$version.dmg
stage=$out/stage
mkdir "$stage"
ditto "$app" "$stage/Byre.app"
ln -s /Applications "$stage/Applications"
hdiutil create -volname byre -srcfolder "$stage" -format UDZO "$out/$dmg"
rm -rf "$stage"
codesign --force --timestamp --sign "$identity" "$out/$dmg"
xcrun notarytool submit "$out/$dmg" --keychain-profile byre-notary --wait
xcrun stapler staple "$out/$dmg"
(cd "$out" && shasum -a 256 "$dmg" > "$dmg.sha256")
build=$(plutil -extract CFBundleVersion raw "$app/Contents/Info.plist")
minimum=$(plutil -extract LSMinimumSystemVersion raw "$app/Contents/Info.plist")
signature=$("$sparkle/bin/sign_update" --account byre "$out/$dmg")
cat > "$out/appcast.xml" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>byre</title>
<item>
<title>byre $version</title>
<pubDate>$(LC_ALL=C date -u "+%a, %d %b %Y %H:%M:%S +0000")</pubDate>
<sparkle:version>$build</sparkle:version>
<sparkle:shortVersionString>$version</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>$minimum</sparkle:minimumSystemVersion>
<enclosure url="https://g.fc.run/$repo/releases/download/$tag/$dmg" type="application/octet-stream" $signature/>
</item>
</channel>
</rss>
EOF
echo "$out/$dmg"
+1 -48
View File
@@ -4,9 +4,6 @@ mac=$(cd "$(dirname "$0")" && pwd)
root=$(dirname "$mac")
repo=ai/byre
api=https://g.fc.run/api/v1/repos/$repo
sparkle_version=2.10.0
sparkle=$HOME/Library/Caches/byre/sparkle-$sparkle_version
identity="Developer ID Application"
if [ -n "$(git -C "$root" status --porcelain)" ]; then
echo "release: the tree has uncommitted changes" >&2
@@ -21,53 +18,9 @@ if [ "$(git -C "$root" rev-parse "FETCH_HEAD^{commit}")" != "$(git -C "$root" re
fi
token=$(security find-generic-password -s byre-release -w)
if [ ! -x "$sparkle/bin/sign_update" ]; then
mkdir -p "$sparkle"
curl -fsSL "https://github.com/sparkle-project/Sparkle/releases/download/$sparkle_version/Sparkle-$sparkle_version.tar.xz" | tar -xJ -C "$sparkle"
fi
"$mac/bundle.sh"
app=$mac/.build/Byre.app
"$mac/package.sh"
out=$mac/.build/release/$version
rm -rf "$out"
mkdir -p "$out"
ditto -c -k --keepParent "$app" "$out/byre.zip"
xcrun notarytool submit "$out/byre.zip" --keychain-profile byre-notary --wait
xcrun stapler staple "$app"
rm "$out/byre.zip"
dmg=byre-$version.dmg
stage=$out/stage
mkdir "$stage"
ditto "$app" "$stage/Byre.app"
ln -s /Applications "$stage/Applications"
hdiutil create -volname byre -srcfolder "$stage" -format UDZO "$out/$dmg"
rm -rf "$stage"
codesign --force --timestamp --sign "$identity" "$out/$dmg"
xcrun notarytool submit "$out/$dmg" --keychain-profile byre-notary --wait
xcrun stapler staple "$out/$dmg"
(cd "$out" && shasum -a 256 "$dmg" > "$dmg.sha256")
build=$(plutil -extract CFBundleVersion raw "$app/Contents/Info.plist")
minimum=$(plutil -extract LSMinimumSystemVersion raw "$app/Contents/Info.plist")
signature=$("$sparkle/bin/sign_update" --account byre "$out/$dmg")
cat > "$out/appcast.xml" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>byre</title>
<item>
<title>byre $version</title>
<pubDate>$(LC_ALL=C date -u "+%a, %d %b %Y %H:%M:%S +0000")</pubDate>
<sparkle:version>$build</sparkle:version>
<sparkle:shortVersionString>$version</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>$minimum</sparkle:minimumSystemVersion>
<enclosure url="https://g.fc.run/$repo/releases/download/$tag/$dmg" type="application/octet-stream" $signature/>
</item>
</channel>
</rss>
EOF
previous=$(git -C "$root" describe --tags --abbrev=0 "$tag^" || true)
if [ -n "$previous" ]; then