Let task agents use client Macs directly: bash, copy and hands
This commit is contained in:
+51
-45
@@ -911,41 +911,29 @@ paths:
|
||||
schema: { $ref: "#/components/schemas/InboxItem" }
|
||||
"404": { $ref: "#/components/responses/Error" }
|
||||
|
||||
/api/inbox/{itemId}/bundle:
|
||||
parameters:
|
||||
- { name: itemId, in: path, required: true, schema: { type: string } }
|
||||
/api/clients/connect:
|
||||
get:
|
||||
operationId: getRunBundle
|
||||
description: A client_run item's files, as a gzipped tar of its paths relative to the worktree. Served while the item is open.
|
||||
operationId: connectClient
|
||||
description: >
|
||||
A WebSocket over which task agents use this Mac: the server sends ClientRequest text messages and the client
|
||||
answers each with a ClientReply text message carrying the same id, in any order. The Byre-Host header names
|
||||
the client; a newer connection from the same host replaces the older one.
|
||||
responses:
|
||||
"101":
|
||||
description: Switched to the WebSocket protocol.
|
||||
"400": { $ref: "#/components/responses/Error" }
|
||||
|
||||
/api/clients/transfers/{transferId}:
|
||||
parameters:
|
||||
- { name: transferId, in: path, required: true, schema: { type: string } }
|
||||
get:
|
||||
operationId: getClientTransfer
|
||||
description: The file or directory a copy ClientRequest sends, as a gzipped tar holding it under its own name. Served while the request is waiting for its reply.
|
||||
responses:
|
||||
"200":
|
||||
description: The bundle.
|
||||
description: The archive.
|
||||
content:
|
||||
application/gzip: { schema: { type: string, format: binary } }
|
||||
"400": { $ref: "#/components/responses/Error" }
|
||||
"404": { $ref: "#/components/responses/Error" }
|
||||
"409": { $ref: "#/components/responses/Error" }
|
||||
|
||||
/api/inbox/{itemId}/runs:
|
||||
parameters:
|
||||
- { name: itemId, in: path, required: true, schema: { type: string } }
|
||||
post:
|
||||
operationId: reportRun
|
||||
description: >
|
||||
Records one Mac's run of a client_run item as a run response, sent to the agent like an answer.
|
||||
The item stays as it is, so other Macs can run it too; it is accepted after the item closes.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/RunReport" }
|
||||
responses:
|
||||
"200":
|
||||
description: The item with its responses.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/InboxItem" }
|
||||
"400": { $ref: "#/components/responses/Error" }
|
||||
"404": { $ref: "#/components/responses/Error" }
|
||||
|
||||
/api/inbox/responses:
|
||||
@@ -1540,7 +1528,7 @@ components:
|
||||
task_id: { type: string }
|
||||
agent_session_id: { type: string }
|
||||
thread_id: { type: string }
|
||||
kind: { type: string, enum: [question, session_error, rate_limited, client_run] }
|
||||
kind: { type: string, enum: [question, session_error, rate_limited] }
|
||||
payload:
|
||||
type: object
|
||||
description: >
|
||||
@@ -1548,7 +1536,6 @@ components:
|
||||
question, why_blocked, options (each choice's text, in order), multiple (true for pick-any), recommended (the marked choices' text), attachments.
|
||||
session_error: error.
|
||||
rate_limited: vendor, rate_limit (the vendor's rate-limit status), error.
|
||||
client_run: why, command, paths (worktree paths in the bundle), bytes (the bundle's size).
|
||||
state: { $ref: "#/components/schemas/InboxState" }
|
||||
created_at: { type: string, format: date-time }
|
||||
resolved_by: { type: string }
|
||||
@@ -1559,7 +1546,7 @@ components:
|
||||
|
||||
InboxResponseType:
|
||||
type: string
|
||||
enum: [option, other, dismiss, run]
|
||||
enum: [option, other, dismiss]
|
||||
|
||||
InboxResponse:
|
||||
type: object
|
||||
@@ -1572,9 +1559,7 @@ components:
|
||||
options:
|
||||
type: array
|
||||
items: { type: string }
|
||||
text: { type: string, description: "run: the command's output, at most its last 64 KiB." }
|
||||
client: { type: string, description: "run: the host name of the Mac that ran the command." }
|
||||
exit_code: { type: integer, description: "run: the command's exit status." }
|
||||
text: { type: string }
|
||||
images:
|
||||
type: array
|
||||
items: { type: string }
|
||||
@@ -1604,15 +1589,6 @@ components:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Attachment" }
|
||||
|
||||
RunReport:
|
||||
type: object
|
||||
required: [run_id, client, exit_code, output]
|
||||
properties:
|
||||
run_id: { type: string, description: "The Mac's id for this one run, used as the response's id; reporting it again records nothing new." }
|
||||
client: { type: string }
|
||||
exit_code: { type: integer }
|
||||
output: { type: string }
|
||||
|
||||
InboxRespond:
|
||||
type: object
|
||||
required: [responses]
|
||||
@@ -1736,6 +1712,36 @@ components:
|
||||
connected_at: { type: string, format: date-time, description: When the client's oldest open stream connected. }
|
||||
last_action_at: { type: string, format: date-time, description: When byre last received a request the human made from this client since byre started; absent when none. }
|
||||
|
||||
ClientRequest:
|
||||
type: object
|
||||
required: [id, op]
|
||||
description: Work a task agent asks a connected Mac to do, sent over /api/clients/connect.
|
||||
properties:
|
||||
id: { type: string }
|
||||
op: { type: string, enum: [bash, copy, hands] }
|
||||
command: { type: string, description: "bash: run with zsh as a login shell in the home directory, in its own process group." }
|
||||
timeout_ms: { type: integer, description: "bash: kill the process group after this long." }
|
||||
transfer: { type: string, description: "copy: the transfer id to download from /api/clients/transfers/{transferId}." }
|
||||
dest: { type: string, description: "copy: where the archive's one entry ends up, absolute or starting with ~/; whatever is there is replaced." }
|
||||
tool: { type: string, description: "hands: the byre-hands tool to call." }
|
||||
arguments:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: "hands: the tool's arguments."
|
||||
|
||||
ClientReply:
|
||||
type: object
|
||||
required: [id]
|
||||
properties:
|
||||
id: { type: string, description: The request's id. }
|
||||
error: { type: string, description: Set when the request couldn't be carried out. }
|
||||
exit_code: { type: integer, description: "bash: the exit status, -1 when it was killed by its timeout." }
|
||||
output: { type: string, description: "bash: stdout and stderr together, at most their last 64 KiB." }
|
||||
result:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: "hands: the tool's MCP CallToolResult."
|
||||
|
||||
TurnRef:
|
||||
type: object
|
||||
required: [session_id, task_id, vendor]
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ The app connects to byre's `tailscale serve` HTTPS URL, so its requests get the
|
||||
- `UserNotifications` for notifications.
|
||||
- Third-party code: Apple's `swift-openapi-generator` (with `swift-openapi-runtime` and `swift-openapi-urlsession`) for the API types and client, and SwiftTerm for the terminal.
|
||||
|
||||
The project is a Swift package under `mac/`, built with `swift build`, not an Xcode project. It builds with Xcode's toolchain: `xcode-select --print-path` must print `/Applications/Xcode.app/Contents/Developer`. With the Command Line Tools selected while Xcode is installed, Swift Build finds two toolchains for the Command Line Tools path and picks one at random on each run; the pick is part of the build request, so every change of pick rebuilds every target. `mac/bundle.sh` builds the release configuration in one directory shared by every worktree, `~/Library/Caches/byre/build`: SwiftPM keys a build on the package's absolute path, so a build in a fresh worktree, or one sharing a `--scratch-path`, recompiles every dependency. Under a `lockf` lock on `~/Library/Caches/byre/build.lock`, so builds from different worktrees take turns, it copies the worktree's `mac/` and `api/` there with `rsync --checksum`, which rewrites only files whose contents differ and leaves the rest's modification times alone, builds, and copies the executable out to `mac/.build/Byre`. It passes `--no-color-diagnostics` because `COLOR_DIAGNOSTICS` otherwise follows whether output is a terminal and is part of the build request, so agents whose shells differ would each rebuild every target in turn; `build-request.json` under `.build/out/Intermediates.noindex/XCBuildData/` shows what a build was keyed on. It then assembles `mac/.build/byre.app` (the executable, `mac/Info.plist` with the fixed bundle identifier `run.fc.byre.mac` and a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, which `API` sends as the `Byre-Commit` header on every request beside `Byre-Host`, the Mac's name, the `mac/fonts/` files and an `.icns` rendered from `mac/icon.svg`, cached as `mac/.build/byre.icns` until the SVG changes) and signs it; notification permission needs the bundle identifier.
|
||||
The project is a Swift package under `mac/`, built with `swift build`, not an Xcode project. It builds with Xcode's toolchain: `xcode-select --print-path` must print `/Applications/Xcode.app/Contents/Developer`. With the Command Line Tools selected while Xcode is installed, Swift Build finds two toolchains for the Command Line Tools path and picks one at random on each run; the pick is part of the build request, so every change of pick rebuilds every target. `mac/bundle.sh` builds the release configuration in one directory shared by every worktree, `~/Library/Caches/byre/build`: SwiftPM keys a build on the package's absolute path, so a build in a fresh worktree, or one sharing a `--scratch-path`, recompiles every dependency. Under a `lockf` lock on `~/Library/Caches/byre/build.lock`, so builds from different worktrees take turns, it copies the worktree's `mac/`, `api/` and `hands/` there with `rsync --checksum`, which rewrites only files whose contents differ and leaves the rest's modification times alone, builds both packages, and copies the executables out to `mac/.build/Byre` and `mac/.build/byre-hands`. It passes `--no-color-diagnostics` because `COLOR_DIAGNOSTICS` otherwise follows whether output is a terminal and is part of the build request, so agents whose shells differ would each rebuild every target in turn; `build-request.json` under `.build/out/Intermediates.noindex/XCBuildData/` shows what a build was keyed on. It then assembles `mac/.build/byre.app` (the executables, `mac/Info.plist` with the fixed bundle identifier `run.fc.byre.mac` and a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, which `API` sends as the `Byre-Commit` header on every request beside `Byre-Host`, the Mac's name, the `mac/fonts/` files and an `.icns` rendered from `mac/icon.svg`, cached as `mac/.build/byre.icns` until the SVG changes) and signs it; notification permission needs the bundle identifier.
|
||||
|
||||
The app is signed with the team's Apple Development certificate (`codesign --sign "Apple Development"`), with the entitlements in `mac/byre.entitlements` (the team-prefixed application identifier, the iCloud container with CloudKit, and development push for sync), and `mac/byre.provisionprofile` embedded as `Contents/embedded.provisionprofile`. The profile is a development profile Xcode manages: it allows the team's Apple Development certificates and the Macs registered to the team, so it runs only on those Macs, and its CloudKit environment is Development. `security cms -D -i mac/byre.provisionprofile` prints it, `ProvisionedDevices` and `DeveloperCertificates` included, and `security find-identity -v -p codesigning` lists the certificates on this Mac. Building on a Mac not yet in the profile, or one without an Apple Development certificate, means regenerating it with Xcode's automatic signing: an Xcode app project with bundle identifier `run.fc.byre.mac`, the team, and an entitlements file naming the iCloud container with CloudKit, built with `xcodebuild -allowProvisioningUpdates -allowProvisioningDeviceRegistration` while the team's Apple ID is signed in to Xcode, creates the certificate, registers the Mac and writes the new profile to `~/Library/Developer/Xcode/UserData/Provisioning Profiles/`; copy it over `mac/byre.provisionprofile` and commit it.
|
||||
|
||||
|
||||
+17
-9
@@ -12,12 +12,13 @@
|
||||
- `internal/codex`: the `codex app-server` JSON-RPC client, with types generated from the CLI's schema by `go generate ./internal/codex` (see `docs/codex-protocol.md`). One goroutine reads the CLI's stdout and blocks on the bounded `Events` channel when its consumer falls behind, so responses queued behind those notifications wait too: the goroutine draining `Events` (`codexProcess.drive`) therefore never waits on a reply itself. It writes `turn/steer` in order and waits for the reply on another goroutine, and starts the prose-question nudge and the turn-end `Deliver` on their own goroutines. Every request gives up after `rpcTimeout`, or the caller's earlier deadline, and that deadline covers waiting for the write lock, writing the request to stdin and waiting for the reply. Stdin is an `os.Pipe`, so writes take a deadline. A request whose deadline passes before any of it is written fails and leaves the CLI running. One that times out after part of its line is written has broken the framing, so byre kills the CLI, logging `codex stopped reading its stdin mid-line`, and the session ends as any exited process does.
|
||||
- `internal/tailscale`: `tailscale serve` for task previews (see "Previews").
|
||||
- `internal/workflow`: validating workflow definitions and reading and writing their YAML files (see "Workflows").
|
||||
- `hands/` is `byre-hands`, the Swift MCP server that lets task agents see and drive the Mac byre runs on (see "Screen and app control").
|
||||
- `hands/` is `byre-hands`, the Swift MCP server that lets task agents see and drive the Mac byre runs on (see "Screen and app control") and, bundled into the macOS app, the human's Macs (see "Client Macs").
|
||||
- `internal/clients`: the WebSocket connections of the macOS apps that task agents use (see "Client Macs").
|
||||
- `mac/` is the macOS app, byre's client; `docs/macos.md` describes how it is built, and "macOS app" below covers building, installing and its storage. It connects to byre's `tailscale serve` `https` origin, typed into its connection screen. The server serves only the API; it has no web pages.
|
||||
|
||||
## Data
|
||||
|
||||
State lives in the directory given by `byre serve --data`, by default the `byre` directory under Go's `os.UserConfigDir()` (on macOS, `~/Library/Application Support/byre`): `byre.db`, `worktrees/<project-id>/<task-id>`, `leads/<login>` (each lead agent's working directory, the login path-escaped), `blobs/<blob-id>` (uploaded images; the `blobs` table maps each id to its content type and its path relative to this directory), and `bundles/<item-id>.tar.gz` (the files of open run requests, see "Client runs"). Task branches are `byre/<task-id>` in the project's repo.
|
||||
State lives in the directory given by `byre serve --data`, by default the `byre` directory under Go's `os.UserConfigDir()` (on macOS, `~/Library/Application Support/byre`): `byre.db`, `worktrees/<project-id>/<task-id>`, `leads/<login>` (each lead agent's working directory, the login path-escaped), `blobs/<blob-id>` (uploaded images; the `blobs` table maps each id to its content type and its path relative to this directory). Task branches are `byre/<task-id>` in the project's repo.
|
||||
|
||||
Schema changes are the `migrations` slice in `internal/store/store.go`: named entries, each with a short kebab-case name saying what it does. On open, byre applies, in slice order, every entry whose name isn't in the `migrations` table, each in its own transaction that also records the name. Each runs with foreign keys off, so a migration can rebuild a table other tables reference, and `PRAGMA foreign_key_check` must come back empty before it commits. byre is deployed from task branches that each append their own migrations, so after a merge a migration can land before others the database has already applied, and it still runs. Give a new migration a name no other branch will use, and make any data migration safe to run twice. `sqlite3 byre.db 'SELECT * FROM migrations'` lists what has been applied. A database from before the table existed is moved over on open: the first `PRAGMA user_version` entries are recorded as applied, and `user_version` is no longer read or written.
|
||||
|
||||
@@ -116,7 +117,7 @@ Clients reach `tailscale serve` over HTTP/2; it proxies to byre over HTTP/1.1 an
|
||||
|
||||
## macOS app
|
||||
|
||||
**Building.** `mac/bundle.sh`, from any directory, builds the release configuration with `swift build` in `~/Library/Caches/byre/build`, one build directory every worktree shares and takes turns in (`docs/macos.md`, "Stack", says why), and assembles `mac/.build/byre.app`: the `Byre` executable, `mac/Info.plist` with a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, the `mac/fonts/` woff2 files, an icon rendered from `mac/icon.svg` with `rsvg-convert` and `iconutil` (cached as `mac/.build/byre.icns` until the SVG changes) and `mac/byre.provisionprofile`, then signs the bundle with the Mac's Apple Development certificate and `mac/byre.entitlements`. It prints the bundle's path. It needs Xcode selected (`xcode-select --print-path` prints `/Applications/Xcode.app/Contents/Developer`), `rsvg-convert` (Homebrew's `librsvg`), an Apple Development certificate for the team (`security find-identity -v -p codesigning` lists them) and a Mac registered in the profile (`security cms -D -i mac/byre.provisionprofile` prints `ProvisionedDevices`); `docs/macos.md`, "Stack", says how to regenerate the profile for a new Mac. Debug builds are refused by an `#error` in `ByreApp.swift`. The first build on a Mac fetches the Swift packages pinned in `mac/Package.resolved` and compiles them and the generated API client; later builds, from any worktree, recompile only the targets whose sources differ from the last build's, usually just `Byre`. The API client is generated from `api/openapi.yaml` at build time. The palette is `mac/Sources/Byre/Palette.swift`, one light and one dark value per colour.
|
||||
**Building.** `mac/bundle.sh`, from any directory, builds the release configuration with `swift build` in `~/Library/Caches/byre/build`, one build directory every worktree shares and takes turns in (`docs/macos.md`, "Stack", says why), and assembles `mac/.build/byre.app`: the `Byre` executable, `byre-hands` built from `hands/` beside it in `Contents/MacOS` (signed on its own, as `run.fc.byre.hands`, before the bundle), `mac/Info.plist` with a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, the `mac/fonts/` woff2 files, an icon rendered from `mac/icon.svg` with `rsvg-convert` and `iconutil` (cached as `mac/.build/byre.icns` until the SVG changes) and `mac/byre.provisionprofile`, then signs the bundle with the Mac's Apple Development certificate and `mac/byre.entitlements`. It prints the bundle's path. It needs Xcode selected (`xcode-select --print-path` prints `/Applications/Xcode.app/Contents/Developer`), `rsvg-convert` (Homebrew's `librsvg`), an Apple Development certificate for the team (`security find-identity -v -p codesigning` lists them) and a Mac registered in the profile (`security cms -D -i mac/byre.provisionprofile` prints `ProvisionedDevices`); `docs/macos.md`, "Stack", says how to regenerate the profile for a new Mac. Debug builds are refused by an `#error` in `ByreApp.swift`. The first build on a Mac fetches the Swift packages pinned in `mac/Package.resolved` and compiles them and the generated API client; later builds, from any worktree, recompile only the targets whose sources differ from the last build's, usually just `Byre`. The API client is generated from `api/openapi.yaml` at build time. The palette is `mac/Sources/Byre/Palette.swift`, one light and one dark value per colour.
|
||||
|
||||
**Installing.** The app runs from where it was built: `open mac/.build/byre.app` in the worktree that built it. Nothing copies it to `/Applications`. A new build replaces the bundle, so quit the running app first (`pkill -x Byre` quits every running copy, whichever worktree built it) and open the new one; step 4 of "Deploying" in `AGENTS.md` does this after a deploy. Every build shares the bundle identifier `run.fc.byre.mac` and the same store, so any build reconnects to the server the last one used. Closing the window leaves the app running for notifications; ⌘Q quits it.
|
||||
|
||||
@@ -192,7 +193,7 @@ Codex runs with its own home, `codex` under the data directory (`CODEX_HOME`, se
|
||||
|
||||
## Status
|
||||
|
||||
`GET /api/admin/status` reports `running_turns` (task sessions now `running`, as session, task and vendor; lead turns aren't listed); `rate_limits`, the latest status each vendor sent since byre started (Claude's `rate_limit_info`, with `windowMins` added to each `unifiedWindows` entry by `addClaudeWindowMins` in `internal/agents/manager.go` from the window's name, since Claude doesn't send a length; Codex's `account/rateLimits/updated` reshaped by `codexRateLimit` in `internal/agents/codex.go` into the same `status`, `rateLimitType` and `unifiedWindows` fields, `windowMins` from its `windowDurationMins`). Claude only reports during its turns; Codex is also asked once at startup (`readCodexRateLimit`, a short-lived `codex app-server` that calls `account/rateLimits/read` without opening a thread), so its entry is there before any Codex turn runs, and every Codex update broadcasts `admin.health`; `agent_processes`, `zombies`, `stuck_after_seconds` and `protocol_errors` (below); `clients`, one entry per distinct login, `host` and `commit` among the open `GET /api/events` streams, with the oldest stream's `connected_at` (the app opens two streams, so it shows once). `host` and `commit` come from the `Byre-Host` and `Byre-Commit` headers, which the macOS app sends on every request: the Mac's name (`Host.current().localizedName`, else `ProcessInfo.processInfo.hostName`, the same name `ClientRun.swift` reports) and the `ByreCommit` that `mac/bundle.sh` stamped into its `Info.plist`; they are empty for a client that sends neither. 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 login, host and commit: any request other than a `GET`, except the three the app sends on its own (`PUT /api/me/pane-focus`, `PUT /api/tasks/{taskId}/summary-read`, `POST /api/inbox/{itemId}/runs`, listed in `automaticRequests` in `internal/server/server.go`), plus `GET /api/inbox/{itemId}/bundle`, which the app fetches only when the human clicks Run (`humanGets`), 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 hub's memory, so it is absent until that client's first action after byre starts. An action at least a second after the last one from that client that broadcast, and every `GET /api/events` stream opening or closing, broadcasts an unstored `admin.clients` event (`clientsChanged`). And server counts (start time, agent processes, projects, active tasks, SSE clients, database size including WAL, last event seq).
|
||||
`GET /api/admin/status` reports `running_turns` (task sessions now `running`, as session, task and vendor; lead turns aren't listed); `rate_limits`, the latest status each vendor sent since byre started (Claude's `rate_limit_info`, with `windowMins` added to each `unifiedWindows` entry by `addClaudeWindowMins` in `internal/agents/manager.go` from the window's name, since Claude doesn't send a length; Codex's `account/rateLimits/updated` reshaped by `codexRateLimit` in `internal/agents/codex.go` into the same `status`, `rateLimitType` and `unifiedWindows` fields, `windowMins` from its `windowDurationMins`). Claude only reports during its turns; Codex is also asked once at startup (`readCodexRateLimit`, a short-lived `codex app-server` that calls `account/rateLimits/read` without opening a thread), so its entry is there before any Codex turn runs, and every Codex update broadcasts `admin.health`; `agent_processes`, `zombies`, `stuck_after_seconds` and `protocol_errors` (below); `clients`, one entry per distinct login, `host` and `commit` among the open `GET /api/events` streams, with the oldest stream's `connected_at` (the app opens two streams, so it shows once). `host` and `commit` come from the `Byre-Host` and `Byre-Commit` headers, which the macOS app sends on every request: the Mac's name (`Host.current().localizedName`, else `ProcessInfo.processInfo.hostName`, the name agents' client tools take, see "Client Macs") and the `ByreCommit` that `mac/bundle.sh` stamped into its `Info.plist`; they are empty for a client that sends neither. 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 login, host and commit: 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 hub's memory, so it is absent until that client's first action after byre starts. An action at least a second after the last one from that client that broadcast, and every `GET /api/events` stream opening or closing, broadcasts an unstored `admin.clients` event (`clientsChanged`). And server counts (start time, agent processes, projects, active tasks, SSE clients, database size including WAL, last event seq).
|
||||
|
||||
Agent health is `internal/agents/health.go`:
|
||||
|
||||
@@ -272,7 +273,7 @@ Answers reach the agent as one message holding every undelivered `option` and `o
|
||||
|
||||
The running macOS app posts a notification for each new inbox item, raised by the app itself from the `inbox.created` events on its SSE stream (`notifyInbox` in `mac/Sources/Byre/Notifications.swift`, through `UNUserNotificationCenter`). With the app not running, nothing notifies; closing its window leaves it running.
|
||||
|
||||
- **Preferences.** `users.notification_prefs` holds which item kinds notify the user, returned as `notification_prefs` on `GET /api/me` and set with `PUT /api/me/notifications`: `questions` (on by default; it also covers `client_run` items, whose body reads "Run on your Mac: <why>"), `reports` (task turns that end with `summary: true` on `turn.ended`, labelled "Turn summaries"), `errors` (`session_error`) and `rate_limits`. The app checks them against each item or `turn.ended` event as it arrives.
|
||||
- **Preferences.** `users.notification_prefs` holds which item kinds notify the user, returned as `notification_prefs` on `GET /api/me` and set with `PUT /api/me/notifications`: `questions` (on by default), `reports` (task turns that end with `summary: true` on `turn.ended`, labelled "Turn summaries"), `errors` (`session_error`) and `rate_limits`. The app checks them against each item or `turn.ended` event as it arrives.
|
||||
- **Content.** The title is `byre · <task title>` and the body the question (or the turn summary's first line, the error, or "Rate limited"), cut at 200 characters. The notification's identifier is the item's ID, or `turn-<seq>` for a turn summary, so a repeat replaces the earlier one.
|
||||
- **Clearing.** A delivered notification leaves Notification Center once it is dealt with anywhere, on this Mac, another one or by the lead: an `inbox.updated` event whose item is no longer `open` removes the item's notification (`clearInbox`), and a `summary.read` event removes that task's `turn-<seq>` notifications up to its `seq` (`clearTurns`).
|
||||
- **Clicking** a notification activates the app, shows its window and points the right pane at the item's task.
|
||||
@@ -293,13 +294,20 @@ A code block fenced as `mermaid`, and an inbox `diagram` attachment, render as a
|
||||
|
||||
Answers reach the agent as one message, listing each answer under the question or error it responds to, once the session is idle and none of its questions are still waiting for the human. An `other` answer to a question tells the agent that if it asks something back, it should answer with `ask_human` using the item's `thread_id`. `thread_id` only groups items: filing an item never changes any other, so several questions on one thread stay open until each is answered, withdrawn or dismissed. Nothing sets the `superseded` state; it stays in the spec because old items hold it. Every item change is an `inbox.created` or `inbox.updated` event carrying the whole item, including who resolved it.
|
||||
|
||||
## Client runs
|
||||
## Client Macs
|
||||
|
||||
An agent asks the human to run a command on their own Mac, with files from its worktree, through the `run_on_client` MCP tool (`internal/agents/clientrun.go`): `paths` (relative to the worktree, checked through an `os.Root` so none leaves it), `command` and `why`. The call packs the paths with `tar -czf` into `bundles/<item id>.tar.gz` in the data directory (`Store.BundlePath`), so later changes to them aren't sent, and files a `client_run` inbox item whose payload holds `why`, `command`, `paths` and the bundle's `bytes`. It returns at once; the agent need not end its turn.
|
||||
Task agents use the Macs the human has byre's app open on directly, with no approval, as they use the machine byre runs on. While it runs, the app (`mac/Sources/Byre/Remote.swift`, started with the global event stream and stopped with it) keeps a WebSocket open to `GET /api/clients/connect`, reconnecting 2s after it drops. The client is named by its `Byre-Host` header (required), and a newer connection from the same host replaces the older one. byre keeps the connections in memory (`internal/clients`), pinging each every 30s and dropping one that doesn't answer within 15s. Over it byre sends `ClientRequest` text messages, each with a fresh `id`, and the app answers each with a `ClientReply` carrying that `id` (`error` when it couldn't carry it out), in any order, handling requests concurrently. A request whose client disconnects before replying fails with "<host> disconnected before replying".
|
||||
|
||||
The item is not exclusive to one Mac. Every open app shows it in the Inbox tab with **Run on this Mac** and **Dismiss**; it notifies under the `questions` preference, and the task's state reads **Ready to run** in the warn colour. Run (`runOnThisMac` in `mac/Sources/Byre/ClientRun.swift`) makes a new directory `runs/<uuid>` under `~/Library/Caches/run.fc.byre.mac`, writes the item id to `item`, the server origin to `server` and the command to `command.sh`, downloads `GET /api/inbox/{itemId}/bundle` (served while the item is open, 409 after) and unpacks it into `files/`, then starts `/bin/zsh -l ../command.sh` in `files/` as a detached child under `/usr/bin/lockf -k lock`, with its output in `output.log` and its exit status written to `exit` when it ends. The `lock` file marks a run whose command started, and lockf holds an exclusive `flock` on it for as long as the command runs. Clicking Run again, or on another Mac, makes another run. The child outlives the app, so a command that quits byre (installing a new build) still finishes. The app polls each run directory once a second for `exit`; when `lock` is no longer locked and there is still no `exit` (the Mac restarted, the command was killed), it reports the run as interrupted, exit status -1 with a line saying so after the output. On startup it picks up every directory left in `runs/` whose `server` is the server it connects to, removing the ones with no `lock` (the app quit while downloading or unpacking, so nothing ran), so the build a command launched reports the run that launched it, and a run is never reported to another server. It reports with `POST /api/inbox/{itemId}/runs` (`run_id`: the run directory's UUID, which becomes the response's id, so a report sent again after its answer was lost records nothing new; `client`: the Mac's computer name, `exit_code`, `output`: the last 64 KiB of `output.log`) and deletes the directory once the server accepts it or answers 4xx; a network failure or 5xx leaves it for the next launch. `paths` may be empty: the server then packs an empty archive (`tar -T /dev/null`). `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` shows `client run started` and `client run reported` lines.
|
||||
Implementer sessions get four MCP tools for it (`internal/agents/clients.go`):
|
||||
|
||||
A report is stored as a `run` response on the item (`inbox_responses.client` and `exit_code`, the output in `text`) without changing its state, and is accepted after the item is closed. It reaches the agent like an answer: as one message once the session is idle, or mid-turn, never held back by the run item itself, since only open questions hold delivery. The card lists each run as "<user> ran it on <client>: exit <n>" with the output collapsed. `POST /api/inbox/responses` takes only `dismiss` for a `client_run` item. Dismissing it, or closing its task, ends it: the `inbox.updated` event with the item no longer open removes its bundle (`removeClosedBundles` in `internal/server/inbox.go`). `ls <data dir>/bundles` lists the bundles of open run requests.
|
||||
- `list_clients`: the connected clients, with each one's login, connection time and app commit, and its `last_action_at` from Status (see "Status") matched on login, host and commit; the most recently active is listed first and marked.
|
||||
- `client_bash`: `op: bash`. The app runs `/bin/zsh -l -c <command>` in the human's home directory through `posix_spawn` in its own process group, stdin from `/dev/null` and stdout and stderr into one temporary file, and kills the group with `SIGKILL` after `timeout_ms` (default 120000, at most 600000), replying `exit_code` -1. Otherwise `exit_code` is the exit status, or 128 plus the signal. `output` is the file's last 64 KiB. The agent's call waits the timeout plus 30s for the reply. Background children the command leaves are not waited for or killed, and a command that quits the app keeps running.
|
||||
- `client_copy`: `op: copy`. `path` is checked against the worktree through an `os.Root`; byre registers a transfer naming the path's directory and base name under a random id for as long as the call waits (at most 10 minutes), and the app downloads `GET /api/clients/transfers/{transferId}`, which streams `tar -czf - -C <dir> -- <name>` (404 once the call has ended). The app unpacks it into a new `.byre-copy-<uuid>` directory beside `dest` (`~/` expanded, parent directories created), removes whatever is at `dest`, moves the archive's one entry there and removes the temporary directory.
|
||||
- `client_hands`: `op: hands`, with `tool` and `arguments` as the `hands` tools take them (see "Screen and app control"). The app runs one `byre-hands`, bundled at `Contents/MacOS/byre-hands`, started on first use and again after it exits, writes each call to its stdin as a `tools/call` line and replies with the line's `result`, which byre returns to the agent as the tool's result, images included (`mcp.Tool.Rich`; Codex gets them as `inputImage` data URLs). The macOS privacy grants it needs (Screen Recording, Accessibility, Automation of Orion) belong to byre's app on that Mac, since the app is the responsible process; `NSAppleEventsUsageDescription` in `mac/Info.plist` is what lets macOS ask for the Automation grant. Each `byre-hands` process has its own pending calls, so one that exits fails only the calls sent to it.
|
||||
|
||||
A byre restart drops every connection; each app reconnects 2s later, and calls in flight at the restart fail as disconnected. Updating a client's app goes through the tools themselves: `client_copy` the new `byre.app`, then `client_bash` a command that quits the old app and opens the new one, which outlives the app and reconnects. An app built before these tools existed never connects, so its first update is by hand.
|
||||
|
||||
`log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` on a client shows `client request <id>: <op>` lines, connection drops and `byre-hands` starts and exits; byre's log shows `client connected` and `client disconnected`.
|
||||
|
||||
## Changes
|
||||
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/mcp"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
bundleTimeout = 5 * time.Minute
|
||||
MaxRunOutput = 64 << 10
|
||||
)
|
||||
|
||||
type runOnClientArgs struct {
|
||||
Paths []string `json:"paths"`
|
||||
Command string `json:"command"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
func (m *Manager) runOnClientTool(l *live) mcp.Tool {
|
||||
return mcp.Tool{
|
||||
Name: "run_on_client",
|
||||
Description: "Ask the human to run a command on their own Mac, with files from your worktree, for example to install and launch an app you built. " +
|
||||
"byre packs paths into a bundle now, so later changes to them aren't sent, and files a card in the human's inbox showing why, the command and the files. " +
|
||||
"Every Mac the human has byre open on shows the card with Run and Dismiss; each Mac where they click Run downloads the bundle, unpacks it into a new empty directory, " +
|
||||
"and runs command there with zsh as a login shell, as the human. The directory is deleted once the command finishes, so copy anything that must stay elsewhere first. " +
|
||||
"The command keeps running if it quits byre's app, and the app reports it when it next starts. " +
|
||||
"This call returns at once; you don't need to end your turn. Each Mac's run reaches you as a message with its host name, exit status and the last 64 KiB of its output. " +
|
||||
"The card stays until the human dismisses it, so it can be run again or on another Mac.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"paths": {"type": "array", "items": {"type": "string"}, "description": "Files or directories to send, relative to your worktree; each is unpacked at the same relative path. May be empty."},
|
||||
"command": {"type": "string", "description": "The zsh command to run in the directory the bundle unpacks into, e.g. 'rm -rf ~/Applications/byre.app && ditto mac/.build/byre.app ~/Applications/byre.app && (pkill -x Byre; open ~/Applications/byre.app)'."},
|
||||
"why": {"type": "string", "description": "One or two sentences for the human: what running it does and why."}
|
||||
},
|
||||
"required": ["paths", "command", "why"]
|
||||
}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return m.runOnClient(ctx, l, raw)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) runOnClient(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
|
||||
args := runOnClientArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed run_on_client call: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(args.Command) == "" {
|
||||
return "", errors.New("command is empty")
|
||||
}
|
||||
if strings.TrimSpace(args.Why) == "" {
|
||||
return "", errors.New("why is empty: say what running it does and why")
|
||||
}
|
||||
|
||||
paths, err := checkBundlePaths(l.dir, args.Paths)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
id := store.NewID()
|
||||
size, err := m.writeBundle(ctx, l.dir, paths, m.store.BundlePath(id))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"why": args.Why,
|
||||
"command": args.Command,
|
||||
"paths": paths,
|
||||
"bytes": size,
|
||||
}
|
||||
err = m.store.CreateInboxItem(ctx, m.newItem(l, id, id, api.ClientRun, payload))
|
||||
if err != nil {
|
||||
os.Remove(m.store.BundlePath(id))
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Run request %s filed in the human's inbox with a %d-byte bundle. Each Mac's run will reach you as a message.", id, size), nil
|
||||
}
|
||||
|
||||
func checkBundlePaths(dir string, paths []string) ([]string, error) {
|
||||
root, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
out := []string{}
|
||||
for _, p := range paths {
|
||||
clean := filepath.Clean(p)
|
||||
if filepath.IsAbs(clean) || clean == "." || strings.HasPrefix(clean, "-") {
|
||||
return nil, fmt.Errorf("path %q must be relative to your worktree and inside it", p)
|
||||
}
|
||||
_, err = root.Lstat(clean)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("path %q: %w", p, err)
|
||||
}
|
||||
out = append(out, clean)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Manager) writeBundle(ctx context.Context, dir string, paths []string, dest string) (int64, error) {
|
||||
err := os.MkdirAll(filepath.Dir(dest), 0o755)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
tmp := dest + ".tmp"
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, bundleTimeout)
|
||||
defer cancel()
|
||||
// -T /dev/null lets tar write an empty archive when paths is empty.
|
||||
cmd := exec.CommandContext(cctx, "tar", append([]string{"-czf", tmp, "-C", dir, "-T", "/dev/null", "--"}, paths...)...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
os.Remove(tmp)
|
||||
return 0, fmt.Errorf("pack bundle: %w: %s", err, out)
|
||||
}
|
||||
|
||||
err = os.Rename(tmp, dest)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
info, err := os.Stat(dest)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
func (m *Manager) ReportRun(ctx context.Context, user, itemID string, report api.RunReport) (api.InboxItem, error) {
|
||||
item, err := m.store.GetInboxItem(ctx, itemID)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
if item.Kind != api.ClientRun {
|
||||
return item, fmt.Errorf("%w: item %s is a %s, not a client_run", ErrInvalid, itemID, item.Kind)
|
||||
}
|
||||
client := strings.TrimSpace(report.Client)
|
||||
if client == "" {
|
||||
return item, fmt.Errorf("%w: client is empty", ErrInvalid)
|
||||
}
|
||||
if strings.TrimSpace(report.RunId) == "" {
|
||||
return item, fmt.Errorf("%w: run_id is empty", ErrInvalid)
|
||||
}
|
||||
|
||||
output := report.Output
|
||||
if len(output) > MaxRunOutput {
|
||||
output = strings.ToValidUTF8(output[len(output)-MaxRunOutput:], "")
|
||||
}
|
||||
resp := api.InboxResponse{
|
||||
Id: report.RunId,
|
||||
ItemId: itemID,
|
||||
User: user,
|
||||
Type: api.Run,
|
||||
Text: &output,
|
||||
Client: &client,
|
||||
ExitCode: &report.ExitCode,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
added, err := m.store.AddRunResponse(ctx, resp)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
if !added {
|
||||
slog.Info("client run already reported", "item", itemID, "response", resp.Id, "client", client)
|
||||
return m.store.GetInboxItem(ctx, itemID)
|
||||
}
|
||||
slog.Info("client run reported", "item", itemID, "response", resp.Id, "user", user, "client", client, "exit", report.ExitCode)
|
||||
|
||||
go func() {
|
||||
err := m.Deliver(context.Background(), item.AgentSessionId)
|
||||
if err != nil {
|
||||
slog.Error("deliver run report", "session", item.AgentSessionId, "err", err)
|
||||
}
|
||||
}()
|
||||
return m.store.GetInboxItem(ctx, itemID)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/clients"
|
||||
"g.fc.run/misc/byre/internal/mcp"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBashTimeout = 2 * time.Minute
|
||||
maxBashTimeout = 10 * time.Minute
|
||||
replyGrace = 30 * time.Second
|
||||
copyTimeout = 10 * time.Minute
|
||||
handsTimeout = 11 * time.Minute
|
||||
)
|
||||
|
||||
func (m *Manager) clientTools(l *live) []mcp.Tool {
|
||||
return []mcp.Tool{
|
||||
{
|
||||
Name: "list_clients",
|
||||
Description: "List the Macs with byre's app open: the human's own machines, not the one byre (and your worktree) runs on. " +
|
||||
"Each has its name, which the other client tools take as client, the human using it, and when the human last did something in byre from it. " +
|
||||
"The most recently active one is listed first; it is usually the Mac the human sits at.",
|
||||
InputSchema: json.RawMessage(`{"type": "object", "properties": {}}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return m.listClients(ctx)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "client_bash",
|
||||
Description: "Run a shell command on a client Mac, as the human, with zsh as a login shell in their home directory, and get its exit status and output (stdout and stderr together, the last 64 KiB). " +
|
||||
"It runs at once, with no approval. The command runs in its own process group, which is killed at the timeout. " +
|
||||
"A command that quits byre's app on that Mac, for example to install a new build, ends this call with the client disconnecting, but the command keeps running.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client": {"type": "string", "description": "The client's name from list_clients."},
|
||||
"command": {"type": "string"},
|
||||
"timeout_ms": {"type": "integer", "description": "Kill the command after this long (default 120000, at most 600000)."}
|
||||
},
|
||||
"required": ["client", "command"]
|
||||
}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return m.clientBash(ctx, raw)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "client_copy",
|
||||
Description: "Copy a file or directory from your worktree to a client Mac, replacing whatever is at dest there. " +
|
||||
"Directories, app bundles included, are copied whole with their permissions and symlinks.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client": {"type": "string", "description": "The client's name from list_clients."},
|
||||
"path": {"type": "string", "description": "The file or directory, relative to your worktree."},
|
||||
"dest": {"type": "string", "description": "Its new path on the client, absolute or starting with ~/, e.g. ~/Applications/byre.app."}
|
||||
},
|
||||
"required": ["client", "path", "dest"]
|
||||
}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return m.clientCopy(ctx, l, raw)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "client_hands",
|
||||
Description: "Call one of your hands tools (screen_list, capture, ax_tree, act, browser) on a client Mac instead of the Mac byre runs on: " +
|
||||
"tool is the tool's name and arguments exactly what that tool takes. Window ids, refs and displays are that Mac's, so start with screen_list there. " +
|
||||
"Every agent using that Mac shares one set of refs, so read the tree again if another agent may have. " +
|
||||
"A call fails until the human has given byre's app Screen Recording and Accessibility (and Automation of Orion, for browser) on that Mac; say so when it does.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client": {"type": "string", "description": "The client's name from list_clients."},
|
||||
"tool": {"type": "string", "enum": ["screen_list", "capture", "ax_tree", "act", "browser"]},
|
||||
"arguments": {"type": "object", "description": "The tool's arguments."}
|
||||
},
|
||||
"required": ["client", "tool"]
|
||||
}`),
|
||||
Rich: func(ctx context.Context, raw json.RawMessage) mcp.Result {
|
||||
return m.clientHands(ctx, raw)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) listClients(ctx context.Context) (string, error) {
|
||||
status, err := m.status(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
type row struct {
|
||||
c clients.Client
|
||||
last *time.Time
|
||||
}
|
||||
rows := []row{}
|
||||
for _, c := range m.clients.List() {
|
||||
r := row{c: c}
|
||||
for _, s := range status.Clients {
|
||||
if s.Login == c.Login && s.Host == c.Host && s.Commit == c.Commit {
|
||||
r.last = s.LastActionAt
|
||||
}
|
||||
}
|
||||
rows = append(rows, r)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return "No client Macs are connected.", nil
|
||||
}
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
a, b := rows[i].last, rows[j].last
|
||||
return a != nil && (b == nil || a.After(*b))
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
b := &strings.Builder{}
|
||||
for i, r := range rows {
|
||||
fmt.Fprintf(b, "- %s (%s): ", r.c.Host, r.c.Login)
|
||||
if r.last == nil {
|
||||
b.WriteString("no action since byre started")
|
||||
} else {
|
||||
fmt.Fprintf(b, "last action %s ago", now.Sub(*r.last).Round(time.Second))
|
||||
}
|
||||
fmt.Fprintf(b, ", connected %s ago, app built from %s", now.Sub(r.c.ConnectedAt).Round(time.Second), r.c.Commit)
|
||||
if i == 0 && r.last != nil {
|
||||
b.WriteString(" (most recently active)")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
type clientBashArgs struct {
|
||||
Client string `json:"client"`
|
||||
Command string `json:"command"`
|
||||
TimeoutMs *int `json:"timeout_ms"`
|
||||
}
|
||||
|
||||
func (m *Manager) clientBash(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
args := clientBashArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed client_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())
|
||||
}
|
||||
|
||||
ms := int(timeout.Milliseconds())
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout+replyGrace)
|
||||
defer cancel()
|
||||
reply, err := m.clients.Call(cctx, args.Client, api.ClientRequest{Op: api.Bash, Command: &args.Command, TimeoutMs: &ms})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if reply.ExitCode == nil || reply.Output == nil {
|
||||
return "", fmt.Errorf("%s's reply has no exit_code or output", args.Client)
|
||||
}
|
||||
status := fmt.Sprintf("exit status %d", *reply.ExitCode)
|
||||
if *reply.ExitCode == -1 {
|
||||
status = fmt.Sprintf("killed after %s", timeout)
|
||||
}
|
||||
return status + "\n" + *reply.Output, nil
|
||||
}
|
||||
|
||||
type clientCopyArgs struct {
|
||||
Client string `json:"client"`
|
||||
Path string `json:"path"`
|
||||
Dest string `json:"dest"`
|
||||
}
|
||||
|
||||
func (m *Manager) clientCopy(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
|
||||
args := clientCopyArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed client_copy call: %w", err)
|
||||
}
|
||||
if !filepath.IsAbs(args.Dest) && !strings.HasPrefix(args.Dest, "~/") {
|
||||
return "", fmt.Errorf("dest %q must be absolute or start with ~/", args.Dest)
|
||||
}
|
||||
|
||||
clean := filepath.Clean(args.Path)
|
||||
if filepath.IsAbs(clean) {
|
||||
return "", fmt.Errorf("path %q must be relative to your worktree", args.Path)
|
||||
}
|
||||
root, err := os.OpenRoot(l.dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = root.Lstat(clean)
|
||||
root.Close()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("path %q: %w", args.Path, err)
|
||||
}
|
||||
|
||||
full := filepath.Join(l.dir, clean)
|
||||
id := m.clients.AddTransfer(clients.Transfer{Dir: filepath.Dir(full), Name: filepath.Base(full)})
|
||||
defer m.clients.RemoveTransfer(id)
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, copyTimeout)
|
||||
defer cancel()
|
||||
_, err = m.clients.Call(cctx, args.Client, api.ClientRequest{Op: api.Copy, Transfer: &id, Dest: &args.Dest})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Copied %s to %s on %s.", clean, args.Dest, args.Client), nil
|
||||
}
|
||||
|
||||
type clientHandsArgs struct {
|
||||
Client string `json:"client"`
|
||||
Tool string `json:"tool"`
|
||||
Arguments *map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
func (m *Manager) clientHands(ctx context.Context, raw json.RawMessage) mcp.Result {
|
||||
args := clientHandsArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return mcp.TextResult(fmt.Sprintf("malformed client_hands call: %s", err), true)
|
||||
}
|
||||
if args.Arguments == nil {
|
||||
args.Arguments = &map[string]any{}
|
||||
}
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, handsTimeout)
|
||||
defer cancel()
|
||||
reply, err := m.clients.Call(cctx, args.Client, api.ClientRequest{Op: api.Hands, Tool: &args.Tool, Arguments: args.Arguments})
|
||||
if err != nil {
|
||||
return mcp.TextResult(err.Error(), true)
|
||||
}
|
||||
if reply.Result == nil {
|
||||
return mcp.TextResult(fmt.Sprintf("%s's reply has no result", args.Client), true)
|
||||
}
|
||||
res := mcp.Result{}
|
||||
err = remarshal(*reply.Result, &res)
|
||||
if err != nil {
|
||||
return mcp.TextResult(fmt.Sprintf("%s's result isn't an MCP tool result: %s", args.Client, err), true)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -492,16 +492,9 @@ func deliveryMessage(pending []store.Pending) string {
|
||||
fmt.Fprintf(b, "Your error %s: %s\n", p.Item.Id, p.Item.Payload["error"])
|
||||
case api.RateLimited:
|
||||
fmt.Fprintf(b, "Your rate-limited turn %s: %s\n", p.Item.Id, p.Item.Payload["error"])
|
||||
case api.ClientRun:
|
||||
fmt.Fprintf(b, "Your run request %s: %s\n", p.Item.Id, p.Item.Payload["command"])
|
||||
}
|
||||
|
||||
switch r.Type {
|
||||
case api.Run:
|
||||
fmt.Fprintf(b, "%s ran it on %s: exit status %d\n", r.User, *r.Client, *r.ExitCode)
|
||||
if r.Text != nil && *r.Text != "" {
|
||||
fmt.Fprintf(b, "Output:\n```\n%s\n```\n", strings.TrimRight(*r.Text, "\n"))
|
||||
}
|
||||
case api.Option:
|
||||
if p.Item.Payload["multiple"] == true {
|
||||
fmt.Fprintf(b, "%s checked:\n", r.User)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/claude"
|
||||
"g.fc.run/misc/byre/internal/clients"
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
"g.fc.run/misc/byre/internal/mcp"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
@@ -39,6 +40,7 @@ type Manager struct {
|
||||
codexHome string
|
||||
status func(context.Context) (api.Status, error)
|
||||
addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error)
|
||||
clients *clients.Registry
|
||||
|
||||
mu sync.Mutex
|
||||
live map[string]*live
|
||||
@@ -117,7 +119,7 @@ func (l *live) implementer() bool {
|
||||
return l.owner == "" && l.reviewID == ""
|
||||
}
|
||||
|
||||
func New(st *store.Store, worktreeRoot, leadRoot, codexHome string, status func(context.Context) (api.Status, error), addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error)) *Manager {
|
||||
func New(st *store.Store, worktreeRoot, leadRoot, codexHome string, status func(context.Context) (api.Status, error), addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error), clientRegistry *clients.Registry) *Manager {
|
||||
m := &Manager{
|
||||
store: st,
|
||||
worktreeRoot: worktreeRoot,
|
||||
@@ -125,6 +127,7 @@ func New(st *store.Store, worktreeRoot, leadRoot, codexHome string, status func(
|
||||
codexHome: codexHome,
|
||||
status: status,
|
||||
addProject: addProject,
|
||||
clients: clientRegistry,
|
||||
live: map[string]*live{},
|
||||
starting: map[string]*start{},
|
||||
deliverLocks: map[string]*sessionLock{},
|
||||
@@ -709,7 +712,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.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.runOnClientTool(l), m.takeSnapshotTool(l)}, m.subtaskTools(l)...)
|
||||
tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.takeSnapshotTool(l)}, append(m.subtaskTools(l), m.clientTools(l)...)...)
|
||||
if sess.Role == api.Implementer {
|
||||
g += longTitleGuidance(task)
|
||||
notes, err := m.notesText(ctx, task.CreatedBy, task.ProjectId)
|
||||
|
||||
@@ -113,10 +113,6 @@ func CheckResponse(item api.InboxItem, c api.InboxResponseCreate) (api.InboxStat
|
||||
return "", fmt.Errorf("%w: item %s: "+format, append([]any{ErrInvalid, item.Id}, args...)...)
|
||||
}
|
||||
|
||||
if item.Kind == api.ClientRun && c.Type != api.Dismiss {
|
||||
return bad("a run request is only dismissed here; runs are reported to /api/inbox/%s/runs", item.Id)
|
||||
}
|
||||
|
||||
switch c.Type {
|
||||
case api.Option:
|
||||
if !question {
|
||||
|
||||
+359
-329
@@ -135,6 +135,27 @@ func (e AttachmentKind) Valid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for ClientRequestOp.
|
||||
const (
|
||||
Bash ClientRequestOp = "bash"
|
||||
Copy ClientRequestOp = "copy"
|
||||
Hands ClientRequestOp = "hands"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the ClientRequestOp enum.
|
||||
func (e ClientRequestOp) Valid() bool {
|
||||
switch e {
|
||||
case Bash:
|
||||
return true
|
||||
case Copy:
|
||||
return true
|
||||
case Hands:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for DirEntryType.
|
||||
const (
|
||||
DirEntryTypeDir DirEntryType = "dir"
|
||||
@@ -203,7 +224,6 @@ func (e FindingState) Valid() bool {
|
||||
|
||||
// Defines values for InboxItemKind.
|
||||
const (
|
||||
ClientRun InboxItemKind = "client_run"
|
||||
Question InboxItemKind = "question"
|
||||
RateLimited InboxItemKind = "rate_limited"
|
||||
SessionError InboxItemKind = "session_error"
|
||||
@@ -212,8 +232,6 @@ const (
|
||||
// Valid indicates whether the value is a known member of the InboxItemKind enum.
|
||||
func (e InboxItemKind) Valid() bool {
|
||||
switch e {
|
||||
case ClientRun:
|
||||
return true
|
||||
case Question:
|
||||
return true
|
||||
case RateLimited:
|
||||
@@ -230,7 +248,6 @@ const (
|
||||
Dismiss InboxResponseType = "dismiss"
|
||||
Option InboxResponseType = "option"
|
||||
Other InboxResponseType = "other"
|
||||
Run InboxResponseType = "run"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the InboxResponseType enum.
|
||||
@@ -242,8 +259,6 @@ func (e InboxResponseType) Valid() bool {
|
||||
return true
|
||||
case Other:
|
||||
return true
|
||||
case Run:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -637,6 +652,50 @@ type Blob struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
|
||||
// ClientReply defines model for ClientReply.
|
||||
type ClientReply struct {
|
||||
// Error Set when the request couldn't be carried out.
|
||||
Error *string `json:"error,omitempty"`
|
||||
|
||||
// ExitCode bash: the exit status, -1 when it was killed by its timeout.
|
||||
ExitCode *int `json:"exit_code,omitempty"`
|
||||
|
||||
// Id The request's id.
|
||||
Id string `json:"id"`
|
||||
|
||||
// Output bash: stdout and stderr together, at most their last 64 KiB.
|
||||
Output *string `json:"output,omitempty"`
|
||||
|
||||
// Result hands: the tool's MCP CallToolResult.
|
||||
Result *map[string]interface{} `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// ClientRequest Work a task agent asks a connected Mac to do, sent over /api/clients/connect.
|
||||
type ClientRequest struct {
|
||||
// Arguments hands: the tool's arguments.
|
||||
Arguments *map[string]interface{} `json:"arguments,omitempty"`
|
||||
|
||||
// Command bash: run with zsh as a login shell in the home directory, in its own process group.
|
||||
Command *string `json:"command,omitempty"`
|
||||
|
||||
// Dest copy: where the archive's one entry ends up, absolute or starting with ~/; whatever is there is replaced.
|
||||
Dest *string `json:"dest,omitempty"`
|
||||
Id string `json:"id"`
|
||||
Op ClientRequestOp `json:"op"`
|
||||
|
||||
// TimeoutMs bash: kill the process group after this long.
|
||||
TimeoutMs *int `json:"timeout_ms,omitempty"`
|
||||
|
||||
// Tool hands: the byre-hands tool to call.
|
||||
Tool *string `json:"tool,omitempty"`
|
||||
|
||||
// Transfer copy: the transfer id to download from /api/clients/transfers/{transferId}.
|
||||
Transfer *string `json:"transfer,omitempty"`
|
||||
}
|
||||
|
||||
// ClientRequestOp defines model for ClientRequest.Op.
|
||||
type ClientRequestOp string
|
||||
|
||||
// ConnectedClient defines model for ConnectedClient.
|
||||
type ConnectedClient struct {
|
||||
// Commit The git commit the app was built from
|
||||
@@ -771,7 +830,7 @@ type InboxItem struct {
|
||||
Id string `json:"id"`
|
||||
Kind InboxItemKind `json:"kind"`
|
||||
|
||||
// Payload question: context (Markdown with the choices inline as `- ( ) text` / `- (*) text` pick-one or `- [ ] text` / `- [x] text` pick-any list items), question, why_blocked, options (each choice's text, in order), multiple (true for pick-any), recommended (the marked choices' text), attachments. session_error: error. rate_limited: vendor, rate_limit (the vendor's rate-limit status), error. client_run: why, command, paths (worktree paths in the bundle), bytes (the bundle's size).
|
||||
// Payload question: context (Markdown with the choices inline as `- ( ) text` / `- (*) text` pick-one or `- [ ] text` / `- [x] text` pick-any list items), question, why_blocked, options (each choice's text, in order), multiple (true for pick-any), recommended (the marked choices' text), attachments. session_error: error. rate_limited: vendor, rate_limit (the vendor's rate-limit status), error.
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
ProjectId string `json:"project_id"`
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||
@@ -793,25 +852,17 @@ type InboxRespond struct {
|
||||
// InboxResponse defines model for InboxResponse.
|
||||
type InboxResponse struct {
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
|
||||
// Client run: the host name of the Mac that ran the command.
|
||||
Client *string `json:"client,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
|
||||
|
||||
// ExitCode run: the command's exit status.
|
||||
ExitCode *int `json:"exit_code,omitempty"`
|
||||
Id string `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
|
||||
Id string `json:"id"`
|
||||
|
||||
// Images Blob ids sent to the agent as images alongside the response.
|
||||
Images []string `json:"images"`
|
||||
ItemId string `json:"item_id"`
|
||||
Options *[]string `json:"options,omitempty"`
|
||||
|
||||
// Text run: the command's output, at most its last 64 KiB.
|
||||
Text *string `json:"text,omitempty"`
|
||||
Type InboxResponseType `json:"type"`
|
||||
User string `json:"user"`
|
||||
Images []string `json:"images"`
|
||||
ItemId string `json:"item_id"`
|
||||
Options *[]string `json:"options,omitempty"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
Type InboxResponseType `json:"type"`
|
||||
User string `json:"user"`
|
||||
}
|
||||
|
||||
// InboxResponseCreate defines model for InboxResponseCreate.
|
||||
@@ -975,16 +1026,6 @@ type Review struct {
|
||||
// ReviewState skipped: the snapshot's step changed no files, so no reviewer ran.
|
||||
type ReviewState string
|
||||
|
||||
// RunReport defines model for RunReport.
|
||||
type RunReport struct {
|
||||
Client string `json:"client"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Output string `json:"output"`
|
||||
|
||||
// RunId The Mac's id for this one run, used as the response's id; reporting it again records nothing new.
|
||||
RunId string `json:"run_id"`
|
||||
}
|
||||
|
||||
// SessionCreate defines model for SessionCreate.
|
||||
type SessionCreate struct {
|
||||
// Images Blob ids from uploadBlob, sent to the agent as images alongside the prompt.
|
||||
@@ -1441,9 +1482,6 @@ type GetTaskTerminalParams struct {
|
||||
// RespondInboxJSONRequestBody defines body for RespondInbox for application/json ContentType.
|
||||
type RespondInboxJSONRequestBody = InboxRespond
|
||||
|
||||
// ReportRunJSONRequestBody defines body for ReportRun for application/json ContentType.
|
||||
type ReportRunJSONRequestBody = RunReport
|
||||
|
||||
// UpdateMeJSONRequestBody defines body for UpdateMe for application/json ContentType.
|
||||
type UpdateMeJSONRequestBody = UserUpdate
|
||||
|
||||
@@ -1495,6 +1533,12 @@ type ServerInterface interface {
|
||||
// (GET /api/blobs/{blobId})
|
||||
GetBlob(w http.ResponseWriter, r *http.Request, blobId string)
|
||||
|
||||
// (GET /api/clients/connect)
|
||||
ConnectClient(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// (GET /api/clients/transfers/{transferId})
|
||||
GetClientTransfer(w http.ResponseWriter, r *http.Request, transferId string)
|
||||
|
||||
// (GET /api/events)
|
||||
StreamEvents(w http.ResponseWriter, r *http.Request, params StreamEventsParams)
|
||||
|
||||
@@ -1507,12 +1551,6 @@ type ServerInterface interface {
|
||||
// (GET /api/inbox/{itemId})
|
||||
GetInboxItem(w http.ResponseWriter, r *http.Request, itemId string)
|
||||
|
||||
// (GET /api/inbox/{itemId}/bundle)
|
||||
GetRunBundle(w http.ResponseWriter, r *http.Request, itemId string)
|
||||
|
||||
// (POST /api/inbox/{itemId}/runs)
|
||||
ReportRun(w http.ResponseWriter, r *http.Request, itemId string)
|
||||
|
||||
// (GET /api/me)
|
||||
GetMe(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
@@ -1736,6 +1774,46 @@ func (siw *ServerInterfaceWrapper) GetBlob(w http.ResponseWriter, r *http.Reques
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// ConnectClient operation middleware
|
||||
func (siw *ServerInterfaceWrapper) ConnectClient(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.ConnectClient(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetClientTransfer operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetClientTransfer(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "transferId" -------------
|
||||
var transferId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "transferId", r.PathValue("transferId"), &transferId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "transferId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetClientTransfer(w, r, transferId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// StreamEvents operation middleware
|
||||
func (siw *ServerInterfaceWrapper) StreamEvents(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1915,58 +1993,6 @@ func (siw *ServerInterfaceWrapper) GetInboxItem(w http.ResponseWriter, r *http.R
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetRunBundle operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetRunBundle(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "itemId" -------------
|
||||
var itemId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "itemId", r.PathValue("itemId"), &itemId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "itemId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetRunBundle(w, r, itemId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// ReportRun operation middleware
|
||||
func (siw *ServerInterfaceWrapper) ReportRun(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "itemId" -------------
|
||||
var itemId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "itemId", r.PathValue("itemId"), &itemId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "itemId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.ReportRun(w, r, itemId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetMe operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -3612,8 +3638,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/blobs/{blobId}", wrapper.GetBlob)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/inbox", wrapper.ListInbox)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/inbox/{itemId}", wrapper.GetInboxItem)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/inbox/{itemId}/bundle", wrapper.GetRunBundle)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/inbox/{itemId}/runs", wrapper.ReportRun)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/clients/connect", wrapper.ConnectClient)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/clients/transfers/{transferId}", wrapper.GetClientTransfer)
|
||||
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)
|
||||
@@ -3630,238 +3656,242 @@ 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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",
|
||||
"IcrTpVneoBlbC8wO7vN7FZD59On/DgA=",
|
||||
"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",
|
||||
}
|
||||
|
||||
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
const (
|
||||
readLimit = 256 << 20
|
||||
pingInterval = 30 * time.Second
|
||||
pingTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Login string
|
||||
Host string
|
||||
Commit string
|
||||
ConnectedAt time.Time
|
||||
}
|
||||
|
||||
type Transfer struct {
|
||||
Dir string
|
||||
Name string
|
||||
}
|
||||
|
||||
type conn struct {
|
||||
Client
|
||||
ws *websocket.Conn
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
pending map[string]chan api.ClientReply
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.Mutex
|
||||
conns map[string]*conn
|
||||
transfers map[string]Transfer
|
||||
}
|
||||
|
||||
func New() *Registry {
|
||||
return &Registry{conns: map[string]*conn{}, transfers: map[string]Transfer{}}
|
||||
}
|
||||
|
||||
func (r *Registry) Serve(ctx context.Context, ws *websocket.Conn, client Client) {
|
||||
ws.SetReadLimit(readLimit)
|
||||
c := &conn{Client: client, ws: ws, done: make(chan struct{}), pending: map[string]chan api.ClientReply{}}
|
||||
|
||||
r.mu.Lock()
|
||||
old := r.conns[client.Host]
|
||||
r.conns[client.Host] = c
|
||||
r.mu.Unlock()
|
||||
if old != nil {
|
||||
old.ws.Close(websocket.StatusGoingAway, "replaced by a newer connection from this host")
|
||||
}
|
||||
slog.Info("client connected", "host", client.Host, "login", client.Login, "commit", client.Commit)
|
||||
|
||||
defer func() {
|
||||
r.mu.Lock()
|
||||
if r.conns[client.Host] == c {
|
||||
delete(r.conns, client.Host)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
close(c.done)
|
||||
slog.Info("client disconnected", "host", client.Host, "login", client.Login)
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go c.ping(ctx)
|
||||
|
||||
for {
|
||||
_, b, err := ws.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
reply := api.ClientReply{}
|
||||
err = json.Unmarshal(b, &reply)
|
||||
if err != nil {
|
||||
ws.Close(websocket.StatusUnsupportedData, "messages must be ClientReply")
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
ch := c.pending[reply.Id]
|
||||
delete(c.pending, reply.Id)
|
||||
c.mu.Unlock()
|
||||
if ch == nil {
|
||||
slog.Warn("client reply for no request", "host", client.Host, "id", reply.Id)
|
||||
continue
|
||||
}
|
||||
ch <- reply
|
||||
}
|
||||
}
|
||||
|
||||
func (c *conn) ping(ctx context.Context) {
|
||||
t := time.NewTicker(pingInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
pctx, cancel := context.WithTimeout(ctx, pingTimeout)
|
||||
err := c.ws.Ping(pctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
c.ws.Close(websocket.StatusGoingAway, "ping timed out")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) List() []Client {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := []Client{}
|
||||
for _, c := range r.conns {
|
||||
out = append(out, c.Client)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Registry) Call(ctx context.Context, host string, req api.ClientRequest) (api.ClientReply, error) {
|
||||
r.mu.Lock()
|
||||
c := r.conns[host]
|
||||
hosts := []string{}
|
||||
for h := range r.conns {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if c == nil {
|
||||
slices.Sort(hosts)
|
||||
return api.ClientReply{}, fmt.Errorf("no client named %q is connected; connected: %s", host, strings.Join(hosts, ", "))
|
||||
}
|
||||
|
||||
req.Id = rand.Text()
|
||||
ch := make(chan api.ClientReply, 1)
|
||||
c.mu.Lock()
|
||||
c.pending[req.Id] = ch
|
||||
c.mu.Unlock()
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, req.Id)
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return api.ClientReply{}, err
|
||||
}
|
||||
err = c.ws.Write(ctx, websocket.MessageText, data)
|
||||
if err != nil {
|
||||
return api.ClientReply{}, fmt.Errorf("send to %s: %w", host, err)
|
||||
}
|
||||
|
||||
select {
|
||||
case reply := <-ch:
|
||||
if reply.Error != nil {
|
||||
return reply, errors.New(*reply.Error)
|
||||
}
|
||||
return reply, nil
|
||||
case <-c.done:
|
||||
return api.ClientReply{}, fmt.Errorf("%s disconnected before replying", host)
|
||||
case <-ctx.Done():
|
||||
return api.ClientReply{}, fmt.Errorf("%s didn't reply: %w", host, ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) AddTransfer(t Transfer) string {
|
||||
id := rand.Text()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.transfers[id] = t
|
||||
return id
|
||||
}
|
||||
|
||||
func (r *Registry) RemoveTransfer(id string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.transfers, id)
|
||||
}
|
||||
|
||||
func (r *Registry) Transfer(id string) (Transfer, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
t, ok := r.transfers[id]
|
||||
return t, ok
|
||||
}
|
||||
@@ -744,11 +744,16 @@ func (s *Session) callTool(raw json.RawMessage) (any, error) {
|
||||
return nil, fmt.Errorf("unknown tool %s in %s", p.Tool, p.Namespace)
|
||||
}
|
||||
|
||||
text, err := t.Handler(context.Background(), p.Arguments)
|
||||
if err != nil {
|
||||
return DynamicToolCallResponse{ContentItems: []DynamicToolCallOutputContentItem{{Type: DynamicToolCallOutputContentItemTypeInputText, Text: err.Error()}}}, nil
|
||||
res := t.Call(context.Background(), p.Arguments)
|
||||
items := []DynamicToolCallOutputContentItem{}
|
||||
for _, c := range res.Content {
|
||||
if c.Type == "image" {
|
||||
items = append(items, DynamicToolCallOutputContentItem{Type: DynamicToolCallOutputContentItemTypeInputImage, ImageURL: "data:" + c.MimeType + ";base64," + c.Data})
|
||||
continue
|
||||
}
|
||||
items = append(items, DynamicToolCallOutputContentItem{Type: DynamicToolCallOutputContentItemTypeInputText, Text: c.Text})
|
||||
}
|
||||
return DynamicToolCallResponse{Success: true, ContentItems: []DynamicToolCallOutputContentItem{{Type: DynamicToolCallOutputContentItemTypeInputText, Text: text}}}, nil
|
||||
return DynamicToolCallResponse{Success: !res.IsError, ContentItems: items}, nil
|
||||
}
|
||||
|
||||
func (s *Session) badLine(line []byte, reason string) {
|
||||
|
||||
+36
-16
@@ -13,6 +13,41 @@ type Tool struct {
|
||||
Description string
|
||||
InputSchema json.RawMessage
|
||||
Handler func(ctx context.Context, args json.RawMessage) (string, error)
|
||||
Rich func(ctx context.Context, args json.RawMessage) Result
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Data string `json:"data"`
|
||||
MimeType string `json:"mimeType"`
|
||||
}
|
||||
|
||||
func (c Content) MarshalJSON() ([]byte, error) {
|
||||
if c.Type == "image" {
|
||||
return json.Marshal(map[string]string{"type": c.Type, "data": c.Data, "mimeType": c.MimeType})
|
||||
}
|
||||
return json.Marshal(map[string]string{"type": c.Type, "text": c.Text})
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Content []Content `json:"content"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
func TextResult(text string, isError bool) Result {
|
||||
return Result{Content: []Content{{Type: "text", Text: text}}, IsError: isError}
|
||||
}
|
||||
|
||||
func (t Tool) Call(ctx context.Context, args json.RawMessage) Result {
|
||||
if t.Rich != nil {
|
||||
return t.Rich(ctx, args)
|
||||
}
|
||||
text, err := t.Handler(ctx, args)
|
||||
if err != nil {
|
||||
return TextResult(err.Error(), true)
|
||||
}
|
||||
return TextResult(text, false)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -52,16 +87,6 @@ type toolDesc struct {
|
||||
InputSchema json.RawMessage `json:"inputSchema"`
|
||||
}
|
||||
|
||||
type content struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type callResult struct {
|
||||
Content []content `json:"content"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) Tool(name string) (Tool, bool) {
|
||||
for _, t := range s.Tools {
|
||||
if t.Name == name {
|
||||
@@ -128,10 +153,5 @@ func (s *Server) call(ctx context.Context, params json.RawMessage) (any, *rpcErr
|
||||
return nil, &rpcError{Code: -32602, Message: fmt.Sprintf("unknown tool: %s", p.Name)}
|
||||
}
|
||||
|
||||
text, err := t.Handler(ctx, p.Arguments)
|
||||
if err != nil {
|
||||
return callResult{Content: []content{{Type: "text", Text: err.Error()}}, IsError: true}, nil
|
||||
}
|
||||
|
||||
return callResult{Content: []content{{Type: "text", Text: text}}}, nil
|
||||
return t.Call(ctx, p.Arguments), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"g.fc.run/misc/byre/internal/clients"
|
||||
)
|
||||
|
||||
func (s *Server) ConnectClient(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Header.Get("Byre-Host")
|
||||
if host == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("missing Byre-Host"))
|
||||
return
|
||||
}
|
||||
|
||||
ws, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
slog.Warn("accept client websocket", "host", host, "err", err)
|
||||
return
|
||||
}
|
||||
defer ws.CloseNow()
|
||||
|
||||
s.clients.Serve(r.Context(), ws, clients.Client{
|
||||
Login: actor(r),
|
||||
Host: host,
|
||||
Commit: r.Header.Get("Byre-Commit"),
|
||||
ConnectedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) GetClientTransfer(w http.ResponseWriter, r *http.Request, transferID string) {
|
||||
t, ok := s.clients.Transfer(transferID)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, fmt.Errorf("no transfer %s", transferID))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
cmd := exec.CommandContext(r.Context(), "tar", "-czf", "-", "-C", t.Dir, "--", t.Name)
|
||||
cmd.Stdout = w
|
||||
stderr := &bytes.Buffer{}
|
||||
cmd.Stderr = stderr
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("stream client transfer", "transfer", transferID, "dir", t.Dir, "name", t.Name, "err", err, "stderr", stderr.String())
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
@@ -41,63 +36,6 @@ func (s *Server) GetInboxItem(w http.ResponseWriter, r *http.Request, itemID str
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) GetRunBundle(w http.ResponseWriter, r *http.Request, itemID string) {
|
||||
item, err := s.store.GetInboxItem(r.Context(), itemID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if item.Kind != api.ClientRun {
|
||||
writeError(w, http.StatusBadRequest, fmt.Errorf("item %s is a %s, not a client_run", itemID, item.Kind))
|
||||
return
|
||||
}
|
||||
if item.State != api.Open {
|
||||
writeError(w, http.StatusConflict, fmt.Errorf("run request %s is %s", itemID, item.State))
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(s.store.BundlePath(itemID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
http.ServeContent(w, r, "", info.ModTime(), f)
|
||||
}
|
||||
|
||||
func (s *Server) ReportRun(w http.ResponseWriter, r *http.Request, itemID string) {
|
||||
req := api.RunReport{}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
item, err := s.agents.ReportRun(r.Context(), actor(r), itemID, req)
|
||||
if err != nil {
|
||||
writeAgentError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) removeClosedBundles(events []api.Event) {
|
||||
for _, ev := range events {
|
||||
if ev.Kind != "inbox.updated" || ev.Payload["kind"] != string(api.ClientRun) || ev.Payload["state"] == string(api.Open) {
|
||||
continue
|
||||
}
|
||||
id, _ := ev.Payload["id"].(string)
|
||||
err := os.Remove(s.store.BundlePath(id))
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
slog.Error("remove run bundle", "item", id, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) RespondInbox(w http.ResponseWriter, r *http.Request) {
|
||||
req := api.InboxRespond{}
|
||||
if !readJSON(w, r, &req) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"g.fc.run/misc/byre/internal/agents"
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/clients"
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
"g.fc.run/misc/byre/internal/todo"
|
||||
@@ -26,6 +27,7 @@ type Server struct {
|
||||
store *store.Store
|
||||
agents *agents.Manager
|
||||
hub *hub
|
||||
clients *clients.Registry
|
||||
terminals *terminals
|
||||
startedAt time.Time
|
||||
|
||||
@@ -43,6 +45,7 @@ func New(dataDir string, portFirst int) (*Server, error) {
|
||||
|
||||
s := &Server{
|
||||
hub: newHub(),
|
||||
clients: clients.New(),
|
||||
terminals: newTerminals(),
|
||||
startedAt: time.Now().UTC(),
|
||||
users: map[string]string{},
|
||||
@@ -65,7 +68,7 @@ func New(dataDir string, portFirst int) (*Server, error) {
|
||||
}
|
||||
}
|
||||
|
||||
s.agents = agents.New(s.store, filepath.Join(dataDir, "worktrees"), filepath.Join(dataDir, "leads"), filepath.Join(dataDir, "codex"), s.status, s.createProject)
|
||||
s.agents = agents.New(s.store, filepath.Join(dataDir, "worktrees"), filepath.Join(dataDir, "leads"), filepath.Join(dataDir, "codex"), s.status, s.createProject, s.clients)
|
||||
err = s.agents.Recover(context.Background())
|
||||
if err != nil {
|
||||
s.store.Close()
|
||||
@@ -81,7 +84,6 @@ func (s *Server) publish(events []api.Event) {
|
||||
s.hub.publish(ev)
|
||||
}
|
||||
s.agents.Observe(events)
|
||||
s.removeClosedBundles(events)
|
||||
for _, ev := range events {
|
||||
if (ev.Kind == "task.merged" || ev.Kind == "task.discarded") && ev.TaskId != nil {
|
||||
s.terminals.close(*ev.TaskId)
|
||||
@@ -134,7 +136,7 @@ func (s *Server) identify(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
if (r.Method != http.MethodGet && !automaticRequests[r.Pattern]) || humanGets[r.Pattern] {
|
||||
if r.Method != http.MethodGet && !automaticRequests[r.Pattern] {
|
||||
s.acted(login, r)
|
||||
}
|
||||
|
||||
@@ -155,11 +157,6 @@ func (s *Server) clientsChanged() {
|
||||
var automaticRequests = map[string]bool{
|
||||
"PUT /api/me/pane-focus": true,
|
||||
"PUT /api/tasks/{taskId}/summary-read": true,
|
||||
"POST /api/inbox/{itemId}/runs": true,
|
||||
}
|
||||
|
||||
var humanGets = map[string]bool{
|
||||
"GET /api/inbox/{itemId}/bundle": true,
|
||||
}
|
||||
|
||||
func (s *Server) ensureUser(ctx context.Context, login, name string) error {
|
||||
|
||||
+5
-40
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,7 +14,7 @@ import (
|
||||
|
||||
func DeliverableResponse(t api.InboxResponseType) bool {
|
||||
switch t {
|
||||
case api.Option, api.Other, api.Run:
|
||||
case api.Option, api.Other:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -168,31 +167,6 @@ func (s *Store) Respond(ctx context.Context, actor string, resolutions []Resolut
|
||||
|
||||
var ErrNotOpen = errors.New("inbox item is not open")
|
||||
|
||||
func (s *Store) AddRunResponse(ctx context.Context, resp api.InboxResponse) (bool, error) {
|
||||
added := false
|
||||
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO inbox_responses (id, item_id, user_login, type, text, client, exit_code, images, attachments, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '[]', '[]', ?)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, resp.Id, resp.ItemId, resp.User, string(api.Run), resp.Text, *resp.Client, *resp.ExitCode, formatTime(resp.CreatedAt))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil || n == 0 {
|
||||
return err
|
||||
}
|
||||
added = true
|
||||
return emitItem(ctx, tx, emit, "inbox.updated", resp.ItemId, resp.User)
|
||||
})
|
||||
return added, err
|
||||
}
|
||||
|
||||
func (s *Store) BundlePath(itemID string) string {
|
||||
return filepath.Join(s.dataDir(), "bundles", itemID+".tar.gz")
|
||||
}
|
||||
|
||||
func (s *Store) WithdrawItem(ctx context.Context, id string) error {
|
||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
res, err := tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_at = ? WHERE id = ? AND state = ?`,
|
||||
@@ -261,9 +235,9 @@ type Pending struct {
|
||||
func (s *Store) Undelivered(ctx context.Context, sessionID string) ([]Pending, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT r.id, r.item_id FROM inbox_responses r JOIN inbox_items i ON i.id = r.item_id
|
||||
WHERE i.agent_session_id = ? AND r.delivered_at IS NULL AND r.type IN (?, ?, ?)
|
||||
WHERE i.agent_session_id = ? AND r.delivered_at IS NULL AND r.type IN (?, ?)
|
||||
ORDER BY r.created_at
|
||||
`, sessionID, string(api.Option), string(api.Other), string(api.Run))
|
||||
`, sessionID, string(api.Option), string(api.Other))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -373,7 +347,7 @@ func getItem(ctx context.Context, q querier, id string) (api.InboxItem, error) {
|
||||
|
||||
func getResponses(ctx context.Context, q querier, itemID string) ([]api.InboxResponse, error) {
|
||||
rows, err := q.QueryContext(ctx, `
|
||||
SELECT id, item_id, user_login, type, options, text, client, exit_code, images, attachments, created_at, delivered_at
|
||||
SELECT id, item_id, user_login, type, options, text, images, attachments, created_at, delivered_at
|
||||
FROM inbox_responses WHERE item_id = ? ORDER BY created_at
|
||||
`, itemID)
|
||||
if err != nil {
|
||||
@@ -387,24 +361,15 @@ func getResponses(ctx context.Context, q querier, itemID string) ([]api.InboxRes
|
||||
typ := ""
|
||||
options := sql.NullString{}
|
||||
text := sql.NullString{}
|
||||
client := ""
|
||||
exitCode := sql.NullInt64{}
|
||||
images := ""
|
||||
attachments := ""
|
||||
createdAt := ""
|
||||
delivered := sql.NullString{}
|
||||
|
||||
err = rows.Scan(&r.Id, &r.ItemId, &r.User, &typ, &options, &text, &client, &exitCode, &images, &attachments, &createdAt, &delivered)
|
||||
err = rows.Scan(&r.Id, &r.ItemId, &r.User, &typ, &options, &text, &images, &attachments, &createdAt, &delivered)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if client != "" {
|
||||
r.Client = &client
|
||||
}
|
||||
if exitCode.Valid {
|
||||
code := int(exitCode.Int64)
|
||||
r.ExitCode = &code
|
||||
}
|
||||
|
||||
r.Type = api.InboxResponseType(typ)
|
||||
if options.Valid {
|
||||
|
||||
@@ -469,6 +469,11 @@ ALTER TABLE agent_sessions ADD COLUMN subagents INTEGER NOT NULL DEFAULT 0;
|
||||
`}, {"add-inbox-run-responses", `
|
||||
ALTER TABLE inbox_responses ADD COLUMN client TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE inbox_responses ADD COLUMN exit_code INTEGER;
|
||||
`}, {"drop-client-runs", `
|
||||
DELETE FROM inbox_responses WHERE item_id IN (SELECT id FROM inbox_items WHERE kind = 'client_run');
|
||||
DELETE FROM inbox_items WHERE kind = 'client_run';
|
||||
ALTER TABLE inbox_responses DROP COLUMN client;
|
||||
ALTER TABLE inbox_responses DROP COLUMN exit_code;
|
||||
`}}
|
||||
|
||||
const (
|
||||
|
||||
@@ -20,5 +20,7 @@
|
||||
<string>27.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>byre's agents drive Orion on this Mac through AppleScript when a task asks them to.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import ByreAPI
|
||||
import Foundation
|
||||
|
||||
private let maxOutput = 64 << 10
|
||||
|
||||
private let runsDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appending(path: "run.fc.byre.mac/runs", directoryHint: .isDirectory)
|
||||
|
||||
@MainActor
|
||||
private var watching: Set<String> = []
|
||||
|
||||
@MainActor
|
||||
func runOnThisMac(_ api: API, _ item: InboxItem) async throws {
|
||||
let dir = runsDir.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
watching.insert(dir.path)
|
||||
do {
|
||||
try await start(api, item, dir)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: dir)
|
||||
watching.remove(dir.path)
|
||||
throw error
|
||||
}
|
||||
poll(api, dir)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func start(_ api: API, _ item: InboxItem, _ dir: URL) async throws {
|
||||
let files = dir.appending(path: "files", directoryHint: .isDirectory)
|
||||
try FileManager.default.createDirectory(at: files, withIntermediateDirectories: true)
|
||||
try item.id.write(to: dir.appending(path: "item"), atomically: true, encoding: .utf8)
|
||||
try api.origin.absoluteString.write(to: dir.appending(path: "server"), atomically: true, encoding: .utf8)
|
||||
try str(item.payload.value, "command")!.write(to: dir.appending(path: "command.sh"), atomically: true, encoding: .utf8)
|
||||
|
||||
let (bundle, response) = try await api.session.download(from: api.url("/api/inbox/\(item.id)/bundle"))
|
||||
let code = (response as! HTTPURLResponse).statusCode
|
||||
if code != 200 {
|
||||
let text = (try? String(contentsOf: bundle, encoding: .utf8)) ?? ""
|
||||
throw ApiError(status: code, message: "download bundle: \(code) \(text)")
|
||||
}
|
||||
try await untar(bundle, into: files, errors: dir.appending(path: "untar.log"))
|
||||
try? FileManager.default.removeItem(at: bundle)
|
||||
|
||||
let p = Process()
|
||||
p.executableURL = URL(filePath: "/usr/bin/lockf")
|
||||
p.arguments = ["-k", "lock", "/bin/zsh", "-c", "cd files && /bin/zsh -l ../command.sh >../output.log 2>&1; echo $? >../exit.tmp; mv ../exit.tmp ../exit"]
|
||||
p.currentDirectoryURL = dir
|
||||
p.standardInput = FileHandle.nullDevice
|
||||
p.standardOutput = FileHandle.nullDevice
|
||||
p.standardError = FileHandle.nullDevice
|
||||
try p.run()
|
||||
let lock = dir.appending(path: "lock")
|
||||
while !FileManager.default.fileExists(atPath: lock.path) {
|
||||
if !p.isRunning {
|
||||
throw ApiError(status: 0, message: "start command: lockf exited \(p.terminationStatus) before taking its lock")
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
log.info("client run started: item \(item.id, privacy: .public) in \(dir.path, privacy: .public)")
|
||||
}
|
||||
|
||||
private func untar(_ bundle: URL, into dir: URL, errors: URL) async throws {
|
||||
FileManager.default.createFile(atPath: errors.path, contents: nil)
|
||||
let errFile = try FileHandle(forWritingTo: errors)
|
||||
defer {
|
||||
try? errFile.close()
|
||||
}
|
||||
let p = Process()
|
||||
p.executableURL = URL(filePath: "/usr/bin/tar")
|
||||
p.arguments = ["-xzf", bundle.path, "-C", dir.path]
|
||||
p.standardError = errFile
|
||||
p.standardOutput = FileHandle.nullDevice
|
||||
try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, any Error>) in
|
||||
p.terminationHandler = { _ in
|
||||
c.resume()
|
||||
}
|
||||
do {
|
||||
try p.run()
|
||||
} catch {
|
||||
c.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
if p.terminationStatus != 0 {
|
||||
let text = tail(errors)
|
||||
throw ApiError(status: 0, message: "unpack bundle: tar exited \(p.terminationStatus): \(text)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func resumeClientRuns(_ api: API) {
|
||||
let dirs = (try? FileManager.default.contentsOfDirectory(at: runsDir, includingPropertiesForKeys: nil)) ?? []
|
||||
for dir in dirs where !watching.contains(dir.path) && (try? String(contentsOf: dir.appending(path: "server"), encoding: .utf8)) == api.origin.absoluteString {
|
||||
if !FileManager.default.fileExists(atPath: dir.appending(path: "lock").path) {
|
||||
log.info("client run never started; removing \(dir.path, privacy: .public)")
|
||||
try? FileManager.default.removeItem(at: dir)
|
||||
continue
|
||||
}
|
||||
watching.insert(dir.path)
|
||||
poll(api, dir)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func poll(_ api: API, _ dir: URL) {
|
||||
Task {
|
||||
let exitFile = dir.appending(path: "exit").path
|
||||
var interrupted = false
|
||||
while !FileManager.default.fileExists(atPath: exitFile) {
|
||||
if lockFree(dir.appending(path: "lock").path) {
|
||||
interrupted = !FileManager.default.fileExists(atPath: exitFile)
|
||||
break
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
}
|
||||
await report(api, dir, interrupted: interrupted)
|
||||
watching.remove(dir.path)
|
||||
}
|
||||
}
|
||||
|
||||
private func lockFree(_ path: String) -> Bool {
|
||||
guard let h = FileHandle(forReadingAtPath: path) else {
|
||||
return false
|
||||
}
|
||||
defer {
|
||||
try? h.close()
|
||||
}
|
||||
let fd = h.fileDescriptor
|
||||
if flock(fd, LOCK_EX | LOCK_NB) != 0 {
|
||||
return false
|
||||
}
|
||||
flock(fd, LOCK_UN)
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func report(_ api: API, _ dir: URL, interrupted: Bool) async {
|
||||
do {
|
||||
let itemId = trimmed(try String(contentsOf: dir.appending(path: "item"), encoding: .utf8))
|
||||
var exit = -1
|
||||
var output = tail(dir.appending(path: "output.log"))
|
||||
if interrupted {
|
||||
output += "\n[byre: the command stopped without finishing, for example because the Mac restarted]"
|
||||
} else {
|
||||
exit = Int(trimmed(try String(contentsOf: dir.appending(path: "exit"), encoding: .utf8))) ?? -1
|
||||
}
|
||||
let client = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||
_ = try await api.client.reportRun(path: .init(itemId: itemId), body: .json(.init(runId: dir.lastPathComponent, client: client, exitCode: exit, output: output))).ok
|
||||
log.info("client run reported: item \(itemId, privacy: .public) exit \(exit)")
|
||||
} catch {
|
||||
log.error("report client run \(dir.path, privacy: .public): \(errorText(error), privacy: .public)")
|
||||
guard let e = rootError(error) as? ApiError, (400..<500).contains(e.status) else {
|
||||
return
|
||||
}
|
||||
}
|
||||
try? FileManager.default.removeItem(at: dir)
|
||||
}
|
||||
|
||||
private func tail(_ file: URL) -> String {
|
||||
guard let h = try? FileHandle(forReadingFrom: file) else {
|
||||
return ""
|
||||
}
|
||||
defer {
|
||||
try? h.close()
|
||||
}
|
||||
let size = (try? h.seekToEnd()) ?? 0
|
||||
try? h.seek(toOffset: size > UInt64(maxOutput) ? size - UInt64(maxOutput) : 0)
|
||||
return String(decoding: (try? h.readToEnd()) ?? Data(), as: UTF8.self)
|
||||
}
|
||||
@@ -192,21 +192,6 @@ private final class CardModel {
|
||||
}
|
||||
}
|
||||
|
||||
func run() {
|
||||
if busy {
|
||||
return
|
||||
}
|
||||
busy = true
|
||||
Task {
|
||||
do {
|
||||
try await runOnThisMac(store.api, item)
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
func submitOther() {
|
||||
if typed.isEmpty && !hasImages {
|
||||
return
|
||||
@@ -304,29 +289,10 @@ private struct CardBody: View {
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
.padding(.vertical, 10)
|
||||
case .clientRun:
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
MarkdownView(blocks: parseMarkdown(jsString(p["why"] ?? "")), ctx: MDContext(taskId: item.taskId))
|
||||
MarkdownView(blocks: runDetails(p), ctx: MDContext(taskId: item.taskId))
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runDetails(_ p: JSONDict) -> [MDBlock] {
|
||||
let paths = (p["paths"] as? [(any Sendable)?] ?? []).compactMap { $0 as? String }
|
||||
let bytes = (p["bytes"] as? Int) ?? Int((p["bytes"] as? Double) ?? 0)
|
||||
var files: [MDInline] = [.text("No files.")]
|
||||
if !paths.isEmpty {
|
||||
files = [.text("Files (\(ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file))): ")]
|
||||
for (i, path) in paths.enumerated() {
|
||||
files += i == 0 ? [.code(path)] : [.text(", "), .code(path)]
|
||||
}
|
||||
}
|
||||
return [.code(jsString(p["command"] ?? "")), .paragraph(files)]
|
||||
}
|
||||
|
||||
private func attachments(_ v: (any Sendable)?) -> [Attachment] {
|
||||
guard let v else {
|
||||
return []
|
||||
@@ -485,19 +451,6 @@ private struct CardActions: View {
|
||||
}
|
||||
.disabled(model.busy)
|
||||
}
|
||||
} else if model.item.kind == .clientRun {
|
||||
HStack(spacing: 8) {
|
||||
Button("Dismiss") {
|
||||
model.submit(InboxResponseCreate(itemId: model.item.id, _type: .dismiss))
|
||||
}
|
||||
.buttonStyle(WebButtonStyle())
|
||||
Spacer()
|
||||
Button("Run on this Mac") {
|
||||
model.run()
|
||||
}
|
||||
.buttonStyle(WebButtonStyle(primary: true))
|
||||
}
|
||||
.disabled(model.busy)
|
||||
} else {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
TextEntry(model: model, label: "Reply…", placeholder: "Reply to the agent", shared: false, focus: focus) {
|
||||
@@ -643,19 +596,11 @@ private struct Thread: View {
|
||||
if !item.responses.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(item.responses, id: \.id) { r in
|
||||
if r._type == .run {
|
||||
Text("\(Text(r.user).font(Fonts.font(11, weight: 700))) ran it on \(r.client ?? ""): exit \(r.exitCode ?? -1)")
|
||||
.textSelection(.enabled)
|
||||
if let out = r.text, !trimmed(out).isEmpty {
|
||||
MarkdownView(blocks: [.details(summary: [.text("Output")], body: [.code(out)])], ctx: MDContext(taskId: item.taskId))
|
||||
}
|
||||
} else {
|
||||
let images = r.images.isEmpty ? "" : " (\(r.images.count == 1 ? "1 image" : "\(r.images.count) images"))"
|
||||
let rest = " \(r._type.rawValue.replacingOccurrences(of: "_", with: " "))\(r.options.map { ": \($0.joined(separator: "; "))" } ?? "")\(r.text.map { " — \($0)" } ?? "")\(images)"
|
||||
Text("\(Text(r.user).font(Fonts.font(11, weight: 700)))\(rest)")
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
let images = r.images.isEmpty ? "" : " (\(r.images.count == 1 ? "1 image" : "\(r.images.count) images"))"
|
||||
let rest = " \(r._type.rawValue.replacingOccurrences(of: "_", with: " "))\(r.options.map { ": \($0.joined(separator: "; "))" } ?? "")\(r.text.map { " — \($0)" } ?? "")\(images)"
|
||||
Text("\(Text(r.user).font(Fonts.font(11, weight: 700)))\(rest)")
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.font(Fonts.font(11))
|
||||
|
||||
@@ -43,7 +43,7 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate {
|
||||
|
||||
private func wants(_ p: NotificationPrefs, _ item: InboxItem) -> Bool {
|
||||
switch item.kind {
|
||||
case .question, .clientRun: p.questions
|
||||
case .question: p.questions
|
||||
case .sessionError: p.errors
|
||||
case .rateLimited: p.rateLimits
|
||||
}
|
||||
@@ -55,7 +55,6 @@ private func itemText(_ item: InboxItem) -> String {
|
||||
case .question: return str(p, "question") ?? ""
|
||||
case .sessionError: return "Error: \(str(p, "error") ?? "")"
|
||||
case .rateLimited: return "Rate limited"
|
||||
case .clientRun: return "Run on your Mac: \(str(p, "why") ?? "")"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import ByreAPI
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OpenAPIRuntime
|
||||
|
||||
typealias ClientRequest = Components.Schemas.ClientRequest
|
||||
typealias ClientReply = Components.Schemas.ClientReply
|
||||
|
||||
private let retryAfter = Duration.seconds(2)
|
||||
private let maxOutput = 64 << 10
|
||||
private let copyTimeoutMs = 10 * 60 * 1000
|
||||
|
||||
@MainActor
|
||||
final class Remote {
|
||||
let api: API
|
||||
private let hands = Hands()
|
||||
private var ws: URLSessionWebSocketTask?
|
||||
private var stopped = false
|
||||
|
||||
init(api: API) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !stopped else {
|
||||
return
|
||||
}
|
||||
let task = api.session.webSocketTask(with: api.url("/api/clients/connect", scheme: "wss"))
|
||||
ws = task
|
||||
task.resume()
|
||||
receive(task)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopped = true
|
||||
ws?.cancel(with: .normalClosure, reason: nil)
|
||||
ws = nil
|
||||
hands.stop()
|
||||
}
|
||||
|
||||
private func receive(_ task: URLSessionWebSocketTask) {
|
||||
task.receive { result in
|
||||
Task { @MainActor in
|
||||
guard self.ws === task, !self.stopped else {
|
||||
return
|
||||
}
|
||||
switch result {
|
||||
case .success(.string(let s)):
|
||||
self.handle(task, Data(s.utf8))
|
||||
self.receive(task)
|
||||
case .success(.data(let d)):
|
||||
self.handle(task, d)
|
||||
self.receive(task)
|
||||
case .success:
|
||||
self.receive(task)
|
||||
case .failure(let error):
|
||||
log.info("client connection closed: \(errorText(error), privacy: .public)")
|
||||
self.ws = nil
|
||||
try? await Task.sleep(for: retryAfter)
|
||||
self.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ task: URLSessionWebSocketTask, _ data: Data) {
|
||||
let req: ClientRequest
|
||||
do {
|
||||
req = try jsonDecoder().decode(ClientRequest.self, from: data)
|
||||
} catch {
|
||||
log.error("decode client request: \(String(describing: error), privacy: .public)")
|
||||
return
|
||||
}
|
||||
log.info("client request \(req.id, privacy: .public): \(req.op.rawValue, privacy: .public)")
|
||||
Task {
|
||||
var reply: ClientReply
|
||||
do {
|
||||
reply = try await perform(req)
|
||||
} catch {
|
||||
reply = ClientReply(id: req.id, error: errorText(error))
|
||||
}
|
||||
do {
|
||||
try await task.send(.string(String(decoding: try JSONEncoder().encode(reply), as: UTF8.self)))
|
||||
} catch {
|
||||
log.error("send client reply \(req.id, privacy: .public): \(errorText(error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func perform(_ req: ClientRequest) async throws -> ClientReply {
|
||||
switch req.op {
|
||||
case .bash:
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
let (code, output) = try await spawn(["/bin/zsh", "-l", "-c", req.command ?? ""], in: home, timeoutMs: req.timeoutMs ?? 120_000)
|
||||
return ClientReply(id: req.id, exitCode: code, output: output)
|
||||
case .copy:
|
||||
try await copy(transfer: req.transfer ?? "", to: req.dest ?? "")
|
||||
return ClientReply(id: req.id)
|
||||
case .hands:
|
||||
let result = try await hands.call(req.tool ?? "", req.arguments?.additionalProperties ?? OpenAPIObjectContainer())
|
||||
return ClientReply(id: req.id, result: .init(additionalProperties: result))
|
||||
}
|
||||
}
|
||||
|
||||
private func copy(transfer: String, to dest: String) async throws {
|
||||
let target = URL(filePath: (dest as NSString).expandingTildeInPath)
|
||||
let parent = target.deletingLastPathComponent()
|
||||
let fm = FileManager.default
|
||||
try fm.createDirectory(at: parent, withIntermediateDirectories: true)
|
||||
|
||||
let (archive, response) = try await api.session.download(from: api.url("/api/clients/transfers/\(transfer)"))
|
||||
defer {
|
||||
try? fm.removeItem(at: archive)
|
||||
}
|
||||
let code = (response as! HTTPURLResponse).statusCode
|
||||
if code != 200 {
|
||||
throw ApiError(status: code, message: "download transfer: \(code) \(String(decoding: (try? Data(contentsOf: archive)) ?? Data(), as: UTF8.self))")
|
||||
}
|
||||
|
||||
let tmp = parent.appending(path: ".byre-copy-\(UUID().uuidString)", directoryHint: .isDirectory)
|
||||
try fm.createDirectory(at: tmp, withIntermediateDirectories: false)
|
||||
defer {
|
||||
try? fm.removeItem(at: tmp)
|
||||
}
|
||||
let (status, output) = try await spawn(["/usr/bin/tar", "-xzf", archive.path, "-C", tmp.path], in: tmp.path, timeoutMs: copyTimeoutMs)
|
||||
if status != 0 {
|
||||
throw ApiError(status: 0, message: "unpack: tar exited \(status): \(output)")
|
||||
}
|
||||
let entries = try fm.contentsOfDirectory(at: tmp, includingPropertiesForKeys: nil)
|
||||
guard entries.count == 1 else {
|
||||
throw ApiError(status: 0, message: "unpack: the archive holds \(entries.count) entries, not 1")
|
||||
}
|
||||
if (try? fm.attributesOfItem(atPath: target.path)) != nil {
|
||||
try fm.removeItem(at: target)
|
||||
}
|
||||
try fm.moveItem(at: entries[0], to: target)
|
||||
}
|
||||
}
|
||||
|
||||
private final class Flag: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var value = false
|
||||
|
||||
func set() -> Bool {
|
||||
lock.withLock {
|
||||
let was = value
|
||||
value = true
|
||||
return !was
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func spawn(_ argv: [String], in dir: String, timeoutMs: Int) async throws -> (Int, String) {
|
||||
let out = FileManager.default.temporaryDirectory.appending(path: "byre-\(UUID().uuidString).log")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: out)
|
||||
}
|
||||
|
||||
var attr: posix_spawnattr_t?
|
||||
unsafe posix_spawnattr_init(&attr)
|
||||
defer {
|
||||
unsafe posix_spawnattr_destroy(&attr)
|
||||
}
|
||||
unsafe posix_spawnattr_setflags(&attr, Int16(POSIX_SPAWN_SETPGROUP))
|
||||
unsafe posix_spawnattr_setpgroup(&attr, 0)
|
||||
|
||||
var actions: posix_spawn_file_actions_t?
|
||||
unsafe posix_spawn_file_actions_init(&actions)
|
||||
defer {
|
||||
unsafe posix_spawn_file_actions_destroy(&actions)
|
||||
}
|
||||
unsafe posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0)
|
||||
unsafe posix_spawn_file_actions_addopen(&actions, 1, out.path, O_WRONLY | O_CREAT | O_TRUNC, 0o600)
|
||||
unsafe posix_spawn_file_actions_adddup2(&actions, 1, 2)
|
||||
unsafe posix_spawn_file_actions_addchdir(&actions, dir)
|
||||
|
||||
var cargs = unsafe argv.map { unsafe strdup($0) } + [nil]
|
||||
defer {
|
||||
for unsafe p in unsafe cargs {
|
||||
unsafe free(p)
|
||||
}
|
||||
}
|
||||
var spawned: pid_t = 0
|
||||
let rc = unsafe posix_spawn(&spawned, argv[0], &actions, &attr, &cargs, environ)
|
||||
if rc != 0 {
|
||||
throw ApiError(status: 0, message: "start \(argv[0]): \(unsafe String(cString: strerror(rc)))")
|
||||
}
|
||||
let pid = spawned
|
||||
|
||||
let done = Flag()
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(timeoutMs)) {
|
||||
if done.set() {
|
||||
kill(-pid, SIGKILL)
|
||||
}
|
||||
}
|
||||
let status: Int32 = await withCheckedContinuation { c in
|
||||
DispatchQueue.global().async {
|
||||
var s: Int32 = 0
|
||||
while unsafe waitpid(pid, &s, 0) == -1 && errno == EINTR {}
|
||||
c.resume(returning: s)
|
||||
}
|
||||
}
|
||||
let timedOut = !done.set()
|
||||
|
||||
let output = tail(out)
|
||||
if timedOut {
|
||||
return (-1, output)
|
||||
}
|
||||
if status & 0x7f == 0 {
|
||||
return (Int((status >> 8) & 0xff), output)
|
||||
}
|
||||
return (128 + Int(status & 0x7f), output)
|
||||
}
|
||||
|
||||
private func tail(_ file: URL) -> String {
|
||||
guard let h = try? FileHandle(forReadingFrom: file) else {
|
||||
return ""
|
||||
}
|
||||
defer {
|
||||
try? h.close()
|
||||
}
|
||||
let size = (try? h.seekToEnd()) ?? 0
|
||||
try? h.seek(toOffset: size > UInt64(maxOutput) ? size - UInt64(maxOutput) : 0)
|
||||
return String(decoding: (try? h.readToEnd()) ?? Data(), as: UTF8.self)
|
||||
}
|
||||
|
||||
private struct HandsRequest: Encodable {
|
||||
struct Params: Encodable {
|
||||
let name: String
|
||||
let arguments: OpenAPIObjectContainer
|
||||
}
|
||||
|
||||
let jsonrpc = "2.0"
|
||||
let id: Int
|
||||
let method = "tools/call"
|
||||
let params: Params
|
||||
}
|
||||
|
||||
private struct HandsResponse: Decodable {
|
||||
struct RPCError: Decodable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
let id: Int
|
||||
let result: OpenAPIObjectContainer?
|
||||
let error: RPCError?
|
||||
}
|
||||
|
||||
private final class Helper: @unchecked Sendable {
|
||||
let process = Process()
|
||||
let input: FileHandle
|
||||
var buffer = Data()
|
||||
var pending: [Int: CheckedContinuation<OpenAPIObjectContainer, any Error>] = [:]
|
||||
|
||||
init(input: FileHandle) {
|
||||
self.input = input
|
||||
}
|
||||
}
|
||||
|
||||
private final class Hands: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var helper: Helper?
|
||||
private var next = 0
|
||||
|
||||
func call(_ tool: String, _ arguments: OpenAPIObjectContainer) async throws -> OpenAPIObjectContainer {
|
||||
try await withCheckedThrowingContinuation { c in
|
||||
lock.withLock {
|
||||
next += 1
|
||||
let id = next
|
||||
var target: Helper?
|
||||
do {
|
||||
let h = try running()
|
||||
target = h
|
||||
var line = try JSONEncoder().encode(HandsRequest(id: id, params: .init(name: tool, arguments: arguments)))
|
||||
line.append(0x0a)
|
||||
h.pending[id] = c
|
||||
try h.input.write(contentsOf: line)
|
||||
} catch {
|
||||
target?.pending[id] = nil
|
||||
c.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
lock.withLock {
|
||||
helper?.process.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
private func running() throws -> Helper {
|
||||
if let helper, helper.process.isRunning {
|
||||
return helper
|
||||
}
|
||||
signal(SIGPIPE, SIG_IGN)
|
||||
let stdin = Pipe()
|
||||
let stdout = Pipe()
|
||||
let h = Helper(input: stdin.fileHandleForWriting)
|
||||
let p = h.process
|
||||
p.executableURL = Bundle.main.executableURL!.deletingLastPathComponent().appending(path: "byre-hands")
|
||||
p.standardInput = stdin
|
||||
p.standardOutput = stdout
|
||||
p.standardError = FileHandle.nullDevice
|
||||
stdout.fileHandleForReading.readabilityHandler = { [weak self] fh in
|
||||
self?.read(h, fh.availableData)
|
||||
}
|
||||
p.terminationHandler = { [weak self] _ in
|
||||
self?.exited(h)
|
||||
}
|
||||
try p.run()
|
||||
log.info("byre-hands started: pid \(p.processIdentifier)")
|
||||
helper = h
|
||||
return h
|
||||
}
|
||||
|
||||
private func read(_ h: Helper, _ data: Data) {
|
||||
let lines: [Data] = lock.withLock {
|
||||
h.buffer.append(data)
|
||||
var out: [Data] = []
|
||||
while let nl = h.buffer.firstIndex(of: 0x0a) {
|
||||
out.append(h.buffer[h.buffer.startIndex..<nl])
|
||||
h.buffer = Data(h.buffer[(nl + 1)...])
|
||||
}
|
||||
return out
|
||||
}
|
||||
for line in lines {
|
||||
guard let resp = try? JSONDecoder().decode(HandsResponse.self, from: line) else {
|
||||
log.error("byre-hands sent an unreadable line")
|
||||
continue
|
||||
}
|
||||
let c = lock.withLock {
|
||||
h.pending.removeValue(forKey: resp.id)
|
||||
}
|
||||
if let result = resp.result {
|
||||
c?.resume(returning: result)
|
||||
} else {
|
||||
c?.resume(throwing: ApiError(status: 0, message: "byre-hands: \(resp.error?.message ?? "no result")"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exited(_ h: Helper) {
|
||||
let waiting = lock.withLock {
|
||||
let all = h.pending
|
||||
h.pending = [:]
|
||||
if helper === h {
|
||||
helper = nil
|
||||
}
|
||||
return all
|
||||
}
|
||||
let status = h.process.terminationStatus
|
||||
log.info("byre-hands exited: status \(status)")
|
||||
for c in waiting.values {
|
||||
c.resume(throwing: ApiError(status: 0, message: "byre-hands exited with status \(status) before replying"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ final class Connection {
|
||||
}
|
||||
phase = .running(store)
|
||||
store.startGlobalStream(after: status.lastEventSeq)
|
||||
resumeClientRuns(store.api)
|
||||
store.remote.start()
|
||||
Notifier.shared.deliver()
|
||||
} catch {
|
||||
await store.stop()
|
||||
|
||||
@@ -50,6 +50,7 @@ final class Store {
|
||||
let images = ImageDrafts()
|
||||
let events = Listeners<ByreEvent>()
|
||||
let paneFocus: PaneFocus
|
||||
let remote: Remote
|
||||
|
||||
private var taskFeeds: [String: Feed] = [:]
|
||||
@ObservationIgnored private var historyLoads: Set<ObjectIdentifier> = []
|
||||
@@ -62,12 +63,14 @@ final class Store {
|
||||
self.api = api
|
||||
leadFeed = Feed(shown: { true })
|
||||
paneFocus = PaneFocus(api: api)
|
||||
remote = Remote(api: api)
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
stopped = true
|
||||
globalStream?.stop()
|
||||
leadStream?.stop()
|
||||
remote.stop()
|
||||
drafts?.flush()
|
||||
await paneFocus.leave()
|
||||
}
|
||||
@@ -163,9 +166,6 @@ final class Store {
|
||||
if open.contains(where: { $0.kind == .question }) {
|
||||
return TaskState(kind: .answer, label: "Needs your answer", title: "The agent asked a question in the inbox")
|
||||
}
|
||||
if open.contains(where: { $0.kind == .clientRun }) {
|
||||
return TaskState(kind: .answer, label: "Ready to run", title: "The agent asked to run something on your Mac; see the inbox")
|
||||
}
|
||||
if task.merging {
|
||||
return TaskState(kind: .merging, label: "Merging", title: "The merge turn is running")
|
||||
}
|
||||
|
||||
+6
-2
@@ -9,10 +9,12 @@ svg=$mac/icon.svg
|
||||
icns=$build/byre.icns
|
||||
mkdir -p "$shared" "$build"
|
||||
lockf -k "$shared.lock" sh -ec '
|
||||
rsync -rlc --delete --exclude /mac/.build "$1/mac" "$1/api" "$2/"
|
||||
rsync -rlc --delete --exclude /mac/.build --exclude /hands/.build "$1/mac" "$1/api" "$1/hands" "$2/"
|
||||
swift build --package-path "$2/mac" --configuration release --no-color-diagnostics
|
||||
swift build --package-path "$2/hands" --configuration release --no-color-diagnostics
|
||||
cp "$2/mac/.build/release/Byre" "$3"
|
||||
' sh "$root" "$shared" "$build/Byre"
|
||||
cp "$2/hands/.build/release/byre-hands" "$4"
|
||||
' sh "$root" "$shared" "$build/Byre" "$build/byre-hands"
|
||||
if [ ! "$icns" -nt "$svg" ]; then
|
||||
iconset=$build/byre.iconset
|
||||
rm -rf "$iconset"
|
||||
@@ -26,6 +28,8 @@ fi
|
||||
rm -rf "$app"
|
||||
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources/fonts"
|
||||
cp "$build/Byre" "$app/Contents/MacOS/Byre"
|
||||
cp "$build/byre-hands" "$app/Contents/MacOS/byre-hands"
|
||||
codesign --force --sign "Apple Development" --identifier run.fc.byre.hands "$app/Contents/MacOS/byre-hands"
|
||||
cp "$mac/Info.plist" "$app/Contents/Info.plist"
|
||||
plutil -insert ByreCommit -string "$(git -C "$root" rev-parse HEAD)" "$app/Contents/Info.plist"
|
||||
cp "$mac"/fonts/*.woff2 "$app/Contents/Resources/fonts/"
|
||||
|
||||
Reference in New Issue
Block a user