Add a per-user lead agent and make the right pane task-focused
This commit is contained in:
+28
-14
@@ -7,16 +7,16 @@
|
||||
- `internal/store`: SQLite (WAL). Every mutation and its event commit in one transaction; events are published to SSE subscribers in `seq` order.
|
||||
- `internal/server`: HTTP handlers, identity, SSE, the worktree file browser (see "Files").
|
||||
- `internal/git`: worktrees, the task changes view (see "Changes"), snapshots (see "Snapshots"), unmerged-change summaries, squash merges and pushes.
|
||||
- `internal/agents`: the agent session manager. It owns the running Claude processes and records their activity as events.
|
||||
- `internal/agents`: the agent session manager. It owns the running Claude processes, task agents and lead agents alike (see "Lead agent"), and records their activity as events.
|
||||
- `internal/claude`: the Claude Code stream-json client (see `docs/claude-protocol.md`).
|
||||
- `internal/push`: web push notifications for new inbox items (see "Notifications").
|
||||
- `web/`: the SPA, frameworkless TypeScript compiled file-by-file to ES modules (no bundler). `web/src/*.ts` is the source; `web/src/api.gen.ts` holds types generated from `api/openapi.yaml`, and `web/src/api.ts` is the typed client over them. `go generate ./web` runs `npm ci`, regenerates the types and compiles `web/dist/`, which is committed so `go install` needs no Node. The binary embeds `index.html`, `style.css`, `dist/`, `src/` (for source maps), `fonts/` and `vendor/` (Mermaid; see "Inbox"). `byre serve --dev` serves `./web` from disk instead (run from the repo root); pair it with `npm --prefix web exec tsc -- --watch`.
|
||||
|
||||
## Data
|
||||
|
||||
State lives in the `byre` directory under Go's `os.UserConfigDir()` (on macOS, `~/Library/Application Support/byre`): `byre.db`, `worktrees/<project-id>/<task-id>`, and `blobs/<blob-id>` (uploaded images; the `blobs` table maps each id to its content type and its path relative to this directory). Task branches are `byre/<task-id>` in the project's repo.
|
||||
State lives in the `byre` directory under Go's `os.UserConfigDir()` (on macOS, `~/Library/Application Support/byre`): `byre.db`, `worktrees/<project-id>/<task-id>`, `leads/<login>` (each lead agent's working directory, the login path-escaped), and `blobs/<blob-id>` (uploaded images; the `blobs` table maps each id to its content type and its path relative to this directory). Task branches are `byre/<task-id>` in the project's repo.
|
||||
|
||||
Schema changes are the `migrations` slice in `internal/store/store.go`: named entries, each with a short kebab-case name saying what it does. On open, byre applies, in slice order, every entry whose name isn't in the `migrations` table, each in its own transaction that also records the name. byre is deployed from task branches that each append their own migrations, so after a merge a migration can land before others the database has already applied, and it still runs. Give a new migration a name no other branch will use, and make any data migration safe to run twice. `sqlite3 byre.db 'SELECT * FROM migrations'` lists what has been applied. A database from before the table existed is moved over on open: the first `PRAGMA user_version` entries are recorded as applied, and `user_version` is no longer read or written.
|
||||
Schema changes are the `migrations` slice in `internal/store/store.go`: named entries, each with a short kebab-case name saying what it does. On open, byre applies, in slice order, every entry whose name isn't in the `migrations` table, each in its own transaction that also records the name. Each runs with foreign keys off, so a migration can rebuild a table other tables reference, and `PRAGMA foreign_key_check` must come back empty before it commits. byre is deployed from task branches that each append their own migrations, so after a merge a migration can land before others the database has already applied, and it still runs. Give a new migration a name no other branch will use, and make any data migration safe to run twice. `sqlite3 byre.db 'SELECT * FROM migrations'` lists what has been applied. A database from before the table existed is moved over on open: the first `PRAGMA user_version` entries are recorded as applied, and `user_version` is no longer read or written.
|
||||
|
||||
## Closing a task
|
||||
|
||||
@@ -79,13 +79,13 @@ A Stop hook (`bounceProseQuestion` in `internal/agents/guidance.go`) bounces a t
|
||||
|
||||
A turn's final message is its summary. When a turn ends normally (not an error, not interrupted) with non-empty `result` text, byre files that text as a `report` inbox item. 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.
|
||||
|
||||
Running turns are capped globally and per vendor (`DefaultLimits` in `internal/agents/manager.go`). A turn that would exceed a cap leaves its session `queued`, with the message held in the `turn_queue` table; whenever a turn ends, a process exits, or a task is merged or discarded, the oldest queued turns whose vendor has room start. Interrupting a `queued` session cancels its queued turn. Idle sessions with a live process don't count against the caps. Whether a turn resumes the Claude conversation is decided by whether the session has ever sent one.
|
||||
Running turns of task agents are capped globally and per vendor (`DefaultLimits` in `internal/agents/manager.go`); lead agents' turns always start and don't count. A turn that would exceed a cap leaves its session `queued`, with the message held in the `turn_queue` table; whenever a turn ends, a process exits, or a task is merged or discarded, the oldest queued turns whose vendor has room start. Interrupting a `queued` session cancels its queued turn. Idle sessions with a live process don't count against the caps. Whether a turn resumes the Claude conversation is decided by whether the session has ever sent one.
|
||||
|
||||
When Claude Code reports a rate-limit status of `rejected` and the turn then fails, the inbox gets a `rate_limited` item carrying the vendor's status (including when the limit resets) instead of a generic error.
|
||||
|
||||
## Admin view
|
||||
## Status
|
||||
|
||||
`GET /api/admin/status` reports `running_turns` (sessions now `running`) and `queued_turns` (rows of `turn_queue`, in the order they will start), each as session, task and vendor; `caps`, the global and per-vendor limits from `DefaultLimits`; `rate_limits`, the latest `rate_limit_info` each vendor sent since byre started, unchanged; `agent_processes`, `zombies`, `stuck_after_seconds` and `protocol_errors` (below); and server counts (start time, agent processes, projects, active tasks, SSE clients, database size including WAL, last event seq). A turn's queue row is written before its session's `queued` state event, so a refetch triggered by that event sees it.
|
||||
`GET /api/admin/status` reports `running_turns` (task sessions now `running`; lead turns aren't capped and aren't listed) and `queued_turns` (rows of `turn_queue`, in the order they will start), each as session, task and vendor; `caps`, the global and per-vendor limits from `DefaultLimits`; `rate_limits`, the latest `rate_limit_info` each vendor sent since byre started, unchanged; `agent_processes`, `zombies`, `stuck_after_seconds` and `protocol_errors` (below); and server counts (start time, agent processes, projects, active tasks, SSE clients, database size including WAL, last event seq). A turn's queue row is written before its session's `queued` state event, so a refetch triggered by that event sees it.
|
||||
|
||||
Agent health is `internal/agents/health.go`:
|
||||
|
||||
@@ -94,13 +94,27 @@ Agent health is `internal/agents/health.go`:
|
||||
- **Watching.** `Manager.Watch` recomputes health every `healthInterval`. When the set of problems (non-`ok` processes and zombies) changes, it logs each new one as `agent health problem` and broadcasts an unstored `admin.health` event (`seq` 0). Nothing is killed or repaired automatically.
|
||||
- **Protocol errors.** `internal/claude` reports a stdout line that isn't a JSON object, doesn't parse, or has a `type` outside `knownTypes` (the line is still passed on), and the manager adds lines whose content it fails to decode. `protocol_errors.counts` counts them per vendor since byre started; `recent` holds the last `keptBadLines`, newest first, each with session, time, reason, the raw line cut to `badLineBytes` and its full `size`. They live in memory only, and each one also broadcasts `admin.health`.
|
||||
|
||||
The Admin view (a pane tab, also opened in the right pane by the navbar's **Admin** button) shows, from the top: a Problems section only when there are zombies or protocol errors (one line per zombie, and per vendor the error count with the latest line folded under it); running turns against each cap, each running turn with the time since its process's last event and `stuck` or `exited` when it is; queued turns in order, both linking to their tasks; each vendor's rate-limit status with its reset time and per-window utilization, and the server counts. It refetches the status when a `session.created`, `session.state`, `task.*`, `project.created` or `admin.health` event arrives, when an `agent.vendor` event carries a `rate_limit_event`, and when the SSE stream reconnects. Other events advance the last-event seq it shows, and any event carrying a process's `agent_session_id` (stored or not, such as `agent.delta`) moves that process's last-event time forward, re-rendering at most once a second per process. A re-render keeps the scroll position and which folded lines are open. There is no timer, so "time since last event" and the "in 2h 10m" beside a reset time are as of the last render; a process going quiet long enough to be stuck shows up when the next health check broadcasts `admin.health`.
|
||||
The Status modal (the navbar's **Status** button; Esc, ×, a click outside it or following a task link closes it) shows, from the top: a Problems section only when there are zombies or protocol errors (one line per zombie, and per vendor the error count with the latest line folded under it); running turns against each cap, each running turn with the time since its process's last event and `stuck` or `exited` when it is; queued turns in order, both linking to their tasks; each vendor's rate-limit status with its reset time and per-window utilization, and the server counts. It refetches the status when a `session.created`, `session.state`, `task.*`, `project.created` or `admin.health` event arrives, when an `agent.vendor` event carries a `rate_limit_event`, and when the SSE stream reconnects. Other events advance the last-event seq it shows, and any event carrying a process's `agent_session_id` (stored or not, such as `agent.delta`) moves that process's last-event time forward, re-rendering at most once a second per process. A re-render keeps the scroll position and which folded lines are open. There is no timer, so "time since last event" and the "in 2h 10m" beside a reset time are as of the last render; a process going quiet long enough to be stuck shows up when the next health check broadcasts `admin.health`.
|
||||
|
||||
Agent activity becomes events with `agent_session_id` set: `user.message`, `agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`, `turn.ended`, `session.state`, `session.model`, and `session.context`. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as `agent.vendor`. The one exception is partial output: each top-level `text_delta` is broadcast to SSE clients as an `agent.delta` event (`payload.text`, `seq` 0) that is never stored, and the transcript grows a live message from them until the finished `agent.message` replaces it. A `turn.ended` event carries `inbox_item_id` when its final message was filed as a `report` item. `session.context` records the context size whenever it changes: `used` is the last assistant message's input, cache-read, cache-creation and output tokens, and `window` is the model's context window from the turn's `result` (`modelUsage[model].contextWindow`), so it is 0 until the session's first turn after a server start ends. The task header's **Compact** button shows the latest one as a background fill across the button, proportional to the window, tinted amber from 70% and red from 90%; the exact count is in its tooltip.
|
||||
|
||||
Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request and the continuation's handoff) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. The transcript view has an accent border while the session is `running`, and a braille spinner on its last line, cycling ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ every 0.8s. It is one element with one CSS animation: the ten frames are drawn side by side in an inline SVG used as the `.spinner` mask over an accent background, and the `braille` keyframes in `web/style.css` step the mask position through them. Orion freezes animated GIFs and per-dot `step-end` opacity animations partway through a turn, so it stays a single element with a single animation. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. A turn's final message that is at least half visible in the transcript while the page is visible dismisses its `report` inbox item, as the viewing user. Selecting a task that has never had a session focuses the composer. In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task in memory, so it survives switching tabs or tasks but not a page reload. The composer is the same whether or not the task has a live session: 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 "sending…" bubble, 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.
|
||||
|
||||
The task switcher (⌘K, or the navbar crumb) lists active tasks with their session state and open inbox item count, live while it is open. Tasks with inbox items come first; within each group, the task least recently visited in this browser comes first (never-visited tasks before all others, ties in the server's order: projects by name, tasks newest first). Visit times are kept in the browser's localStorage under `byre.visits`, stamped when a task is selected and again when it is left. Typing filters them and adds a last row that creates a task with the typed text as its title, in the current task's project (the first project when no task is selected); Enter picks the highlighted row. Picking a task here or in the Tasks view selects it and, if neither pane shows the Transcript tab, switches a pane that isn't showing Tasks to it.
|
||||
The task switcher (⌘K, or the navbar crumb) lists active tasks with their session state and open inbox item count, live while it is open. Tasks with inbox items come first; within each group, the task least recently visited in this browser comes first (never-visited tasks before all others, ties in the server's order: projects by name, tasks newest first). Visit times are kept in the browser's localStorage under `byre.visits`, stamped when a task is selected and again when it is left. Typing filters them and adds a last row that creates a task with the typed text as its title, in the current task's project (the first project when no task is selected); Enter picks the highlighted row. Picking a task points the right pane at it on the Transcript tab (see "Lead agent").
|
||||
|
||||
## Lead agent
|
||||
|
||||
Each user has one long-running lead agent, shown in the left pane. It is not tied to a project: it watches every task, tells the human what needs them, points the right pane at it, and acts on the other agents. Its code is `internal/agents/lead.go` (sessions, held input, signals) and `internal/agents/leadtools.go` (its MCP tools).
|
||||
|
||||
- **Sessions.** A lead session is an `agent_sessions` row with `owner` set to the user's login, `role` `lead` and no `task_id`. Its events carry `owner` and no project or task. It runs in `leads/<login>` with `leadGuidance` (passed and re-attached the same way as a task agent's guidance) and the lead tools instead of the task tools. It has no Stop-hook bounce, no snapshots, no end-of-turn commit and no inbox report; a turn that ends in error records the error on its `session.state` event, which the pane shows as a note. Its turns don't count against the caps.
|
||||
- **Messages.** `POST /api/me/lead/messages` sends the caller's message to their lead, starting a lead session when there is none. While the lead is mid-turn the message is held in `lead_inputs` and becomes its next turn; the composer stays enabled. `GET /api/me/lead/sessions` lists the caller's lead sessions; interrupt and compact use the usual session endpoints.
|
||||
- **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, a report's text cut to 1500 characters, an error or rate limit), `inbox.updated` when a human answered a question or error, `task.merged` and `task.discarded`. `focus.updated` signals only its owner's lead. A signal caused by the lead itself (actor `<login> (lead)`) is skipped, as is the update from an answer it gave. 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)".
|
||||
- **Tools.** `list_tasks`, `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`), `read_inbox`, `answer_question` (an `option`, `other` or `dismiss` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; refused while it is mid-turn), `create_task`, `start_todo`, `interrupt`, `compact_task`, `merge_task`, `discard_task`, `show` and `compact_self`. The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor `<login> (lead)`, so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks.
|
||||
- **Focus.** `users.focus` holds what the user's right pane shows: `task_id`, `tabs` (the tabs put first, in order) and `tab` (the selected one). `GET /api/me/focus` reads it; `PUT /api/me/focus` and the lead's `show` set it, refusing an unknown tab or a task that isn't active, and record a `focus.updated` event owned by the user. The page applies the focus on load and on every `focus.updated` it didn't cause itself; the human's own changes (⌘K, the navbar crumb, clicking a tab, a task link, a notification) are sent with `PUT /api/me/focus`, which signals the lead.
|
||||
- **Compaction.** The pane's **Compact** button asks the lead for a handoff (`leadHandoffPrompt`). The lead can also call `compact_self` at a logical point; 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.
|
||||
- **Restarts.** On shutdown, byre marks each running lead session idle with "byre stopped mid-turn" and holds a restart notice for it, so a lead is never left `running` for a build without lead support to find. After a crash, startup resumes a running lead like a task session. Startup then delivers any held input.
|
||||
|
||||
The UI is two panes: the left is the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), the right shows the focused task with the Transcript, Changes, Files, Inbox and Todo tabs, the lead's `tabs` first.
|
||||
|
||||
## Todo
|
||||
|
||||
@@ -108,13 +122,13 @@ A project's `TODO.md` is read from the root of its default branch in the project
|
||||
|
||||
The title is the item's identity. `POST /api/projects/{id}/todo/tasks` with a `title` re-reads `TODO.md`, refuses an unknown title (404) or one an active task already has (409), creates a task from the default branch with that title and `todo_item` (the `tasks.todo_item` column, empty for other tasks), and starts its session as the requesting user with `todoPrompt` (`internal/agents/tasks.go`): the item as written, and an instruction to remove it from `TODO.md` in the worktree when the work is done. The agent's guidance names the item too (`taskGuidance`, see "Agent sessions"), so the instruction survives compaction and resumes. The agent removes the item on its own branch, so the removal lands on the default branch in the same squash commit as the work.
|
||||
|
||||
The **Todo** tab lists each project's items, grouped by section, with each body rendered as Markdown. An item with an active task whose `todo_item` equals its title shows that task's session state as a link to the task instead of a **Start** button. **Start** posts the item with the composer's default model, selects the new task and shows its transcript. The tab fetches every project's `TODO.md` when it opens, when the project list changes, when the SSE stream reconnects, and refetches a project's on `task.merged`; a change pushed to the default branch outside byre shows the next time one of those happens.
|
||||
The **Todo** tab lists the items of the selected task's project, grouped by section, with each body rendered as Markdown. An item with an active task whose `todo_item` equals its title shows that task's session state as a link to the task instead of a **Start** button. **Start** posts the item with the composer's default model and points the right pane at the new task's transcript. The tab fetches the project's `TODO.md` when it opens, when the selected task moves to another project, when the project list changes, when the SSE stream reconnects, and on `task.merged`; a change pushed to the default branch outside byre shows the next time one of those happens.
|
||||
|
||||
## 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. 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 turn's final message (as a `report`) and 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`; reports and non-blocking questions are `fyi`. The inbox view groups items by task, each group headed by project and task: tasks with a blocked item come first, then tasks sort by project name and by their oldest item. Within a task, items are in time order.
|
||||
`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`; reports and non-blocking questions are `fyi`. The Inbox tab is task-specific: it shows the selected task's open items in time order, and nothing when no task is selected.
|
||||
|
||||
`POST /api/inbox/responses` answers any number of items in one submission. Response types and what they do to the item:
|
||||
|
||||
@@ -134,7 +148,7 @@ New inbox items are sent as web push notifications to every browser a user has t
|
||||
- **Sending.** `internal/push` watches the event stream for `inbox.created`. The first item starts a 3-second window (`pushWindow` in `internal/server/server.go`); everything that arrives inside it goes out together, so each subscribed user gets at most one notification per window, built from the items their preferences select. One item reads `byre · <task title>` with the question (or the report's first line, the error, or "Rate limited") as the body; several read `byre · N new inbox items` with one `<task title>: <text>` line each. Encryption and VAPID signing are `github.com/SherClockHolmes/webpush-go`. Notifications carry a 24-hour TTL and `Urgency: high` when any item is blocking. Delivery failures are logged, not retried.
|
||||
- **Clicking** a notification opens the first item's task (`/#/task/<id>`): the worker focuses an open byre window and tells it to switch task, or opens a new one.
|
||||
|
||||
The **Settings** tab turns notifications on or off for the current browser (asking for permission on the way) and edits the preferences. Opening it re-registers the browser's existing subscription, which restores one byre deleted as expired.
|
||||
The **Settings** modal (the navbar's **Settings** button) turns notifications on or off for the current browser (asking for permission on the way) and edits the preferences. Opening it re-registers the browser's existing subscription, which restores one byre deleted as expired.
|
||||
|
||||
In the UI each card 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 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. A report has only an OK button, which dismisses it at once; 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.
|
||||
|
||||
@@ -177,10 +191,10 @@ Merging or discarding a task stops new snapshots for it, waits for one in progre
|
||||
|
||||
`GET /api/tasks/{taskId}/changes?snapshot=<n>` reports a snapshot instead of the live worktree: its `tree` is the snapshot's tree, and `base` is the merge-base of the snapshot's commit and the default branch, or with `step=true` the previous snapshot's tree (the snapshot commit's parent for snapshot 1). It reads the stored shas, so it works only while the task is active and its refs keep the objects; `since` with `snapshot` is a 400. `GET /api/tasks/{taskId}/diff` takes that `base` and `tree` as usual.
|
||||
|
||||
The transcript is the timeline. The UI learns a task's snapshots from the `snapshot.created` events in its transcript (`snapshots` in `web/src/state.ts`). Each snapshot is marked `#n` at the right of the transcript row it belongs to: for `tool_call`, `timer` and `ask_human`, the last tool call, `ask_human` note or turn end whose `seq` is at most the snapshot's `event_seq` (the PostToolUse hook fires before the tool's result is recorded, so that is the call that made it); for `turn_end`, that turn's end line, which may be the turn after `event_seq` since the snapshot and `turn.ended` race. A row with several snapshots shows `#a–#b`, and a collapsed group of tool calls shows the range of all its calls' snapshots. Clicking a mark pins the latest snapshot it covers (`state.pinned`, cleared when the selected task changes) and shows the Changes tab in the pane the transcript isn't in, unless a pane already shows it. Pinned, the Changes header reads `snapshot <n> · <trigger> · <time>` with ← and → to step to the neighbouring snapshots, and the range buttons are **Since main** and **This step** (`step=true`, the default when pinning) plus **Live**, which unpins. A pinned view doesn't refresh on tool results, since a snapshot never changes.
|
||||
The transcript is the timeline. The UI learns a task's snapshots from the `snapshot.created` events in its transcript (`snapshots` in `web/src/state.ts`). Each snapshot is marked `#n` at the right of the transcript row it belongs to: for `tool_call`, `timer` and `ask_human`, the last tool call, `ask_human` note or turn end whose `seq` is at most the snapshot's `event_seq` (the PostToolUse hook fires before the tool's result is recorded, so that is the call that made it); for `turn_end`, that turn's end line, which may be the turn after `event_seq` since the snapshot and `turn.ended` race. A row with several snapshots shows `#a–#b`, and a collapsed group of tool calls shows the range of all its calls' snapshots. Clicking a mark pins the latest snapshot it covers (`state.pinned`, cleared when the selected task changes) and switches the right pane to the Changes tab. Pinned, the Changes header reads `snapshot <n> · <trigger> · <time>` with ← and → to step to the neighbouring snapshots, and the range buttons are **Since main** and **This step** (`step=true`, the default when pinning) plus **Live**, which unpins. A pinned view doesn't refresh on tool results, since a snapshot never changes.
|
||||
|
||||
## Events
|
||||
|
||||
`GET /api/events` is server-sent events: `id` is the event's `seq`, `data` the Event JSON. Reconnecting with `Last-Event-ID` (EventSource does this itself) or `?after=` replays everything after that seq before going live. `?task_id=` limits the stream, replay and live, to one task's events (404 for an unknown task). Unstored events (`seq` 0, currently only `agent.delta`) go out live only, without an `id` line, so they don't move `Last-Event-ID`. A client that falls further behind than its subscriber buffer (`subscribe` in `internal/server/hub.go`) is disconnected and catches up by reconnecting. Every 15s the server sends an `event: ping` so clients can tell a live stream from a dead one. The web UI (`subscribe` in `web/src/api.ts`) tracks the last seq it saw and opens a new stream from it when nothing (event or ping) has arrived for 40s, 2s after the browser gives up on one (as it does when `tailscale serve` answers 502 while byre restarts), when the browser comes back online, and on becoming visible if the stream is stale. While disconnected the navbar shows a red dot and "reconnecting…"; partial output (`agent.delta`) sent while disconnected is lost, and the finished `agent.message` replaces it.
|
||||
`GET /api/events` is server-sent events: `id` is the event's `seq`, `data` the Event JSON. Reconnecting with `Last-Event-ID` (EventSource does this itself) or `?after=` replays everything after that seq before going live. `?task_id=` limits the stream, replay and live, to one task's events (404 for an unknown task); `?lead=true` to the events of the caller's lead agent sessions. An event with an `owner` (a lead agent's events, a user's `focus.updated`) goes only to that user's streams; the others never see it. Unstored events (`seq` 0, currently only `agent.delta`) go out live only, without an `id` line, so they don't move `Last-Event-ID`. A client that falls further behind than its subscriber buffer (`subscribe` in `internal/server/hub.go`) is disconnected and catches up by reconnecting. Every 15s the server sends an `event: ping` so clients can tell a live stream from a dead one. The web UI (`subscribe` in `web/src/api.ts`) tracks the last seq it saw and opens a new stream from it when nothing (event or ping) has arrived for 40s, 2s after the browser gives up on one (as it does when `tailscale serve` answers 502 while byre restarts), when the browser comes back online, and on becoming visible if the stream is stale. While disconnected the navbar shows a red dot and "reconnecting…"; partial output (`agent.delta`) sent while disconnected is lost, and the finished `agent.message` replaces it.
|
||||
|
||||
The web UI keeps two streams: one for all events from the page's load onward, which drives the task, session and inbox lists and the navbar's connection dot, and one per selected task (`selectTask` in `web/src/state.ts`), opened with `?task_id=` and `after=0` so it replays the task's whole history and then stays live. The transcript is drawn only from the task stream, so a reconnect after a byre restart fills in both the history and anything missed. Selecting another task closes the old task stream. Events arriving together are drawn in one batch.
|
||||
The web UI keeps three streams: one for all events from the page's load onward, which drives the task, session, inbox and lead session lists, the right pane's focus and the navbar's connection dot; one per selected task (`selectTask` in `web/src/state.ts`), opened with `?task_id=` and `after=0` so it replays the task's whole history and then stays live; and one for the lead (`startLeadStream`), opened with `?lead=true` and `after=0`. Each transcript is drawn only from its own stream, so a reconnect after a byre restart fills in both the history and anything missed. Selecting another task closes the old task stream. Events arriving together are drawn in one batch.
|
||||
|
||||
Reference in New Issue
Block a user