5 Commits
24 changed files with 1207 additions and 425 deletions
+58 -2
View File
@@ -1045,6 +1045,31 @@ paths:
application/json:
schema: { $ref: "#/components/schemas/Status" }
/api/admin/usage-commands:
get:
operationId: getUsageCommands
responses:
"200":
description: Each vendor's usage command.
content:
application/json:
schema: { $ref: "#/components/schemas/UsageCommands" }
put:
operationId: updateUsageCommands
description: Sets each vendor's usage command and reads every vendor's usage again.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/UsageCommands" }
responses:
"200":
description: The saved commands.
content:
application/json:
schema: { $ref: "#/components/schemas/UsageCommands" }
"400": { $ref: "#/components/responses/Error" }
/api/admin/permissions:
post:
operationId: checkPermissions
@@ -1823,8 +1848,8 @@ components:
items: { $ref: "#/components/schemas/TurnRef" }
rate_limits:
type: object
description: The latest rate-limit status each vendor reported, keyed by vendor.
additionalProperties: { type: object }
description: Each vendor's latest usage, keyed by vendor, from its usage command or, without one, what the vendor itself reported.
additionalProperties: { $ref: "#/components/schemas/Usage" }
started_at: { type: string, format: date-time }
db_bytes: { type: integer, format: int64 }
last_event_seq: { type: integer, format: int64 }
@@ -1836,6 +1861,37 @@ components:
description: Each running app that holds its /api/clients/connect WebSocket, oldest connection first.
items: { $ref: "#/components/schemas/ConnectedClient" }
Usage:
type: object
required: [updated_at, windows]
properties:
updated_at: { type: string, format: date-time, description: When the numbers were current. }
rejected: { type: string, description: "Why the vendor is refusing work, such as a usage cap or depleted credits; absent while it isn't. A window at its limit also counts as rejected." }
windows:
type: array
description: Independent meters, in the order to show them.
items: { $ref: "#/components/schemas/UsageWindow" }
UsageWindow:
type: object
required: [label, used, unit]
properties:
label: { type: string, description: Short name for the meter. }
used: { type: number, format: double, description: Amount used, in unit. }
limit: { type: number, format: double, description: The window's limit, in unit; absent for a bare count. }
unit: { type: string, description: "usd, tokens, requests or percent; selects formatting." }
resets_at: { type: string, format: date-time, description: When the meter resets. }
window_seconds: { type: integer, format: int64, description: The window's length, when known, for the pace bar. }
UsageCommands:
type: object
description: The shell command that reports each vendor's usage, keyed by vendor; empty uses what the vendor itself reports.
required: [claude, codex, antigravity]
properties:
claude: { type: string }
codex: { type: string }
antigravity: { type: string }
ConnectedClient:
type: object
required: [id, login, host, commit, connected_at]
+20 -9
View File
@@ -41,6 +41,8 @@ An implementer says its task is ready to merge with the `ready_for_merge` MCP to
Agents find closed tasks with the `search_closed_tasks` MCP tool, which task agents get for their own project and the lead for one project (`project_id`) or all: a case-insensitive substring match of `query` on title, merge title, TODO item, summary and merge commit (`Store.SearchClosedTasks`), newest closed first, at most `limit` (default 20, at most 100), each with its id, outcome, merge commit, close time, creator, titles, TODO item, fork source and summary. The lead reads a closed task's transcript with `read_transcript`. There is no closed-task list in the UI; the human asks the lead.
**Knowledge base.** How byre features work that agents need only now and then lives in articles they fetch on demand instead of in their guidance: the Markdown files in `internal/agents/kb/`, embedded in the binary (`kb.go`). A file's name without `.md` is its topic, its first line a one-line summary and the rest the article. The `read_knowledge` MCP tool, which task agents and the lead get, lists every topic with its summary in its description and returns the article for `topic`. Adding an article is adding a file; the index follows. The articles are `signing` (turning on signed merge commits) and `usage-commands` (the usage command's JSON format, see "Usage" under "Status").
`POST /api/tasks/{id}/discard` (the **Discard** button, the lead's `discard_task`, and byre itself for a finished workflow's other stages) doesn't remove anything at once: it gives the task's agent one dedicated turn to write its summary, and closes the task when that turn ends. The discard is a persisted state of the task, `tasks.discard_state`, and every step that changes it is one transaction in the store (`internal/store/discard.go`), which is what keeps it safe against turns starting, ending and byre restarting around it:
| `discard_state` | meaning | left by |
@@ -203,7 +205,7 @@ 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`; 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`.
`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. An interrupt holds all of that: after a turn ends interrupted, byre starts no turn on its own until someone sends the session a message (`Manager.interrupted`, true while the live session's `interrupted` flag is set, which the next `user.message` clears). The turn end delivers nothing, the ready-for-merge prompt waits, a message from byre, an agent (`message_subtask`) or the board is queued as if the session were running instead of starting a turn, and a lead holds its signals (a human's queued lead message still goes out). Whatever was held goes out mid-turn during, or at the end of, the next turn the human (or the lead's `send_message`) starts; an inbox answer starts a turn as usual. The flag is in memory only, so a byre restart releases what was held. 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.
@@ -231,11 +233,18 @@ 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`.
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 usage comes from `agy --output-format json --print /usage` (`ReadUsage` in `internal/antigravity/usage.go`), which answers without starting a turn (see "Usage" under "Status"). 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`; 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).
`GET /api/admin/status` reports `running_turns` (task sessions now `running`, as session, task and vendor; lead turns aren't listed); `rate_limits`, each vendor's latest usage, see "Usage" below; `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).
**Usage.** Each vendor's entry in `rate_limits` is a `Usage` (`api/openapi.yaml`): `updated_at`, an optional `rejected` (why the vendor is refusing work, such as Claude's `rateLimitType` when its status is `rejected` or Codex's `rateLimitReachedType`, which covers depleted credits and caps no window shows) and `windows`, each with `label`, `used`, an optional `limit`, `unit` (`usd`, `tokens`, `requests` or `percent`), and optional `resets_at` and `window_seconds`. It lives in memory only (`Manager.rateLimits`) and comes from one of two places, both in `internal/agents/usage.go`:
- **A usage command.** `usage_commands` holds an optional shell command per vendor, read with `GET /api/admin/usage-commands` and set with `PUT /api/admin/usage-commands` (which records `admin.usage_commands`) from the Server tab. When a vendor has one, byre runs it with `$SHELL -l -c` in byre's own environment, in its own process group, killed after 10 seconds (`runUsageCommand`), one run at a time per vendor (`Manager.usageMu`), so a later run's result always lands last: at startup, after every turn of that vendor's sessions ends (`turnEnded`, leads and reviewers included), and when it is saved. It prints one JSON object of the shape above on stdout and exits 0; a window may give `resets_in_seconds`, relative to `updated_at` (now when absent), in place of `resets_at`, and unknown fields are ignored (`parseUsage`). A non-zero exit, a timeout or output that doesn't parse removes the vendor's entry and logs `usage command`. The vendor's own reports are then ignored.
- **The vendor's own reports**, when its command is empty, each window a `percent` with `limit` 100. Claude's `rate_limit_event` during its turns, one window per `unifiedWindows` entry with `window_seconds` from its name (`claudeUsage`), since Claude doesn't send a length. Codex's `account/rateLimits/updated`, its `primary` and `secondary` windows (`codexUsage`), 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. Antigravity's `agy --output-format json --print /usage`, one window per bucket labelled by its `id` (`agyUsage`), asked at startup and after every Antigravity turn ends.
Every change broadcasts `admin.health`. A Claude `rejected` status still drives `session.rate_limited` (see "Errors") whichever source the Status tab shows.
Agent health is `internal/agents/health.go`:
@@ -244,7 +253,7 @@ Agent health is `internal/agents/health.go`:
- **Watching.** `Manager.Watch` recomputes health every `healthInterval`. When the set of problems (non-`ok` processes and zombies) changes, it logs each new one as `agent health problem` and broadcasts an unstored `admin.health` event (`seq` 0). Nothing is killed or repaired automatically.
- **Protocol errors.** `internal/claude` reports a stdout line that isn't a JSON object, doesn't parse, or has a `type` outside `knownTypes` (the line is still passed on), and the manager adds lines whose content it fails to decode. `protocol_errors.counts` counts them per vendor since byre started; `recent` holds the last `keptBadLines`, newest first, each with session, time, reason, the raw line cut to `badLineBytes` and its full `size`. They live in memory only, and each one also broadcasts `admin.health`.
The 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, all vendors in one table so their columns and pace bars line up; 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`.
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 usage, headed `rejected` (with the `rejected` reason when there is one) when `rejected` is set or any window's `used` has reached its `limit` (else `allowed`) and the time since its `updated_at`, and per window its label, the amount used formatted by `unit` (`$150.94 / $200.00 · 75%` for `usd`, `36 / 40 requests · 90%`, `1.2M tokens` without a limit, `42%` for `percent`), the share of the window's time elapsed (from `resets_at` and `window_seconds`, `—` without both), 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, all vendors in one table so their columns and pace bars line up; 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` (as `mcp__<namespace>__<tool>`, so byre's tools carry the same names as Claude's), `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.
@@ -269,19 +278,19 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie
- **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; 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_queued` ("<task>'s merge was requested by <login> and is held until the merge in progress in its project is done."), `task.merge_after_review` ("<task> was set by <login> to merge once the review of snapshot <n> finishes with no findings."), `task.merge_after_review_cancelled` ("<task>'s merge after review was cancelled by <actor>."), `task.merge_after_review_dropped` ("<task> didn't merge after the review: <reason>"), `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, a held merge (`merge_queued_at`) and a merge after review (`merge_after_review`) each noted, 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.
- **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_queued` ("<task>'s merge was requested by <login> and is held until the merge in progress in its project is done."), `task.merge_after_review` ("<task> was set by <login> to merge once the review of snapshot <n> finishes with no findings."), `task.merge_after_review_cancelled` ("<task>'s merge after review was cancelled by <actor>."), `task.merge_after_review_dropped` ("<task> didn't merge after the review: <reason>"), `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. A batch whose lines are all quiet is dropped instead of held (`quietSignal` in `internal/agents/lead.go`): `task.state`, `review.started`, and `task.created`, `task.merge_started`, `task.merged` and `task.discarded` by a human, unless the line carries "Your right pane is free" (see "Pane lock"). Quiet lines still ride along in a batch with anything else, and the "Tasks now" list that follows every batch shows each task's current state. 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, a held merge (`merge_queued_at`) and a merge after review (`merge_after_review`) each noted, 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`, `read_knowledge` (see "Knowledge base"), 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.
- **Focus.** `users.focus` holds what the user's right pane shows: `project_id`, `task_id` and `tab` (the selected one). The tabs' order is fixed; neither the lead nor the human can change it. With a `task_id`, `project_id` is always that task's project, filled in by the server; with only a `project_id`, the pane shows that project with no task, which only the Project tab has anything for. `GET /api/me/focus` reads it; `PUT /api/me/focus` and the lead's `show` set it, refusing an unknown tab, a task that isn't active or an unknown project, and record a `focus.updated` event owned by the user. The app applies the focus at startup and on every `focus.updated` it didn't cause itself; the human's own changes (⌘K, the right pane's task picker, clicking a tab, a task link, a notification) are sent with `PUT /api/me/focus`.
- **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and changes nothing. Otherwise, while the human is working in the right pane (the app reports `focused` or `drafting`, below), the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (`Manager.paneFinished`): its task is merged, discarded or merging (`merging_by` or `finalizing` set), or the human is waiting on the task's agent (`Manager.paneWaiting`): the selected tab is `transcript`, the task's implementer session is running, the app reports no unsent text in the pane, and either the `user.message` that started the running turn came from the user after the pane last moved, or the pane last moved 10 seconds ago or more (`watchHold`). The pane's last move is the last `PUT /api/me/focus` or `show` that set it (`Manager.paneMoved`, in memory, so a restart counts as long ago). For the user's own message, unsent text reported before the turn started doesn't count, since the app's report of the composer emptying on send can arrive after the message does. A `user.message` on a task from a human (not `byre`, an agent or a lead) signals every lead "<actor> sent <task> a message; its agent is working on it.", and the sender's own lead, when its pane shows that task and is waiting that way, also gets "Your right pane is free: the human is waiting on <task>'s agent." Each `PUT /api/me/focus` from the user starts a 10-second timer (`endWatchHold`); when it fires, if the pane hasn't moved since, the app still reports the human in it, the user hasn't messaged the task since the move (that already freed it, above), and the pane is waiting that way, the lead gets the signal "Your right pane is free: the human has watched <task>'s agent work for 10 seconds without typing." The lead's own `show` starts no timer. When a `task.merge_started`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: <task> is merging." (or "is merged."/"is discarded."). The lead's guidance tells it to show the next thing that needs the human on that line, or, when nothing does, the project's Project tab with a short status in chat: one line per active task, then up to three TODO items it would start next, each with a reason, starting one only when the human picks it. It writes each item as a link to `#/todo/<project_id>/<URL-encoded title>`; Markdown views draw that (`todo` in `mac/Sources/Byre/MarkdownView.swift`) as the title followed by `start` and `discuss` actions, as accent link text so the line stays selectable, that call `POST /api/projects/{id}/todo/tasks` with the user's default model, as the Project tab's do, or, once an active task was started from that item, the task's colour, dot and state, linking to it. When the lead suggests new work that isn't in TODO.md, its guidance tells it to write a link to `#/suggest/<project_id>/<URL-encoded title>/<URL-encoded brief>`, drawn by the same code with `start` and `discuss` buttons: `start` calls `POST /api/projects/{id}/tasks` with the title, the brief as `prompt` and the user's default model, which checks the user's git identity, creates the task and starts its agent in one request, with no lead turn in between; `discuss` sends the lead `POST /api/me/lead/messages` asking to discuss the suggestion, quoting its title and brief (with the user's default model only when the user has no live lead session). Once an active task in that project has the suggestion's title, it shows that task's state the same way. A chat view refills them whenever its transcript grows or tasks or sessions change. When that status says nothing new since its last one, it replies `NO_REPLY` instead. The app sends `PUT /api/me/pane-focus` with `focused` and `drafting` whenever either changes (`PaneFocus` in `mac/Sources/Byre/PaneFocus.swift`): `focused` is true while the window is key, the app is active and the keyboard focus or the last click is inside the right pane, so a click anywhere in it counts, not only on inputs and buttons; `drafting` while any text field in the right pane other than the terminal holds non-blank text. The window reports to the server it shows. It sends false on deactivating, switching to another server, Forget Server, closing the window and quit, and resends true when its event stream reconnects. byre keeps both flags and the time of the last report in memory per user (`Manager.paneFocus`, `Manager.paneReported`), so they start false after a restart until the app resends them.
- **Move hold.** Each `PUT /api/me/focus` from the user (switching task or tab, by any means: the switcher, a tab, a link or a notification) records the time in `Manager.humanMoved`, and for 10 seconds after it (`moveHold`) every `show` that would change the view is refused, whatever the pane lock above would allow, with an error giving how long ago the move was. A `show` of what the pane already shows changes nothing and says so. A refusal also marks the user in `Manager.moveRefused`, which each move by the user clears. When the 10 seconds pass without another move by the user (`endMoveHold`, on a timer), byre clears both and, only if a `show` was refused since that move, sends the lead the signal "The move hold is over: …"; its guidance tells it to show what it held back if the pane's other rules allow, or reply `NO_REPLY`. Because only a refusal arranges the signal, the lead's system prompt and the `show` tool's description tell it to call `show` at once whenever the user asks to see something or it wants to show something, never holding back in expectation of a refusal. It is in memory, so a restart clears it.
- **Start hold.** Start on a TODO item in the Project tab leaves the pane where it is. When the user starts one themselves (`StartTodo` with the user as actor, not the lead's `start_todo`), byre records the time in `Manager.startedTodo` for them, and for 10 seconds after it (`startHold`), while the pane's selected tab is `project`, every `show` that would change the view is refused, focused or not, with an error telling the lead the human is likely to start more. A refusal also marks the user in `Manager.startRefused`. The user's own `PUT /api/me/focus` (switching task or tab) or a message to the lead clears both, and another Start restarts the hold and clears the mark. The refusal and the `show` tool's description both tell the lead the hold lasts 10 seconds. When the 10 seconds pass without it being cleared or restarted (`endStartHold`, on a timer), byre clears both and, only if a `show` was refused since that Start and the pane's selected tab is still `project`, sends the lead the signal "The start hold is over: …" so it can show what it held back; its guidance tells it to reply `NO_REPLY` when that no longer matters. It is in memory, so a restart clears it.
- **Compaction.** The pane's **Compact** button asks the lead for a handoff (`leadHandoffPrompt`). The lead can't see its own context size, so the first time one of its turns ends (other than a handoff or a `compact_self` turn) with the latest `session.context` `used` at half of its `window` or more, byre holds a signal for it giving the percentage and asking it to call `compact_self` at the next logical point (`warnLeadContext` in `internal/agents/lead.go`); it is sent once per live process, so a byre restart can send it again once the window is known. The lead calls `compact_self`; when that turn ends normally, byre sends the same request. The handoff turn ends the session, and a new lead session starts from `leadHandoffPreamble` plus the handoff; held input follows once that turn ends.
- **Compaction.** The pane's **Compact** button asks the lead for a handoff (`leadHandoffPrompt`). The lead can't see its own context size, so the first time one of its turns ends (other than a handoff or a `compact_self` turn) with the latest `session.context` `used` at a quarter of its `window` or more, byre holds a signal for it giving the percentage and asking it to call `compact_self` at the next logical point (`warnLeadContext` in `internal/agents/lead.go`); it is sent once per live process, so a byre restart can send it again once the window is known. The lead calls `compact_self`; when that turn ends normally, byre sends the same request. The handoff turn ends the session, and a new lead session starts from `leadHandoffPreamble` plus the handoff; held input follows once that turn ends.
- **Notes.** The lead keeps notes on how its user works (priorities, preferences, the reasoning behind choices) in `lead_notes`, one row per note keyed by `owner`, through its `remember` (add, or replace the text and project of an `id`), `forget` and `list_notes` tools (`internal/agents/leadnotes.go`). A note has a `project_id` when it applies to one project and none (the column empty) when it applies to the user in every project; `remember` takes `project_id` and refuses one that names no project, and the guidance tells the lead to file a note about one project's code, UI, docs or process under it and to apply it only to that project's tasks. Each change records a `lead.note_saved` or `lead.note_deleted` event owned by the user. When a lead's agent process starts, `notesText` lists every note with its id after `leadGuidance`, the every-project notes first and then each project's under its name and id, in the session's instructions (`--append-system-prompt` for Claude, which keeps what the session's first request carried; `developerInstructions` for Codex), but not in the per-turn guidance reminder, so a new or compacted lead session starts from the current notes and notes written mid-session are already in its context. The guidance tells the lead when to write a note (an answer that departs from a recommendation, a correction, a stated preference), to cite a note's id when it shapes a recommendation, and that it may answer a task agent's question itself when a note settles it outright. `GET /api/me/lead/notes` lists the caller's notes and `DELETE /api/me/lead/notes/{noteId}` deletes one (`Manager.DeleteLeadNote`), and, when the user has a live lead session, holds a message for it from `byre` naming the note's id and text and telling it to stop applying it, since the running session still has the note in its instructions. It is held as a message, not a signal, so it keeps its place in line with the human's own messages: it goes out ahead of any message they send after the delete, as its own turn (drawn like a signal batch's, and folded away on `NO_REPLY`) or mid-turn after the lead's next tool call; the Notes tab (`mac/Sources/Byre/Notes.swift`) shows one set at a time, picked from a dropdown of Every project and each project by name with its note count (the pick is kept in memory across tab switches), each note with a small delete button styled like the transcript's, refetching on either event. `sqlite3 byre.db "SELECT owner, project_id, id, text FROM lead_notes ORDER BY created_at"` shows them.
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 six tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Notes, the lead's notes (see "Notes" under the lead), Workflows, Status, Server and Settings, and at the end of its tab strip the connection dot and the user's name. Workflows (`mac/Sources/Byre/Workflows.swift`) lists `GET /api/workflows` by name with each description, refetching on `workflow.updated` and `workflow.deleted`; a workflow's run button doesn't start anything itself, since a run needs a project and a brief: it appends `Run the "<name>" workflow: ` to the lead's composer draft, selects the Lead tab and focuses the composer, for the human to finish and send. 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`), so its headings name the Mac (`Host.current().localizedName`, as in `Byre-Host`): Server on <Mac> 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. Below that, Tools on <Mac> lists every check from the bundle's `byre check` (see "Running as a service"), each Found or Missing, with how to install a missing one: its command in a box with Copy and Run in Terminal, which writes the command to a `.command` file in the temporary directory, run by the user's login shell, and opens it in Terminal, so prompts like sudo's work and the list updates when the app becomes active again; Missing is in the danger colour only when it blocks the server. Run byre on this Mac runs `byre check` first, and when something blocks it lists the missing checks in the same form instead of installing. 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. The app runs both helpers through the user's login shell, so they see the `PATH` the service is given. It reads the state and checks again each time it shows and when the app becomes active. While the service is on, Agents' permissions has Check permissions, which calls `POST /api/admin/permissions` on the service at that address (see "Permissions" under "Screen and app control") and lists each grant as Granted, Missing (with Open System Settings, which opens the grant's pane) or Not checked. Settings holds the user's and this client's preferences; its This Mac's permissions has the same Check permissions for the app's own grants, the ones `client_hands` uses, running the bundle's `byre-hands check` from a copy in the temporary directory (`checkLocalPermissions` in `Server.swift`). Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Notes, Workflows, 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, Workflow and Project, always in that order. Board shows only on a team task (see "Teams") 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. Questions and reviews have no tabs: both live in the transcript (see "Inbox" and "Reviews"), and the transcript's attention buttons, at its top right, count what needs the user there (see "Attention" under "Inbox"). 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 six tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Notes, the lead's notes (see "Notes" under the lead), Workflows, Status, Server and Settings, and at the end of its tab strip the connection dot and the user's name. Workflows (`mac/Sources/Byre/Workflows.swift`) lists `GET /api/workflows` by name with each description, refetching on `workflow.updated` and `workflow.deleted`; a workflow's run button doesn't start anything itself, since a run needs a project and a brief: it appends `Run the "<name>" workflow: ` to the lead's composer draft, selects the Lead tab and focuses the composer, for the human to finish and send. 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`), so its headings name the Mac (`Host.current().localizedName`, as in `Byre-Host`): Server on <Mac> 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. Below that, Tools on <Mac> lists every check from the bundle's `byre check` (see "Running as a service"), each Found or Missing, with how to install a missing one: its command in a box with Copy and Run in Terminal, which writes the command to a `.command` file in the temporary directory, run by the user's login shell, and opens it in Terminal, so prompts like sudo's work and the list updates when the app becomes active again; Missing is in the danger colour only when it blocks the server. Run byre on this Mac runs `byre check` first, and when something blocks it lists the missing checks in the same form instead of installing. 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. The app runs both helpers through the user's login shell, so they see the `PATH` the service is given. It reads the state and checks again each time it shows and when the app becomes active. While the service is on, Agents' permissions has Check permissions, which calls `POST /api/admin/permissions` on the service at that address (see "Permissions" under "Screen and app control") and lists each grant as Granted, Missing (with Open System Settings, which opens the grant's pane) or Not checked. Below it, Usage commands has one field per vendor, empty meaning the vendor's own reports, loaded from and saved to that service's `/api/admin/usage-commands` (see "Usage" under "Status"). Settings holds the user's and this client's preferences; its This Mac's permissions has the same Check permissions for the app's own grants, the ones `client_hands` uses, running the bundle's `byre-hands check` from a copy in the temporary directory (`checkLocalPermissions` in `Server.swift`). Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Notes, Workflows, 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, Workflow and Project, always in that order. Board shows only on a team task (see "Teams") 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. Questions and reviews have no tabs: both live in the transcript (see "Inbox" and "Reviews"), and the transcript's attention buttons, at its top right, count what needs the user there (see "Attention" under "Inbox"). 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
@@ -366,7 +375,7 @@ A byre restart drops every connection; each app reconnects 2s later, and calls i
byre never signs with a key on the machine it runs on. When a project checkout's `commit.gpgsign` is true (`git -C <checkout> config --type=bool --get commit.gpgsign` prints it), the squash commit of a Merge or `merge_to_main` is signed on the merging user's Mac, so a key that never leaves that Mac's Secure Enclave and needs a fingerprint works. `SquashCommit` (`internal/git/git.go`) makes the unsigned commit with `git commit-tree`, reads its object with `git cat-file commit`, and hands those bytes to the signer (`internal/agents/sign.go`). The signer sends `op: sign` with the bytes in `data`, `ident` (`Name <email>` from the user's git identity) and `timeout_ms` 120000 to the user's most recently active connected client, the same order `list_clients` uses, and fails the merge when the user has none connected. The app writes the bytes to a temporary file and runs a script under `/bin/zsh -l` in the home directory that reads that Mac's own git config: `gpg.format` (default `openpgp`), its program (`gpg.openpgp.program` or `gpg.program`, default `gpg`, for `openpgp`; `gpg.x509.program`, default `gpgsm`, for `x509`; any other format fails), and `user.signingkey`, or `ident` when unset. It runs the program as git does, `<program> --status-fd=2 -bsau <key>` with the commit on stdin, with the signature written to a second temporary file, since login shell startup files can print to stdout. The reply carries the exit status, the program's stderr in `output` and the signature in `data`. byre fails the merge unless the status is 0 and `output` has a `[GNUPG:] SIG_CREATED ` line, as git requires, and otherwise inserts the signature as a `gpgsig` header before the commit's blank line, each line after the first indented by one space, and writes the signed object with `git hash-object -t commit -w --stdin`. The merge waits up to 2 minutes 30 seconds for the reply, and a Touch ID prompt the human doesn't answer within the 2 minutes fails it. The notes commit is not signed.
`byre signing <checkout>` (`cmd/byre/signing.go`) turns it on for a project, and task agents' guidance names it. It sets `commit.gpgsign` true in the checkout's git config, and false for its task worktrees, which share that config and would otherwise try to sign their end-of-turn and agents' own commits on the server, and fail. It writes `commit.gpgsign false` to `unsigned.gitconfig` in the checkout's git common directory (`git rev-parse --path-format=absolute --git-common-dir`, which resolves symlinks, as git's `gitdir:` matching does) and includes it with `includeIf.gitdir:<common dir>/worktrees/**.path`, which `git config` appends after `commit.gpgsign` so it wins. It prints both values, and fails unless the checkout's reads true. It refuses a checkout that is itself a linked worktree, since the include would turn signing off for it too. `extensions.worktreeConfig` wouldn't do it, since `git worktree add` copies the checkout's `config.worktree` into each new worktree, nor would the server's global config, which is read before the checkout's. The client Mac needs git's own signing config, the same that signs the user's local commits: `gpg.format` (`openpgp` or `x509`; `ssh` is not supported), the program when it isn't `gpg`/`gpgsm`, and `user.signingkey` when the program can't find the key from `ident`. The program must print `[GNUPG:] SIG_CREATED ` on its status fd. The signature is made for the user's git identity in byre, not the Mac's `user.email`.
`byre signing <checkout>` (`cmd/byre/signing.go`) turns it on for a project, and agents learn it from the `signing` knowledge-base article (see "Knowledge base"). It sets `commit.gpgsign` true in the checkout's git config, and false for its task worktrees, which share that config and would otherwise try to sign their end-of-turn and agents' own commits on the server, and fail. It writes `commit.gpgsign false` to `unsigned.gitconfig` in the checkout's git common directory (`git rev-parse --path-format=absolute --git-common-dir`, which resolves symlinks, as git's `gitdir:` matching does) and includes it with `includeIf.gitdir:<common dir>/worktrees/**.path`, which `git config` appends after `commit.gpgsign` so it wins. It prints both values, and fails unless the checkout's reads true. It refuses a checkout that is itself a linked worktree, since the include would turn signing off for it too. `extensions.worktreeConfig` wouldn't do it, since `git worktree add` copies the checkout's `config.worktree` into each new worktree, nor would the server's global config, which is read before the checkout's. The client Mac needs git's own signing config, the same that signs the user's local commits: `gpg.format` (`openpgp` or `x509`; `ssh` is not supported), the program when it isn't `gpg`/`gpgsm`, and `user.signingkey` when the program can't find the key from `ident`. The program must print `[GNUPG:] SIG_CREATED ` on its status fd. The signature is made for the user's git identity in byre, not the Mac's `user.email`.
No restart is needed; the next Merge or `merge_to_main` reads the checkout's config. It needs the merging user's app connected on a configured Mac, and the squash commit then carries a `gpgsig` header (`git -C <checkout> cat-file commit HEAD` shows it; the server may have no program to verify it with).
@@ -390,6 +399,8 @@ Each active task can have one terminal: a login shell (`$SHELL -l`, from byre's
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.
For commands that don't need the human to see them, agents have the `bash` MCP tool (`internal/agents/bash.go`), for agents whose own shell tool runs in a sandbox that gets in the way. byre runs `$SHELL -l -c <command>` itself, as the service user and outside the agent's sandbox, in the task's worktree with byre's environment plus the task's `PORT`, stdin empty, stdout and stderr into one buffer, in its own process group. The group gets `SIGKILL` after `timeout_ms` (default 120000, at most 600000) or when the call is cancelled (in a Claude session, an MCP call's context ends when Claude sends `control_cancel_request` for it, as on an interrupt, or when the session's output ends), and the reply says `killed after <timeout>`; otherwise it starts with the exit status (`exit status 1`, `signal: killed`). The output is the last 64 KiB, as raw text. Background children holding the output open are waited for at most 5s after the command exits, and the reply then says the output was cut off.
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.
## Snapshots
+5 -19
View File
@@ -153,7 +153,6 @@ func (m *Manager) agyTurnEnded(ctx context.Context, l *live, p antigravityProces
m.mu.Lock()
final := l.lastText
m.mu.Unlock()
go m.readAgyRateLimit()
success := res.Status == "SUCCESS"
bounce := false
@@ -207,30 +206,17 @@ func (m *Manager) readAgyRateLimit() {
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{}})
m.setVendorUsage(api.Antigravity, agyUsage(groups))
}
func agyRateLimit(groups []antigravity.UsageGroup) map[string]any {
info := map[string]any{"status": "allowed"}
windows := map[string]any{}
func agyUsage(groups []antigravity.UsageGroup) api.Usage {
u := api.Usage{UpdatedAt: time.Now(), Windows: []api.UsageWindow{}}
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
u.Windows = append(u.Windows, percentWindow(b.ID, 1-b.RemainingFraction, b.ResetTime.Unix(), int64(agyWindowMins[b.Window])*60))
}
}
}
info["unifiedWindows"] = windows
return info
return u
}
func (m *Manager) agyBeforeModel(l *live) antigravity.HookFunc {
+111
View File
@@ -0,0 +1,111 @@
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
"g.fc.run/ai/byre/internal/mcp"
)
const bashKeep = 64 << 10
type bashArgs struct {
Command string `json:"command"`
TimeoutMs *int `json:"timeout_ms"`
}
func (m *Manager) bashTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "bash",
Description: "Run a shell command on the Mac byre runs on, in your task's worktree, with $SHELL as a login shell, and get its exit status and output (stdout and stderr together, the last 64 KiB). " +
"byre runs it itself, outside any sandbox your own shell tool has, with byre's environment and $PORT set to this task's first port. " +
"Stdin is empty. The command runs in its own process group, which is killed at the timeout.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"command": {"type": "string"},
"timeout_ms": {"type": "integer", "description": "Kill the command after this long (default 120000, at most 600000)."}
},
"required": ["command"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
return m.bash(ctx, l, raw)
},
}
}
func (m *Manager) bash(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
args := bashArgs{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed bash call: %w", err)
}
if strings.TrimSpace(args.Command) == "" {
return "", errors.New("command is empty")
}
timeout := defaultBashTimeout
if args.TimeoutMs != nil {
timeout = time.Duration(*args.TimeoutMs) * time.Millisecond
}
if timeout <= 0 || timeout > maxBashTimeout {
return "", fmt.Errorf("timeout_ms must be between 1 and %d", maxBashTimeout.Milliseconds())
}
task, err := m.store.GetTask(ctx, l.taskID)
if err != nil {
return "", err
}
shell := os.Getenv("SHELL")
if shell == "" {
return "", errors.New("SHELL is not set in byre's environment")
}
cctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(cctx, shell, "-l", "-c", args.Command)
cmd.Dir = l.dir
cmd.Env = append(os.Environ(), portEnv(task)...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
cmd.WaitDelay = 5 * time.Second
out := &tailWriter{}
cmd.Stdout = out
cmd.Stderr = out
err = cmd.Run()
status := "exit status 0"
exit := &exec.ExitError{}
switch {
case ctx.Err() != nil:
return "", ctx.Err()
case cctx.Err() != nil:
status = fmt.Sprintf("killed after %s", timeout)
case errors.As(err, &exit):
status = exit.ProcessState.String()
case errors.Is(err, exec.ErrWaitDelay):
status = "exit status 0; output cut off after 5s: a background process kept it open"
case err != nil:
return "", err
}
return status + "\n" + string(out.b[max(0, len(out.b)-bashKeep):]), nil
}
type tailWriter struct {
b []byte
}
func (w *tailWriter) Write(p []byte) (int, error) {
w.b = append(w.b, p...)
if len(w.b) > 2*bashKeep {
w.b = append([]byte{}, w.b[len(w.b)-bashKeep:]...)
}
return len(p), nil
}
+11 -23
View File
@@ -225,36 +225,24 @@ func (m *Manager) readCodexRateLimit() {
}
func (m *Manager) setCodexRateLimit(r codex.RateLimitSnapshot) {
m.mu.Lock()
m.rateLimits[api.Codex] = codexRateLimit(r)
m.mu.Unlock()
m.store.Broadcast(api.Event{Kind: "admin.health", Actor: "byre", Payload: map[string]any{}})
m.setVendorUsage(api.Codex, codexUsage(r))
}
func codexRateLimit(r codex.RateLimitSnapshot) map[string]any {
status := "allowed"
func codexUsage(r codex.RateLimitSnapshot) api.Usage {
u := api.Usage{UpdatedAt: time.Now(), Windows: []api.UsageWindow{}}
if r.RateLimitReachedType != "" {
status = "rejected"
u.Rejected = new(string(r.RateLimitReachedType))
}
windows := map[string]any{}
for name, w := range map[string]*codex.RateLimitWindow{"primary": r.Primary, "secondary": r.Secondary} {
if w == nil {
for _, w := range []struct {
name string
w *codex.RateLimitWindow
}{{"primary", r.Primary}, {"secondary", r.Secondary}} {
if w.w == nil {
continue
}
window := map[string]any{"utilization": float64(w.UsedPercent) / 100}
if w.ResetsAt != 0 {
window["resetsAt"] = w.ResetsAt
u.Windows = append(u.Windows, percentWindow(w.name, float64(w.w.UsedPercent)/100, w.w.ResetsAt, w.w.WindowDurationMins*60))
}
if w.WindowDurationMins != 0 {
window["windowMins"] = w.WindowDurationMins
}
windows[name] = window
}
info := map[string]any{"status": status, "unifiedWindows": windows, "planType": r.PlanType}
if r.RateLimitReachedType != "" {
info["rateLimitType"] = r.RateLimitReachedType
}
return info
return u
}
func (m *Manager) codexTurnCompleted(ctx context.Context, l *live, s *codex.Session, turn codex.Turn) error {
-1
View File
@@ -21,7 +21,6 @@ const guidance = `You are running inside byre. The human reads your work in byre
- In that final message and in ask_human context, put long supporting detail (logs, full lists, data) in collapsed sections the human expands on demand: a line <details><summary>Title</summary>, the Markdown content, then a line </details>.
- Don't commit. byre commits everything in the worktree at the end of each turn, using the turn's final message.
- Don't push, don't switch or touch other branches, and don't run git maintenance (gc, prune, worktree prune). byre handles merging and pushing.
- byre signs merge commits on the merging user's Mac. To turn it on for a project, run ` + "`byre signing <checkout>`" + ` on this machine.
- TODO.md at the repo root is the project's list of work for byre. Each item is a top-level ` + "`- `" + ` line holding only a short title, which byre uses as the task's title and the item's identity, with its description in lines indented two spaces under it. ` + "`#`" + ` headings group items into sections, and a checked ` + "`- [x]`" + ` item is skipped.`
const proseQuestionNudge = "Your final message asks the human something in prose. Ask it with byre's ask_human tool " +
+66
View File
@@ -0,0 +1,66 @@
package agents
import (
"context"
"embed"
"encoding/json"
"fmt"
"io/fs"
"strings"
"g.fc.run/ai/byre/internal/mcp"
)
//go:embed kb/*.md
var kbFiles embed.FS
var kb, kbIndex = loadKB()
func loadKB() (map[string]string, string) {
entries, err := fs.ReadDir(kbFiles, "kb")
if err != nil {
panic(err)
}
articles := map[string]string{}
index := ""
for _, e := range entries {
b, err := kbFiles.ReadFile("kb/" + e.Name())
if err != nil {
panic(err)
}
summary, body, _ := strings.Cut(string(b), "\n")
topic := strings.TrimSuffix(e.Name(), ".md")
articles[topic] = strings.TrimSpace(body)
index += fmt.Sprintf("\n- %s: %s", topic, summary)
}
return articles, index
}
func (m *Manager) readKnowledgeTool() mcp.Tool {
return mcp.Tool{
Name: "read_knowledge",
Description: "Read an article from byre's knowledge base: how byre features work that agents need only now and then. " +
"Read the matching article before working on or answering about one of these topics:" + kbIndex,
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"topic": {"type": "string", "description": "The article's topic, as listed."}
},
"required": ["topic"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
args := struct {
Topic string `json:"topic"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed read_knowledge call: %w", err)
}
body, ok := kb[args.Topic]
if !ok {
return "", fmt.Errorf("no article %q; the topics are:%s", args.Topic, kbIndex)
}
return body, nil
},
}
}
+29
View File
@@ -0,0 +1,29 @@
How byre signs merge commits on the merging user's Mac, and how to turn it on for a project.
# Signing merge commits
byre never signs with a key on the machine it runs on. When a project checkout has `commit.gpgsign` true, the squash commit of a Merge or `merge_to_main` is signed on the merging user's most recently active connected Mac, so a key that never leaves that Mac (a Secure Enclave key behind Touch ID, say) works. The merge fails when that user has no app connected.
## Turning it on
Run, on the machine byre runs on:
```
byre signing <checkout>
```
`<checkout>` is the project's main checkout, not a task worktree. It sets `commit.gpgsign` true for the checkout and false for its task worktrees (which would otherwise try to sign on the server and fail), prints both values, and fails unless the checkout's reads true. It refuses a checkout that is itself a linked worktree. No restart is needed; the next merge reads the config.
## What the Mac needs
The Mac's own git signing config, the same that signs the user's local commits:
- `gpg.format`: `openpgp` (default) or `x509`. `ssh` is not supported.
- The program, when it isn't `gpg` or `gpgsm`: `gpg.openpgp.program` or `gpg.program`, or `gpg.x509.program`.
- `user.signingkey`, when the program can't find the key from the user's git identity in byre.
The program must print `[GNUPG:] SIG_CREATED ` on its status fd. A Touch ID prompt left unanswered for 2 minutes fails the merge.
## Checking
`git -C <checkout> cat-file commit HEAD` shows a `gpgsig` header on a signed merge commit. The notes commit under `refs/notes/byre` is not signed.
+53
View File
@@ -0,0 +1,53 @@
The JSON format a usage command prints so byre's Status tab shows a vendor's usage and limits, and how to set one.
# Usage commands
byre shows each vendor's (claude, codex, antigravity) usage and limits in the Status tab. By default it uses what the vendor itself reports. A usage command replaces that for one vendor: a shell command that prints the numbers in a vendor-neutral format, so byre needn't know where they come from.
## Contract
- byre runs it with `$SHELL -l -c '<command>'` in byre's own environment (no task's worktree or `$PORT`), at startup, after every turn of that vendor's agents ends, and when the command is saved. One run at a time per vendor.
- It must finish within 10 seconds, print one JSON object to stdout and exit 0. On a non-zero exit, a timeout or output that doesn't parse, byre shows nothing for that vendor.
- Every field is optional. Unknown fields are ignored. No field names a vendor.
```json
{
"updated_at": "2026-10-19T21:24:56Z",
"rejected": "daily budget spent",
"windows": [
{
"label": "Today",
"used": 150.94,
"limit": 200,
"unit": "usd",
"resets_at": "2026-10-20T12:00:00Z",
"window_seconds": 86400
},
{
"label": "Rate",
"used": 36,
"limit": 40,
"unit": "requests",
"resets_in_seconds": 240
}
]
}
```
| Field | Meaning |
|---|---|
| `updated_at` | RFC 3339 time the numbers were current; now when absent. |
| `rejected` | Why the vendor is refusing work; absent while it isn't. A window whose `used` reaches its `limit` also shows the vendor as rejected. |
| `windows[]` | Independent meters, shown in order. |
| `label` | Short name for the meter. |
| `used`, `limit` | Numbers in `unit`. A window without `limit` shows as a bare count. |
| `unit` | `usd`, `tokens`, `requests` or `percent`; selects formatting. |
| `resets_at` | RFC 3339 time the meter resets. |
| `resets_in_seconds` | Alternative to `resets_at`, relative to `updated_at`. |
| `window_seconds` | The window's length; with `resets_at`, it draws the pace bar of time elapsed against amount used. |
Status shows the example as `Today $150.94 / $200.00 · 75% … in 14h 35m` and `Rate 36 / 40 requests · 90% … in 4m`.
## Setting one
The human sets it in the app: Server tab, Usage commands, one field per vendor; empty uses the vendor's own reports. The same is `PUT /api/admin/usage-commands` with `{"claude": "...", "codex": "...", "antigravity": "..."}` (all three; `GET` reads them). Test a command by running it by hand and checking its output parses as above.
+22 -5
View File
@@ -15,7 +15,7 @@ import (
)
const leadGuidance = `You are the human's lead agent in byre, shown in the left pane of byre's app. The human talks to you there, and you coordinate every task agent on their behalf. You don't do task work yourself: task agents work in their own worktrees, and you read, direct and report on them.
- Signals: byre sends you, as messages from byre, what happens across tasks: questions that task agents ask, errors and rate limits their sessions hit, turns they finish with their final message, a task's agent saying the task is ready to merge (with its overview of the whole task, which byre asks for once the review of its latest work has finished and its turn has ended), questions the human answered, tasks created, reviews started and finished, a merge held behind another task's or set to start after the review (and that cancelled or dropped), starting to merge, merged or discarded, and every change of a task's state, as "<task> state: <before> → <now>." listed first among what happened at the same moment. The states are running, idle, review_pending, reviewing, merging, discarding, merged, discarded and no_agent. A finished turn arrives with its state change: review_pending when byre is about to review the turn's work, reviewing while the review runs; neither is done, since the review can still find problems, so don't treat a task's work as finished until its state goes to idle. Several may arrive in one message, followed by the current task list (as list_tasks gives it) and what your right pane shows, so you rarely need to call list_tasks after a signal. The human doesn't see signals. Messages from the human arrive separately.
- Signals: byre sends you, as messages from byre, what happens across tasks: questions that task agents ask, errors and rate limits their sessions hit, turns they finish with their final message, a task's agent saying the task is ready to merge (with its overview of the whole task, which byre asks for once the review of its latest work has finished and its turn has ended), questions the human answered, tasks created, reviews started and finished, a merge held behind another task's or set to start after the review (and that cancelled or dropped), starting to merge, merged or discarded, and every change of a task's state, as "<task> state: <before> → <now>." listed first among what happened at the same moment. State changes, reviews starting, and tasks the human created, started merging, merged or discarded never wake you on their own; they arrive only alongside another signal. The states are running, idle, review_pending, reviewing, merging, discarding, merged, discarded and no_agent. A finished turn arrives with its state change: review_pending when byre is about to review the turn's work, reviewing while the review runs; neither is done, since the review can still find problems, so don't treat a task's work as finished until its state goes to idle. Several may arrive in one message, followed by the current task list (as list_tasks gives it) and what your right pane shows, so you rarely need to call list_tasks after a signal. The human doesn't see signals. Messages from the human arrive separately.
- Don't respond to what the human just did. A signal about their own action (a question they answered, a task they created, merged, started merging or discarded) tells them nothing new: don't report, confirm or comment on it. Act on it only if it changes what you are doing, and then end the turn with one short line at most. When a batch of signals needs nothing from you to the human, make your final message exactly NO_REPLY and nothing else; byre folds that turn away so the human doesn't see it.
- "Your right pane is free" means the human finished what was in the right pane. Show them the next thing that needs them, most urgent first (questions, then errors on the task's transcript tab, then finished turns, then tasks ready to merge), with one line saying what it is. If nothing needs them, show that project's project tab, then post a short status: one line per active task (what it's doing or waiting on), then up to three TODO items you'd start next (from list_tasks), each with a one-line reason. Write each item as a link to #/todo/<project_id>/<title, URL-encoded with parentheses as %28 and %29>, for example [Fix login redirect](#/todo/3f2a9c1b7d4e/Fix%20login%20redirect): byre draws it with a Start button the human can click, and once the task exists, its state. Start one with start_todo only when the human picks it. If that says nothing new since your last status, make your final message NO_REPLY.
- Whenever you suggest new work that isn't in TODO.md (a task you think is worth doing, an improvement to byre or to your own role), write it as a link to #/suggest/<project_id>/<title>/<brief>, both URL-encoded with parentheses as %28 and %29, for example [Lead tool for admin status](#/suggest/3f2a9c1b7d4e/Lead%20tool%20for%20admin%20status/Add%20a%20lead%20tool%20that%20reads%20%2Fapi%2Fadmin%2Fstatus%20...). The brief is the new task agent's whole first message, so make it a complete brief, as for create_task. byre draws the link with two buttons: Start creates the task with that title and brief at once, without you, and Discuss sends you a message asking to talk the suggestion over. Once an active task has that title, it shows the task's state instead. Don't ask whether to start a suggestion; the buttons do that.
@@ -36,7 +36,7 @@ const leadGuidance = `You are the human's lead agent in byre, shown in the left
- Workflows: you manage the server's workflows with list_workflows, read_workflow, save_workflow and delete_workflow, and run one by giving its name to create_task or start_todo. Every stage of a workflow runs as its own task on the stage's model, once the stages it comes after have finished, so stages fan out (several stages after one) and fan in (one stage after several); a stage's agent calls finish_stage to hand its output to the stages after it, signals tell you when a stage starts and when the workflow finishes, and when the final stage finishes byre discards the run's other stage tasks, leaving the final one to merge. Whenever you show or propose a workflow, draw it as a Mermaid flowchart of its stage names only, with no models, for example a fenced mermaid block holding "flowchart LR", "Build --> Review", "Build --> Test", "Review --> Fix" and "Test --> Fix".
- Whenever you name a task in a message, link it as a Markdown link to #/task/<task_id>, for example [Fix login redirect](#/task/da7e487305e4). byre draws it in that task's colour, and clicking it shows the task in the right pane. To open a specific tab, append it: #/task/<task_id>/<tab>. When you point the human at a question to answer or a finished turn, link to #/task/<task_id>/transcript.
- Keep messages short; the human reads them in half the screen. Put long detail in collapsed sections: a line <details><summary>Title</summary>, the Markdown content, then a line </details>.
- You are long-running. Your context window is large. byre sends you a signal once your context passes half of its window; then call compact_self at a logical point (a piece of work wrapped up, a topic closed), and not before; when the turn ends, byre replaces you with a fresh session that starts from your handoff.
- You are long-running. Your context window is large. byre sends you a signal once your context passes a quarter of its window; then call compact_self at a logical point (a piece of work wrapped up, a topic closed), and not before; when the turn ends, byre replaces you with a fresh session that starts from your handoff.
- Don't commit, push, or touch git branches yourself.`
const leadRestartNotice = "byre restarted while you were mid-turn, so your last turn was cut off. Pick up where you left off."
@@ -149,6 +149,9 @@ func (m *Manager) DeliverLead(ctx context.Context, owner string) error {
}
batch, actor, text, images := nextLeadTurn(inputs)
if batch[0].Kind == store.LeadSignal && m.interrupted(sess.Id) {
return nil
}
if batch[0].Kind == store.LeadSignal {
state, err := m.leadStateText(ctx, owner)
if err != nil {
@@ -309,7 +312,7 @@ func (m *Manager) leadTurnEnded(ctx context.Context, l *live, res endResult) err
}
func (m *Manager) warnLeadContext(ctx context.Context, l *live) error {
if l.contextWarned || l.contextWindow == 0 || l.contextUsed*2 < l.contextWindow {
if l.contextWarned || l.contextWindow == 0 || l.contextUsed*4 < l.contextWindow {
return nil
}
l.contextWarned = true
@@ -380,6 +383,7 @@ func (m *Manager) signal(ctx context.Context, batch []api.Event) error {
for _, owner := range owners {
states := []string{}
lines := []string{}
loud := false
for i, ev := range batch {
if ev.Actor == leadActor(owner) {
continue
@@ -392,16 +396,19 @@ func (m *Manager) signal(ctx context.Context, batch []api.Event) error {
if text == "" {
continue
}
if freed != "" || !quietSignal(ev) {
loud = true
}
if ev.Kind == "task.state" {
states = append(states, text)
continue
}
lines = append(lines, text)
}
text := strings.Join(append(states, lines...), "\n")
if text == "" {
if !loud {
continue
}
text := strings.Join(append(states, lines...), "\n")
err = m.store.AddLeadInput(ctx, store.LeadInput{Owner: owner, Kind: store.LeadSignal, Actor: "byre", Text: text})
if err != nil {
return err
@@ -411,6 +418,16 @@ func (m *Manager) signal(ctx context.Context, batch []api.Event) error {
return nil
}
func quietSignal(ev api.Event) bool {
switch ev.Kind {
case "task.state", "review.started":
return true
case "task.created", "task.merge_started", "task.merged", "task.discarded":
return humanActor(ev.Actor)
}
return false
}
func (m *Manager) freedText(ctx context.Context, owner string, ev api.Event) (string, error) {
if ev.Kind == "user.message" {
return m.waitingText(ctx, owner, ev)
+2 -2
View File
@@ -95,7 +95,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
}),
tool("read_status",
"Read byre's health as JSON, the same as the Status tab: rate_limits (the latest each vendor reported: how much of its window is used, when it resets, and whether it is allowed or allowed_warning), "+
"Read byre's health as JSON, the same as the Status tab: rate_limits (each vendor's latest usage: per window, how much is used of its limit and in what unit, and when it resets),"+
"agent_processes with each one's health, zombies, protocol_errors and running_turns. "+
"Check it before starting a batch of work or when agents misbehave: warn the human when a vendor nears its limit, and put new work on the vendor with headroom.",
`{"type": "object", "properties": {}}`,
@@ -525,7 +525,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
}),
tool("compact_self",
"Ask byre to compact you when this turn ends: you write a handoff, and a fresh lead session continues from it. Call it only after byre signals that your context is past half of its window, at a logical point, then end the turn with a short final message.",
"Ask byre to compact you when this turn ends: you write a handoff, and a fresh lead session continues from it. Call it only after byre signals that your context is past a quarter of its window, at a logical point, then end the turn with a short final message.",
`{"type": "object", "properties": {}}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
m.mu.Lock()
+19 -28
View File
@@ -50,7 +50,8 @@ type Manager struct {
live map[string]*live
starting map[string]*start
closing bool
rateLimits map[api.AgentSessionVendor]map[string]any
rateLimits map[api.AgentSessionVendor]api.Usage
usageCommands map[api.AgentSessionVendor]string
leadAnswered map[string]bool
paneFocus map[string]api.PaneFocus
paneReported map[string]time.Time
@@ -76,6 +77,8 @@ type Manager struct {
reviewMu sync.Mutex
usageMu map[api.AgentSessionVendor]*sync.Mutex
workflowMu sync.Mutex
bad *badLines
@@ -141,7 +144,9 @@ func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome, antigrav
live: map[string]*live{},
starting: map[string]*start{},
deliverLocks: map[string]*sessionLock{},
rateLimits: map[api.AgentSessionVendor]map[string]any{},
rateLimits: map[api.AgentSessionVendor]api.Usage{},
usageCommands: map[api.AgentSessionVendor]string{},
usageMu: map[api.AgentSessionVendor]*sync.Mutex{api.Claude: {}, api.Codex: {}, api.Antigravity: {}},
leadAnswered: map[string]bool{},
paneFocus: map[string]api.PaneFocus{},
paneReported: map[string]time.Time{},
@@ -160,8 +165,7 @@ func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome, antigrav
watchStop: make(chan struct{}),
}
go m.signalLoop()
go m.readCodexRateLimit()
go m.readAgyRateLimit()
go m.loadUsage()
return m
}
@@ -504,13 +508,13 @@ func (m *Manager) LiveCount() int {
type Usage struct {
Running []api.TurnRef
RateLimits map[string]map[string]any
RateLimits map[string]api.Usage
}
func (m *Manager) Usage(ctx context.Context) (Usage, error) {
u := Usage{
Running: []api.TurnRef{},
RateLimits: map[string]map[string]any{},
RateLimits: map[string]api.Usage{},
}
running, err := m.store.SessionsInState(ctx, api.AgentSessionStateRunning)
@@ -738,7 +742,7 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api
if err != nil {
return nil, err
}
tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.mergeToMainTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.takeSnapshotTool(l), m.terminalReadTool(l), m.terminalTypeTool(l)}, append(m.subtaskTools(l), m.clientTools(l)...)...)
tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.mergeToMainTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.takeSnapshotTool(l), m.terminalReadTool(l), m.terminalTypeTool(l), m.bashTool(l), m.readKnowledgeTool()}, append(m.subtaskTools(l), m.clientTools(l)...)...)
lead := true
if sess.Role == api.Implementer {
g += longTitleGuidance(task)
@@ -806,7 +810,7 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api
return nil, err
}
l.notes = "\n\n" + notes
tools = m.leadTools(l)
tools = append(m.leadTools(l), m.readKnowledgeTool())
env = nil
}
servers := []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: tools}}
@@ -1001,14 +1005,12 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
return err
}
addClaudeWindowMins(info.RateLimitInfo)
m.mu.Lock()
m.rateLimits[l.vendor] = info.RateLimitInfo
if info.RateLimitInfo["status"] == "rejected" {
l.rateLimited = info.RateLimitInfo
}
m.mu.Unlock()
m.setVendorUsage(l.vendor, claudeUsage(info.RateLimitInfo))
case "system":
if ev.Subtype == "init" {
@@ -1057,6 +1059,7 @@ type endResult struct {
}
func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
go m.readUsage(l.vendor)
if l.owner != "" {
return m.leadTurnEnded(ctx, l, res)
}
@@ -1245,11 +1248,15 @@ func (m *Manager) afterTurn(ctx context.Context, l *live) {
m.startDiscard(l.taskID)
return
}
m.deliverTeam(ctx, l)
if m.interrupted(l.id) {
m.logUndelivered(ctx, l.id, "turn was interrupted")
return
}
err = m.Deliver(ctx, l.id)
if err != nil {
slog.Error("deliver inbox responses", "session", l.id, "err", err)
}
m.deliverTeam(ctx, l)
}
func commitMessage(res endResult) string {
@@ -1335,22 +1342,6 @@ func (m *Manager) event(l *live, actor, kind string, payload map[string]any) api
return ev
}
func addClaudeWindowMins(info map[string]any) {
windows, _ := info["unifiedWindows"].(map[string]any)
for name, w := range windows {
window, ok := w.(map[string]any)
if !ok {
continue
}
switch {
case strings.HasPrefix(name, "five_hour"):
window["windowMins"] = 5 * 60
case strings.HasPrefix(name, "seven_day"):
window["windowMins"] = 7 * 24 * 60
}
}
}
func decode(raw json.RawMessage) (any, error) {
if len(raw) == 0 {
return nil, nil
+14 -2
View File
@@ -47,7 +47,8 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model str
if err != nil {
return api.AgentSession{}, false, err
}
if sess.State != api.AgentSessionStateRunning || sess.Role != api.Implementer {
held := sess.State == api.AgentSessionStateIdle && sess.Role == api.Implementer && automatedActor(actor) && m.interrupted(sess.Id)
if !held && (sess.State != api.AgentSessionStateRunning || sess.Role != api.Implementer) {
sess, err = m.Send(ctx, sessionID, actor, text, model, images...)
return sess, false, err
}
@@ -72,10 +73,21 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model str
if err != nil {
return api.AgentSession{}, false, err
}
slog.Info("message queued", "session", sess.Id, "task", sess.TaskId, "actor", actor)
slog.Info("message queued", "session", sess.Id, "task", sess.TaskId, "actor", actor, "interrupted", held)
return sess, true, nil
}
func automatedActor(actor string) bool {
return actor == "byre" || actor == agentActor || actor == boardActor
}
func (m *Manager) interrupted(sessionID string) bool {
m.mu.Lock()
defer m.mu.Unlock()
l := m.live[sessionID]
return l != nil && l.interrupted
}
func (m *Manager) Withdraw(ctx context.Context, taskID string, inputID int64, actor string) error {
task, err := m.store.GetTask(ctx, taskID)
if err != nil {
+1 -1
View File
@@ -81,7 +81,7 @@ func (m *Manager) tryPromptReady(ctx context.Context, taskID string, fromReview
return nil
}
sess, err = m.store.GetSession(ctx, sess.Id)
if err != nil || sess.State == api.AgentSessionStateRunning {
if err != nil || sess.State == api.AgentSessionStateRunning || m.interrupted(sess.Id) {
return err
}
latest, err := m.store.LastSnapshotNot(ctx, taskID, api.SnapshotTriggerAnswer)
+1 -1
View File
@@ -213,7 +213,7 @@ func (m *Manager) deliverTeam(ctx context.Context, l *live) {
return
}
for _, s := range teamSessions(sessions) {
if s.Id == l.id || s.State != api.AgentSessionStateIdle {
if s.Id == l.id || s.State != api.AgentSessionStateIdle || m.interrupted(s.Id) {
continue
}
err = m.Deliver(ctx, s.Id)
+205
View File
@@ -0,0 +1,205 @@
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"maps"
"os"
"os/exec"
"slices"
"strings"
"syscall"
"time"
"g.fc.run/ai/byre/internal/api"
)
const usageTimeout = 10 * time.Second
func (m *Manager) loadUsage() {
cmds, err := m.store.UsageCommands(context.Background())
if err != nil {
slog.Error("load usage commands", "err", err)
return
}
m.mu.Lock()
m.usageCommands = cmds
m.mu.Unlock()
go m.readCodexRateLimit()
for _, v := range []api.AgentSessionVendor{api.Claude, api.Codex, api.Antigravity} {
go m.readUsage(v)
}
}
func (m *Manager) UsageCommands() api.UsageCommands {
m.mu.Lock()
defer m.mu.Unlock()
return api.UsageCommands{
Claude: m.usageCommands[api.Claude],
Codex: m.usageCommands[api.Codex],
Antigravity: m.usageCommands[api.Antigravity],
}
}
func (m *Manager) SetUsageCommands(ctx context.Context, actor string, c api.UsageCommands) error {
cmds := map[api.AgentSessionVendor]string{
api.Claude: strings.TrimSpace(c.Claude),
api.Codex: strings.TrimSpace(c.Codex),
api.Antigravity: strings.TrimSpace(c.Antigravity),
}
err := m.store.SetUsageCommands(ctx, actor, cmds)
if err != nil {
return err
}
changed := []api.AgentSessionVendor{}
m.mu.Lock()
for v, cmd := range cmds {
if m.usageCommands[v] != cmd {
changed = append(changed, v)
delete(m.rateLimits, v)
}
}
m.usageCommands = cmds
m.mu.Unlock()
m.store.Broadcast(api.Event{Kind: "admin.health", Actor: "byre", Payload: map[string]any{}})
for _, v := range changed {
if v == api.Codex && cmds[v] == "" {
go m.readCodexRateLimit()
}
go m.readUsage(v)
}
return nil
}
func (m *Manager) readUsage(vendor api.AgentSessionVendor) {
mu := m.usageMu[vendor]
mu.Lock()
defer mu.Unlock()
m.mu.Lock()
cmdline := m.usageCommands[vendor]
m.mu.Unlock()
if cmdline == "" {
if vendor == api.Antigravity {
m.readAgyRateLimit()
}
return
}
u, err := runUsageCommand(cmdline)
m.mu.Lock()
if m.usageCommands[vendor] != cmdline {
m.mu.Unlock()
return
}
if err != nil {
delete(m.rateLimits, vendor)
} else {
m.rateLimits[vendor] = u
}
m.mu.Unlock()
if err != nil {
slog.Error("usage command", "vendor", vendor, "err", err)
}
m.store.Broadcast(api.Event{Kind: "admin.health", Actor: "byre", Payload: map[string]any{}})
}
func (m *Manager) setVendorUsage(vendor api.AgentSessionVendor, u api.Usage) {
m.mu.Lock()
if m.usageCommands[vendor] != "" {
m.mu.Unlock()
return
}
m.rateLimits[vendor] = u
m.mu.Unlock()
m.store.Broadcast(api.Event{Kind: "admin.health", Actor: "byre", Payload: map[string]any{}})
}
func runUsageCommand(cmdline string) (api.Usage, error) {
shell := os.Getenv("SHELL")
if shell == "" {
return api.Usage{}, errors.New("SHELL is not set in byre's environment")
}
ctx, cancel := context.WithTimeout(context.Background(), usageTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, shell, "-l", "-c", cmdline)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
cmd.WaitDelay = time.Second
out, err := cmd.Output()
if err != nil {
return api.Usage{}, fmt.Errorf("%s: %w", cmdline, err)
}
return parseUsage(out)
}
func parseUsage(out []byte) (api.Usage, error) {
in := struct {
UpdatedAt *time.Time `json:"updated_at"`
Rejected *string `json:"rejected"`
Windows []struct {
api.UsageWindow
ResetsInSeconds *float64 `json:"resets_in_seconds"`
} `json:"windows"`
}{}
err := json.Unmarshal(out, &in)
if err != nil {
return api.Usage{}, fmt.Errorf("usage output: %w", err)
}
u := api.Usage{UpdatedAt: time.Now(), Rejected: in.Rejected, Windows: []api.UsageWindow{}}
if in.UpdatedAt != nil {
u.UpdatedAt = *in.UpdatedAt
}
for _, w := range in.Windows {
if w.ResetsAt == nil && w.ResetsInSeconds != nil {
w.ResetsAt = new(u.UpdatedAt.Add(time.Duration(*w.ResetsInSeconds * float64(time.Second))))
}
u.Windows = append(u.Windows, w.UsageWindow)
}
return u, nil
}
func percentWindow(label string, fraction float64, resetsAt int64, windowSeconds int64) api.UsageWindow {
w := api.UsageWindow{Label: label, Used: fraction * 100, Limit: new(100.0), Unit: "percent"}
if resetsAt != 0 {
w.ResetsAt = new(time.Unix(resetsAt, 0))
}
if windowSeconds != 0 {
w.WindowSeconds = &windowSeconds
}
return w
}
func claudeUsage(info map[string]any) api.Usage {
u := api.Usage{UpdatedAt: time.Now(), Windows: []api.UsageWindow{}}
if info["status"] == "rejected" {
reason, _ := info["rateLimitType"].(string)
u.Rejected = new(reason)
}
windows, _ := info["unifiedWindows"].(map[string]any)
for _, name := range slices.Sorted(maps.Keys(windows)) {
w, ok := windows[name].(map[string]any)
if !ok {
continue
}
used, _ := w["utilization"].(float64)
resets, _ := w["resetsAt"].(float64)
seconds := int64(0)
switch {
case strings.HasPrefix(name, "five_hour"):
seconds = 5 * 60 * 60
case strings.HasPrefix(name, "seven_day"):
seconds = 7 * 24 * 60 * 60
}
u.Windows = append(u.Windows, percentWindow(name, used, int64(resets), seconds))
}
return u
}
+349 -262
View File
@@ -1231,8 +1231,8 @@ type Status struct {
Projects int `json:"projects"`
ProtocolErrors ProtocolErrors `json:"protocol_errors"`
// RateLimits The latest rate-limit status each vendor reported, keyed by vendor.
RateLimits map[string]map[string]interface{} `json:"rate_limits"`
// RateLimits Each vendor's latest usage, keyed by vendor, from its usage command or, without one, what the vendor itself reported.
RateLimits map[string]Usage `json:"rate_limits"`
// RunningTurns Turns running now, oldest session first.
RunningTurns []TurnRef `json:"running_turns"`
@@ -1429,6 +1429,46 @@ type Unmerged struct {
Uncommitted int `json:"uncommitted"`
}
// Usage defines model for Usage.
type Usage struct {
// Rejected Why the vendor is refusing work, such as a usage cap or depleted credits; absent while it isn't. A window at its limit also counts as rejected.
Rejected *string `json:"rejected,omitempty"`
// UpdatedAt When the numbers were current.
UpdatedAt time.Time `json:"updated_at"`
// Windows Independent meters, in the order to show them.
Windows []UsageWindow `json:"windows"`
}
// UsageCommands The shell command that reports each vendor's usage, keyed by vendor; empty uses what the vendor itself reports.
type UsageCommands struct {
Antigravity string `json:"antigravity"`
Claude string `json:"claude"`
Codex string `json:"codex"`
}
// UsageWindow defines model for UsageWindow.
type UsageWindow struct {
// Label Short name for the meter.
Label string `json:"label"`
// Limit The window's limit
Limit *float64 `json:"limit,omitempty"`
// ResetsAt When the meter resets.
ResetsAt *time.Time `json:"resets_at,omitempty"`
// Unit usd, tokens, requests or percent; selects formatting.
Unit string `json:"unit"`
// Used Amount used
Used float64 `json:"used"`
// WindowSeconds The window's length
WindowSeconds *int64 `json:"window_seconds,omitempty"`
}
// User defines model for User.
type User struct {
// DefaultModel The model the app starts new tasks on.
@@ -1599,6 +1639,9 @@ type GetTaskTerminalParams struct {
Rows int `form:"rows" json:"rows"`
}
// UpdateUsageCommandsJSONRequestBody defines body for UpdateUsageCommands for application/json ContentType.
type UpdateUsageCommandsJSONRequestBody = UsageCommands
// RespondInboxJSONRequestBody defines body for RespondInbox for application/json ContentType.
type RespondInboxJSONRequestBody = InboxRespond
@@ -1662,6 +1705,12 @@ type ServerInterface interface {
// (GET /api/admin/status)
GetStatus(w http.ResponseWriter, r *http.Request)
// (GET /api/admin/usage-commands)
GetUsageCommands(w http.ResponseWriter, r *http.Request)
// (PUT /api/admin/usage-commands)
UpdateUsageCommands(w http.ResponseWriter, r *http.Request)
// (POST /api/blobs)
UploadBlob(w http.ResponseWriter, r *http.Request)
@@ -1892,6 +1941,34 @@ func (siw *ServerInterfaceWrapper) GetStatus(w http.ResponseWriter, r *http.Requ
handler.ServeHTTP(w, r)
}
// GetUsageCommands operation middleware
func (siw *ServerInterfaceWrapper) GetUsageCommands(w http.ResponseWriter, r *http.Request) {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetUsageCommands(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// UpdateUsageCommands operation middleware
func (siw *ServerInterfaceWrapper) UpdateUsageCommands(w http.ResponseWriter, r *http.Request) {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.UpdateUsageCommands(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// UploadBlob operation middleware
func (siw *ServerInterfaceWrapper) UploadBlob(w http.ResponseWriter, r *http.Request) {
@@ -3858,6 +3935,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/inbox/responses", wrapper.RespondInbox)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/events", wrapper.StreamEvents)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/admin/status", wrapper.GetStatus)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/admin/usage-commands", wrapper.GetUsageCommands)
m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/api/admin/usage-commands", wrapper.UpdateUsageCommands)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/admin/permissions", wrapper.CheckPermissions)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/workflows", wrapper.ListWorkflows)
m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/api/workflows/{name}", wrapper.DeleteWorkflow)
@@ -3872,266 +3951,274 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
// const string: with thousands of chunks the chained `+` fold is several
// times slower for the Go compiler than parsing a slice literal.
var swaggerSpec = []string{
"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",
"TustmrGzVPPgPr9XtZ737//PAA==",
"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",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
+33 -7
View File
@@ -206,6 +206,11 @@ type Session struct {
pendingMu sync.Mutex
pending map[string]chan controlResult
ctx context.Context
stop context.CancelFunc
inflightMu sync.Mutex
inflight map[string]context.CancelFunc
hooks map[string]HookFunc
servers map[string]*mcp.Server
@@ -236,12 +241,14 @@ func Start(ctx context.Context, opts Options) (*Session, error) {
done: make(chan struct{}),
transcript: opts.Transcript,
pending: map[string]chan controlResult{},
inflight: map[string]context.CancelFunc{},
hooks: map[string]HookFunc{},
servers: map[string]*mcp.Server{},
id: initialID(opts),
onBadLine: opts.OnBadLine,
}
s.lastLine.Store(time.Now().UnixNano())
s.ctx, s.stop = context.WithCancel(context.Background())
args, err := s.buildArgs(opts)
if err != nil {
@@ -556,6 +563,7 @@ func (s *Session) record(dir string, line []byte) {
func (s *Session) readLoop(stdout io.Reader) {
defer close(s.done)
defer close(s.events)
defer s.stop()
r := bufio.NewReader(stdout)
for {
@@ -606,10 +614,23 @@ func (s *Session) handleLine(line []byte) {
return
case "control_request":
go s.handleControlRequest(env.RequestID, env.Request)
ctx, cancel := context.WithCancel(s.ctx)
s.inflightMu.Lock()
s.inflight[env.RequestID] = cancel
s.inflightMu.Unlock()
go s.handleControlRequest(ctx, cancel, env.RequestID, env.Request)
return
case "control_cancel_request", "keep_alive":
case "control_cancel_request":
s.inflightMu.Lock()
cancel := s.inflight[env.RequestID]
s.inflightMu.Unlock()
if cancel != nil {
cancel()
}
return
case "keep_alive":
return
}
@@ -674,8 +695,15 @@ func (s *Session) handleControlResponse(raw json.RawMessage) {
ch <- controlResult{response: resp.Response}
}
func (s *Session) handleControlRequest(id string, raw json.RawMessage) {
result, err := s.dispatchControl(raw)
func (s *Session) handleControlRequest(ctx context.Context, cancel context.CancelFunc, id string, raw json.RawMessage) {
defer func() {
s.inflightMu.Lock()
delete(s.inflight, id)
s.inflightMu.Unlock()
cancel()
}()
result, err := s.dispatchControl(ctx, raw)
if err != nil {
slog.Error("control request failed", "request_id", id, "err", err)
s.write(map[string]any{
@@ -699,7 +727,7 @@ func (s *Session) handleControlRequest(id string, raw json.RawMessage) {
})
}
func (s *Session) dispatchControl(raw json.RawMessage) (any, error) {
func (s *Session) dispatchControl(ctx context.Context, raw json.RawMessage) (any, error) {
req := struct {
Subtype string `json:"subtype"`
CallbackID string `json:"callback_id"`
@@ -712,8 +740,6 @@ func (s *Session) dispatchControl(raw json.RawMessage) (any, error) {
return nil, err
}
ctx := context.Background()
switch req.Subtype {
case "hook_callback":
fn := s.hooks[req.CallbackID]
+18
View File
@@ -632,6 +632,24 @@ func (s *Server) CheckPermissions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, perms)
}
func (s *Server) GetUsageCommands(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.agents.UsageCommands())
}
func (s *Server) UpdateUsageCommands(w http.ResponseWriter, r *http.Request) {
req := api.UsageCommands{}
if !readJSON(w, r, &req) {
return
}
err := s.agents.SetUsageCommands(r.Context(), actor(r), req)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, s.agents.UsageCommands())
}
func (s *Server) status(ctx context.Context) (api.Status, error) {
c, err := s.store.Counts(ctx)
if err != nil {
@@ -0,0 +1,4 @@
CREATE TABLE usage_commands (
vendor TEXT PRIMARY KEY,
command TEXT NOT NULL
);
+43
View File
@@ -0,0 +1,43 @@
package store
import (
"context"
"database/sql"
"g.fc.run/ai/byre/internal/api"
)
func (s *Store) UsageCommands(ctx context.Context) (map[api.AgentSessionVendor]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT vendor, command FROM usage_commands`)
if err != nil {
return nil, err
}
defer rows.Close()
cmds := map[api.AgentSessionVendor]string{}
for rows.Next() {
vendor := ""
cmd := ""
err = rows.Scan(&vendor, &cmd)
if err != nil {
return nil, err
}
cmds[api.AgentSessionVendor(vendor)] = cmd
}
return cmds, rows.Err()
}
func (s *Store) SetUsageCommands(ctx context.Context, actor string, cmds map[api.AgentSessionVendor]string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
for vendor, cmd := range cmds {
_, err := tx.ExecContext(ctx, `
INSERT INTO usage_commands (vendor, command) VALUES (?, ?)
ON CONFLICT (vendor) DO UPDATE SET command = excluded.command
`, string(vendor), cmd)
if err != nil {
return err
}
}
return emit(api.Event{Kind: "admin.usage_commands", Actor: actor, Payload: map[string]any{}})
})
}
+73
View File
@@ -3,6 +3,7 @@ import Foundation
import SwiftUI
typealias Permission = Components.Schemas.Permission
typealias UsageCommands = Components.Schemas.UsageCommands
let serverHelper = Bundle.main.bundleURL.appending(path: "Contents/Helpers/byre").path
private let service = "gui/\(getuid())/run.fc.byre"
@@ -215,6 +216,71 @@ struct PermissionsCheck: View {
}
}
private struct UsageCommandsForm: View {
let api: API
@ViewState private var saved: UsageCommands?
@ViewState private var draft: UsageCommands?
@ViewState private var error = ""
@ViewState private var busy = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if draft != nil {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) {
row("claude", \.claude)
row("codex", \.codex)
row("antigravity", \.antigravity)
}
Button(busy ? "Saving…" : "Save", action: save)
.buttonStyle(WebButtonStyle())
.disabled(busy || draft == saved)
}
if !error.isEmpty {
Text(error)
.foregroundStyle(Palette.danger)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.top, 4)
.task {
do {
saved = try await api.client.getUsageCommands().ok.body.json
draft = saved
error = ""
} catch {
self.error = "Couldn't load usage commands: \(errorText(error))"
}
}
}
private func row(_ vendor: String, _ key: WritableKeyPath<UsageCommands, String>) -> some View {
GridRow {
Text(vendor)
TextField("Built in", text: Binding(get: { draft?[keyPath: key] ?? "" }, set: { draft?[keyPath: key] = $0 }))
.modifier(FieldStyle())
.onSubmit(save)
}
}
private func save() {
guard let draft, draft != saved, !busy else {
return
}
busy = true
Task {
do {
saved = try await api.client.updateUsageCommands(body: .json(draft)).ok.body.json
self.draft = saved
error = ""
} catch {
self.error = "Couldn't save usage commands: \(errorText(error))"
}
busy = false
}
}
}
struct ProcessFailure: LocalizedError {
let errorDescription: String?
}
@@ -336,6 +402,13 @@ struct ServerView: View {
PermissionsCheck {
try await API(origin: origin).client.checkPermissions().ok.body.json
}
Text("Usage commands")
.font(Fonts.font(13, weight: 700))
.padding(.top, 16)
Text("A shell command per vendor that prints its usage as JSON for the Status tab, run after each of that vendor's turns. Empty uses what the vendor itself reports.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
UsageCommandsForm(api: API(origin: origin))
}
}
.padding(.horizontal, 16)
+51 -47
View File
@@ -3,6 +3,8 @@ import SwiftUI
typealias AgentProcess = Components.Schemas.AgentProcess
typealias Zombie = Components.Schemas.Zombie
typealias VendorUsage = Components.Schemas.Usage
typealias UsageWindow = Components.Schemas.UsageWindow
private let refreshKinds: Set<String> = ["session.created", "session.state", "task.created", "task.merged", "task.discarded", "project.created", "admin.health", "admin.clients"]
@@ -33,8 +35,7 @@ private func bytes(_ n: Int64) -> String {
return "\(v.formatted(.number.precision(.fractionLength(i == 0 ? 0 : 1)).grouping(.never))) \(units[i])"
}
private func resetText(_ unixSeconds: Double) -> String {
let at = Date(timeIntervalSince1970: unixSeconds)
private func resetText(_ at: Date) -> String {
let time = at.formatted(Date.FormatStyle().weekday(.abbreviated).hour().minute())
let minutes = Int((at.timeIntervalSinceNow / 60).rounded())
if minutes <= 0 {
@@ -44,21 +45,38 @@ private func resetText(_ unixSeconds: Double) -> String {
return "\(time) (in \(rel))"
}
private func number(_ v: (any Sendable)??) -> Double? {
switch v {
case .some(.some(let x)):
if let d = x as? Double {
return d
}
if let i = x as? Int {
return Double(i)
}
return nil
private func amount(_ v: Double, _ unit: String) -> String {
switch unit {
case "usd":
return v.formatted(.currency(code: "USD"))
case "tokens":
return v.formatted(.number.notation(.compactName).precision(.significantDigits(1...3)))
default:
return nil
return v.formatted(.number.precision(.fractionLength(0...2)))
}
}
private func percentText(_ v: Double) -> String {
"\(v.formatted(.number.precision(.fractionLength(0)).grouping(.never)))%"
}
private func usedText(_ w: UsageWindow, _ fraction: Double?) -> String {
if w.unit == "percent" {
return percentText(w.used)
}
var s = amount(w.used, w.unit)
if let limit = w.limit {
s += " / \(amount(limit, w.unit))"
}
if w.unit != "usd" {
s += " \(w.unit)"
}
if let fraction {
s += " · \(percentText(fraction * 100))"
}
return s
}
struct StatusView: View {
@Environment(Store.self) private var store
@ViewState private var status: ServerStatus?
@@ -79,7 +97,7 @@ struct StatusView: View {
section("Running turns")
TurnList(status: status)
section("Rate limits")
RateLimits(limits: status.rateLimits.additionalProperties.mapValues { $0.value })
RateLimits(limits: status.rateLimits.additionalProperties)
section("Clients")
ClientTable(status: status)
section("Server")
@@ -263,11 +281,11 @@ private struct TurnList: View {
}
private struct RateLimits: View {
let limits: [String: JSONDict]
let limits: [String: VendorUsage]
var body: some View {
if limits.isEmpty {
Text("No vendor has reported a rate-limit status since byre started.").foregroundStyle(Palette.muted)
Text("No vendor has reported usage since byre started.").foregroundStyle(Palette.muted)
} else {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 2) {
GridRow {
@@ -283,65 +301,51 @@ private struct RateLimits: View {
}
@ViewBuilder
private func vendorRows(_ vendor: String, _ info: JSONDict) -> some View {
let statusText = info["status"].map { jsString($0) } ?? "unknown"
let tone = statusText == "allowed" ? Palette.ok : statusText == "rejected" ? Palette.danger : Palette.warn
let windows = dict(info, "unifiedWindows") ?? [:]
let rest = info.filter { !["status", "rateLimitType", "resetsAt", "unifiedWindows"].contains($0.key) }.sorted { $0.key < $1.key }
private func vendorRows(_ vendor: String, _ usage: VendorUsage) -> some View {
let rejected = usage.rejected != nil || usage.windows.contains { w in w.limit.map { w.used >= $0 } ?? false }
let reason = usage.rejected.flatMap { $0.isEmpty ? nil : $0 }
GridRow {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(vendor).font(Fonts.font(13, weight: 700)).foregroundStyle(Palette.text)
Text(statusText).font(Fonts.font(11, weight: 700)).foregroundStyle(tone)
Text(rejected ? "rejected\(reason.map { ": \($0)" } ?? "")" : "allowed").font(Fonts.font(11, weight: 700)).foregroundStyle(rejected ? Palette.danger : Palette.ok)
Text("as of \(since(usage.updatedAt))").font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
.padding(.top, 12)
.gridCellColumns(5)
}
Divider().overlay(Palette.border)
ForEach(windows.keys.sorted(), id: \.self) { name in
let w = dict(windows, name) ?? [:]
let used = number(w["utilization"])
ForEach(Array(usage.windows.enumerated()), id: \.offset) { _, w in
let used = w.limit.flatMap { $0 > 0 && (w.used / $0).isFinite ? w.used / $0 : nil }
let elapsed = elapsedFraction(w)
GridRow {
Text(name)
Text(used.map { "\(Int(($0 * 100).rounded()))%" } ?? "—")
Text(w.label)
Text(usedText(w, used))
.foregroundStyle(used.map { $0 >= 0.9 ? Palette.danger : $0 >= 0.7 ? Palette.warn : Palette.text } ?? Palette.text)
Text(elapsed.map { "\(Int(($0 * 100).rounded()))%" } ?? "—")
Text(elapsed.map { percentText($0 * 100) } ?? "—")
PaceBar(used: used, elapsed: elapsed)
Text(number(w["resetsAt"]).map(resetText) ?? "—")
Text(w.resetsAt.map(resetText) ?? "—")
}
Divider().overlay(Palette.border)
}
ForEach(rest, id: \.key) { k, v in
GridRow {
Text(k).foregroundStyle(Palette.muted)
Text(v is JSONDict || v is [(any Sendable)?] ? compactJSONText(v) : jsString(v))
.textSelection(.enabled)
.gridCellColumns(4)
}
}
}
private func elapsedFraction(_ w: JSONDict) -> Double? {
guard let resets = number(w["resetsAt"]), let mins = number(w["windowMins"]) else {
private func elapsedFraction(_ w: UsageWindow) -> Double? {
guard let resets = w.resetsAt, let seconds = w.windowSeconds, seconds > 0 else {
return nil
}
let remaining = resets * 1000 - Date().timeIntervalSince1970 * 1000
return min(1, max(0, 1 - remaining / (mins * 60000)))
return min(1, max(0, 1 - resets.timeIntervalSinceNow / Double(seconds)))
}
}
private func compactJSONText(_ v: (any Sendable)?) -> String {
prettyJSON(v).components(separatedBy: "\n").map { trimmed($0) }.joined(separator: " ")
}
private struct PaceBar: View {
let used: Double?
let elapsed: Double?
var body: some View {
if let used, let elapsed {
let diff = Int(((used - elapsed) * 100).rounded())
let title = diff > 0 ? "\(diff) points ahead of an even pace" : diff < 0 ? "\(-diff) points behind an even pace" : "on an even pace"
let diff = ((used - elapsed) * 100).rounded()
let points = abs(diff).formatted(.number.precision(.fractionLength(0)).grouping(.never))
let title = diff > 0 ? "\(points) points ahead of an even pace" : diff < 0 ? "\(points) points behind an even pace" : "on an even pace"
GeometryReader { g in
ZStack(alignment: .leading) {
Rectangle()
+4 -1
View File
@@ -878,7 +878,10 @@ private func toolTarget(_ input: (any Sendable)?, _ root: String) -> String {
}
private func relative(_ path: String, _ root: String) -> String {
!root.isEmpty && path.hasPrefix("\(root)/") ? String(path.dropFirst(root.count + 1)) : path
guard !root.isEmpty, let r = path.range(of: "\(root)/", options: [.anchored, .caseInsensitive]) else {
return path
}
return String(path[r.upperBound...])
}
private func resultText(_ content: (any Sendable)?) -> String {