diff --git a/docs/operations.md b/docs/operations.md index 950f3bd..a0e0712 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -224,7 +224,7 @@ curl -H 'Tailscale-User-Login: you@example.com' -H 'Tailscale-User-Name: You' ht ## Agent sessions -`POST /api/tasks/{id}/sessions` starts a Claude session in the task's worktree and sends the prompt as its first turn; `POST /api/sessions/{id}/messages` starts later turns and `POST /api/sessions/{id}/interrupt` ends the current one. A message sent while a task session's turn is running, from the app, the lead's `send_message`, a parent's `message_subtask` or byre's review findings, is queued (`Manager.Message` in `internal/agents/queue.go`) in `task_inputs`, keyed by task and team member (`member`, empty on a task with one agent) so it survives a handoff to a new session, and recorded, in the same transaction, as a `user.queued` event (its `text`, `images` and `task_inputs` `id`, the sender as actor), and the request returns the running session with 202. `DELETE /api/tasks/{id}/queue/{input id}` takes a queued message back (`Manager.Withdraw`): under the task's queue lock (`queueLockKey`), which both hand-over paths (`deliverQueued`, `takeQueuedMidTurn`) hold from reading the queue until the message is sent or taken, whichever session of the task is delivering, it deletes the row and records a `user.unqueued` event with its `id`, or answers 409 once the message has been handed over (the row is gone). A model it carries is applied to the session at once and takes effect from the next turn. The agent gets queued messages after its next tool call, the same way as inbox answers (see "Inbox"): each is recorded as its own `user.message` with the sender as actor and `mid_turn: true`, and the agent reads them, headed " 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` 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`. `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. @@ -238,7 +238,7 @@ Agents don't commit; byre does. Each `user.message` event that starts a turn rec A session runs on one of three vendors, picked by the model it is created with (`workflow.VendorOf` in `internal/workflow/workflow.go`): `gpt-*` models run on Codex (`codex app-server`), `gemini-*` models on Antigravity (`agy`), 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 another vendor's model. All vendors sit behind `agentProcess` (`internal/agents/process.go`), and all end a turn through the same `turnEnded`, so commits, turn summaries, snapshots, handoffs, merges and deliveries don't depend on the vendor. -For Claude, byre chooses the session ID (`vendor_session_id`) up front. Codex picks its own thread id: a Codex session is created with an empty `vendor_session_id`, and byre stores the thread id when its first process starts (`startCodex`); a session with an id resumes it with `thread/resume`. Antigravity does the same with its conversation id, which `agy` reports in its `init` line at launch (`startAntigravity`), resumed with `--conversation`. Processes start on demand: the first message after a restart resumes the session (`--resume` for Claude). Sessions are `running` from the moment a turn is sent until it ends (Claude's and Antigravity's `result`, Codex's `turn/completed`); on startup, every session still marked `running` was cut off mid-turn, and byre resumes it and sends a message telling the agent to re-check the working tree. Stopping the server kills all agent processes. Merging or discarding a task kills its sessions and marks them `completed`. Every agent CLI starts in its own process group, and killing one sends `SIGKILL` to the whole group. That way a launcher that runs the real agent as its child, along with the agent's MCP servers, can't keep holding byre's stdout pipe, which would leave the kill waiting forever for end of file. +For Claude, byre chooses the session ID (`vendor_session_id`) up front. Codex picks its own thread id: a Codex session is created with an empty `vendor_session_id`, and byre stores the thread id when its first process starts (`startCodex`); a session with an id resumes it with `thread/resume`. Antigravity does the same with its conversation id, which `agy` reports in its `init` line at launch (`startAntigravity`), resumed with `--conversation`. Processes start on demand: the first message after a restart resumes the session (`--resume` for Claude). A Claude session counts as started, and so is resumed rather than created with `--session-id`, once byre has sent its process a message (`agent_sessions.started`, set by `Manager.send`, read by `Store.SessionStarted`), not when the message is recorded. `agent_sessions.pending` is set in the transaction that records a turn's `user.message` (`Store.RecordTurnMessage`) and cleared when the message is sent. On startup, a `running` session with `pending` set is sent its last recorded `user.message` (`Manager.deliverRecorded`) instead of the restart notice, so a restart between recording a message and sending it, while the process starts, doesn't lose it. Sessions are `running` from the moment a turn is sent until it ends (Claude's and Antigravity's `result`, Codex's `turn/completed`); on startup, every session still marked `running` was cut off mid-turn, and byre resumes it and sends a message telling the agent to re-check the working tree. Stopping the server kills all agent processes. Merging or discarding a task kills its sessions and marks them `completed`. Every agent CLI starts in its own process group, and killing one sends `SIGKILL` to the whole group. That way a launcher that runs the real agent as its child, along with the agent's MCP servers, can't keep holding byre's stdout pipe, which would leave the kill waiting forever for end of file. Every session gets byre's guidance (`guidance` in `internal/agents/guidance.go`): ask questions with `ask_human`, end each turn with a final message that summarizes it (including every departure from the plan), put everything the human needs in a question's context since that turn has no summary, don't commit (byre commits each turn), don't push, touch other branches or run git maintenance, and write `TODO.md` items the way byre parses them (see "TODO"): a top-level `- ` line holding only the title, with the description indented under it. A task agent's guidance also names its port range, tells it to do the setup AGENTS.md describes in a new worktree (see "Worktree setup and ports"), and quotes the task's title, which may be all the human typed when creating the task. A task started from a `TODO.md` item gets one more line naming the item and telling the agent to remove it from `TODO.md` in its worktree when the work is done (`taskGuidance`; see "TODO"). A subtask gets one naming its parent and saying that its final messages go to the parent's agent (`subtaskGuidance`; see "Subtasks"). For Claude it is passed as `--append-system-prompt`, which Claude Code records on a session's first request and reuses on resume, so a running session keeps the text it started with; a `UserPromptSubmit` hook also attaches it as `additionalContext` to every user turn, which reaches existing sessions too. For Codex it is the thread's `developerInstructions`, and every `turn/start` also carries it as `additionalContext` under the key `byre`. For Antigravity it is an always-on rule in the session's plugin (`rules/AGENTS.md`, with the notes in `rules/notes.md`), written when byre starts the session's process, and every message also starts with it in `` tags. The guidance names byre's tools vendor-neutrally ("byre's ask_human tool"): Claude sees them as `mcp__byre__`, Codex as `` in the namespace `byre` (Codex reserves the `mcp__` prefix), Antigravity as `` on the MCP server `byre_byre`, called through its `call_mcp_tool` (recorded as `mcp__byre__`). @@ -300,7 +300,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie - **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation), `session.error` and `session.rate_limited` on a task, `inbox.updated` when a human answered a question (the signal also carries its text and the agent's recommendation, so the lead sees where the human departed from it), `task.created` (naming the TODO item it was started from, or a subtask's parent, if any), `task.merge_queued` ("'s merge was requested by and is held until the merge in progress in its project is done."), `task.merge_after_review` (" was set by to merge once the review of snapshot finishes with no findings."), `task.merge_after_review_cancelled` ("'s merge after review was cancelled by ."), `task.merge_after_review_dropped` (" didn't merge after the review: "), `task.merge_started` (" is merging, started by ."), `task.merged`, `task.discarded`, `turn.ended` with `summary: true` (" finished a turn:" with its `result` cut to 1500 characters), `task.workflow_stage` (" started stage "" of workflow "".") and `task.workflow_finished` (" finished workflow "":" with its output cut to 1500 characters), `review.started` (" started a review of snapshot ."), `review.finished` (" review of snapshot :" 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` (" state: → .", or " state: ." for a new task; see "Task state in the store"). The store hands `Observe` all of one transaction's events together, and they become one signal, its `task.state` lines first, so a turn's summary always arrives with the state its turn end left the task in. A batch whose lines are all quiet is dropped instead of held (`quietSignal` in `internal/agents/lead.go`): `task.state`, `review.started`, and `task.created`, `task.merge_started`, `task.merged` and `task.discarded` by a human, unless the line carries "Your right pane is free" (see "Pane lock"). Quiet lines still ride along in a batch with anything else, and the "Tasks now" list that follows every batch shows each task's current state. The human switching the right pane is not a signal. A signal caused by the lead itself (actor ` (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 ` (lead)`. Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as one `byre` message headed "Signals since your last turn (n)", followed by "Tasks now:" with the `list_tasks` text and a line saying what the lead's right pane shows (task or project, and selected tab) and whether the human is working in it (`leadStateText` in `internal/agents/lead.go`). If collecting that fails, for example because a project's repository is gone, the batch is still sent, ending instead with "The task list and right pane state are unavailable:" and the error, which is also logged as `lead state`. Its guidance tells it not to respond to the human's own actions, and to make its final message exactly `NO_REPLY` when a batch needs nothing from it. A transcript (`quietReply` in `mac/Sources/Byre/TranscriptBuilder.swift`) never draws an `agent.message` whose last line is `NO_REPLY` (the model sometimes writes a sentence before it), and hides the live partial message while its text is still a prefix of it, so an empty one never takes up space. A lead turn that byre starts without a handoff (a signal batch or a handoff continuation) is held (`ctx.held`): its `byre` message is never drawn, the turn isn't opened, so the scanner stays off, its partial message stays hidden, and its tool calls, subagents and thinking are built into a held row list instead of the transcript. The turn is released, inserting those rows where the turn belongs, when it draws anything else: a real message, a note, or its own `turn.ended` when it ends without `NO_REPLY`. When it ends in `NO_REPLY` while still held, the held rows are dropped, so nothing of the turn ever appears; a turn that draws a real message anywhere in it keeps all of it, even when a later message is `NO_REPLY`. - **Tools.** `list_tasks` (each project's active tasks with their state, each subtask noting its parent, a held merge (`merge_queued_at`) and a merge after review (`merge_after_review`) each noted, then a line listing the titles of its `TODO.md` items on the default branch that no active task was started from), `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`; closed tasks too), `search_closed_tasks` (see "Closing a task"), `read_inbox`, `read_status` (the `GET /api/admin/status` body as JSON, built by the same `Server.status`, which the server hands `agents.New`), `answer_question` (an `option` or `other` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; while it is mid-turn the message is queued through `Manager.Message` like the human's, see "Agent sessions", and the tool result says it was queued; refused while the agent is stopped after an interrupt or error, see "Agent sessions"; on a team task it posts to the board, see "Teams"), `create_task` and `start_todo` (each with an optional `workflow` to freeze and run; `create_task` also takes `agents` to start a team), `create_project` (`name` and `repo_path`, created as the user by the same `Server.createProject` as `POST /api/projects`, which the server hands `agents.New`, so it publishes the same `project.created` event, and returned as JSON), `interrupt`, `compact_task`, `take_snapshot` (see "Snapshots"), `merge_task` (optional `target`: `main`, or `parent` for a subtask, see "Subtasks"), `discard_task`, `show`, `compact_self`, `read_knowledge` (see "Knowledge base"), the note tools (see "Notes") and the workflow tools (see "Workflows"). The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor ` (lead)`, so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks. - **Focus.** `users.focus` holds what the user's right pane shows: `project_id`, `task_id` and `tab` (the selected one). The tabs' order is fixed; neither the lead nor the human can change it. With a `task_id`, `project_id` is always that task's project, filled in by the server; with only a `project_id`, the pane shows that project with no task, which only the Project tab has anything for. `GET /api/me/focus` reads it; `PUT /api/me/focus` and the lead's `show` set it, refusing an unknown tab, a task that isn't active or an unknown project, and record a `focus.updated` event owned by the user. The app applies the focus at startup and on every `focus.updated` it didn't cause itself; the human's own changes (⌘K, the right pane's task picker, clicking a tab, a task link, a notification) are sent with `PUT /api/me/focus`. -- **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and changes nothing. Otherwise, while the human is working in the right pane (the app reports `focused` or `drafting`, below), the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (`Manager.paneFinished`): its task is merged, discarded or merging (`merging_by` or `finalizing` set), or the human is waiting on the task's agent (`Manager.paneWaiting`): the selected tab is `transcript`, the task's implementer session is running, the app reports no unsent text in the pane, and either the `user.message` that started the running turn came from the user after the pane last moved, or the pane last moved 10 seconds ago or more (`watchHold`). The pane's last move is the last `PUT /api/me/focus` or `show` that set it (`Manager.paneMoved`, in memory, so a restart counts as long ago). For the user's own message, unsent text reported before the turn started doesn't count, since the app's report of the composer emptying on send can arrive after the message does. A `user.message` on a task from a human (not `byre`, an agent or a lead) signals every lead " sent a message; its agent is working on it:" followed by the message's full text, 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 '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 's agent work for 10 seconds without typing." The lead's own `show` starts no timer. When a `task.merge_started`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: is merging." (or "is merged."/"is discarded."). The lead's guidance tells it to show the next thing that needs the human on that line, or, when nothing does, the project's Project tab with a short status in chat: one line per active task, then up to three TODO items it would start next, each with a reason, starting one only when the human picks it. It writes each item as a link to `#/todo//`; 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///`, drawn by the same code with `start` and `discuss` buttons: `start` calls `POST /api/projects/{id}/tasks` with the title, the brief as `prompt` and the user's default model, which checks the user's git identity, creates the task and starts its agent in one request, with no lead turn in between; `discuss` sends the lead `POST /api/me/lead/messages` asking to discuss the suggestion, quoting its title and brief (with the user's default model only when the user has no live lead session). Once an active task in that project has the suggestion's title, it shows that task's state the same way. A chat view refills them whenever its transcript grows or tasks or sessions change. When that status says nothing new since its last one, it replies `NO_REPLY` instead. The app sends `PUT /api/me/pane-focus` with `focused` and `drafting` whenever either changes (`PaneFocus` in `mac/Sources/Byre/PaneFocus.swift`): `focused` is true while the window is key, the app is active and the keyboard focus or the last click is inside the right pane, so a click anywhere in it counts, not only on inputs and buttons; `drafting` while any text field in the right pane other than the terminal holds non-blank text. The window reports to the server it shows. It sends false on deactivating, switching to another server, Forget Server, closing the window and quit, and resends true when its event stream reconnects. byre keeps both flags and the time of the last report in memory per user (`Manager.paneFocus`, `Manager.paneReported`), so they start false after a restart until the app resends them. +- **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and changes nothing. Otherwise, while the human is working in the right pane (the app reports `focused` or `drafting`, below), the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (`Manager.paneFinished`): its task is merged, discarded or merging (`merging_by` or `finalizing` set), or the human is waiting on the task's agent (`Manager.paneWaiting`): the selected tab is `transcript`, the task's implementer session is running, the app reports no unsent text in the pane, and either the `user.message` that started the running turn came from the user after the pane last moved, or the pane last moved 10 seconds ago or more (`watchHold`). The pane's last move is the last `PUT /api/me/focus` or `show` that set it (`Manager.paneMoved`, in memory, so a restart counts as long ago). For the user's own message, unsent text reported before the turn started doesn't count, since the app's report of the composer emptying on send can arrive after the message does. A `user.message` on a task from a human (not `byre`, an agent or a lead) signals every lead " sent a message; its agent is working on it:" followed by the message's full text, 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 '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 's agent work for 10 seconds without typing." The lead's own `show` starts no timer. When a `task.merge_started`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: is merging." (or "is merged."/"is discarded."). The lead's guidance tells it to show the next thing that needs the human on that line, or, when nothing does, the project's Project tab with a short status in chat: one line per active task, then up to three TODO items it would start next, each with a reason, starting one only when the human picks it. It writes each item as a link to `#/todo//`; 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///`, 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 from one request, with no lead turn in between; `discuss` sends the lead `POST /api/me/lead/messages` asking to discuss the suggestion, quoting its title and brief (with the user's default model only when the user has no live lead session). Once an active task in that project has the suggestion's title, it shows that task's state the same way. A chat view refills them whenever its transcript grows or tasks or sessions change. When that status says nothing new since its last one, it replies `NO_REPLY` instead. The app sends `PUT /api/me/pane-focus` with `focused` and `drafting` whenever either changes (`PaneFocus` in `mac/Sources/Byre/PaneFocus.swift`): `focused` is true while the window is key, the app is active and the keyboard focus or the last click is inside the right pane, so a click anywhere in it counts, not only on inputs and buttons; `drafting` while any text field in the right pane other than the terminal holds non-blank text. The window reports to the server it shows. It sends false on deactivating, switching to another server, Forget Server, closing the window and quit, and resends true when its event stream reconnects. byre keeps both flags and the time of the last report in memory per user (`Manager.paneFocus`, `Manager.paneReported`), so they start false after a restart until the app resends them. - **Move hold.** Each `PUT /api/me/focus` from the user (switching task or tab, by any means: the switcher, a tab, a link or a notification) records the time in `Manager.humanMoved`, and for 10 seconds after it (`moveHold`) every `show` that would change the view is refused, whatever the pane lock above would allow, with an error giving how long ago the move was. A `show` of what the pane already shows changes nothing and says so. A refusal also marks the user in `Manager.moveRefused`, which each move by the user clears. When the 10 seconds pass without another move by the user (`endMoveHold`, on a timer), byre clears both and, only if a `show` was refused since that move, sends the lead the signal "The move hold is over: …"; its guidance tells it to show what it held back if the pane's other rules allow, or reply `NO_REPLY`. Because only a refusal arranges the signal, the lead's system prompt and the `show` tool's description tell it to call `show` at once whenever the user asks to see something or it wants to show something, never holding back in expectation of a refusal. It is in memory, so a restart clears it. - **Start hold.** Start on a TODO item in the Project tab leaves the pane where it is. When the user starts one themselves (`StartTodo` with the user as actor, not the lead's `start_todo`), byre records the time in `Manager.startedTodo` for them, and for 10 seconds after it (`startHold`), while the pane's selected tab is `project`, every `show` that would change the view is refused, focused or not, with an error telling the lead the human is likely to start more. A refusal also marks the user in `Manager.startRefused`. The user's own `PUT /api/me/focus` (switching task or tab) or a message to the lead clears both, and another Start restarts the hold and clears the mark. The refusal and the `show` tool's description both tell the lead the hold lasts 10 seconds. When the 10 seconds pass without it being cleared or restarted (`endStartHold`, on a timer), byre clears both and, only if a `show` was refused since that Start and the pane's selected tab is still `project`, sends the lead the signal "The start hold is over: …" so it can show what it held back; its guidance tells it to reply `NO_REPLY` when that no longer matters. It is in memory, so a restart clears it. - **Compaction.** The pane's **Compact** button asks the lead for a handoff (`leadHandoffPrompt`). The lead can't see its own context size, so the first time one of its turns ends (other than a handoff or a `compact_self` turn) with the latest `session.context` `used` at a quarter of its `window` or more, byre holds a signal for it giving the percentage and asking it to call `compact_self` at the next logical point (`warnLeadContext` in `internal/agents/lead.go`); it is sent once per live process, so a byre restart can send it again once the window is known. The lead calls `compact_self`; when that turn ends normally, byre sends the same request. The handoff turn ends the session, and a new lead session starts from `leadHandoffPreamble` plus the handoff; held input follows once that turn ends. diff --git a/internal/agents/manager.go b/internal/agents/manager.go index 33dcf8c..e6d097e 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -380,6 +380,20 @@ func (m *Manager) Recover(ctx context.Context) error { continue } + pending, err := m.store.SessionPending(ctx, sess.Id) + if err != nil { + return err + } + if pending { + found, err := m.deliverRecorded(ctx, sess, task) + if err != nil { + slog.Error("resume session: deliver its recorded message", "session", sess.Id, "task", task.Id, "err", err) + } + if found { + continue + } + } + l, err := m.ensureLive(ctx, sess, task) if err != nil { ferr := m.failResume(ctx, sess, task, err) @@ -399,7 +413,7 @@ func (m *Manager) Recover(ctx context.Context) error { notice = discardRestartPrompt } } - err = m.message(ctx, l, "byre", notice, nil) + err = m.message(ctx, sess, task, l, "byre", notice, nil) if err != nil { ferr := m.failResume(ctx, sess, task, err) if ferr != nil { @@ -566,28 +580,67 @@ func (m *Manager) startRunning(ctx context.Context, sess api.AgentSession, taskI } 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) + if err != nil { + m.failStart(ctx, sess.Id, actor, err) + return api.AgentSession{}, err + } + err = m.deliver(ctx, sess, task, actor, text, imgs, compacting) + if err != nil { + return api.AgentSession{}, err + } + return m.store.GetSession(ctx, sess.Id) +} + +func (m *Manager) deliver(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, imgs []image, compacting bool) error { l, err := m.ensureLive(ctx, sess, task) if err == nil { err = m.syncModel(ctx, l, sess.Model) } if err != nil { m.failStart(ctx, sess.Id, actor, err) - return api.AgentSession{}, err + return err } - imgs, err := m.prepareMessage(ctx, l, actor, text, images) - if err != nil { - m.failStart(ctx, sess.Id, actor, err) - return api.AgentSession{}, err - } - - err = l.sess.send(ctx, text, imgs) + m.beginTurn(l, compacting) + err = m.send(ctx, l, text, imgs) if err != nil { m.endMergeTurn(sess.Id, false) - return api.AgentSession{}, err + return err } + return nil +} - return m.store.GetSession(ctx, sess.Id) +func (m *Manager) deliverRecorded(ctx context.Context, sess api.AgentSession, task api.Task) (bool, error) { + ev, found, err := m.store.LastMessage(ctx, sess.Id) + if err != nil || !found { + return false, err + } + text, _ := ev.Payload["text"].(string) + ids := []string{} + raw, _ := ev.Payload["images"].([]any) + for _, id := range raw { + s, _ := id.(string) + ids = append(ids, s) + } + imgs, err := m.readImages(ctx, ids) + if err != nil { + m.failStart(ctx, sess.Id, ev.Actor, err) + return true, err + } + return true, m.deliver(ctx, sess, task, ev.Actor, text, imgs, isHandoffPrompt(ev.Actor, text)) +} + +func (m *Manager) readImages(ctx context.Context, ids []string) ([]image, error) { + imgs := []image{} + for _, id := range ids { + b, data, err := m.store.ReadBlob(ctx, id) + if err != nil { + return nil, err + } + imgs = append(imgs, image{mediaType: b.ContentType, data: data}) + } + return imgs, nil } func (m *Manager) failStart(ctx context.Context, sessionID, actor string, err error) { @@ -625,32 +678,38 @@ func transition(ctx context.Context, st *store.Store, id string, from, to api.Ag return err } -func (m *Manager) message(ctx context.Context, l *live, actor, text string, images []string) error { - imgs, err := m.prepareMessage(ctx, l, actor, text, images) +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) if err != nil { return err } - return l.sess.send(ctx, text, imgs) + m.beginTurn(l, compacting) + return m.send(ctx, l, text, imgs) } -func (m *Manager) prepareMessage(ctx context.Context, l *live, actor, text string, images []string) ([]image, error) { - if l.implementer() && isMergeInPrompt(actor, text) { - m.takeMergeTurn(l) +func (m *Manager) send(ctx context.Context, l *live, text string, imgs []image) error { + err := l.sess.send(ctx, text, imgs) + if err != nil { + return err } - imgs := []image{} - for _, id := range images { - b, data, err := m.store.ReadBlob(ctx, id) - if err != nil { - return nil, err - } - imgs = append(imgs, image{mediaType: b.ContentType, data: data}) + 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) { + implementer := sess.Role == api.Implementer + if implementer && isMergeInPrompt(actor, text) { + m.ownMerge(sess.Id, task.Id, task.WorktreePath) + } + imgs, err := m.readImages(ctx, images) + if err != nil { + return nil, false, err } payload := map[string]any{"text": text} - if l.implementer() { - head, err := git.Head(l.dir) + if implementer { + head, err := git.Head(task.WorktreePath) if err != nil { - return nil, err + return nil, false, err } payload["head"] = head } @@ -664,11 +723,14 @@ func (m *Manager) prepareMessage(ctx context.Context, l *live, actor, text strin if isContinuation(actor, text) { payload["continuation"] = true } - err := m.emit(ctx, l, actor, "user.message", payload) + 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, err + return nil, false, err } + return imgs, compacting, nil +} +func (m *Manager) beginTurn(l *live, compacting bool) { m.mu.Lock() l.interrupted = false l.asked = false @@ -678,8 +740,6 @@ func (m *Manager) prepareMessage(ctx context.Context, l *live, actor, text strin l.rateLimited = nil l.lastText = "" m.mu.Unlock() - - return imgs, nil } func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task api.Task) (*live, error) { @@ -1357,18 +1417,22 @@ func (m *Manager) emit(ctx context.Context, l *live, actor, kind string, payload } func (m *Manager) event(l *live, actor, kind string, payload map[string]any) api.Event { + return newEvent(l.id, l.owner, l.projectID, l.taskID, actor, kind, payload) +} + +func newEvent(sessionID, owner, projectID, taskID, actor, kind string, payload map[string]any) api.Event { ev := api.Event{ Kind: kind, - AgentSessionId: &l.id, + AgentSessionId: &sessionID, Actor: actor, Payload: payload, } - if l.owner != "" { - ev.Owner = &l.owner + if owner != "" { + ev.Owner = &owner return ev } - ev.ProjectId = &l.projectID - ev.TaskId = &l.taskID + ev.ProjectId = &projectID + ev.TaskId = &taskID return ev } diff --git a/internal/agents/mergein.go b/internal/agents/mergein.go index ccf1efd..4a5c4d0 100644 --- a/internal/agents/mergein.go +++ b/internal/agents/mergein.go @@ -189,6 +189,10 @@ 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) + if err != nil { + return err + } l, err := m.ensureLive(ctx, sess, task) if err != nil { return err @@ -197,10 +201,7 @@ func (m *Manager) deliverMerge(ctx context.Context, sess api.AgentSession, task if err != nil { return err } - _, err = m.prepareMessage(ctx, l, "byre", prompt, nil) - if err != nil { - return err - } + m.beginTurn(l, compacting) m.mu.Lock() l.fetching = true m.mu.Unlock() @@ -218,7 +219,7 @@ func (m *Manager) deliverMerge(ctx context.Context, sess api.AgentSession, task if interrupted { return errors.New("interrupted before the merge started") } - return l.sess.send(ctx, prompt, nil) + return m.send(ctx, l, prompt, nil) } func (m *Manager) recoverMerges(ctx context.Context) error { diff --git a/internal/agents/tasks.go b/internal/agents/tasks.go index ad35f81..e1c840f 100644 --- a/internal/agents/tasks.go +++ b/internal/agents/tasks.go @@ -26,10 +26,24 @@ func (m *Manager) StartTask(ctx context.Context, project api.Project, creator ap if err != nil { return api.Task{}, err } - _, err = m.create(ctx, task, creator, "", prompt, model, creator.Login, images) + sess, err := m.newSession(ctx, task, creator, "", model) + if err == nil { + err = m.startRunning(ctx, sess, task.Id, creator.Login) + } 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) + 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) + } + go func() { + err := m.deliver(context.Background(), sess, task, creator.Login, prompt, imgs, compacting) + if err != nil { + slog.Error("start task agent", "task", task.Id, "err", err) + } + }() return task, nil } diff --git a/internal/store/migrations/067-add-session-started.sql b/internal/store/migrations/067-add-session-started.sql new file mode 100644 index 0000000..9dcaa31 --- /dev/null +++ b/internal/store/migrations/067-add-session-started.sql @@ -0,0 +1,3 @@ +ALTER TABLE agent_sessions ADD COLUMN started INTEGER NOT NULL DEFAULT 0; +ALTER TABLE agent_sessions ADD COLUMN pending INTEGER NOT NULL DEFAULT 0; +UPDATE agent_sessions SET started = 1 WHERE id IN (SELECT agent_session_id FROM events WHERE kind = 'user.message' AND agent_session_id IS NOT NULL); diff --git a/internal/store/store.go b/internal/store/store.go index 8bda09e..efea8ff 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1006,6 +1006,21 @@ func (s *Store) FirstPrompt(ctx context.Context, taskID string) (string, error) return text, err } +func (s *Store) LastMessage(ctx context.Context, sessionID string) (api.Event, bool, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT `+eventColumns+` + FROM events WHERE agent_session_id = ? AND kind = 'user.message' ORDER BY seq DESC LIMIT 1 + `, sessionID) + if err != nil { + return api.Event{}, false, err + } + evs, err := scanEvents(rows) + if err != nil || len(evs) == 0 { + return api.Event{}, false, err + } + return evs[0], true, nil +} + func (s *Store) BoardPosts(ctx context.Context, taskID string, after int64) ([]api.Event, error) { rows, err := s.db.QueryContext(ctx, ` SELECT `+eventColumns+` @@ -1330,10 +1345,37 @@ func (s *Store) SetSubagents(ctx context.Context, id string, n int, actor string func (s *Store) SessionStarted(ctx context.Context, sessionID string) (bool, error) { started := false - err := s.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM events WHERE agent_session_id = ? AND kind = 'user.message')`, sessionID).Scan(&started) + err := s.db.QueryRowContext(ctx, `SELECT started FROM agent_sessions WHERE id = ?`, sessionID).Scan(&started) + if errors.Is(err, sql.ErrNoRows) { + return false, ErrNotFound + } return started, err } +func (s *Store) SessionPending(ctx context.Context, sessionID string) (bool, error) { + pending := false + err := s.db.QueryRowContext(ctx, `SELECT pending FROM agent_sessions WHERE id = ?`, sessionID).Scan(&pending) + if errors.Is(err, sql.ErrNoRows) { + return false, ErrNotFound + } + return pending, err +} + +func (s *Store) RecordTurnMessage(ctx context.Context, sessionID string, ev api.Event) error { + return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { + _, err := tx.ExecContext(ctx, `UPDATE agent_sessions SET pending = 1 WHERE id = ?`, sessionID) + if err != nil { + return err + } + return emit(ev) + }) +} + +func (s *Store) MarkSessionDelivered(ctx context.Context, sessionID string) error { + _, err := s.db.ExecContext(ctx, `UPDATE agent_sessions SET started = 1, pending = 0 WHERE id = ?`, sessionID) + return err +} + type Turn struct { Actor string At time.Time