From 78cca2fcbe80bdf040a6d85922273aa672bdf9fb Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 16 Oct 2026 08:35:49 -0700 Subject: [PATCH] Make every question blocking and drop inbox priority --- TODO.md | 2 +- api/openapi.yaml | 16 +- docs/operations.md | 16 +- internal/agents/guidance.go | 2 +- internal/agents/inbox.go | 39 +--- internal/agents/lead.go | 4 +- internal/agents/leadtools.go | 15 +- internal/agents/manager.go | 6 +- internal/api/api.gen.go | 378 ++++++++++++++++------------------ internal/store/inbox.go | 27 +-- internal/store/store.go | 39 ++++ web/dist/inbox.js | 2 +- web/dist/inbox.js.map | 2 +- web/dist/notifications.js | 2 +- web/dist/notifications.js.map | 2 +- web/dist/settings.js | 3 +- web/dist/settings.js.map | 2 +- web/dist/task.js | 2 +- web/dist/task.js.map | 2 +- web/src/api.gen.ts | 11 +- web/src/inbox.ts | 3 +- web/src/notifications.ts | 2 +- web/src/settings.ts | 3 +- web/src/task.ts | 2 +- web/style.css | 8 - 25 files changed, 268 insertions(+), 322 deletions(-) diff --git a/TODO.md b/TODO.md index 94834a2..ccb6019 100644 --- a/TODO.md +++ b/TODO.md @@ -32,7 +32,7 @@ # M7 — Inbox triage - Human-facing task state - Switcher and Project rows show one derived label instead of the raw session state, by precedence: "Needs your answer" (a blocked or gate item), "Merging" or "Merge held", "Error", "Working", "Idle". + Switcher and Project rows show one derived label instead of the raw session state, by precedence: "Needs your answer" (an open inbox item), "Merging" or "Merge held", "Error", "Working", "Idle". - Unread summary marker Switcher and Project rows show a "New summary" marker, with text and an accessible label, when a task has a summary the user hasn't read. Selecting it opens the Transcript at that summary. Read state is per browser and per user, by event seq; a summary counts as read only when shown in a visible transcript or through "Mark seen", not by opening another tab or switching tasks. Task carries its latest summary seq so the marker survives reload without loading transcripts. - Answer refinements diff --git a/api/openapi.yaml b/api/openapi.yaml index 5a4ea8a..8d8d636 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -715,7 +715,7 @@ paths: get: operationId: listInbox description: > - Inbox items, highest priority first (blocked, gate, review, fyi), then oldest first. + Inbox items, oldest first. Filters narrow the one query to a project's or a task's view. parameters: - { name: project_id, in: query, required: false, schema: { type: string } } @@ -748,7 +748,7 @@ paths: operationId: respondInbox description: > Answers one or more items in one submission. Answers reach the agent as one message once - it is idle and none of its blocking questions are unanswered. + it is idle and none of its questions are unanswered. requestBody: required: true content: @@ -873,10 +873,9 @@ components: NotificationPrefs: type: object description: Which new inbox items and finished turns notify this user in their open byre pages. - required: [blocking_questions, questions, reports, errors, rate_limits] + required: [questions, reports, errors, rate_limits] properties: - blocking_questions: { type: boolean, description: "Questions asked with blocking: true. On by default." } - questions: { type: boolean, description: Non-blocking questions. } + questions: { type: boolean, description: Questions. On by default. } reports: { type: boolean, description: "Task turns that end with a summary (turn.ended with summary: true)." } errors: { type: boolean, description: session_error items. } rate_limits: { type: boolean, description: rate_limited items. } @@ -1219,7 +1218,7 @@ components: InboxItem: type: object - required: [id, project_id, task_id, agent_session_id, thread_id, kind, priority, blocking, payload, state, created_at, responses, reviews] + required: [id, project_id, task_id, agent_session_id, thread_id, kind, payload, state, created_at, responses, reviews] properties: id: { type: string } project_id: { type: string } @@ -1227,11 +1226,6 @@ components: agent_session_id: { type: string } thread_id: { type: string } kind: { type: string, enum: [question, session_error, rate_limited] } - priority: - type: string - enum: [blocked, gate, review, fyi] - x-enum-varnames: [Blocked, Gate, ReviewPriority, Fyi] - blocking: { type: boolean } payload: type: object description: > diff --git a/docs/operations.md b/docs/operations.md index 97f99fe..b3d62d9 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -104,11 +104,11 @@ A session runs on one of two vendors, picked by the model it is created with (`w 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`. 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 `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 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 blocking question's context since that turn has no summary, don't commit (byre commits each turn), and don't push, touch other branches or run git maintenance. A task agent's guidance also names its port range and tells it to do the setup AGENTS.md describes in a new worktree (see "Worktree setup and ports"). 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"). 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`. 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). +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), and don't push, touch other branches or run git maintenance. A task agent's guidance also names its port range and tells it to do the setup AGENTS.md describes in a new worktree (see "Worktree setup and ports"). 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"). 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`. 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). A turn whose final message asks the human something in prose is bounced once (`takeBounce` in `internal/agents/guidance.go`): if `ask_human` wasn't called during the turn and the final message has a sentence ending in `?` outside code spans, code blocks, quotes and blockquotes, the agent is told to ask through `ask_human` and carries on. For Claude this is a Stop hook (`bounceProseQuestion`) that blocks the stop; Claude Code reports the block as a "Stop hook error" notification, which is expected. For Codex, byre doesn't end its turn on `turn/completed`: it records the nudge as a `byre` `user.message` with `mid_turn: true` and starts another Codex turn with it, and only that turn's end ends byre's turn. The per-turn flags reset when the next user message is sent. An agent told explicitly to ask in plain text will refuse the bounce. -A turn's final message is its summary, and it lives only in the transcript: nothing is filed in the inbox, so the human reads it once. When a task turn ends normally (not an error, not interrupted, not a handoff) with non-empty `result` text and didn't call `ask_human` with `blocking` true, its `turn.ended` event carries `summary: true`. That flag drives the lead's "finished a turn" signal and the "Turn summaries" notification (see "Lead agent" and "Notifications"). A turn that asked a blocking question has no summary, since the final message comes after the question it explains; the guidance tells agents to put everything the human needs in the question's context. There is no report tool: models write a closing recap whatever they are told, so byre uses it rather than asking for a second one. +A turn's final message is its summary, and it lives only in the transcript: nothing is filed in the inbox, so the human reads it once. When a task turn ends normally (not an error, not interrupted, not a handoff) with non-empty `result` text and didn't call `ask_human`, its `turn.ended` event carries `summary: true`. That flag drives the lead's "finished a turn" signal and the "Turn summaries" notification (see "Lead agent" and "Notifications"). A turn that asked a question has no summary, since the final message comes after the question it explains; the guidance tells agents to put everything the human needs in the question's context. There is no report tool: models write a closing recap whatever they are told, so byre uses it rather than asking for a second one. Every turn starts as soon as it is sent; byre doesn't cap how many run at once. Whether a turn resumes the Claude conversation is decided by whether the session has ever sent one. @@ -165,9 +165,9 @@ The **Project** tab is the dashboard of the right pane's project (the focused ta ## Inbox -Agents ask questions with the `ask_human` MCP tool. Each question carries a `context` with the background the human needs to answer without reading the transcript, and its choices are written inline in that context as Markdown list items: `- ( ) text` for pick-one choices, with exactly one `- (*) text` marking the recommendation, or `- [ ] text` for pick-any choices, with `- [x] text` marking the recommended ones. There is no separate list of options. byre parses the choices out of `context` (`internal/agents/choices.go`; list items inside code fences and blockquotes don't count) and stores their text in the payload as `options`, with `multiple` (pick-any) and `recommended`. The UI renders the context as Markdown with a radio button or checkbox in place of each choice's marker. The tool's description tells agents to keep everything about one choice (what it does, its example, code, trade-offs) in lines indented beneath that choice rather than in text above the list; those lines render inside the option, and only the marker line's text is the option. A malformed call (no `context` or `why_blocked`, fewer than two choices, pick-one and pick-any choices mixed, a pick-one question without exactly one recommendation, two choices with the same text, an unknown attachment kind, a foreign `thread_id`) is rejected with a message saying what to fix, and nothing is filed. The call returns the item's id as `question_id`. An agent withdraws a question it no longer needs answered (the human answered it in a chat message, say) with the `cancel_question` MCP tool and that id: an `open` question of its own session becomes `dismissed` with no `resolved_by` (its `inbox.updated` event has actor `agent`), which also stops a blocking one from holding back delivery; anything else is rejected with a message saying why. Each agent error (a turn that ends in error without a human interrupt, a process that dies mid-turn) also files an item. +Agents ask questions with the `ask_human` MCP tool. Each question carries a `context` with the background the human needs to answer without reading the transcript, and its choices are written inline in that context as Markdown list items: `- ( ) text` for pick-one choices, with exactly one `- (*) text` marking the recommendation, or `- [ ] text` for pick-any choices, with `- [x] text` marking the recommended ones. There is no separate list of options. byre parses the choices out of `context` (`internal/agents/choices.go`; list items inside code fences and blockquotes don't count) and stores their text in the payload as `options`, with `multiple` (pick-any) and `recommended`. The UI renders the context as Markdown with a radio button or checkbox in place of each choice's marker. The tool's description tells agents to keep everything about one choice (what it does, its example, code, trade-offs) in lines indented beneath that choice rather than in text above the list; those lines render inside the option, and only the marker line's text is the option. A malformed call (no `context` or `why_blocked`, fewer than two choices, pick-one and pick-any choices mixed, a pick-one question without exactly one recommendation, two choices with the same text, an unknown attachment kind, a foreign `thread_id`) is rejected with a message saying what to fix, and nothing is filed. All questions are alike: the tool's description and its result tell the agent to end its turn after asking, and the answer arrives as its next message. The call returns the item's id as `question_id`. An agent withdraws a question it no longer needs answered (the human answered it in a chat message, say) with the `cancel_question` MCP tool and that id: an `open` question of its own session becomes `dismissed` with no `resolved_by` (its `inbox.updated` event has actor `agent`), which also stops it from holding back delivery; anything else is rejected with a message saying why. Each agent error (a turn that ends in error without a human interrupt, a process that dies mid-turn) also files an item. -`GET /api/inbox` is one query over open items, oldest first; `project_id` and `task_id` narrow it to a project or task view, and `state` picks another state. Blocking questions and errors are `blocked`; non-blocking questions are `fyi`. The Inbox tab is task-specific: it shows one of the selected task's open items at a time, the oldest, and the next one once that is answered or dismissed; it shows nothing when no task is selected. The tab is hidden while the task has no open items, unless it is selected. While the selected task has open items, the Inbox tab's button is highlighted in the warn colours (`.attention` in `web/style.css`) and carries their count. Answering or dismissing the task's last open item from the Inbox tab switches the pane to the Transcript tab. The other way round, a new question from the selected task arriving while the Transcript tab is shown switches the pane to the Inbox tab; questions already open when the page loaded don't. +`GET /api/inbox` is one query over open items, oldest first; `project_id` and `task_id` narrow it to a project or task view, and `state` picks another state. The Inbox tab is task-specific: it shows one of the selected task's open items at a time, the oldest, and the next one once that is answered or dismissed; it shows nothing when no task is selected. The tab is hidden while the task has no open items, unless it is selected. While the selected task has open items, the Inbox tab's button is highlighted in the warn colours (`.attention` in `web/style.css`) and carries their count. Answering or dismissing the task's last open item from the Inbox tab switches the pane to the Transcript tab. The other way round, a new question from the selected task arriving while the Transcript tab is shown switches the pane to the Inbox tab; questions already open when the page loaded don't. `POST /api/inbox/responses` answers any number of items in one submission. Response types and what they do to the item: @@ -177,13 +177,13 @@ Agents ask questions with the `ask_human` MCP tool. Each question carries a `con | `other` (free text in `text`: an answer, a question back, or new instructions) | questions, errors | `answered` | | `dismiss` | errors | `dismissed` | -Answers reach the agent as one message holding every undelivered `option` and `other` response of the session (`deliveryMessage` in `internal/agents/inbox.go`), never while one of its blocking questions is still open; until then they stay held (`inbox_responses.delivered_at` empty). While a turn is running, the task session's PostToolUse hook (`afterTool` in `internal/agents/snapshots.go`, through `deliverMidTurn`) hands them over after the agent's next tool call as the hook's `additionalContext`, after any queued messages (see "Agent sessions"), so the agent reads them alongside that tool's result without being interrupted (for Codex, after each completed tool item, `deliverMidTurn` hands them over with `turn/steer` instead); the `user.message` event recording it carries `mid_turn: true` and no `head`, and the transcript doesn't treat it as a turn start. A turn that makes no further tool call leaves them to `Deliver`, which runs after each submission and when a turn ends, and sends them as a new turn once the session is `idle`. The log traces each answer: `inbox response recorded` when it is stored, `deliver held` with the reason, `deliver sent mid-turn`, `deliver sent` with the session's state and whether its agent process had exited or byre was closing, and `undelivered responses` with a reason wherever a session has held answers when its turn ends in a handoff or merge, its agent process ends, startup resumes it, or startup finds it idle. `sqlite3 byre.db "SELECT * FROM inbox_responses WHERE delivered_at IS NULL"` lists the answers held now. +Answers reach the agent as one message holding every undelivered `option` and `other` response of the session (`deliveryMessage` in `internal/agents/inbox.go`), never while one of its questions is still open and unanswered (`AwaitingHuman` in `internal/store/inbox.go`); until then they stay held (`inbox_responses.delivered_at` empty). While a turn is running, the task session's PostToolUse hook (`afterTool` in `internal/agents/snapshots.go`, through `deliverMidTurn`) hands them over after the agent's next tool call as the hook's `additionalContext`, after any queued messages (see "Agent sessions"), so the agent reads them alongside that tool's result without being interrupted (for Codex, after each completed tool item, `deliverMidTurn` hands them over with `turn/steer` instead); the `user.message` event recording it carries `mid_turn: true` and no `head`, and the transcript doesn't treat it as a turn start. A turn that makes no further tool call leaves them to `Deliver`, which runs after each submission and when a turn ends, and sends them as a new turn once the session is `idle`. The log traces each answer: `inbox response recorded` when it is stored, `deliver held` with the reason, `deliver sent mid-turn`, `deliver sent` with the session's state and whether its agent process had exited or byre was closing, and `undelivered responses` with a reason wherever a session has held answers when its turn ends in a handoff or merge, its agent process ends, startup resumes it, or startup finds it idle. `sqlite3 byre.db "SELECT * FROM inbox_responses WHERE delivered_at IS NULL"` lists the answers held now. ## Notifications An open byre page shows a browser notification for each new inbox item, raised by the page itself from the `inbox.created` events on its SSE stream (`notifyInbox` in `web/src/notifications.ts`). With no byre page open, nothing notifies. byre has no service worker and never uses one; on load the page unregisters any worker left in the browser from earlier versions and, if one was controlling the page, reloads. -- **Preferences.** `users.notification_prefs` holds which item kinds notify the user, returned as `notification_prefs` on `GET /api/me` and set with `PUT /api/me/notifications`: `blocking_questions` (on by default), `questions` (non-blocking), `reports` (task turns that end with `summary: true` on `turn.ended`, labelled "Turn summaries"), `errors` (`session_error`) and `rate_limits`. The page checks them against each item or `turn.ended` event as it arrives. +- **Preferences.** `users.notification_prefs` holds which item kinds notify the user, returned as `notification_prefs` on `GET /api/me` and set with `PUT /api/me/notifications`: `questions` (on by default), `reports` (task turns that end with `summary: true` on `turn.ended`, labelled "Turn summaries"), `errors` (`session_error`) and `rate_limits`. The page checks them against each item or `turn.ended` event as it arrives. - **Content.** The title is `byre · ` and the body the question (or the turn summary's first line, the error, or "Rate limited"), cut at 200 characters. The notification's tag is the item's ID, or `turn-` for a turn summary, so several byre pages open in one browser show it once. - **Clicking** a notification focuses the page and points the right pane at the item's task. @@ -191,7 +191,7 @@ The left pane's **Settings** tab asks the browser for notification permission an The Inbox tab shows one item at a time, the task's oldest, filling the tab without a card frame; the next appears when it is answered. Each item is answered on its own; there is no batch submission. A pick-one question's choices are clickable blocks, and clicking one sends that answer at once. A choice's block (or a pick-any choice's checkbox label) holds the whole list item: its first line, which is the choice's text, and everything under it, such as an example's paragraph or code block. A pick-any question's choices are checkboxes, sent with the Submit button at the bottom of the card. Below the choices, an "Other…" block in the same style opens the only text field. A card has at most one Submit button: on a pick-any question it sends the checked choices with the Other text alongside (one `option` response), or the Other text alone as `other` when nothing is checked. An error has a "Reply…" block (`other`) and a Dismiss button. The Other and Reply text boxes send with the Submit button under them or on Enter (Shift+Enter for a newline), like the message box, and fold back into their block when left empty. The list updates in place: a card stays attached while its item is unchanged, so task and inbox updates from other agents don't pull focus out of a text box being typed in. -There is no snoozing. The `snoozed_until` and `snooze_until` columns are still in the schema, always empty, so builds from older branches keep working against the same database; nothing reads them. +There is no snoozing. The `snoozed_until` and `snooze_until` columns are still in the schema, always empty, so builds from older branches keep working against the same database; nothing reads them. For the same reason `inbox_items` keeps its `priority` and `blocking` columns, which nothing reads or writes; new rows take their defaults (0 and 1, which older builds read as a blocking question). Every Markdown view (inbox items and the transcript) renders a line `
Title` (or `
` with the `` on the next line), Markdown content, and a line `
` as a collapsed section (`web/src/markdown.ts`; sections nest) showing only the title; session guidance and the `ask_human` schema tell agents to put long supporting detail in them. @@ -199,7 +199,7 @@ Markdown images (`![alt](src)`, or `![alt]()`; the plain form also accepts A code block fenced as `mermaid`, and an inbox `diagram` attachment, render as a Mermaid diagram (`web/src/mermaid.ts`). The library is vendored: `web/package.json` pins `mermaid` as a devDependency, and `go generate ./web` copies `node_modules/mermaid/dist/mermaid.min.js` and its LICENSE into `web/vendor/`, which is committed and embedded. To upgrade, change the pinned version, `npm --prefix web install`, and regenerate. Nothing loads it until the first diagram is drawn; that injects one `