diff --git a/README.md b/README.md index cb9bea2..9b49103 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A server and macOS app for running many coding agents (Claude Code and Codex) in - **Tasks.** Each task gets its own git worktree, branch and port block. Talk to its agent in a live transcript; messages sent mid-turn reach it after its next tool call. - **Lead agent.** One long-running agent per user watches every task, tells you what needs you, points the right pane at it, and starts, messages, merges or discards tasks when you ask. It keeps notes on how you work and passes them to task agents. -- **Inbox.** Agents ask structured questions with a recommended answer; you answer by clicking. macOS notifications for questions, turn summaries and errors. +- **Questions.** Agents ask structured questions with a recommended answer; you answer by clicking. Each question shows in the transcript where it was asked, and clicking it shows it again with your answer. macOS notifications for questions, turn summaries and errors. - **Cross-vendor reviews.** Every turn that changes files is snapshotted and reviewed by the other vendor's model. Findings go back to the agent, and you can fix or ignore each one. - **Snapshots and forks.** Step through a task's snapshots, diff any of them, and fork a new task from any one, conversation included. - **Merge.** One click: the agent merges main in and resolves conflicts, then byre squashes, pushes and closes the task, with co-author trailers and a git note recording the task. Merge after review, and one merge per project at a time with a queue. diff --git a/TODO.md b/TODO.md index 96d07b0..be6f679 100644 --- a/TODO.md +++ b/TODO.md @@ -17,6 +17,11 @@ # M7 — Inbox triage +- Answer a question again + From an answered question in the Questions tab, give a different answer, which forks the task: a new task starts from the snapshot taken just before the original answer was delivered (the answer's `snapshot_n`), its agent continuing the conversation as it stood there (as Fork does from a snapshot), and gets the new answer. The original task carries on untouched. +- Take both paths when unattended + An agent working unattended (no human to ask) that hits a question with two plausible answers forks at it: the task carries on with one answer and a fork, started the same way as answering again, carries on with the other, so the human later compares both results instead of the agent guessing. + # M8 — macOS client - Phone app diff --git a/api/openapi.yaml b/api/openapi.yaml index 56fbba1..773da1a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -30,7 +30,7 @@ paths: /api/me/notifications: put: operationId: updateNotificationPrefs - description: Sets which new inbox items notify the calling user in their open byre pages. + description: Sets which new questions, finished turns, errors and rate limits notify the calling user in their open byre pages. requestBody: required: true content: @@ -1125,13 +1125,13 @@ components: NotificationPrefs: type: object - description: Which new inbox items and finished turns notify this user in their open byre pages. + description: Which new questions, finished turns, errors and rate limits notify this user in their open byre pages. required: [questions, reports, errors, rate_limits] properties: questions: { type: boolean, description: Questions. On by default. } reports: { type: boolean, description: "Task turns that end with a summary (turn.ended with summary: true)." } - errors: { type: boolean, description: session_error items. } - rate_limits: { type: boolean, description: rate_limited items. } + errors: { type: boolean, description: session.error events on tasks. } + rate_limits: { type: boolean, description: session.rate_limited events on tasks. } UserUpdate: type: object @@ -1469,9 +1469,9 @@ components: event_seq: { type: integer, format: int64, description: "The task's latest event when the snapshot was started: the tree holds at least the work up to it." } trigger: type: string - enum: [message, turn_end, tool_call, ask_human, timer, requested] - x-enum-varnames: [SnapshotTriggerMessage, SnapshotTriggerTurnEnd, SnapshotTriggerToolCall, SnapshotTriggerAskHuman, SnapshotTriggerTimer, SnapshotTriggerRequested] - description: "turn_end: after byre's end-of-turn commit. requested: a task's agent or the lead asked for it with take_snapshot. message (the agent wrote text after one or more tool calls), tool_call, ask_human and timer appear only on snapshots taken by earlier builds." + enum: [message, turn_end, tool_call, ask_human, timer, requested, answer] + x-enum-varnames: [SnapshotTriggerMessage, SnapshotTriggerTurnEnd, SnapshotTriggerToolCall, SnapshotTriggerAskHuman, SnapshotTriggerTimer, SnapshotTriggerRequested, SnapshotTriggerAnswer] + description: "turn_end: after byre's end-of-turn commit. requested: a task's agent or the lead asked for it with take_snapshot. answer: just before answers from the human were handed to the agent. message (the agent wrote text after one or more tool calls), tool_call, ask_human and timer appear only on snapshots taken by earlier builds." created_at: { type: string, format: date-time } Preview: @@ -1609,21 +1609,19 @@ components: InboxItem: type: object - required: [id, project_id, task_id, agent_session_id, thread_id, kind, payload, state, created_at, responses] + description: A question an agent asked the human with ask_human. + required: [id, project_id, task_id, agent_session_id, thread_id, payload, state, created_at, responses] properties: id: { type: string } project_id: { type: string } task_id: { type: string } agent_session_id: { type: string } thread_id: { type: string } - kind: { type: string, enum: [question, session_error, rate_limited] } payload: type: object description: > - question: context (Markdown with the choices inline as `- ( ) text` / `- (*) text` pick-one or `- [ ] text` / `- [x] text` pick-any list items), + 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. state: { $ref: "#/components/schemas/InboxState" } created_at: { type: string, format: date-time } resolved_by: { type: string } @@ -1634,7 +1632,7 @@ components: InboxResponseType: type: string - enum: [option, other, dismiss] + enum: [option, other] InboxResponse: type: object @@ -1657,6 +1655,7 @@ components: items: { $ref: "#/components/schemas/Attachment" } created_at: { type: string, format: date-time } delivered_at: { type: string, format: date-time } + snapshot_n: { type: integer, description: "The task's snapshot of the worktree as it stood just before the response was handed to the agent; absent until delivered, and when no snapshot could be taken." } InboxResponseCreate: type: object @@ -1746,8 +1745,8 @@ components: TaskTab: type: string - enum: [transcript, board, changes, files, terminal, inbox, reviews, workflow, project] - x-enum-varnames: [TabTranscript, TabBoard, TabChanges, TabFiles, TabTerminal, TabInbox, TabReviews, TabWorkflow, TabProject] + enum: [transcript, board, changes, files, terminal, questions, reviews, workflow, project] + x-enum-varnames: [TabTranscript, TabBoard, TabChanges, TabFiles, TabTerminal, TabQuestions, TabReviews, TabWorkflow, TabProject] TerminalSize: type: object diff --git a/docs/macos.md b/docs/macos.md index a3c4820..37b38e0 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -44,7 +44,7 @@ SwiftUI's own macros don't build here: the SDK declares `@State`, `@Entry`, `@An | `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 | | `Diagram.swift` | Native flowchart parser, layout and drawing for Mermaid blocks and inbox `diagram` attachments | -| `Inbox.swift` | One item at a time, choices, Other, Let the agent decide | +| `Questions.swift` | The Questions tab: one item at a time, choices, Other, Let the agent decide, answered items read-only | | `Changes.swift` | Since main, This turn, pinned snapshots, per-file diffs, line comments | | `Files.swift` | Read-only worktree browser | | `Terminal.swift` | The terminal view and its WebSocket | @@ -83,7 +83,7 @@ Focus lives in `Store`. In-app `#/` links go through `Store.route`: `#/task/ One window. The panes follow the "UI layout" rules in `AGENTS.md`: an `HStack` of two panes of exactly equal width at every size, no sidebar, no navbar, nothing above them but the identity banner when the user has no git identity. The title bar is the standard one; no byre controls go in it or in a toolbar. - **Left pane:** tabs Lead, Status, Settings, with "reconnecting…", the connection dot and the user's name at the end of the strip. -- **Right pane:** the task bar (task picker ` ▸ ▾` and the preview links), then the task's tabs in the server's focus order, hiding Inbox, 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. +- **Right pane:** the task bar (task picker `<project> ▸ <title> ▾` and the preview links), then the task's tabs in the server's focus order, hiding Questions, Reviews and Workflow when they have nothing, with a **More ▾** menu for tabs that don't fit, carrying their badges. The pane's accent is the selected task's colour. Overlays (the switcher, alerts, confirmations, the merge dialog, keyboard help) are SwiftUI views drawn above the panes inside the window, not system sheets or alerts. The switcher and the Discard confirmation sit just below their anchor; the merge dialog is centred in the right pane. @@ -93,13 +93,13 @@ Closing the window leaves the app running with its store and streams (`applicati ## Views -- **Transcript and Lead.** `TranscriptBuilder` is a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review, and notes for the other event kinds. It is incremental: a new batch extends the existing rows. The view is a `ScrollView` with a plain `VStack`, stuck to the bottom unless the user has scrolled more than 80 points up. The stack isn't lazy because a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum. While the user is scrolling (`onScrollPhaseChange`), content height changes only update whether it is stuck and never pull it back to the bottom. The lead pages back when the view comes within one screen of the top: the fetched page is held while the scroll phase is scrolling and inserted once it is idle (`ChatModel.older`, `prependOlder`), and the next geometry change moves the offset down by the height added, so the rows on screen stay put; a page that adds no rows (all folded `NO_REPLY` turns) fetches the next one at once. Each Markdown text run's `NSTextView` measures its height with one TextKit stack kept in its coordinator, and a repeat measurement at the same width and text returns the stored size. Pending sends show at the end until the server echoes them. A turn summary counts as read when its row is on screen and the app is active (`markSummaryRead`). The head carries Compact (with its context-usage fill; a long press opens a menu of models to continue on), Sync from main, Merge and Discard. +- **Transcript and Lead.** `TranscriptBuilder` is a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review, question bubbles that follow their item's `inbox.updated` events and open it in the Questions tab when clicked, and notes for the other event kinds. It is incremental: a new batch extends the existing rows. The view is a `ScrollView` with a plain `VStack`, stuck to the bottom unless the user has scrolled more than 80 points up. The stack isn't lazy because a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum. While the user is scrolling (`onScrollPhaseChange`), content height changes only update whether it is stuck and never pull it back to the bottom. The lead pages back when the view comes within one screen of the top: the fetched page is held while the scroll phase is scrolling and inserted once it is idle (`ChatModel.older`, `prependOlder`), and the next geometry change moves the offset down by the height added, so the rows on screen stay put; a page that adds no rows (all folded `NO_REPLY` turns) fetches the next one at once. Each Markdown text run's `NSTextView` measures its height with one TextKit stack kept in its coordinator, and a repeat measurement at the same width and text returns the stored size. Pending sends show at the end until the server echoes them. A turn summary counts as read when its row is on screen and the app is active (`markSummaryRead`). The head carries Compact (with its context-usage fill; a long press opens a menu of models to continue on), Sync from main, Merge and Discard. - **Composer.** A `TextEditor`, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered to the live session's vendor), Interrupt and Send. Enter sends, Shift-Enter adds a newline, and Enter during IME composition does neither. Pasted or dropped PNG, JPEG, GIF and WebP images become thumbnails with ×, uploaded to `POST /api/blobs` on send; any other image `NSBitmapImageRep` decodes, such as TIFF, is converted to PNG first, its size checked after conversion. A failed send puts the text and images back and shows the error. -- **Inbox.** The selected task's oldest open item, rendered from its Markdown context with each choice's marker replaced by a clickable block (pick-one, sends at once) or checkbox (pick-any, with Submit), the "recommended" tag, image-choice grids, attachments, Let the agent decide, Other… and, for errors, Reply… and Dismiss. Arrow keys move between choices. A 409 shows its error. The Changes summary line sits above it. +- **Questions.** The question a transcript bubble picked (`Store.question`), read-only once it is no longer open, or else the selected task's oldest open item, rendered from its Markdown context with each choice's marker replaced by a clickable block (pick-one, sends at once) or checkbox (pick-any, with Submit), the "recommended" tag, image-choice grids, attachments, Let the agent decide and Other…. Arrow keys move between choices. A 409 shows its error. The Changes summary line sits above it. - **Changes, Files, Reviews, Workflow, Project, Status, Settings.** Including the pinned-snapshot stepper, open-diff memory, per-task place in Files, finding diffs with Fix and Ignore, nested subtasks, TODO Start and Discuss, rate-limit pace bars and lead notes. The Workflow tab draws its stage graph natively from the `WorkflowRun` (stages as boxes coloured by state, `after` as edges, laid out top to bottom by depth) with the layout and drawing code from "Diagrams", fed from the run's data rather than Mermaid text. - **Terminal.** A terminal view attached to `GET /api/tasks/{taskId}/terminal` with `URLSessionWebSocketTask`: binary input and output, `TerminalSize` JSON on resize, close code 4000 for `shell ended: <status>` with New shell, 1011 for a start failure, reconnect after 2 s while the task is active, one socket per open tab and selected task. Colours come from the palette. Ctrl combinations typed in it go to the shell. The cursor is a steady block (`setCursorStyle(.steadyBlock)` when the view is made): SwiftTerm's blinking cursor restarts its Core Animation fade on every keypress, scheduled 0.7 s out, and on a ProMotion (variable refresh rate) display macOS then holds the window's screen update until that fade starts, so each typed letter appears about 0.7 s late. A program in the shell can still ask for a blinking cursor with DECSCUSR. -- **Switcher.** The ⌘K overlay, also opened by clicking the task picker, centred below the picker: tasks with inbox items first, then least recently visited, the selected task last, the `name: title` project prefix, New task, Start in …, Start workflow… and its second step, projects, Escape stepping back, and the list scrolling to keep the highlighted row in view. Picking a task opens its Inbox when it has items, its unread summary when it has one, and its Transcript otherwise. -- **Merge and Discard.** The transcript head's Merge button is enabled once `GET /api/tasks/{taskId}/unmerged` reports commits or files, refetched 1.5 s after a tool result or turn end. `confirmMerge` (`MergeDialog.swift`): the editable commit title (at most 72 characters, kept across a warning link's detour), co-authors, warnings that refetch on the task's snapshot and review events and link to the Reviews or Inbox tab, the parent-or-default-branch choice for subtasks, and Merge now / After review while the latest snapshot's review is running. It is centred in the right pane. Discard confirms below its button, then clears the focused task. +- **Switcher.** The ⌘K overlay, also opened by clicking the task picker, centred below the picker: tasks with inbox items first, then least recently visited, the selected task last, the `name: title` project prefix, New task, Start in …, Start workflow… and its second step, projects, Escape stepping back, and the list scrolling to keep the highlighted row in view. Picking a task opens its Questions tab when it has open items, its unread summary when it has one, and its Transcript otherwise. +- **Merge and Discard.** The transcript head's Merge button is enabled once `GET /api/tasks/{taskId}/unmerged` reports commits or files, refetched 1.5 s after a tool result or turn end. `confirmMerge` (`MergeDialog.swift`): the editable commit title (at most 72 characters, kept across a warning link's detour), co-authors, warnings that refetch on the task's snapshot and review events and link to the Reviews or Questions tab, the parent-or-default-branch choice for subtasks, and Merge now / After review while the latest snapshot's review is running. It is centred in the right pane. Discard confirms below its button, then clears the focused task. ## Markdown diff --git a/docs/operations.md b/docs/operations.md index dc3eae7..e1364b1 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -32,7 +32,7 @@ Every task worktree is made by `createTask` (`internal/agents/tasks.go`): new ta ## Closing a task -A task ends `merged` or `discarded`; either way its sessions are stopped, its snapshot refs, worktree and branch are removed, its `open` inbox items become `dismissed` (resolved by whoever closed it), and its record, summary and events remain. `GET /api/tasks/{id}/unmerged` reports what would be lost or merged: commits on the branch that aren't on the default branch, and files, lines and uncommitted paths that differ from the merge base. The UI's Merge and Discard buttons each ask a confirmation, Discard's just below its button like the task picker's switcher and Merge's centred in the right pane, and are disabled while the task's session is `running`. Merge is also disabled until `unmerged` reports a commit or a changed file; the transcript fetches it when the task is opened and again shortly after each tool result or turn end. Merge's confirmation (`confirmMerge` in `mac/Sources/Byre/MergeDialog.swift`) is kept brief: the commit title as text with a ✎ button that swaps in an input, a collapsed "N co-authors" line holding the trailers, and one warning line for each thing worth stopping for. Those are the latest snapshot's review still running, failed, missing or with findings that weren't ignored (counted by severity); the task's open inbox questions, which merging dismisses; the worktree differing from the latest snapshot (`changes?since=` its commit), or no snapshot at all; and "Review status unavailable" when those fetches fail. Earlier snapshots' reviews aren't mentioned. Warnings never block Merge. When the latest snapshot's review is running as the dialog opens, its buttons are Cancel, **Merge now** and **After review**, the default (Enter picks it); otherwise Cancel and **Merge**. The buttons are chosen once at opening, so a review that finishes while the dialog is open leaves **After review** there and choosing it is refused. The review and question warnings link to the Reviews tab at that snapshot or the Inbox tab; following one closes the dialog and keeps the title, so the next Merge opens with it. While open, the dialog refetches on the task's `snapshot.created` and `review.*` events and redraws on inbox changes. +A task ends `merged` or `discarded`; either way its sessions are stopped, its snapshot refs, worktree and branch are removed, its `open` inbox items become `dismissed` (resolved by whoever closed it), and its record, summary and events remain. `GET /api/tasks/{id}/unmerged` reports what would be lost or merged: commits on the branch that aren't on the default branch, and files, lines and uncommitted paths that differ from the merge base. The UI's Merge and Discard buttons each ask a confirmation, Discard's just below its button like the task picker's switcher and Merge's centred in the right pane, and are disabled while the task's session is `running`. Merge is also disabled until `unmerged` reports a commit or a changed file; the transcript fetches it when the task is opened and again shortly after each tool result or turn end. Merge's confirmation (`confirmMerge` in `mac/Sources/Byre/MergeDialog.swift`) is kept brief: the commit title as text with a ✎ button that swaps in an input, a collapsed "N co-authors" line holding the trailers, and one warning line for each thing worth stopping for. Those are the latest snapshot's review still running, failed, missing or with findings that weren't ignored (counted by severity); the task's open inbox questions, which merging dismisses; the worktree differing from the latest snapshot (`changes?since=` its commit), or no snapshot at all; and "Review status unavailable" when those fetches fail. Earlier snapshots' reviews aren't mentioned. Warnings never block Merge. When the latest snapshot's review is running as the dialog opens, its buttons are Cancel, **Merge now** and **After review**, the default (Enter picks it); otherwise Cancel and **Merge**. The buttons are chosen once at opening, so a review that finishes while the dialog is open leaves **After review** there and choosing it is refused. The review and question warnings link to the Reviews tab at that snapshot or the Questions tab; following one closes the dialog and keeps the title, so the next Merge opens with it. While open, the dialog refetches on the task's `snapshot.created` and `review.*` events and redraws on inbox changes. A task's summary is what its agent writes, before the task closes, about the whole task: what it set out to do, what was built or tried, the key decisions and why, and what was left out. The agent sets it with the `set_task_summary` MCP tool (`internal/agents/summary.go`), any time while the task is active; each call replaces `tasks.summary` and records a `task.summary` event carrying it, which the transcript shows as a note. The Merge turn's prompt and the discard turn's prompt (`summaryRequest`) both ask for it. Turn summaries don't stand in for it: each covers only its own turn, and a merged task's last one is always the Merge turn's. Tasks closed before the column existed have none. `sqlite3 byre.db "SELECT id, status, summary FROM tasks WHERE status != 'active' ORDER BY closed_at DESC LIMIT 5"` shows recent ones. @@ -49,7 +49,7 @@ Agents find closed tasks with the `search_closed_tasks` MCP tool, which task age - **Request.** `RequestDiscard` records the requester in `discarding_by`, sets `held`, clears any held merge and emits `task.discard_started` (a transcript note). The request returns the still-active task with 202. It is refused (409) while the task is merging or its discard is `held` or `running`; on a `closing` task it retries the cleanup. Merge and holding a merge are refused while `discard_state` is set. - **Admission.** While `discard_state` is set, no ordinary turn starts in the task: the store's idle-to-running transition (`transitionSession`) refuses it, so messages, inbox answers, Sync, Compact, queued messages and workflow stage starts all get a conflict and stay queued or held. Queued messages and answers aren't handed over mid-turn either (`deliverMidTurn`). The one way in is `StartDiscardTurn`, which needs `held` and an idle implementer session, and in one transaction sets the session `running`, the state `running` and `discard_session`. byre calls it (`startDiscard` in `internal/agents/summary.go`) right after the request, and again whenever a turn of the task ends with the state `held`; whichever call comes first wins, and a busy session simply leaves the state `held` until its turn ends. It uses the task's live implementer session, or a new one created by the task's creator when there is none, and sends `discardPrompt` (actor `byre`): the task is being discarded, change no files, call `set_task_summary` saying also why it is being dropped, then end the turn. Open inbox questions don't hold it back. If starting the turn fails, byre gives up on the summary: `CloseDiscard` sets `closing` and the task is removed. -- **Turn end.** Every implementer turn end, in `turnEnded`, on an agent process that exits mid-turn (`pump`), and on a session that can't be resumed or sent its restart notice at startup (`failResume`, after which startup carries on with the other sessions; a session whose task can't be loaded is logged as `resume session: load task` and left `running`), marks the session idle through `EndTurn`, which in the same transaction reports what the discard needs: `closing` when the session is `discard_session` and the state was `running` (it sets `closing`), `held` when a discard is waiting. A turn of the task that ends while a discard is pending takes no snapshot. On `held`, the ending turn's handoff continuation and inbox delivery are skipped and the summary turn starts instead, in the same session, so a turn that was writing a handoff hands the summary turn its full context. On `closing`, `finishDiscard` stops the session, cancels reviews, deletes the snapshot refs, removes the worktree and branch (`git.RemoveWorktree`, which skips whatever is already gone so the cleanup can be repeated), and only then closes the task with `discarding_by` as the actor. The summary turn closes the task however it ends (finished, interrupted, an error), so interrupting it discards without a summary; a summary turn whose process dies discards without a summary too, while an ordinary turn whose process dies while a discard is `held` gets the summary turn started on a new process. A failed cleanup is filed in the task's inbox and leaves the state `closing`. +- **Turn end.** Every implementer turn end, in `turnEnded`, on an agent process that exits mid-turn (`pump`), and on a session that can't be resumed or sent its restart notice at startup (`failResume`, after which startup carries on with the other sessions; a session whose task can't be loaded is logged as `resume session: load task` and left `running`), marks the session idle through `EndTurn`, which in the same transaction reports what the discard needs: `closing` when the session is `discard_session` and the state was `running` (it sets `closing`), `held` when a discard is waiting. A turn of the task that ends while a discard is pending takes no snapshot. On `held`, the ending turn's handoff continuation and inbox delivery are skipped and the summary turn starts instead, in the same session, so a turn that was writing a handoff hands the summary turn its full context. On `closing`, `finishDiscard` stops the session, cancels reviews, deletes the snapshot refs, removes the worktree and branch (`git.RemoveWorktree`, which skips whatever is already gone so the cleanup can be repeated), and only then closes the task with `discarding_by` as the actor. The summary turn closes the task however it ends (finished, interrupted, an error), so interrupting it discards without a summary; a summary turn whose process dies discards without a summary too, while an ordinary turn whose process dies while a discard is `held` gets the summary turn started on a new process. A failed cleanup is recorded as an error in the task's transcript (see "Errors") and leaves the state `closing`. - **Workflows.** `FinishStage` refuses to mark a stage finished while `discard_state` is set, so a stage whose turn ends after the discard was requested drops its output and doesn't advance. A stage that finished before the request advances as usual (`advanceStage` runs before the summary turn starts, since it reads the worktree's HEAD), and a `closing` task's stage never advances (`nextStage` clears it). An existing stage task being discarded isn't restarted by `advanceTo`. - **Restarts.** On startup, a `running` summary turn's session is resumed with `discardRestartPrompt` (the discard prompt again, after a line saying byre restarted) instead of the usual restart notice; `recoverDiscards` then starts the summary turn for each `held` task whose session isn't running, closes each `running` one whose session isn't running, and finishes each `closing` one. - **Handoffs.** A handoff continuation (`handoff` in `internal/agents/compact.go`) doesn't start while a discard is pending; it starts the summary turn in the old session instead. It retires the old session only through an idle-to-completed transition, so it can't retire a session the summary turn has just taken, and if a discard arrives between retiring and starting the continuation, the summary turn runs in the new session, without the handoff. @@ -76,13 +76,13 @@ A task's participants are the users who put a message bubble with their name in `POST /api/tasks/{id}/merge` starts the same turn, with the prompt's closing invitation to revise the title left out since the human just confirmed it and a request to call `set_task_summary` in its place, and records in `tasks.merging_by` that the task is to be merged by the requesting user (emitting `task.merge_started` with that user as actor, not drawn), who must have a git identity (400 otherwise). Every turn end of the task's session reads and clears it. It survives a byre restart during the turn: startup resumes the session with the restart notice, and the merge happens when that resumed turn ends. When that turn ends normally (not interrupted, not an error), the transaction that ends it (`Store.EndTurn`) moves the login from `merging_by` to `tasks.finalizing`, so the task stays **Merging** while byre squashes, pushes and closes it. While `finalizing` is set, the task's implementer session can't start a turn (a message or Sync is a 409; the store refuses the `running` transition in `transitionSession`), Discard and merging into a parent are refused as merging, `set_merge_title` is refused, and the task can't get new subtasks (`ErrParentNotOpen`). Closing the task clears it; a failed squash or push clears it (emitting `task.merge_ended`) before filing the error, so the queued inbox responses can be delivered. Just before pushing, byre records the squash commit and its notes commit in `tasks.finalizing_commit` and `tasks.finalizing_notes` (`Store.SetFinalizingCommit`); when the merge changes nothing, it records that in `tasks.finalizing_unchanged` (`Store.SetFinalizingUnchanged`) before removing the worktree. A push the remote rejected as non-fast-forward clears the marker as a failed push. Any other push error is ambiguous, since the remote may have taken the push before the connection dropped, so byre runs `git fetch origin` and checks whether the commit is an ancestor of `origin/<default branch>`: when it is, it finishes as if the push had succeeded; when it isn't, the push failed; when the check itself fails, the marker stays. A failure after the push was accepted (updating the checkout's notes, fast-forwarding it, removing the worktree, closing the task), or while closing an unchanged merge, keeps the marker and files an error saying byre finishes the merge when it next starts. At startup, `recoverFinalizing` (`internal/agents/mergein.go`) takes each task with `finalizing` set: an unchanged merge is finished by removing the worktree and closing the task with no commit; with a recorded commit, it runs `git fetch origin` and, when the commit is an ancestor of `origin/<default branch>`, finishes the merge (`closePushed`: advance the checkout's `refs/notes/byre` to the notes commit unless it already contains it, `git merge --ff-only`, remove the worktree, close the task), each step a no-op when already done. When the commit is not on the remote, or none was recorded, it clears the marker and files an error on the task saying to merge again; when the fetch, the ancestry check or a finishing step fails, it keeps the marker for the next start and files the error. `sqlite3 byre.db "SELECT id, finalizing, finalizing_commit FROM tasks WHERE finalizing != ''"` lists merges being finished. Then byre squashes the worktree, now containing the default branch, onto it with `git merge-tree --merge-base`, makes one commit titled with the task's `merge_title` with `git commit-tree`, authored and committed by the merging user with no agent attribution, its body one `Co-authored-by: Name <email>` trailer per co-author with a git identity (`squashMessage` in `internal/agents/mergein.go`; the co-authors are computed when the squash runs, relative to the user who clicked Merge), and pushes that commit to the default branch on the `origin` remote of the project's checkout together with its note (below), in one atomic push (`git push --atomic --porcelain origin <commit>:refs/heads/<default branch> <notes commit>:refs/notes/byre`, run in the checkout), so either both land or neither does. Only once the push is accepted does it point the checkout's `refs/notes/byre` at the notes commit, run `git merge --ff-only` in the project's checkout (which must have the default branch checked out) and close the task; the `task.merged` event carries the commit and `pushed` (`origin/<default branch>`), and the transcript shows both. When the merge changes nothing, no commit is made and nothing is pushed. -Before the agent gets the prompt, Merge runs `git fetch origin` (every configured refspec, so the notes too) and `git merge --ff-only origin/<default branch>` in the checkout, so the agent merges in what the remote has. The request doesn't wait for it: it sets `merging_by`, moves the session to `running` and returns 202, and a goroutine (`launchMerge` in `internal/agents/mergein.go`) starts the agent process if needed, emits the prompt's `user.message` so the transcript shows it at once, then fetches and only then sends the prompt to the agent. A failure there (the process, or the fetch: network, or a checkout with commits the remote lacks) puts the session back to `idle` with the error, which the transcript shows as a `session idle: …` note, and abandons the merge, clearing `merging_by` and releasing the project's merge. Interrupt during the fetch (`live.fetching` set) records the interrupt without calling the vendor, since no vendor turn exists yet; the fetch runs to its end, and then the prompt isn't sent and the merge is abandoned the same way, with the error `interrupted before the merge started`. If the push is still refused, for example because someone pushed during the turn, or fails for any other reason, byre files the error in the inbox and the task stays active; clicking Merge again fetches and goes round once more. A refused push leaves the checkout's default branch where it was, since the squash commit was never moved onto it. If the squash fails, for example because the default branch moved again during the turn, byre also files an error and the task stays active. The agent calls `stop_merge` (`internal/agents/mergein.go`) with a `reason` when its conflict resolution took substantive changes: it clears `merging_by` and releases the project's merge at once, and records a `task.merge_stopped` event carrying the reason, which the transcript shows as a note. The turn then ends without a squash, the task stays active, its snapshot is reviewed, and the human clicks Merge again when satisfied. Called with no merge pending (a Sync turn, say), it changes nothing and tells the agent so. An interrupted or failed turn, an agent process that exits mid-turn, or a session that can't be resumed after a restart clears `merging_by` and leaves the task active without squashing; a turn that ends interrupted or with an empty final message also files an error in the inbox saying the merge didn't happen. A Claude `result` with `num_turns` 0, no `result` text and no error, outside an interrupt, isn't a turn end: the CLI emits one on its own, for example after a resume when it reports a background shell left over from the previous process, possibly after byre has already sent the next message. byre logs `ignored result with no model turns`, records it as an `agent.vendor` event, and keeps the turn running, so it can't take `merging_by` from the Merge turn that follows. Merge (either target, see "Subtasks") and Discard are refused (409, naming them) while the task has active subtasks, whoever asks: the button, the lead or an agent. +Before the agent gets the prompt, Merge runs `git fetch origin` (every configured refspec, so the notes too) and `git merge --ff-only origin/<default branch>` in the checkout, so the agent merges in what the remote has. The request doesn't wait for it: it sets `merging_by`, moves the session to `running` and returns 202, and a goroutine (`launchMerge` in `internal/agents/mergein.go`) starts the agent process if needed, emits the prompt's `user.message` so the transcript shows it at once, then fetches and only then sends the prompt to the agent. A failure there (the process, or the fetch: network, or a checkout with commits the remote lacks) puts the session back to `idle` with the error, which the transcript shows as a `session idle: …` note, and abandons the merge, clearing `merging_by` and releasing the project's merge. Interrupt during the fetch (`live.fetching` set) records the interrupt without calling the vendor, since no vendor turn exists yet; the fetch runs to its end, and then the prompt isn't sent and the merge is abandoned the same way, with the error `interrupted before the merge started`. If the push is still refused, for example because someone pushed during the turn, or fails for any other reason, byre records the error in the transcript and the task stays active; clicking Merge again fetches and goes round once more. A refused push leaves the checkout's default branch where it was, since the squash commit was never moved onto it. If the squash fails, for example because the default branch moved again during the turn, byre also files an error and the task stays active. The agent calls `stop_merge` (`internal/agents/mergein.go`) with a `reason` when its conflict resolution took substantive changes: it clears `merging_by` and releases the project's merge at once, and records a `task.merge_stopped` event carrying the reason, which the transcript shows as a note. The turn then ends without a squash, the task stays active, its snapshot is reviewed, and the human clicks Merge again when satisfied. Called with no merge pending (a Sync turn, say), it changes nothing and tells the agent so. An interrupted or failed turn, an agent process that exits mid-turn, or a session that can't be resumed after a restart clears `merging_by` and leaves the task active without squashing; a turn that ends interrupted or with an empty final message also records an error saying the merge didn't happen. A Claude `result` with `num_turns` 0, no `result` text and no error, outside an interrupt, isn't a turn end: the CLI emits one on its own, for example after a resume when it reports a background shell left over from the previous process, possibly after byre has already sent the next message. byre logs `ignored result with no model turns`, records it as an `agent.vendor` event, and keeps the turn running, so it can't take `merging_by` from the Merge turn that follows. Merge (either target, see "Subtasks") and Discard are refused (409, naming them) while the task has active subtasks, whoever asks: the button, the lead or an agent. Each squash commit gets a git note under `refs/notes/byre`, the durable record of the task (`mergeNote` in `internal/agents/notes.go`): a Markdown heading with the merge title; the task's id, title, creator, merging user, TODO item and fork source; a pointer to the full transcripts as the task's events in byre (`GET /api/events?task_id=<id>`), which the note doesn't copy; the task summary, when the agent wrote one; every handoff its sessions wrote on compaction (the `result` of each `turn.ended` with `handoff: true`); each review's state, error, `change` and `summary`, by snapshot; and each `answered` question with its context and the responses to it. Plans and eval results aren't recorded yet, since byre has neither. `git.NoteCommit` builds the notes commit on top of the checkout's current `refs/notes/byre` in a temporary index, authored as the merging user, without moving the ref, so a refused push leaves the checkout's notes as they were. Notes don't fetch by default, so registering a project (`POST /api/projects`), and every byre startup for each existing project, adds the refspec `refs/notes/byre*:refs/notes/byre*` to the checkout's `remote.origin.fetch` if it isn't there (`git.ConfigureNotesFetch`); it's a pattern because git refuses a fetch whose exact refspec names a ref the remote doesn't have yet, as it doesn't before the first note is pushed. Another clone adds the same refspec with `git config --add remote.origin.fetch 'refs/notes/byre*:refs/notes/byre*'`. `git log --notes=byre` shows the notes, and `git notes --ref=byre show <commit>` one commit's. git's network commands (`git fetch` and `git push`, through `runNetwork` in `internal/git/git.go`) are bounded by `networkTimeout` and by the caller's context, and run non-interactively: `GIT_TERMINAL_PROMPT=0`, and `GIT_SSH_COMMAND` set to `ssh` with `BatchMode=yes` and a connect timeout (`networkEnv`), which replaces any `core.sshCommand` the checkout configures. A remote needing a password or an unknown host key fails at once instead of waiting for input. Every git command runs in its own process group, and cancelling one kills the whole group, so an `ssh` or remote helper it started can't outlive the deadline and finish a fetch or push after byre has moved on. A timed-out fetch fails the Merge like any other fetch error; a timed-out push is ambiguous and goes through the landed check above. -One merge runs per project at a time. The Manager keeps, per project, a lock and the task holding that project's merge in memory (`projectMerge` in `internal/agents/mergein.go`, in `Manager.merges`), from when `merging_by` is set until the merge closes the task, fails, or is abandoned as above, or the task is discarded; on startup it is rebuilt from the active tasks with `merging_by` set. The fetch runs after the lock is released but while the task holds the merge, so a stalled remote delays merges in that project alone, and Merge requests in the same project meanwhile see its holder. Merge on another task of that project while one is held doesn't fetch or start a turn: it records the request in `tasks.merge_queued_by` and `merge_queued_at` (`merge_queued_at` on the Task), emits `task.merge_queued` (a transcript note), and returns the task with 202. A second Merge on a held or merging task is a 409. Whenever a project's merge is released, any task turn in it ends, and on startup, byre starts the oldest held merge in the project whose task session is not `running`: it clears the request (emitting `task.merge_unqueued`, not drawn) and goes through the same fetch and turn as a direct Merge by the user who asked, so it merges in what the earlier merge pushed. If that start fails before the turn begins (the user has no git identity, or the session isn't idle), byre files the error in the task's inbox and tries the next held merge; a fetch failure after that ends the turn like a direct Merge's, as a transcript note. The Merge button reads **Merge held**, disabled, while the task has `merge_queued_at`; discarding the task drops the request. `sqlite3 byre.db "SELECT id, merge_queued_by, merge_queued_at FROM tasks WHERE merge_queued_by != ''"` lists held merges. +One merge runs per project at a time. The Manager keeps, per project, a lock and the task holding that project's merge in memory (`projectMerge` in `internal/agents/mergein.go`, in `Manager.merges`), from when `merging_by` is set until the merge closes the task, fails, or is abandoned as above, or the task is discarded; on startup it is rebuilt from the active tasks with `merging_by` set. The fetch runs after the lock is released but while the task holds the merge, so a stalled remote delays merges in that project alone, and Merge requests in the same project meanwhile see its holder. Merge on another task of that project while one is held doesn't fetch or start a turn: it records the request in `tasks.merge_queued_by` and `merge_queued_at` (`merge_queued_at` on the Task), emits `task.merge_queued` (a transcript note), and returns the task with 202. A second Merge on a held or merging task is a 409. Whenever a project's merge is released, any task turn in it ends, and on startup, byre starts the oldest held merge in the project whose task session is not `running`: it clears the request (emitting `task.merge_unqueued`, not drawn) and goes through the same fetch and turn as a direct Merge by the user who asked, so it merges in what the earlier merge pushed. If that start fails before the turn begins (the user has no git identity, or the session isn't idle), byre records the error in the task's transcript and tries the next held merge; a fetch failure after that ends the turn like a direct Merge's, as a transcript note. The Merge button reads **Merge held**, disabled, while the task has `merge_queued_at`; discarding the task drops the request. `sqlite3 byre.db "SELECT id, merge_queued_by, merge_queued_at FROM tasks WHERE merge_queued_by != ''"` lists held merges. `POST /api/tasks/{id}/merge` with `after_review: true` (the dialog's **After review**) starts nothing (`MergeAfterReview` in `internal/agents/afterreview.go`). It checks what the chosen target's Merge would (git identity and title for `main`, a parent for `parent`, no subtasks, not already merging, held or merging into the parent), stores the title like Merge does, and records the requesting user, the running review of the latest snapshot and the target in `tasks.merge_after_review_by`, `merge_after_review_id` and `merge_after_review_target` (`merge_after_review` on the Task; a `task.merge_after_review` event, drawn as a note). It is a 409 when that review isn't running. Asking again replaces the request. Whenever a review finishes (submitted, failed, interrupted, or failed by a restart or the task closing), `reviewFinished` takes the request if it names that review (emitting `task.merge_after_review_ended`, not drawn) and makes the merge as that user, through the same `Merge` or `MergeIntoParent` as a direct request, when the review is `done` with no findings, its snapshot is still the latest, the task is active and not being discarded, and no turn is running. Otherwise, or when that merge is refused, it drops the request with a `task.merge_after_review_dropped` event carrying the reason, drawn as a note. On startup, `recoverMergeAfterReviews` runs the same check for every request whose review is no longer `running`, so one left by a restart between the review finishing and its request being taken still merges or is dropped. A direct Merge (either target) clears the request; so do Discard and closing the task. `DELETE /api/tasks/{id}/merge-after-review` withdraws it (404 when none is waiting), emitting `task.merge_after_review_cancelled`, drawn as a note naming who cancelled. The transcript's `task.merge_after_review` note carries a **Cancel** button that calls it; only the last such note in the transcript shows the button, and only while the task has `merge_after_review` (`NoteView` and `lastMergeAfterReview` in `mac/Sources/Byre/Chat.swift` and `TranscriptBuilder.swift`). The Merge button reads **Merge after review**, still enabled so the dialog can merge now, and the task state label is **Merge after review**. `sqlite3 byre.db "SELECT id, merge_after_review_by, merge_after_review_id FROM tasks WHERE merge_after_review_by != ''"` lists them. @@ -193,7 +193,7 @@ Compaction is vendor-agnostic: `POST /api/sessions/{id}/compact` (the transcript Task agents spawn parallel work as subtasks (see "Subtasks"). -Agents don't commit; byre does. Each `user.message` event that starts a turn records `head`, the worktree's `HEAD` when the turn started. When a turn ends, byre runs `git add --all` and commits if anything changed (`git.CommitAll`, under the task's mutex, see "Teams"), as the session's creator, with the first sentence of the turn's final message as the message (`commitMessage` in `internal/agents/manager.go`); `turn.ended` then carries the new sha as `commit`. A failed commit files a `session_error` in the task's inbox and the turn ends unfinished: no summary, no handoff, no merge finalize, and a workflow stage neither finishes nor records its output, so the next stage never branches from a `HEAD` that lacks the turn's work. +Agents don't commit; byre does. Each `user.message` event that starts a turn records `head`, the worktree's `HEAD` when the turn started. When a turn ends, byre runs `git add --all` and commits if anything changed (`git.CommitAll`, under the task's mutex, see "Teams"), as the session's creator, with the first sentence of the turn's final message as the message (`commitMessage` in `internal/agents/manager.go`); `turn.ended` then carries the new sha as `commit`. A failed commit records a `session.error` in the task's transcript and the turn ends unfinished: no summary, no handoff, no merge finalize, and a workflow stage neither finishes nor records its output, so the next stage never branches from a `HEAD` that lacks the turn's work. A session runs on one of two vendors, picked by the model it is created with (`workflow.VendorOf` in `internal/workflow/workflow.go`): `gpt-*` models run on Codex (`codex app-server`), every other model on Claude Code. The UI's model list is `models` in `mac/Sources/Byre/Composer.swift`; a live session's picker offers only its own vendor's models, and the API refuses (400) a switch to the other vendor's model. Both vendors sit behind `agentProcess` (`internal/agents/process.go`), and both end a turn through the same `turnEnded`, so commits, turn summaries, snapshots, handoffs, merges and deliveries don't depend on the vendor. @@ -203,11 +203,11 @@ Every session gets byre's guidance (`guidance` in `internal/agents/guidance.go`) A turn whose final message asks the human something in prose is bounced once (`takeBounce` in `internal/agents/guidance.go`): if `ask_human` wasn't called during the turn and the final message's last paragraph has a sentence ending in `?` outside code spans, code blocks, quotes, blockquotes, table rows and `<details>` sections, the agent is told to ask through `ask_human` and carries on. Questions earlier in the message (quoted, answered, or listed for someone else) don't count. A task started as a TODO discussion (its first `user.message` holds `discussPrompt` from `internal/agents/tasks.go`) is never bounced, since that prompt tells the agent not to use `ask_human`, and neither is a task with an open question already in the inbox, since the prose then points at that question. A bounce is recorded as a `byre` `user.message` with `mid_turn: true` and `bounce: true` carrying the nudge; the transcript draws neither it nor the agent message before it, so the rewritten message takes the blocked one's place. For Claude the bounce is a Stop hook (`bounceProseQuestion`) that blocks the stop; Claude Code reports the block as a "Stop hook error" notification, which is expected. For Codex, byre doesn't end its turn on `turn/completed`: it records the nudge and starts another Codex turn with it, and only that turn's end ends byre's turn. The per-turn flags reset when the next user message is sent. An agent told explicitly to ask in plain text will refuse the bounce. -A turn's final message is its summary, and it lives only in the transcript: nothing is filed in the inbox, so the human reads it once. When a task turn ends normally (not an error, not interrupted, not a handoff) with non-empty `result` text, didn't call `ask_human`, and isn't a Merge turn or discard turn (the task closes right after one, so its final message is never left to read), its `turn.ended` event carries `summary: true`. A Merge turn that called `stop_merge` keeps the task open and does carry it. That flag drives the lead's "finished a turn" signal and the "Turn summaries" notification (see "Lead agent" and "Notifications"). A turn that asked a question has no summary, since the final message comes after the question it explains; the guidance tells agents to put everything the human needs in the question's context. There is no report tool: models write a closing recap whatever they are told, so byre uses it rather than asking for a second one. +A turn's final message is its summary, and it lives only in the transcript, so the human reads it once. When a task turn ends normally (not an error, not interrupted, not a handoff) with non-empty `result` text, didn't call `ask_human`, and isn't a Merge turn or discard turn (the task closes right after one, so its final message is never left to read), its `turn.ended` event carries `summary: true`. A Merge turn that called `stop_merge` keeps the task open and does carry it. That flag drives the lead's "finished a turn" signal and the "Turn summaries" notification (see "Lead agent" and "Notifications"). A turn that asked a question has no summary, since the final message comes after the question it explains; the guidance tells agents to put everything the human needs in the question's context. There is no report tool: models write a closing recap whatever they are told, so byre uses it rather than asking for a second one. Every turn starts as soon as it is sent; byre doesn't cap how many run at once. Whether a turn resumes the Claude conversation is decided by whether the session has ever sent one. -When Claude Code reports a rate-limit status of `rejected` and the turn then fails, or a Codex turn fails with `codexErrorInfo` `usageLimitExceeded` or `rateLimitExceeded`, the inbox gets a `rate_limited` item carrying the vendor's status instead of a generic error. +When Claude Code reports a rate-limit status of `rejected` and the turn then fails, or a Codex turn fails with `codexErrorInfo` `usageLimitExceeded` or `rateLimitExceeded`, byre records a `session.rate_limited` event carrying the vendor's status instead of a generic error (see "Errors"). Codex runs with its own home, `codex` under the data directory (`CODEX_HOME`, set by `startCodex`), so the human's `~/.codex/config.toml` (its `notify` program, MCP servers and plugins) doesn't apply and byre's threads stay out of the ChatGPT app's history. That home needs its own login, once: `CODEX_HOME="$HOME/Library/Application Support/byre/codex" codex login`. Until then a Codex turn fails with `401 Unauthorized`, filed as a session error. The `codex` binary comes from `PATH`; see `docs/codex-protocol.md`. @@ -230,15 +230,15 @@ Event payloads are stored whole in `events.payload`, and `agent.tool_result` con Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. The transcript view has a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is `running` and its transcript has an open turn (`open` in `TranscriptBuilder`: a drawn turn-starting `user.message`, not mid-turn, with no `turn.ended` after it), and a Cylon-style scanner in the same colour: a row of 2pt dots, 5pt apart along an 84pt track, where a glow sweeping back and forth every 1.2s lights up the dots under it, drawn below the last message, in the transcript's bottom padding, so it scrolls with the transcript and sits just above the composer's top border when scrolled to the bottom. It is an overlay 8pt below the transcript's last row, so showing or hiding it never changes the transcript's scroll height or position, and it is drawn with Core Animation (`ScannerView` in `mac/Sources/Byre/Chat.swift`: a gradient layer masked by the dots, its position animated by a repeating `CABasicAnimation`), so the render server animates it and SwiftUI never redraws the transcript for it. The session list (`GET /api/me/lead/sessions` or the task's sessions) is fetched separately from the event stream, so it says `running` a little before the turn's `user.message` arrives and a little after its `turn.ended`; requiring the open turn as well keeps the scanner and border from flashing on in those gaps. Consecutive tool calls collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. Thinking output appears as expanded Markdown text with a small muted "thinking" label and ends the current tool-call group just like an agent message, including inside subagent rows. While the scanner shows, a tool call still waiting for its result, and any group or subagent row holding one, gets a border in the task's colour, so a running tool reads apart from waiting on the model without expanding anything (`working` in `mac/Sources/Byre/Chat.swift`). The braille spinner the Project tab shows for task states is drawn with Core Animation too (`SpinnerView` in `mac/Sources/Byre/Format.swift`: its ten glyphs rendered once into images in the surrounding font and colour, cycled every 80ms by a repeating discrete `CAKeyframeAnimation` on the layer's contents), so it never makes SwiftUI redraw or lay out the views around it. When a turn ends, or a turn-starting `user.message` (not mid-turn) arrives, any call still without a result, other than those inside an active subagent row (below), reads "no result" instead of "running…", so an interrupted call never spins during a later turn. The second case covers a turn a restart cut off: it has no `turn.ended`, and the restart notice starts the next turn (`endTools` in `mac/Sources/Byre/TranscriptBuilder.swift`). byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `toolLabel` in `mac/Sources/Byre/TranscriptBuilder.swift`); other tools show under the name the agent used. A Claude Code subagent call (the `Agent` or `Task` tool) stands on its own, collapsed, labelled with its `subagent_type` and `description`; expanding it shows the prompt and the call's result. Its summary carries a status from the `agent.vendor` `system` events whose `tool_use_id` names it (`task_started`: running, or running in the background; `task_progress`: its `description`; `task_notification`: its `status`, red unless `completed`). From its `task_started` to its `task_notification` the row is active, and an active row gets the task-coloured border while the scanner shows or while the session's `subagents` count is above zero, as do the waiting tool calls inside it in the latter case; so a background subagent still reads as running after the turn that started it has ended. A turn's end leaves the waiting calls inside an active row waiting; the row's end (its `task_notification`, or a `session.subagents` event with 0, which ends every still-active row as "ended with its process", replayed history included) marks the calls inside it still waiting "no result". The subagent's own messages, thinking and tool calls (events whose `parent_tool_use_id` names the call) go inside that call's row, between the prompt and the result, so parallel subagents don't interleave in the main stream; its consecutive tool calls group there the same way, its messages get an accent rule down their left edge, and a subagent it starts nests inside it in turn. An event naming a subagent call the transcript never saw stays in the main stream marked `↳ subagent`. A subagent call ends the main agent's run of tool calls, so it never joins a group. A subagent's messages never end the main agent's live message, count as a turn's final message or hide a quiet turn. Claude Code streams a background subagent's activity, but for a foreground one only its prompt, so a foreground subagent shows only as its call, its status and its result. Selecting a task that has never had a session focuses the composer, and so does picking any task in the ⌘K switcher when it lands on the transcript (`Store.composerFocus`). In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task (see "Drafts" below), so it survives switching tabs or tasks and relaunching the app. The composer has no `@`-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand `@path` the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session, and stays enabled while a turn runs: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a bubble reading "queued…" while the session is running and "sending…" otherwise, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails. A queued message's `user.queued` event takes over its bubble (the oldest local one with the same text), and the transcript shows a "queued…" bubble below everything else for every `user.queued` not yet followed by a `user.message` with the same text, each `user.message` clearing the oldest such bubble first (queued messages are delivered oldest first), then a local one, and `task.merged` or `task.discarded` clearing them all, since closing the task deletes its queue, so a queued message stays visible across switching tasks, relaunching the app, and on every view. A bubble from a `user.queued` event has a × that withdraws the message (`ChatModel.withdraw`): it first downloads the message's images, so a failed download leaves the message queued, then sends the `DELETE`; on success the bubble goes, its text goes back into that task's composer ahead of whatever is there, separated by a blank line, and its images are added back as composer attachments. A `user.unqueued` event removes the bubble with its `id` on every other view. A refusal (the agent already has the message) shows as an alert and the bubble stays until the `user.message` clears it. -**Drafts.** Unsent composer text (task and lead) and unsent inbox answers (checked choices and Other/Reply text) are kept in memory by `Drafts` (`mac/Sources/Byre/Drafts.swift`) and saved to a SwiftData `DraftRecord` per server and login (`docs/macos.md`, "Drafts"), one JSON object with `composer` (keyed by `message:<task id>` or `message:lead`, each `{text, images}`), `checked` and `texts` (both keyed by inbox item id), and `comments`, the Changes view's pending line comments keyed by task id, each `{anchor: {path, line, old, quote}, text}`. A comment shows only under a diff line whose path, line number, side and text all still match, so after the agent moves lines a comment never attaches to a different line. Every pending comment not shown under a line (its file closed or not loaded, or its line gone from the diff) is listed at the top of the Changes view with its `path:line`, the quoted line, its editable text and **remove**, so nothing is sent unseen. Writes are debounced (400 ms) and flushed at once when a composer or answer box loses focus, on send and on quit. A composer draft is cleared only once its send succeeds, and an inbox answer only once its response is accepted; each send in flight is tracked on its own and the stored draft holds the text of every unconfirmed send (oldest first, then the composer's current text, separated by blank lines) and counts its images, so a relaunch mid-send brings back everything not yet confirmed. A failed send's text goes back into the composer ahead of whatever is there now (text typed since, or earlier failures), separated by a blank line, rather than replacing it. The composer follows its `Drafts` text as it changes, so text put back from outside it (a withdrawn queued message, a draft synced in from another Mac) shows at once. Drafts for tasks that are no longer active and for inbox items no longer open are pruned whenever the task list or inbox reloads. A record that doesn't decode is logged and ignored, as is a write that fails; drafts then live only in memory. Pending images are not stored, only their count: after a relaunch the composer shows how many need re-attaching, until dismissed or the next successful send. +**Drafts.** Unsent composer text (task and lead) and unsent inbox answers (checked choices and Other text) are kept in memory by `Drafts` (`mac/Sources/Byre/Drafts.swift`) and saved to a SwiftData `DraftRecord` per server and login (`docs/macos.md`, "Drafts"), one JSON object with `composer` (keyed by `message:<task id>` or `message:lead`, each `{text, images}`), `checked` and `texts` (both keyed by inbox item id), and `comments`, the Changes view's pending line comments keyed by task id, each `{anchor: {path, line, old, quote}, text}`. A comment shows only under a diff line whose path, line number, side and text all still match, so after the agent moves lines a comment never attaches to a different line. Every pending comment not shown under a line (its file closed or not loaded, or its line gone from the diff) is listed at the top of the Changes view with its `path:line`, the quoted line, its editable text and **remove**, so nothing is sent unseen. Writes are debounced (400 ms) and flushed at once when a composer or answer box loses focus, on send and on quit. A composer draft is cleared only once its send succeeds, and an inbox answer only once its response is accepted; each send in flight is tracked on its own and the stored draft holds the text of every unconfirmed send (oldest first, then the composer's current text, separated by blank lines) and counts its images, so a relaunch mid-send brings back everything not yet confirmed. A failed send's text goes back into the composer ahead of whatever is there now (text typed since, or earlier failures), separated by a blank line, rather than replacing it. The composer follows its `Drafts` text as it changes, so text put back from outside it (a withdrawn queued message, a draft synced in from another Mac) shows at once. Drafts for tasks that are no longer active and for inbox items no longer open are pruned whenever the task list or inbox reloads. A record that doesn't decode is logged and ignored, as is a write that fails; drafts then live only in memory. Pending images are not stored, only their count: after a relaunch the composer shows how many need re-attaching, until dismissed or the next successful send. -The task switcher (⌘K, or the project ▸ task picker centred in the right pane's top row) lists active tasks with their task state and open inbox item count (the Project tab shows the same state), live while it is open. Tasks with inbox items come first; within each group, the task least recently visited comes first (never-visited tasks before all others, the right pane's own task after all others, ties in the server's order: projects by name, tasks newest first), so ⌘K Enter repeated cycles through the tasks. Visit times are SwiftData `VisitRecord`s synced through iCloud (`docs/macos.md`, "Persistence"), stamped when a task is selected and again when it is left. After the tasks come the projects, one row each; picking one points the right pane at the project with no task, on the Project tab. Typing filters both and adds a last row that creates a task with the typed text as its title, in the right pane's project (the first project when there is none); Enter picks the highlighted row. Text before a first colon that starts at least one project's name (ignoring case, no spaces) picks those projects instead: `by: fix` lists only their tasks whose titles contain "fix", their project rows that match, and one row per project creating the task titled "fix" there. When no project name starts with it, the whole text is the title, so "Fix: login" stays as typed. A create row shows the user's default model (see "Agent sessions"), the model the composer offers first for the new task's first message; the task itself starts with no session. Below each create row, a "Start in …" row creates the same task and starts its agent on the default model with the title as its first message, through `POST /api/projects/{id}/tasks` with `prompt` set to the title. Picking a task points the right pane at it on the Inbox tab when it has open inbox items, otherwise on the Transcript tab, scrolled to its latest summary when that is unread (see below and "Lead agent"). +The task switcher (⌘K, or the project ▸ task picker centred in the right pane's top row) lists active tasks with their task state and open inbox item count (the Project tab shows the same state), live while it is open. Tasks with inbox items come first; within each group, the task least recently visited comes first (never-visited tasks before all others, the right pane's own task after all others, ties in the server's order: projects by name, tasks newest first), so ⌘K Enter repeated cycles through the tasks. Visit times are SwiftData `VisitRecord`s synced through iCloud (`docs/macos.md`, "Persistence"), stamped when a task is selected and again when it is left. After the tasks come the projects, one row each; picking one points the right pane at the project with no task, on the Project tab. Typing filters both and adds a last row that creates a task with the typed text as its title, in the right pane's project (the first project when there is none); Enter picks the highlighted row. Text before a first colon that starts at least one project's name (ignoring case, no spaces) picks those projects instead: `by: fix` lists only their tasks whose titles contain "fix", their project rows that match, and one row per project creating the task titled "fix" there. When no project name starts with it, the whole text is the title, so "Fix: login" stays as typed. A create row shows the user's default model (see "Agent sessions"), the model the composer offers first for the new task's first message; the task itself starts with no session. Below each create row, a "Start in …" row creates the same task and starts its agent on the default model with the title as its first message, through `POST /api/projects/{id}/tasks` with `prompt` set to the title. Picking a task points the right pane at it on the Questions tab when it has open inbox items, otherwise on the Transcript tab, scrolled to its latest summary when that is unread (see below and "Lead agent"). -**Task state.** The switcher and the Project tab show one label per task in place of its raw session state, the first that applies (`taskState` in `mac/Sources/Byre/Store.swift`), with a dot and a tooltip saying why: **Needs your answer** (an open `question` inbox item), **Merging** (`merging` or `merging_into_parent`), **Merge held** (`merge_queued_at`), **Merge after review** (`merge_after_review`), **Error** (an open `session_error` or `rate_limited` inbox item), **Working** (the task's `state` is `running`, `review_pending`, `reviewing` or `discarding`; see "Task state in the store"), **Idle** (anything else, including no session). The Project tab, in its task list and TODO items, draws a spinner in place of the dot for **Working** and **Merging**. The label follows the tasks and inbox state the app already holds, so it needs no extra fetch. +**Task state.** The switcher and the Project tab show one label per task in place of its raw session state, the first that applies (`taskState` in `mac/Sources/Byre/Store.swift`), with a dot and a tooltip saying why: **Needs your answer** (an open `question` inbox item), **Merging** (`merging` or `merging_into_parent`), **Merge held** (`merge_queued_at`), **Merge after review** (`merge_after_review`), **Working** (the task's `state` is `running`, `review_pending`, `reviewing` or `discarding`; see "Task state in the store"), **Idle** (anything else, including no session). The Project tab, in its task list and TODO items, draws a spinner in place of the dot for **Working** and **Merging**. The label follows the tasks and inbox state the app already holds, so it needs no extra fetch. **Unread summaries.** A Task's `summary_seq` is the seq of its latest `turn.ended` event with `summary: true` (0 for none), read through the partial index `events_summary`, so the app knows of unread summaries without loading transcripts; a `turn.ended` with `summary: true` on the global stream raises it in the app. The switcher's and the Project tab's task rows show a **new summary** state label while `summary_seq` is above the seq the user has read (`unreadSummary` in `mac/Sources/Byre/Store.swift`), except on tasks of a workflow run, which close too soon for their summaries to be read. Picking such a row (in the switcher, only when the task has no open inbox items) points the right pane at the task's Transcript and scrolls it to that turn's final message (`Store.jump`, applied by the transcript once the event is drawn). Read state is per user on the server: `summary_reads` holds one highest-read seq per user and task. `GET /api/me/summary-reads` returns the caller's, for active tasks, as a map from task id to seq, and `PUT /api/tasks/{taskId}/summary-read` with `{seq}` raises it, emitting `summary.read` (`task_id`, `seq`) owned by the user only when it went up, so every Mac the user has byre open on clears the label. A summary counts as read only when the summary turn's end row is on screen in a transcript while the app is active (`summaryVisible` in `mac/Sources/Byre/Chat.swift`), so selecting the task on another tab leaves it unread. `sqlite3 byre.db 'SELECT * FROM summary_reads'` shows the read seqs. -**Task colours.** Each task has a colour, `tasks.color` (`color` in the API), an index into `taskPalette` (`mac/Sources/Byre/Format.swift`), the `Palette.task0` … `Palette.taskN` colours in `Palette.swift`, each with a light and a dark value, all at least 4.5:1 against the backgrounds so they work as text. `CreateTask` assigns it in the same transaction as the task's row: of the `store.TaskColors` colours, one no active task holds, and among those the one given out longest ago (the lowest `max(rowid)` of tasks with that colour, never-used first), so a new task doesn't take the colour of one just closed. Active tasks have distinct colours until more than `TaskColors` are active at once. `TaskColors` must match the number of `taskPalette` entries. The right pane sets its accent (the `paneAccent` environment value, the palette's accent when no task is selected) to the selected task's colour, which tints the picker's row, colours the task picker's text, the selected tab's underline, the composer's border and the running border and scanner. Everywhere a task is named it shows in its colour: task titles in the switcher, the Project tab and the Status tab, and task links, including any Markdown link to `#/task/<id>` or `#/task/<id>/<tab>`. The lead's guidance tells it to link every task it names that way, using `#/task/<id>/inbox` when it points the human at the task's inbox, and clicking one points the right pane at the task, selecting the named tab if there is one. +**Task colours.** Each task has a colour, `tasks.color` (`color` in the API), an index into `taskPalette` (`mac/Sources/Byre/Format.swift`), the `Palette.task0` … `Palette.taskN` colours in `Palette.swift`, each with a light and a dark value, all at least 4.5:1 against the backgrounds so they work as text. `CreateTask` assigns it in the same transaction as the task's row: of the `store.TaskColors` colours, one no active task holds, and among those the one given out longest ago (the lowest `max(rowid)` of tasks with that colour, never-used first), so a new task doesn't take the colour of one just closed. Active tasks have distinct colours until more than `TaskColors` are active at once. `TaskColors` must match the number of `taskPalette` entries. The right pane sets its accent (the `paneAccent` environment value, the palette's accent when no task is selected) to the selected task's colour, which tints the picker's row, colours the task picker's text, the selected tab's underline, the composer's border and the running border and scanner. Everywhere a task is named it shows in its colour: task titles in the switcher, the Project tab and the Status tab, and task links, including any Markdown link to `#/task/<id>` or `#/task/<id>/<tab>`. The lead's guidance tells it to link every task it names that way, using `#/task/<id>/questions` when it points the human at a question, and clicking one points the right pane at the task, selecting the named tab if there is one. ## Lead agent @@ -247,11 +247,11 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie - **Sessions.** A lead session is an `agent_sessions` row with `owner` set to the user's login, `role` `lead` and no `task_id`. Its events carry `owner` and no project or task. It runs in `leads/<login>` with `leadGuidance` (passed and re-attached the same way as a task agent's guidance) and the lead tools instead of the task tools. It has no Stop-hook bounce, no snapshots, no end-of-turn commit and no turn summary; a turn that ends in error records the error on its `session.state` event, which the pane shows as a note. - **Messages.** `POST /api/me/lead/messages` sends the caller's message to their lead, starting a lead session when there is none. While the lead is mid-turn the message is held in `lead_inputs`; the lead's PostToolUse hook (Codex: `turn/steer` after each completed tool item) hands held messages over after its next tool call, like a task's queued messages (see "Agent sessions"), and any still held become its next turn. Signals are never handed over mid-turn. The composer stays enabled. `GET /api/me/lead/sessions` lists the caller's lead sessions; interrupt and compact use the usual session endpoints. - **Scrollback.** `GET /api/me/lead/events` returns a page of the caller's lead events in whole turns, `turns` of them (default 50): those after the `turns`+1'th `turn.ended` before `before` (or before the latest event, so the page also holds a turn still running), with `more` saying whether earlier events exist. The lead pane loads only the last page (see "Events"); when its top scrolls into view (an empty row kept first in the transcript), it fetches the page before its first event (`loadOlderLead` in `mac/Sources/Byre/Store.swift`), puts its rows in front (`TranscriptBuilder.prepend`) and scrolls back to the row that was first. A page starts just after a `turn.ended`, so rendering it from scratch matches how it drew as part of the whole history. Live events still append without limit until the app is relaunched. -- **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation, an error or rate limit), `inbox.updated` when a human answered a question or error (a question's 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`, `other` or `dismiss` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; while it is mid-turn the message is queued through `Manager.Message` like the human's, see "Agent sessions", and the tool result says it was queued; on a team task it posts to the board, see "Teams"), `create_task` and `start_todo` (each with an optional `workflow` to freeze and run; `create_task` also takes `agents` to start a team), `create_project` (`name` and `repo_path`, created as the user by the same `Server.createProject` as `POST /api/projects`, which the server hands `agents.New`, so it publishes the same `project.created` event, and returned as JSON), `interrupt`, `compact_task`, `take_snapshot` (see "Snapshots"), `merge_task` (optional `target`: `main`, or `parent` for a subtask, see "Subtasks"), `discard_task`, `show`, `compact_self`, the note tools (see "Notes") and the workflow tools (see "Workflows"). The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor `<login> (lead)`, so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks. +- **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation), `session.error` and `session.rate_limited` on a task, `inbox.updated` when a human answered a question (the signal also carries its text and the agent's recommendation, so the lead sees where the human departed from it), `task.created` (naming the TODO item it was started from, or a subtask's parent, if any), `task.merge_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 `inbox` 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 dismissed error; `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. It sends false on deactivating, Change 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 Inbox 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. +- **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. It sends false on deactivating, Change 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. - **Notes.** The lead keeps notes on how its user works (priorities, preferences, the reasoning behind choices) in `lead_notes`, one row per note keyed by `owner`, through its `remember` (add, or replace the text and project of an `id`), `forget` and `list_notes` tools (`internal/agents/leadnotes.go`). A note has a `project_id` when it applies to one project and none (the column empty) when it applies to the user in every project; `remember` takes `project_id` and refuses one that names no project, and the guidance tells the lead to file a note about one project's code, UI, docs or process under it and to apply it only to that project's tasks. Each change records a `lead.note_saved` or `lead.note_deleted` event owned by the user. When a lead's agent process starts, `notesText` lists every note with its id after `leadGuidance`, the every-project notes first and then each project's under its name and id, in the session's instructions (`--append-system-prompt` for Claude, which keeps what the session's first request carried; `developerInstructions` for Codex), but not in the per-turn guidance reminder, so a new or compacted lead session starts from the current notes and notes written mid-session are already in its context. The guidance tells the lead when to write a note (an answer that departs from a recommendation, a correction, a stated preference), to cite a note's id when it shapes a recommendation, and that it may answer a task agent's question itself when a note settles it outright. `GET /api/me/lead/notes` lists the caller's notes and `DELETE /api/me/lead/notes/{noteId}` deletes one (`Manager.DeleteLeadNote`), and, when the user has a live lead session, holds a message for it from `byre` naming the note's id and text and telling it to stop applying it, since the running session still has the note in its instructions. It is held as a message, not a signal, so it keeps its place in line with the human's own messages: it goes out ahead of any message they send after the delete, as its own turn (drawn like a signal batch's, and folded away on `NO_REPLY`) or mid-turn after the lead's next tool call; the Settings tab shows one set at a time, picked from a dropdown of Every project and each project by name with its note count (the pick is kept in memory across tab switches), each note with a small delete button styled like the transcript's, refetching on either event. `sqlite3 byre.db "SELECT owner, project_id, id, text FROM lead_notes ORDER BY created_at"` shows them. @@ -259,7 +259,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie Task agents get notes too. An implementer session's instructions end with `taskNotesGuidance` and `notesText` for the task's creator, limited to the every-project notes and the task's project's (`startLive` in `internal/agents/manager.go`), fixed when the agent process starts like the lead's. Reviewers get none. Implementers have a `suggest_note` tool (`text`, `evidence`) that queues a lead signal for the task's creator naming the task, the suggested text, the evidence and the project id, and refuses when the creator has no live lead session. The lead's guidance tells it to save a suggestion with `remember`, choosing its project, only when it holds and isn't already covered, and to tell the human nothing either way. A suggestion leaves no record of its own beyond the signal; a saved one shows as the lead's `lead.note_saved`. - **Restarts.** On shutdown, byre marks each running lead session idle with "byre stopped mid-turn" and holds a restart notice for it, so a lead is never left `running` for a build without lead support to find. After a crash, startup resumes a running lead like a task session. Startup then delivers any held input. -The UI is two panes filling the window, with no navbar. The left has three tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Status and Settings, and at the end of its tab strip the connection dot and the user's name. Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Status or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Inbox, Reviews, Workflow and Project, the lead's `tabs` first. Board shows only on a team task (see "Teams"), Reviews only once it has a `review.*` event, Inbox only while the task has open items, and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there, and More carries the Inbox highlight and count when Inbox is in it. The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open. +The UI is two panes filling the window, with no navbar. The left has three tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Status and Settings, and at the end of its tab strip the connection dot and the user's name. Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Status or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Questions, Reviews, Workflow and Project, the lead's `tabs` first. Board shows only on a team task (see "Teams"), Reviews only once it has a `review.*` event, Inbox only while the task has open items, and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there, and More carries the Questions highlight and count when Questions is in it. The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open. ## TODO @@ -271,36 +271,41 @@ The **Project** tab is the dashboard of the right pane's project (the focused ta ## Inbox -Agents ask questions with the `ask_human` MCP tool. Each question carries a `context` with the background the human needs to answer without reading the transcript, and its choices are written inline in that context as Markdown list items: `- ( ) text` for pick-one choices, with exactly one `- (*) text` marking the recommendation, or `- [ ] text` for pick-any choices, with `- [x] text` marking the recommended ones. There is no separate list of options. byre parses the choices out of `context` (`internal/agents/choices.go`; list items inside code fences and blockquotes don't count) and stores their text in the payload as `options`, with `multiple` (pick-any) and `recommended`. The UI renders the context as Markdown with a radio button or checkbox in place of each choice's marker. The tool's description tells agents to keep everything about one choice (what it does, its example, code, trade-offs) in lines indented beneath that choice rather than in text above the list; those lines render inside the option, and only the marker line's text is the option. When every item of a choice list has exactly one non-blank indented line and that line is only a Markdown image, the list renders as a grid of tiles (`grid`, set in `parseList` in `mac/Sources/Byre/Markdown.swift`): four across, or two across when the context is at most 480pt wide. The tool's description tells agents to write image choices this way. A malformed call (no `context` or `why_blocked`, fewer than two choices, pick-one and pick-any choices mixed, a pick-one question without exactly one recommendation, two choices with the same text, an unknown attachment kind, a foreign `thread_id`) is rejected with a message saying what to fix, and nothing is filed. All questions are alike: the tool's description and its result tell the agent to end its turn after asking, and the answer arrives as its next message. The call returns the item's id as `question_id`. An agent withdraws a question it no longer needs answered (the human answered it in a chat message, say) with the `cancel_question` MCP tool and that id: an `open` question of its own session becomes `dismissed` with no `resolved_by` (its `inbox.updated` event has actor `agent`), which also stops it from holding back delivery; anything else is rejected with a message saying why. Each agent error (a turn that ends in error without a human interrupt, a process that dies mid-turn) also files an item. +Agents ask questions with the `ask_human` MCP tool. Each question carries a `context` with the background the human needs to answer without reading the transcript, and its choices are written inline in that context as Markdown list items: `- ( ) text` for pick-one choices, with exactly one `- (*) text` marking the recommendation, or `- [ ] text` for pick-any choices, with `- [x] text` marking the recommended ones. There is no separate list of options. byre parses the choices out of `context` (`internal/agents/choices.go`; list items inside code fences and blockquotes don't count) and stores their text in the payload as `options`, with `multiple` (pick-any) and `recommended`. The UI renders the context as Markdown with a radio button or checkbox in place of each choice's marker. The tool's description tells agents to keep everything about one choice (what it does, its example, code, trade-offs) in lines indented beneath that choice rather than in text above the list; those lines render inside the option, and only the marker line's text is the option. When every item of a choice list has exactly one non-blank indented line and that line is only a Markdown image, the list renders as a grid of tiles (`grid`, set in `parseList` in `mac/Sources/Byre/Markdown.swift`): four across, or two across when the context is at most 480pt wide. The tool's description tells agents to write image choices this way. A malformed call (no `context` or `why_blocked`, fewer than two choices, pick-one and pick-any choices mixed, a pick-one question without exactly one recommendation, two choices with the same text, an unknown attachment kind, a foreign `thread_id`) is rejected with a message saying what to fix, and nothing is filed. All questions are alike: the tool's description and its result tell the agent to end its turn after asking, and the answer arrives as its next message. The call returns the item's id as `question_id`. An agent withdraws a question it no longer needs answered (the human answered it in a chat message, say) with the `cancel_question` MCP tool and that id: an `open` question of its own session becomes `dismissed` with no `resolved_by` (its `inbox.updated` event has actor `agent`), which also stops it from holding back delivery; anything else is rejected with a message saying why. The inbox holds only questions; errors go in the transcript (see "Errors"). -`GET /api/inbox` is one query over open items, oldest first; `project_id` and `task_id` narrow it to a project or task view, and `state` picks another state. The Inbox tab is task-specific: it shows one of the selected task's open items at a time, the oldest, and the next one once that is answered or dismissed; it shows nothing when no task is selected. The tab is hidden while the task has no open items, unless it is selected. While the selected task has open items, the Inbox tab's button is highlighted in the warn colours (`attention` on `PaneTabButton` in `mac/Sources/Byre/Panes.swift`) and carries their count. Answering or dismissing the task's last open item from the Inbox tab switches the pane to the Transcript tab. The other way round, a new question from the selected task arriving while the Transcript tab is shown switches the pane to the Inbox tab, unless a text box in the pane holds unsent text (the `drafting` flag of the pane-focus report); questions already open when the app started don't. +`GET /api/inbox` is one query over open items, oldest first; `project_id` and `task_id` narrow it to a project or task view, and `state` picks another state. The right pane shows a task's questions in its Questions tab. The tab is task-specific: it shows one of the selected task's open items at a time, the oldest, and the next one once that is answered or dismissed; it shows nothing when no task is selected. The tab is hidden while the task has no open items, unless it is selected. While the selected task has open items, the Questions tab's button is highlighted in the warn colours (`attention` on `PaneTabButton` in `mac/Sources/Byre/Panes.swift`) and carries their count. Answering or dismissing the task's last open item from the Questions tab switches the pane to the Transcript tab. The other way round, a new question from the selected task arriving while the Transcript tab is shown switches the pane to the Questions tab, unless a text box in the pane holds unsent text (the `drafting` flag of the pane-focus report); questions already open when the app started don't. -`POST /api/inbox/responses` answers any number of items in one submission. Response types and what they do to the item: +`POST /api/inbox/responses` answers any number of questions in one submission, each becoming `answered`. Response types: -| type | allowed on | item becomes | -|---|---|---| -| `option` (the chosen choices' text in `options`: exactly one for pick-one, one or more for pick-any; a pick-any one may also carry `text`, delivered after the checked list) | questions | `answered` | -| `other` (free text in `text`: an answer, a question back, or new instructions) | questions, errors | `answered` | -| `dismiss` | errors | `dismissed` | +| type | carries | +|---|---| +| `option` | the chosen choices' text in `options`: exactly one for pick-one, one or more for pick-any; a pick-any one may also carry `text`, delivered after the checked list | +| `other` | free text in `text`: an answer, a question back, or new instructions | An `other` response, or a pick-any `option` one, may carry `images`, blob ids from `POST /api/blobs` (unknown ids are a 400; see "Images"); an `other` with images may have empty text. They are stored in `inbox_responses.images` and sent to the agent as images with the delivery message, whose text says how many each response attached. Since mid-turn delivery carries only text, answers with images are never handed over mid-turn (`deliver held mid-turn: response has images`); `Deliver` sends them as a new turn once the session is idle. A submission is all or nothing. `Store.Respond` resolves each item with an `UPDATE` conditioned on `state = 'open'` in one transaction, so when any item was answered, dismissed or withdrawn by someone else in the meantime (a second tab, the lead's `answer_question`, the agent's `cancel_question`), the whole submission is rolled back and refused with 409, and no response is recorded. -Answers reach the agent as one message holding every undelivered `option` and `other` response of the session (`deliveryMessage` in `internal/agents/inbox.go`), never while one of its questions is still open and unanswered (`AwaitingHuman` in `internal/store/inbox.go`); until then they stay held (`inbox_responses.delivered_at` empty). While a turn is running, the task session's PostToolUse hook (`afterTool` in `internal/agents/snapshots.go`, through `deliverMidTurn`) hands them over after the agent's next tool call as the hook's `additionalContext`, after any queued messages (see "Agent sessions"), so the agent reads them alongside that tool's result without being interrupted (for Codex, after each completed tool item, `deliverMidTurn` hands them over with `turn/steer` instead; when the steer fails, `restoreMidTurn` puts the taken queued messages back under their original ids and clears the answers' `delivered_at`, so `Deliver` sends them when the turn ends, and logs `mid-turn delivery restored`); the `user.message` event recording it carries `mid_turn: true` and no `head`, and the transcript doesn't treat it as a turn start. A turn that makes no further tool call leaves them to `Deliver`, which runs after each submission and when a turn ends, and sends them as a new turn once the session is `idle`. The log traces each answer: `inbox response recorded` when it is stored, `deliver held` with the reason, `deliver sent mid-turn`, `deliver sent` with the session's state and whether its agent process had exited or byre was closing, and `undelivered responses` with a reason wherever a session has held answers when its turn ends in a handoff or merge, its agent process ends, startup resumes it, or startup finds it idle. `sqlite3 byre.db "SELECT * FROM inbox_responses WHERE delivered_at IS NULL"` lists the answers held now. +Answers reach the agent as one message holding every undelivered `option` and `other` response of the session (`deliveryMessage` in `internal/agents/inbox.go`), never while one of its questions is still open and unanswered (`AwaitingHuman` in `internal/store/inbox.go`); until then they stay held (`inbox_responses.delivered_at` empty). While a turn is running, the task session's PostToolUse hook (`afterTool` in `internal/agents/snapshots.go`, through `deliverMidTurn`) hands them over after the agent's next tool call as the hook's `additionalContext`, after any queued messages (see "Agent sessions"), so the agent reads them alongside that tool's result without being interrupted (for Codex, after each completed tool item, `deliverMidTurn` hands them over with `turn/steer` instead; when the steer fails, `restoreMidTurn` puts the taken queued messages back under their original ids and clears the answers' `delivered_at` and `snapshot_n`, so `Deliver` sends them when the turn ends, and logs `mid-turn delivery restored`); the `user.message` event recording it carries `mid_turn: true` and no `head`, and the transcript doesn't treat it as a turn start. A turn that makes no further tool call leaves them to `Deliver`, which runs after each submission and when a turn ends, and sends them as a new turn once the session is `idle`. Just before either hands answers over, byre snapshots the task's worktree (`snapshotForAnswer` in `internal/agents/snapshots.go`, trigger `answer`), synchronously and under the task's mutex, and records the snapshot's `n` on each answer it hands over (`inbox_responses.snapshot_n`, `snapshot_n` in the API), so the snapshot's tree and `event_seq` give the worktree and the conversation as they stood before the agent read the answer. A new snapshot is taken for every hand-over, even when the tree is unchanged, so each one's `event_seq` and session are the conversation boundary just before that answer and `POST /api/tasks/{taskId}/snapshots/{n}/fork` on it continues from there. An `answer` snapshot is never reviewed. Everything that looks for the task's latest snapshot to judge review state skips `answer` ones (`LastSnapshotNot`): the dedupe of the other triggers, which skip a snapshot only when its tree equals the last non-`answer` one's, so work an `answer` snapshot caught mid-turn is still snapshotted and reviewed when the turn ends; Merge after review; and the Merge confirmation's review warnings. A Merge turn already holds the task's mutex, so mid-turn delivery in it snapshots without taking it again; a worktree with merge conflicts snapshots with the conflict markers as they stand, and its real index is left untouched. No snapshot is taken once the task is closing, or when taking it fails; the answers then have no `snapshot_n`. A failure is logged as `take snapshot` and recorded as a `session.error` in the transcript, and the answers are still handed over: holding them would leave an idle agent waiting with nothing to retry the delivery. The log traces each answer: `inbox response recorded` when it is stored, `deliver held` with the reason, `deliver sent mid-turn` with the snapshot, `deliver sent` with the session's state, the snapshot and whether its agent process had exited or byre was closing, and `undelivered responses` with a reason wherever a session has held answers when its turn ends in a handoff or merge, its agent process ends, startup resumes it, or startup finds it idle. `sqlite3 byre.db "SELECT * FROM inbox_responses WHERE delivered_at IS NULL"` lists the answers held now. + +### Errors + +An agent error (a turn that ends in error without a human interrupt, a process that dies mid-turn, a failed commit, merge, cleanup or workflow step) is recorded as a `session.error` event on the session (`recordError` in `internal/agents/inbox.go`, actor `byre`, payload `error`), and a rate-limited turn as `session.rate_limited` (`recordRateLimited`, payload `vendor`, `rate_limit` and `error`). The transcript draws each as an error note, a rate limit with its vendor, limit type and reset time. Nothing answers or dismisses them: the human reacts by messaging the agent. On a task they signal the lead and can notify (below). ## Notifications -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. +The running macOS app posts a notification for each new question, error and rate limit, raised by the app itself from the `inbox.created`, `session.error` and `session.rate_limited` events on its SSE stream (`notifyInbox` and `notifyError` 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), `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. +- **Preferences.** `users.notification_prefs` holds what notifies 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` (`session.rate_limited`). The app checks them against each 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 question's ID, `turn-<seq>` for a turn summary or `error-<seq>` for an error or rate limit, 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. The left pane's **Settings** tab asks macOS for notification permission and edits the preferences, and also holds the Default model menu (see "Agent sessions"). -The Inbox tab shows one item at a time, the task's oldest, filling the tab without a card frame; the next appears when it is answered. Each item is answered on its own; there is no batch submission. A pick-one question's choices are clickable blocks, and clicking one sends that answer at once. A choice's block (or a pick-any choice's checkbox label) holds the whole list item: its first line, which is the choice's text, and everything under it, such as an example's paragraph or code block. A pick-any question's choices are checkboxes, sent with the Submit button at the bottom of the card. Below the choices, an "Other…" block in the same style opens the only text field. Under it, every question has one submit row: a "Let the agent decide" button at its left, away from the choices so it isn't hit by accident, and the Submit button at its right. Let the agent decide sends at once an `other` answer reading "Use your judgment for this decision and continue within the existing task instructions." (`delegateText` in `mac/Sources/Byre/Inbox.swift`), which shows in the card's answer list like any other; the `ask_human` description tells agents that this answer settles only that decision and never grants an authorization the owner must give explicitly. ArrowDown and ArrowUp move between the choice blocks, the checkboxes and the Other block. A question has exactly one Submit button, enabled once there is something to send: on a pick-one question it sends the Other text as `other`; on a pick-any question it sends the checked choices with the Other text alongside (one `option` response), or the Other text alone as `other` when nothing is checked. An error has a "Reply…" block (`other`) and a Dismiss button. The Other and Reply text boxes send with the Submit button under them or on Enter (Shift+Enter for a newline), like the message box, and fold back into their block when left empty with no images. Pasting or dropping an image into them adds a thumbnail, as in the composer (both keep pending images in `Store.images`, an `ImageDrafts`); images upload when the answer is submitted and are put back if the submission fails. Pending answer images are not kept across a relaunch. Clicking a pick-one choice sends no images. +Each question also shows in the task's transcript where it was asked, as a bubble (`QuestionBubble` in `mac/Sources/Byre/Chat.swift`) holding its `question` text and its state: "waiting for your answer" in the warn colours while it is open, then who chose which options or the first line of who answered what, "dismissed by" whoever dismissed it, or "withdrawn by the agent". The transcript builds it from the item's `inbox.created` event and updates it from each later `inbox.updated` event of the same item. Clicking a bubble shows that question in the Questions tab (`Store.showQuestion`, held in `Store.question` with its task id), open or not. An item that is no longer open shows read-only: its header adds its state, its chosen options are marked "chosen", its answers are listed under it, and it has no Other, Let the agent decide or Submit. The item comes from `Store.inbox` while it is open and otherwise from its latest `inbox.created` or `inbox.updated` event in the task's feed (`Store.questionItem`). Switching tab or task in any other way, including clicking the Questions tab itself, clears the pick, so the tab goes back to the task's oldest open item; answering the picked question clears it too. + +The Questions tab shows one item at a time, the task's oldest open one unless a bubble picked another, filling the tab without a card frame; the next appears when it is answered. Each item is answered on its own; there is no batch submission. A pick-one question's choices are clickable blocks, and clicking one sends that answer at once. A choice's block (or a pick-any choice's checkbox label) holds the whole list item: its first line, which is the choice's text, and everything under it, such as an example's paragraph or code block. A pick-any question's choices are checkboxes, sent with the Submit button at the bottom of the card. Below the choices, an "Other…" block in the same style opens the only text field. Under it, every question has one submit row: a "Let the agent decide" button at its left, away from the choices so it isn't hit by accident, and the Submit button at its right. Let the agent decide sends at once an `other` answer reading "Use your judgment for this decision and continue within the existing task instructions." (`delegateText` in `mac/Sources/Byre/Questions.swift`), which shows in the card's answer list like any other; the `ask_human` description tells agents that this answer settles only that decision and never grants an authorization the owner must give explicitly. ArrowDown and ArrowUp move between the choice blocks, the checkboxes and the Other block. A question has exactly one Submit button, enabled once there is something to send: on a pick-one question it sends the Other text as `other`; on a pick-any question it sends the checked choices with the Other text alongside (one `option` response), or the Other text alone as `other` when nothing is checked. The Other text box sends with the Submit button or on Enter (Shift+Enter for a newline), like the message box, and folds back into its block when left empty with no images. Pasting or dropping an image into it adds a thumbnail, as in the composer (both keep pending images in `Store.images`, an `ImageDrafts`); images upload when the answer is submitted and are put back if the submission fails. Pending answer images are not kept across a relaunch. Clicking a pick-one choice sends no images. There is no snoozing. The `snoozed_until` and `snooze_until` columns are still in the schema, always empty, so builds from older branches keep working against the same database; nothing reads them. For the same reason `inbox_items` keeps its `priority` and `blocking` columns, which nothing reads or writes; new rows take their defaults (0 and 1, which older builds read as a blocking question). @@ -312,7 +317,7 @@ Markdown images (`![alt](src)`, or `![alt](<src>)`; the plain form also accepts A code block fenced as `mermaid`, and an inbox `diagram` attachment, render as a diagram drawn natively when the source is a flowchart (`mac/Sources/Byre/Diagram.swift`; `docs/macos.md`, "Diagrams", lists what it parses); any other source stays as source with "diagram not rendered: <reason>" under it. The image endpoint (`internal/server/images.go`) serves PNG, JPEG, GIF, WebP and SVG by extension, resolves symlinks on both the worktree and the file and answers 403 for anything outside the worktree, 404 for a missing file, 400 for another extension or a non-file, and 409 once the task is closed (its worktree is gone). Responses carry `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`, so an SVG opened directly runs no script, and `Cache-Control: no-cache` with `Last-Modified`, so a changed file is refetched. -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. +Answers reach the agent as one message, listing each answer under the question 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 Macs @@ -337,9 +342,9 @@ A byre restart drops every connection; each app reconnects 2s later, and calls i ## Changes -`GET /api/tasks/{taskId}/changes` snapshots the task's worktree as if everything were staged: it copies the worktree's index to a temporary file, runs `git add --all` against that copy (`GIT_INDEX_FILE`), and `git write-tree`s it, so committed, staged, unstaged and untracked work is included, `.gitignore` is respected, and the worktree's real index is never touched. Starting from the real index rather than `read-tree HEAD` keeps git's stat cache, so unchanged files aren't rehashed. The tree is compared with `git merge-base HEAD <default branch>`, or with `since` when given, using rename detection, giving per-file status and line counts. Work already merged into the default branch therefore drops out. The Changes view toggles between **Since main** and **This turn**, which passes the latest `user.message` event's `head` as `since`; the summary above the inbox always shows since main. `GET /api/tasks/{taskId}/diff` returns one file's patch between a `base` and `tree` from that response. Each request writes the changed blobs and a tree into the repo's object store as loose objects. +`GET /api/tasks/{taskId}/changes` snapshots the task's worktree as if everything were staged: it copies the worktree's index to a temporary file, runs `git add --all` against that copy (`GIT_INDEX_FILE`), and `git write-tree`s it, so committed, staged, unstaged and untracked work is included, `.gitignore` is respected, and the worktree's real index is never touched. Starting from the real index rather than `read-tree HEAD` keeps git's stat cache, so unchanged files aren't rehashed. The tree is compared with `git merge-base HEAD <default branch>`, or with `since` when given, using rename detection, giving per-file status and line counts. Work already merged into the default branch therefore drops out. The Changes view toggles between **Since main** and **This turn**, which passes the latest `user.message` event's `head` as `since`; the summary above the Questions tab always shows since main. `GET /api/tasks/{taskId}/diff` returns one file's patch between a `base` and `tree` from that response. Each request writes the changed blobs and a tree into the repo's object store as loose objects. -The Changes view lists the files with their counts, opens a file's diff on click (open files stay open across refreshes), and refreshes when it opens, when the selected task changes, and 1.5s after the selected task's last tool result or turn end. It can also be pinned to a snapshot (see "Snapshots"). Clicking a diff line (added, removed or context) opens a comment box under it and marks the line with an accent bar; clicking the line again closes an empty box, and **remove** drops the comment. Pending comments are drafts (see "Drafts" below), kept per task and pruned when it closes. Once any has text, the header shows **Send N comments**, which posts them as one ordinary message to the task's live session, or starts a session when there is none (`Store.messageTask`, also used by a review finding's Fix), in the order they were made, each as `` `path:line` `` (the new file's line; a removed line gives the old file's line and adds "(removed line)"), the diff line quoted with its `+`, `-` or space, and the comment, separated by blank lines (`commentsMessage` in `mac/Sources/Byre/Changes.swift`). Empty boxes stay open; a failed send puts the comments back. The Inbox view shows just its summary line (file count, +/− totals, base) for the selected task at the top. +The Changes view lists the files with their counts, opens a file's diff on click (open files stay open across refreshes), and refreshes when it opens, when the selected task changes, and 1.5s after the selected task's last tool result or turn end. It can also be pinned to a snapshot (see "Snapshots"). Clicking a diff line (added, removed or context) opens a comment box under it and marks the line with an accent bar; clicking the line again closes an empty box, and **remove** drops the comment. Pending comments are drafts (see "Drafts" below), kept per task and pruned when it closes. Once any has text, the header shows **Send N comments**, which posts them as one ordinary message to the task's live session, or starts a session when there is none (`Store.messageTask`, also used by a review finding's Fix), in the order they were made, each as `` `path:line` `` (the new file's line; a removed line gives the old file's line and adds "(removed line)"), the diff line quoted with its `+`, `-` or space, and the comment, separated by blank lines (`commentsMessage` in `mac/Sources/Byre/Changes.swift`). Empty boxes stay open; a failed send puts the comments back. The Questions tab shows just its summary line (file count, +/− totals, base) for the selected task at the top. ## Files @@ -359,7 +364,7 @@ The **Terminal** tab (`mac/Sources/Byre/Terminal.swift`) draws it with SwiftTerm byre snapshots each task's worktree at the end of every turn that has no subagents running (`internal/agents/snapshots.go`, `internal/git/snapshot.go`), so a snapshot always sits on a turn boundary, the only place a Codex conversation can be forked. A snapshot builds the tree the same way as the Changes view (a copy of the real index, `git add --all`, `git write-tree`), commits it with `git commit-tree` whose parent is the worktree's `HEAD`, authored as the session's creator with the message `byre snapshot <n> (<trigger>)`, and points `refs/byre/tasks/<task-id>/snap/<n>` at it (`git update-ref` with an empty old value, so an existing ref is never overwritten). A row in the `snapshots` table records id, task, session, `n` (from 1 per task), commit and tree shas, `event_seq` (the task's latest event when the snapshot started, at or after the turn's `turn.ended`, so the tree holds at least the work up to that event), the trigger and the time, and the same transaction records a `snapshot.created` event carrying the row. -The trigger is `turn_end`: the snapshot starts after byre's end-of-turn commit and the `turn.ended` event. A turn that ends while the session's subagent count (see "Subagent count") is above zero takes no snapshot and requests no review, since its work is half done; the CLI starts another turn when each background subagent finishes, and the first of those to end with none running takes the snapshot and its review. Snapshots taken by earlier builds also carry `message` (mid-turn, when the agent wrote text after tool calls), `tool_call`, `ask_human` and `timer`. A snapshot whose tree equals the task's latest snapshot's tree is skipped, so a turn that changed no files leaves none. Snapshots run one at a time per task: a trigger that arrives while one is running replaces any already waiting one, and the waiting one runs when the current one finishes. Failures are logged and dropped. +The trigger is `turn_end`: the snapshot starts after byre's end-of-turn commit and the `turn.ended` event. A turn that ends while the session's subagent count (see "Subagent count") is above zero takes no snapshot and requests no review, since its work is half done; the CLI starts another turn when each background subagent finishes, and the first of those to end with none running takes the snapshot and its review. The trigger `answer` is a snapshot taken just before answers from the human are handed to the agent (see "Inbox"); it is taken even when its tree is unchanged, is never reviewed (its transcript row has no review button), and doesn't wait in the queue below but runs at once under the task's mutex. Snapshots taken by earlier builds also carry `message` (mid-turn, when the agent wrote text after tool calls), `tool_call`, `ask_human` and `timer`. A snapshot whose tree equals the task's latest snapshot's tree is skipped, so a turn that changed no files leaves none. Snapshots run one at a time per task: a trigger that arrives while one is running replaces any already waiting one, and the waiting one runs when the current one finishes. Failures are logged and dropped. The `take_snapshot` MCP tool asks for a snapshot now (`RequestSnapshot` and `takeSnapshotTool` in `internal/agents/snapshots.go`): a task's agent calls it with no arguments for its own task, and the lead with a `task_id`. The snapshot has trigger `requested`, is taken even when its tree equals the latest snapshot's (so it retries a failed review of unchanged work), and is reviewed like any other (see "Reviews"); it counts as a review request (`BeginReviewRequest`) from the call until the reviewer starts. It is attributed to the task's latest implementer session, authored as that session's creator, and can be taken mid-turn, uncommitted work included. A requested snapshot that is replaced while waiting passes its trigger and its taken-even-when-unchanged to the one that replaces it. Refused for a closed task, while the task is merging or being discarded, and for a task with no agent session. @@ -401,7 +406,7 @@ A workflow is a named graph of stages, each a stage name, the stages it comes af - **Lead tools.** `list_workflows`, `read_workflow` (`name`, or `task_id` for a task's frozen copy), `save_workflow` (`name` and `definition`, the file as YAML) and `delete_workflow`, with the same checks and errors as the API. Task agents don't get them. - **Starting.** The lead's `create_task` and `start_todo` take an optional `workflow` (404 when there is none of that name) and then start a run of it instead of an agent on the prompt (`Manager.StartWorkflow`); `model` together with `workflow` is refused, since each stage names its own. `POST /api/projects/{id}/tasks` takes the same optional `workflow`, with the title as the run's brief, and returns the run's first stage task. The task switcher (`mac/Sources/Byre/Switcher.swift`) offers it as a "Start workflow…" row after each "New task in …" and "Start in …" pair: picking that lists `GET /api/workflows`, filtered by what is typed next (Esc goes back to the task list), and picking a workflow starts the run with the typed title and opens its first stage task. A run gets a new id and creates a task for every stage with no `after` stages, branched from the default branch, or for a stage with `base: parent`, from the lead's `from_task_id` HEAD when given. Each stage task copies the workflow's name and definition as they are when the run starts into `tasks.workflow_name` and `tasks.workflow_definition`, so later saves or deletes don't change it; its brief into `tasks.workflow_brief` (the lead's `create_task` `prompt`, optional with a workflow and may be empty, or for `start_todo` the TODO item as `todoPrompt` words it); the run's title into `tasks.workflow_title`; the run id into `tasks.workflow_run`; and its stage's name into `tasks.workflow_stage`. Stage tasks are titled `<title>: <stage>`, with the run's title as their merge title, and carry the run's TODO item. The Task's `workflow`, `workflow_run` and `workflow_stage` are those; `GET /api/tasks/{id}/workflow` returns the copy as `{name, definition}` with the definition as YAML (404 for a task without one). A task agent's `create_subtask`, `POST /api/projects/{id}/tasks` without `workflow` and the Project tab's Start create tasks without one. A fork of a stage task copies its frozen workflow, brief, title, run and stage, so it is another attempt at the same stage of the same run. `sqlite3 byre.db "SELECT id, status, workflow_stage, stage_output != '' FROM tasks WHERE workflow_run = '<run>'"` lists a run's tasks and which have finished. - **Stages.** A stage task's agent is an implementer session on the stage's model (for a team stage, one per agent, see "Teams"), created by the user who started the run; any later session of the task (a Compact handoff's, one started by a message) runs the same stage, since the stage belongs to the task. Starting a stage records a `task.workflow_stage` event on its task (`workflow`, `run`, `stage` the name, `after` the ids of the tasks whose outputs it gets; actor the lead for the stages a run starts with, `byre` after) and sends the session its first message, actor `byre`, so the transcript shows it collapsed under its first line: `Workflow "<name>", stage "<stage>"`, then the stage's prompt, a `## Brief` section when the brief isn't empty, a `## From stage "<stage>" (task <id>, branch <branch>)` section with each `after` stage's output, and a line saying where the task starts: from the default branch, with the branches above to `git merge` or read as it needs, or from the commit its one `after` stage finished at. Nothing else of earlier stages is passed on; their branches carry their work. -- **Advancing.** A stage session gets the `finish_stage` tool (`output`, required) and a guidance line naming the workflow, its stage and the stages after it. The call stores the output in `agent_sessions.stage_output`, replacing any earlier one; it is refused once the task's stage has finished. When a turn of that session ends normally (not an error or interrupt, not a handoff, not a Merge turn that squashes) with an output stored, byre sets `agent_sessions.stage_finished` before marking the session idle, and then advances (`nextStage`): it records the output and the worktree's HEAD in `tasks.stage_output` and `tasks.stage_commit`, then, holding `workflowMu` so two stages finishing at once can't both start the one after them, goes through each stage after this one. A stage starts once every stage it comes after has a finished task in the run (the latest one, when a fork made several); its task branches from that task's `stage_commit` for `base: parent` with one `after` stage, else from the default branch. Once every stage after it has been dealt with, byre clears `stage_output` and `stage_finished` on the session. The session stays idle in its task for more messages, and queued messages and held answers are delivered to it once advancing is done. When the final stage finishes, byre starts discarding every other active task of the run (as `byre`, through the same `Discard` as the button, so each gets its summary turn first and closes when that turn ends; one already merging or being discarded is skipped and logged as `stage task not discarded`), then records `task.workflow_finished` (`workflow`, `output`) on the final task and clears the flags in the same transaction, leaving the final task for the human to merge. A turn that ends without the call leaves the task in its stage. The output belongs to the turn that stored it: when that turn ends any other way (an error, an interrupt, a handoff, a Merge turn that squashes, an agent process that exits mid-turn, or a session that can't be resumed after a restart), byre clears `stage_output` before marking the session idle, so a later turn can't hand off a stale output; the agent calls `finish_stage` again. A turn cut off by a byre restart and resumed keeps it. If a stage can't start, byre logs `advance workflow stage` and files the error in the finishing task's inbox, and the flag stays set. On startup, before resuming running sessions, `recoverStages` advances every session with `stage_finished` set, skipping the steps already done: the recorded output isn't overwritten; a stage that already has a task in the run isn't created again, but when that task is active and has no session, its sessions are started, and each of its sessions that never got a message (`SessionStarted`) is sent the stage's first message (a team member's with its own prompt), and the restart notice isn't sent to them. `sqlite3 byre.db 'SELECT id, task_id FROM agent_sessions WHERE stage_finished = 1'` lists stages waiting to advance. `list_tasks` shows each stage task's workflow and `stage "<name>"`, with `(finished)` once its stage has finished and `workflow finished` once any task of the run has a `task.workflow_finished` event. Nothing reads or writes `agent_sessions.workflow_stage`; it stays in the schema so builds from older branches keep working against the same database. +- **Advancing.** A stage session gets the `finish_stage` tool (`output`, required) and a guidance line naming the workflow, its stage and the stages after it. The call stores the output in `agent_sessions.stage_output`, replacing any earlier one; it is refused once the task's stage has finished. When a turn of that session ends normally (not an error or interrupt, not a handoff, not a Merge turn that squashes) with an output stored, byre sets `agent_sessions.stage_finished` before marking the session idle, and then advances (`nextStage`): it records the output and the worktree's HEAD in `tasks.stage_output` and `tasks.stage_commit`, then, holding `workflowMu` so two stages finishing at once can't both start the one after them, goes through each stage after this one. A stage starts once every stage it comes after has a finished task in the run (the latest one, when a fork made several); its task branches from that task's `stage_commit` for `base: parent` with one `after` stage, else from the default branch. Once every stage after it has been dealt with, byre clears `stage_output` and `stage_finished` on the session. The session stays idle in its task for more messages, and queued messages and held answers are delivered to it once advancing is done. When the final stage finishes, byre starts discarding every other active task of the run (as `byre`, through the same `Discard` as the button, so each gets its summary turn first and closes when that turn ends; one already merging or being discarded is skipped and logged as `stage task not discarded`), then records `task.workflow_finished` (`workflow`, `output`) on the final task and clears the flags in the same transaction, leaving the final task for the human to merge. A turn that ends without the call leaves the task in its stage. The output belongs to the turn that stored it: when that turn ends any other way (an error, an interrupt, a handoff, a Merge turn that squashes, an agent process that exits mid-turn, or a session that can't be resumed after a restart), byre clears `stage_output` before marking the session idle, so a later turn can't hand off a stale output; the agent calls `finish_stage` again. A turn cut off by a byre restart and resumed keeps it. If a stage can't start, byre logs `advance workflow stage` and records the error in the finishing task's transcript, and the flag stays set. On startup, before resuming running sessions, `recoverStages` advances every session with `stage_finished` set, skipping the steps already done: the recorded output isn't overwritten; a stage that already has a task in the run isn't created again, but when that task is active and has no session, its sessions are started, and each of its sessions that never got a message (`SessionStarted`) is sent the stage's first message (a team member's with its own prompt), and the restart notice isn't sent to them. `sqlite3 byre.db 'SELECT id, task_id FROM agent_sessions WHERE stage_finished = 1'` lists stages waiting to advance. `list_tasks` shows each stage task's workflow and `stage "<name>"`, with `(finished)` once its stage has finished and `workflow finished` once any task of the run has a `task.workflow_finished` event. Nothing reads or writes `agent_sessions.workflow_stage`; it stays in the schema so builds from older branches keep working against the same database. - **Progress.** `GET /api/tasks/{id}/workflow/run` (`Manager.WorkflowRun`) returns the run of a stage task (404 for a task without one): the definition that task froze (typed, not YAML), name, title, brief, `finished` (any task of the run has `task.workflow_finished`), and per stage in definition order its resolved `after`, `waiting_on` (the `after` stages with no finished attempt), its `attempts` and a `state`. An attempt is every task of the run at that stage, closed ones included, oldest first (the first and its forks), with its `output` and `commit` once `tasks.stage_output` is set, `forked_from`, `discarded_by` (the `task.discarded` actor) and `inputs`: the tasks named in the `after` of its latest `task.workflow_stage` event, each with its stage, so it says which attempt fed it; a fork has no such event and takes its source's. An attempt's state is `merged`, `cleaned_up` (discarded after finishing; `byre` as `discarded_by` is the final stage's cleanup), `discarded` (before finishing), `finished`, `running` (a session is running) or `idle`. A stage is `waiting` with no attempts, else it takes the state of its latest finished attempt, or its latest attempt when none finished. The Workflow tab (`mac/Sources/Byre/Workflow.swift`) draws the graph natively with the flowchart layout from `Diagram.swift`, each stage's node coloured by its state (`stateColors`, from the palette's tint and accent pairs: waiting grey, running accent, idle warn, finished, merged and cleaned up ok, discarded danger) and redrawn whenever a refetch changes a state; then a card per stage with its state, its attempts linking to their tasks, each attempt's inputs ("started from") and the attempts it fed, and its output collapsed as Markdown. It refetches on any stored `task.*` or `session.*` event of a task in the run, and on a `task.created` whose payload carries the run, so a new stage or fork shows up. ## Events diff --git a/internal/agents/afterreview.go b/internal/agents/afterreview.go index 3ebc42a..645b446 100644 --- a/internal/agents/afterreview.go +++ b/internal/agents/afterreview.go @@ -34,14 +34,13 @@ func (m *Manager) MergeAfterReview(ctx context.Context, task api.Task, user api. } } - snaps, err := m.store.ListSnapshots(ctx, task.Id) + latest, err := m.store.LastSnapshotNot(ctx, task.Id, api.SnapshotTriggerAnswer) + if errors.Is(err, store.ErrNotFound) { + return api.Task{}, fmt.Errorf("%w: task %s has no snapshot under review", ErrConflict, task.Id) + } if err != nil { return api.Task{}, err } - if len(snaps) == 0 { - return api.Task{}, fmt.Errorf("%w: task %s has no snapshot under review", ErrConflict, task.Id) - } - latest := snaps[len(snaps)-1] reviews, err := m.store.ListReviews(ctx, task.Id) if err != nil { return api.Task{}, err @@ -147,11 +146,11 @@ func (m *Manager) startMergeAfterReview(ctx context.Context, r api.Review, mar s if task.Discarding { return "the task is being discarded" } - snaps, err := m.store.ListSnapshots(ctx, task.Id) + latest, err := m.store.LastSnapshotNot(ctx, task.Id, api.SnapshotTriggerAnswer) if err != nil { return err.Error() } - if len(snaps) == 0 || snaps[len(snaps)-1].N != r.SnapshotN { + if latest.N != r.SnapshotN { return "a newer snapshot was taken" } sess, found, err := m.liveSession(ctx, task.Id) diff --git a/internal/agents/guidance.go b/internal/agents/guidance.go index 1e4f638..b124a52 100644 --- a/internal/agents/guidance.go +++ b/internal/agents/guidance.go @@ -85,10 +85,8 @@ func (m *Manager) takeBounce(ctx context.Context, l *live, final string) (bool, if err != nil { return false, err } - for _, item := range open { - if item.Kind == api.Question { - return false, nil - } + if len(open) > 0 { + return false, nil } m.mu.Lock() diff --git a/internal/agents/inbox.go b/internal/agents/inbox.go index 0d05ba1..27078e7 100644 --- a/internal/agents/inbox.go +++ b/internal/agents/inbox.go @@ -127,7 +127,17 @@ func (m *Manager) ask(ctx context.Context, l *live, raw json.RawMessage) (string "attachments": attachments, } - err = m.store.CreateInboxItem(ctx, m.newItem(l, id, thread, api.Question, store.ToPayload(payload))) + err = m.store.CreateInboxItem(ctx, api.InboxItem{ + Id: id, + ProjectId: l.projectID, + TaskId: l.taskID, + AgentSessionId: l.id, + ThreadId: thread, + Payload: store.ToPayload(payload), + State: api.Open, + CreatedAt: time.Now().UTC(), + Responses: []api.InboxResponse{}, + }) if err != nil { return "", err } @@ -176,7 +186,7 @@ func (m *Manager) cancelQuestion(ctx context.Context, l *live, raw json.RawMessa if err != nil { return "", err } - if item.AgentSessionId != l.id || item.Kind != api.Question { + if item.AgentSessionId != l.id { return "", fmt.Errorf("question_id %s is not a question you asked", args.QuestionID) } @@ -251,35 +261,18 @@ func (m *Manager) validateAsk(ctx context.Context, l *live, args askArgs) error return nil } -func (m *Manager) fileError(l *live, msg string) { - id := store.NewID() - err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.SessionError, map[string]any{"error": msg})) +func (m *Manager) recordError(l *live, msg string) { + err := m.emit(context.Background(), l, "byre", "session.error", map[string]any{"error": msg}) if err != nil { - slog.Error("file session error", "session", l.id, "err", err) + slog.Error("record session error", "session", l.id, "err", err) } } -func (m *Manager) fileRateLimited(l *live, info map[string]any, failure string) { - id := store.NewID() +func (m *Manager) recordRateLimited(l *live, info map[string]any, failure string) { payload := map[string]any{"vendor": string(l.vendor), "rate_limit": info, "error": failure} - err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.RateLimited, payload)) + err := m.emit(context.Background(), l, "byre", "session.rate_limited", payload) if err != nil { - slog.Error("file rate limit", "session", l.id, "err", err) - } -} - -func (m *Manager) newItem(l *live, id, thread string, kind api.InboxItemKind, payload map[string]any) api.InboxItem { - return api.InboxItem{ - Id: id, - ProjectId: l.projectID, - TaskId: l.taskID, - AgentSessionId: l.id, - ThreadId: thread, - Kind: kind, - Payload: payload, - State: api.Open, - CreatedAt: time.Now().UTC(), - Responses: []api.InboxResponse{}, + slog.Error("record rate limit", "session", l.id, "err", err) } } @@ -326,6 +319,12 @@ func (m *Manager) Deliver(ctx context.Context, sessionID string) error { return err } + target, err := m.sessionTarget(ctx, sess, task) + if err != nil { + return err + } + snapshotN := m.snapshotForAnswer(ctx, target, false) + images := []string{} for _, p := range pending { images = append(images, p.Response.Images...) @@ -350,8 +349,8 @@ func (m *Manager) Deliver(ctx context.Context, sessionID string) error { pid = l.sess.pid() exited = l.sess.exited() } - slog.Info("deliver sent", "session", sessionID, "state", after.State, "pid", pid, "exited", exited, "closing", closing, "responses", ids) - return m.store.MarkDelivered(ctx, ids) + slog.Info("deliver sent", "session", sessionID, "state", after.State, "pid", pid, "exited", exited, "closing", closing, "responses", ids, "snapshot", snapshotN) + return m.store.MarkDelivered(ctx, ids, snapshotN) } type midTurn struct { @@ -454,7 +453,8 @@ func (m *Manager) responsesMidTurn(ctx context.Context, l *live) (string, []stri return "", nil } - err = m.store.MarkDelivered(ctx, ids) + snapshotN := m.snapshotForAnswer(ctx, liveTarget(l), m.ownsTask(l.id)) + err = m.store.MarkDelivered(ctx, ids, snapshotN) if err != nil { slog.Error("mark responses delivered", "session", l.id, "err", err, "responses", ids) return "", nil @@ -465,7 +465,7 @@ func (m *Manager) responsesMidTurn(ctx context.Context, l *live) (string, []stri if err != nil { slog.Error("record mid-turn delivery", "session", l.id, "err", err) } - slog.Info("deliver sent mid-turn", "session", l.id, "responses", ids) + slog.Info("deliver sent mid-turn", "session", l.id, "responses", ids, "snapshot", snapshotN) return text, ids } @@ -485,14 +485,7 @@ func deliveryMessage(pending []store.Pending) string { r := p.Response b.WriteString("\n") - switch p.Item.Kind { - case api.Question: - fmt.Fprintf(b, "Your question %s: %s\n", p.Item.Id, p.Item.Payload["question"]) - case api.SessionError: - 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"]) - } + fmt.Fprintf(b, "Your question %s: %s\n", p.Item.Id, p.Item.Payload["question"]) switch r.Type { case api.Option: @@ -512,12 +505,8 @@ func deliveryMessage(pending []store.Pending) string { if r.Text != nil { text = *r.Text } - if p.Item.Kind == api.Question { - fmt.Fprintf(b, "%s answered (none of your options): %s\n", r.User, text) - fmt.Fprintf(b, "If this asks you something back, answer by calling ask_human with thread_id %q.\n", p.Item.ThreadId) - } else { - fmt.Fprintf(b, "%s replied: %s\n", r.User, text) - } + fmt.Fprintf(b, "%s answered (none of your options): %s\n", r.User, text) + fmt.Fprintf(b, "If this asks you something back, answer by calling ask_human with thread_id %q.\n", p.Item.ThreadId) } if len(r.Images) > 0 { diff --git a/internal/agents/lead.go b/internal/agents/lead.go index 5a1dfe7..9ef1261 100644 --- a/internal/agents/lead.go +++ b/internal/agents/lead.go @@ -15,13 +15,13 @@ import ( ) const leadGuidance = `You are the human's lead agent in byre, shown in the left pane of byre's app. The human talks to you there, and you coordinate every task agent on their behalf. You don't do task work yourself: task agents work in their own worktrees, and you read, direct and report on them. -- Signals: byre sends you, as messages from byre, what happens across tasks: questions and errors that task agents file, turns they finish with their final message, questions the human answered, tasks created, reviews started and finished, starting to merge, merged or discarded, and every change of a task's state, as "<task> state: <before> → <now>." listed first among what happened at the same moment. The states are running, idle, review_pending, reviewing, merging, discarding, merged, discarded and no_agent. A finished turn arrives with its state change: review_pending when byre is about to review the turn's work, reviewing while the review runs; neither is done, since the review can still find problems, so don't treat a task's work as finished until its state goes to idle. Several may arrive in one message, followed by the current task list (as list_tasks gives it) and what your right pane shows, so you rarely need to call list_tasks after a signal. The human doesn't see signals. Messages from the human arrive separately. +- Signals: byre sends you, as messages from byre, what happens across tasks: questions that task agents ask, errors and rate limits their sessions hit, turns they finish with their final message, questions the human answered, tasks created, reviews started and finished, starting to merge, merged or discarded, and every change of a task's state, as "<task> state: <before> → <now>." listed first among what happened at the same moment. The states are running, idle, review_pending, reviewing, merging, discarding, merged, discarded and no_agent. A finished turn arrives with its state change: review_pending when byre is about to review the turn's work, reviewing while the review runs; neither is done, since the review can still find problems, so don't treat a task's work as finished until its state goes to idle. Several may arrive in one message, followed by the current task list (as list_tasks gives it) and what your right pane shows, so you rarely need to call list_tasks after a signal. The human doesn't see signals. Messages from the human arrive separately. - Don't respond to what the human just did. A signal about their own action (a question they answered, a task they created, merged, started merging or discarded) tells them nothing new: don't report, confirm or comment on it. Act on it only if it changes what you are doing, and then end the turn with one short line at most. When a batch of signals needs nothing from you to the human, make your final message exactly NO_REPLY and nothing else; byre folds that turn away so the human doesn't see it. -- "Your right pane is free" means the human finished what was in the right pane. Show them the next thing that needs them, most urgent first (questions and errors, then finished turns, then tasks ready to merge), with one line saying what it is. If nothing needs them, show that project's project tab, then post a short status: one line per active task (what it's doing or waiting on), then up to three TODO items you'd start next (from list_tasks), each with a one-line reason. Write each item as a link to #/todo/<project_id>/<title, URL-encoded with parentheses as %28 and %29>, for example [Fix login redirect](#/todo/3f2a9c1b7d4e/Fix%20login%20redirect): byre draws it with a Start button the human can click, and once the task exists, its state. Start one with start_todo only when the human picks it. If that says nothing new since your last status, make your final message NO_REPLY. +- "Your right pane is free" means the human finished what was in the right pane. Show them the next thing that needs them, most urgent first (questions, then errors on the task's transcript tab, then finished turns, then tasks ready to merge), with one line saying what it is. If nothing needs them, show that project's project tab, then post a short status: one line per active task (what it's doing or waiting on), then up to three TODO items you'd start next (from list_tasks), each with a one-line reason. Write each item as a link to #/todo/<project_id>/<title, URL-encoded with parentheses as %28 and %29>, for example [Fix login redirect](#/todo/3f2a9c1b7d4e/Fix%20login%20redirect): byre draws it with a Start button the human can click, and once the task exists, its state. Start one with start_todo only when the human picks it. If that says nothing new since your last status, make your final message NO_REPLY. - Whenever you suggest new work that isn't in TODO.md (a task you think is worth doing, an improvement to byre or to your own role), write it as a link to #/suggest/<project_id>/<title>/<brief>, both URL-encoded with parentheses as %28 and %29, for example [Lead tool for admin status](#/suggest/3f2a9c1b7d4e/Lead%20tool%20for%20admin%20status/Add%20a%20lead%20tool%20that%20reads%20%2Fapi%2Fadmin%2Fstatus%20...). The brief is the new task agent's whole first message, so make it a complete brief, as for create_task. byre draws the link with two buttons: Start creates the task with that title and brief at once, without you, and Discuss sends you a message asking to talk the suggestion over. Once an active task has that title, it shows the task's state instead. Don't ask whether to start a suggestion; the buttons do that. - Steering the right pane is a core part of your job, as important as what you write. The right pane shows one task, or a project with no task; point it with show (the task or project, the tabs to put first, the tab to select). By default, keep it on whatever you and the human are discussing, so they can see it while they read your reply; don't make them go looking for it. Call show in the same turn you bring something up: - Discussing, picking or starting TODO items, or a project's tasks as a whole: the project's project tab (show with project_id when no single task of that project is involved). - - A question that needs the human: that task's inbox tab, with one line naming it and what you recommend. + - A question that needs the human: that task's questions tab, with one line naming it and what you recommend. - A finished turn that needs the human, discussing a task's work, what its agent did or is doing, or whether it is ready to merge: its transcript tab, which ends with the agent's final message. A specific file in its worktree: its files tab. - The changes tab only when the human asks to see the diff; they rarely want it. - Show, don't tell. What the right pane shows, the human reads there: never repeat, quote or paraphrase a task agent's question, choices, context or final message in chat. Add only what the pane doesn't say, such as your recommendation and why, or how it connects to other work. @@ -34,7 +34,7 @@ const leadGuidance = `You are the human's lead agent in byre, shown in the left - You can create tasks, start TODO items, message, interrupt and compact task agents, and merge or discard tasks. Merge pushes to the default branch and Discard destroys work: do either only when the human asks for it. - Projects: when the human asks you to add a project, create it with create_project from its name and the path of its git repository on this machine. - Workflows: you manage the server's workflows with list_workflows, read_workflow, save_workflow and delete_workflow, and run one by giving its name to create_task or start_todo. Every stage of a workflow runs as its own task on the stage's model, once the stages it comes after have finished, so stages fan out (several stages after one) and fan in (one stage after several); a stage's agent calls finish_stage to hand its output to the stages after it, signals tell you when a stage starts and when the workflow finishes, and when the final stage finishes byre discards the run's other stage tasks, leaving the final one to merge. Whenever you show or propose a workflow, draw it as a Mermaid flowchart of its stage names only, with no models, for example a fenced mermaid block holding "flowchart LR", "Build --> Review", "Build --> Test", "Review --> Fix" and "Test --> Fix". -- Whenever you name a task in a message, link it as a Markdown link to #/task/<task_id>, for example [Fix login redirect](#/task/da7e487305e4). byre draws it in that task's colour, and clicking it shows the task in the right pane. To open a specific tab, append it: #/task/<task_id>/<tab>. When you point the human at a question to answer, link to #/task/<task_id>/inbox; at a finished turn, to #/task/<task_id>/transcript. +- Whenever you name a task in a message, link it as a Markdown link to #/task/<task_id>, for example [Fix login redirect](#/task/da7e487305e4). byre draws it in that task's colour, and clicking it shows the task in the right pane. To open a specific tab, append it: #/task/<task_id>/<tab>. When you point the human at a question to answer, link to #/task/<task_id>/questions; at a finished turn, to #/task/<task_id>/transcript. - Keep messages short; the human reads them in half the screen. Put long detail in collapsed sections: a line <details><summary>Title</summary>, the Markdown content, then a line </details>. - You are long-running. Your context window is large. byre sends you a signal once your context passes half of its window; then call compact_self at a logical point (a piece of work wrapped up, a topic closed), and not before; when the turn ends, byre replaces you with a fresh session that starts from your handoff. - Don't commit, push, or touch git branches yourself.` @@ -326,6 +326,10 @@ func (m *Manager) Observe(events []api.Event) { if ev.TaskId == nil || !humanActor(ev.Actor) { continue } + case "session.error", "session.rate_limited": + if ev.TaskId == nil { + continue + } default: continue } @@ -473,6 +477,12 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error) } return m.itemText(ctx, item), nil + case "session.error": + return fmt.Sprintf("%s hit an error: %v", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["error"]), nil + + case "session.rate_limited": + return fmt.Sprintf("%s was rate limited: %v", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["error"]), nil + case "task.created": task := api.Task{} err := remarshal(ev.Payload, &task) @@ -547,34 +557,26 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error) func (m *Manager) itemText(ctx context.Context, item api.InboxItem) string { label := m.taskLabel(ctx, item.TaskId) p := item.Payload - switch item.Kind { - case api.Question: - b := &strings.Builder{} - fmt.Fprintf(b, "%s asked question %s: %v\n", label, item.Id, p["question"]) - recommended := map[string]bool{} - for _, r := range anyStrings(p["recommended"]) { - recommended[r] = true - } - kind := "pick one" - if p["multiple"] == true { - kind = "pick any" - } - fmt.Fprintf(b, "Choices (%s):\n", kind) - for _, o := range anyStrings(p["options"]) { - mark := "" - if recommended[o] { - mark = " (recommended)" - } - fmt.Fprintf(b, "- %s%s\n", clip(o, 300), mark) - } - fmt.Fprintf(b, "Blocked on: %v\nThe full context is in read_inbox.", p["why_blocked"]) - return b.String() - case api.SessionError: - return fmt.Sprintf("%s hit an error (item %s): %v", label, item.Id, p["error"]) - case api.RateLimited: - return fmt.Sprintf("%s was rate limited (item %s): %v", label, item.Id, p["error"]) + b := &strings.Builder{} + fmt.Fprintf(b, "%s asked question %s: %v\n", label, item.Id, p["question"]) + recommended := map[string]bool{} + for _, r := range anyStrings(p["recommended"]) { + recommended[r] = true } - return "" + kind := "pick one" + if p["multiple"] == true { + kind = "pick any" + } + fmt.Fprintf(b, "Choices (%s):\n", kind) + for _, o := range anyStrings(p["options"]) { + mark := "" + if recommended[o] { + mark = " (recommended)" + } + fmt.Fprintf(b, "- %s%s\n", clip(o, 300), mark) + } + fmt.Fprintf(b, "Blocked on: %v\nThe full context is in read_inbox.", p["why_blocked"]) + return b.String() } func (m *Manager) answeredText(ctx context.Context, item api.InboxItem) string { @@ -594,9 +596,6 @@ func (m *Manager) answeredText(ctx context.Context, item api.InboxItem) string { if r.Text != nil && strings.TrimSpace(*r.Text) != "" { answer = strings.TrimSpace(answer + " " + *r.Text) } - if item.Kind != api.Question { - return fmt.Sprintf("%s: %s answered %s %s: %s", m.taskLabel(ctx, item.TaskId), r.User, item.Kind, item.Id, clip(answer, 600)) - } recommended := strings.Join(anyStrings(item.Payload["recommended"]), "; ") return fmt.Sprintf("%s: %s answered question %s %q: %s\nThe agent recommended: %s", m.taskLabel(ctx, item.TaskId), r.User, item.Id, clip(fmt.Sprint(item.Payload["question"]), 300), clip(answer, 600), clip(recommended, 600)) } diff --git a/internal/agents/leadtools.go b/internal/agents/leadtools.go index 97054ee..7ee2396 100644 --- a/internal/agents/leadtools.go +++ b/internal/agents/leadtools.go @@ -36,7 +36,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { return append([]mcp.Tool{ tool("list_tasks", - "List every project and its active tasks (id, title, worktree path, the agent's state and model, and open inbox items), "+ + "List every project and its active tasks (id, title, worktree path, the agent's state and model, and open questions), "+ "and the titles of its TODO.md items on the default branch that no active task was started from.", `{"type": "object", "properties": {}}`, func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) { @@ -44,7 +44,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { }), tool("read_transcript", - "Read a task's transcript: messages, tool calls (briefly), tool results (cut short), turn ends and inbox filings, each with its event seq. "+ + "Read a task's transcript: messages, tool calls (briefly), tool results (cut short), turn ends, questions asked, errors and rate limits, each with its event seq. "+ "Without after_seq it returns the last limit events; with it, the events after that seq.", `{"type": "object", "properties": { "task_id": {"type": "string"}, @@ -77,7 +77,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { }), tool("read_inbox", - "Read open inbox items as JSON, with each question's full context, choices and responses. Give task_id for one task's items.", + "Read open questions as JSON, with each one's full context, choices and responses. Give task_id for one task's questions.", `{"type": "object", "properties": {"task_id": {"type": "string"}}}`, func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) { args := struct { @@ -110,12 +110,12 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { }), tool("answer_question", - "Respond to an open inbox item on the human's behalf. Only when the answer is clear or the human told you it. "+ + "Answer an open question on the human's behalf. Only when the answer is clear or the human told you it. "+ "type option: options holds the chosen choices' exact text (one for pick-one; pick-any may add text). "+ - "type other: free text to a question or an error. type dismiss: clears an error.", + "type other: free text.", `{"type": "object", "properties": { "item_id": {"type": "string"}, - "type": {"type": "string", "enum": ["option", "other", "dismiss"]}, + "type": {"type": "string", "enum": ["option", "other"]}, "options": {"type": "array", "items": {"type": "string"}}, "text": {"type": "string"} }, "required": ["item_id", "type"]}`, @@ -388,7 +388,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { "Merge a task as the human: its agent merges the default branch in, then byre squashes it into one commit, pushes it to origin and closes the task. "+ "While another task in the project is merging, byre holds the merge and starts it when that one is done. "+ "For a subtask, target parent instead asks its parent task's agent to merge it into the parent's branch, and the subtask closes once the parent has. "+ - "Refused for a task with active subtasks. Only when the human asks. You get a signal when it is merged; failures are filed in the task's inbox.", + "Refused for a task with active subtasks. Only when the human asks. You get a signal when it is merged; failures show as errors in the task's transcript, and you get a signal for each.", `{"type": "object", "properties": { "task_id": {"type": "string"}, "target": {"type": "string", "enum": ["main", "parent"], "description": "Default main."} @@ -458,18 +458,18 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { tool("show", "Point the human's right pane at a task, or at a project with no task: tabs are the tabs to put first, in order (the rest follow), and tab is the one to select. "+ "Whenever the human asks you to show something (\"show me that when you can\") or you want to show something, call show right away; never hold back because you expect a refusal, since only a refused show arranges byre's signal to try again. "+ - "Tabs: transcript, board (a team task's board: its agents' posts, the human's and yours), changes, files, terminal (a shell in the task's worktree), inbox (the task's open items), reviews (the task's reviews by reviewer agents), workflow (a workflow stage task's run: its graph and each stage's state, attempts and outputs), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+ + "Tabs: transcript, board (a team task's board: its agents' posts, the human's and yours), changes, files, terminal (a shell in the task's worktree), questions (the task's oldest open question), reviews (the task's reviews by reviewer agents), workflow (a workflow stage task's run: its graph and each stage's state, attempts and outputs), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+ "Omit both task_id and project_id to clear the pane. "+ "If the pane already shows that task (or project) on that tab, only the tab order changes and you are told so, even while the human is working in the pane. "+ "Otherwise refused for 10 seconds after the human last moved the pane themselves (switching task or tab), whatever else holds; if a show was refused for it, byre signals you \"The move hold is over\" when those 10 seconds pass without another move, and sends no signal otherwise. "+ - "Otherwise refused while the human is working in the right pane (focused in it, or with unsent text in it), unless it is on the project tab, or what it shows is finished (a merged or discarded task, or an inbox tab with nothing left), "+ + "Otherwise refused while the human is working in the right pane (focused in it, or with unsent text in it), unless it is on the project tab, or what it shows is finished (a merged or discarded task, or a questions tab with nothing left open),"+ "or it is on the transcript tab of a task whose agent is mid-turn, the human has no unsent text there, and either the human sent the message that started that turn since the pane last moved or the pane moved there 10 seconds ago or more; byre signals you \"Your right pane is free\" when either happens. "+ "Also refused while the pane is on the project tab and the human started a TODO item there in the last 10 seconds, unless they switched task or tab or messaged you since; if a show was refused for it, byre signals you \"The start hold is over\" when those 10 seconds pass with the pane still on the project tab, and sends no signal otherwise.", `{"type": "object", "properties": { "task_id": {"type": "string"}, "project_id": {"type": "string", "description": "Ignored when task_id is given."}, - "tabs": {"type": "array", "items": {"type": "string", "enum": ["transcript", "board", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"]}}, - "tab": {"type": "string", "enum": ["transcript", "board", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"]} + "tabs": {"type": "array", "items": {"type": "string", "enum": ["transcript", "board", "changes", "files", "terminal", "questions", "reviews", "workflow", "project"]}}, + "tab": {"type": "string", "enum": ["transcript", "board", "changes", "files", "terminal", "questions", "reviews", "workflow", "project"]} }, "required": ["tabs", "tab"]}`, func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) { f := api.Focus{} @@ -640,7 +640,7 @@ func sameView(a, b api.Focus) bool { return deref(a.ProjectId) == deref(b.ProjectId) } -const inboxEmptied = "has nothing left in its inbox" +const inboxEmptied = "has no open questions left" func (m *Manager) paneFinished(ctx context.Context, f api.Focus) (string, error) { if f.TaskId == nil { @@ -656,7 +656,7 @@ func (m *Manager) paneFinished(ctx context.Context, f api.Focus) (string, error) if task.Merging { return "is merging", nil } - if f.Tab != api.TabInbox { + if f.Tab != api.TabQuestions { return "", nil } items, err := m.store.ListInbox(ctx, store.InboxFilter{State: api.Open, TaskID: task.Id}) @@ -883,7 +883,7 @@ func (m *Manager) listTasksText(ctx context.Context) (string, error) { open++ } } - fmt.Fprintf(b, "- %s %q: %s; inbox %d open; worktree %s", t.Id, t.Title, agent, open, t.WorktreePath) + fmt.Fprintf(b, "- %s %q: %s; %d open questions; worktree %s", t.Id, t.Title, agent, open, t.WorktreePath) if t.TodoItem != nil { fmt.Fprintf(b, "; from TODO item %q", *t.TodoItem) } @@ -1023,7 +1023,11 @@ func transcriptLine(ev api.Event, root string) string { } return "turn ended" case "inbox.created": - return fmt.Sprintf("filed %s %s", text("kind"), text("id")) + return fmt.Sprintf("asked question %s", text("id")) + case "session.error": + return "error: " + clip(text("error"), 300) + case "session.rate_limited": + return "rate limited: " + clip(text("error"), 300) case "session.created": return fmt.Sprintf("session started by %s", ev.Actor) case "session.state": diff --git a/internal/agents/manager.go b/internal/agents/manager.go index f88e1e2..f8a377a 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -889,7 +889,7 @@ func (m *Manager) pump(l *live) { if m.leadsTask(ctx, l.taskID, l.id) { m.abandonMerge(l.taskID, l.projectID) } - m.fileError(l, "The agent process exited mid-turn. Send a message to resume it.") + m.recordError(l, "The agent process exited mid-turn. Send a message to resume it.") } } @@ -1104,7 +1104,7 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error { sha, err := git.CommitAll(l.dir, commitMessage(res), l.gitName, l.gitEmail) if err != nil { slog.Error("commit turn", "session", l.id, "err", err) - m.fileError(l, fmt.Sprintf("byre couldn't commit the turn's work, so the turn counts as unfinished: %v", err)) + m.recordError(l, fmt.Sprintf("byre couldn't commit the turn's work, so the turn counts as unfinished: %v", err)) finished = false if owner { err = git.AbortMerge(l.dir) @@ -1152,9 +1152,9 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error { switch { case !res.isError || interrupted: case rateLimited != nil: - m.fileRateLimited(l, rateLimited, failure) + m.recordRateLimited(l, rateLimited, failure) default: - m.fileError(l, failure) + m.recordError(l, failure) } stageFinished := false @@ -1217,7 +1217,7 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error { case mergeBy != "": go m.releaseMerge(l.projectID, l.taskID) if !res.isError || interrupted { - m.fileError(l, "The Merge turn ended unfinished (interrupted, or with no final message), so byre didn't squash or push the task. Merge again to retry.") + m.recordError(l, "The Merge turn ended unfinished (interrupted, or with no final message), so byre didn't squash or push the task. Merge again to retry.") } default: go m.nextMerge(l.projectID) diff --git a/internal/agents/mergein.go b/internal/agents/mergein.go index b249009..c4d4470 100644 --- a/internal/agents/mergein.go +++ b/internal/agents/mergein.go @@ -392,7 +392,7 @@ func (m *Manager) fileTaskError(ctx context.Context, task api.Task, sessions []a return } last := sessions[len(sessions)-1] - m.fileError(&live{id: last.Id, taskID: task.Id, projectID: task.ProjectId}, msg) + m.recordError(&live{id: last.Id, taskID: task.Id, projectID: task.ProjectId}, msg) } func (m *Manager) takeMerge(taskID string) string { @@ -416,14 +416,14 @@ func (m *Manager) finishMerge(l *live, login string) { slog.Error("finish merge", "task", l.taskID, "err", err) unfinished := &unfinishedMergeError{} if errors.As(err, &unfinished) { - m.fileError(l, "Merging the task failed: "+err.Error()+".") + m.recordError(l, "Merging the task failed: "+err.Error()+".") return } cerr := m.store.ClearFinalizing(ctx, l.taskID) if cerr != nil { slog.Error("clear merge finalizing", "task", l.taskID, "err", cerr) } - m.fileError(l, "Merging the task failed: "+err.Error()) + m.recordError(l, "Merging the task failed: "+err.Error()) err = m.Deliver(ctx, l.id) if err != nil { slog.Error("deliver inbox responses", "session", l.id, "err", err) diff --git a/internal/agents/notes.go b/internal/agents/notes.go index 7f4deb7..6c32588 100644 --- a/internal/agents/notes.go +++ b/internal/agents/notes.go @@ -69,15 +69,9 @@ func (m *Manager) mergeNote(ctx context.Context, task api.Task, login string) (s } } - decisions := []api.InboxItem{} - for _, item := range answered { - if item.Kind == api.Question { - decisions = append(decisions, item) - } - } - if len(decisions) > 0 { + if len(answered) > 0 { b.WriteString("\n## Decisions\n") - for _, item := range decisions { + for _, item := range answered { fmt.Fprintf(b, "\n### %s\n", item.Payload["question"]) background, _ := item.Payload["context"].(string) if strings.TrimSpace(background) != "" { diff --git a/internal/agents/respond.go b/internal/agents/respond.go index 4879673..280b5a9 100644 --- a/internal/agents/respond.go +++ b/internal/agents/respond.go @@ -108,16 +108,12 @@ func CheckResponse(item api.InboxItem, c api.InboxResponseCreate) (api.InboxStat text = strings.TrimSpace(*c.Text) } images := c.Images != nil && len(*c.Images) > 0 - question := item.Kind == api.Question bad := func(format string, args ...any) (api.InboxState, error) { return "", fmt.Errorf("%w: item %s: "+format, append([]any{ErrInvalid, item.Id}, args...)...) } switch c.Type { case api.Option: - if !question { - return bad("only questions have options") - } if c.Options == nil || len(*c.Options) == 0 { return bad("option needs the chosen options") } @@ -144,15 +140,6 @@ func CheckResponse(item api.InboxItem, c api.InboxResponseCreate) (api.InboxStat return bad("other needs text or images") } return api.Answered, nil - - case api.Dismiss: - if question { - return bad("questions need an option or other, not a dismissal") - } - if images { - return bad("a dismissal takes no images") - } - return api.Dismissed, nil } return bad("unknown response type %q", c.Type) diff --git a/internal/agents/snapshots.go b/internal/agents/snapshots.go index 687a6b3..6143b91 100644 --- a/internal/agents/snapshots.go +++ b/internal/agents/snapshots.go @@ -145,7 +145,11 @@ func (m *Manager) takeSnapshot(ctx context.Context, t snapTarget, trigger api.Sn if err != nil && !errors.Is(err, store.ErrNotFound) { return nil, err } - if err == nil && last.TreeSha == tree && !force { + reviewed, err := m.store.LastSnapshotNot(ctx, t.taskID, api.SnapshotTriggerAnswer) + if err != nil && !errors.Is(err, store.ErrNotFound) { + return nil, err + } + if err == nil && reviewed.TreeSha == tree && !force { return nil, nil } @@ -179,7 +183,7 @@ func (m *Manager) takeSnapshot(ctx context.Context, t snapTarget, trigger api.Sn } func (m *Manager) RequestSnapshot(ctx context.Context, taskID string) error { - task, project, err := m.activeTask(ctx, taskID) + task, _, err := m.activeTask(ctx, taskID) if err != nil { return err } @@ -204,8 +208,7 @@ func (m *Manager) RequestSnapshot(ctx context.Context, taskID string) error { if len(sessions) == 0 { return fmt.Errorf("%w: task %s has no agent session to attribute a snapshot to", ErrConflict, task.Id) } - sess := sessions[len(sessions)-1] - creator, err := m.store.GetUser(ctx, sess.CreatedBy) + t, err := m.sessionTarget(ctx, sessions[len(sessions)-1], task) if err != nil { return err } @@ -213,11 +216,44 @@ func (m *Manager) RequestSnapshot(ctx context.Context, taskID string) error { if err != nil { return err } - t := snapTarget{taskID: task.Id, projectID: project.Id, sessionID: sess.Id, dir: task.WorktreePath, gitName: creator.GitName, gitEmail: creator.GitEmail} m.snapshotSoon(t, api.SnapshotTriggerRequested, 0, true, true) return nil } +func (m *Manager) sessionTarget(ctx context.Context, sess api.AgentSession, task api.Task) (snapTarget, error) { + creator, err := m.store.GetUser(ctx, sess.CreatedBy) + if err != nil { + return snapTarget{}, err + } + return snapTarget{taskID: task.Id, projectID: task.ProjectId, sessionID: sess.Id, dir: task.WorktreePath, gitName: creator.GitName, gitEmail: creator.GitEmail}, nil +} + +func (m *Manager) snapshotForAnswer(ctx context.Context, t snapTarget, held bool) int { + if !held { + unlock := m.lockTask(t.taskID) + defer unlock() + } + m.snapMu.Lock() + st := m.snapStateLocked(t.taskID) + m.snapMu.Unlock() + st.mu.Lock() + defer st.mu.Unlock() + m.snapMu.Lock() + closed := st.closed + m.snapMu.Unlock() + if closed { + return 0 + } + + snap, err := m.takeSnapshot(ctx, t, api.SnapshotTriggerAnswer, 0, true) + if err != nil { + slog.Error("take snapshot", "task", t.taskID, "trigger", api.SnapshotTriggerAnswer, "err", err) + m.recordError(&live{id: t.sessionID, taskID: t.taskID, projectID: t.projectID}, fmt.Sprintf("byre couldn't snapshot the worktree before handing over an answer, so the answer went to the agent without one: %v", err)) + return 0 + } + return snap.N +} + func (m *Manager) takeSnapshotTool(l *live) mcp.Tool { return mcp.Tool{ Name: "take_snapshot", diff --git a/internal/agents/subtasks.go b/internal/agents/subtasks.go index d653732..907169a 100644 --- a/internal/agents/subtasks.go +++ b/internal/agents/subtasks.go @@ -111,7 +111,7 @@ func (m *Manager) subtaskTools(l *live) []mcp.Tool { }), tool("read_subtask", - "Read a subtask's transcript: messages, tool calls (briefly), tool results (cut short), turn ends and inbox filings, each with its event seq. "+ + "Read a subtask's transcript: messages, tool calls (briefly), tool results (cut short), turn ends, questions asked, errors and rate limits, each with its event seq. "+ "Without after_seq it returns the last limit events; with it, the events after that seq.", `{"type": "object", "properties": { "task_id": {"type": "string"}, diff --git a/internal/agents/workflows.go b/internal/agents/workflows.go index ffa2cd4..0ec830f 100644 --- a/internal/agents/workflows.go +++ b/internal/agents/workflows.go @@ -315,7 +315,7 @@ func (m *Manager) advanceStage(l *live) { _, err := m.nextStage(ctx, l.id) if err != nil { slog.Error("advance workflow stage", "session", l.id, "err", err) - m.fileError(l, fmt.Sprintf("The workflow didn't move on from this stage: %v. byre tries again when it next starts.", err)) + m.recordError(l, fmt.Sprintf("The workflow didn't move on from this stage: %v. byre tries again when it next starts.", err)) } m.afterTurn(ctx, l) } diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index d5fd35e..0de1f35 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -231,39 +231,15 @@ func (e FindingState) Valid() bool { } } -// Defines values for InboxItemKind. -const ( - Question InboxItemKind = "question" - RateLimited InboxItemKind = "rate_limited" - SessionError InboxItemKind = "session_error" -) - -// Valid indicates whether the value is a known member of the InboxItemKind enum. -func (e InboxItemKind) Valid() bool { - switch e { - case Question: - return true - case RateLimited: - return true - case SessionError: - return true - default: - return false - } -} - // Defines values for InboxResponseType. const ( - Dismiss InboxResponseType = "dismiss" - Option InboxResponseType = "option" - Other InboxResponseType = "other" + Option InboxResponseType = "option" + Other InboxResponseType = "other" ) // Valid indicates whether the value is a known member of the InboxResponseType enum. func (e InboxResponseType) Valid() bool { switch e { - case Dismiss: - return true case Option: return true case Other: @@ -323,6 +299,7 @@ func (e ReviewState) Valid() bool { // Defines values for SnapshotTrigger. const ( + SnapshotTriggerAnswer SnapshotTrigger = "answer" SnapshotTriggerAskHuman SnapshotTrigger = "ask_human" SnapshotTriggerMessage SnapshotTrigger = "message" SnapshotTriggerRequested SnapshotTrigger = "requested" @@ -334,6 +311,8 @@ const ( // Valid indicates whether the value is a known member of the SnapshotTrigger enum. func (e SnapshotTrigger) Valid() bool { switch e { + case SnapshotTriggerAnswer: + return true case SnapshotTriggerAskHuman: return true case SnapshotTriggerMessage: @@ -464,8 +443,8 @@ const ( TabBoard TaskTab = "board" TabChanges TaskTab = "changes" TabFiles TaskTab = "files" - TabInbox TaskTab = "inbox" TabProject TaskTab = "project" + TabQuestions TaskTab = "questions" TabReviews TaskTab = "reviews" TabTerminal TaskTab = "terminal" TabTranscript TaskTab = "transcript" @@ -481,10 +460,10 @@ func (e TaskTab) Valid() bool { return true case TabFiles: return true - case TabInbox: - return true case TabProject: return true + case TabQuestions: + return true case TabReviews: return true case TabTerminal: @@ -866,14 +845,13 @@ type Focus struct { TaskId *string `json:"task_id,omitempty"` } -// InboxItem defines model for InboxItem. +// InboxItem A question an agent asked the human with ask_human. type InboxItem struct { - AgentSessionId string `json:"agent_session_id"` - CreatedAt time.Time `json:"created_at"` - Id string `json:"id"` - Kind InboxItemKind `json:"kind"` + AgentSessionId string `json:"agent_session_id"` + CreatedAt time.Time `json:"created_at"` + Id string `json:"id"` - // 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 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. Payload map[string]interface{} `json:"payload"` ProjectId string `json:"project_id"` ResolvedAt *time.Time `json:"resolved_at,omitempty"` @@ -884,9 +862,6 @@ type InboxItem struct { ThreadId string `json:"thread_id"` } -// InboxItemKind defines model for InboxItem.Kind. -type InboxItemKind string - // InboxRespond defines model for InboxRespond. type InboxRespond struct { Responses []InboxResponseCreate `json:"responses"` @@ -900,12 +875,15 @@ type InboxResponse struct { 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 *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"` + + // SnapshotN The task's snapshot of the worktree as it stood just before the response was handed to the agent; absent until delivered, and when no snapshot could be taken. + SnapshotN *int `json:"snapshot_n,omitempty"` + Text *string `json:"text,omitempty"` + Type InboxResponseType `json:"type"` + User string `json:"user"` } // InboxResponseCreate defines model for InboxResponseCreate. @@ -953,15 +931,15 @@ type MessageCreate struct { Text string `json:"text"` } -// NotificationPrefs Which new inbox items and finished turns notify this user in their open byre pages. +// NotificationPrefs Which new questions, finished turns, errors and rate limits notify this user in their open byre pages. type NotificationPrefs struct { - // Errors session_error items. + // Errors session.error events on tasks. Errors bool `json:"errors"` // Questions Questions. On by default. Questions bool `json:"questions"` - // RateLimits rate_limited items. + // RateLimits session.rate_limited events on tasks. RateLimits bool `json:"rate_limits"` // Reports Task turns that end with a summary (turn.ended with summary: true). @@ -1094,11 +1072,11 @@ type Snapshot struct { TaskId string `json:"task_id"` TreeSha string `json:"tree_sha"` - // Trigger turn_end: after byre's end-of-turn commit. requested: a task's agent or the lead asked for it with take_snapshot. message (the agent wrote text after one or more tool calls), tool_call, ask_human and timer appear only on snapshots taken by earlier builds. + // Trigger turn_end: after byre's end-of-turn commit. requested: a task's agent or the lead asked for it with take_snapshot. answer: just before answers from the human were handed to the agent. message (the agent wrote text after one or more tool calls), tool_call, ask_human and timer appear only on snapshots taken by earlier builds. Trigger SnapshotTrigger `json:"trigger"` } -// SnapshotTrigger turn_end: after byre's end-of-turn commit. requested: a task's agent or the lead asked for it with take_snapshot. message (the agent wrote text after one or more tool calls), tool_call, ask_human and timer appear only on snapshots taken by earlier builds. +// SnapshotTrigger turn_end: after byre's end-of-turn commit. requested: a task's agent or the lead asked for it with take_snapshot. answer: just before answers from the human were handed to the agent. message (the agent wrote text after one or more tool calls), tool_call, ask_human and timer appear only on snapshots taken by earlier builds. type SnapshotTrigger string // Stage defines model for Stage. @@ -1384,7 +1362,7 @@ type User struct { GitName string `json:"git_name"` Login string `json:"login"` - // NotificationPrefs Which new inbox items and finished turns notify this user in their open byre pages. + // NotificationPrefs Which new questions, finished turns, errors and rate limits notify this user in their open byre pages. NotificationPrefs NotificationPrefs `json:"notification_prefs"` } @@ -3803,253 +3781,255 @@ 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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", + "ewA=", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/store/inbox.go b/internal/store/inbox.go index 955898c..ab0f5cc 100644 --- a/internal/store/inbox.go +++ b/internal/store/inbox.go @@ -12,14 +12,6 @@ import ( "g.fc.run/misc/byre/internal/api" ) -func DeliverableResponse(t api.InboxResponseType) bool { - switch t { - case api.Option, api.Other: - return true - } - return false -} - func (s *Store) CreateInboxItem(ctx context.Context, item api.InboxItem) error { return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { payload, err := json.Marshal(item.Payload) @@ -28,9 +20,9 @@ func (s *Store) CreateInboxItem(ctx context.Context, item api.InboxItem) error { } _, err = tx.ExecContext(ctx, ` - INSERT INTO inbox_items (id, project_id, task_id, agent_session_id, thread_id, kind, payload, state, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, item.Id, item.ProjectId, item.TaskId, item.AgentSessionId, item.ThreadId, string(item.Kind), + INSERT INTO inbox_items (id, project_id, task_id, agent_session_id, thread_id, payload, state, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, item.Id, item.ProjectId, item.TaskId, item.AgentSessionId, item.ThreadId, string(payload), string(item.State), formatTime(item.CreatedAt)) if err != nil { return err @@ -220,10 +212,9 @@ func (s *Store) AwaitingHuman(ctx context.Context, sessionID string) (int, error n := 0 err := s.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM inbox_items i - WHERE i.agent_session_id = ? AND i.kind = ? AND i.state = ? - AND NOT EXISTS (SELECT 1 FROM inbox_responses r WHERE r.item_id = i.id AND r.type IN (?, ?)) - `, sessionID, string(api.Question), string(api.Open), - string(api.Option), string(api.Other)).Scan(&n) + WHERE i.agent_session_id = ? AND i.state = ? + AND NOT EXISTS (SELECT 1 FROM inbox_responses r WHERE r.item_id = i.id) + `, sessionID, string(api.Open)).Scan(&n) return n, err } @@ -235,9 +226,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 ORDER BY r.created_at - `, sessionID, string(api.Option), string(api.Other)) + `, sessionID) if err != nil { return nil, err } @@ -274,11 +265,15 @@ func (s *Store) Undelivered(ctx context.Context, sessionID string) ([]Pending, e return pending, nil } -func (s *Store) MarkDelivered(ctx context.Context, responseIDs []string) error { +func (s *Store) MarkDelivered(ctx context.Context, responseIDs []string, snapshotN int) error { return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { at := now() + var snap any + if snapshotN > 0 { + snap = snapshotN + } for _, id := range responseIDs { - _, err := tx.ExecContext(ctx, `UPDATE inbox_responses SET delivered_at = ? WHERE id = ?`, at, id) + _, err := tx.ExecContext(ctx, `UPDATE inbox_responses SET delivered_at = ?, snapshot_n = ? WHERE id = ?`, at, snap, id) if err != nil { return err } @@ -290,7 +285,7 @@ func (s *Store) MarkDelivered(ctx context.Context, responseIDs []string) error { func (s *Store) MarkUndelivered(ctx context.Context, responseIDs []string) error { return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { for _, id := range responseIDs { - _, err := tx.ExecContext(ctx, `UPDATE inbox_responses SET delivered_at = NULL WHERE id = ?`, id) + _, err := tx.ExecContext(ctx, `UPDATE inbox_responses SET delivered_at = NULL, snapshot_n = NULL WHERE id = ?`, id) if err != nil { return err } @@ -306,7 +301,6 @@ type querier interface { func getItem(ctx context.Context, q querier, id string) (api.InboxItem, error) { item := api.InboxItem{} - kind := "" payload := "" state := "" createdAt := "" @@ -314,16 +308,15 @@ func getItem(ctx context.Context, q querier, id string) (api.InboxItem, error) { resolvedAt := sql.NullString{} err := q.QueryRowContext(ctx, ` - SELECT id, project_id, task_id, agent_session_id, thread_id, kind, payload, state, + SELECT id, project_id, task_id, agent_session_id, thread_id, payload, state, created_at, resolved_by, resolved_at FROM inbox_items WHERE id = ? - `, id).Scan(&item.Id, &item.ProjectId, &item.TaskId, &item.AgentSessionId, &item.ThreadId, &kind, + `, id).Scan(&item.Id, &item.ProjectId, &item.TaskId, &item.AgentSessionId, &item.ThreadId, &payload, &state, &createdAt, &resolvedBy, &resolvedAt) if err != nil { return item, err } - item.Kind = api.InboxItemKind(kind) item.State = api.InboxState(state) err = json.Unmarshal([]byte(payload), &item.Payload) if err != nil { @@ -347,7 +340,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, images, attachments, created_at, delivered_at + SELECT id, item_id, user_login, type, options, text, images, attachments, created_at, delivered_at, snapshot_n FROM inbox_responses WHERE item_id = ? ORDER BY created_at `, itemID) if err != nil { @@ -365,8 +358,9 @@ func getResponses(ctx context.Context, q querier, itemID string) ([]api.InboxRes attachments := "" createdAt := "" delivered := sql.NullString{} + snap := sql.NullInt64{} - err = rows.Scan(&r.Id, &r.ItemId, &r.User, &typ, &options, &text, &images, &attachments, &createdAt, &delivered) + err = rows.Scan(&r.Id, &r.ItemId, &r.User, &typ, &options, &text, &images, &attachments, &createdAt, &delivered, &snap) if err != nil { return nil, err } @@ -383,6 +377,10 @@ func getResponses(ctx context.Context, q querier, itemID string) ([]api.InboxRes if text.Valid { r.Text = &text.String } + if snap.Valid { + n := int(snap.Int64) + r.SnapshotN = &n + } err = json.Unmarshal([]byte(images), &r.Images) if err != nil { return nil, err diff --git a/internal/store/snapshot.go b/internal/store/snapshot.go index 66540d3..e48fbd2 100644 --- a/internal/store/snapshot.go +++ b/internal/store/snapshot.go @@ -62,6 +62,14 @@ func (s *Store) LastSnapshot(ctx context.Context, taskID string) (api.Snapshot, return snap, err } +func (s *Store) LastSnapshotNot(ctx context.Context, taskID string, trigger api.SnapshotTrigger) (api.Snapshot, error) { + snap, err := scanSnapshot(s.db.QueryRowContext(ctx, `SELECT `+snapshotColumns+` FROM snapshots WHERE task_id = ? AND trigger != ? ORDER BY n DESC LIMIT 1`, taskID, string(trigger))) + if errors.Is(err, sql.ErrNoRows) { + return snap, ErrNotFound + } + return snap, err +} + func (s *Store) LastTaskSeq(ctx context.Context, taskID string) (int64, error) { seq := int64(0) err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq), 0) FROM events WHERE task_id = ?`, taskID).Scan(&seq) diff --git a/internal/store/store.go b/internal/store/store.go index 28fa862..19e4116 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -479,6 +479,23 @@ ALTER TABLE users ADD COLUMN default_model TEXT NOT NULL DEFAULT 'claude-opus-5- `}, {"add-team-members", ` ALTER TABLE agent_sessions ADD COLUMN member TEXT NOT NULL DEFAULT ''; ALTER TABLE task_inputs ADD COLUMN member TEXT NOT NULL DEFAULT ''; +`}, {"rename-inbox-tab", ` +UPDATE users SET focus = replace(focus, '"inbox"', '"questions"'); +`}, {"add-inbox-response-snapshots", ` +ALTER TABLE inbox_responses ADD COLUMN snapshot_n INTEGER; +`}, {"errors-to-transcript", ` +UPDATE events SET kind = 'session.error', payload = json_object('error', json_extract(payload, '$.payload.error')) + WHERE kind = 'inbox.created' AND json_extract(payload, '$.kind') = 'session_error'; +UPDATE events SET kind = 'session.rate_limited', payload = json_object( + 'vendor', json_extract(payload, '$.payload.vendor'), + 'rate_limit', json(json_extract(payload, '$.payload.rate_limit')), + 'error', json_extract(payload, '$.payload.error')) + WHERE kind = 'inbox.created' AND json_extract(payload, '$.kind') = 'rate_limited'; +DELETE FROM events WHERE kind IN ('inbox.created', 'inbox.updated') AND json_extract(payload, '$.kind') != 'question'; +UPDATE events SET payload = json_remove(payload, '$.kind') WHERE kind IN ('inbox.created', 'inbox.updated'); +DELETE FROM inbox_responses WHERE item_id IN (SELECT id FROM inbox_items WHERE kind != 'question'); +DELETE FROM inbox_items WHERE kind != 'question'; +ALTER TABLE inbox_items DROP COLUMN kind; `}} const ( diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index c7154b6..3fb6eef 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -1019,10 +1019,73 @@ struct RowView: View { case .snapshot(let snap): SnapshotRow(snap: snap, review: row.review, taskId: row.taskId) + + case .question: + if let item = row.question { + QuestionBubble(item: item) + } } } } +private struct QuestionBubble: View { + let item: InboxItem + @Environment(Store.self) private var store + @ViewState private var hover = false + + var body: some View { + let open = item.state == .open + let tone = open ? Palette.warn : Palette.muted + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text("asked") + WhenText(when: item.createdAt) + } + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + MarkdownView(blocks: parseMarkdown(jsString(item.payload.value["question"] ?? "")), ctx: MDContext(taskId: item.taskId)) + Text(status) + .font(Fonts.font(11, weight: open ? 700 : 400)) + .foregroundStyle(tone) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(open ? Palette.warnTint : Palette.surface, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(hover ? Palette.accent : open ? Palette.warn.opacity(0.55) : Palette.border)) + .contentShape(Rectangle()) + .onTapGesture { + store.showQuestion(item.taskId, item.id) + } + .onHover { hover = $0 } + .help("Show this question in the Questions tab") + } + + private var status: String { + let answer = item.responses.last + switch item.state { + case .open: + return "waiting for your answer" + case .dismissed: + return item.resolvedBy == nil ? "withdrawn by the agent" : "dismissed by \(item.resolvedBy!)" + default: + guard let answer else { + return item.state.rawValue + } + if let options = answer.options, !options.isEmpty { + return "\(answer.user) chose: \(options.joined(separator: "; "))" + } + return "\(answer.user) answered: \(firstLine(answer.text ?? ""))" + } + } + + private func firstLine(_ s: String) -> String { + let line = trimmed(s).components(separatedBy: "\n").first ?? "" + return line.count > 120 ? "\(line.prefix(120))…" : line + } +} + private struct ToolResult: View { let result: String? let unfinished: Bool @@ -1109,7 +1172,7 @@ struct NoteView: View { @ViewState private var cancelling = false var body: some View { - let color = note.style == .error ? Palette.danger : note.style == .ask ? Palette.accent : Palette.muted + let color = note.style == .error ? Palette.danger : Palette.muted HStack(alignment: .firstTextBaseline, spacing: 12) { Text(text()) .font(Fonts.font(11)) @@ -1216,7 +1279,9 @@ private struct SnapshotRow: View { .buttonStyle(SmallLinkButtonStyle()) .disabled(forking) .help("Start a new task from this snapshot, its agent continuing the conversation as it stood here") - reviewButton + if snap.trigger != .answer { + reviewButton + } Rectangle().fill(Palette.border).frame(height: 1) } .font(Fonts.font(11)) diff --git a/mac/Sources/Byre/Format.swift b/mac/Sources/Byre/Format.swift index 0658837..31f49cb 100644 --- a/mac/Sources/Byre/Format.swift +++ b/mac/Sources/Byre/Format.swift @@ -513,8 +513,6 @@ struct StateMark: View { Spinner(color: color) case .answer: StateDot(filled: Palette.warn) - case .error: - StateDot(filled: Palette.danger) default: StateDot(filled: nil) } diff --git a/mac/Sources/Byre/MergeDialog.swift b/mac/Sources/Byre/MergeDialog.swift index 1ec0d4f..51b1c8d 100644 --- a/mac/Sources/Byre/MergeDialog.swift +++ b/mac/Sources/Byre/MergeDialog.swift @@ -160,7 +160,7 @@ private func reviewWarnings(_ taskId: String, _ store: Store, _ leave: @escaping async let snapsCall = client.listSnapshots(path: .init(taskId: taskId)).ok.body.json async let reviewsCall = client.listReviews(path: .init(taskId: taskId)).ok.body.json let (snaps, reviews) = try await (snapsCall, reviewsCall) - guard let latest = snaps.last else { + guard let latest = snaps.last(where: { $0.trigger != .answer }) else { return ([MergeWarning(text: "These changes haven't been reviewed")], false) } var items: [MergeWarning] = [] @@ -226,14 +226,14 @@ private struct MergeContent: View { @FocusState private var focused: Bool var body: some View { - let questions = store.inbox.filter { $0.taskId == model.taskId && $0.kind == .question }.count + let questions = store.inbox.filter { $0.taskId == model.taskId }.count var warnings = model.reviewItems if questions > 0 { let taskId = model.taskId let leave = model.leave warnings.append(MergeWarning(text: questions == 1 ? "The agent's open question will be dismissed" : "The agent's \(questions) open questions will be dismissed", open: { leave { - store.focusTask(taskId, .inbox) + store.focusTask(taskId, .questions) } })) } diff --git a/mac/Sources/Byre/Notifications.swift b/mac/Sources/Byre/Notifications.swift index 3c5f784..51efdbd 100644 --- a/mac/Sources/Byre/Notifications.swift +++ b/mac/Sources/Byre/Notifications.swift @@ -41,23 +41,6 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate { } } -private func wants(_ p: NotificationPrefs, _ item: InboxItem) -> Bool { - switch item.kind { - case .question: p.questions - case .sessionError: p.errors - case .rateLimited: p.rateLimits - } -} - -private func itemText(_ item: InboxItem) -> String { - let p = item.payload.value - switch item.kind { - case .question: return str(p, "question") ?? "" - case .sessionError: return "Error: \(str(p, "error") ?? "")" - case .rateLimited: return "Rate limited" - } -} - @MainActor private var queue: Task<Void, Never>? @@ -94,10 +77,22 @@ private func show(_ store: Store, _ taskId: String, _ tag: String, _ text: Strin @MainActor func notifyInbox(_ store: Store, _ item: InboxItem) { - guard let me = store.me, wants(me.notificationPrefs, item) else { + guard let me = store.me, me.notificationPrefs.questions else { return } - show(store, item.taskId, item.id, itemText(item)) + show(store, item.taskId, item.id, str(item.payload.value, "question") ?? "") +} + +@MainActor +func notifyError(_ store: Store, _ ev: ByreEvent) { + guard let me = store.me, let taskId = ev.taskId, !taskId.isEmpty else { + return + } + let limited = ev.kind == "session.rate_limited" + if !(limited ? me.notificationPrefs.rateLimits : me.notificationPrefs.errors) { + return + } + show(store, taskId, "error-\(ev.seq)", limited ? "Rate limited" : "Error: \(str(ev.payload.value, "error") ?? "")") } @MainActor diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index 5f0e07e..8611083 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -27,7 +27,7 @@ let tabLabels: [TaskTab: String] = [ .changes: "Changes", .files: "Files", .terminal: "Terminal", - .inbox: "Inbox", + .questions: "Questions", .reviews: "Reviews", .workflow: "Workflow", .project: "Project", @@ -211,8 +211,8 @@ private struct TaskPane: View { for item in fresh { seen.insert(item.id) } - if store.focus.tab == .transcript && !store.paneFocus.drafting && fresh.contains(where: { $0.kind == .question && $0.taskId == store.selectedTaskId }) { - store.focusTab(.inbox) + if store.focus.tab == .transcript && !store.paneFocus.drafting && fresh.contains(where: { $0.taskId == store.selectedTaskId }) { + store.focusTab(.questions) } } } @@ -231,8 +231,8 @@ private struct TaskPane: View { FilesView() case .terminal: TerminalTab() - case .inbox: - InboxView() + case .questions: + QuestionsView() case .reviews: ReviewsView() case .workflow: @@ -370,11 +370,11 @@ private struct TabStrip: View { var reviewing: Bool func count(_ t: TaskTab) -> Int { - t == .inbox ? waiting : t == .reviews && !reviewing ? findings : 0 + t == .questions ? waiting : t == .reviews && !reviewing ? findings : 0 } func badge(_ t: TaskTab) -> TabBadge { - count(t) > 0 ? .count(count(t), t == .inbox ? "inbox items" : "open findings in the latest review") : .none + count(t) > 0 ? .count(count(t), t == .questions ? "open questions" :"open findings in the latest review") : .none } } @@ -398,7 +398,7 @@ private struct TabStrip: View { } switch t { case .board: return team - case .inbox: return waiting > 0 + case .questions: return waiting > 0 case .reviews: return hasReviews case .workflow: return workflow default: return true diff --git a/mac/Sources/Byre/Project.swift b/mac/Sources/Byre/Project.swift index 49c9956..9a6700a 100644 --- a/mac/Sources/Byre/Project.swift +++ b/mac/Sources/Byre/Project.swift @@ -186,7 +186,7 @@ private struct TaskRow: View { .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) if count > 0 { - Badge(count: count).help("inbox items") + Badge(count: count).help("open questions") } if store.unreadSummary(task) { Text("new summary") @@ -197,7 +197,7 @@ private struct TaskRow: View { } Text(ts.label) .font(Fonts.font(11)) - .foregroundStyle(ts.kind == .answer ? Palette.warn : ts.kind == .error ? Palette.danger : Palette.muted) + .foregroundStyle(ts.kind == .answer ? Palette.warn : Palette.muted) .help(ts.title) } } diff --git a/mac/Sources/Byre/Inbox.swift b/mac/Sources/Byre/Questions.swift similarity index 83% rename from mac/Sources/Byre/Inbox.swift rename to mac/Sources/Byre/Questions.swift index a815d49..0af2e2d 100644 --- a/mac/Sources/Byre/Inbox.swift +++ b/mac/Sources/Byre/Questions.swift @@ -9,25 +9,27 @@ private let delegateText = "Use your judgment for this decision and continue wit private let otherStop = "\u{0}other" private let httpURL = Re(#"^https?://"#, caseless: true) -struct InboxView: View { +struct QuestionsView: View { @Environment(Store.self) private var store var body: some View { let taskId = store.selectedTaskId - let top = store.inbox.filter { $0.taskId == taskId }.sorted { $0.createdAt < $1.createdAt }.first + let picked = store.question.flatMap { $0.taskId == taskId ? store.questionItem($0.itemId) : nil } + let shown = picked ?? store.inbox.filter { $0.taskId == taskId }.sorted { $0.createdAt < $1.createdAt }.first VStack(spacing: 0) { ChangesView(compact: true) if taskId == nil { EmptyText(text: "No task selected.") - } else if let top { + } else if let shown { + let key = "\(shown.id)/\(shown.state.rawValue)/\(shown.responses.count)" ScrollView { - InboxCard(item: top) - .id(top.id) + QuestionCard(item: shown) + .id(key) .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 16) } - .id(top.id) + .id(shown.id) } else { EmptyText(text: "Nothing waiting on you in this task.") } @@ -121,8 +123,16 @@ private final class CardModel { bool(payload, "multiple") } + var answered: Bool { + item.state != .open + } + + var chosen: [String] { + item.responses.flatMap { $0.options ?? [] } + } + var checked: [String] { - store.drafts?.checkedAnswer(item.id) ?? [] + answered ? chosen : store.drafts?.checkedAnswer(item.id) ?? [] } var hasImages: Bool { @@ -146,7 +156,7 @@ private final class CardModel { } func pick(_ text: String) { - if busy { + if busy || answered { return } if multiple { @@ -169,8 +179,11 @@ private final class CardModel { _ = try await store.api.client.respondInbox(body: .json(.init(responses: [r]))).ok store.drafts?.clearAnswer(item.id) store.paneFocus.setDraft(key, false) + if store.question?.itemId == item.id { + store.question = nil + } let others = store.inbox.contains { $0.taskId == item.taskId && $0.id != item.id } - if !others && store.focus.tab == .inbox && store.selectedTaskId == item.taskId { + if !others && store.focus.tab == .questions && store.selectedTaskId == item.taskId { store.focusTab(.transcript) } } catch { @@ -191,16 +204,9 @@ private final class CardModel { submit(InboxResponseCreate(itemId: item.id, _type: .other, text: store.drafts?.textAnswer(item.id) ?? ""), withImages: true) } } - - func submitOther() { - if typed.isEmpty && !hasImages { - return - } - submit(InboxResponseCreate(itemId: item.id, _type: .other, text: store.drafts?.textAnswer(item.id) ?? ""), withImages: true) - } } -private struct InboxCard: View { +private struct QuestionCard: View { let item: InboxItem @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @@ -212,7 +218,7 @@ private struct InboxCard: View { if let model { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .firstTextBaseline, spacing: 10) { - Text(item.kind.rawValue.replacingOccurrences(of: "_", with: " ")) + Text("question\(model.answered ? " · \(item.state.rawValue)" : "")") Spacer(minLength: 0) Text(ago(item.createdAt)).help(isoText(item.createdAt)) } @@ -220,8 +226,10 @@ private struct InboxCard: View { .foregroundStyle(Palette.muted) CardBody(model: model, focus: $focus) Thread(item: item) - CardActions(model: model, focus: $focus) - .padding(.top, 6) + if !model.answered { + CardActions(model: model, focus: $focus) + .padding(.top, 6) + } } .onKeyPress(keys: [.downArrow, .upArrow]) { press in move(model, press.key == .downArrow ? 1 : -1) @@ -264,32 +272,15 @@ private struct CardBody: View { let p = model.payload let item = model.item VStack(alignment: .leading, spacing: 0) { - switch item.kind { - case .question: - MarkdownView(blocks: model.context, ctx: contextCtx) - .onGeometryChange(for: Bool.self) { $0.size.width > 480 } action: { wide = $0 } - .padding(.vertical, 10) - MarkdownView(blocks: parseMarkdown(jsString(p["question"] ?? "")), ctx: MDContext(taskId: item.taskId)) - .padding(.top, 10) - .padding(.bottom, 4) - WhyLine(text: jsString(p["why_blocked"] ?? ""), taskId: item.taskId) - .padding(.bottom, 8) - Attachments(list: attachments(p["attachments"] ?? nil), taskId: item.taskId) - case .sessionError: - Text(jsString(p["error"] ?? "")) - .foregroundStyle(Palette.danger) - .textSelection(.enabled) - .padding(.vertical, 10) - case .rateLimited: - let limit = dict(p, "rate_limit") ?? [:] - let resets = (limit["resetsAt"] as? Double ?? (limit["resetsAt"] as? Int).map(Double.init)).map { Date(timeIntervalSince1970: $0).formatted(date: .numeric, time: .standard) } ?? "unknown" - VStack(alignment: .leading, spacing: 10) { - Text(jsString(p["error"] ?? "")).foregroundStyle(Palette.danger) - Text("\(jsString(p["vendor"] ?? "")) \(limit["rateLimitType"].map { jsString($0) } ?? "") limit resets \(resets)") - } - .textSelection(.enabled) + MarkdownView(blocks: model.context, ctx: contextCtx) + .onGeometryChange(for: Bool.self) { $0.size.width > 480 } action: { wide = $0 } .padding(.vertical, 10) - } + MarkdownView(blocks: parseMarkdown(jsString(p["question"] ?? "")), ctx: MDContext(taskId: item.taskId)) + .padding(.top, 10) + .padding(.bottom, 4) + WhyLine(text: jsString(p["why_blocked"] ?? ""), taskId: item.taskId) + .padding(.bottom, 8) + Attachments(list: attachments(p["attachments"] ?? nil), taskId: item.taskId) } } @@ -350,7 +341,8 @@ private struct ChoiceBox: View { private func box(_ text: String) -> some View { let rec = model.recommended.contains(text) let focused = focus.wrappedValue == text - let lit = (hover && !model.multiple) || focused + let picked = model.answered && model.chosen.contains(text) + let lit = (hover && !model.multiple && !model.answered) || focused || picked let hasDetail = !item.inline.isEmpty || !item.blocks.isEmpty let on = model.checked.contains(text) let keys: Set<KeyEquivalent> = model.multiple ? [.space] : [.return, .space] @@ -361,10 +353,16 @@ private struct ChoiceBox: View { .toggleStyle(.checkbox) .labelsHidden() .tint(Palette.accent) - .disabled(model.busy) + .disabled(model.busy || model.answered) } InlineView(inlines: parseInline(text), ctx: MDContext(taskId: model.item.taskId, weight: 600)) .frame(maxWidth: .infinity, alignment: .leading) + if picked { + Text("chosen") + .font(Fonts.font(11)) + .foregroundStyle(Palette.accent) + .allowsHitTesting(false) + } if rec { Text("recommended") .font(Fonts.font(11)) @@ -408,7 +406,7 @@ private struct ChoiceBox: View { } .opacity(model.busy && !model.multiple ? 0.6 : 1) .contentShape(Rectangle()) - .focusable(!model.busy) + .focusable(!model.busy && !model.answered) .focusEffectDisabled() .focused(focus, equals: text) .onKeyPress(keys: keys) { _ in @@ -431,46 +429,30 @@ private struct CardActions: View { var focus: FocusState<String?>.Binding var body: some View { - if model.item.kind == .question { - let disabled = model.typed.isEmpty && !model.hasImages && (!model.multiple || model.checked.isEmpty) - VStack(alignment: .leading, spacing: 6) { - TextEntry(model: model, label: "Other…", placeholder: "Your answer, a question back, or new instructions", shared: true, focus: focus) { - model.submitQuestion() - } - HStack(spacing: 8) { - Button("Let the agent decide") { - model.submit(InboxResponseCreate(itemId: model.item.id, _type: .other, text: delegateText)) - } - .buttonStyle(WebButtonStyle()) - Spacer() - Button("Submit") { - model.submitQuestion() - } - .buttonStyle(WebButtonStyle(primary: true)) - .disabled(disabled) - } - .disabled(model.busy) + let disabled = model.typed.isEmpty && !model.hasImages && (!model.multiple || model.checked.isEmpty) + VStack(alignment: .leading, spacing: 6) { + TextEntry(model: model, focus: focus) { + model.submitQuestion() } - } else { - HStack(alignment: .top, spacing: 8) { - TextEntry(model: model, label: "Reply…", placeholder: "Reply to the agent", shared: false, focus: focus) { - model.submitOther() - } - Button("Dismiss") { - model.submit(InboxResponseCreate(itemId: model.item.id, _type: .dismiss)) + HStack(spacing: 8) { + Button("Let the agent decide") { + model.submit(InboxResponseCreate(itemId: model.item.id, _type: .other, text: delegateText)) } .buttonStyle(WebButtonStyle()) - .disabled(model.busy) + Spacer() + Button("Submit") { + model.submitQuestion() + } + .buttonStyle(WebButtonStyle(primary: true)) + .disabled(disabled) } + .disabled(model.busy) } } } private struct TextEntry: View { let model: CardModel - let label: String - let placeholder: String - let shared: Bool var focus: FocusState<String?>.Binding let go: () -> Void @Environment(Store.self) private var store @@ -512,7 +494,7 @@ private struct TextEntry: View { .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) .overlay(alignment: .topLeading) { if text.wrappedValue.isEmpty { - Text(placeholder) + Text("Your answer, a question back, or new instructions") .foregroundStyle(Palette.muted) .padding(.horizontal, 10) .padding(.vertical, 6) @@ -529,14 +511,6 @@ private struct TextEntry: View { return true } } - if !shared { - HStack { - Spacer() - Button("Submit", action: go) - .buttonStyle(WebButtonStyle(primary: true)) - .disabled(model.busy || (model.typed.isEmpty && !model.hasImages)) - } - } } .onChange(of: editing) { if !editing { @@ -547,7 +521,7 @@ private struct TextEntry: View { } } } else { - Text(label) + Text("Other…") .foregroundStyle(Palette.muted) .padding(.horizontal, 10) .padding(.vertical, 4) diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index f03f517..42ceabf 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -2,7 +2,7 @@ import Foundation import Observation import SwiftData -let taskTabs: [TaskTab] = [.transcript, .board, .changes, .files, .terminal, .inbox, .reviews, .workflow, .project] +let taskTabs: [TaskTab] = [.transcript, .board, .changes, .files, .terminal, .questions, .reviews, .workflow, .project] private let historyPage = 1000 private let leadPage = 50 @@ -10,7 +10,7 @@ private let routeHash = Re(#"^#/(task|project)/(\w+)(?:/(\w+))?$"#) struct TaskState { enum Kind { - case answer, merging, held, error, working, idle + case answer, merging, held, working, idle } let kind: Kind @@ -37,6 +37,7 @@ final class Store { private(set) var selectedTaskId: String? var pinned: Int? var reviewed: Int? + var question: (taskId: String, itemId: String)? var connected = false private(set) var focus = Focus(tabs: [.transcript], tab: .transcript) var leadSessions: [AgentSession] = [] @@ -188,9 +189,8 @@ final class Store { } func taskState(_ task: ByreTask) -> TaskState { - let open = inbox.filter { $0.taskId == task.id } - 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 inbox.contains(where: { $0.taskId == task.id }) { + return TaskState(kind: .answer, label: "Needs your answer", title: "The agent asked a question") } if task.merging { return TaskState(kind: .merging, label: "Merging", title: "The merge turn is running") @@ -204,9 +204,6 @@ final class Store { if task.mergeAfterReview { return TaskState(kind: .held, label: "Merge after review", title: "Merges when the running review finishes with no findings") } - if !open.isEmpty { - return TaskState(kind: .error, label: "Error", title: "The agent's session failed or hit a rate limit; see the inbox") - } if let working = workingTitles[task.state] { return TaskState(kind: .working, label: "Working", title: working) } @@ -234,6 +231,21 @@ final class Store { focusTab(.reviews) } + func showQuestion(_ taskId: String, _ itemId: String) { + focusTask(taskId, .questions) + question = (taskId, itemId) + } + + func questionItem(_ itemId: String) -> InboxItem? { + if let open = inbox.first(where: { $0.id == itemId }) { + return open + } + guard let ev = transcript.last(where: { ($0.kind == "inbox.created" || $0.kind == "inbox.updated") && str($0.payload.value, "id") == itemId }) else { + return nil + } + return try! decodePayload(ev.payload, as: InboxItem.self) + } + var defaultModel: String { me!.defaultModel } @@ -346,6 +358,7 @@ final class Store { } func focusTask(_ taskId: String, _ tab: TaskTab = .transcript) { + question = nil spawn("set focus") { try await self.setFocus(taskId: taskId, tab: tab) } @@ -358,6 +371,7 @@ final class Store { } func focusTab(_ tab: TaskTab) { + question = nil spawn("set focus") { try await self.setFocus(tab: tab) } @@ -370,6 +384,7 @@ final class Store { let name = m?[3] ?? nil let tab = taskTabs.first { $0.rawValue == name } if kind == "task", let id { + question = nil if id != focus.taskId || (tab != nil && tab != focus.tab) { spawn("set focus") { try await self.setFocus(taskId: id, tab: tab) @@ -617,6 +632,8 @@ final class Store { spawn("load inbox") { try await self.loadInbox() } + case "session.error", "session.rate_limited": + notifyError(self, ev) case "turn.ended": notifyTurn(self, ev) if ev.payload.value["summary"] as? Bool == true, let taskId = ev.taskId { diff --git a/mac/Sources/Byre/Switcher.swift b/mac/Sources/Byre/Switcher.swift index 1104870..5a972f7 100644 --- a/mac/Sources/Byre/Switcher.swift +++ b/mac/Sources/Byre/Switcher.swift @@ -55,7 +55,7 @@ private let shortcuts: [(String, [(String, String)])] = [ ("name: title", "Create the task in the project whose name starts with name"), ("Esc", "Back from the workflow list to the task list"), ]), - ("Composer and inbox answers", [ + ("Composer and answers", [ ("Enter", "Send"), ("Shift+Enter", "New line"), ("Enter / Space", "Pick the focused choice"), @@ -261,7 +261,7 @@ final class SwitcherModel { private func go(_ taskId: String) { close() if store.inbox.contains(where: { $0.taskId == taskId }) { - store.focusTask(taskId, .inbox) + store.focusTask(taskId, .questions) } else if let task = store.findTask(taskId), store.unreadSummary(task) { store.composerFocus = taskId store.openSummary(task) @@ -408,14 +408,14 @@ private struct SwitcherRowView: View { case .task(let project, let task, let count): let ts = store.taskState(task) let color = taskPalette[task.color] - StateDot(filled: ts.kind == .working || ts.kind == .merging ? Palette.ok : ts.kind == .answer ? Palette.warn : ts.kind == .error ? Palette.danger : nil) + StateDot(filled: ts.kind == .working || ts.kind == .merging ? Palette.ok : ts.kind == .answer ? Palette.warn : nil) .help(ts.title) Text(taskTitle(project, task, color)) .lineLimit(1) .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) if count > 0 { - Badge(count: count).help("inbox items") + Badge(count: count).help("open questions") } if store.unreadSummary(task) { Text("new summary") @@ -426,7 +426,7 @@ private struct SwitcherRowView: View { } Text(ts.label) .font(Fonts.font(11)) - .foregroundStyle(ts.kind == .answer ? Palette.warn : ts.kind == .error ? Palette.danger : Palette.muted) + .foregroundStyle(ts.kind == .answer ? Palette.warn : Palette.muted) .help(ts.title) case .project(let p): StateDot(filled: nil) diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index bb10f49..54f29b9 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -23,7 +23,7 @@ enum NotePart { } enum NoteStyle { - case plain, error, ask + case plain, error } struct Note { @@ -52,6 +52,7 @@ enum RowKind { case note(Note) case handoff case snapshot(Snapshot) + case question } @MainActor @@ -81,6 +82,7 @@ final class Row: Identifiable { var calls: [Call] = [] var summary: Note? var review: Review? + var question: InboxItem? var text = "" var md: [MDBlock] = [] var summarySeq: Int64? @@ -171,6 +173,7 @@ private final class Ctx { var subagents: [String: Subagent] = [:] var reviewers: Set<String> = [] var reviewButtons: [Int: Row] = [:] + var questions: [String: Row] = [:] var run: Run? var held: RowList? var open = false @@ -463,6 +466,11 @@ final class TranscriptBuilder { let r = try! decodePayload(ev.payload, as: Review.self) ctx.reviewButtons[r.snapshotN]?.review = r } + if ev.kind == "inbox.updated" { + let item = try! decodePayload(ev.payload, as: InboxItem.self) + ctx.questions[item.id]?.question = item + continue + } let sub = ctx.subagents[parent] if ev.kind == "agent.tool_call" && isSubagentCall(p) { let row = renderSubagent(p) @@ -660,15 +668,19 @@ final class TranscriptBuilder { return note([.text("session \(jsString(p["state"])): \(error)")], .error, time: false) case "inbox.created": - let q = dict(p, "payload") ?? [:] - switch str(p, "kind") { - case "question": - return note([.text("asked: \(jsString(q["question"]))")], .ask, time: false) - case "session_error", "rate_limited": - return note([.text(jsString(q["error"]))], .error, time: false) - default: - return nil - } + let row = Row(.question, taskId: taskId) + let item = try! decodePayload(ev.payload, as: InboxItem.self) + row.question = item + ctx.questions[item.id] = row + return row + + case "session.error": + return note([.text(jsString(p["error"]))], .error) + + case "session.rate_limited": + let limit = dict(p, "rate_limit") ?? [:] + let resets = (limit["resetsAt"] as? Double ?? (limit["resetsAt"] as? Int).map(Double.init)).map { Date(timeIntervalSince1970: $0).formatted(date: .numeric, time: .standard) } ?? "unknown" + return note([.text("\(jsString(p["error"])) (\(jsString(p["vendor"])) \(limit["rateLimitType"].map { jsString($0) } ?? "") limit resets \(resets))")], .error) case "task.created": if let from = str(p, "forked_from") {