diff --git a/docs/macos.md b/docs/macos.md index 8cfe870..1b8b4e3 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -41,7 +41,7 @@ SwiftUI's own macros don't build here: the SDK declares `@State`, `@Entry`, `@An | --- | --- | | `API.swift`, `EventStream.swift` | The generated client over `URLSession`; the SSE client | | `Store.swift`, `Feed.swift` | The one `@Observable` store, `handleEvent`, per-task feeds, focus | -| `ByreApp.swift`, `Startup.swift` | The `App`, menus, the saved servers and their connections (`Servers`, `Connection`), server windows, the connection screen, startup sequence | +| `ByreApp.swift`, `Startup.swift` | The `App`, menus, the saved servers and their connections (`Servers`, `Connection`), the window and its server picker, the connection screen, startup sequence | | `Panes.swift`, `PaneFocus.swift` | The two panes, tab strips, More menu, pane-focus reporting | | `Chat.swift`, `TranscriptBuilder.swift`, `Composer.swift`, `MergeDialog.swift` | Transcript and composer for tasks and the lead | | `Markdown.swift`, `MarkdownView.swift` | byre's Markdown parser to a block tree, and its views | @@ -82,18 +82,18 @@ Focus lives in `Store`. In-app `#/` links go through `Store.route`: `#/task/ ## Window and layout -One window per saved server, sharing no UI: the windows are a `WindowGroup` (id `main`) whose value is the server's origin, so opening a server's window again brings forward the one already open. The windows are tabs of one window: each sets `tabbingMode = .preferred` and, once shown, joins the tab group of another visible server window (`addTabbedWindow`), so the windows opened at launch end up tabbed together as New Server Window's do. `addTabbedWindow` puts the new tab right after the host's, so the order would follow which windows came up first; instead, each time a window joins or its server changes, `Servers.arrange` moves the tabs with a server into saved order (`ServerRecord.saved`, the order Previous and Next Server follow) with `NSWindowTabGroup.insertWindow`, then restores the selected tab, which `insertWindow` changes. Tabs with no server stay after them. Each window has its own `Overlays`, so its switcher, dialogs, left-pane tab and key monitor act only on it, and its own pane-focus reporting. The window's title is the server's host, so the Window menu lists the servers, and its content sits inside a 3-point border in the server's colour (`Servers.color`, one of the task colours, picked from a row of swatches in Settings under Window colour and kept in the server's `ServerRecord`, so every Mac shows the same colour). A window with no server (New Server Window, or after Forget Server) shows the connection screen with no border. The panes follow the "UI layout" rules in `AGENTS.md`: an `HStack` of two panes of exactly equal width at every size, no sidebar, no navbar, nothing above them but the identity banner when the user has no git identity. The title bar is the standard one; no byre controls go in it or in a toolbar. +One window (a `Window` scene, id `main`) shows one saved server at a time: `Servers.current`, the origin it shows, or nil for the connection screen. The title bar holds no title, only the server picker (`ServerPicker`, a toolbar item in the principal placement): the current server's colour dot and short name (its host up to the first dot, `serverName`), or Add Server with none. Its menu lists every saved server in saved order (`ServerRecord.saved`, the order Previous and Next Server follow), then Add Server…, which shows the connection screen. Switching servers swaps the window's content; every connection keeps running in the background. Each server has its own `Overlays`, held by its `Connection`, so its switcher, dialogs and left-pane tab survive switching away and back, and its `MainView` is rebuilt (`.id(origin)`) so no view state carries from one server to another. The content sits inside a 3-point border in the server's colour (`Servers.color`, one of the task colours, picked from a row of swatches in Settings under Window colour and kept in the server's `ServerRecord`, so every Mac shows the same colour). The connection screen has no border. The panes follow the "UI layout" rules in `AGENTS.md`: an `HStack` of two panes of exactly equal width at every size, no sidebar, no navbar, nothing above them but the identity banner when the user has no git identity. -At launch the first window takes the oldest saved server and opens a window for each of the others. A CloudKit import that brings a server saved on another Mac starts its connection, and a window showing an empty connection screen takes it. SwiftUI's own window restoration is off (`restorationBehavior(.disabled)`); the scene keeps the id `main` that the single-window builds used, because AppKit restores their saved window by that id and SwiftUI then opens no launch window at all when the id names no scene. +At launch the window shows the oldest saved server. A CloudKit import that brings a server saved on another Mac starts its connection, and the window takes it while it shows an empty connection screen. A CloudKit import that removes the current server switches to the first saved one. SwiftUI's own window restoration is off (`restorationBehavior(.disabled)`); the scene keeps the id `main` that earlier builds used, because AppKit restores their saved window by that id and SwiftUI then opens no launch window at all when the id names no scene. - **Left pane:** tabs Lead, Status, Server, Settings, with "reconnecting…", the connection dot and the user's name at the end of the strip. - **Right pane:** the task bar (task picker ` ▸ ▾` and the preview links), then the task's tabs in the server's focus order, hiding Questions, Reviews and Workflow when they have nothing, with a **More ▾** menu for tabs that don't fit, carrying their badges. The pane's accent is the selected task's colour. Overlays (the switcher, alerts, confirmations, the merge dialog, keyboard help) are SwiftUI views drawn above the panes inside the window, not system sheets or alerts. The switcher and the Discard confirmation sit just below their anchor; the merge dialog is centred in the right pane. -Overlays live in `Overlays` (`Dialogs.swift`), one environment object for each window. An open overlay takes Escape (closing the topmost: alert, modal, image, switcher step or switcher, More menu) and Enter (the alert's OK, the modal's last button when its `canSubmit` holds, the switcher's highlighted row) through a local key monitor, which handles only key events for its own window and passes Enter on while an input method holds marked text. The switcher also takes ↑ and ↓ there. The left pane's selected tab lives in `Overlays` too, so ⌘L can switch to Lead and focus its composer. +Overlays live in `Overlays` (`Dialogs.swift`), one environment object for each server. An open overlay takes Escape (closing the topmost: alert, modal, image, switcher step or switcher, More menu) and Enter (the alert's OK, the modal's last button when its `canSubmit` holds, the switcher's highlighted row) through a local key monitor, which handles only key events for its own window and passes Enter on while an input method holds marked text. The switcher also takes ↑ and ↓ there. The left pane's selected tab lives in `Overlays` too, so ⌘L can switch to Lead and focus its composer. -Connections belong to `Servers`, not to windows: closing a server's window leaves its store and streams running (and the app running after the last window, `applicationShouldTerminateAfterLastWindowClosed` returns false), so its notifications keep coming. Clicking the Dock icon with no window showing opens every saved server's window again (`applicationShouldHandleReopen`), and File → Open Server opens one. Quit flushes every server's drafts and reports pane focus false before exiting. +Connections belong to `Servers`, not to the window: every saved server's store and streams run whichever one the window shows, and closing the window leaves them all running (and the app running, `applicationShouldTerminateAfterLastWindowClosed` returns false), so notifications keep coming. Clicking the Dock icon with no window showing opens it again (`applicationShouldHandleReopen`). Quit flushes every server's drafts and reports pane focus false before exiting. ## Views @@ -137,7 +137,7 @@ Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/ima ## Notifications -`Notifications.swift` posts through `UNUserNotificationCenter`: the preferences from `GET /api/me`, the events (`inbox.created` by kind, `turn.ended` with `summary: true`), title `byre · <task title>`, body the first line cut at 200 characters, `task_id` and `origin` (the server's) in its user info, and the item id, `turn-<seq>@<origin>` or `error-<seq>@<origin>` as the request identifier, so a repeat replaces the earlier one and two servers' event seqs don't collide. Every connected server notifies, its window open or not. They show as banners even while byre is the active app (`Notifier`, the centre's delegate, set at launch). Clicking one activates the app, opens or brings forward its server's window and focuses the task's Transcript. A notification left in Notification Center after quitting relaunches the app when clicked; `Notifier` holds its server and task until that server's startup has a store, then focuses it. Settings shows the authorization state (`This Mac shows notifications while byre is running.`, `This Mac doesn't show notifications from byre.` with a **Turn on for this Mac** button, which asks macOS for permission, or `Notifications are blocked for byre in System Settings.`). Nothing notifies while the app isn't running. +`Notifications.swift` posts through `UNUserNotificationCenter`: the preferences from `GET /api/me`, the events (`inbox.created` by kind, `turn.ended` with `summary: true`), title `byre · <task title>`, body the first line cut at 200 characters, `task_id` and `origin` (the server's) in its user info, and the item id, `turn-<seq>@<origin>` or `error-<seq>@<origin>` as the request identifier, so a repeat replaces the earlier one and two servers' event seqs don't collide. Every connected server notifies, its window open or not. They show as banners even while byre is the active app (`Notifier`, the centre's delegate, set at launch). Clicking one activates the app, opens the window on its server and focuses the task's Transcript. A notification left in Notification Center after quitting relaunches the app when clicked; `Notifier` holds its server and task until that server's startup has a store, then focuses it. Settings shows the authorization state (`This Mac shows notifications while byre is running.`, `This Mac doesn't show notifications from byre.` with a **Turn on for this Mac** button, which asks macOS for permission, or `Notifications are blocked for byre in System Settings.`). Nothing notifies while the app isn't running. ## Pane focus @@ -149,11 +149,11 @@ Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/ima ## Keyboard and menus -The app menu has About, Check for Updates…, Forget Server (for the focused window's server), Hide, Hide Others and Quit; File has New Server Window ⌘N, which opens a window with the connection screen, and Open Server, listing every saved server by host; Edit has the standard items, with Cut, Copy, Paste and Select All being the app's own commands so Paste can attach images (see "Gaps in SwiftUI"); a **Go** menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S, Previous Server ⌘[, Next Server ⌘] and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items and one entry per open window. The Go commands act on the focused window (its `ServerFocus`, a focused scene value holding its store and `Overlays`), and are disabled while that window's alert or modal is open (`dialogOpen`); the switcher and the More menu don't block them, and ⌘L closes the switcher so the Lead composer gets the keys. Previous and Next Server bring forward, or open, the window of the saved server before or after the focused window's, in saved order, wrapping round; from a window with no server it goes to the first. The Ctrl variants (Ctrl-K, P, L, S and /) run the same actions from the key monitor in `Overlays`, except while the terminal's `TerminalView` is the first responder (`terminalFocused`), where they go to the shell. ⌘ combinations always reach the menu, the terminal included. Escape closes the open overlay. +The app menu has About, Check for Updates…, Forget Server (for the server the window shows), Hide, Hide Others and Quit; File has Add Server… ⌘N, which shows the connection screen; Edit has the standard items, with Cut, Copy, Paste and Select All being the app's own commands so Paste can attach images (see "Gaps in SwiftUI"); a **Go** menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S, Previous Server ⌘[, Next Server ⌘] and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items and the window. The Go commands act on the server the window shows (its `ServerFocus`, a focused scene value holding its store and `Overlays`), and are disabled while that server's alert or modal is open (`dialogOpen`); the switcher and the More menu don't block them, and ⌘L closes the switcher so the Lead composer gets the keys. Previous and Next Server switch the window to the saved server before or after the current one, in saved order, wrapping round; from the connection screen they go to the first. The Ctrl variants (Ctrl-K, P, L, S and /) run the same actions from the key monitor in `Overlays`, except while the terminal's `TerminalView` is the first responder (`terminalFocused`), where they go to the shell. ⌘ combinations always reach the menu, the terminal included. Escape closes the open overlay. ## Connection -On first launch, and in a window from New Server Window, the window shows one field, Server URL (an `https` origin with no path), Connect, and Run byre on this Mac. Run byre on this Mac runs the bundle's `Contents/Helpers/byre install` through the user's login shell (`pw_shell -l -c 'exec "$BYRE_HELPER" install'`, which sh, zsh, bash and fish all accept), since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`; it takes the address from the `byre is at` line, polls `GET /api/admin/status` for up to 10 s while the service starts, then connects to it as Connect does. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is Tailscale connected?"), 401 (no Tailscale identity: use the `ts.net` HTTPS address), 403 (the request didn't come through `tailscale serve`); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is saved as a `ServerRecord` (see "Persistence") with the first task colour no other saved server uses, startup runs, and the window becomes that server's; later launches start against every saved server directly. Connecting to a server whose startup failed replaces its failed connection with a new one, keeping its record and colour. Connecting to a server that already has a window brings that window forward and closes this one, so each server has one window and one `Store`; worktree images (`ImageCache`) and review patches (keyed by origin) are cached per server. Each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) reloads the saved servers: it starts a connection for each new one and stops the connection of each one forgotten on another Mac, and a window showing the connection screen with an empty field takes the first new one, so on a new Mac the address another Mac saved connects without typing. Forget Server deletes the window's `ServerRecord` (on every Mac, through iCloud), stops its connection and shows the connection screen in that window with the old origin in the field. +On first launch, and from Add Server…, the window shows one field, Server URL (an `https` origin with no path), Connect, and Run byre on this Mac. Run byre on this Mac runs the bundle's `Contents/Helpers/byre install` through the user's login shell (`pw_shell -l -c 'exec "$BYRE_HELPER" install'`, which sh, zsh, bash and fish all accept), since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`; it takes the address from the `byre is at` line, polls `GET /api/admin/status` for up to 10 s while the service starts, then connects to it as Connect does. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is Tailscale connected?"), 401 (no Tailscale identity: use the `ts.net` HTTPS address), 403 (the request didn't come through `tailscale serve`); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is saved as a `ServerRecord` (see "Persistence") with the first task colour no other saved server uses, startup runs, and the window switches to that server; later launches start against every saved server directly. Connecting to a server whose startup failed replaces its failed connection with a new one, keeping its record and colour. Connecting to a saved server switches to it, so each server has one `Store`; worktree images (`ImageCache`) and review patches (keyed by origin) are cached per server. Each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) reloads the saved servers: it starts a connection for each new one and stops the connection of each one forgotten on another Mac, and when the window shows the connection screen with an empty field it takes the first new one, so on a new Mac the address another Mac saved connects without typing. Forget Server deletes the current server's `ServerRecord` (on every Mac, through iCloud), stops its connection and shows the connection screen with the old origin in the field. ## Updates diff --git a/docs/operations.md b/docs/operations.md index c9babf6..20e1a95 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -146,7 +146,7 @@ Clients reach `tailscale serve` over HTTP/2; it proxies to byre over HTTP/1.1 an **Building.** `mac/bundle.sh`, from any directory, builds the release configuration with `swift build` in `~/Library/Caches/byre/build`, one build directory every worktree shares and takes turns in (`docs/macos.md`, "Stack", says why), and assembles `mac/.build/Byre.app`: the `Byre` executable in `Contents/MacOS`; the server, built with `go build ./cmd/byre`, and `byre-hands`, built from `hands/`, in `Contents/Helpers` (not beside `Byre`: the default APFS volume ignores case, so `byre` and `Byre` would be one file); Sparkle's framework in `Contents/Frameworks`; `mac/Info.plist` with a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, `CFBundleShortVersionString` its `git describe --tags --always` without the leading `v` (`0.1.0` on a release tag, `0.1.0-12-gabc1234` after it) and `CFBundleVersion` its `git rev-list --count HEAD` (the About panel shows them as "Version <version> (<count>)", and Sparkle compares the count); the `mac/fonts/` woff2 files, an icon rendered from `mac/icon.svg` with `rsvg-convert` and `iconutil` (cached as `mac/.build/byre.icns` until the SVG changes) and `mac/byre.provisionprofile`. It signs each piece with the Developer ID Application certificate, a secure timestamp and the hardened runtime, innermost first: `byre-hands` as `run.fc.byre.hands` and the server as `run.fc.byre`, both with `mac/automation.entitlements` (Apple Events, which the hardened runtime otherwise refuses), Sparkle's `Autoupdate`, `Updater.app` and framework (its XPC services, only needed by sandboxed apps, are removed), then the bundle with `mac/byre.entitlements`. It prints the bundle's path. It needs Xcode selected (`xcode-select --print-path` prints `/Applications/Xcode.app/Contents/Developer`), Go, `rsvg-convert` (Homebrew's `librsvg`), the team's Developer ID Application certificate and network access for the timestamp, and it stops first unless `mac/byre.provisionprofile` has `ProvisionsAllDevices` (a Developer ID profile; a development profile can't authorize the Developer ID signature); `docs/macos.md`, "Signing", covers the certificate and profile. Debug builds are refused by an `#error` in `ByreApp.swift`. The first build on a Mac fetches the Swift packages pinned in `mac/Package.resolved` and compiles them and the generated API client; later builds, from any worktree, recompile only the targets whose sources differ from the last build's, usually just `Byre`. The API client is generated from `api/openapi.yaml` at build time. The palette is `mac/Sources/Byre/Palette.swift`, one light and one dark value per colour. -**Installing.** A release is installed from its disk image into `/Applications` (see "Releases") and updates itself (`docs/macos.md`, "Updates"). A deploy installs its build there the same way (see "Releases"). A build from a checkout can also run from where it was built, `open mac/.build/Byre.app` in the worktree that built it, after quitting the running app (`pkill -x Byre` quits every running copy). Every build shares the bundle identifier `run.fc.byre.mac` and the same store, so any build reconnects to the servers the last one saved, one window each. Closing the windows leaves the app running for notifications; ⌘Q quits it. +**Installing.** A release is installed from its disk image into `/Applications` (see "Releases") and updates itself (`docs/macos.md`, "Updates"). A deploy installs its build there the same way (see "Releases"). A build from a checkout can also run from where it was built, `open mac/.build/Byre.app` in the worktree that built it, after quitting the running app (`pkill -x Byre` quits every running copy). Every build shares the bundle identifier `run.fc.byre.mac` and the same store, so any build reconnects to the servers the last one saved, switched between with the window's server picker. Closing the window leaves the app running for notifications; ⌘Q quits it. **Storage.** The app keeps three kinds of record in SwiftData (`mac/Sources/Byre/Persistence.swift`): each saved server's origin and window colour, when each task was last selected, and composer and answer drafts as JSON, one record per server and login (`docs/macos.md`, "Persistence" and "Drafts"). The store is `~/Library/Application Support/run.fc.byre.mac/byre.store`, with its `-shm` and `-wal` files and CloudKit's working files beside it in that directory; the app isn't sandboxed, so it names its own file rather than SwiftData's default `default.store`, which every unsandboxed app shares. The store syncs through the iCloud private database of container `iCloud.run.fc.byre.mac`, in CloudKit's Production environment (`com.apple.developer.icloud-container-environment` in `mac/byre.entitlements`), whose schema is deployed from Development in the CloudKit console before a release that changes the SwiftData models; the CloudKit console (icloud.developer.apple.com, the container, Production, Private Database, zone `com.apple.coredata.cloudkit.zone`) shows the records. Deleting the local files while the app isn't running makes the next launch download the records again from iCloud; deleting the records in the console as well starts from the connection screen. Pasted images stay in memory and are dropped on Forget Server and quit. The app logs through `os.Logger`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it. @@ -187,7 +187,7 @@ A task's build of byre runs alongside the service with its own empty state, from go run ./cmd/byre serve --data "$PWD/.preview" --listen 127.0.0.1:$PORT --ports 51000 ``` -It must never share the service's data directory: nothing locks the database, and startup resumes every session marked `running` in its recorded worktree. `--ports` puts the preview's own tasks' port blocks in a range the service's tasks don't use; each instance only knows its own tasks, so with the default both would hand out blocks from the same first port, starting with the one the preview listens on. `.preview/` is gitignored, so the end-of-turn commit leaves it out. The macOS app's New Server Window connects to its exposed URL, beside the service's window. The preview starts with no users, projects or sessions; set a git identity with `PUT /api/me` and add a project to try things. A project's Merge pushes to its checkout's `origin`, so a preview project should be a clone whose `origin` is a local bare repository (`git clone --bare <checkout> .preview/origin.git`, then clone that), never the real checkout. +It must never share the service's data directory: nothing locks the database, and startup resumes every session marked `running` in its recorded worktree. `--ports` puts the preview's own tasks' port blocks in a range the service's tasks don't use; each instance only knows its own tasks, so with the default both would hand out blocks from the same first port, starting with the one the preview listens on. `.preview/` is gitignored, so the end-of-turn commit leaves it out. The macOS app's Add Server… connects to its exposed URL, beside the service in the server picker. The preview starts with no users, projects or sessions; set a git identity with `PUT /api/me` and add a project to try things. A project's Merge pushes to its checkout's `origin`, so a preview project should be a clone whose `origin` is a local bare repository (`git clone --bare <checkout> .preview/origin.git`, then clone that), never the real checkout. ## Identity @@ -270,7 +270,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie - **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation), `session.error` and `session.rate_limited` on a task, `inbox.updated` when a human answered a question (the signal also carries its text and the agent's recommendation, so the lead sees where the human departed from it), `task.created` (naming the TODO item it was started from, or a subtask's parent, if any), `task.merge_started` ("<task> is merging, started by <login>."), `task.merged`, `task.discarded`, `turn.ended` with `summary: true` ("<task> finished a turn:" with its `result` cut to 1500 characters), `task.workflow_stage` ("<task> started stage "<stage>" of workflow "<workflow>".") and `task.workflow_finished` ("<task> finished workflow "<workflow>":" with its output cut to 1500 characters), `review.started` ("<task> started a review of snapshot <n>."), `review.finished` ("<task> review of snapshot <n>:" with the review's summary, `change` and findings, or why it failed or was skipped; `reviewLine` in `internal/agents/review.go`), `user.message` on a task from a human (see "Pane lock"), and `task.state` ("<task> state: <from> → <to>.", or "<task> state: <to>." for a new task; see "Task state in the store"). The store hands `Observe` all of one transaction's events together, and they become one signal, its `task.state` lines first, so a turn's summary always arrives with the state its turn end left the task in. The human switching the right pane is not a signal. A signal caused by the lead itself (actor `<login> (lead)`) is skipped, as is the update from an answer it gave. A task's `task.created` event carries the actor that created it, separately from its `created_by` login: tasks the lead creates with `create_task` or `start_todo` keep the human's login as `created_by` but have actor `<login> (lead)`. Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as one `byre` message headed "Signals since your last turn (n)", followed by "Tasks now:" with the `list_tasks` text and a line saying what the lead's right pane shows (task or project, and selected tab) and whether the human is working in it (`leadStateText` in `internal/agents/lead.go`). If collecting that fails, for example because a project's repository is gone, the batch is still sent, ending instead with "The task list and right pane state are unavailable:" and the error, which is also logged as `lead state`. Its guidance tells it not to respond to the human's own actions, and to make its final message exactly `NO_REPLY` when a batch needs nothing from it. A transcript (`quietReply` in `mac/Sources/Byre/TranscriptBuilder.swift`) never draws an `agent.message` whose last line is `NO_REPLY` (the model sometimes writes a sentence before it), and hides the live partial message while its text is still a prefix of it, so an empty one never takes up space. A lead turn that byre starts without a handoff (a signal batch or a handoff continuation) is held (`ctx.held`): its `byre` message is never drawn, the turn isn't opened, so the scanner stays off, its partial message stays hidden, and its tool calls, subagents and thinking are built into a held row list instead of the transcript. The turn is released, inserting those rows where the turn belongs, when it draws anything else: a real message, a note, or its own `turn.ended` when it ends without `NO_REPLY`. When it ends in `NO_REPLY` while still held, the held rows are dropped, so nothing of the turn ever appears; a turn that draws a real message anywhere in it keeps all of it, even when a later message is `NO_REPLY`. - **Tools.** `list_tasks` (each project's active tasks with their state, each subtask noting its parent, then a line listing the titles of its `TODO.md` items on the default branch that no active task was started from), `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`; closed tasks too), `search_closed_tasks` (see "Closing a task"), `read_inbox`, `read_status` (the `GET /api/admin/status` body as JSON, built by the same `Server.status`, which the server hands `agents.New`), `answer_question` (an `option` or `other` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; while it is mid-turn the message is queued through `Manager.Message` like the human's, see "Agent sessions", and the tool result says it was queued; on a team task it posts to the board, see "Teams"), `create_task` and `start_todo` (each with an optional `workflow` to freeze and run; `create_task` also takes `agents` to start a team), `create_project` (`name` and `repo_path`, created as the user by the same `Server.createProject` as `POST /api/projects`, which the server hands `agents.New`, so it publishes the same `project.created` event, and returned as JSON), `interrupt`, `compact_task`, `take_snapshot` (see "Snapshots"), `merge_task` (optional `target`: `main`, or `parent` for a subtask, see "Subtasks"), `discard_task`, `show`, `compact_self`, the note tools (see "Notes") and the workflow tools (see "Workflows"). The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor `<login> (lead)`, so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks. - **Focus.** `users.focus` holds what the user's right pane shows: `project_id`, `task_id`, `tabs` (the tabs put first, in order) and `tab` (the selected one). With a `task_id`, `project_id` is always that task's project, filled in by the server; with only a `project_id`, the pane shows that project with no task, which only the Project tab has anything for. `GET /api/me/focus` reads it; `PUT /api/me/focus` and the lead's `show` set it, refusing an unknown tab, a task that isn't active or an unknown project, and record a `focus.updated` event owned by the user. The app applies the focus at startup and on every `focus.updated` it didn't cause itself; the human's own changes (⌘K, the right pane's task picker, clicking a tab, a task link, a notification) are sent with `PUT /api/me/focus`. -- **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and applies only its `tabs` order, whether or not the human is in the pane, so the human's selected tab stays selected. Otherwise, while the human is working in the right pane (the app reports `focused` or `drafting`, below), the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (`Manager.paneFinished`): its task is merged, discarded or merging (`merging_by` or `finalizing` set), or its selected tab is `questions` and the task has no open items, or the human is waiting on the task's agent (`Manager.paneWaiting`): the selected tab is `transcript`, the task's implementer session is running, the app reports no unsent text in the pane, and either the `user.message` that started the running turn came from the user after the pane last moved, or the pane last moved 10 seconds ago or more (`watchHold`). The pane's last move is the last `PUT /api/me/focus` or `show` that set it (`Manager.paneMoved`, in memory, so a restart counts as long ago). For the user's own message, unsent text reported before the turn started doesn't count, since the app's report of the composer emptying on send can arrive after the message does. A `user.message` on a task from a human (not `byre`, an agent or a lead) signals every lead "<actor> sent <task> a message; its agent is working on it.", and the sender's own lead, when its pane shows that task and is waiting that way, also gets "Your right pane is free: the human is waiting on <task>'s agent." Each `PUT /api/me/focus` from the user starts a 10-second timer (`endWatchHold`); when it fires, if the pane hasn't moved since, the app still reports the human in it, the user hasn't messaged the task since the move (that already freed it, above), and the pane is waiting that way, the lead gets the signal "Your right pane is free: the human has watched <task>'s agent work for 10 seconds without typing." The lead's own `show` starts no timer. When an `inbox.updated`, `task.merge_started`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: <task> has nothing left in its inbox." (or "is merging."/"is merged."/"is discarded."), even when the event itself makes no signal, such as a withdrawn question; `inbox.updated` on a task that is no longer active adds none. The lead's guidance tells it to show the next thing that needs the human on that line, or, when nothing does, the project's Project tab with a short status in chat: one line per active task, then up to three TODO items it would start next, each with a reason, starting one only when the human picks it. It writes each item as a link to `#/todo/<project_id>/<URL-encoded title>`; Markdown views draw that (`todo` in `mac/Sources/Byre/MarkdownView.swift`) as the title followed by `start` and `discuss` actions, as accent link text so the line stays selectable, that call `POST /api/projects/{id}/todo/tasks` with the user's default model, as the Project tab's do, or, once an active task was started from that item, the task's colour, dot and state, linking to it. When the lead suggests new work that isn't in TODO.md, its guidance tells it to write a link to `#/suggest/<project_id>/<URL-encoded title>/<URL-encoded brief>`, drawn by the same code with `start` and `discuss` buttons: `start` calls `POST /api/projects/{id}/tasks` with the title, the brief as `prompt` and the user's default model, which checks the user's git identity, creates the task and starts its agent in one request, with no lead turn in between; `discuss` sends the lead `POST /api/me/lead/messages` asking to discuss the suggestion, quoting its title and brief (with the user's default model only when the user has no live lead session). Once an active task in that project has the suggestion's title, it shows that task's state the same way. A chat view refills them whenever its transcript grows or tasks or sessions change. When that status says nothing new since its last one, it replies `NO_REPLY` instead. The app sends `PUT /api/me/pane-focus` with `focused` and `drafting` whenever either changes (`PaneFocus` in `mac/Sources/Byre/PaneFocus.swift`): `focused` is true while the window is key, the app is active and the keyboard focus or the last click is inside the right pane, so a click anywhere in it counts, not only on inputs and buttons; `drafting` while any text field in the right pane other than the terminal holds non-blank text. Each server's window reports to its own server. It sends false on deactivating, Forget Server, closing the window and quit, and resends true when its event stream reconnects. byre keeps both flags and the time of the last report in memory per user (`Manager.paneFocus`, `Manager.paneReported`), so they start false after a restart until the app resends them. +- **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and applies only its `tabs` order, whether or not the human is in the pane, so the human's selected tab stays selected. Otherwise, while the human is working in the right pane (the app reports `focused` or `drafting`, below), the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (`Manager.paneFinished`): its task is merged, discarded or merging (`merging_by` or `finalizing` set), or its selected tab is `questions` and the task has no open items, or the human is waiting on the task's agent (`Manager.paneWaiting`): the selected tab is `transcript`, the task's implementer session is running, the app reports no unsent text in the pane, and either the `user.message` that started the running turn came from the user after the pane last moved, or the pane last moved 10 seconds ago or more (`watchHold`). The pane's last move is the last `PUT /api/me/focus` or `show` that set it (`Manager.paneMoved`, in memory, so a restart counts as long ago). For the user's own message, unsent text reported before the turn started doesn't count, since the app's report of the composer emptying on send can arrive after the message does. A `user.message` on a task from a human (not `byre`, an agent or a lead) signals every lead "<actor> sent <task> a message; its agent is working on it.", and the sender's own lead, when its pane shows that task and is waiting that way, also gets "Your right pane is free: the human is waiting on <task>'s agent." Each `PUT /api/me/focus` from the user starts a 10-second timer (`endWatchHold`); when it fires, if the pane hasn't moved since, the app still reports the human in it, the user hasn't messaged the task since the move (that already freed it, above), and the pane is waiting that way, the lead gets the signal "Your right pane is free: the human has watched <task>'s agent work for 10 seconds without typing." The lead's own `show` starts no timer. When an `inbox.updated`, `task.merge_started`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: <task> has nothing left in its inbox." (or "is merging."/"is merged."/"is discarded."), even when the event itself makes no signal, such as a withdrawn question; `inbox.updated` on a task that is no longer active adds none. The lead's guidance tells it to show the next thing that needs the human on that line, or, when nothing does, the project's Project tab with a short status in chat: one line per active task, then up to three TODO items it would start next, each with a reason, starting one only when the human picks it. It writes each item as a link to `#/todo/<project_id>/<URL-encoded title>`; Markdown views draw that (`todo` in `mac/Sources/Byre/MarkdownView.swift`) as the title followed by `start` and `discuss` actions, as accent link text so the line stays selectable, that call `POST /api/projects/{id}/todo/tasks` with the user's default model, as the Project tab's do, or, once an active task was started from that item, the task's colour, dot and state, linking to it. When the lead suggests new work that isn't in TODO.md, its guidance tells it to write a link to `#/suggest/<project_id>/<URL-encoded title>/<URL-encoded brief>`, drawn by the same code with `start` and `discuss` buttons: `start` calls `POST /api/projects/{id}/tasks` with the title, the brief as `prompt` and the user's default model, which checks the user's git identity, creates the task and starts its agent in one request, with no lead turn in between; `discuss` sends the lead `POST /api/me/lead/messages` asking to discuss the suggestion, quoting its title and brief (with the user's default model only when the user has no live lead session). Once an active task in that project has the suggestion's title, it shows that task's state the same way. A chat view refills them whenever its transcript grows or tasks or sessions change. When that status says nothing new since its last one, it replies `NO_REPLY` instead. The app sends `PUT /api/me/pane-focus` with `focused` and `drafting` whenever either changes (`PaneFocus` in `mac/Sources/Byre/PaneFocus.swift`): `focused` is true while the window is key, the app is active and the keyboard focus or the last click is inside the right pane, so a click anywhere in it counts, not only on inputs and buttons; `drafting` while any text field in the right pane other than the terminal holds non-blank text. The window reports to the server it shows. It sends false on deactivating, switching to another server, Forget Server, closing the window and quit, and resends true when its event stream reconnects. byre keeps both flags and the time of the last report in memory per user (`Manager.paneFocus`, `Manager.paneReported`), so they start false after a restart until the app resends them. - **Move hold.** Each `PUT /api/me/focus` from the user (switching task or tab, by any means: the switcher, a tab, a link, a notification, or the app's own switch to Questions when a question arrives) records the time in `Manager.humanMoved`, and for 10 seconds after it (`moveHold`) every `show` that would change the view is refused, whatever the pane lock above would allow, with an error giving how long ago the move was. A `show` of what the pane already shows still applies its tab order. A refusal also marks the user in `Manager.moveRefused`, which each move by the user clears. When the 10 seconds pass without another move by the user (`endMoveHold`, on a timer), byre clears both and, only if a `show` was refused since that move, sends the lead the signal "The move hold is over: …"; its guidance tells it to show what it held back if the pane's other rules allow, or reply `NO_REPLY`. Because only a refusal arranges the signal, the lead's system prompt and the `show` tool's description tell it to call `show` at once whenever the user asks to see something or it wants to show something, never holding back in expectation of a refusal. It is in memory, so a restart clears it. - **Start hold.** Start on a TODO item in the Project tab leaves the pane where it is. When the user starts one themselves (`StartTodo` with the user as actor, not the lead's `start_todo`), byre records the time in `Manager.startedTodo` for them, and for 10 seconds after it (`startHold`), while the pane's selected tab is `project`, every `show` that would change the view is refused, focused or not, with an error telling the lead the human is likely to start more. A refusal also marks the user in `Manager.startRefused`. The user's own `PUT /api/me/focus` (switching task or tab) or a message to the lead clears both, and another Start restarts the hold and clears the mark. The refusal and the `show` tool's description both tell the lead the hold lasts 10 seconds. When the 10 seconds pass without it being cleared or restarted (`endStartHold`, on a timer), byre clears both and, only if a `show` was refused since that Start and the pane's selected tab is still `project`, sends the lead the signal "The start hold is over: …" so it can show what it held back; its guidance tells it to reply `NO_REPLY` when that no longer matters. It is in memory, so a restart clears it. - **Compaction.** The pane's **Compact** button asks the lead for a handoff (`leadHandoffPrompt`). The lead can't see its own context size, so the first time one of its turns ends (other than a handoff or a `compact_self` turn) with the latest `session.context` `used` at half of its `window` or more, byre holds a signal for it giving the percentage and asking it to call `compact_self` at the next logical point (`warnLeadContext` in `internal/agents/lead.go`); it is sent once per live process, so a byre restart can send it again once the window is known. The lead calls `compact_self`; when that turn ends normally, byre sends the same request. The handoff turn ends the session, and a new lead session starts from `leadHandoffPreamble` plus the handoff; held input follows once that turn ends. diff --git a/mac/Sources/Byre/ByreApp.swift b/mac/Sources/Byre/ByreApp.swift index 9ce8cc4..b42eebc 100644 --- a/mac/Sources/Byre/ByreApp.swift +++ b/mac/Sources/Byre/ByreApp.swift @@ -24,7 +24,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if flag { return true } - Servers.shared.openAll() + Servers.shared.openWindow?() return false } @@ -64,8 +64,8 @@ struct ByreApp: App { @NSApplicationDelegateAdaptor private var delegate: AppDelegate var body: some Scene { - WindowGroup(id: "main", for: String.self) { $origin in - ServerWindow(origin: $origin) + Window("byre", id: "main") { + ServerWindow() } .defaultSize(width: 1280, height: 820) .restorationBehavior(.disabled) @@ -86,23 +86,16 @@ struct ByreCommands: Commands { Updater.shared.check() } Button("Forget Server") { - focus?.forget?() + focus?.forget() } - .disabled(focus?.forget == nil) + .disabled(focus == nil) } CommandGroup(replacing: .newItem) { - Button("New Server Window") { + Button("Add Server…") { + Servers.shared.current = nil openWindow(id: "main") } .keyboardShortcut("n") - Menu("Open Server") { - ForEach(Servers.shared.origins, id: \.self) { origin in - Button(URL(string: origin)?.host() ?? origin) { - openWindow(id: "main", value: origin) - } - } - } - .disabled(Servers.shared.origins.isEmpty) } PasteboardCommands() CommandMenu("Go") { @@ -119,9 +112,7 @@ struct ByreCommands: Commands { private func serverButton(_ label: String, _ key: Character, _ step: Int) -> some View { Button(label) { - if let origin = Servers.shared.step(from: focus?.origin, by: step) { - openWindow(id: "main", value: origin) - } + Servers.shared.current = Servers.shared.step(from: Servers.shared.current, by: step) } .keyboardShortcut(KeyEquivalent(key)) .disabled(Servers.shared.origins.isEmpty) diff --git a/mac/Sources/Byre/Notifications.swift b/mac/Sources/Byre/Notifications.swift index fd08f3c..54e7ae9 100644 --- a/mac/Sources/Byre/Notifications.swift +++ b/mac/Sources/Byre/Notifications.swift @@ -12,7 +12,7 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate { private func open(_ origin: String, _ taskId: String) { NSApp.activate() - Servers.shared.openWindow?(origin) + Servers.shared.show(origin) pending = (origin, taskId) deliver() } diff --git a/mac/Sources/Byre/Startup.swift b/mac/Sources/Byre/Startup.swift index faa5111..733f506 100644 --- a/mac/Sources/Byre/Startup.swift +++ b/mac/Sources/Byre/Startup.swift @@ -11,13 +11,12 @@ final class Servers { private(set) var entries: [(origin: String, record: ServerRecord)] = [] private(set) var connections: [String: Connection] = [:] - @ObservationIgnored var shown: Set<String> = [] - @ObservationIgnored var windows: [String: WindowSlot] = [:] - @ObservationIgnored var launched = false - @ObservationIgnored var openWindow: ((String?) -> Void)? + var current: String? + @ObservationIgnored var openWindow: (() -> Void)? private init() { load() + current = origins.first Task { for await note in NotificationCenter.default.notifications(named: NSPersistentCloudKitContainer.eventChangedNotification) { if let event = note.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event, event.type == .import, event.endDate != nil { @@ -50,6 +49,9 @@ final class Servers { } } entries = loaded + if let current, record(current) == nil { + self.current = origins.first + } for (origin, _) in loaded where connections[origin] == nil { connections[origin] = Connection(origin: parseOrigin(origin)!) } @@ -93,31 +95,17 @@ final class Servers { return origins[(i + step + origins.count) % origins.count] } - func arrange(_ group: NSWindowTabGroup?) { - guard let group else { - return - } - let selected = group.selectedWindow - let ordered = origins.compactMap { windows[$0]?.window }.filter { group.windows.contains($0) } - for (i, window) in ordered.enumerated() where group.windows[i] !== window { - group.insertWindow(window, at: i) - } - group.selectedWindow = selected - } - - func openAll() { - if origins.isEmpty { - openWindow?(nil) - } - for origin in origins { - openWindow?(origin) + func show(_ origin: String) { + if record(origin) != nil { + current = origin } + openWindow?() } } -@MainActor -final class WindowSlot { - weak var window: NSWindow? +func serverName(_ origin: String) -> String { + let host = URL(string: origin)?.host() ?? origin + return host.split(separator: ".").first.map(String.init) ?? host } @MainActor @@ -130,6 +118,7 @@ final class Connection { } let origin: URL + let overlays = Overlays() var phase: Phase = .starting @ObservationIgnored private var stopped = false @@ -368,10 +357,9 @@ struct FatalView: View { } struct ServerFocus { - let origin: String? let store: Store? let overlays: Overlays - let forget: (() -> Void)? + let forget: () -> Void } extension FocusedValues { @@ -379,23 +367,15 @@ extension FocusedValues { } struct ServerWindow: View { - @Binding var origin: String? - @ViewState private var overlays = Overlays() @ViewState private var form = ConnectForm() - @ViewState private var slot = WindowSlot() @Environment(\.openWindow) private var openWindow - @Environment(\.dismiss) private var dismissWindow private var connection: Connection? { - origin.flatMap { Servers.shared.connections[$0] } + Servers.shared.current.flatMap { Servers.shared.connections[$0] } } - private var focus: ServerFocus { - var forgetting: (() -> Void)? - if connection != nil { - forgetting = forget - } - return ServerFocus(origin: connection?.origin.absoluteString, store: connection?.store, overlays: overlays, forget: forgetting) + private var focus: ServerFocus? { + connection.map { ServerFocus(store: $0.store, overlays: $0.overlays, forget: forget) } } private var frameColor: Color { @@ -413,16 +393,13 @@ struct ServerWindow: View { case .running(let store): MainView() .environment(store) + .environment(connection.overlays) + .id(connection.origin) } } else { ConnectView(form: form) { connected in - let key = connected.absoluteString - if Servers.shared.shown.contains(key) { - openWindow(id: "main", value: key) - dismissWindow() - return - } - origin = key + form.serverText = "" + Servers.shared.current = connected.absoluteString } } } @@ -434,98 +411,75 @@ struct ServerWindow: View { .background(Palette.bg) .padding(connection == nil ? 0 : 3) .background(frameColor) - .environment(overlays) .font(Fonts.font(13)) .frame(minWidth: 640, minHeight: 400) } var body: some View { framed - .navigationTitle(connection?.origin.host() ?? "byre") - .background { - WindowReader(found: joinTabs) + .toolbar { + ToolbarItem(placement: .principal) { + ServerPicker() + } } + .toolbar(removing: .title) .focusedSceneValue(\.serverWindow, focus) - .onChange(of: origin, initial: true) { old, new in - moved(old, new) - } .onChange(of: Servers.shared.origins) { old, new in adopt(new.filter { !old.contains($0) }) } .onAppear(perform: appeared) - .onDisappear { - if let origin { - Servers.shared.shown.remove(origin) - release(origin) - } - } - } - - private func moved(_ old: String?, _ new: String?) { - let servers = Servers.shared - if let old { - servers.shown.remove(old) - release(old) - } - if let new { - servers.shown.insert(new) - servers.windows[new] = slot - } - servers.arrange(slot.window?.tabGroup) - } - - private func release(_ origin: String) { - if Servers.shared.windows[origin] === slot { - Servers.shared.windows[origin] = nil - } } private func appeared() { - let servers = Servers.shared let openWindow = openWindow - servers.openWindow = { origin in - if let origin { - openWindow(id: "main", value: origin) - } else { - openWindow(id: "main") - } - } - if !servers.launched { - servers.launched = true - adopt(servers.origins) - for o in servers.origins where !servers.shown.contains(o) { - openWindow(id: "main", value: o) - } + Servers.shared.openWindow = { + openWindow(id: "main") } Notifier.shared.deliver() } - private func joinTabs(_ window: NSWindow) { - window.tabbingMode = .preferred - slot.window = window - DispatchQueue.main.async { - let host = NSApp.windows.first { $0 !== window && $0.isVisible && $0.tabbingIdentifier == window.tabbingIdentifier } - if let host, !(host.tabbedWindows ?? []).contains(window) { - host.addTabbedWindow(window, ordered: .above) - } - Servers.shared.arrange(window.tabGroup) - } - } - private func adopt(_ candidates: [String]) { - guard origin == nil, form.serverText.isEmpty, !form.connecting, let next = candidates.first(where: { !Servers.shared.shown.contains($0) }) else { + guard Servers.shared.current == nil, form.serverText.isEmpty, !form.connecting, let next = candidates.first else { return } - origin = next - Servers.shared.shown.insert(next) + Servers.shared.current = next } private func forget() { - guard let current = origin else { + guard let current = Servers.shared.current else { return } form.serverText = current - origin = nil + Servers.shared.current = nil Servers.shared.remove(current) } } + +struct ServerPicker: View { + var body: some View { + let servers = Servers.shared + Menu { + ForEach(servers.origins, id: \.self) { origin in + Button(serverName(origin)) { + servers.current = origin + } + } + Divider() + Button("Add Server…") { + servers.current = nil + } + } label: { + HStack(spacing: 6) { + if let current = servers.current { + Circle() + .fill(servers.color(current)) + .frame(width: 8, height: 8) + Text(serverName(current)) + } else { + Text("Add Server") + } + } + .font(Fonts.font(13)) + } + } +}