diff --git a/api/openapi.yaml b/api/openapi.yaml index d15b25c..75eea4e 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1749,6 +1749,9 @@ components: type: array items: { type: string } description: Blob ids from uploadBlob, sent to the agent as images alongside the prompt. + send_id: + type: string + description: The caller's id for this send, carried on the user.message event the prompt records. MessageCreate: type: object @@ -1762,6 +1765,9 @@ components: model: type: string description: Model for this turn and later ones. When it differs from the session's model, the session switches before the message is sent. + send_id: + type: string + description: The caller's id for this send, carried on the user.queued and user.message events the message records. BoardPostCreate: type: object diff --git a/docs/operations.md b/docs/operations.md index 0b7c060..e89f722 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -223,7 +223,7 @@ Every API request arrives over byre's tailnet node, and the `identify` middlewar ## Agent sessions -`POST /api/tasks/{id}/sessions` starts a Claude session in the task's worktree and sends the prompt as its first turn; `POST /api/projects/{id}/tasks` with a `prompt` or `images` (and no `workflow`) returns once the worktree, task row and session exist and the first message is recorded, and starts the agent's process in the background (`Manager.deliver`). Every turn's message is recorded as its `user.message` before the agent's process is started (`Manager.recordMessage`, then `ensureLive` in `Manager.launch`), so it shows in the transcript while the CLI starts; a start that fails then leaves the message followed by the session's idle state with its `error`. `POST /api/sessions/{id}/messages` starts later turns and `POST /api/sessions/{id}/interrupt` ends the current one. A message sent while a task session's turn is running, from the app, the lead's `send_message`, a parent's `message_subtask` or byre's review findings, is queued (`Manager.Message` in `internal/agents/queue.go`) in `task_inputs`, keyed by task and team member (`member`, empty on a task with one agent) so it survives a handoff to a new session, and recorded, in the same transaction, as a `user.queued` event (its `text`, `images` and `task_inputs` `id`, the sender as actor), and the request returns the running session with 202. `DELETE /api/tasks/{id}/queue/{input id}` takes a queued message back (`Manager.Withdraw`): under the task's queue lock (`queueLockKey`), which both hand-over paths (`deliverQueued`, `takeQueuedMidTurn`) hold from reading the queue until the message is sent or taken, whichever session of the task is delivering, it deletes the row and records a `user.unqueued` event with its `id`, or answers 409 once the message has been handed over (the row is gone). A model it carries is applied to the session at once and takes effect from the next turn. The agent gets queued messages after its next tool call, the same way as inbox answers (see "Inbox"): each is recorded as its own `user.message` with the sender as actor and `mid_turn: true`, and the agent reads them, headed " sent you this message while you were working", in the hook's `additionalContext` (Codex: `turn/steer`; Antigravity: a user message injected by its `PreInvocation` hook before the model's next call, `agyBeforeModel` in `internal/agents/antigravity.go`). Messages are handed over oldest first, stopping at the first one with images, since mid-turn delivery carries only text; none are handed over during a handoff turn. Whatever is still queued when the turn ends goes out one message per turn, ahead of held inbox answers, from `Deliver`; startup does the same for idle sessions. An interrupt holds all of that: after a turn ends interrupted, byre starts no turn on its own until someone sends the session a message (`Manager.interrupted`, true while the live session's `interrupted` flag is set, which the next `user.message` clears). The turn end delivers nothing, the ready-for-merge prompt waits, a message from byre, an agent (`message_subtask`) or the board is queued as if the session were running instead of starting a turn, and a lead holds its signals (a human's queued lead message still goes out). Whatever was held goes out mid-turn during, or at the end of, the next turn the human starts; an inbox answer starts a turn as usual. The flag is in memory only, so a byre restart releases what was held. The lead can't start that turn either: every turn end whose turn was interrupted or ended in an error, and every process exit mid-turn, marks its `session.state` idle event `stopped: true`, and while a session's latest `session.state` event carries it (`Store.SessionStopped`), the lead's `send_message`, `answer_question`, `compact_task`, `merge_task` (for `target` `parent`, the parent's agent) and `discard_task` refuse with an error (`checkNotStopped` in `internal/agents/leadtools.go`), telling the lead to put it to the human in chat instead. For `send_message` the check is in `Manager.Message`, under the session's delivery lock, once it has read the session as idle; the idle state and its mark are written in one transaction (`Store.EndTurn`), so a turn that ends just as the lead sends can't slip past it. On a team task, `send_message` refuses the board post while any member is stopped. The mark is in the event log, so it survives a restart, and the next turn's `running` state lifts it, as does an answer to one of the session's questions recorded after it (an `inbox.updated` event with state `answered`), even one held behind another open question; the lead's own `answer_question` is refused meanwhile, so such an answer is the human's; the human's own message, answer, merge or discard is never refused. Closing the task deletes its queued messages. `sqlite3 byre.db 'SELECT * FROM task_inputs'` lists them. A task has at most one session that isn't `completed` per team member, so a task with one agent has at most one (see "Teams"). A message may carry `model`; when it differs from the session's, byre writes it to `agent_sessions.model`, records a `session.model` event (shown as a note), and sends the live CLI a `set_model` control request before the message, so the switch applies from that turn and survives a resume (`--model`) or compaction. The composer's model menu sends `model` only when the choice differs from the session's model, or from the `session.context` model when the session has none. The session's commits use the git identity of the user who created it, so that user must have set one with `PUT /api/me`. +`POST /api/tasks/{id}/sessions` starts a Claude session in the task's worktree and sends the prompt as its first turn; `POST /api/projects/{id}/tasks` with a `prompt` or `images` (and no `workflow`) returns once the worktree, task row and session exist and the first message is recorded, and starts the agent's process in the background (`Manager.deliver`). Every turn's message is recorded as its `user.message` before the agent's process is started (`Manager.recordMessage`, then `ensureLive` in `Manager.launch`), so it shows in the transcript while the CLI starts; a start that fails then leaves the message followed by the session's idle state with its `error`. `POST /api/sessions/{id}/messages` starts later turns and `POST /api/sessions/{id}/interrupt` ends the current one. A message sent while a task session's turn is running, from the app, the lead's `send_message`, a parent's `message_subtask` or byre's review findings, is queued (`Manager.Message` in `internal/agents/queue.go`) in `task_inputs`, keyed by task and team member (`member`, empty on a task with one agent) so it survives a handoff to a new session, and recorded, in the same transaction, as a `user.queued` event (its `text`, `images`, `task_inputs` `id` and `send_id`, the sender as actor), and the request returns the running session with 202. `DELETE /api/tasks/{id}/queue/{input id}` takes a queued message back (`Manager.Withdraw`): under the task's queue lock (`queueLockKey`), which both hand-over paths (`deliverQueued`, `takeQueuedMidTurn`) hold from reading the queue until the message is sent or taken, whichever session of the task is delivering, it deletes the row and records a `user.unqueued` event with its `id`, or answers 409 once the message has been handed over (the row is gone). A model it carries is applied to the session at once and takes effect from the next turn. The agent gets queued messages after its next tool call, the same way as inbox answers (see "Inbox"): each is recorded as its own `user.message` with the sender as actor and `mid_turn: true`, and the agent reads them, headed " sent you this message while you were working", in the hook's `additionalContext` (Codex: `turn/steer`; Antigravity: a user message injected by its `PreInvocation` hook before the model's next call, `agyBeforeModel` in `internal/agents/antigravity.go`). Messages are handed over oldest first, stopping at the first one with images, since mid-turn delivery carries only text; none are handed over during a handoff turn. Whatever is still queued when the turn ends goes out one message per turn, ahead of held inbox answers, from `Deliver`; startup does the same for idle sessions. An interrupt holds all of that: after a turn ends interrupted, byre starts no turn on its own until someone sends the session a message (`Manager.interrupted`, true while the live session's `interrupted` flag is set, which the next `user.message` clears). The turn end delivers nothing, the ready-for-merge prompt waits, a message from byre, an agent (`message_subtask`) or the board is queued as if the session were running instead of starting a turn, and a lead holds its signals (a human's queued lead message still goes out). Whatever was held goes out mid-turn during, or at the end of, the next turn the human starts; an inbox answer starts a turn as usual. The flag is in memory only, so a byre restart releases what was held. The lead can't start that turn either: every turn end whose turn was interrupted or ended in an error, and every process exit mid-turn, marks its `session.state` idle event `stopped: true`, and while a session's latest `session.state` event carries it (`Store.SessionStopped`), the lead's `send_message`, `answer_question`, `compact_task`, `merge_task` (for `target` `parent`, the parent's agent) and `discard_task` refuse with an error (`checkNotStopped` in `internal/agents/leadtools.go`), telling the lead to put it to the human in chat instead. For `send_message` the check is in `Manager.Message`, under the session's delivery lock, once it has read the session as idle; the idle state and its mark are written in one transaction (`Store.EndTurn`), so a turn that ends just as the lead sends can't slip past it. On a team task, `send_message` refuses the board post while any member is stopped. The mark is in the event log, so it survives a restart, and the next turn's `running` state lifts it, as does an answer to one of the session's questions recorded after it (an `inbox.updated` event with state `answered`), even one held behind another open question; the lead's own `answer_question` is refused meanwhile, so such an answer is the human's; the human's own message, answer, merge or discard is never refused. Closing the task deletes its queued messages. `sqlite3 byre.db 'SELECT * FROM task_inputs'` lists them. A task has at most one session that isn't `completed` per team member, so a task with one agent has at most one (see "Teams"). A message may carry `model`; when it differs from the session's, byre writes it to `agent_sessions.model`, records a `session.model` event (shown as a note), and sends the live CLI a `set_model` control request before the message, so the switch applies from that turn and survives a resume (`--model`) or compaction. The composer's model menu sends `model` only when the choice differs from the session's model, or from the `session.context` model when the session has none. The session's commits use the git identity of the user who created it, so that user must have set one with `PUT /api/me`. `users.default_model` is the model the app starts new work on: ⌘K's Start and New task (whose composer then offers it first), the Project tab's and TODO links' `start`, suggestion links' `start` and `discuss` (when there is no live lead session), and the composer of a task or lead with no live session. It is returned as `default_model` on `GET /api/me`, set with `PUT /api/me/default-model` from the Settings tab's Default model menu, and records `user.updated`, so every open app reloads it. The server itself never reads it: the lead's `create_task` and `start_todo` default to the lead's own model, and `create_subtask` to the parent session's. @@ -277,7 +277,7 @@ Agent activity becomes events with `agent_session_id` set: `user.message`, `agen Event payloads are stored whole in `events.payload`, and `agent.tool_result` content (full command output, file reads, diffs) dominates the log's size; the app loads every active task's whole history when it starts (see "Events"), so this is what its startup costs. `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events GROUP BY kind ORDER BY 3 DESC"` shows the split, and `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events WHERE task_id = '' GROUP BY kind ORDER BY 3 DESC"` one task's. -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. Review findings forwarded to the agent come this way too, but their `user.message` carries `findings: true` (`isFindingsMessage` in `internal/agents/review.go`) and the transcript never draws it, since it shows them itself: a `review.finished` event with findings draws its counts note with the review's `summary` (one bullet per finding) expanded under it, behind a border-coloured rule, and then each finding as its own row (see "Reviews"). 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) or the task's state is `review_pending` or `reviewing` (see "Task state in the store"), and, only in the first case, 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 collapsed boxes (tool calls, byre's messages and subagent calls) fold into one group whose summary counts calls per tool and file, with `byre` for each of byre's messages, and file paths show relative to the worktree. A note the agent's own tool call caused (actor `agent`: "agent set the task title to …", the merge title, the summary, an exposed port) folds into the same group without adding to its summary. `ToolSearch` calls, Claude Code loading a tool's schema, are never drawn. 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`). MCP tools show without their `mcp____` prefix, in title case (`mcp__byre__create_task` shows as Create Task, `mcp__hands__screen_list` as Screen List; `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) is 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'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. +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. Review findings forwarded to the agent come this way too, but their `user.message` carries `findings: true` (`isFindingsMessage` in `internal/agents/review.go`) and the transcript never draws it, since it shows them itself: a `review.finished` event with findings draws its counts note with the review's `summary` (one bullet per finding) expanded under it, behind a border-coloured rule, and then each finding as its own row (see "Reviews"). 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) or the task's state is `review_pending` or `reviewing` (see "Task state in the store"), and, only in the first case, 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 collapsed boxes (tool calls, byre's messages and subagent calls) fold into one group whose summary counts calls per tool and file, with `byre` for each of byre's messages, and file paths show relative to the worktree. A note the agent's own tool call caused (actor `agent`: "agent set the task title to …", the merge title, the summary, an exposed port) folds into the same group without adding to its summary. `ToolSearch` calls, Claude Code loading a tool's schema, are never drawn. 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`). MCP tools show without their `mcp____` prefix, in title case (`mcp__byre__create_task` shows as Create Task, `mcp__hands__screen_list` as Screen List; `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) is 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'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. These local bubbles live in the `Store` per task (`LocalSends`), not in the open transcript, so they survive switching tasks, and the `Store` confirms them from the live streams whether or not that transcript is showing (`confirmSend`). Messages are matched by id, never by text: the composer gives each send a `send_id` (a UUID) in `sendMessage`, `createSession` or `messageLead`, and the server carries it on the `user.queued` and `user.message` events that send records, through `task_inputs.send_id` and `lead_inputs.send_id` while it waits (`store.AddTaskInput` gives a queued message without one an id of its own, so every queued message has one). A request that fails after its `user.queued` or `user.message` has already confirmed it shows no alert and leaves the composer empty, since the server has the message. A `user.queued` event takes over its local bubble, and the transcript shows a "queued…" bubble below everything else for every `user.queued` until a `user.message` with its `send_id`, 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 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:` 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. diff --git a/internal/agents/compact.go b/internal/agents/compact.go index fbff71f..b325dde 100644 --- a/internal/agents/compact.go +++ b/internal/agents/compact.go @@ -44,7 +44,7 @@ func (m *Manager) Compact(ctx context.Context, sessionID, model string) (api.Age m.mu.Lock() m.handoffModel[sessionID] = model m.mu.Unlock() - return m.launch(ctx, sess, task, "byre", prompt, nil) + return m.launch(ctx, sess, task, "byre", prompt, "", nil) } func (m *Manager) continuationModel(sess api.AgentSession) string { @@ -90,7 +90,7 @@ func (m *Manager) handoff(ctx context.Context, sessionID, handoff string) error if err != nil { return err } - _, err = m.createLead(ctx, creator, leadHandoffPreamble+handoff, model, "byre", nil) + _, err = m.createLead(ctx, creator, leadHandoffPreamble+handoff, model, "byre", "", nil) return err } @@ -112,7 +112,7 @@ func (m *Manager) handoff(ctx context.Context, sessionID, handoff string) error if err != nil { return err } - _, err = m.create(ctx, task, creator, sess.Member, handoffPreamble+handoff, model, "byre", nil) + _, err = m.create(ctx, task, creator, sess.Member, handoffPreamble+handoff, model, "byre", "", nil) if errors.Is(err, ErrConflict) { m.startDiscard(task.Id) return nil diff --git a/internal/agents/fork.go b/internal/agents/fork.go index b51b789..a2e38d4 100644 --- a/internal/agents/fork.go +++ b/internal/agents/fork.go @@ -73,7 +73,7 @@ func (m *Manager) forkTool(l *live) mcp.Tool { if err != nil { return "", err } - _, _, err = m.Message(ctx, sessionID, agentActor, args.Prompt, "") + _, _, err = m.Message(ctx, sessionID, agentActor, args.Prompt, "", "") if err != nil { return "", fmt.Errorf("task %s forked, but its agent did not start: %w", t.Id, err) } diff --git a/internal/agents/inbox.go b/internal/agents/inbox.go index 74730da..0863a50 100644 --- a/internal/agents/inbox.go +++ b/internal/agents/inbox.go @@ -373,7 +373,7 @@ func (m *Manager) Deliver(ctx context.Context, sessionID string) error { for _, p := range pending { images = append(images, p.Response.Images...) } - after, err := m.turn(ctx, sess, task, pending[0].Response.User, deliveryMessage(pending), images) + after, err := m.turn(ctx, sess, task, pending[0].Response.User, deliveryMessage(pending), "", images) if errors.Is(err, ErrConflict) { slog.Info("deliver held: session changed state", "session", sessionID, "err", err, "responses", ids) return nil @@ -412,7 +412,7 @@ func (m *Manager) deliverMidTurn(ctx context.Context, l *live) midTurn { } msgs := []queued{} for _, in := range inputs { - msgs = append(msgs, queued{actor: in.Actor, text: in.Text}) + msgs = append(msgs, queued{actor: in.Actor, text: in.Text, sendID: in.SendID}) } return midTurn{text: m.recordQueued(ctx, l, msgs), leadInputs: inputs} } @@ -435,7 +435,7 @@ func (m *Manager) deliverMidTurn(ctx context.Context, l *live) midTurn { if len(mt.taskInputs) > 0 { msgs := []queued{} for _, in := range mt.taskInputs { - msgs = append(msgs, queued{actor: in.Actor, text: in.Text}) + msgs = append(msgs, queued{actor: in.Actor, text: in.Text, sendID: in.SendID}) } parts = append(parts, m.recordQueued(ctx, l, msgs)) } diff --git a/internal/agents/lead.go b/internal/agents/lead.go index 5360e8c..db49dc7 100644 --- a/internal/agents/lead.go +++ b/internal/agents/lead.go @@ -88,7 +88,7 @@ func (m *Manager) activeLead(ctx context.Context, owner string) (api.AgentSessio return api.AgentSession{}, false, nil } -func (m *Manager) createLead(ctx context.Context, owner api.User, prompt, model, actor string, images []string) (api.AgentSession, error) { +func (m *Manager) createLead(ctx context.Context, owner api.User, prompt, model, actor, sendID string, images []string) (api.AgentSession, error) { _, found, err := m.activeLead(ctx, owner.Login) if err != nil { return api.AgentSession{}, err @@ -114,10 +114,10 @@ func (m *Manager) createLead(ctx context.Context, owner api.User, prompt, model, if err != nil { return api.AgentSession{}, err } - return m.turn(ctx, sess, api.Task{}, actor, prompt, images) + return m.turn(ctx, sess, api.Task{}, actor, prompt, sendID, images) } -func (m *Manager) MessageLead(ctx context.Context, user api.User, text, model string, images []string) (api.AgentSession, error) { +func (m *Manager) MessageLead(ctx context.Context, user api.User, text, model, sendID string, images []string) (api.AgentSession, error) { m.setStartedTodo(user.Login, false) m.leadMu.Lock() @@ -128,11 +128,11 @@ func (m *Manager) MessageLead(ctx context.Context, user api.User, text, model st return api.AgentSession{}, err } if !found { - return m.createLead(ctx, user, text, model, user.Login, images) + return m.createLead(ctx, user, text, model, user.Login, sendID, images) } if sess.State == api.AgentSessionStateIdle { - s, err := m.Send(ctx, sess.Id, user.Login, text, model, images...) + s, err := m.Send(ctx, sess.Id, user.Login, text, model, sendID, images...) if !errors.Is(err, ErrConflict) { return s, err } @@ -149,7 +149,7 @@ func (m *Manager) MessageLead(ctx context.Context, user api.User, text, model st } sess.Model = model } - err = m.store.AddLeadInput(ctx, store.LeadInput{Owner: user.Login, Kind: store.LeadMessage, Actor: user.Login, Text: text, Images: images}) + err = m.store.AddLeadInput(ctx, store.LeadInput{Owner: user.Login, Kind: store.LeadMessage, Actor: user.Login, Text: text, Images: images, SendID: sendID}) if err != nil { return api.AgentSession{}, err } @@ -171,6 +171,10 @@ func (m *Manager) DeliverLead(ctx context.Context, owner string) error { } batch, actor, text, images := nextLeadTurn(inputs) + sendID := "" + if batch[0].Kind == store.LeadMessage { + sendID = batch[0].SendID + } if batch[0].Kind == store.LeadSignal && m.interrupted(sess.Id) { return nil } @@ -182,7 +186,7 @@ func (m *Manager) DeliverLead(ctx context.Context, owner string) error { } text += "\n\n" + state } - _, err = m.Send(ctx, sess.Id, actor, text, "", images...) + _, err = m.Send(ctx, sess.Id, actor, text, "", sendID, images...) if errors.Is(err, ErrConflict) { return nil } diff --git a/internal/agents/leadtools.go b/internal/agents/leadtools.go index 78f32a6..fa3044c 100644 --- a/internal/agents/leadtools.go +++ b/internal/agents/leadtools.go @@ -196,13 +196,13 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { return fmt.Sprintf("Posted to %s's board; every agent of its team gets it.", task.Id), nil } if !found { - _, err = m.create(ctx, task, user, "", args.Text, args.Model, leadActor(user.Login), nil) + _, err = m.create(ctx, task, user, "", args.Text, args.Model, leadActor(user.Login), "", nil) if err != nil { return "", err } return fmt.Sprintf("Started an agent on %s with your message.", task.Id), nil } - _, queued, err := m.Message(ctx, sess.Id, leadActor(user.Login), args.Text, args.Model) + _, queued, err := m.Message(ctx, sess.Id, leadActor(user.Login), args.Text, args.Model, "") if err != nil { return "", err } @@ -301,7 +301,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { } return fmt.Sprintf("Task %s %q created from %s and its team of %d agents started.", task.Id, task.Title, task.BaseRef, len(args.Agents)), nil } - _, err = m.create(ctx, task, user, "", args.Prompt, m.modelOr(args.Model, l), leadActor(user.Login), nil) + _, err = m.create(ctx, task, user, "", args.Prompt, m.modelOr(args.Model, l), leadActor(user.Login), "", nil) if err != nil { return "", fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) } diff --git a/internal/agents/manager.go b/internal/agents/manager.go index 2d89d77..0172ea7 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -185,11 +185,11 @@ func (m *Manager) taskFor(ctx context.Context, sess api.AgentSession) (api.Task, return m.store.GetTask(ctx, sess.TaskId) } -func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model string, images ...string) (api.AgentSession, error) { - return m.create(ctx, task, creator, "", prompt, model, creator.Login, images) +func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model, sendID string, images ...string) (api.AgentSession, error) { + return m.create(ctx, task, creator, "", prompt, model, creator.Login, sendID, images) } -func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, member, prompt, model, actor string, images []string) (api.AgentSession, error) { +func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, member, prompt, model, actor, sendID string, images []string) (api.AgentSession, error) { if creator.GitName == "" || creator.GitEmail == "" { return api.AgentSession{}, ErrNoGitIdentity } @@ -202,7 +202,7 @@ func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, m if err != nil { return api.AgentSession{}, err } - return m.turn(ctx, sess, task, actor, prompt, images) + return m.turn(ctx, sess, task, actor, prompt, sendID, images) } func (m *Manager) newSession(ctx context.Context, task api.Task, creator api.User, member, model string) (api.AgentSession, error) { @@ -235,7 +235,7 @@ func (m *Manager) newSession(ctx context.Context, task api.Task, creator api.Use return sess, err } -func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model string, images ...string) (api.AgentSession, error) { +func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model, sendID string, images ...string) (api.AgentSession, error) { sess, err := m.store.GetSession(ctx, sessionID) if err != nil { return api.AgentSession{}, err @@ -262,7 +262,7 @@ func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model string return api.AgentSession{}, err } - return m.turn(ctx, sess, task, actor, text, images) + return m.turn(ctx, sess, task, actor, text, sendID, images) } func (m *Manager) Interrupt(ctx context.Context, sessionID string) (api.AgentSession, error) { @@ -564,12 +564,12 @@ func (m *Manager) Usage(ctx context.Context) (Usage, error) { return u, nil } -func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string) (api.AgentSession, error) { +func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text, sendID string, images []string) (api.AgentSession, error) { err := m.startRunning(ctx, sess, task.Id, actor) if err != nil { return api.AgentSession{}, err } - return m.launch(ctx, sess, task, actor, text, images) + return m.launch(ctx, sess, task, actor, text, sendID, images) } func (m *Manager) startRunning(ctx context.Context, sess api.AgentSession, taskID, actor string) error { @@ -591,8 +591,8 @@ func (m *Manager) startRunning(ctx context.Context, sess api.AgentSession, taskI return nil } -func (m *Manager) launch(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string) (api.AgentSession, error) { - imgs, compacting, err := m.recordMessage(ctx, sess, task, actor, text, images) +func (m *Manager) launch(ctx context.Context, sess api.AgentSession, task api.Task, actor, text, sendID string, images []string) (api.AgentSession, error) { + imgs, compacting, err := m.recordMessage(ctx, sess, task, actor, text, sendID, images) if err != nil { m.failStart(ctx, sess.Id, actor, err) return api.AgentSession{}, err @@ -691,7 +691,7 @@ func transition(ctx context.Context, st *store.Store, id string, from, to api.Ag } func (m *Manager) message(ctx context.Context, sess api.AgentSession, task api.Task, l *live, actor, text string, images []string) error { - imgs, compacting, err := m.recordMessage(ctx, sess, task, actor, text, images) + imgs, compacting, err := m.recordMessage(ctx, sess, task, actor, text, "", images) if err != nil { return err } @@ -707,7 +707,7 @@ func (m *Manager) send(ctx context.Context, l *live, text string, imgs []image) return m.store.MarkSessionDelivered(ctx, l.id) } -func (m *Manager) recordMessage(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string) ([]image, bool, error) { +func (m *Manager) recordMessage(ctx context.Context, sess api.AgentSession, task api.Task, actor, text, sendID string, images []string) ([]image, bool, error) { implementer := sess.Role == api.Implementer if implementer && isMergeInPrompt(actor, text) { m.ownMerge(sess.Id, task.Id, task.WorktreePath) @@ -738,6 +738,9 @@ func (m *Manager) recordMessage(ctx context.Context, sess api.AgentSession, task if isFindingsMessage(actor, text) { payload["findings"] = true } + if sendID != "" { + payload["send_id"] = sendID + } err = m.store.RecordTurnMessage(ctx, sess.Id, newEvent(sess.Id, sess.Owner, task.ProjectId, task.Id, actor, "user.message", payload)) if err != nil { return nil, false, err diff --git a/internal/agents/mergein.go b/internal/agents/mergein.go index 4a5c4d0..59bd8a2 100644 --- a/internal/agents/mergein.go +++ b/internal/agents/mergein.go @@ -51,7 +51,7 @@ func (m *Manager) headStartMerge(ctx context.Context, task api.Task, user api.Us } prompt := mergeInPrompt(target) + fmt.Sprintf(" This task's Merge is held while another task merges into %s; this turn gets a head start, and byre merges %s in again when the held merge starts. If you stop for substantive changes, call stop_merge first, so byre drops the held merge and nothing is pushed before the review.", target, target) go func() { - _, err := m.launch(context.Background(), sess, task, "byre", prompt, nil) + _, err := m.launch(context.Background(), sess, task, "byre", prompt, "", nil) if err != nil { slog.Error("start held merge head start", "task", task.Id, "err", err) } @@ -64,9 +64,9 @@ func (m *Manager) sendMergeIn(ctx context.Context, task api.Task, user api.User, return api.AgentSession{}, err } if found { - return m.Send(ctx, sess.Id, "byre", prompt, "") + return m.Send(ctx, sess.Id, "byre", prompt, "", "") } - return m.create(ctx, task, user, sess.Member, prompt, "", "byre", nil) + return m.create(ctx, task, user, sess.Member, prompt, "", "byre", "", nil) } func (m *Manager) Merge(ctx context.Context, task api.Task, project api.Project, user api.User, title string) (api.Task, error) { @@ -189,7 +189,7 @@ func (m *Manager) launchMerge(sess api.AgentSession, task api.Task, project api. } func (m *Manager) deliverMerge(ctx context.Context, sess api.AgentSession, task api.Task, project api.Project, prompt string) error { - _, compacting, err := m.recordMessage(ctx, sess, task, "byre", prompt, nil) + _, compacting, err := m.recordMessage(ctx, sess, task, "byre", prompt, "", nil) if err != nil { return err } diff --git a/internal/agents/mergemain.go b/internal/agents/mergemain.go index ff7cfc4..6a901a0 100644 --- a/internal/agents/mergemain.go +++ b/internal/agents/mergemain.go @@ -223,7 +223,7 @@ func (m *Manager) MergeToMain(ctx context.Context, task api.Task, project api.Pr } if err == nil { m.clearMergeAfterReview(ctx, task.Id) - _, err = m.Send(ctx, sess.Id, "byre", landPrompt(project.DefaultBranch, title), "") + _, err = m.Send(ctx, sess.Id, "byre", landPrompt(project.DefaultBranch, title), "", "") } if err != nil { m.endLanding(task.Id, sess.Id) diff --git a/internal/agents/queue.go b/internal/agents/queue.go index 0fef360..788c3c9 100644 --- a/internal/agents/queue.go +++ b/internal/agents/queue.go @@ -39,7 +39,7 @@ func (m *Manager) lockDelivery(sessionID string) func() { } } -func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model string, images ...string) (api.AgentSession, bool, error) { +func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model, sendID string, images ...string) (api.AgentSession, bool, error) { unlock := m.lockDelivery(sessionID) defer unlock() @@ -55,7 +55,7 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model str } held := sess.State == api.AgentSessionStateIdle && sess.Role == api.Implementer && (automatedActor(actor) && m.interrupted(sess.Id) || m.heldForLanding(sess.TaskId, sess.Id)) if !held && (sess.State != api.AgentSessionStateRunning || sess.Role != api.Implementer) { - sess, err = m.Send(ctx, sessionID, actor, text, model, images...) + sess, err = m.Send(ctx, sessionID, actor, text, model, sendID, images...) return sess, false, err } @@ -75,7 +75,7 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model str if err != nil { return api.AgentSession{}, false, err } - err = m.store.AddTaskInput(ctx, store.TaskInput{TaskID: sess.TaskId, Member: sess.Member, Actor: actor, Text: text, Images: images}, task.ProjectId, sess.Id) + err = m.store.AddTaskInput(ctx, store.TaskInput{TaskID: sess.TaskId, Member: sess.Member, Actor: actor, Text: text, Images: images, SendID: sendID}, task.ProjectId, sess.Id) if err != nil { return api.AgentSession{}, false, err } @@ -139,7 +139,7 @@ func (m *Manager) deliverQueued(ctx context.Context, sess api.AgentSession) (boo } in := inputs[0] - _, err = m.Send(ctx, sess.Id, in.Actor, in.Text, "", in.Images...) + _, err = m.Send(ctx, sess.Id, in.Actor, in.Text, "", in.SendID, in.Images...) if errors.Is(err, ErrConflict) { slog.Info("queued message held: session changed state", "session", sess.Id, "task", task.Id, "err", err) return true, nil @@ -171,8 +171,9 @@ func (m *Manager) deliverAllQueued(ctx context.Context) { } type queued struct { - actor string - text string + actor string + text string + sendID string } func (m *Manager) takeQueuedMidTurn(ctx context.Context, l *live) []store.TaskInput { @@ -260,6 +261,9 @@ func (m *Manager) recordQueued(ctx context.Context, l *live, msgs []queued) stri if isFindingsMessage(q.actor, q.text) { payload["findings"] = true } + if q.sendID != "" { + payload["send_id"] = q.sendID + } err := m.emit(ctx, l, q.actor, "user.message", payload) if err != nil { slog.Error("record mid-turn message", "session", l.id, "err", err) diff --git a/internal/agents/ready.go b/internal/agents/ready.go index 4ef06e8..ebf119a 100644 --- a/internal/agents/ready.go +++ b/internal/agents/ready.go @@ -205,7 +205,7 @@ func (m *Manager) tryPromptReady(ctx context.Context, taskID string, fromReview if review.State == api.ReviewStateSkipped { prompt = readyPromptUnchanged } - _, err = m.Send(ctx, sess.Id, "byre", prompt, "") + _, err = m.Send(ctx, sess.Id, "byre", prompt, "", "") if err != nil { return err } diff --git a/internal/agents/review.go b/internal/agents/review.go index fcf2009..90dbe5d 100644 --- a/internal/agents/review.go +++ b/internal/agents/review.go @@ -217,7 +217,7 @@ func (m *Manager) startReviewer(ctx context.Context, task api.Task, snap api.Sna return err } - _, err = m.turn(ctx, sess, task, "byre", prompt, nil) + _, err = m.turn(ctx, sess, task, "byre", prompt, "", nil) if err != nil { m.endReviewer(ctx, sess.Id, project, reviewID) m.failReview(ctx, reviewID, "the reviewer didn't start: "+err.Error()) @@ -350,7 +350,7 @@ func (m *Manager) forwardFindings(taskID, reviewID string) { slog.Info("review findings not forwarded, task has no live agent", "review", reviewID, "task", taskID) return } - _, _, err = m.Message(ctx, s.Id, "byre", findingsMessage(r), "") + _, _, err = m.Message(ctx, s.Id, "byre", findingsMessage(r), "", "") if err != nil { slog.Error("forward review findings", "review", reviewID, "session", s.Id, "err", err) return diff --git a/internal/agents/subtasks.go b/internal/agents/subtasks.go index 51de991..511ff6a 100644 --- a/internal/agents/subtasks.go +++ b/internal/agents/subtasks.go @@ -73,7 +73,7 @@ func (m *Manager) subtaskTools(l *live) []mcp.Tool { if err != nil { return "", err } - _, err = m.create(ctx, task, creator, "", args.Prompt, m.modelOr(args.Model, l), agentActor, nil) + _, err = m.create(ctx, task, creator, "", args.Prompt, m.modelOr(args.Model, l), agentActor, "", nil) if err != nil { return "", fmt.Errorf("subtask %s created, but its agent did not start: %w", task.Id, err) } @@ -294,10 +294,10 @@ func (m *Manager) messageTask(ctx context.Context, task api.Task, user api.User, return err } if !found { - _, err = m.create(ctx, task, user, sess.Member, text, "", actor, nil) + _, err = m.create(ctx, task, user, sess.Member, text, "", actor, "", nil) return err } - _, _, err = m.Message(ctx, sess.Id, actor, text, "") + _, _, err = m.Message(ctx, sess.Id, actor, text, "", "") return err } diff --git a/internal/agents/summary.go b/internal/agents/summary.go index 0b923e0..6053f09 100644 --- a/internal/agents/summary.go +++ b/internal/agents/summary.go @@ -202,7 +202,7 @@ func (m *Manager) launchDiscard(ctx context.Context, taskID string) (bool, error if err != nil { return false, err } - _, err = m.launch(ctx, sess, task, "byre", discardPrompt, nil) + _, err = m.launch(ctx, sess, task, "byre", discardPrompt, "", nil) return err == nil, err } diff --git a/internal/agents/tasks.go b/internal/agents/tasks.go index e1c840f..162a4b3 100644 --- a/internal/agents/tasks.go +++ b/internal/agents/tasks.go @@ -33,7 +33,7 @@ func (m *Manager) StartTask(ctx context.Context, project api.Project, creator ap if err != nil { return api.Task{}, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) } - imgs, compacting, err := m.recordMessage(ctx, sess, task, creator.Login, prompt, images) + imgs, compacting, err := m.recordMessage(ctx, sess, task, creator.Login, prompt, "", images) if err != nil { m.failStart(ctx, sess.Id, creator.Login, err) return api.Task{}, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) @@ -84,7 +84,7 @@ func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator ap if err != nil { return nil, err } - _, err = m.create(ctx, task, creator, "", todoPrompt(item, discuss), model, actor, nil) + _, err = m.create(ctx, task, creator, "", todoPrompt(item, discuss), model, actor, "", nil) if err != nil { return nil, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) } diff --git a/internal/agents/team.go b/internal/agents/team.go index e9bcc6f..1a4ddff 100644 --- a/internal/agents/team.go +++ b/internal/agents/team.go @@ -95,7 +95,7 @@ func (m *Manager) startTeam(ctx context.Context, task api.Task, creator api.User } failed := []string{} for i, sess := range sessions { - _, err = m.turn(ctx, sess, task, actor, teamPrompt(shared, members[i]), nil) + _, err = m.turn(ctx, sess, task, actor, teamPrompt(shared, members[i]), "", nil) if err != nil { failed = append(failed, fmt.Sprintf("%q: %v", members[i].Name, err)) } @@ -189,7 +189,7 @@ func (m *Manager) post(ctx context.Context, task api.Task, from poster, text str func (m *Manager) deliverPost(ctx context.Context, task api.Task, s api.AgentSession, msg string) { started, err := m.store.SessionStarted(ctx, s.Id) if err == nil && started { - _, _, err = m.Message(ctx, s.Id, boardActor, msg, "") + _, _, err = m.Message(ctx, s.Id, boardActor, msg, "", "") } if err == nil && started { return diff --git a/internal/agents/terminal.go b/internal/agents/terminal.go index 620991b..885a87a 100644 --- a/internal/agents/terminal.go +++ b/internal/agents/terminal.go @@ -166,7 +166,7 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool { func (m *Manager) wakeOnPrompt(l *live) func(string, []byte) { return func(exit string, out []byte) { text := fmt.Sprintf("The line you left unsent in the terminal was run, and the shell is back at its prompt with exit status %s (130 means it was cancelled with Ctrl-C). The terminal's output since you typed it:\n\n%s", exit, terminal.Text(out)) - _, _, err := m.Message(context.Background(), l.id, "byre", text, "") + _, _, err := m.Message(context.Background(), l.id, "byre", text, "", "") if err != nil { slog.Error("wake agent on terminal prompt", "session", l.id, "task", l.taskID, "err", err) return diff --git a/internal/agents/workflows.go b/internal/agents/workflows.go index 82c5de3..e1700ac 100644 --- a/internal/agents/workflows.go +++ b/internal/agents/workflows.go @@ -269,7 +269,7 @@ func (m *Manager) beginStage(ctx context.Context, task api.Task, project api.Pro s := workflow.Get(frozen.Definition, stage) message := stageMessage(frozen, stage, parents, project.DefaultBranch) if s.Agents == nil { - return m.create(ctx, task, creator, "", message, s.Model, "byre", nil) + return m.create(ctx, task, creator, "", message, s.Model, "byre", "", nil) } err = m.startTeam(ctx, task, creator, message, *s.Agents, "byre") if err != nil { @@ -516,7 +516,7 @@ func (m *Manager) resumeStage(ctx context.Context, task api.Task, project api.Pr } message = teamPrompt(message, mem) } - _, err = m.turn(ctx, sess, task, "byre", message, nil) + _, err = m.turn(ctx, sess, task, "byre", message, "", nil) if err != nil { return ids, err } diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index b6a788b..c0ff331 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -1014,6 +1014,9 @@ type MessageCreate struct { // Model Model for this turn and later ones. When it differs from the session's model, the session switches before the message is sent. Model *string `json:"model,omitempty"` + // SendId The caller's id for this send, carried on the user.queued and user.message events the message records. + SendId *string `json:"send_id,omitempty"` + // Text May be empty when images are given. Text string `json:"text"` } @@ -1179,6 +1182,9 @@ type SessionCreate struct { Images *[]string `json:"images,omitempty"` Model *string `json:"model,omitempty"` Prompt string `json:"prompt"` + + // SendId The caller's id for this send, carried on the user.message event the prompt records. + SendId *string `json:"send_id,omitempty"` } // Snapshot defines model for Snapshot. @@ -4240,232 +4246,233 @@ var swaggerSpec = []string{ "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/server/lead.go b/internal/server/lead.go index f39879f..8220875 100644 --- a/internal/server/lead.go +++ b/internal/server/lead.go @@ -62,8 +62,12 @@ func (s *Server) MessageLead(w http.ResponseWriter, r *http.Request) { if req.Model != nil { model = *req.Model } + sendID := "" + if req.SendId != nil { + sendID = *req.SendId + } - sess, err := s.agents.MessageLead(r.Context(), u, req.Text, model, images) + sess, err := s.agents.MessageLead(r.Context(), u, req.Text, model, sendID, images) if err != nil { writeAgentError(w, err) return diff --git a/internal/server/server.go b/internal/server/server.go index bbf5131..c2d1c03 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -886,8 +886,12 @@ func (s *Server) CreateSession(w http.ResponseWriter, r *http.Request, taskID st if req.Model != nil { model = *req.Model } + sendID := "" + if req.SendId != nil { + sendID = *req.SendId + } - sess, err := s.agents.Create(r.Context(), t, u, req.Prompt, model, images...) + sess, err := s.agents.Create(r.Context(), t, u, req.Prompt, model, sendID, images...) if err != nil { writeAgentError(w, err) return @@ -924,8 +928,12 @@ func (s *Server) SendMessage(w http.ResponseWriter, r *http.Request, sessionID s if req.Model != nil { model = *req.Model } + sendID := "" + if req.SendId != nil { + sendID = *req.SendId + } - sess, _, err := s.agents.Message(r.Context(), sessionID, actor(r), req.Text, model, images...) + sess, _, err := s.agents.Message(r.Context(), sessionID, actor(r), req.Text, model, sendID, images...) if err != nil { writeAgentError(w, err) return diff --git a/internal/store/lead.go b/internal/store/lead.go index 73cb5b9..d6e65bc 100644 --- a/internal/store/lead.go +++ b/internal/store/lead.go @@ -24,6 +24,7 @@ type LeadInput struct { Actor string Text string Images []string + SendID string CreatedAt time.Time } @@ -33,14 +34,14 @@ func (s *Store) AddLeadInput(ctx context.Context, in LeadInput) error { return err } return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { - _, err := tx.ExecContext(ctx, `INSERT INTO lead_inputs (owner, kind, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?, ?)`, - in.Owner, string(in.Kind), in.Actor, in.Text, string(images), now()) + _, err := tx.ExecContext(ctx, `INSERT INTO lead_inputs (owner, kind, actor, text, images, send_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + in.Owner, string(in.Kind), in.Actor, in.Text, string(images), in.SendID, now()) return err }) } func (s *Store) LeadInputs(ctx context.Context, owner string) ([]LeadInput, error) { - rows, err := s.db.QueryContext(ctx, `SELECT id, owner, kind, actor, text, images, created_at FROM lead_inputs WHERE owner = ? ORDER BY id`, owner) + rows, err := s.db.QueryContext(ctx, `SELECT id, owner, kind, actor, text, images, send_id, created_at FROM lead_inputs WHERE owner = ? ORDER BY id`, owner) if err != nil { return nil, err } @@ -52,7 +53,7 @@ func (s *Store) LeadInputs(ctx context.Context, owner string) ([]LeadInput, erro kind := "" images := "" createdAt := "" - err = rows.Scan(&in.ID, &in.Owner, &kind, &in.Actor, &in.Text, &images, &createdAt) + err = rows.Scan(&in.ID, &in.Owner, &kind, &in.Actor, &in.Text, &images, &in.SendID, &createdAt) if err != nil { return nil, err } @@ -77,8 +78,8 @@ func (s *Store) RestoreLeadInputs(ctx context.Context, inputs []LeadInput) error if err != nil { return err } - _, err = tx.ExecContext(ctx, `INSERT INTO lead_inputs (id, owner, kind, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - in.ID, in.Owner, string(in.Kind), in.Actor, in.Text, string(images), formatTime(in.CreatedAt)) + _, err = tx.ExecContext(ctx, `INSERT INTO lead_inputs (id, owner, kind, actor, text, images, send_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + in.ID, in.Owner, string(in.Kind), in.Actor, in.Text, string(images), in.SendID, formatTime(in.CreatedAt)) if err != nil { return err } diff --git a/internal/store/migrations/069-add-input-send-ids.sql b/internal/store/migrations/069-add-input-send-ids.sql new file mode 100644 index 0000000..eeef8ca --- /dev/null +++ b/internal/store/migrations/069-add-input-send-ids.sql @@ -0,0 +1,33 @@ +ALTER TABLE task_inputs ADD COLUMN send_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE lead_inputs ADD COLUMN send_id TEXT NOT NULL DEFAULT ''; + +UPDATE task_inputs SET send_id = 'queued-' || id; + +UPDATE events SET payload = json_set(payload, '$.send_id', 'queued-' || json_extract(payload, '$.id')) +WHERE kind = 'user.queued'; + +-- The n-th delivered queue entry of a task, sender and text pairs with the n-th later user.message of the same. +WITH q AS ( + SELECT e.seq, e.task_id, e.actor, json_extract(e.payload, '$.text') AS text, json_extract(e.payload, '$.send_id') AS send_id, + row_number() OVER (PARTITION BY e.task_id, e.actor, json_extract(e.payload, '$.text') ORDER BY e.seq) AS n + FROM events AS e + WHERE e.kind = 'user.queued' + AND json_extract(e.payload, '$.id') NOT IN (SELECT id FROM task_inputs) + AND NOT EXISTS ( + SELECT 1 FROM events AS u + WHERE u.kind = 'user.unqueued' AND u.task_id = e.task_id AND json_extract(u.payload, '$.id') = json_extract(e.payload, '$.id') + ) +), first AS ( + SELECT task_id, actor, text, min(seq) AS seq FROM q GROUP BY task_id, actor, text +), m AS ( + SELECT e.seq, e.task_id, e.actor, json_extract(e.payload, '$.text') AS text, + row_number() OVER (PARTITION BY e.task_id, e.actor, json_extract(e.payload, '$.text') ORDER BY e.seq) AS n + FROM events AS e + JOIN first AS f ON f.task_id = e.task_id AND f.actor = e.actor AND f.text = json_extract(e.payload, '$.text') AND e.seq > f.seq + WHERE e.kind = 'user.message' +), pairs AS ( + SELECT m.seq, q.send_id FROM m + JOIN q ON q.task_id = m.task_id AND q.actor = m.actor AND q.text = m.text AND q.n = m.n AND q.seq < m.seq +) +UPDATE events SET payload = json_set(payload, '$.send_id', (SELECT send_id FROM pairs WHERE pairs.seq = events.seq)) +WHERE seq IN (SELECT seq FROM pairs); diff --git a/internal/store/taskinputs.go b/internal/store/taskinputs.go index 74ebaf1..722904c 100644 --- a/internal/store/taskinputs.go +++ b/internal/store/taskinputs.go @@ -15,6 +15,7 @@ type TaskInput struct { Actor string Text string Images []string + SendID string CreatedAt time.Time } @@ -23,13 +24,16 @@ func (s *Store) AddTaskInput(ctx context.Context, in TaskInput, projectID, sessi if err != nil { return err } - payload := map[string]any{"text": in.Text} + if in.SendID == "" { + in.SendID = NewID() + } + payload := map[string]any{"text": in.Text, "send_id": in.SendID} if len(in.Images) > 0 { payload["images"] = in.Images } return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { - res, err := tx.ExecContext(ctx, `INSERT INTO task_inputs (task_id, member, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?, ?)`, - in.TaskID, in.Member, in.Actor, in.Text, string(images), now()) + res, err := tx.ExecContext(ctx, `INSERT INTO task_inputs (task_id, member, actor, text, images, send_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + in.TaskID, in.Member, in.Actor, in.Text, string(images), in.SendID, now()) if err != nil { return err } @@ -62,7 +66,7 @@ func (s *Store) WithdrawTaskInput(ctx context.Context, taskID string, id int64, } func (s *Store) TaskInputs(ctx context.Context, taskID, member string) ([]TaskInput, error) { - rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, member, actor, text, images, created_at FROM task_inputs WHERE task_id = ? AND member = ? ORDER BY id`, taskID, member) + rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, member, actor, text, images, send_id, created_at FROM task_inputs WHERE task_id = ? AND member = ? ORDER BY id`, taskID, member) if err != nil { return nil, err } @@ -73,7 +77,7 @@ func (s *Store) TaskInputs(ctx context.Context, taskID, member string) ([]TaskIn in := TaskInput{} images := "" createdAt := "" - err = rows.Scan(&in.ID, &in.TaskID, &in.Member, &in.Actor, &in.Text, &images, &createdAt) + err = rows.Scan(&in.ID, &in.TaskID, &in.Member, &in.Actor, &in.Text, &images, &in.SendID, &createdAt) if err != nil { return nil, err } @@ -97,8 +101,8 @@ func (s *Store) RestoreTaskInputs(ctx context.Context, inputs []TaskInput) error if err != nil { return err } - _, err = tx.ExecContext(ctx, `INSERT INTO task_inputs (id, task_id, member, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - in.ID, in.TaskID, in.Member, in.Actor, in.Text, string(images), formatTime(in.CreatedAt)) + _, err = tx.ExecContext(ctx, `INSERT INTO task_inputs (id, task_id, member, actor, text, images, send_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + in.ID, in.TaskID, in.Member, in.Actor, in.Text, string(images), in.SendID, formatTime(in.CreatedAt)) if err != nil { return err } diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index 24a76d0..5696970 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -60,7 +60,7 @@ final class ChatModel { case .task(let id): id.flatMap { store?.currentSession($0) } } } - builder = TranscriptBuilder(taskId: taskId, worktree: { [weak store] in + builder = TranscriptBuilder(taskId: taskId, local: store.sends(taskId ?? "lead"), worktree: { [weak store] in taskId.flatMap { store?.findTask($0)?.worktreePath } ?? "" }, running: { session()?.state == .running @@ -458,11 +458,11 @@ final class ChatModel { } } - func send(sessionId: String?, text: String, images: [String]?, model: String?, firstModel: String) async throws { + func send(sessionId: String?, text: String, images: [String]?, model: String?, firstModel: String, sendID: String) async throws { let api = store.api.client switch kind { case .lead: - _ = try await api.messageLead(body: .json(.init(text: text, images: images, model: sessionId != nil ? model : firstModel))).accepted + _ = try await api.messageLead(body: .json(.init(text: text, images: images, model: sessionId != nil ? model : firstModel, sendId: sendID))).accepted case .task(let id): if team { if images != nil { @@ -472,10 +472,10 @@ final class ChatModel { return } if let sessionId { - _ = try await api.sendMessage(path: .init(sessionId: sessionId), body: .json(.init(text: text, images: images, model: model))).accepted + _ = try await api.sendMessage(path: .init(sessionId: sessionId), body: .json(.init(text: text, images: images, model: model, sendId: sendID))).accepted return } - _ = try await api.createSession(path: .init(taskId: id ?? ""), body: .json(.init(prompt: text, model: firstModel, images: images))).created + _ = try await api.createSession(path: .init(taskId: id ?? ""), body: .json(.init(prompt: text, model: firstModel, images: images, sendId: sendID))).created } } diff --git a/mac/Sources/Byre/Composer.swift b/mac/Sources/Byre/Composer.swift index 82c5d60..c4b3da5 100644 --- a/mac/Sources/Byre/Composer.swift +++ b/mac/Sources/Byre/Composer.swift @@ -519,16 +519,21 @@ struct ComposerView: View { let imgs = images.take(draftKey) text = "" let sent = drafts.startSend(draftKey, value, imgs.count) - let cancel = chat.team ? {} : chat.builder.showPending(value, imgs) + let sendID = UUID().uuidString + let cancel = chat.team ? { true } : chat.builder.showPending(value, imgs, sendID) Task { do { let ids = try await uploadImages(store.api, imgs) - try await chat.send(sessionId: m.live?.id, text: value, images: ids, model: m.selected != m.current ? m.selected : nil, firstModel: m.selected) + try await chat.send(sessionId: m.live?.id, text: value, images: ids, model: m.selected != m.current ? m.selected : nil, firstModel: m.selected, sendID: sendID) modelChoices[m.modelKey] = nil pick = nil drafts.endSend(sent, true) } catch { - cancel() + guard cancel() else { + log.notice("send failed after the server confirmed it: \(errorText(error), privacy: .public)") + drafts.endSend(sent, true) + return + } drafts.endSend(sent, false) text = drafts.composerText(draftKey) images.restore(draftKey, imgs) diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index dad3100..aa3e64e 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -88,6 +88,7 @@ final class Store { @ObservationIgnored private var leadStream: EventStream? @ObservationIgnored private var stopped = false @ObservationIgnored var changesMemory = ChangesMemory() + @ObservationIgnored private var localSends: [String: LocalSends] = [:] init(api: API, images: ImageDrafts, forwards: Forwards) { self.api = api @@ -98,6 +99,22 @@ final class Store { remote = Remote(api: api) } + func sends(_ key: String) -> LocalSends { + if let sends = localSends[key] { + return sends + } + let sends = LocalSends() + localSends[key] = sends + return sends + } + + private func confirmSend(_ key: String, _ ev: ByreEvent) { + guard ev.kind == "user.queued" || ev.kind == "user.message", let sendID = str(ev.payload.value, "send_id") else { + return + } + localSends[key]?.items.removeAll { $0.sendID == sendID } + } + func stop() async { stopped = true globalStream?.stop() @@ -569,6 +586,7 @@ final class Store { leadMore = page.more leadStream = EventStream(api: api, after: page.events.last?.seq ?? 0, lead: true, onEvent: { [weak self] ev in self?.leadFeed.push(ev) + self?.confirmSend("lead", ev) }, onState: { _ in }, onReady: { [weak self] in self?.leadFeed.load(page.events) }) @@ -603,6 +621,7 @@ final class Store { func handleEvent(_ ev: ByreEvent) { if let taskId = ev.taskId { taskFeeds[taskId]?.push(ev) + confirmSend(taskId, ev) } if ev.seq == 0 { events.send(ev) diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index 15a0838..e107e80 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -151,9 +151,18 @@ struct PendingSend: Identifiable { let local: Bool let queued: Bool let md: [MDBlock] + let sendID: String var inputId: Int? = nil } +// Sends not yet confirmed by the server, kept by the Store so they outlive the transcript showing them. +@MainActor +@Observable +final class LocalSends { + var items: [PendingSend] = [] + @ObservationIgnored var ids = 0 +} + @MainActor private final class Run { let first: Row @@ -202,7 +211,7 @@ private final class Ctx { @Observable final class TranscriptBuilder { let root = RowList() - private(set) var pending: [PendingSend] = [] + private(set) var queued: [PendingSend] = [] private(set) var context: [String: Usage] = [:] private(set) var open = false private(set) var lastMergeAfterReview: Int? @@ -216,14 +225,20 @@ final class TranscriptBuilder { @ObservationIgnored private let worktree: () -> String @ObservationIgnored private let running: () -> Bool @ObservationIgnored private var pendingIds = 0 + @ObservationIgnored private let local: LocalSends - init(taskId: String?, worktree: @escaping () -> String, running: @escaping () -> Bool) { + init(taskId: String?, local: LocalSends, worktree: @escaping () -> String, running: @escaping () -> Bool) { self.taskId = taskId + self.local = local rowTask = taskId self.worktree = worktree self.running = running } + var pending: [PendingSend] { + queued + local.items + } + func stop() { dropStream() } @@ -245,25 +260,23 @@ final class TranscriptBuilder { root.rows.insert(contentsOf: box.rows, at: 0) } - func showPending(_ text: String, _ images: [PendingImage]) -> () -> Void { - pendingIds += 1 - let entry = PendingSend(id: pendingIds, text: text, images: images, imageIds: [], local: true, queued: running(), md: trimmed(text).isEmpty ? [] : parseMarkdown(text)) - pending.append(entry) + // The returned closure drops the send and says whether it was still unconfirmed. + func showPending(_ text: String, _ images: [PendingImage], _ sendID: String) -> () -> Bool { + local.ids -= 1 + let entry = PendingSend(id: local.ids, text: text, images: images, imageIds: [], local: true, queued: running(), md: trimmed(text).isEmpty ? [] : parseMarkdown(text), sendID: sendID) + local.items.append(entry) ctx.run = nil - return { [weak self] in - self?.pending.removeAll { $0.id == entry.id } + return { [local] in + guard let i = local.items.firstIndex(where: { $0.id == entry.id }) else { + return false + } + local.items.remove(at: i) + return true } } func withdrawn(_ inputId: Int) { - pending.removeAll { $0.inputId == inputId } - } - - private func confirmPending(_ text: String, queued: Bool, local: Bool) { - let i = queued ? pending.firstIndex { !$0.local && $0.text == text } : nil - if let i = i ?? (local ? pending.firstIndex { $0.local && $0.text == text } : nil) { - pending.remove(at: i) - } + queued.removeAll { $0.inputId == inputId } } private func release() { @@ -441,19 +454,18 @@ final class TranscriptBuilder { if sink == nil && ((ev.kind == "agent.message" && parent.isEmpty) || ev.kind == "turn.ended" || ev.kind == "user.message") { dropStream() } - if sink == nil && own && ev.kind == "user.message" { - confirmPending(str(p, "text") ?? "", queued: true, local: ev.actor != "byre") + if sink == nil && own && ev.kind == "user.message", let sendID = str(p, "send_id") { + queued.removeAll { $0.sendID == sendID } } if sink == nil && own && (ev.kind == "task.merged" || ev.kind == "task.discarded") { - pending.removeAll { !$0.local } + queued.removeAll() } if ev.kind == "user.queued" { if sink == nil && own { let text = str(p, "text") ?? "" - confirmPending(text, queued: false, local: true) pendingIds += 1 let images = (p["images"] as? [(any Sendable)?] ?? []).map { jsString($0) } - pending.append(PendingSend(id: pendingIds, text: text, images: [], imageIds: images, local: false, queued: true, md: trimmed(text).isEmpty ? [] : parseMarkdown(text), inputId: int(p, "id"))) + queued.append(PendingSend(id: pendingIds, text: text, images: [], imageIds: images, local: false, queued: true, md: trimmed(text).isEmpty ? [] : parseMarkdown(text), sendID: str(p, "send_id") ?? "", inputId: int(p, "id"))) } continue }