diff --git a/TODO.md b/TODO.md index 5ba72ed..0e95ec1 100644 --- a/TODO.md +++ b/TODO.md @@ -15,9 +15,6 @@ # M6 — Fan-out -- Agents talking to agents - A more collaborative version of forks and workflow handoff: agents in related tasks (a workflow run's stages, a task and its forks or subtasks) talk to each other while they work, instead of one stage passing a single output to the next. Claude Code agent teams (shared task list, per-agent mailboxes) and Claude Code desktop's cross-session messaging are the nearest designs. - # M7 — Inbox triage # M8 — macOS client diff --git a/api/openapi.yaml b/api/openapi.yaml index 541be85..e96d165 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -489,6 +489,25 @@ paths: "404": { $ref: "#/components/responses/Error" } "409": { $ref: "#/components/responses/Error" } + /api/tasks/{taskId}/board: + parameters: + - { name: taskId, in: path, required: true, schema: { type: string } } + post: + operationId: postToBoard + description: > + Posts to a team task's board as the caller: records a board.posted event and sends the post to every agent of + the team, starting a turn for an idle one and reaching a working one after its next tool call. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/BoardPostCreate" } + responses: + "204": { description: The post was recorded and sent. } + "400": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /api/tasks/{taskId}/events: parameters: - { name: taskId, in: path, required: true, schema: { type: string } } @@ -1262,7 +1281,7 @@ components: Stage: type: object - required: [name, model, prompt] + required: [name, prompt] properties: name: { type: string, description: "One line of 1 to 100 characters, unique within the workflow." } after: @@ -1279,8 +1298,22 @@ components: Where the stage's task branches from. parent (the default): the commit its one after stage finished at, or for a stage with none, where the workflow was started from. main: the project's default branch. Refused on a stage with more than one after stage, which always branches from the default branch. - model: { type: string, description: "The stage agent's model, which also picks its vendor: gpt-* models run on Codex, every other model on Claude Code." } - prompt: { type: string, description: "The stage agent's instructions." } + model: { type: string, x-go-type-skip-optional-pointer: true, description: "The stage agent's model, which also picks its vendor: gpt-* models run on Codex, every other model on Claude Code. Required without agents, refused with them." } + prompt: { type: string, description: "The stage agent's instructions; with agents, the instructions every agent of the team gets before its own." } + agents: + type: array + items: { $ref: "#/components/schemas/TeamMember" } + description: > + Runs the stage as a team: several agents in the stage's one task and worktree, each on its own model, sharing a + board. The first agent finishes the stage. + + TeamMember: + type: object + required: [name, model, prompt] + properties: + name: { type: string, description: "The agent's name in the team, one line of 1 to 100 characters, unique within the team." } + model: { type: string, description: "The agent's model, which also picks its vendor." } + prompt: { type: string, description: "This agent's own instructions: its role in the team." } TaskWorkflow: type: object @@ -1489,10 +1522,11 @@ components: AgentSession: type: object - required: [id, task_id, owner, vendor, model, vendor_session_id, state, role, created_by, created_at, subagents] + required: [id, task_id, owner, vendor, model, vendor_session_id, state, role, member, created_by, created_at, subagents] properties: id: { type: string } task_id: { type: string, description: Empty for a lead session. } + member: { type: string, description: "The agent's name in its task's team; empty on a task with one agent, and for leads and reviewers." } owner: { type: string, description: "The login whose lead agent this is; empty for a task's session." } vendor: { type: string, enum: [claude, codex], description: "Follows from the model the session was created with: gpt-* models run on Codex, every other model on Claude Code." } model: { type: string, description: "Requested model; empty means the vendor's default. A session only switches between its own vendor's models." } @@ -1531,6 +1565,12 @@ components: type: string description: Model for this turn and later ones. When it differs from the session's model, the session switches before the message is sent. + BoardPostCreate: + type: object + required: [text] + properties: + text: { type: string } + CompactCreate: type: object properties: @@ -1706,8 +1746,8 @@ components: TaskTab: type: string - enum: [transcript, changes, files, terminal, inbox, reviews, workflow, project] - x-enum-varnames: [TabTranscript, TabChanges, TabFiles, TabTerminal, TabInbox, TabReviews, TabWorkflow, TabProject] + enum: [transcript, board, changes, files, terminal, inbox, reviews, workflow, project] + x-enum-varnames: [TabTranscript, TabBoard, TabChanges, TabFiles, TabTerminal, TabInbox, TabReviews, TabWorkflow, TabProject] TerminalSize: type: object diff --git a/docs/operations.md b/docs/operations.md index bff77ab..8dbfb30 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -95,6 +95,18 @@ A task's agent starts a subtask with the `create_subtask` MCP tool (`internal/ag - **Merging into the default branch.** `target: main` (the default) is the ordinary Merge, squashing the subtask onto the default branch and pushing it. - **UI.** The Project tab's task list draws each subtask under its parent, indented by depth with a ↳ mark whose tooltip names the parent; the ⌘K switcher prefixes a subtask's title with " ↳". Both show **Merging** while the flag is set, with a tooltip saying it waits for the parent's agent. A subtask's transcript opens with the note "subtask of ", linking the parent while it is active. The Merge dialog for a subtask whose parent is active offers "Into " (selected first; the commit title and co-authors are hidden, since the parent's own commit holds the merge) and "Into ". +## Teams + +A team task has several agents working at once in its one worktree, branch and port block, each on its own model, talking through a shared board (`internal/agents/team.go`). It is opt-in: the lead's `create_task` with `agents`, or a workflow stage with `agents` (see "Workflows"). Everything else (snapshots, reviews, merge, discard, subtasks, the inbox) stays per task. + +- **Members.** Each agent is an implementer session with `agent_sessions.member` (`member` on the AgentSession) set to its name; a task with one agent has an empty `member`. `agents` is a list of at least two `TeamMember`s (`name`, `model`, `prompt`), checked by `workflow.TeamProblems`: unique one-line names, a model and a prompt each. `startTeam` creates every session first, so no post can miss a member, then starts each with the shared brief (the `create_task` prompt, or the stage's message) followed by `## Your part: ` and its own prompt. A task has at most one session that isn't `completed` per member; a Compact handoff's new session keeps the member. `sqlite3 byre.db "SELECT task_id, member, model, state FROM agent_sessions WHERE member != ''"` lists team sessions. +- **First agent.** The member of the task's earliest session (`teamLead`) is the team's first agent: whatever byre sends "the task's agent" goes to it (`liveSession`): Merge, Sync, the discard summary turn, review findings, a subtask's reports and merge-ins, and for a stage task `finish_stage`, which only the first agent gets. Only the first agent's turn end can claim a merge's finalize in `EndTurn`, and only its turn ends, failed resumes and process exits clear a pending merge (`leadsTask`); `stop_merge` is refused to the other members. `startTeam` starts every member even when one fails, and reports the ones that didn't start; the retry for a team that came up incomplete is to discard the task and create it again. +- **Task mutex.** Members run in parallel; byre's own operations on the worktree are what can't, so every task has one in-memory mutex (`lockTask` in `internal/agents/critical.go`) around them: each turn-end commit and the stage record that goes with it (`turnEnded`), each snapshot (`runSnapshots`, taken before the snapshot state's lock), removing the task's worktree when it is discarded (`discardNow`) or merged into its parent (`closeMergedSubtask`, which holds the subtask's mutex across checking that its branch is in the parent's history and removing it), and every merge turn. A turn that is handed a merge prompt (`isMergeInPrompt`: Merge, Sync, or a subtask's merge-in, at the start of a turn in `prepareMessage` or mid-turn in `recordQueued`) takes the mutex for its session (`takeMergeTurn`) and holds it until that turn ends, so no other member's commit can conclude its unresolved merge; the squash, push and worktree removal of a finishing merge run before it is released (`finishMerge`). A merge turn that ends unfinished, or whose process exits mid-turn, runs `git merge --abort` first, so the merge is either concluded or undone. A member whose turn ends meanwhile waits on the mutex, then commits, or skips the commit when the task was closed while it waited. The mutex is in memory; on startup, before resuming sessions, `recoverMergeTurns` gives it back to each running first agent whose worktree has a merge in progress (`git.MergeInProgress`), since merge prompts only go to the first agent, and a resume that fails aborts that merge. +- **Board.** Members get `post_to_board` (`text`) and `read_board` (`after_seq`). A post is a `board.posted` event on the task (actor `agent` with `member` and `model` in the payload and the poster's session; or the human's login; or the lead's actor) and is sent to every other member's live session as `"" posted on the board:` and the text, through `Manager.Message` with actor `board`: an idle member starts a turn with it, a working one gets it after its next tool call (headed "While you were working, …"). Posts are delivered in order, one member after another, before the post call returns (`deliverPost`). The roster is every member that ever had a session (`teamSessions`, each member's live session or else its latest), so a post for a member whose first turn hasn't started yet (`SessionStarted`), whose session was just completed by a Compact handoff, or whose delivery fails (a conflict such as the task finishing a merge or being discarded, or its process not starting), is queued in `task_inputs` for that member instead, and every turn end of a team member delivers to the other idle members (`deliverTeam`). `POST /api/tasks/{id}/board` posts as the caller (204; 400 for a task without a team), and the lead's `send_message` to a team task posts as the lead. Each member's guidance names the team and its models, tells it to post only what adds something, that its final message goes to the human, to re-read files before editing them since the others change the same ones, and who finishes a stage. `sqlite3 byre.db "SELECT seq, actor, payload FROM events WHERE kind = 'board.posted' AND task_id = ''"` lists a board. +- **State and signals.** The task's state takes the running member's session first (`taskStateExpr`). A member's `turn.ended` carries `member`, and the lead's signal reads "'s agent "" finished a turn"; `list_tasks` lists each member's state and model, and `read_transcript` prefixes a member's lines with `[]` and shows posts as `board post by …` lines, leaving out the `board` deliveries. +- **UI.** The Transcript tab of a team task shows one member at a time: its header has a button per member (● while it runs), the pick kept in memory per task (`Store.pickedMembers`, the first member by default, and set to the summary's member when an unread summary is opened, read from its `turn.ended` in the task's feed or else fetched), and the transcript draws only that member's events and events with no session (`ChatModel.shows`), the board deliveries that woke it included. Compact and Interrupt act on the picked member. The composer posts to the board (`postToBoard`; text only, no model picker), and Merge, Sync and Discard are disabled while any member runs. The **Board** tab, shown only on a team task, lists the task's `board.posted` events from its event feed, oldest first, each with its author, model and time. +- **Forks.** A fork of a team task continues the conversation of the member whose snapshot it was cut from, as a task with one agent. + ## Running as a service The listen address is `byre serve --listen`, whose default is in `cmd/byre/serve.go` (`go run ./cmd/byre serve --help` prints it); keep it on loopback, since identity relies on requests arriving from `tailscale serve`. @@ -170,7 +182,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 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`). 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. 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`. 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/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`). 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. 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. @@ -180,7 +192,7 @@ Compaction is vendor-agnostic: `POST /api/sessions/{id}/compact` (the transcript Task agents spawn parallel work as subtasks (see "Subtasks"). -Agents don't commit; byre does. Each `user.message` event that starts a turn records `head`, the worktree's `HEAD` when the turn started. When a turn ends, byre runs `git add --all` and commits if anything changed (`git.CommitAll`), as the session's creator, with the first sentence of the turn's final message as the message (`commitMessage` in `internal/agents/manager.go`); `turn.ended` then carries the new sha as `commit`. A failed commit files a `session_error` in the task's inbox and the turn ends unfinished: no summary, no handoff, no merge finalize, and a workflow stage neither finishes nor records its output, so the next stage never branches from a `HEAD` that lacks the turn's work. +Agents don't commit; byre does. Each `user.message` event that starts a turn records `head`, the worktree's `HEAD` when the turn started. When a turn ends, byre runs `git add --all` and commits if anything changed (`git.CommitAll`, under the task's mutex, see "Teams"), as the session's creator, with the first sentence of the turn's final message as the message (`commitMessage` in `internal/agents/manager.go`); `turn.ended` then carries the new sha as `commit`. A failed commit files a `session_error` in the task's inbox and the turn ends unfinished: no summary, no handoff, no merge finalize, and a workflow stage neither finishes nor records its output, so the next stage never branches from a `HEAD` that lacks the turn's work. A session runs on one of two vendors, picked by the model it is created with (`workflow.VendorOf` in `internal/workflow/workflow.go`): `gpt-*` models run on Codex (`codex app-server`), every other model on Claude Code. The UI's model list is `models` in `mac/Sources/Byre/Composer.swift`; a live session's picker offers only its own vendor's models, and the API refuses (400) a switch to the other vendor's model. Both vendors sit behind `agentProcess` (`internal/agents/process.go`), and both end a turn through the same `turnEnded`, so commits, turn summaries, snapshots, handoffs, merges and deliveries don't depend on the vendor. @@ -235,7 +247,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie - **Messages.** `POST /api/me/lead/messages` sends the caller's message to their lead, starting a lead session when there is none. While the lead is mid-turn the message is held in `lead_inputs`; the lead's PostToolUse hook (Codex: `turn/steer` after each completed tool item) hands held messages over after its next tool call, like a task's queued messages (see "Agent sessions"), and any still held become its next turn. Signals are never handed over mid-turn. The composer stays enabled. `GET /api/me/lead/sessions` lists the caller's lead sessions; interrupt and compact use the usual session endpoints. - **Scrollback.** `GET /api/me/lead/events` returns a page of the caller's lead events in whole turns, `turns` of them (default 50): those after the `turns`+1'th `turn.ended` before `before` (or before the latest event, so the page also holds a turn still running), with `more` saying whether earlier events exist. The lead pane loads only the last page (see "Events"); when its top scrolls into view (an empty row kept first in the transcript), it fetches the page before its first event (`loadOlderLead` in `mac/Sources/Byre/Store.swift`), puts its rows in front (`TranscriptBuilder.prepend`) and scrolls back to the row that was first. A page starts just after a `turn.ended`, so rendering it from scratch matches how it drew as part of the whole history. Live events still append without limit until the app is relaunched. - **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation, an error or rate limit), `inbox.updated` when a human answered a question or error (a question's signal also carries its text and the agent's recommendation, so the lead sees where the human departed from it), `task.created` (naming the TODO item it was started from, or a subtask's parent, if any), `task.merge_started` (" 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. 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, then a line listing the titles of its `TODO.md` items on the default branch that no active task was started from), `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`; closed tasks too), `search_closed_tasks` (see "Closing a task"), `read_inbox`, `read_status` (the `GET /api/admin/status` body as JSON, built by the same `Server.status`, which the server hands `agents.New`), `answer_question` (an `option`, `other` or `dismiss` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; while it is mid-turn the message is queued through `Manager.Message` like the human's, see "Agent sessions", and the tool result says it was queued), `create_task` and `start_todo` (each with an optional `workflow` to freeze and run), `create_project` (`name` and `repo_path`, created as the user by the same `Server.createProject` as `POST /api/projects`, which the server hands `agents.New`, so it publishes the same `project.created` event, and returned as JSON), `interrupt`, `compact_task`, `take_snapshot` (see "Snapshots"), `merge_task` (optional `target`: `main`, or `parent` for a subtask, see "Subtasks"), `discard_task`, `show`, `compact_self`, the note tools (see "Notes") and the workflow tools (see "Workflows"). The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor ` (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. +- **Tools.** `list_tasks` (each project's active tasks with their state, each subtask noting its parent, then a line listing the titles of its `TODO.md` items on the default branch that no active task was started from), `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`; closed tasks too), `search_closed_tasks` (see "Closing a task"), `read_inbox`, `read_status` (the `GET /api/admin/status` body as JSON, built by the same `Server.status`, which the server hands `agents.New`), `answer_question` (an `option`, `other` or `dismiss` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; while it is mid-turn the message is queued through `Manager.Message` like the human's, see "Agent sessions", and the tool result says it was queued; on a team task it posts to the board, see "Teams"), `create_task` and `start_todo` (each with an optional `workflow` to freeze and run; `create_task` also takes `agents` to start a team), `create_project` (`name` and `repo_path`, created as the user by the same `Server.createProject` as `POST /api/projects`, which the server hands `agents.New`, so it publishes the same `project.created` event, and returned as JSON), `interrupt`, `compact_task`, `take_snapshot` (see "Snapshots"), `merge_task` (optional `target`: `main`, or `parent` for a subtask, see "Subtasks"), `discard_task`, `show`, `compact_self`, the note tools (see "Notes") and the workflow tools (see "Workflows"). The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor ` (lead)`, so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks. - **Focus.** `users.focus` holds what the user's right pane shows: `project_id`, `task_id`, `tabs` (the tabs put first, in order) and `tab` (the selected one). With a `task_id`, `project_id` is always that task's project, filled in by the server; with only a `project_id`, the pane shows that project with no task, which only the Project tab has anything for. `GET /api/me/focus` reads it; `PUT /api/me/focus` and the lead's `show` set it, refusing an unknown tab, a task that isn't active or an unknown project, and record a `focus.updated` event owned by the user. The app applies the focus at startup and on every `focus.updated` it didn't cause itself; the human's own changes (⌘K, the right pane's task picker, clicking a tab, a task link, a notification) are sent with `PUT /api/me/focus`. - **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and applies only its `tabs` order, whether or not the human is in the pane, so the human's selected tab stays selected. Otherwise, while the human is working in the right pane, the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (`Manager.paneFinished`): its task is merged, discarded or merging (`merging_by` or `finalizing` set), or its selected tab is `inbox` and the task has no open items, or the human is waiting on the task's agent (`Manager.paneWaiting`): the selected tab is `transcript`, the task's implementer session is running, the app reports no unsent text in the pane, and either the `user.message` that started the running turn came from the user after the pane last moved, or the pane last moved 10 seconds ago or more (`watchHold`). The pane's last move is the last `PUT /api/me/focus` or `show` that set it (`Manager.paneMoved`, in memory, so a restart counts as long ago). For the user's own message, unsent text reported before the turn started doesn't count, since the app's report of the composer emptying on send can arrive after the message does. A `user.message` on a task from a human (not `byre`, an agent or a lead) signals every lead " sent a message; its agent is working on it.", and the sender's own lead, when its pane shows that task and is waiting that way, also gets "Your right pane is free: the human is waiting on '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 an `inbox.updated`, `task.merge_started`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: has nothing left in its inbox." (or "is merging."/"is merged."/"is discarded."), even when the event itself makes no signal, such as a dismissed error; `inbox.updated` on a task that is no longer active adds none. The lead's guidance tells it to show the next thing that needs the human on that line, or, when nothing does, the project's Project tab with a short status in chat: one line per active task, then up to three TODO items it would start next, each with a reason, starting one only when the human picks it. It writes each item as a link to `#/todo//`; 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. It sends false on deactivating, Change Server…, closing the window and quit, and resends true when its event stream reconnects. byre keeps both flags and the time of the last report in memory per user (`Manager.paneFocus`, `Manager.paneReported`), so they start false after a restart until the app resends them. - **Move hold.** Each `PUT /api/me/focus` from the user (switching task or tab, by any means: the switcher, a tab, a link, a notification, or the app's own switch to Inbox when a question arrives) records the time in `Manager.humanMoved`, and for 10 seconds after it (`moveHold`) every `show` that would change the view is refused, whatever the pane lock above would allow, with an error giving how long ago the move was. A `show` of what the pane already shows still applies its tab order. A refusal also marks the user in `Manager.moveRefused`, which each move by the user clears. When the 10 seconds pass without another move by the user (`endMoveHold`, on a timer), byre clears both and, only if a `show` was refused since that move, sends the lead the signal "The move hold is over: …"; its guidance tells it to show what it held back if the pane's other rules allow, or reply `NO_REPLY`. Because only a refusal arranges the signal, the lead's system prompt and the `show` tool's description tell it to call `show` at once whenever the user asks to see something or it wants to show something, never holding back in expectation of a refusal. It is in memory, so a restart clears it. @@ -246,7 +258,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie Task agents get notes too. An implementer session's instructions end with `taskNotesGuidance` and `notesText` for the task's creator, limited to the every-project notes and the task's project's (`startLive` in `internal/agents/manager.go`), fixed when the agent process starts like the lead's. Reviewers get none. Implementers have a `suggest_note` tool (`text`, `evidence`) that queues a lead signal for the task's creator naming the task, the suggested text, the evidence and the project id, and refuses when the creator has no live lead session. The lead's guidance tells it to save a suggestion with `remember`, choosing its project, only when it holds and isn't already covered, and to tell the human nothing either way. A suggestion leaves no record of its own beyond the signal; a saved one shows as the lead's `lead.note_saved`. - **Restarts.** On shutdown, byre marks each running lead session idle with "byre stopped mid-turn" and holds a restart notice for it, so a lead is never left `running` for a build without lead support to find. After a crash, startup resumes a running lead like a task session. Startup then delivers any held input. -The UI is two panes filling the window, with no navbar. The left has three tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Status and Settings, and at the end of its tab strip the connection dot and the user's name. Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Status or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Changes, Files, Terminal, Inbox, Reviews, Workflow and Project, the lead's `tabs` first. Reviews shows only once it has a `review.*` event, Inbox only while the task has open items, and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there, and More carries the Inbox highlight and count when Inbox is in it. The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open. +The UI is two panes filling the window, with no navbar. The left has three tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Status and Settings, and at the end of its tab strip the connection dot and the user's name. Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Status or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Inbox, Reviews, Workflow and Project, the lead's `tabs` first. Board shows only on a team task (see "Teams"), Reviews only once it has a `review.*` event, Inbox only while the task has open items, and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there, and More carries the Inbox highlight and count when Inbox is in it. The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open. ## TODO @@ -380,13 +392,13 @@ Every new snapshot of a task gets a review: a separate agent session on the vend A workflow is a named graph of stages, each a stage name, the stages it comes after, where its branch starts, a model and the stage agent's prompt written out in full. Every stage runs as its own task, once all the stages it comes after have finished, so one stage's output can go to several stages (fan-out) and one stage can wait for several (fan-in). Workflows live in byre's database, in the `workflows` table (`name`, and the definition's JSON as `definition`), shared by every project and never in a repo. byre stores, validates, exports, imports and freezes them, and runs each run's frozen copy stage by stage (`internal/agents/workflows.go`; the graph helpers `After`, `Next`, `Starts` and `FromMain` are in `internal/workflow/workflow.go`). The human starts one from the task switcher (see "Starting"); there is no UI for managing them: the lead does that with its tools, and draws a workflow for the human as a Mermaid flowchart of its stage names, which its chat renders (see "Inbox"). A run's progress is shown read-only in the stage tasks' Workflow tab (see "Progress" below). - **Names.** The name is the workflow's key and is shown to the human on buttons and pickers, so it is free text: one line of 1 to 100 characters, trimmed (`workflow.CleanName`). It isn't part of the definition, so the file doesn't carry it. In URLs it is a path-escaped segment. -- **File.** One workflow per file: the `WorkflowDefinition` (`api/openapi.yaml`) as YAML, an optional `description` and its `stages`. A stage's `after` lists the stages whose outputs it gets; absent, it means the previous stage in the list (none for the first), so a file without `after` is a chain; `[]` means the stage starts from the brief. Its `base` is `parent` (the default: the commit its one `after` stage finished at, or for a stage with none, where the run was started from) or `main` (the project's default branch); a stage with more than one `after` stage always starts from the default branch. `workflow.Format` writes it with multi-line prompts as literal blocks; `workflow.Parse` reads YAML or JSON and refuses unknown fields. The download is named after the workflow with `/\:*?"<>|` replaced by `_` (`workflow.Filename`). -- **Validation.** `workflow.Validate` refuses a definition (400, everything wrong listed) with no stages, a stage name that isn't a valid one-line name or repeats another stage's, an empty model or an empty prompt, a `base` other than `parent` or `main`, a `base` on a stage with more than one `after` stage, an `after` naming the stage itself, a name twice or a name that isn't a stage, a cycle, or anything but exactly one final stage (one no other stage comes after). A stage's vendor follows from its model (`workflow.VendorOf`, the same rule sessions use: `gpt-*` is Codex, everything else Claude Code); a file with a `vendor` key is refused as an unknown field. +- **File.** One workflow per file: the `WorkflowDefinition` (`api/openapi.yaml`) as YAML, an optional `description` and its `stages`. A stage's `after` lists the stages whose outputs it gets; absent, it means the previous stage in the list (none for the first), so a file without `after` is a chain; `[]` means the stage starts from the brief. Its `base` is `parent` (the default: the commit its one `after` stage finished at, or for a stage with none, where the run was started from) or `main` (the project's default branch); a stage with more than one `after` stage always starts from the default branch. In place of `model`, a stage may list `agents` (each `name`, `model` and `prompt`) to run as a team in its one task (see "Teams"): each agent gets the stage's message and then its own prompt, and the first agent calls `finish_stage`. `workflow.Format` writes it with multi-line prompts as literal blocks; `workflow.Parse` reads YAML or JSON and refuses unknown fields. The download is named after the workflow with `/\:*?"<>|` replaced by `_` (`workflow.Filename`). +- **Validation.** `workflow.Validate` refuses a definition (400, everything wrong listed) with no stages, a stage name that isn't a valid one-line name or repeats another stage's, an empty model without `agents` or a model with them, `agents` that fail `workflow.TeamProblems`, an empty prompt, a `base` other than `parent` or `main`, a `base` on a stage with more than one `after` stage, an `after` naming the stage itself, a name twice or a name that isn't a stage, a cycle, or anything but exactly one final stage (one no other stage comes after). A stage's vendor follows from its model (`workflow.VendorOf`, the same rule sessions use: `gpt-*` is Codex, everything else Claude Code); a file with a `vendor` key is refused as an unknown field. - **API.** `GET /api/workflows` lists every workflow's name and description. `GET /api/workflows/{name}` downloads its file; `PUT` takes a file as the body and creates or replaces the workflow; `DELETE` removes it. Each save or delete records a `workflow.updated` or `workflow.deleted` event carrying the name. `sqlite3 byre.db 'SELECT name FROM workflows'` lists them. Saves and reads go through the Manager (`internal/agents/workflows.go`) so the HTTP API and the lead's tools share them. - **Lead tools.** `list_workflows`, `read_workflow` (`name`, or `task_id` for a task's frozen copy), `save_workflow` (`name` and `definition`, the file as YAML) and `delete_workflow`, with the same checks and errors as the API. Task agents don't get them. - **Starting.** The lead's `create_task` and `start_todo` take an optional `workflow` (404 when there is none of that name) and then start a run of it instead of an agent on the prompt (`Manager.StartWorkflow`); `model` together with `workflow` is refused, since each stage names its own. `POST /api/projects/{id}/tasks` takes the same optional `workflow`, with the title as the run's brief, and returns the run's first stage task. The task switcher (`mac/Sources/Byre/Switcher.swift`) offers it as a "Start workflow…" row after each "New task in …" and "Start in …" pair: picking that lists `GET /api/workflows`, filtered by what is typed next (Esc goes back to the task list), and picking a workflow starts the run with the typed title and opens its first stage task. A run gets a new id and creates a task for every stage with no `after` stages, branched from the default branch, or for a stage with `base: parent`, from the lead's `from_task_id` HEAD when given. Each stage task copies the workflow's name and definition as they are when the run starts into `tasks.workflow_name` and `tasks.workflow_definition`, so later saves or deletes don't change it; its brief into `tasks.workflow_brief` (the lead's `create_task` `prompt`, optional with a workflow and may be empty, or for `start_todo` the TODO item as `todoPrompt` words it); the run's title into `tasks.workflow_title`; the run id into `tasks.workflow_run`; and its stage's name into `tasks.workflow_stage`. Stage tasks are titled `: <stage>`, with the run's title as their merge title, and carry the run's TODO item. The Task's `workflow`, `workflow_run` and `workflow_stage` are those; `GET /api/tasks/{id}/workflow` returns the copy as `{name, definition}` with the definition as YAML (404 for a task without one). A task agent's `create_subtask`, `POST /api/projects/{id}/tasks` without `workflow` and the Project tab's Start create tasks without one. A fork of a stage task copies its frozen workflow, brief, title, run and stage, so it is another attempt at the same stage of the same run. `sqlite3 byre.db "SELECT id, status, workflow_stage, stage_output != '' FROM tasks WHERE workflow_run = '<run>'"` lists a run's tasks and which have finished. -- **Stages.** A stage task's agent is an implementer session on the stage's model, created by the user who started the run; any later session of the task (a Compact handoff's, one started by a message) runs the same stage, since the stage belongs to the task. Starting a stage records a `task.workflow_stage` event on its task (`workflow`, `run`, `stage` the name, `after` the ids of the tasks whose outputs it gets; actor the lead for the stages a run starts with, `byre` after) and sends the session its first message, actor `byre`, so the transcript shows it collapsed under its first line: `Workflow "<name>", stage "<stage>"`, then the stage's prompt, a `## Brief` section when the brief isn't empty, a `## From stage "<stage>" (task <id>, branch <branch>)` section with each `after` stage's output, and a line saying where the task starts: from the default branch, with the branches above to `git merge` or read as it needs, or from the commit its one `after` stage finished at. Nothing else of earlier stages is passed on; their branches carry their work. -- **Advancing.** A stage session gets the `finish_stage` tool (`output`, required) and a guidance line naming the workflow, its stage and the stages after it. The call stores the output in `agent_sessions.stage_output`, replacing any earlier one; it is refused once the task's stage has finished. When a turn of that session ends normally (not an error or interrupt, not a handoff, not a Merge turn that squashes) with an output stored, byre sets `agent_sessions.stage_finished` before marking the session idle, and then advances (`nextStage`): it records the output and the worktree's HEAD in `tasks.stage_output` and `tasks.stage_commit`, then, holding `workflowMu` so two stages finishing at once can't both start the one after them, goes through each stage after this one. A stage starts once every stage it comes after has a finished task in the run (the latest one, when a fork made several); its task branches from that task's `stage_commit` for `base: parent` with one `after` stage, else from the default branch. Once every stage after it has been dealt with, byre clears `stage_output` and `stage_finished` on the session. The session stays idle in its task for more messages, and queued messages and held answers are delivered to it once advancing is done. When the final stage finishes, byre starts discarding every other active task of the run (as `byre`, through the same `Discard` as the button, so each gets its summary turn first and closes when that turn ends; one already merging or being discarded is skipped and logged as `stage task not discarded`), then records `task.workflow_finished` (`workflow`, `output`) on the final task and clears the flags in the same transaction, leaving the final task for the human to merge. A turn that ends without the call leaves the task in its stage. The output belongs to the turn that stored it: when that turn ends any other way (an error, an interrupt, a handoff, a Merge turn that squashes, an agent process that exits mid-turn, or a session that can't be resumed after a restart), byre clears `stage_output` before marking the session idle, so a later turn can't hand off a stale output; the agent calls `finish_stage` again. A turn cut off by a byre restart and resumed keeps it. If a stage can't start, byre logs `advance workflow stage` and files the error in the finishing task's inbox, and the flag stays set. On startup, before resuming running sessions, `recoverStages` advances every session with `stage_finished` set, skipping the steps already done: the recorded output isn't overwritten; a stage that already has a task in the run isn't created again, but when that task is active and has no session, its session is started, and when its session never got a message (`SessionStarted`), it is sent the stage's first message, and the restart notice isn't sent to it. `sqlite3 byre.db 'SELECT id, task_id FROM agent_sessions WHERE stage_finished = 1'` lists stages waiting to advance. `list_tasks` shows each stage task's workflow and `stage "<name>"`, with `(finished)` once its stage has finished and `workflow finished` once any task of the run has a `task.workflow_finished` event. Nothing reads or writes `agent_sessions.workflow_stage`; it stays in the schema so builds from older branches keep working against the same database. +- **Stages.** A stage task's agent is an implementer session on the stage's model (for a team stage, one per agent, see "Teams"), created by the user who started the run; any later session of the task (a Compact handoff's, one started by a message) runs the same stage, since the stage belongs to the task. Starting a stage records a `task.workflow_stage` event on its task (`workflow`, `run`, `stage` the name, `after` the ids of the tasks whose outputs it gets; actor the lead for the stages a run starts with, `byre` after) and sends the session its first message, actor `byre`, so the transcript shows it collapsed under its first line: `Workflow "<name>", stage "<stage>"`, then the stage's prompt, a `## Brief` section when the brief isn't empty, a `## From stage "<stage>" (task <id>, branch <branch>)` section with each `after` stage's output, and a line saying where the task starts: from the default branch, with the branches above to `git merge` or read as it needs, or from the commit its one `after` stage finished at. Nothing else of earlier stages is passed on; their branches carry their work. +- **Advancing.** A stage session gets the `finish_stage` tool (`output`, required) and a guidance line naming the workflow, its stage and the stages after it. The call stores the output in `agent_sessions.stage_output`, replacing any earlier one; it is refused once the task's stage has finished. When a turn of that session ends normally (not an error or interrupt, not a handoff, not a Merge turn that squashes) with an output stored, byre sets `agent_sessions.stage_finished` before marking the session idle, and then advances (`nextStage`): it records the output and the worktree's HEAD in `tasks.stage_output` and `tasks.stage_commit`, then, holding `workflowMu` so two stages finishing at once can't both start the one after them, goes through each stage after this one. A stage starts once every stage it comes after has a finished task in the run (the latest one, when a fork made several); its task branches from that task's `stage_commit` for `base: parent` with one `after` stage, else from the default branch. Once every stage after it has been dealt with, byre clears `stage_output` and `stage_finished` on the session. The session stays idle in its task for more messages, and queued messages and held answers are delivered to it once advancing is done. When the final stage finishes, byre starts discarding every other active task of the run (as `byre`, through the same `Discard` as the button, so each gets its summary turn first and closes when that turn ends; one already merging or being discarded is skipped and logged as `stage task not discarded`), then records `task.workflow_finished` (`workflow`, `output`) on the final task and clears the flags in the same transaction, leaving the final task for the human to merge. A turn that ends without the call leaves the task in its stage. The output belongs to the turn that stored it: when that turn ends any other way (an error, an interrupt, a handoff, a Merge turn that squashes, an agent process that exits mid-turn, or a session that can't be resumed after a restart), byre clears `stage_output` before marking the session idle, so a later turn can't hand off a stale output; the agent calls `finish_stage` again. A turn cut off by a byre restart and resumed keeps it. If a stage can't start, byre logs `advance workflow stage` and files the error in the finishing task's inbox, and the flag stays set. On startup, before resuming running sessions, `recoverStages` advances every session with `stage_finished` set, skipping the steps already done: the recorded output isn't overwritten; a stage that already has a task in the run isn't created again, but when that task is active and has no session, its sessions are started, and each of its sessions that never got a message (`SessionStarted`) is sent the stage's first message (a team member's with its own prompt), and the restart notice isn't sent to them. `sqlite3 byre.db 'SELECT id, task_id FROM agent_sessions WHERE stage_finished = 1'` lists stages waiting to advance. `list_tasks` shows each stage task's workflow and `stage "<name>"`, with `(finished)` once its stage has finished and `workflow finished` once any task of the run has a `task.workflow_finished` event. Nothing reads or writes `agent_sessions.workflow_stage`; it stays in the schema so builds from older branches keep working against the same database. - **Progress.** `GET /api/tasks/{id}/workflow/run` (`Manager.WorkflowRun`) returns the run of a stage task (404 for a task without one): the definition that task froze (typed, not YAML), name, title, brief, `finished` (any task of the run has `task.workflow_finished`), and per stage in definition order its resolved `after`, `waiting_on` (the `after` stages with no finished attempt), its `attempts` and a `state`. An attempt is every task of the run at that stage, closed ones included, oldest first (the first and its forks), with its `output` and `commit` once `tasks.stage_output` is set, `forked_from`, `discarded_by` (the `task.discarded` actor) and `inputs`: the tasks named in the `after` of its latest `task.workflow_stage` event, each with its stage, so it says which attempt fed it; a fork has no such event and takes its source's. An attempt's state is `merged`, `cleaned_up` (discarded after finishing; `byre` as `discarded_by` is the final stage's cleanup), `discarded` (before finishing), `finished`, `running` (a session is running) or `idle`. A stage is `waiting` with no attempts, else it takes the state of its latest finished attempt, or its latest attempt when none finished. The Workflow tab (`mac/Sources/Byre/Workflow.swift`) draws the graph natively with the flowchart layout from `Diagram.swift`, each stage's node coloured by its state (`stateColors`, from the palette's tint and accent pairs: waiting grey, running accent, idle warn, finished, merged and cleaned up ok, discarded danger) and redrawn whenever a refetch changes a state; then a card per stage with its state, its attempts linking to their tasks, each attempt's inputs ("started from") and the attempts it fed, and its output collapsed as Markdown. It refetches on any stored `task.*` or `session.*` event of a task in the run, and on a `task.created` whose payload carries the run, so a new stage or fork shows up. ## Events diff --git a/internal/agents/compact.go b/internal/agents/compact.go index 634ab27..5b9faf0 100644 --- a/internal/agents/compact.go +++ b/internal/agents/compact.go @@ -112,7 +112,7 @@ func (m *Manager) handoff(ctx context.Context, sessionID, handoff string) error if err != nil { return err } - _, err = m.create(ctx, task, creator, handoffPreamble+handoff, model, "byre", nil) + _, err = m.create(ctx, task, creator, sess.Member, handoffPreamble+handoff, model, "byre", nil) if errors.Is(err, ErrConflict) { m.startDiscard(task.Id) return nil diff --git a/internal/agents/critical.go b/internal/agents/critical.go new file mode 100644 index 0000000..4fe266f --- /dev/null +++ b/internal/agents/critical.go @@ -0,0 +1,83 @@ +package agents + +import ( + "context" + "log/slog" + + "g.fc.run/misc/byre/internal/api" + "g.fc.run/misc/byre/internal/git" +) + +type mergeTurn struct { + dir string + unlock func() +} + +func (m *Manager) lockTask(taskID string) func() { + return m.lockDelivery("critical:" + taskID) +} + +func (m *Manager) ownsTask(sessionID string) bool { + m.mu.Lock() + defer m.mu.Unlock() + _, found := m.mergeTurns[sessionID] + return found +} + +func (m *Manager) takeMergeTurn(l *live) { + m.ownMerge(l.id, l.taskID, l.dir) +} + +func (m *Manager) ownMerge(sessionID, taskID, dir string) { + if m.ownsTask(sessionID) { + return + } + unlock := m.lockTask(taskID) + m.mu.Lock() + m.mergeTurns[sessionID] = mergeTurn{dir: dir, unlock: unlock} + m.mu.Unlock() +} + +func (m *Manager) recoverMergeTurns(ctx context.Context, running []api.AgentSession) error { + for _, sess := range running { + if sess.Role != api.Implementer { + continue + } + lead, found, err := m.liveSession(ctx, sess.TaskId) + if err != nil { + return err + } + if !found || lead.Id != sess.Id { + continue + } + task, err := m.store.GetTask(ctx, sess.TaskId) + if err != nil { + return err + } + merging, err := git.MergeInProgress(task.WorktreePath) + if err != nil { + return err + } + if merging { + m.ownMerge(sess.Id, task.Id, task.WorktreePath) + } + } + return nil +} + +func (m *Manager) endMergeTurn(sessionID string, abort bool) { + m.mu.Lock() + t, found := m.mergeTurns[sessionID] + delete(m.mergeTurns, sessionID) + m.mu.Unlock() + if !found { + return + } + if abort { + err := git.AbortMerge(t.dir) + if err != nil { + slog.Error("abort unfinished merge", "session", sessionID, "err", err) + } + } + t.unlock() +} diff --git a/internal/agents/lead.go b/internal/agents/lead.go index ef15811..5a1dfe7 100644 --- a/internal/agents/lead.go +++ b/internal/agents/lead.go @@ -46,7 +46,7 @@ func leadActor(login string) string { } func humanActor(actor string) bool { - return actor != "byre" && actor != agentActor && !strings.HasSuffix(actor, leadActor("")) + return actor != "byre" && actor != agentActor && actor != boardActor && !strings.HasSuffix(actor, leadActor("")) } func (m *Manager) LeadSessions(ctx context.Context, owner string) ([]api.AgentSession, error) { @@ -536,6 +536,9 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error) if ev.Payload["summary"] != true { return "", nil } + if member, _ := ev.Payload["member"].(string); member != "" { + return fmt.Sprintf("%s's agent %q finished a turn:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), member, clip(fmt.Sprint(ev.Payload["result"]), 1500)), nil + } return fmt.Sprintf("%s finished a turn:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), clip(fmt.Sprint(ev.Payload["result"]), 1500)), nil } return "", nil diff --git a/internal/agents/leadtools.go b/internal/agents/leadtools.go index a0b583f..e3de147 100644 --- a/internal/agents/leadtools.go +++ b/internal/agents/leadtools.go @@ -141,7 +141,8 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { tool("send_message", "Send a message to a task's agent; it starts a turn. Starts the agent when the task has none. "+ "If the agent is mid-turn, the message is queued like the human's: the agent reads it with its next tool call's result, "+ - "or as its next turn if it makes no further tool call, and gets it only once.", + "or as its next turn if it makes no further tool call, and gets it only once. "+ + "On a team task, the message is posted to the team's board instead, so every agent of the team gets it the same way; model is refused there.", `{"type": "object", "properties": { "task_id": {"type": "string"}, "text": {"type": "string"}, @@ -168,8 +169,18 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { if err != nil { return "", err } + if sess.Member != "" { + if args.Model != "" { + return "", fmt.Errorf("%w: model can't be given for a team task; its agents keep their own models", ErrInvalid) + } + err = m.Post(ctx, task, leadActor(user.Login), args.Text) + if err != nil { + return "", err + } + return fmt.Sprintf("Posted to %s's board; every agent of its team gets it.", task.Id), nil + } if !found { - _, err = m.create(ctx, task, user, args.Text, args.Model, leadActor(user.Login), nil) + _, err = m.create(ctx, task, user, "", args.Text, args.Model, leadActor(user.Login), nil) if err != nil { return "", err } @@ -190,23 +201,31 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { "The new agent sees nothing else, so the prompt must be a complete brief. It starts from the project's default branch, "+ "or from from_task_id's committed HEAD. With workflow, it runs that workflow instead: each stage becomes its own task, titled \"<title>: <stage>\", on the stage's model, "+ "and this call creates the tasks of the stages that start from the brief (the others follow as the stages they come after finish); "+ - "prompt is then optional and is added to every stage's first message as its brief, and model can't be given.", + "prompt is then optional and is added to every stage's first message as its brief, and model can't be given. "+ + "With agents, it starts a team instead of one agent: each agent works in the task's one worktree on its own model, gets prompt and then its own prompt, "+ + "and talks to the others through the task's board; model and workflow can't be given with it.", `{"type": "object", "properties": { "project_id": {"type": "string"}, "title": {"type": "string"}, "prompt": {"type": "string"}, "from_task_id": {"type": "string"}, "model": {"type": "string"}, - "workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of and run."} + "workflow": {"type": "string", "description": "The name of a workflow for the task to freeze a copy of and run."}, + "agents": {"type": "array", "description": "A team of at least two agents; the first is the team's first agent, which byre's own requests (merges, discards, review findings) go to.", "items": {"type": "object", "properties": { + "name": {"type": "string", "description": "One line, unique in the team, such as a role: architect, skeptic."}, + "model": {"type": "string"}, + "prompt": {"type": "string", "description": "This agent's own instructions: its role in the team."} + }, "required": ["name", "model", "prompt"]}} }, "required": ["project_id", "title"]}`, func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) { args := struct { - ProjectID string `json:"project_id"` - Title string `json:"title"` - Prompt string `json:"prompt"` - FromTaskID string `json:"from_task_id"` - Model string `json:"model"` - Workflow string `json:"workflow"` + ProjectID string `json:"project_id"` + Title string `json:"title"` + Prompt string `json:"prompt"` + FromTaskID string `json:"from_task_id"` + Model string `json:"model"` + Workflow string `json:"workflow"` + Agents []api.TeamMember `json:"agents"` }{} err := json.Unmarshal(raw, &args) if err != nil { @@ -221,6 +240,15 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { if args.Workflow != "" && args.Model != "" { return "", errModelWithWorkflow } + if args.Agents != nil && (args.Workflow != "" || args.Model != "") { + return "", fmt.Errorf("%w: agents can't be combined with model or workflow", ErrInvalid) + } + if args.Agents != nil { + err = checkTeam(args.Agents) + if err != nil { + return "", err + } + } if user.GitName == "" || user.GitEmail == "" { return "", ErrNoGitIdentity } @@ -250,7 +278,14 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { if err != nil { return "", err } - _, err = m.create(ctx, task, user, args.Prompt, m.modelOr(args.Model, l), leadActor(user.Login), nil) + if args.Agents != nil { + err = m.startTeam(ctx, task, user, args.Prompt, args.Agents, leadActor(user.Login)) + if err != nil { + return "", fmt.Errorf("task %s created, but its team did not start: %w", task.Id, err) + } + return fmt.Sprintf("Task %s %q created from %s and its team of %d agents started.", task.Id, task.Title, task.BaseRef, len(args.Agents)), nil + } + _, err = m.create(ctx, task, user, "", args.Prompt, m.modelOr(args.Model, l), leadActor(user.Login), nil) if err != nil { return "", fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) } @@ -423,7 +458,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { tool("show", "Point the human's right pane at a task, or at a project with no task: tabs are the tabs to put first, in order (the rest follow), and tab is the one to select. "+ "Whenever the human asks you to show something (\"show me that when you can\") or you want to show something, call show right away; never hold back because you expect a refusal, since only a refused show arranges byre's signal to try again. "+ - "Tabs: transcript, changes, files, terminal (a shell in the task's worktree), inbox (the task's open items), reviews (the task's reviews by reviewer agents), workflow (a workflow stage task's run: its graph and each stage's state, attempts and outputs), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+ + "Tabs: transcript, board (a team task's board: its agents' posts, the human's and yours), changes, files, terminal (a shell in the task's worktree), inbox (the task's open items), reviews (the task's reviews by reviewer agents), workflow (a workflow stage task's run: its graph and each stage's state, attempts and outputs), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+ "Omit both task_id and project_id to clear the pane. "+ "If the pane already shows that task (or project) on that tab, only the tab order changes and you are told so, even while the human is working in the pane. "+ "Otherwise refused for 10 seconds after the human last moved the pane themselves (switching task or tab), whatever else holds; if a show was refused for it, byre signals you \"The move hold is over\" when those 10 seconds pass without another move, and sends no signal otherwise. "+ @@ -433,8 +468,8 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { `{"type": "object", "properties": { "task_id": {"type": "string"}, "project_id": {"type": "string", "description": "Ignored when task_id is given."}, - "tabs": {"type": "array", "items": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"]}}, - "tab": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"]} + "tabs": {"type": "array", "items": {"type": "string", "enum": ["transcript", "board", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"]}}, + "tab": {"type": "string", "enum": ["transcript", "board", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"]} }, "required": ["tabs", "tab"]}`, func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) { f := api.Focus{} @@ -764,12 +799,12 @@ func (m *Manager) liveSession(ctx context.Context, taskID string) (api.AgentSess if err != nil { return api.AgentSession{}, false, err } - for _, s := range sessions { - if s.State != api.AgentSessionStateCompleted { - return s, true, nil - } + lead := teamLead(sessions) + sess, found := memberSession(sessions, lead) + if !found { + return api.AgentSession{Member: lead}, false, nil } - return api.AgentSession{}, false, nil + return sess, true, nil } func (m *Manager) taskArg(ctx context.Context, raw json.RawMessage) (api.Task, api.Project, error) { @@ -826,11 +861,20 @@ func (m *Manager) listTasksText(ctx context.Context) (string, error) { started[*t.TodoItem] = true } agent := string(t.State) - sess, found, err := m.liveSession(ctx, t.Id) + sessions, err := m.store.ListSessions(ctx, t.Id) if err != nil { return "", err } - if found && sess.Model != "" { + sess, found := memberSession(sessions, teamLead(sessions)) + team := teamSessions(sessions) + switch { + case len(team) > 0: + members := []string{} + for _, s := range team { + members = append(members, fmt.Sprintf("%q %s on %s", s.Member, s.State, s.Model)) + } + agent += "; team: " + strings.Join(members, ", ") + case found && sess.Model != "": agent += "; agent on " + sess.Model } open := 0 @@ -909,6 +953,14 @@ func (m *Manager) transcriptText(ctx context.Context, taskID string, after *int6 if err != nil { return "", err } + sessions, err := m.store.ListSessions(ctx, task.Id) + if err != nil { + return "", err + } + members := map[string]string{} + for _, s := range sessions { + members[s.Id] = s.Member + } b := &strings.Builder{} for _, ev := range events { @@ -916,9 +968,13 @@ func (m *Manager) transcriptText(ctx context.Context, taskID string, after *int6 continue } line := transcriptLine(ev, task.WorktreePath) - if line != "" { - fmt.Fprintf(b, "#%d %s\n", ev.Seq, line) + if line == "" { + continue } + if ev.AgentSessionId != nil && members[*ev.AgentSessionId] != "" && ev.Kind != "board.posted" { + line = fmt.Sprintf("[%s] %s", members[*ev.AgentSessionId], line) + } + fmt.Fprintf(b, "#%d %s\n", ev.Seq, line) } if len(events) == limit && limit > 0 { fmt.Fprintf(b, "(more may follow: call again with after_seq %d)\n", events[len(events)-1].Seq) @@ -944,10 +1000,15 @@ func transcriptLine(ev api.Event, root string) string { } switch ev.Kind { case "user.message": - if ev.Actor == "byre" { + switch ev.Actor { + case "byre": return "byre: " + clip(text("text"), 300) + case boardActor: + return "" } return ev.Actor + ": " + text("text") + case "board.posted": + return fmt.Sprintf("board post by %s: %s", postAuthor(ev), text("text")) case "agent.message": return "agent: " + text("text") case "agent.tool_call": diff --git a/internal/agents/manager.go b/internal/agents/manager.go index f22c524..944bdd6 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -57,6 +57,7 @@ type Manager struct { startRefused map[string]bool finishing map[string]bool handoffModel map[string]string + mergeTurns map[string]mergeTurn deliverMu sync.Mutex deliverLocks map[string]*sessionLock @@ -88,6 +89,7 @@ type live struct { taskID string projectID string owner string + member string notes string reviewID string stage string @@ -143,6 +145,7 @@ func New(st *store.Store, worktreeRoot, leadRoot, codexHome string, status func( startRefused: map[string]bool{}, finishing: map[string]bool{}, handoffModel: map[string]string{}, + mergeTurns: map[string]mergeTurn{}, signals: make(chan []api.Event, 1024), snaps: map[string]*snapState{}, merges: map[string]*projectMerge{}, @@ -162,10 +165,10 @@ func (m *Manager) taskFor(ctx context.Context, sess api.AgentSession) (api.Task, } func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model string, images ...string) (api.AgentSession, error) { - return m.create(ctx, task, creator, prompt, model, creator.Login, images) + return m.create(ctx, task, creator, "", prompt, model, creator.Login, images) } -func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, prompt, model, actor string, images []string) (api.AgentSession, error) { +func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, member, prompt, model, actor string, images []string) (api.AgentSession, error) { if creator.GitName == "" || creator.GitEmail == "" { return api.AgentSession{}, ErrNoGitIdentity } @@ -174,20 +177,20 @@ func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, p return api.AgentSession{}, fmt.Errorf("%w: task %s is %s", ErrConflict, task.Id, task.Status) } - sess, err := m.newSession(ctx, task, creator, model) + sess, err := m.newSession(ctx, task, creator, member, model) if err != nil { return api.AgentSession{}, err } return m.turn(ctx, sess, task, actor, prompt, images) } -func (m *Manager) newSession(ctx context.Context, task api.Task, creator api.User, model string) (api.AgentSession, error) { +func (m *Manager) newSession(ctx context.Context, task api.Task, creator api.User, member, model string) (api.AgentSession, error) { existing, err := m.store.ListSessions(ctx, task.Id) if err != nil { return api.AgentSession{}, err } for _, s := range existing { - if s.State != api.AgentSessionStateCompleted { + if s.State != api.AgentSessionStateCompleted && s.Member == member { return api.AgentSession{}, fmt.Errorf("%w: task %s already has session %s", ErrConflict, task.Id, s.Id) } } @@ -202,6 +205,7 @@ func (m *Manager) newSession(ctx context.Context, task api.Task, creator api.Use VendorSessionId: initialVendorSessionID(vendor, id), State: api.AgentSessionStateIdle, Role: api.Implementer, + Member: member, CreatedBy: creator.Login, CreatedAt: time.Now().UTC(), } @@ -347,6 +351,11 @@ func (m *Manager) Recover(ctx context.Context) error { return err } + err = m.recoverMergeTurns(ctx, sessions) + if err != nil { + return err + } + for _, sess := range sessions { if staged[sess.Id] { continue @@ -415,6 +424,7 @@ func (m *Manager) Recover(ctx context.Context) error { func (m *Manager) failResume(ctx context.Context, sess api.AgentSession, task api.Task, err error) error { slog.Error("resume session", "session", sess.Id, "err", err) + m.endMergeTurn(sess.Id, true) m.logUndelivered(ctx, sess.Id, "resume failed") derr := m.store.DropStageOutput(ctx, sess.Id) if derr != nil { @@ -429,7 +439,7 @@ func (m *Manager) failResume(ctx context.Context, sess api.AgentSession, task ap go m.finishDiscard(task.Id) case next == store.DiscardHeld: go m.startDiscard(task.Id) - case sess.Role == api.Implementer: + case sess.Role == api.Implementer && m.leadsTask(ctx, task.Id, sess.Id): m.abandonMerge(task.Id, task.ProjectId) } return nil @@ -540,6 +550,7 @@ func (m *Manager) launch(ctx context.Context, sess api.AgentSession, task api.Ta err = l.sess.send(ctx, text, imgs) if err != nil { + m.endMergeTurn(sess.Id, false) return api.AgentSession{}, err } @@ -547,6 +558,7 @@ func (m *Manager) launch(ctx context.Context, sess api.AgentSession, task api.Ta } func (m *Manager) failStart(ctx context.Context, sessionID, actor string, err error) { + m.endMergeTurn(sessionID, false) cctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) defer cancel() @@ -589,6 +601,9 @@ func (m *Manager) message(ctx context.Context, l *live, actor, text string, imag } 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) + } imgs := []image{} for _, id := range images { b, data, err := m.store.ReadBlob(ctx, id) @@ -707,6 +722,7 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api taskID: task.Id, projectID: task.ProjectId, owner: sess.Owner, + member: sess.Member, subagents: map[string]bool{}, } @@ -715,6 +731,7 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api return nil, err } tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.takeSnapshotTool(l)}, append(m.subtaskTools(l), m.clientTools(l)...)...) + lead := true if sess.Role == api.Implementer { g += longTitleGuidance(task) notes, err := m.notesText(ctx, task.CreatedBy, task.ProjectId) @@ -724,7 +741,16 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api l.notes = "\n\n" + taskNotesGuidance + "\n\n" + notes tools = append(tools, m.suggestNoteTool(l)) } - if sess.Role == api.Implementer && task.WorkflowStage != nil { + if sess.Role == api.Implementer && sess.Member != "" { + sessions, err := m.store.ListSessions(ctx, task.Id) + if err != nil { + return nil, err + } + lead = sess.Member == teamLead(sessions) + g += teamGuidance(sessions, sess.Member, task.WorkflowStage != nil) + tools = append(tools, m.boardTools(l)...) + } + if sess.Role == api.Implementer && task.WorkflowStage != nil && lead { frozen, err := m.store.TaskWorkflow(ctx, task.Id) if err != nil { return nil, err @@ -828,6 +854,7 @@ func (m *Manager) pump(l *live) { slog.Error("drop stage output", "session", l.id, "err", err) } } + m.endMergeTurn(l.id, true) next, _, err := m.store.EndTurn(ctx, l.id, agentActor, map[string]any{"error": "agent process exited mid-turn"}, nil, false, false) if err != nil { slog.Error("record session exit", "session", l.id, "err", err) @@ -845,7 +872,9 @@ func (m *Manager) pump(l *live) { return } if l.owner == "" { - m.abandonMerge(l.taskID, l.projectID) + if m.leadsTask(ctx, l.taskID, l.id) { + m.abandonMerge(l.taskID, l.projectID) + } m.fileError(l, "The agent process exited mid-turn. Send a message to resume it.") } } @@ -1026,11 +1055,49 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error { finished := !res.isError && !interrupted && strings.TrimSpace(res.result) != "" payload := res.payload + if l.member != "" { + payload["member"] = l.member + } + owner := m.ownsTask(l.id) + keep := false + if owner { + defer func() { + if !keep { + m.endMergeTurn(l.id, false) + } + }() + if !finished { + err := git.AbortMerge(l.dir) + if err != nil { + slog.Error("abort unfinished merge", "session", l.id, "err", err) + } + } + } + unlock := func() {} + if !owner { + locked := m.lockTask(l.taskID) + once := sync.OnceFunc(locked) + unlock = once + defer once() + task, err := m.store.GetTask(ctx, l.taskID) + if err != nil { + return err + } + if task.Status != api.TaskStatusActive { + return nil + } + } sha, err := git.CommitAll(l.dir, commitMessage(res), l.gitName, l.gitEmail) if err != nil { slog.Error("commit turn", "session", l.id, "err", err) m.fileError(l, fmt.Sprintf("byre couldn't commit the turn's work, so the turn counts as unfinished: %v", err)) finished = false + if owner { + err = git.AbortMerge(l.dir) + if err != nil { + slog.Error("abort unfinished merge", "session", l.id, "err", err) + } + } } handoff := compacting && finished if sha != "" { @@ -1084,10 +1151,14 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error { if err == nil && !stageFinished { err = m.store.DropStageOutput(ctx, l.id) } + if err == nil && stageFinished { + err = m.recordStage(ctx, l) + } if err != nil { return err } } + unlock() held := len(l.subagents) > 0 turn := m.event(l, agentActor, "turn.ended", payload) @@ -1120,10 +1191,14 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error { } if finalizeBy != "" { m.logUndelivered(ctx, l.id, "turn ended in merge") + keep = owner go m.finishMerge(l, finalizeBy) return nil } - mergeBy := m.takeMerge(l.taskID) + mergeBy := "" + if m.leadsTask(ctx, l.taskID, l.id) { + mergeBy = m.takeMerge(l.taskID) + } switch { case mergeBy != "": go m.releaseMerge(l.projectID, l.taskID) @@ -1155,6 +1230,7 @@ func (m *Manager) afterTurn(ctx context.Context, l *live) { if err != nil { slog.Error("deliver inbox responses", "session", l.id, "err", err) } + m.deliverTeam(ctx, l) } func commitMessage(res endResult) string { diff --git a/internal/agents/mergein.go b/internal/agents/mergein.go index 21a3dfb..b249009 100644 --- a/internal/agents/mergein.go +++ b/internal/agents/mergein.go @@ -20,8 +20,14 @@ import ( const maxMergeTitle = 72 +const mergeInMarker = " into this branch: run `git merge --no-ff --no-commit " + +func isMergeInPrompt(actor, text string) bool { + return actor == "byre" && strings.Contains(text, mergeInMarker) +} + func mergeInPrompt(target string) string { - return fmt.Sprintf("Merge %[1]s into this branch: run `git merge --no-ff --no-commit %[1]s`. "+ + return fmt.Sprintf("Merge %[1]s"+mergeInMarker+"%[1]s`. "+ "Resolve any conflicts keeping both sides' intent; regenerate generated files instead of hand-merging them. "+ "Check that the build and tests pass. Leave the merge uncommitted: byre concludes it when the turn ends. "+ "If resolving the conflicts took substantive changes rather than combining both sides as written, stop there: "+ @@ -34,18 +40,14 @@ func (m *Manager) MergeIn(ctx context.Context, task api.Task, user api.User, tar } func (m *Manager) sendMergeIn(ctx context.Context, task api.Task, user api.User, prompt string) (api.AgentSession, error) { - sessions, err := m.store.ListSessions(ctx, task.Id) + sess, found, err := m.liveSession(ctx, task.Id) if err != nil { return api.AgentSession{}, err } - - for _, sess := range sessions { - if sess.State != api.AgentSessionStateCompleted { - return m.Send(ctx, sess.Id, "byre", prompt, "") - } + if found { + return m.Send(ctx, sess.Id, "byre", prompt, "") } - - return m.create(ctx, task, user, prompt, "", "byre", nil) + return m.create(ctx, task, user, sess.Member, prompt, "", "byre", nil) } func (m *Manager) Merge(ctx context.Context, task api.Task, project api.Project, user api.User, title string) (api.Task, error) { @@ -140,19 +142,12 @@ func (m *Manager) startMerge(ctx context.Context, pm *projectMerge, task api.Tas } func (m *Manager) mergeSession(ctx context.Context, task api.Task, user api.User) (api.AgentSession, error) { - sessions, err := m.store.ListSessions(ctx, task.Id) + sess, found, err := m.liveSession(ctx, task.Id) if err != nil { return api.AgentSession{}, err } - - sess := api.AgentSession{} - for _, s := range sessions { - if s.State != api.AgentSessionStateCompleted { - sess = s - } - } - if sess.Id == "" { - sess, err = m.newSession(ctx, task, user, "") + if !found { + sess, err = m.newSession(ctx, task, user, sess.Member, "") if err != nil { return api.AgentSession{}, err } @@ -413,6 +408,7 @@ func (m *Manager) finishMerge(l *live, login string) { ctx := context.Background() err := m.squashAndClose(ctx, l.taskID, login) + m.endMergeTurn(l.id, false) if err == nil { return } @@ -655,6 +651,9 @@ func (m *Manager) stopMerge(ctx context.Context, l *live, raw json.RawMessage) ( return "", fmt.Errorf("%w: reason must say what the resolution changed", ErrInvalid) } + if !m.leadsTask(ctx, l.taskID, l.id) { + return "", fmt.Errorf("%w: only the team's first agent runs the merge, so only it can stop it", ErrConflict) + } if m.takeMerge(l.taskID) == "" { return "No merge is pending for this task; just end your turn.", nil } diff --git a/internal/agents/queue.go b/internal/agents/queue.go index be7bc27..55fb0d9 100644 --- a/internal/agents/queue.go +++ b/internal/agents/queue.go @@ -68,7 +68,7 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model str if err != nil { return api.AgentSession{}, false, err } - err = m.store.AddTaskInput(ctx, store.TaskInput{TaskID: sess.TaskId, Actor: actor, Text: text, Images: images}, task.ProjectId, sess.Id) + err = m.store.AddTaskInput(ctx, store.TaskInput{TaskID: sess.TaskId, Member: sess.Member, Actor: actor, Text: text, Images: images}, task.ProjectId, sess.Id) if err != nil { return api.AgentSession{}, false, err } @@ -81,26 +81,18 @@ func (m *Manager) Withdraw(ctx context.Context, taskID string, inputID int64, ac if err != nil { return err } - sessions, err := m.store.ListSessions(ctx, taskID) - if err != nil { - return err - } - if len(sessions) == 0 { - return fmt.Errorf("%w: message %d is not queued", ErrConflict, inputID) - } - sess := sessions[len(sessions)-1] unlock := m.lockDelivery(queueLockKey(taskID)) defer unlock() - err = m.store.WithdrawTaskInput(ctx, taskID, inputID, task.ProjectId, sess.Id, actor) + sessionID, err := m.store.WithdrawTaskInput(ctx, taskID, inputID, task.ProjectId, actor) if errors.Is(err, store.ErrNotFound) { return fmt.Errorf("%w: message %d is not queued", ErrConflict, inputID) } if err != nil { return err } - slog.Info("queued message withdrawn", "session", sess.Id, "task", taskID, "input", inputID, "actor", actor) + slog.Info("queued message withdrawn", "session", sessionID, "task", taskID, "input", inputID, "actor", actor) return nil } @@ -112,10 +104,16 @@ func (m *Manager) deliverQueued(ctx context.Context, sess api.AgentSession) (boo unlock := m.lockDelivery(queueLockKey(sess.TaskId)) defer unlock() - inputs, err := m.store.TaskInputs(ctx, sess.TaskId) + inputs, err := m.store.TaskInputs(ctx, sess.TaskId, sess.Member) if err != nil || len(inputs) == 0 { return false, err } + if sess.Member != "" { + started, err := m.store.SessionStarted(ctx, sess.Id) + if err != nil || !started { + return false, err + } + } task, err := m.store.GetTask(ctx, sess.TaskId) if err != nil { @@ -170,7 +168,7 @@ func (m *Manager) takeQueuedMidTurn(ctx context.Context, l *live) []store.TaskIn unlock := m.lockDelivery(queueLockKey(l.taskID)) defer unlock() - inputs, err := m.store.TaskInputs(ctx, l.taskID) + inputs, err := m.store.TaskInputs(ctx, l.taskID, l.member) if err != nil { slog.Error("list queued messages", "session", l.id, "err", err) return nil @@ -237,6 +235,9 @@ func (m *Manager) takeLeadQueuedMidTurn(ctx context.Context, l *live) []store.Le func (m *Manager) recordQueued(ctx context.Context, l *live, msgs []queued) string { b := &strings.Builder{} for i, q := range msgs { + if l.implementer() && isMergeInPrompt(q.actor, q.text) { + m.takeMergeTurn(l) + } err := m.emit(ctx, l, q.actor, "user.message", map[string]any{"text": q.text, "mid_turn": true}) if err != nil { slog.Error("record mid-turn message", "session", l.id, "err", err) @@ -244,6 +245,10 @@ func (m *Manager) recordQueued(ctx context.Context, l *live, msgs []queued) stri if i > 0 { b.WriteString("\n\n") } + if q.actor == boardActor { + fmt.Fprintf(b, "While you were working, %s\n\nTake it into account now.", q.text) + continue + } fmt.Fprintf(b, "%s sent you this message while you were working; take it into account now:\n\n%s", q.actor, q.text) } slog.Info("queued messages sent mid-turn", "session", l.id, "count", len(msgs)) diff --git a/internal/agents/review.go b/internal/agents/review.go index 02286c0..90fbf3e 100644 --- a/internal/agents/review.go +++ b/internal/agents/review.go @@ -325,24 +325,21 @@ func (m *Manager) forwardFindings(taskID, reviewID string) { slog.Error("load review to forward", "review", reviewID, "err", err) return } - sessions, err := m.store.ListSessions(ctx, taskID) + s, found, err := m.liveSession(ctx, taskID) if err != nil { slog.Error("list sessions to forward review", "review", reviewID, "err", err) return } - for _, s := range sessions { - if s.State == api.AgentSessionStateCompleted { - continue - } - _, _, err = m.Message(ctx, s.Id, "byre", findingsMessage(r), "") - if err != nil { - slog.Error("forward review findings", "review", reviewID, "session", s.Id, "err", err) - return - } - slog.Info("review findings forwarded", "review", reviewID, "session", s.Id) + if !found { + slog.Info("review findings not forwarded, task has no live agent", "review", reviewID, "task", taskID) return } - slog.Info("review findings not forwarded, task has no live agent", "review", reviewID, "task", taskID) + _, _, err = m.Message(ctx, s.Id, "byre", findingsMessage(r), "") + if err != nil { + slog.Error("forward review findings", "review", reviewID, "session", s.Id, "err", err) + return + } + slog.Info("review findings forwarded", "review", reviewID, "session", s.Id) } func findingsMessage(r api.Review) string { diff --git a/internal/agents/snapshots.go b/internal/agents/snapshots.go index 4de91e5..687a6b3 100644 --- a/internal/agents/snapshots.go +++ b/internal/agents/snapshots.go @@ -17,6 +17,7 @@ import ( ) type snapRequest struct { + target snapTarget trigger api.SnapshotTrigger seq int64 review bool @@ -61,7 +62,7 @@ func (m *Manager) snapshotSoon(t snapTarget, trigger api.SnapshotTrigger, seq in m.dropRequest(t.taskID, &snapRequest{review: review}) return } - req := &snapRequest{trigger: trigger, seq: seq, review: review, force: force} + req := &snapRequest{target: t, trigger: trigger, seq: seq, review: review, force: force} if st.running { if p := st.pending; p != nil && p.force { req.force = true @@ -72,11 +73,13 @@ func (m *Manager) snapshotSoon(t snapTarget, trigger api.SnapshotTrigger, seq in return } st.running = true - go m.runSnapshots(t, st, req) + go m.runSnapshots(st, req) } -func (m *Manager) runSnapshots(t snapTarget, st *snapState, req *snapRequest) { +func (m *Manager) runSnapshots(st *snapState, req *snapRequest) { for { + t := req.target + unlock := m.lockTask(t.taskID) st.mu.Lock() m.snapMu.Lock() closed := st.closed @@ -96,6 +99,7 @@ func (m *Manager) runSnapshots(t snapTarget, st *snapState, req *snapRequest) { m.dropRequest(t.taskID, req) } st.mu.Unlock() + unlock() m.snapMu.Lock() req = st.pending diff --git a/internal/agents/subtasks.go b/internal/agents/subtasks.go index 2f26651..d653732 100644 --- a/internal/agents/subtasks.go +++ b/internal/agents/subtasks.go @@ -73,7 +73,7 @@ func (m *Manager) subtaskTools(l *live) []mcp.Tool { if err != nil { return "", err } - _, err = m.create(ctx, task, creator, args.Prompt, m.modelOr(args.Model, l), agentActor, nil) + _, err = m.create(ctx, task, creator, "", args.Prompt, m.modelOr(args.Model, l), agentActor, nil) if err != nil { return "", fmt.Errorf("subtask %s created, but its agent did not start: %w", task.Id, err) } @@ -294,7 +294,7 @@ func (m *Manager) messageTask(ctx context.Context, task api.Task, user api.User, return err } if !found { - _, err = m.create(ctx, task, user, text, "", actor, nil) + _, err = m.create(ctx, task, user, sess.Member, text, "", actor, nil) return err } _, _, err = m.Message(ctx, sess.Id, actor, text, "") @@ -366,6 +366,12 @@ func (m *Manager) closeMergedSubtasks(parentID, dir string) { } func (m *Manager) closeMergedSubtask(ctx context.Context, t api.Task, parentID, dir string) error { + unlock := m.lockTask(t.Id) + defer unlock() + t, err := m.store.GetTask(ctx, t.Id) + if err != nil || t.Status != api.TaskStatusActive { + return err + } merged, err := git.IsAncestor(dir, t.Branch, "HEAD") if err != nil || !merged { return err diff --git a/internal/agents/summary.go b/internal/agents/summary.go index 4b2ac0c..3f14d09 100644 --- a/internal/agents/summary.go +++ b/internal/agents/summary.go @@ -180,22 +180,16 @@ func (m *Manager) launchDiscard(ctx context.Context, taskID string) (bool, error if err != nil { return false, err } - sessions, err := m.store.ListSessions(ctx, taskID) + sess, found, err := m.liveSession(ctx, taskID) if err != nil { return false, err } - sess := api.AgentSession{} - for _, s := range sessions { - if s.State != api.AgentSessionStateCompleted { - sess = s - } - } - if sess.Id == "" { + if !found { creator, err := m.store.GetUser(ctx, task.CreatedBy) if err != nil { return false, err } - sess, err = m.newSession(ctx, task, creator, "") + sess, err = m.newSession(ctx, task, creator, sess.Member, "") if err != nil { return false, err } @@ -246,6 +240,8 @@ func (m *Manager) finishDiscard(taskID string) { } func (m *Manager) discardNow(ctx context.Context, taskID string) error { + unlock := m.lockTask(taskID) + defer unlock() task, err := m.store.GetTask(ctx, taskID) if err != nil { return err diff --git a/internal/agents/tasks.go b/internal/agents/tasks.go index c9cd380..27a2a69 100644 --- a/internal/agents/tasks.go +++ b/internal/agents/tasks.go @@ -26,7 +26,7 @@ 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, nil) + _, err = m.create(ctx, task, creator, "", prompt, model, creator.Login, nil) if err != nil { return api.Task{}, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) } @@ -70,7 +70,7 @@ func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator ap if err != nil { return nil, err } - _, err = m.create(ctx, task, creator, todoPrompt(item, discuss), model, actor, nil) + _, err = m.create(ctx, task, creator, "", todoPrompt(item, discuss), model, actor, nil) if err != nil { return nil, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) } diff --git a/internal/agents/team.go b/internal/agents/team.go new file mode 100644 index 0000000..e7a7fbc --- /dev/null +++ b/internal/agents/team.go @@ -0,0 +1,286 @@ +package agents + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + + "g.fc.run/misc/byre/internal/api" + "g.fc.run/misc/byre/internal/mcp" + "g.fc.run/misc/byre/internal/store" + "g.fc.run/misc/byre/internal/workflow" +) + +const boardActor = "board" + +func teamLead(sessions []api.AgentSession) string { + if len(sessions) == 0 { + return "" + } + return sessions[0].Member +} + +func memberSession(sessions []api.AgentSession, member string) (api.AgentSession, bool) { + for i := len(sessions) - 1; i >= 0; i-- { + s := sessions[i] + if s.Member == member && s.State != api.AgentSessionStateCompleted { + return s, true + } + } + return api.AgentSession{}, false +} + +func teamSessions(sessions []api.AgentSession) []api.AgentSession { + team := []api.AgentSession{} + seen := map[string]bool{} + for _, s := range sessions { + if s.Member == "" || seen[s.Member] { + continue + } + seen[s.Member] = true + live, found := memberSession(sessions, s.Member) + if !found { + live = latestSession(sessions, s.Member) + } + team = append(team, live) + } + return team +} + +func latestSession(sessions []api.AgentSession, member string) api.AgentSession { + latest := api.AgentSession{} + for _, s := range sessions { + if s.Member == member { + latest = s + } + } + return latest +} + +func (m *Manager) leadsTask(ctx context.Context, taskID, sessionID string) bool { + sess, found, err := m.liveSession(ctx, taskID) + if err != nil { + slog.Error("find task's first agent", "task", taskID, "err", err) + return false + } + return found && sess.Id == sessionID +} + +func checkTeam(members []api.TeamMember) error { + problems := workflow.TeamProblems(members) + if len(problems) > 0 { + return fmt.Errorf("%w: %s", ErrInvalid, strings.Join(problems, "; ")) + } + return nil +} + +func (m *Manager) startTeam(ctx context.Context, task api.Task, creator api.User, shared string, members []api.TeamMember, actor string) error { + err := checkTeam(members) + if err != nil { + return err + } + if creator.GitName == "" || creator.GitEmail == "" { + return ErrNoGitIdentity + } + sessions := []api.AgentSession{} + for _, mem := range members { + sess, err := m.newSession(ctx, task, creator, mem.Name, mem.Model) + if err != nil { + return err + } + sessions = append(sessions, sess) + } + failed := []string{} + for i, sess := range sessions { + _, err = m.turn(ctx, sess, task, actor, teamPrompt(shared, members[i]), nil) + if err != nil { + failed = append(failed, fmt.Sprintf("%q: %v", members[i].Name, err)) + } + } + if len(failed) > 0 { + return fmt.Errorf("these agents did not start, so the team is incomplete; discard the task and create it again: %s", strings.Join(failed, "; ")) + } + return nil +} + +func teamPrompt(shared string, mem api.TeamMember) string { + return fmt.Sprintf("%s\n\n## Your part: %s\n\n%s", strings.TrimSpace(shared), mem.Name, strings.TrimSpace(mem.Prompt)) +} + +func teamGuidance(sessions []api.AgentSession, member string, stage bool) string { + team := teamSessions(sessions) + names := []string{} + for _, s := range team { + names = append(names, fmt.Sprintf("%q (%s)", s.Member, s.Model)) + } + lead := teamLead(sessions) + g := fmt.Sprintf("\n- This task is worked by a team of agents in this one worktree: %s. You are %q. "+ + "The team talks through a shared board: post_to_board sends a post to every other agent (a working agent gets it after its next tool call; an idle one starts a turn with it), and read_board reads the posts so far. "+ + "The human and the lead post there too. Post only when you add something: a finding, a proposal, an objection, a decision, a request. Don't post acknowledgements or thanks, since every post wakes every other agent. "+ + "Your final message goes to the human, not to the team. The other agents change the same files: re-read a file before you edit it, and say on the board when you take on a file or an area.", + strings.Join(names, ", "), member) + if !stage { + return g + } + if member == lead { + return g + " You are the team's first agent, so you call finish_stage once the team's work on this stage is done; the others tell you on the board when their parts are." + } + return g + fmt.Sprintf(" %q, the team's first agent, finishes this stage with finish_stage; tell it on the board when your part is done.", lead) +} + +type poster struct { + actor string + member string + model string + session string +} + +func (m *Manager) Post(ctx context.Context, task api.Task, actor, text string) error { + return m.post(ctx, task, poster{actor: actor}, text) +} + +func (m *Manager) post(ctx context.Context, task api.Task, from poster, text string) error { + if strings.TrimSpace(text) == "" { + return fmt.Errorf("%w: text is empty", ErrInvalid) + } + if task.Status != api.TaskStatusActive { + return fmt.Errorf("%w: task %s is %s", ErrConflict, task.Id, task.Status) + } + sessions, err := m.store.ListSessions(ctx, task.Id) + if err != nil { + return err + } + team := teamSessions(sessions) + if len(team) == 0 { + return fmt.Errorf("%w: task %s has no team, so no board", ErrInvalid, task.Id) + } + + payload := map[string]any{"text": text} + if from.member != "" { + payload["member"] = from.member + payload["model"] = from.model + } + ev := api.Event{Kind: "board.posted", ProjectId: &task.ProjectId, TaskId: &task.Id, Actor: from.actor, Payload: payload} + if from.session != "" { + ev.AgentSessionId = &from.session + } + err = m.store.AppendEvent(ctx, ev) + if err != nil { + return err + } + + who := from.actor + if from.member != "" { + who = fmt.Sprintf("%q", from.member) + } + msg := fmt.Sprintf("%s posted on the board:\n\n%s", who, text) + for _, s := range team { + if s.Member == from.member { + continue + } + m.deliverPost(context.WithoutCancel(ctx), task, s, msg) + } + return nil +} + +func (m *Manager) deliverPost(ctx context.Context, task api.Task, s api.AgentSession, msg string) { + started, err := m.store.SessionStarted(ctx, s.Id) + if err == nil && started { + _, _, err = m.Message(ctx, s.Id, boardActor, msg, "") + } + if err == nil && started { + return + } + if err != nil && !errors.Is(err, ErrConflict) { + slog.Error("deliver board post", "task", task.Id, "member", s.Member, "err", err) + } + err = m.store.AddTaskInput(ctx, store.TaskInput{TaskID: task.Id, Member: s.Member, Actor: boardActor, Text: msg}, task.ProjectId, s.Id) + if err != nil { + slog.Error("queue board post", "task", task.Id, "member", s.Member, "err", err) + } +} + +func (m *Manager) deliverTeam(ctx context.Context, l *live) { + if l.member == "" { + return + } + sessions, err := m.store.ListSessions(ctx, l.taskID) + if err != nil { + slog.Error("list team sessions", "task", l.taskID, "err", err) + return + } + for _, s := range teamSessions(sessions) { + if s.Id == l.id || s.State != api.AgentSessionStateIdle { + continue + } + err = m.Deliver(ctx, s.Id) + if err != nil { + slog.Error("deliver to team member", "session", s.Id, "err", err) + } + } +} + +func (m *Manager) boardTools(l *live) []mcp.Tool { + return []mcp.Tool{ + { + Name: "post_to_board", + Description: "Post to this task's team board. Every other agent of the team gets the post: a working agent after its next tool call, an idle one as a new turn. The human sees the board in the task's Board tab.", + InputSchema: json.RawMessage(`{"type": "object", "properties": {"text": {"type": "string", "description": "Markdown."}}, "required": ["text"]}`), + Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { + args := struct { + Text string `json:"text"` + }{} + err := json.Unmarshal(raw, &args) + if err != nil { + return "", fmt.Errorf("malformed post_to_board call: %w", err) + } + task, err := m.store.GetTask(ctx, l.taskID) + if err != nil { + return "", err + } + err = m.post(ctx, task, poster{actor: agentActor, member: l.member, model: l.model, session: l.id}, args.Text) + if err != nil { + return "", err + } + return "Posted to the board.", nil + }, + }, + { + Name: "read_board", + Description: "Read this task's team board: every post, oldest first, each with its seq and who posted it. With after_seq, only the posts after it.", + InputSchema: json.RawMessage(`{"type": "object", "properties": {"after_seq": {"type": "integer"}}}`), + Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { + args := struct { + AfterSeq int64 `json:"after_seq"` + }{} + err := json.Unmarshal(raw, &args) + if err != nil { + return "", fmt.Errorf("malformed read_board call: %w", err) + } + posts, err := m.store.BoardPosts(ctx, l.taskID, args.AfterSeq) + if err != nil { + return "", err + } + if len(posts) == 0 { + return "No posts.", nil + } + b := &strings.Builder{} + for _, ev := range posts { + fmt.Fprintf(b, "#%d %s:\n%v\n\n", ev.Seq, postAuthor(ev), ev.Payload["text"]) + } + return strings.TrimSpace(b.String()), nil + }, + }, + } +} + +func postAuthor(ev api.Event) string { + member, _ := ev.Payload["member"].(string) + if member != "" { + return fmt.Sprintf("%q (%v)", member, ev.Payload["model"]) + } + return ev.Actor +} diff --git a/internal/agents/workflows.go b/internal/agents/workflows.go index 6f9f730..ffa2cd4 100644 --- a/internal/agents/workflows.go +++ b/internal/agents/workflows.go @@ -266,7 +266,17 @@ func (m *Manager) beginStage(ctx context.Context, task api.Task, project api.Pro if err != nil { return api.AgentSession{}, err } - return m.create(ctx, task, creator, stageMessage(frozen, stage, parents, project.DefaultBranch), workflow.Get(frozen.Definition, stage).Model, "byre", nil) + s := workflow.Get(frozen.Definition, stage) + message := stageMessage(frozen, stage, parents, project.DefaultBranch) + if s.Agents == nil { + return m.create(ctx, task, creator, "", message, s.Model, "byre", nil) + } + err = m.startTeam(ctx, task, creator, message, *s.Agents, "byre") + if err != nil { + return api.AgentSession{}, err + } + sess, _, err := m.liveSession(ctx, task.Id) + return sess, err } func stageMessage(frozen store.FrozenWorkflow, stage string, parents []parentStage, defaultBranch string) string { @@ -288,6 +298,18 @@ func stageMessage(frozen store.FrozenWorkflow, stage string, parents []parentSta return b.String() } +func (m *Manager) recordStage(ctx context.Context, l *live) error { + output, err := m.store.StageOutput(ctx, l.id) + if err != nil { + return err + } + head, err := git.Head(l.dir) + if err != nil { + return err + } + return m.store.RecordStage(ctx, l.taskID, output, head) +} + func (m *Manager) advanceStage(l *live) { ctx := context.Background() _, err := m.nextStage(ctx, l.id) @@ -371,43 +393,41 @@ func (m *Manager) nextStage(ctx context.Context, sessionID string) ([]string, er started := []string{} for _, n := range next { - id, err := m.advanceTo(ctx, task, frozen, n) + ids, err := m.advanceTo(ctx, task, frozen, n) if err != nil { return started, err } - if id != "" { - started = append(started, id) - } + started = append(started, ids...) } return started, m.store.ClearStage(ctx, sess.Id) } -func (m *Manager) advanceTo(ctx context.Context, from api.Task, frozen store.FrozenWorkflow, stage string) (string, error) { +func (m *Manager) advanceTo(ctx context.Context, from api.Task, frozen store.FrozenWorkflow, stage string) ([]string, error) { tasks, err := m.store.RunTasks(ctx, *from.WorkflowRun) if err != nil { - return "", err + return nil, err } parents := []parentStage{} for _, a := range workflow.After(frozen.Definition, stage) { p, found, err := m.finishedStage(ctx, tasks, a) if err != nil || !found { - return "", err + return nil, err } parents = append(parents, p) } project, err := m.store.GetProject(ctx, from.ProjectId) if err != nil { - return "", err + return nil, err } creator, err := m.store.GetUser(ctx, from.CreatedBy) if err != nil { - return "", err + return nil, err } existing, found := latestStage(tasks, stage) if found { if existing.Status != api.TaskStatusActive || existing.Discarding { - return "", nil + return nil, nil } return m.resumeStage(ctx, existing, project, creator, frozen, parents) } @@ -418,13 +438,21 @@ func (m *Manager) advanceTo(ctx context.Context, from api.Task, frozen store.Fro } task, err := m.startStage(ctx, project, creator, frozen, *from.WorkflowRun, stage, base, from.TodoItem, parents, "byre") if err != nil { - return "", err + return nil, err } - sessions, err := m.store.ListSessions(ctx, task.Id) - if err != nil || len(sessions) == 0 { - return "", err + return m.sessionIDs(ctx, task.Id) +} + +func (m *Manager) sessionIDs(ctx context.Context, taskID string) ([]string, error) { + sessions, err := m.store.ListSessions(ctx, taskID) + if err != nil { + return nil, err } - return sessions[0].Id, nil + ids := []string{} + for _, s := range sessions { + ids = append(ids, s.Id) + } + return ids, nil } func latestStage(tasks []api.Task, stage string) (api.Task, bool) { @@ -452,28 +480,58 @@ func (m *Manager) finishedStage(ctx context.Context, tasks []api.Task, stage str return parentStage{}, false, nil } -func (m *Manager) resumeStage(ctx context.Context, task api.Task, project api.Project, creator api.User, frozen store.FrozenWorkflow, parents []parentStage) (string, error) { +func (m *Manager) resumeStage(ctx context.Context, task api.Task, project api.Project, creator api.User, frozen store.FrozenWorkflow, parents []parentStage) ([]string, error) { sessions, err := m.store.ListSessions(ctx, task.Id) if err != nil { - return "", err + return nil, err } if len(sessions) == 0 { - sess, err := m.beginStage(ctx, task, project, creator, frozen, parents, "byre") - return sess.Id, err - } - sess := sessions[0] - started, err := m.store.SessionStarted(ctx, sess.Id) - if err != nil || started || sess.State == api.AgentSessionStateCompleted { - return "", err - } - if sess.State == api.AgentSessionStateRunning { - err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, "byre", nil) + _, err := m.beginStage(ctx, task, project, creator, frozen, parents, "byre") if err != nil { - return "", err + return nil, err + } + return m.sessionIDs(ctx, task.Id) + } + s := workflow.Get(frozen.Definition, *task.WorkflowStage) + ids := []string{} + for _, sess := range sessions { + started, err := m.store.SessionStarted(ctx, sess.Id) + if err != nil { + return ids, err + } + if started || sess.State == api.AgentSessionStateCompleted { + continue + } + if sess.State == api.AgentSessionStateRunning { + err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, "byre", nil) + if err != nil { + return ids, err + } + } + message := stageMessage(frozen, *task.WorkflowStage, parents, project.DefaultBranch) + if s.Agents != nil { + mem, err := teamMember(*s.Agents, sess.Member) + if err != nil { + return ids, err + } + message = teamPrompt(message, mem) + } + _, err = m.turn(ctx, sess, task, "byre", message, nil) + if err != nil { + return ids, err + } + ids = append(ids, sess.Id) + } + return ids, nil +} + +func teamMember(members []api.TeamMember, name string) (api.TeamMember, error) { + for _, mem := range members { + if mem.Name == name { + return mem, nil } } - _, err = m.turn(ctx, sess, task, "byre", stageMessage(frozen, *task.WorkflowStage, parents, project.DefaultBranch), nil) - return sess.Id, err + return api.TeamMember{}, fmt.Errorf("the stage's frozen workflow has no agent %q", name) } func (m *Manager) discardStages(ctx context.Context, final api.Task) error { @@ -595,6 +653,8 @@ const workflowFileHelp = "A workflow's file is YAML: an optional description, th "base (parent, the default: branch from the commit its one after stage finished at, or for a stage with none, from where the workflow was started; main: branch from the default branch; " + "refused on a stage with more than one after stage, which always branches from the default branch and is told the branches it can merge), " + "model (which also picks the vendor: gpt-* models run on Codex, every other model on Claude Code) and prompt (the stage agent's instructions). " + + "In place of model, agents (a list of at least two, each with name, model and prompt) runs the stage as a team: those agents work in the stage's one task and worktree and talk through a shared board; " + + "each gets the stage's prompt and then its own; the first agent finishes the stage. " + "Every stage runs as its own task. Exactly one stage is final (no other stage comes after it), and cycles are refused. " + "The workflow's name is not in the file." diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index 14cb65f..8760ee9 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -461,6 +461,7 @@ func (e TaskStatus) Valid() bool { // Defines values for TaskTab. const ( + TabBoard TaskTab = "board" TabChanges TaskTab = "changes" TabFiles TaskTab = "files" TabInbox TaskTab = "inbox" @@ -474,6 +475,8 @@ const ( // Valid indicates whether the value is a known member of the TaskTab enum. func (e TaskTab) Valid() bool { switch e { + case TabBoard: + return true case TabChanges: return true case TabFiles: @@ -595,6 +598,9 @@ type AgentSession struct { ForkedFrom *string `json:"forked_from,omitempty"` Id string `json:"id"` + // Member The agent's name in its task's team; empty on a task with one agent, and for leads and reviewers. + Member string `json:"member"` + // Model Requested model; empty means the vendor's default. A session only switches between its own vendor's models. Model string `json:"model"` @@ -661,6 +667,11 @@ type Blob struct { Id string `json:"id"` } +// BoardPostCreate defines model for BoardPostCreate. +type BoardPostCreate struct { + Text string `json:"text"` +} + // ClientReply defines model for ClientReply. type ClientReply struct { // Data read_file: the file's contents. @@ -1092,16 +1103,19 @@ type Stage struct { // After The stages whose outputs this stage gets, by name; it starts once all of them have finished. Absent means the previous stage in the list (for the first stage, none); empty means it starts from the brief. After *[]string `json:"after,omitempty"` + // Agents Runs the stage as a team: several agents in the stage's one task and worktree, each on its own model, sharing a board. The first agent finishes the stage. + Agents *[]TeamMember `json:"agents,omitempty"` + // Base Where the stage's task branches from. parent (the default): the commit its one after stage finished at, or for a stage with none, where the workflow was started from. main: the project's default branch. Refused on a stage with more than one after stage, which always branches from the default branch. Base *StageBase `json:"base,omitempty"` - // Model The stage agent's model, which also picks its vendor: gpt-* models run on Codex, every other model on Claude Code. - Model string `json:"model"` + // Model The stage agent's model, which also picks its vendor: gpt-* models run on Codex, every other model on Claude Code. Required without agents, refused with them. + Model string `json:"model,omitempty"` // Name One line of 1 to 100 characters, unique within the workflow. Name string `json:"name"` - // Prompt The stage agent's instructions. + // Prompt The stage agent's instructions; with agents, the instructions every agent of the team gets before its own. Prompt string `json:"prompt"` } @@ -1290,6 +1304,18 @@ type TaskWorkflow struct { Name string `json:"name"` } +// TeamMember defines model for TeamMember. +type TeamMember struct { + // Model The agent's model, which also picks its vendor. + Model string `json:"model"` + + // Name The agent's name in the team, one line of 1 to 100 characters, unique within the team. + Name string `json:"name"` + + // Prompt This agent's own instructions: its role in the team. + Prompt string `json:"prompt"` +} + // TerminalSize defines model for TerminalSize. type TerminalSize struct { Cols int `json:"cols"` @@ -1549,6 +1575,9 @@ type CompactSessionJSONRequestBody = CompactCreate // SendMessageJSONRequestBody defines body for SendMessage for application/json ContentType. type SendMessageJSONRequestBody = MessageCreate +// PostToBoardJSONRequestBody defines body for PostToBoard for application/json ContentType. +type PostToBoardJSONRequestBody = BoardPostCreate + // MergeTaskJSONRequestBody defines body for MergeTask for application/json ContentType. type MergeTaskJSONRequestBody MergeTaskJSONBody @@ -1672,6 +1701,9 @@ type ServerInterface interface { // (GET /api/tasks/{taskId}) GetTask(w http.ResponseWriter, r *http.Request, taskId string) + // (POST /api/tasks/{taskId}/board) + PostToBoard(w http.ResponseWriter, r *http.Request, taskId string) + // (GET /api/tasks/{taskId}/changes) GetTaskChanges(w http.ResponseWriter, r *http.Request, taskId string, params GetTaskChangesParams) @@ -2637,6 +2669,32 @@ func (siw *ServerInterfaceWrapper) GetTask(w http.ResponseWriter, r *http.Reques handler.ServeHTTP(w, r) } +// PostToBoard operation middleware +func (siw *ServerInterfaceWrapper) PostToBoard(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "taskId" ------------- + var taskId string + + err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PostToBoard(w, r, taskId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetTaskChanges operation middleware func (siw *ServerInterfaceWrapper) GetTaskChanges(w http.ResponseWriter, r *http.Request) { @@ -3698,6 +3756,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/api/tasks/{taskId}/merge-after-review", wrapper.CancelMergeAfterReview) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/merge-in", wrapper.MergeIn) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/discard", wrapper.DiscardTask) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/board", wrapper.PostToBoard) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/events", wrapper.ListTaskEvents) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/changes", wrapper.GetTaskChanges) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/image", wrapper.GetTaskImage) @@ -3741,247 +3800,253 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "7H39cxs3luC/guJdlazdFuXMZPdupbofPLaT8V6c+CzlUnszKS3EBkmMmkAHACUzLt/ffvU+gEY30SQl", - "W45ncr/YVDcaHw8PD+/7vZ/M7Kq1RpngJ2fvJ0751hqv8I+XzlkHP2bWBGUC/JRt2+iZDNqa0795a+CZ", - "ny3VSsKv/+rUfHI2+S+nXa+n9NafUm8fPnyoJrXyM6db6GRyRsNMJ/CC20JXzxbKhDfOzpTHv1tnW+WC", - "pqktlWzCEn71+/JhPbsRKyWNF1KEtTNCe+HWxmizENLUwljhg1NyJdStMkEspRfSOX2rajG3TmAPV3Ie", - "lLvyamZN7c+FeqeDqrnfsFSipYlB5wtr1HRSTZRZryZnf5nYm0lF84CH+OHk52oSNq3CCTptFpNq8u4E", - "Pji5lc7IFSzqL5M/46J+gO/o5wX3Qn+95L4+VJNG+nCF87+SYRsKPy2V6U0T2os7Z4MSUjTaKBGs0MEz", - "KCbVZG7dCrqa1DKok6BXalJNrBN30JUOwgfpgqqnkzOzbprhcj5Uk1bXMBF+rk1QC+XghVfea2uueu+7", - "73yQQcEb6YU1Arf9gj5Jg/VXd7lUgjs98oK+L0woSH8zNuitMrV1hVcfqolTv6y1UzXsSDb3rsP0OS06", - "LmG4K1VE0m7z7fXf1CzABPJlbmP3zCkZVM17W9yarSXFb643xRXPrbtJPQ6wRYel4PdzZ1dniDq0xiMv", - "dC3sHB8hFoUlnCT4Ez4RN0q1vhJSrJT3coFPxfNGrmslnttaVfEY4nNbq3fT0uyz4bcn+AyHOvJirp1n", - "VBRO+fVKwWnUnicbsaISs3UQMoi06EqsTa2ckMKoO2591W3uuZDXXhn8QNiwVKkrX5ztCFatbK2a7em/", - "Vb+slUf6AQ3OhVq1YZMRkwTqWs3luglT8SyOL6xpNsLf6TBbKi+uVbhTeCC9sHem+xJ7Lk/W3hnltmcF", - "p6ixC23E3dJ6JRolayEBLQmk2seJAlCkAOw/8nFexZGcbdT2QE7danWn3JmQgn533YgnXinxFp8en2+N", - "4kWjfYCp3eJOK6+EXYec2upV26iVMkE5OIFKwoGMQ27T3ZzgpC7qRsFHdElMKrwSG3U43c4P8wX0/op6", - "3Hr+Ng2x9ep5NybMcX2NW+G3wflneydW0mziocR2ALL4iZBOxRuvEtdydrNwdm1qYY3yQptZs65VLfCG", - "wx7weIalTEQenq7wYlSmBppfFch6Rl4H13mGNIhUu1CmI8T9Tr6xTWPvvACCgJNEBMdfEXXupBdM9MSd", - "DsszsWjDyT9RS7zz4T5BklPBXe82fLSpK2tyMpWj1AwfIx7U6t3k59FpX/Wvtv4Kss4zfNd1JWZw3oy4", - "3ojrjVPnwtI24ExFWDqAGbSj07c2QfcWnugg7htumUeCQJ8WwDy41AaXGdGH7FIjMlZaZHfV4VHvXTpV", - "fmvlGFy8/kKQs+WKecrB5ScZhAUKq0x9BexLmdPQK7koECB8fCZqGaT48e13RTy80abOacJc4/pqPZ9P", - "Ysfwp1w4uWLwwSqNbP3SwoLXrimiykq5lSwhCHd2Jl5TC+Ht2s1UcXqtLDG7MMkz4VRrT5xqZNC3SkDL", - "qeAlS0O/BLQUmnhCJrF31t0Ep+CCvva2WQcFiJj6Qf6wTOXVfHsqAKgzsdABSTyiupNmUV5NhNoOltCF", - "Hfu8i7ODbdjL1uFul/DyT429LmAkiT9X1Pz9OO/1Cfk1Xe9fBp7I3uTGT2Rptc8brUx4q9pms71oOC6l", - "y1zWV4R3yALqBqgbzwH5j7T6602ZL1dRquz3fKECCRvQryOWSczsuqnNURDXSsykc1rVkQHY7vedDldA", - "srf7vpZ+SROGRigyrH0lTr5K4g1cJje6aVQNdBmpqV6p/lA5panLDBXPG7nmMjO2Du06jM3Qh9oC52pq", - "+KmcE8EuFFxalZBBrCzy30o7YsX/9WvxP/WfRg6pXzc4jqxrDYPI5k22v8Gt1VCuWkpTewJTsLY58uL1", - "8zfiuWyaS2ubt9jjdLKFR9tIuQvXED4FGQRkCWL/mAmV/sYLCahl1CyoWryWM6BKta0E8ur2VjlxKlt9", - "OsO+/Sm3hTn2cVm6xXoV2amPAUjqqAAH5BtX0tRjuwscCTAq4le/FBIWR/y3X6qmidR5aVdK1NqpWbBu", - "U8HjyOxHYR7Yuba46+Uze+d0UNuHFiSheHDPRSsdADWOrBXxkUxBDjrYdXFn121jZX0GJ80p4ljdbKlv", - "YQrWKKFMcBuhTO3Fuu1fRngPgLSJUPu/p+fibikDsHJCozjgFPxwqm3kTNX3EdVsm9/2sD+Tiqc6qSa1", - "vTP8MxG8SZUBclIRchRv/PJlHfusROqyEqWtEU+sq5B/7j7B59al7dkcA/8KXxDu74ZbETBM365Wfgxf", - "gR72tEiIeElw0F401ixGpANrC5JwdqCA9T3Bv/FswcmeyaYpT9VJ4+clITYiFx5RbiV0Db0tVCDpoUci", - "YiN/+j7+fFV/mCZQF7tq1yFHXBHsgUy2bcu00K5aOQvP8Wxt37wjaoTX8BjxAuYCpzepO6wRSqNsQzz7", - "VMBlZJskewkfYDMR3UjnAKC38/lUvCCdA2yDCP2vol6hvNrCsphUE60v8VGrlQ7lmxNYR3pPoG5bvJOv", - "17qhfQQKBNupgxd/AuR5Tq2XStbKRV1F4iBow+mmMKihHVNcphtmtyqVOgSi1dTKB2FbZaImOXUxHdOk", - "bo26tH4EEq/lDKizXCnRX/Cfrb/Hcgtjon5SzmCs8bXCwST2wqmZQrW4TPwYYs56JY1YyVpF6Vz7NL42", - "MzrbUZeQVGs41Ti1w2CE1+N+NpiaMUiriGWDjS2dQ0b91/G8jRzD3aNTs2L32r2Ey227a70wFj8f7gCc", - "AnpJpxQvxzMxXehAj5E3hDekylDvUJuD94MXKxlmS6GDQN1LuiuEvLa3qi/OXVvbKGlgmoBpZTFM/6rG", - "hE60HehfUagEfqDP+msT/vXr4r3gN6tGm5tsvGwmUb4aYqUMHTCE9nA5gnYILTqCe0wKrI2PrFSUcKcE", - "rLNh406tmJoC5FqrTfDCWORYKgamutUzVQlvZzcKAdzqtqc1qrUDEBBzgAMeqD2MaHK5adUL7CR/8g11", - "mD/6gTofIiLuI4/YAZq3sUo4N4Kp32kPLMM2rgLU+acOauX3mfkS1ndXhHRObsY5IyB6GcebqyB6e+Os", - "DblOOizp2QjPsDYzGVS9ZzxUc67gZPFCATeMuFNOCadAxdZjLBO2DoCPK6sSsPLxSwBPltUBySEbygFE", - "hxsW+74tXr4Slls85yhvXe0x0z1ExRGVaoeaJEAFYE1UG0fFNhrUPBI+KdZegbljbmdrP123MDzzbCRL", - "haXaiGsFjCmwaeIHMJ/gaYcvhfZ0RYKCe0TRtkHG/+z9NmBbZ+HnGIS8+qUHmnEiOK6/2jI//jJhMFa8", - "gd0U96p3EBHeMD4NDvVtlIYPOtPYU+lAw8Ep8hF4OwWU0KRTQknXaOXiXuIRQ76hlQt1wPHi+fKApcUC", - "nXy+BJVjAfVZ4vdldeK1NtJtyldSrRq141Pb1FdlouYU0GPGTWgirtXcsgRML3dqeotmq7XPpVZZ16om", - "fb2ea/yJ08VfPD4PcTVD0BQMWiOEjEerMtjlwEhAG92KzmOkvxcdsLeJMmmpPej7frz85uS/i6DehXP8", - "F04uUv4yDzMONeZhDjmV6l0o9rHjKsGpLW1TezLUhiUzvtE6B0s6/Prgq5phlI/M0xsD9wuwU2zBugV+", - "sLikEXiVZ0XdlIc2tTaLi2hRHXKK7+AAEMG1ue2BzcxWzPW7KTO89ZmotV9p71VdIbVv1DyAtjfC8rVy", - "CyXupAPLZs564UBFFmcX38WT/4Y/5j9fxT4Gy/uxrXmRfRgna/Iu+tmD0xaRx6dF8MItV9jW3j2UeS70", - "5Nzt48XfEQXSi2UQrTRK+KW98+f59sSGJF3SZUVXZyg72lzvA8Cl9DeX8ppb+/Lxhzd0lcP1D0oXtHOi", - "DtS6WkW1Bt0toCnGL0gYSFf6QRdaPp/BlTZq175kAI3Ab2sf9lFaBARBr7T5r8y1ffcqqFXhPnsshm2k", - "q6FxFHUBAJTOuYuMOtXEyaCuGr0qe731Gaw+eGOnZ6SUfhfEk9fS3YBijvSYqOZYWj1DLwZ0YpNe/OeJ", - "eCKO8Y74T3GKf/5T/LPVs5sTa1Cq+88T8Rfxc97uL+9+ztuBSwV6myACHVciTqgSd8vN1XUDkl9dCYsT", - "9uKJkrMlT+jIY08dph5XYrVugm5BnRvcmtyi4jjHlXAK1BToXiGewMpW0t2oOi7wCPs7roRMhnI/FT1g", - "nwn8bypykJ+xArDKnlL/yVMIXpzQC7rkjyvu6a+mZNTYw/U65W1ze088Sx+NGD97XqgHHWg8LW/5s9Kx", - "PohOYy9MpXebmcnX4upgK20Gxa7jguiV95w4/47jj+4XPVeLDlqjdIQgU2+Tko+ENKuwQRLQ5hV9/tUQ", - "+AN4HDxdX2Llu/Nw8IwzZ5MCYjyETtaq0bfKfRrqih4ahSsRfBGErn2PgYq2UfLr8EKCpOt1HeUKglvv", - "HtzG3QEEoOXVqJ0sST+HdzjOSbOC72D8AnUXfAjC+4EnLa6GP0oKMQZz1UOhvTJ0Cds/A1KO4YQlXwDr", - "+BoidUm6v7o79DoiD6royUwGCFV9Kdg0WFdL0+Y73isT71nwvZez0GzQWExehele765ovuVRkZc1yoFy", - "v2VEHC7An6YJDbaBCcyCni3FSoIaKrfSRBA7JRb6Vpnpvi2k17LJRsL9ybkhBTxJBFVZD/qwIzc8WelQ", - "YX97T8olj5piIlrmFxGA6EmHol6RRcxu4V4XCjqQxt8pp+quD/zt161yXtUjXOd3Stbf29LZ/YRscp9T", - "2i1/GQzFgFga5UWwPfFB0lvUWnZN2IWVOynv9RjdZUXpPRZZdBmF7ge8R9ZzCSlek556jHDuvfo+gnrB", - "bO935A+wu2tPXreonpBBOXSqnoqf2I0M3CCVy9yWB5b0Kn+Yu/Un1WAMo2BN9c59HsxzP8nZL5OOKZm+", - "t0HPOe7rjVNzX9L6AuEz6k5oOMAkRyGk5tpov1Q1ws4Dduv5hqBJWnnDXm1oU0frMeiF/bYjFwoqhbF7", - "khGNXFYURvJa6ON/xVdgNQAvwBiNUeyok68KXXUvVb1rNk611pU6uEQ9A0ILyYAy5OMupPDr1Uq6jXgC", - "r6ckQeIrfnEmQN48PkDp2MGim0kVQdxfYQkl3kijRjRUtZPzaE0cLAxk4bulbpSQdLEhqpihUoV0qmtD", - "552P8jYA0Q6k6p3jsDsJuDxrU9s7tPjhh8mYzt0IRaEkcPi06figNK0DgBpnVHVAKAPPBT3TrSzpyGvt", - "20ZurkYt8wsdrtRK6mb0bfy0FJqRnEbw8C0lKty9CkKiF46ulQk6bM7JmBag9UIFYawITupGuekncNbo", - "rTGbc764IuAotufTXOSjAIbDUIpDg+cDP3pUDFXCK4euMkEsQ2j92enpX9dPn/5xBtTsyIul9QEfqDN6", - "Dj3Rg9PpxxkJO20CAxAnv1eyeUOMxGcJPmRKenXtpBmxSIzwVKM75NTKhrFXrb06zMKRg637bGvCabj7", - "+dcziMe4nx1rO3gBW3MfmUewM9uMeB3cZ6OdxKMH8ZUcQUyxSOyJPmp7iOra6FNW7FtJPxL3sy+GuOip", - "FMcFf4F104hGmUVY9jyWto/doZHB3K7qhwizPg7XQcDiye3dFl9yl1zvdFkvGaQHDhXknghAIMuKrGUb", - "lOviKlrpPHsW499OzezCaK8KrnyVuFEbCpBgN9OSvhh22Iy4NmIAAzVAxrkCdlF5NvQcbL3p4/OHPfpG", - "BmOaWWkr3qZLZUDxDUsZXThuFrG7bdKsrcLbdO1VxcGu2ixgrSIGPUG4zDKGRu2365RiTG4xuLWLKux5", - "g/ob3bYjzvAPIe0j4Tpz4ATQrX9THGpOJs8r1FjvROLDDadVIRS280iVde2URzYOLDM8gQxpdfBCGwiy", - "1EZQgMyUW4H9Ln4B8glaghQaPUEmKSJ6lGo+hepgRPR8edi2dsE+w5ADw3EFOc6sr1c6XNGTLqDlrBMs", - "otntOApvCKGKwEqihrpVTodNJWYyqAW67QUdGlWJWgWpm4pcXlBM1kYV4ZeiAE2ZlPmyVwEDgtaVHSof", - "VMsnC5N8oEtsJbyFP+L6hZMm9xzoYr4BVpOK0XpSxWEO9CUg+jGI8c4evqDesyffxIGyZxdxzIOzVzwA", - "XXYHA2ebUrRObQUJR4tUNAD3tEMDIlCivBwD/1toiFpnV+1DdUQl7d+qDQc41VC7IiwY+A+1+KPj+5Vf", - "ypLoQm/Rl8YpBa048QNHfum+J/SRF39++exFL0AyyBtlPt3Fcktr+mXc1eLIo5bNB87VkyTXiKY4LWZP", - "YuiOitoDcvH2Ia1LrNsurvkAj7AxmWR7wm+sx7ttIBvGafqK8PSrc6bGc4pbm/tTYLBOoTlEJNEx/HAK", - "352+Nx/uKxlWk7i3Iy/1YlFyvAVV0pUy9RkHd7HQqkx9Yucn8JbRZxojQQDcss/7WNc570h/w+mUdKAr", - "A3DnKsJjmtScT7pjSumJUCdEs8htOhgjBgFi4K4Af1zBHxUMdMXXv6kxZteBskdJRw551nSbQPgLnEB0", - "R4XootrnFwLPa1IlmEyqSRpvUk3SgBMKoXOTapJgcuBtEc/5Je3H6zTm4MXl2pmXpi68sLZ5TvMZvHnm", - "b/7Msxt+w5MdPH7bzX3P1VC4DcykR3Uy9MuPd4d4eyXmi1B0V0Z8KJMJH5CyEymj6GrOCYRvxELB6bve", - "YEDVecpk5YUF8UY2DXsXQr6TW5VU1VPxjAwyecovdavtOnbMJx1dhp7EcISUoGihKnQDO+5n++mGTxaC", - "a6fVnHxvDr+GrqUv+12zFQFnAF5JoEcmJYaiMaeR3uPJYz3HMVte6YrAeGOj+BTSaiNchAxofiY7Fb3D", - "8w2LrbIgX6C3c/DLyyg0T2AlNZt62ZjVZT7iuU7FWzVHjaw1/WGIGID7+GCG0fAqmzu58f01i2ypcQQE", - "eDz1BJJJNYGpHXqIYdQ/Sa/exI/Tk9fYyzhbn/A2xTiwcSguwVs0CFNaF2K5Pjq7zahqrT+3Hwwra+xc", - "fAW35VdPnwJb7eQsKOchkZb+ZU27oU1vq4ujdGzRPhho44Nbz8ZM2WXFV2RDd3FVMMqzEBTP47CYVEyK", - "RvSkdzjS6cqPRHHltfYz6eqkFR0mTOG3eHHNwJag6qt1CxK1Fd1bHc5JCRNJDDYdi/+nz4qWlxiUIcUL", - "atU7mdESggSDXPBnjYUTuFGjtpcdKdsyZ1nthSTo44j0GZ7LntU7J1tERvmr4kq1AUJfHpe/G14KXRJD", - "HJx8RlrlciqCagDgFJeSDh/l5DnyB+umENtewexKdHss/cflMuLTFSEXNeyA4nmWOsCNlgBHyaF04I9V", - "2QdkRJJm4ZeCOYkH016sdI0c31ToulFnQs6CvlVVZsjlKbLhiISCdBq6D/AaoWU4NbOAzcD3uQX8nxD8", - "rPsZzeDUFfj4985F145AkZoBF0hnpLYqxbG7NVjdO2dx30kPcw1uNb1T7PsXQqcX4ORwcXkTTOi0iP4n", - "NKFJNekmemjOOELSYVq47GlMIpc9+qabRf74dZxR/vBFNrv8+XOa6Y/tXjUDanTuY3+iDzqdQEaO0okd", - "pdB0ZkrxFYuyleTwKLrAHD1/MDKFULJkEzJfwacjGndiizlFRklV8RIvZ2yWMmkgtmovgpOzG0RhwLMu", - "7WHM0ncw2ellyy15uVL+i8LsQKlXY/DvjIIkK8y0g+GWbUsiEvseGHLQIFmcUh9UMR0CB9uDDHw/Zf4w", - "a0Rh8vX1FVpsDgzkytKwHh6TCQ69V2M5F5/lm6e8mK2dUwacEplQlAV1Zm9HEKdlK8ZV59RysNHDF1xQ", - "9hiJOlwvWMlIybIVGkAqXzZ2kIvIwWYgAswVOrEULjx4HKEHsfYJkaKl5X5YBP29VfMS9nivrjL0Lyqa", - "3X2VV4Us0eVUneixKdNKARzoHLqweKgw3Vc/ITXfgpgPZY2Rz56SUpeR7Fe7utYlunMRiQmHlsQpsLgm", - "GkwXSDhN17scoDlSKeCEjO22xbqDN+X/4NT2Gugy+GdnfescZ+ep6tPl/h53xK5/qoc42T9A24S8A215", - "u7dPcPFiIavKWyUL0Rejyk+vfolRj5mbV1SCWq+StQaZ+KW9MwfpNAvx5aVJg/vZ9mxB6XDF2R+3DsQO", - "1w4SJHZn10FZAdZC3BVZpJmBOTxhzMw2JbvIKzb6MWf446uoGWllo0JQwEB6vTDkv4MqKvTAlgI6XDvA", - "f0I4+gzVy+TKaQ37VmKgKhx25YOQC1s+rI/hW/M4Mh+neGEsi4n076TGxGIp/UZG1qr4FyqIatTP9EcQ", - "1woaO7Wyt+WsGg/IU27iQUk2Ad0TNOVICsd7iK+wlF2iayZk3CtdOKA6ExU34oDQ7SBHPyMjln2ya1e4", - "Rcrejpd8AhOGX4M8Ud4Imt2YegTfRiPsL2vpl8nAZArqtmG+KkcANZazyVs690Xo0UR+Wav1LiLC4EEX", - "o5hunf1MTbc/UWubIpyZ4mizSDuKimFyxYYuo7rYdRmsAOZL1dyDMNEakkBVQDZ4lTC5B1Ba2UreqJiL", - "EhETP+iSQ0c3leDTgtlUH66y0UchvId+dODNqQerADrZXwc/LLpRRcMQPqeLDL6NIhAtFhu2a/zBGjHr", - "Y6EBWPA4ksKNDrT9irW4O1ZBO4pQ1ugJwTjBrmzUwQCJIg3jl7xkvqZ0GNeP0QdX++PJ6UKnDWRi39Gd", - "qIeiud6L7AC7flW2FFwmW0XkMjJnVngG33rxJHUBRzT98c//dlyJVqKvDYOOZo+wUm6F+hXpxZsf3l7u", - "FI32FgUZSQUWL5TasotPG0AeCaguQ+6Z7cRyxdkcKe0dTIqU5eQpQtMXT8iUOcVhmc2idMMsAlHr47MS", - "e0IEg27OSqjGK9HdyVUkLxz5gQPYeQezzsSN/HhKbk+EUga+mHXd9DzKogs9PYGz5uPrq1aRH1MsFkEF", - "OMiei1uECNV5znAfKWuodXxRsxNJxaNjZgPipqe7dGX9aaQH9DvSmoF2qKhUi6MdqFIDrnWgT0uPWJnW", - "NcEpvUlTHLwYPHydJp0evchn32uo6vxJroZLD7+3z2hd5bw+yGqWgXI/SKz9s9hX92hrjmvfTRJnREzf", - "1UHSSc9LY1tW2QpMORdPk08JiZZGHSS6jOsjq0mwtb3SnCtjz+V6+cOLH6YrCsvpix65deBehDYawMqj", - "w67EwWPLbuC5s7+qXOwQTxYqwNb8FNtGW8TMtpvjQaQgTZ4VCWN5P+OoV25tRtjq2IQ4DF0zlcTu7Rx6", - "JpPjYBl+KZ3aDZSrpMHdO2wOKfyKI+5gEkDhRsdBt4N7ePz3szGw1jrn0ZI8O+y/6qTg/IKNomeWOGtH", - "aQxWj+8ghUOepigs9E/qmBxPSdF8WZwvY2zk5B15U4FmR7pUZSWxxifQQ04ImNlPLFNfBqjIpTzxg50/", - "RXcVwXgj7sTNPXJUZMngSqHdTpUW7lRaTNxz5L7mdBZIUsG8kIibdVrI/vkPcJBRBucRlza6ffdLEn25", - "HDgVELEl8zhWqxsJBB231F+QALTl685h6NpHHpUYSvagqnosGLPTU/G95Q93Og2MU/rDSK0UXkI0Vkdu", - "LZIvbXxQEuuZEbll3/xERVHTA6s7I76v5+CSG4q9uFYzu1I+ZeeHPtgLuhZ/nVCUF/6JP9WZoEf4OT36", - "66TqgvmxKYCyM16Sa5C4zHIxRDfNjHm3XvHdlCfwGTPWIWTHkO2SMnlFRgQZZwQxkDCmIxFhq0nk9NG4", - "d23fJU7PT7KNStT2YN7l+jIf91JeP09DX8rrb3h0aNdN4FJev+I5XMrrt2kal/L6p24ml/I6hr39zEv+", - "aSdCJQSS+W2d+8QyZa/oEOCjsKQTNgjqVCAmx8pG20PFibxI7aDL/3j2+rvpPUIXy14y2djFrWc4XnAY", - "1dBDpsH/V9ro1XqVJ/jJmDNn7/a22grMafyEvyxOy9Z2ezrqnfbB70w6Orh4qMRnYvwkebgNMgdngnu6", - "Yg6zPNnaYpq2fWYOnnjsf2zF5ZRv17becJHM7axo0OERxbaYQExk0rGORuTxR/O1Q7BhjFjR0kVW3RHO", - "Wr1jKqSEUdIpzlCPWhzMOh7nV9AAaj+eqn6HqixOHCkgTLtKDDDsKzpkosmLE7VE6HBY8/RAGlkRzDsA", - "jG0Y3o/FIO7Z2pdMn6rJVXZdANOdNFSGgT9Ns4erdLaUyTQoDXMjmgskjCjHRmMVDlVE4uDBkmQ0/ZjL", - "hc2zBQvYzpCGx6/hWprtj4Yl7xFvwcKmUjEKH7XfOTdMeVycAjVOUTle1pDtyTycGOJB3UR4TENSLpKs", - "giKsSUTes99hNVkbWlvAJIR432ZeKEhF0O3OK7djVlkvhSAJGZaeqFZ5tH7W/skBF8iKbbeRJcnm109X", - "nE+suOWcYGzrzsYw8B3MdleXUrZtNBYYdcc+ZyNlLz9hhoeDEzFUE5MlcblqYxaXXZfbdtqXj8nnUJxB", - "NYDy2O6MJeB9MKQGCzk4A8U2o1YKt4qcI95UTXJPp8ikTKC85msLhmabK8oXHG4JUovsCxmdgYpakqfI", - "KrmIDuIYLqJiPlYzSGwligtDhM9W8b7Ari/sCTw8AdXwScxQdoIlM5SjKmqk01zcQ1ynqI97JZHkEXbt", - "z9t1obI2ilRjOSqy7dw13QICZOHAuznTgutpHXEDa6Cx9HfPUi2s2CvZUYrhk6/SPcQrOPKUuPZg354M", - "ymn7thQthzmQ8qmDNXTaONqo3rb0vHAPQ4AHxxP1z1QmpvdAVsfaWZSmrb5fLGn0Ux83DfJN7qTpzjsF", - "7nBmbHTKtu7Gk03pOHnR3c97rhciUZjpKN6N2OnYIeEMcwYByQIjqcC6NXfaq21TGNsPspAKnAtluuw1", - "SaEEyRiP6Zwiuet5cfM0JtW2jeqT+3MjCNG481MatXvWWaS6Z2yS6h5k3t3dw2St6R7lJqXuad+vm5d+", - "NSa49VRZUb81BH+0bR+K0uVzjUNlau9satkZ2HWQ2YeuxJ/tuK7uryTpOivNhr0ZC5MIY9zHMHF6dEBk", - "eTU6G9I1DL/SGxZayinUewJR7sj6UGGqVB65igvbhsUHlATmqJxhGj8Bdw6UqpwnLHs6fTp9SklflZGt", - "npxN/jh9Ov0jFZRYIuiwNKOsV9qcdmbQhUJpGkCMfOKrenI2+VaFi2hZ6eWs/sPTp1mJZviJtJhYzNO/", - "cUIhInQHkEEYAdc39KV1t8qJpZINp7VYe6xc8wEb4zog5S5OqS2W9rsI1imfleS2Tqw9eirIqDQnXS/o", - "HaKe18k7an9OvjWbFvUmtQpUFLcLI90ERQntVmuP9ZLffP9tJf79zctvK/Htq2+Akv6krt9MxTMuJPwv", - "4rX+ExHMPqx/TNkUutjmP7H+KYMzTut0oed9AHdlYlMVky1ko0//1qrFQ79tzYM/vVPX7X2/7R0QZnIH", - "aPjVJ0NDhHwBCS+jd0uN6Z2ncLi+fvp0rLs0v9OUJ+nrr/54cOsBYp++h/9e1R92HdGEMzsP6O8McbY3", - "ESB55OnIMmtJ9n0EER5y3tyv77Fd6HEmVyooBxzJ+4mG8dhqTTfhhPZwMsTmKlvTcAU/RzQYVNnO8GAo", - "+/6kri+4TiJQTZJuutLemPWVLIev5Yx9SInAepSBerXCScfMBLJL2UmzEZQO2me5iaTIytr3PkaXrk10", - "KkS7pK7RTinNJq8wM6y2isK5z4aFMFGD+YTysKdIirHnJXzOlal9LKxLSS1KNJejoGjqwyP01dOvtsF8", - "QcmD6xjv2AE9hiXcm0B0R353ueTRrU9FvPJS1XDlUYKewcbiZldUCX3xK7qaiSAd6khglzTEtwLMovYD", - "i6aJC8q02SV35Stq6AkNX8EGbKZb0P5WMaQveVH34ypgsp+EEHBB6Uc67N2O3e/AV5N2ffjmptLZg91F", - "8XVu3f03+NlsptrAjpXVR230m3Vpo8c4mk+wx/u4hK+34fqWU2I+AAvice3KORaPJXGvJ+g2Rk2nAuM+", - "8Q+0jQFMAY5e/XIuahlkZECx9CNs4b9f/PD9lP6MmsbvpA8n+OTk1QsoW49f0Ltf1pitPiLpsZBOET3c", - "dJHe6PIaZ/QTpQlCGaWK5fRkiE4nikd2SngkwUjtGyXr/4EYHb+IHUYbFuTtwcR3XVHRtIwU1npnlBML", - "y33YrmJoiVZTVs2XsSRl6SDSvdEdxR6kJlUJq8ZDtrhPhGjXZRKrP76rLP3bKFUY+RSAWvquS4/9817S", - "Cnc0ofAJBUL2z91eSvoyi0f+iENEPixjZ+hVl9N+oGQT3+gGdl8Y6Ry7dnJxEofIJLM8N8lp88gL8FEp", - "4ReUYo7OLCXkGmxBz5nx3hv4EXsfFTqHSTV5ba0DkGK3+HR4paoR14wtJHoNRS7pUlIrn/S92iXXKz8V", - "L6K+N1gKhefc/kMsOu0trqwPeMaca57ljMbWlNwIE3WiQmcqYmOHfG4vtSE0jQwuWoY0XpCYTwCYZdSR", - "sg41ZfxHMro2sZhKCQu5ZNir5Nl1yK15P0G3V5rsoOvzt8YS4IG45Ena+3sL4U+/vlfrf3soLTt9D1Pc", - "I7J3y39ExVoG4zJMYaKdINyduMdhjAksD5OCV2oXNF+rxwQjOimMQBDYHCBfyLQwINaFSZIZ/bV6pCOd", - "Weof4UA/FDjxyDJwHioVr9QpW/xOkkdIUVS6UOww3XmH5Ptz5Hc5i5T2iy8eLEX0SDvXG+Ifcu/msVjN", - "2OmlajaPuE4aoLDQFNg4QJNhmeH+yd6SI62rfeZQSPWlosKr66sr8VyxR4kTQV5vI99FDpNPj3IZOD4f", - "ro3uQaZeR0x55Ku9h5ogRu2T4i+30QM+Y3G3L5RUIHRIinW+W9qGoq/9GYcLkIgeg7L9P391FJZ58F5W", - "j4x/QsjfE+vin1k+gSwzM7/UnmPUjqfijUSfw9kNhOe20qeYbpxmUkBA9zLWQRuTiaCI306peyCfUG+f", - "SETmlDFdVyv5jhzd/+Xp02q32/vPj4jOCJA36ICzjdI/Yr5rriOG/z6Jrqf/8hRiuBssgsX5PXzQTdOl", - "CSrt6LSEt1E3v8P2iWr9ZOaMKusSNnMVzRSMLOnxIC46dx8XPyUFITbNchgOK+otVUMR0D7mhcf79x1H", - "rhbwjnMzf0dqjscggv0SiQcRwz98ssExDJkTRY3RxB78c4DecaZ96xJsMTvUx9zRiFDGBjVOB8fuyg59", - "OLAYszdcQ1zAktQyG/TKHKpv0hpvlGo95YLORJGVWl0rFz2tFipg+nE/HSVR31tKX/X4omsc7VDJFeE6", - "HYX46Xv4j+VFdJ5W22zSC3yeRj5EwU2f1I8kzNGcHyrM0fIZu+9/9/aKA20pBp9R6CyZB+EnwIHTepia", - "aJzn2BmqcFjGqIs4vc+BVH2ScBhiRQAMcSt39/a7paW7YvXQVCq0D/6dFUNL8tO2H/vjEPOSv/w/oCQF", - "QsRJEqd2SiN3mfMzSSVgvbMOsp5u1/0kj9bsmw25s2eFQOn2z279DSozsy51qLJKFJRWRxoBAXECE1Q1", - "mzjyTHoO4fV4R1jbCFrHdQmZLlToqp4+DgZ1/X+EPZGSLX/UFnNqgROnZL3bLa9LcPjRBGoshekhyUG2", - "XRQHkVHWkZdInslvi7AA5+AQb5gnptUJOyecWurFknwXfsF2PYqX53stggsI+pvY6HMQcx7sEDr+rGmi", - "ViCpGawvLII41djzI52CXuXMz+x/l4A2wkWpu64K+4OPWAT16Xv+tUdPn8P7ke6OPevur/lTs3EJCg/j", - "5ErgPE1Zu0cP4yW2+BwnEUY6lJ3qzMW4gmGVzC8J/tWIxE8nt5c1JEsOk/KrJJ+5sUowzBCAOMY5TJhr", - "TgnUMEakl1Kkim1kl7NIhvSaBtWh6IaH08a9ehzClqV0+cxUjRBwnKRRksXPpPIsH1fO9zAqgZFMYOd5", - "KgfkH61NWQjGEUmb6L7GcW5bNBYTTjwigcX+d1hA/bmgJBFCezGXjVddzYpCSgtjIyD+nmiyrW1HmH8z", - "uhTzuaA/Tp4SruJiJVFPDc+ipjClmaty+T1Ron5CjpjKp5cVqSts1c9N28suLX3nRgw9lX3QpAspXcgj", - "0aqU3OL3R6oe6njBCZBO39MPwPhYQPf0PRYf/nAY3sfv74X21UhdIRgfjH6DKpU0xpGPTWKZyqcY9Lc9", - "J5z/IRPqmT72GCtlKNZw7soxYz4vTCdO88Vqfp5znOpBrVdMav0OU5huhF4Y66hV2baZV5d+JBNnNsRv", - "4xfBpc1HjhOB9LPd+4Xj4YNtH/FIjN4EL8kk1c+VnrLhssbfZTZ1egFknYpEd7R8bRrle4269NcFxAu2", - "fRszOH4J2/74VDHqh0/f8689wm5UPz8ifA6xfPFkH4m7SqB4GHdVgukp2xsOO08PnMDYgXpG+QpUr5RV", - "yhZJnNZSmtrO5+LJwsqmErWaabacoDMxPJG1oLggVO0nj9WKCrofg+I3psaOGeW5FgcPCYcvGlvI2ovB", - "Wum1zVODA8PFfFzi3uIky2ktrcljUVQdC246Sv3NkV7d+svRXrhNOZp/+quHB/lyDcsRzriNWQLm35Qs", - "nWoTlHPr9rc5RC9jDhguMUaOCVsI9CpOcpRSft6tTEDDfawVBPa433one34pn3sjL6IKCgUWxPCULzc5", - "UKRy9h3lAuIZjVsspHakdKu0xsC/hYqkMOcisf5xFivABUSjx0usqI4KNNnZoUdQDnx4ukrlv0tPGAT/", - "cotQfWkyKGpXTt/Df3v4rKTofCyF1w5BPhPiP3ngK678YWxVH3insy57elEt+bLL5KZNru1OKu4nKfFg", - "lRKHrw39okwiBquAwnNYr5qh39t0oQPKr+qYAmihs6qfi/0eqdjH1aJRe47J2YkkUY5wcZllYzyiAF1N", - "ldTQMUMEu55hijmM3IxZ0Iko4dI1y/dZgnTOwL1/IZQQASA3mk4+znxXMnlakA+qxdm4ddG7lI9Cl2d6", - "gGPDBAc8sWDjMmgiWXrx/oIq4dezJe0jel9Emh39X5dgSsbk6LJp7F0ES1xM0scMI/Bgzyb30gg9yyFk", - "ztjvIWJNl8Ram/7eCSfZJ4OTkWHsbp6sszhB/nqyU0lUFWtDxG/P+kAu7jYlRUt/0f4cpYpOsTAaZ003", - "6nh0wkG1HxvP+pHWIUbCAtl8o9wJmjB8kOFLCz373ITZnsh1WFo3Tpsvo1oxlQGPuEsx4TFfSGKgegS8", - "S34vnqDzUVbmKpMGnVjKGrM6odsaRVR2HDAnOCBnNYDCMRqi0AbFdEK7oY3gWczVu8lnC1SZE51gQUKa", - "/EKHlNoacp9EsKj6BD53UjflKPZvVXhunzEIP4svinRBz3QrTTjUEN5t8pfPKUC65Z2ouDZ6rlWNeZlj", - "TR08zV2dLrz88LpLxvFF72qajl1dL2D0w6IiKAH0vYwKpX64eslH98Mb8tH92KaOJXp2imuPF1ikG4Xb", - "MObVARHmv2+iXWs3ekR+MCpLK9MvLxbZjCq10MrHSKeAgSobTh7z0gR8CX/VyEWje2ij5kHYdTgXia+u", - "heK20CDmOqcUelgxGwgxgIIYoUbJW5anu+mEpbPrxVJMp5TZwW9WjTY3FQrpZB3YwW++0G4fr3nRSL88", - "8QoaBRIRGkm2YtubCzpBxCIL1nG8TlcS1o5zkb/pqXmhHThi7UiSlJDiyMcd+72fIsz/epha6yHjjum0", - "vtW3qlAICi6yWtWw/6omJUmw4s7poFLj6EhbdfVjudIuF9PxQyUYhhtqwxq0XvxXVjrwGM+rTZmFpRfe", - "UumcYX1qthTEZLfMEsILWXMpgpWSBh0zzkXtbEuxcRjYhAzZefROH9ofIMBI3aJ2jUeh+iu+qBGATlg8", - "j4VLuOZbIj7dh1kZdHHZdUaGySqCFT/kvEdOraQ2Q0+TvGpqKkkqG6dkveFyn2kkZmt9dEvh4rP+XABk", - "86K1mCZ53bJFFJZNFDUs07sS/eMMxmX10x8+m/qp4pBHWmbFa6q3S6VPfxvV3SERwYPsWKjjleCePgxG", - "okjcfhBuI9mVHSN0JX9fDkQCYN4n+vYT5qdq9EqHcvDtV08fPfr2IOEGIXOIWEMhubiiuGlZTO7T4y9f", - "0AGZZUfyT6cW60Y6Em3KOtGpuAQ7A/JIXJNi3VKmLK9/VQSdc0F59rCj2BIIN2/co3NnwM9/WvZsDwN2", - "rxv6UcWY59zRCO2EHfl9s2CYEHj8EKQc37phMb4ozABznqq54ReU35Y+liHI2XJFyRER0ykM3zbroACh", - "x3DtnK8Bp7xtboHWM+KjcjemB9fG61r1PpwOcoZXmDEcp3Txv7+txAzuR5S11LugDPrGjJ+fVysyFR6S", - "wu7TH4C/1yTT/nbxz+9WzW+eoxpQN+aoftBZ/+PvkzKgoPD5RbOUcYqJTFbMOmXbsGau3UrVVOOPFSdZ", - "EDp6RLEbLYpF3M2JNv0Si9rcaq4zGSz6ErLjE3Zc9NASBtCwaTYVRWv7X9bSL0cEJG2CRaky2tmoqG5X", - "Knd7jdbsMXNGrfhAqR5ThhfV5kigSeHeU0d1aulKtGtchuZMGHoBwJpDute5dXcyOo92E5st1ewGIBls", - "jFQ2PXGxJCcW5EJcfZ1cRUxXpD5yXTxmT+LrCgHGlB3RaPmEwEkuJFcSP/MqHOehDlx0p+tBY2nphVPe", - "D0eNVluG2UC6LyXhoJ3UoNh7F0tbLBUmqgy66VY+ayxm4xyEUmx7x7DuK802iruw7pRvGFN6kJ2wYi9F", - "lncxUpxAxk+6KDaGqaoJWeFUUB+DaDeGLiW5pxZJbwIz8jeUwZ3nGMQT2gCh55zIM+ayOa4SSOP8CBDp", - "nNYx/2c+S236Q//55bMXMW5KmToaIuy834yzBRVq0TNedJtijku6hg5ig20RmLE73w4eZAjJqXipGadx", - "jzqmfajSKKgqoguBvTO803np/ErAcYExGbE3KpwNU4w7jqOPJyPvoHc4VrJWFWmctI+EJYUt4kNaQtV5", - "fHE3fbrCGbWSGZvrMfUKNHGcRt82rz1rMrK8XJT7dQj980xXRrsAiq42UleZmz2NDVnA0pOvn/7bcefq", - "LkPB172YQMktPjagspwWAAPkqlJlN96k7VsSJ9PVT+z7/sfNuMcuZA4fplfKPq/C291SIxXn8CrsfBaw", - "6RmeSqpuLLlgz3/7A9RKdXIWlEPyR1wD6fjpjKDmr1hPGd9vTwA+IOcJvjCPz+KErAk2p+hbl+qUD+pZ", - "pF7pgwIlPEdq2iew06xWG0yEhAVlDi0Vz7gFK3tNn2dP3nBPH8rJIT6fZ+EuLSQBLtM0Vng54dXlVUi1", - "l7OMfZ8lYucT6S+JfcS5n+RnMqaW2vb6qZ2880IKOql3Q8JNhCeRu+0qENDoPFH3dEtvqzSfSzNTDQ7z", - "DAZ4/IicXXgQrx3AhDsGg/nyVYFRPPj88k4KNulZohIjtYtsdXQqc5SsMm0JGH1AWGr0LHh2pl4og9hj", - "FoJ/qpo0k1xhN+vyyPdCYOgwR3fwMUPWXnZ29HJ99Zt7/zPMy2EcXziJajkkcaeRpbUuFNBNvWutj6wT", - "/XEFTavMHUESG+6paBHIllI3fiYbrnnVC0eCj4VZY8JBO0dJGZxDrQ8VldL68+Xlm4tqKBB1woh10XxV", - "Vm1TAiJe8edJQMRxiIdmPqG5ffmkD8U0COlu12ErZeJgYfJGeTaxxRCN6HP4hALmYk4ST+7B3AjtQ8d5", - "zl3Cu4UKHnUHeYgqexavTf5xR1US0xtHXmK+XWUwDAsa3Jb9BOOtnMd+7EtBdpmN83FX2hei7avel+Ph", - "cfN39rTf6DmGYYcQpuQNgE0rFBpa5TJn/GE96G168PYzkoO396IGfzfEoJA/dBvOX3wWz97dNpLT9MvZ", - "in2Bd6a/kLHwHM4GXZMKd6limqle8G85Io4C1R43iJd7/21SRh3C+2XOWX8fDF8kjQcR1tR4qK+OfJ2M", - "NgpWHnV45UGFq7rwsDvlVAoAu1GAYkE4NfenwOaVZnn63nw4jNU7xwbO3kXvr6QvkGGMDbxIcPgc9CiO", - "digtSoD/OyD+caqwX6dz624eTRwusyHm/il59uTGikmW2COulLmP1FJdzdsuqIwORJWKK1Ilsh4tRi0O", - "IJg2a+WHHVDbmTVQTJ7MjBSX5oO1KelbP/TrVpnaOo5SjMPAXhyTbSkjU0c5VY9lLnsROFQeAI0Ru9wo", - "0fxSVzE1vJDdnKKqONkQaE2xfxlEV0ujnInwG+tuLrrouf+fa2s/Yc8yFj+qQuqgfFblrMJ5fFmXXlgr", - "zw5wXv0Cfm1YDoVxpMlKSkaLWUIrDk+OCk+oS40Zih02Vyv0qya4TB3G3do70xnBUWLc1ipJd5NldX4s", - "ziYb4bOnun5o/qoBygXlVtrI5qBa6OTP1RXsjnjAfVRCisYutBF+qZqGEpnn3mKZ4/zMNuCaQze+nlON", - "x0xViCSPS6oTZSMW9y5iZxyUMXOmTGg2onVwS9TskwCj23Vo18wMA2FXHpzGG3jA3plM0jyNyVXZaTQU", - "jwtNWW0Brp5ovO4XeI/FoaW45Ele6F87pxLF4FHvdHRboXWyMRwp78zWSnz99OnTZDKH5gDBsPYpNkBJ", - "v9OBLY5/mA8b7MlOUrLTSXnE8xk2+OGd/vwb1ZH/h3LoWhtiuPfXoxmKloDJfCOgV8OYPaK2ypujUIzt", - "/DGO/pg1I+IYY3Uj+H28bab/YDscE0PvFAhjo44DnDv7awpfQjXnDBnousrSKAAlnYrvZFBOqFqzPYk0", - "xT4XGrHv2pqjwGAe4QuZNv0U5/zIBss0zpibKADBpBV8+dJanOmpW5vdNp/o1zbcJLc2mfVQNdYsvAj2", - "LOYo0YaSrPYRpRMT0KEQFQEU9hZkUDF+y21iXQqJDINatVRquOq8H/mxZ+GMrugsMUDkE0A2G8GguKtv", - "14+aYzEfZkzFuzZHPsH6C0SfuO27dbs/pVafQ5kSR2MW+hCdCuYpSjh8RPHRTItSu85umfO8CQKn7+Hd", - "AdXBxonTjupgAsgNX5dEWnXAkmycI2Nm282D8GMs+dXDSOhGDp3zD3KnzyAPXgNnePTjDF50REN68R/P", - "Xn+HV4g0WQwIB7THJG6PdFLwv08hD0ddEoaptI2cZc7NeREGAgfIPuPAwEjgf7/44fuoywHU1T55yAGF", - "/koEK756+rTnIZdhVJScCbN6MxniWIFkvln3seUQafggRPl8aaC3aMbOAriD+/xeBW4+fPh/AwA=", + "7L1rcxs5liD6VxC8N0LWTIqSu2vm3pFjP7htV7Vn21VeS7UVs90VGogJkmglgSwAlMxyeH/7xnkAiUwi", + "SUq2XO6p/WJTmUg8Dg4Ozvt8mMzsqrVGmeAn5x8mTvnWGq/wj1fOWQc/ZtYEZQL8lG3b6JkM2prTv3tr", + "4JmfLdVKwq//16n55Hzy/5x2vZ7SW39KvX38+LGa1MrPnG6hk8k5DTOdwAtuC109XygT3jo7Ux7/bp1t", + "lQuaprZUsglL+NXvy4f17EaslDReSBHWzgjthVsbo81CSFMLY4UPTsmVULfKBLGUXkjn9K2qxdw6gT1c", + "yXlQ7sqrmTW1fybUex1Uzf2GpRItTQw6X1ijppNqosx6NTn/68TeTCqaBzzEDyc/V5OwaRVO0GmzmFST", + "9yfwwcmtdEauYFF/nfwZF/UDfEc/L7gX+usV9/WxmjTShyuc/5UM21D4aalMb5rQXtw5G5SQotFGiWCF", + "Dp5BMakmc+tW0NWklkGdBL1Sk2pinbiDrnQQPkgXVD2dnJt10wyX87GatLqGifBzbYJaKAcvvPJeW3PV", + "e99954MMCt5IL6wRuO0X9EkarL+6y6US3OmRF/R9YUJB+puxQW+Vqa0rvPpYTZz6Za2dqmFHsrl3HabP", + "adFxCcNdqSKSdptvr/+uZgEmkC9zG7tnTsmgat7b4tZsLSl+c70prnhu3U3qcYAtOiwFv587uzpH1KE1", + "Hnmha2Hn+AixKCzhJMGf8Im4Uar1lZBipbyXC3wqXjRyXSvxwtaqiscQn9tavZ+WZp8Nvz3B5zjUkRdz", + "7TyjonDKr1cKTqP2PNmIFZWYrYOQQaRFV2JtauWEFEbdceurbnOfCXntlcEPhA1LlbryxdmOYNVKra6V", + "254/IKyEDT/yAg670AYPHyDUkRdBydUzoVZt2MAJkPhc3MGuWMNfVki7YHqNkrXHv5y61epOufIcV7ZW", + "zfZc3qlf1sojMYMGcdyOsqV9r9VcrpswFc8jMIQ1zUb4Ox1mS+XFtQp3StFS7J3pvsSey7Oyd2YMQo1d", + "aCPultYrXCQtnPZX+zhRAIGMkPORThRGcrZR2wNFmJ0LyfDruhFPvFLiHT49frY1iheN9gGmdotop7wS", + "dh1y0q9XbaNWygTlgBwoWU+qNOT2JZBTv9RF3Sj4iG6sSYX3c6MOv0RyynIBvb+mHreev0tDbL160Y0J", + "c1xf41b4bXD+2d6JlTSbSCEilqdPhHQqXr+VuJazm4Wza1MDZnuhzaxZ16oWeN1iD0grwlKmGweervCW", + "VqaGC6gq3DEZrR/wFhnSIFLtQpnuVuh38q1tGnvnBVAnnCQiOP6KqHMnvWAKjCf3XCzacPJP1BIZEDja", + "SP8qYDzchukMdWVNTjNzlJrhY8SDWr2f/Dw67av+PdtfQdZ5hu+6rsQMzpsR1xtxvXHqmbC0DThTEZYO", + "YAbt6PStTdC9hSeijPuGW+aJtuGnBTAPbtjBzUr0IbthiYyVFtndu3jUE/ntXYVVfpfmqFy8lEOQs+WK", + "Od3BlSwZlgW6r0x9BUxVmf/RK7koUCJ8fC5qGaT48d1figh5o02dE4e5xoXWej6fxI7hT7lwcsVwhFUa", + "2fqlhQWvXVPEmZVyK1nCFO7sXLyhFsLbtZup4vRaWWLBYZLnwqnWnjjVyKBvlYCWU8FLloZ+CWgJdyGe", + "e6K1d9bdBKdUBTeybdZBAUamfpBrLZN7Nd+eCgDqXCx0QFqPOO+kWZRXE6G2g1F1Ycc+7+I3YRv2Mpu4", + "2yW8/FNjrwsYSULZFTX/MM4RfkYuUtf7l4FHsze58RNZXK2Vrn5rfXiB7bYXHtT7sH8a2KrU/4tGKxPe", + "qbbZbPcNx7HENcj6ivAaGV/dABnlNSKjk6B7vSlLIyrK0v2eL1QgEQv6dcSbiZldN7U5CuJaiZl0Tqs6", + "chrb/b7X4Qruhu2+r6Vf0oShEQpKa1+Jk6dJqINb60Y3jarhAkCyrVeqP1ROyeoy58bzRlmhzPWtQ7sO", + "YzP0obbAr5safirnRLALBbdjJWQQK4tSh9KOBJB//Ub8d/2nESLg1w2OI+tawyCyeZvtb3BrNZQml9LU", + "nsAUrG2OvHjz4q14IZvm0trmHfY4nWzh0TbS78I1hE9B8gIJivl94nalv/FCAmoZNQuqFm/kDKhebSuB", + "Eoq9VU6cylafzrBvf8ptYY59XJZusV5Fvu1TAJI6KsABGdSVNPXY7gLrg7LMr34pJCyOGH2/VE0Tqf/S", + "rpSotVOzYN2mErqTKqIKA/jGtrjr5TN753RQ24cW5L94cJ+JVjoAahxZK2JYmUIddLDr4s6u28bK+hxO", + "mlPEGrvZUt/CFKxRQpngNkKZ2ot127/s8J4BGRuh9r9Pn4m7pQzAMwqNcodT8MOptpEzVd9HQLVtzk3A", + "/kwqnuqkmtT2zvDPRPAmVQbISUXIUeQoysxA7LMSqctKlLZGPLGuQka9+wSfW5e2Z3MMjDJ8Qbi/G25F", + "wDB9u1r5MXwFetjTnSHiJQlFe9FYsxgRQ6wtiNzZgQIe+wT/xrMFJ3smm6Y8VSeNn5ek5YhceES5ldA1", + "9LZQgcSUHomIjfzph/jzdf1xmkBd7KpdhxxxRbAHcvO2LdNCu2rlbPRWH9FXvIHHiBcwFzi9ScljjVAa", + "hSgSDqYCLiPbJCFP+ACbiehGyg0AvZ3Pp+IlKTdgG0TofxUVGOXVFpbFpJpofYlPW610KN+cwJrSewJ1", + "2+KdfL3WDe0jUCDYTh28+BMgzwtqvVSyVi4qRRIHQRtON4VBvfSYujbdMLsVyNQhEK2mVj4I2yoT9eep", + "i+mY/nhr1KX1I5B4I2dRO9Zf8J+tv8dyC2OiVlbOYKzxtcLBJPbCqZlCY4BM/BhiznoljVjJWkU1gPZp", + "fG1mdLaj0iIpFHGqcWqHwQivx/38LTVjkFYRywYbWzqHjPpv4nkbOYa7R6dmxe61ewWX23bXemEsfj7c", + "ATgF9JJOKV6O52K60IEeI28Ib0hnot6j2gjvBy9WMsyWQgeBSp50Vwh5bW9VX1y8trZR0sA0AdPKYp7+", + "VY0JtWgx0b+i0Ar8QJ/11yb86zfFe8FvVo02N9l42Uyi/DbEShk6YAjt4XIENRTasQT3mDRlGx9ZqShB", + "TwlY58PGnf4yNQXItVab4IWxyLFUDEx1q2eqEt7ObhQCuNVtTz1VawcgIOYABzxQTRnR5HLTqpfYSf7k", + "W+owf/QDdT5ERNxHHrEDNG9jlXBuBFP/oj2wDNu4ClDnnzqold9n3ExY310R0jm5GeeMgOhlHG+u4ujt", + "jbM25MrvsKRnIzzD2sxkUPWe8VCfuoKTxQsF3DDiTjklnAJdXo+xTNg6AD6urErAyscvATzZkwckhyxH", + "BxAdbljs+7Z4+UpYbvGco7x1tcc4+RAVSlTaHWr7ABWANVE/HTXoaEYkO48Ua6/ArjK3s7WfrlsYnnk2", + "kqXCUm3EtQLGFNg08QPYafC0w5dCe7oiQZM+osjbION//mEbsK2z8HMMQl790gPNOBEc149tGV1/mTAY", + "K97Abop71UeICG8ZnwaH+jZKwwedaeypdKDh4BT5CLydAkpo0imhpGu0cnEv8Ygh39DKhTrgePF8ecDS", + "YoFOvliCSrOA+izx+7K68lob6TblK6lWjdrxqW3qqzJRcwroMeMmNBHXam5ZAqaXOzXJRfvY2udSq6xr", + "VZNhQM81/sTp4i8en4e4miFoCpazEULGo1UZ7HJgJKCNbkXnJ9Pfiw7Y20SZtOAe9H0/Xn578v8L0Fw+", + "w3/h5CLlL/Mw41BjHuaQU1lWpu68SnBqS9vUnizCYcmMbzQDwpIOvz74qmYY5SNX41pcAPdLsINswboF", + "frC4pBF4lWdF3ZSHNrU2i4touh1yiu/hABDBtbltg+3ZVsz1+ykzvPW5qLVfae9VTTb+Rs0DaHsjLN8o", + "t1DiTjowoeasFw5UZHF28V08+W/5Y/7zdexjsLwf25oX2YdxMlvvop89OG0ReXxaBC/ccoVt7d1Dmb9G", + "T87dPl78HVEgvVgG0UqjhF/aO/8s357YkKRLuqzo6gxl96LrfQC4lP7mUl5za18+/vCGrnK4/kHpggZV", + "1IFaV6uo1qC7BTTF+AUJA+lKP+hCy+czuNJGDeiXDKAR+G3twz5Ki4Ag6JU2/7W5tu9fB7Uq3GePxbCN", + "dDU0vqIuAIDSubSRUaeaOBnUVaNXZV+/PoPVB2/s9JyU0u+DePJGuhtQzJEeE9UcS6tn6C6BrnvSi/88", + "EU/EMd4R/ylO8c9/in+2enZzYg1Kdf95Iv4qfs7b/fX9z3k78N1AtxZEoONKxAlV4m65ubpuQPKrK2Fx", + "wl48UXK25Amh69L7DFOPK7FaN0G3oM4Nbk3OYHGc40o4BWoK9OMQT2BlK+luVB0XeIT9HVdCJkO8n4oe", + "sM8F/jcVOcjPWQFYZU+p/+SSBC9O6AVd8scV9/Q3UzJq7OF6nfK2ub0nnqWPRoyrPd/bgw40npZ3/Fnp", + "WB9Ep7EXptK7zdjk1HF1sBU4g2LXcUH0yntOnH/H8Uc/j54rRwetUTpCkKm3ScknQppV2CAJaPOaPn86", + "BP4AHgdP15dY+e48HDzjzJmlgBgPoZO1avStcp+HuqIHSOFKBF8HoWvfY6CibZT8RryQIOl6XUe5guDW", + "uwe3cXcAAWh5NWonS9LP4R2Oc9Ks4DsYv0DdBR+C8H7gSYur4Y+SQozBXPVQaK8MXcL2L4CUYzhhyRfA", + "Or6GSF2S7q/uDr2OyIMqejKTAUJVXws2DdbV0rT5jvfKxHsWIg7kLDQbNBaT+2K617srmm95VORljXKg", + "3G8ZEYcL8KdpQoNtYAKzoGdLsZKghsqtNBHETomFvlVmum8L6bVsspFwf3JuSAFPEkFV1oM+7MgNT1Y6", + "VNjf3pNyyaOmSJCW+UUEIHrqoahXZBGzW7jXhYIOpPF3yqm66wN/+3WrnFf1CNf5FyXr723p7H5GNrnP", + "Ke2WvwwGoEAEkfIi2J74IOktai27Juwry52U93qM7rKi9B6LLPqmQvcD3iPruYQUb0hPPUY49159n0C9", + "YLb3O/IH2N21J/deVE/IoBx6b0/FT+xGBm6WymX+0QNLepU/zOMHkmowBo+wpnrnPg/muZ/k7JdJx5RM", + "39ug5xzt9tapuS9pfYHwGXUnNBxgkqMoWEMb7ZeqRth5wG493xA0SStv2KsNbepoPQa9sN925EJBpTB2", + "TzKikcuKwkheC338j/gKrAbgBRjDPooddfJVoavupap3zcap1rpSB5eoZ0BoIRlQhpzphRR+vVpJtxFP", + "4PWUJEh8xS/OBcibxwcoHTtYdDOpIoj7KyyhxFtp1IiGqnZyHq2Jg4WBLHy31I0Ski42RBUzVKqQTnVt", + "6LzzUd4GINqBVL1zHHYnAZdqbWp7hxY//DAZ07kboShmBQ6fNh0flKZ1AFDjjKoOCGXguaBnupUlHXmt", + "fdvIzdWoZX6hw5VaSd2Mvo2flmJAktMIHr6lRIW7V0FI9MLRtTJBh80zMqYFaL1QQRgrgpO6UW76GZw1", + "emvM5pwvrgg4CiL6PBf5KIDhMJSi7+D5wE8fFUOV8Mqhq0wQyxBaf356+rf12dkfZ0DNjrxYWh/wgTqn", + "59ATPTidfpqRsNMmMABx8nslm7fESHyRkEumpFfXTpoRi8QITzW6Q06tbBh71dqrwywcOdi6z7YmnIa7", + "n/8+g3iM+9mxtoMXsDX3kXkEO7PNiNfBfTbaSTx6EFXKcdMU9MSe6KO2h6iujT5lxb6V9CNxRfsip4ue", + "SnFc8BdYN41olFmEZc9jafvYHRoPze2qfmA06+NwHQQsntzebfEld8n1Tpf1kkF64FBB7okABLKsyFq2", + "QbkurqKVzrNnMf7t1MwujPaq4MpXiRu1oQAJdjMt6Ythh82IayMGMFADZJwrYBeVZ0PPwdabPj5/3KNv", + "ZDCmmZW24l26VAYU37CU0cX9ZqHB2ybN2iq8TddeVRxVq80C1ipiUBWEyyxj6NV+u04pxuQWo2i78MWe", + "N6i/0W074gz/ENI+Eq4zB04A3fo3xaHmZPK8Qo31TiQ+3HBaFWJuO49UWddOeWTjwDLDE8iQVgcvtIFo", + "Tm0EBchMuRXY7+IXIJ+gJUih0RNkkiKiR6nmc6gORkTPV4dtaxfsMww5MBxXkOPM+nqlwxU96QJazjvB", + "IprdjqPwhhCqCKwkaqhb5XTYVGImg1qg217QoVGVqFWQuqnI5QXFZG1UEX4pytCUSZkvexUwIGhd2aHy", + "QbV8sjC1CbrEVsJb+COuXzhpcs+BLrgcYDWpGK0nVRzmQF8Coh+DYPLs4UvqPXvybRwoe3YRxzw4Z8cD", + "0GV31HG2KUXr1FY0crRIRQNwTzs0IAIlysvB9r+Fhqh1dtU+VEdU0v6t2gOiMbldERYM/Ida/NHx/cov", + "ZUl0obfoS+OUglacYYIjv3TfE/rIiz+/ev6yFyAZ5I0yn+9iuaU1/TLuanHkUcvmA2coSpJrRFOcFrMn", + "MXRHRe0BuXj7kNYl1m0XN32AR9iYTLI94bfW4902kA3jNH1FePr0GVPjOcWtzf0pMFin0BwikugYfjyF", + "704/mI/3lQyrSdzbkZd6sSg53oIq6UqZ+pyDu1hoVaY+sfMTeMvoM42RIABu2ed9rOucd6S/4SRSOtCV", + "AbhzFeExTWrOJ90xpaRMqBOiWeQ2HYwRgwAxcFeAP67gjwoGuuLr39QYs+tA2aOkI4c8a7pNIPwFTiC6", + "o0J0Ue3zC4HnNakSTCbVJI03qSZpwAmF0LlJNUkwOfC2iOf8kvbjTRpz8OJy7cwrUxdeWNu8oPkM3jz3", + "N3/m2Q2/4ckOHr/r5r7naijcBmbSozoZ+uXHu0O8vRLzRSi6KyM+lMmED0jZiZRRdDVnQsI3YqHg9F1v", + "MKDqWcrf5YUF8UY2DXsXQmKVW5VU1VPxnAwyeaIzdavtOnbMJx1dhp7EcISUlmmhKnQDO+6nFeqGTxaC", + "a6fVnHxvDr+GxrLQvFvzZGmOGGQclFydE8smGzprKUQGm3EgLsVemzpdAczx2S4CmW0YfikdBd9cW+lq", + "csujpdNRZihmUxkscKdvnpKrN5TApLDya+nLHudOdaMdUUYpQeobRdCexpsOaQ5reI7Z5kyXI67TKKY/", + "BMNkvJABDe9koaN3SNlgm6ssvBnANwePxOxu4gmspGYjN5vxuuRSPNepeKfmqIu2pj8MkUFwnB/MMJqc", + "ZXMnN76/ZpEtNY6AOxHpHYFkUk1gaoeSLxj1T9Krt/Hj9OQN9jIu0Fx2qMnRHYxScQneoimcMucQs/lp", + "CYTEO6ZoCEXMroAnoBKOAR3N6YWAEFj+wp7AwxPgqE+iTf4Eg8SUo7wBmZ6yv9wfDGu+7Fw8FcGKp2dn", + "IKM4OQvKecjFpn9Z0wZr08OecnBA4jH3gVUbH9x6xi4UuMS4bhglf80w5EucXK2BZiDpjKZJpgD75Ymo", + "g97B5cJEn4egeCmHxQhjaj6i770j2xG77KAWgVdrP5OuTlrqYYIcfoskcAa2HVVfrVvQcFjRvdXhGSnF", + "IsnHpmP5GOizoiUsBslI8ZJa9ehFtEwhGaOQiFljAV03atQWtiNxYOa8rL2QBH0ckT5DatHzQsiJKdN2", + "+qq4Um3g4i2Py98NL+kulSYOTj48rXI5bUO1DHDuYLMDHKQcTEf+YF0hYttrmF3pNhlLx3K5jPh0RchF", + "DTugeJ6lDnBMEuAoK5jubsAisEY0G6yMoOBaOo7ai5WukQOfCl036lzIWdC3qsoM6zxFNuSRkJZOQ/cB", + "HmFahlMzC9gMfLhbwP8Jwc+7n/HsU1cQc9E7F107AkVqBlw5nZHaqpRXwK3BC6Jz3vedNDfX4ObUO8W+", + "f011ehrOChiXN8EEXovoD0QTmlSTbqKHJgskJB3mA8yexuyB2aNvu1nkj9/EGeUPX2azy5+/oJn+2O5V", + "+6CG7T72QPqg09Fk5Cid2FEKTWemFO+yKFutDo9qDCxh8QcjUwglzwJC5iv4dMQCQmIKpywpqY5eZdcd", + "NyNs1V4EJ2c3iMKAZ12+y5ie8WCy08vZXPI6pnwkhdkBy11jMPaMglYrzHyE4a9tSyIrX+iGHGZIN0Kp", + "KKqYnoKTH2hr7mlcGWbxKEy+vr5CC9qBgXVZMuDDY2TBwfpqTMx5nm+e8mK2dk4ZcBJlQlFWnDDTPYI4", + "LVuVrjono4ONUL7gErTHaNfhesFqSUqvrVANEsjY+EQuOweb5QgwV+hUVLjw4HGEHuQ+SIgULV/3wyLo", + "752al7DHe3WVoX9R8e/uq0ws5Cov52hFD1qZVgrgQGfdhe0EhH5a9MQBC7IkCmTT1rObMpL9alfXukR3", + "LiIx4VCfOAUWIkWD6SEJp+l6lwM0RyoFnJCx3bZYd/Cm/C+c2l6DaQb/7KxvnePsPFV9utzf447Y9U/1", + "ECf7B2ibkHegLW/39gkuXixk5XqnZCEaZlQZ7dUvSTTq3O6iUtp6laxnyMQvWVTaS+cK8f6lSYM74PZs", + "QRVyxdk+tw7EDlcbEiR2ZzuixNvSM4NIHgLMwByewGdmm5Kd6jUbYZkz/PF11Ne0slEhKGAgvV4Y8qdC", + "lSF6xEsBHa4d4D8hHH2G6n4SbK1hX1cMHIbDrlArZcuH9TF8nR5H5uOUO4xlsZzDndSY6C2lQ8nIWhX/", + "QrVVjVqj/gjiWkFjp1b2tpzl5AHZ8k08KMlGo3uCphxJqXkP8RWWskt0zYSM+yWtdwvFRMWNOIR0O8jR", + "6MiIZZ/s2hVukWoI4CWfwITh8CBPlDeCZjemHsG30Sj+y1r6ZTL4mYIScJg/zBFAjeWaBpbOfRF6NJFf", + "1mq9i4gweNDlK+bZZ79f0+1P1EOniHOmONos0o6iop5c46HLqL53XUYxgPlSNfcgTLSGJFAVkA1eJUzu", + "AZRWtpI3KuYGRcTED7qs4NFtKPi0YHadCFfZ6KMQ3kM/OvDm1INVAJ3sr4Mfln6poqEOn9NFBt9GEYgW", + "iw3bNf5gjZj1sdwFLHgcSeFGB9p+xbrlHaugHUUoa/RMYZxg10LqYIBEkYbxS14yX1M6jOvH6IOr/fH9", + "dKHTBjKx7+hO1EPRXO9FdoBdvyrbLzoDSuQyMudieAbfevEkdQFHNP3xz/92XIlWou8Tg45mj7BSboX6", + "FenF2x/eXe4UjfaWphlJzRYvlNqyy1UbQB4JqC5D7pntTXLF2TUpDaGQUf1Mnjs0ffGETMtTHJbZLEr/", + "zCIQtT4+L7EnRDDo5qyEarwS3Z1cRfLCkTg4gJ13MOtcDpAfT1UNiFDKwBezrpueh18MaaAncNZ8fH3V", + "KvIri1VCqAwM2ddxixChOk8m7iNlcbWOL2p26ql4dMw0Qdz0dJeurD+N9IB+R1oz0A4VlWpxtANVasC1", + "DvRp6REr07omOKW3aYqDF4OHb9Kk06OX+ex7DVWdP8nVcOnh9/Y5raucZwlZzTJQ7geJtX8e++oebc1x", + "7btJ4oyI6bs6SDrpec1syypbgULPxFny8SHR0qiDRJdxfWQ1Cba2V5pzl+y5XC9/ePnDdEVhUn3RI7cO", + "3IvQRhtaeXTYlTh4bNkNPHf2V5WLHeLJQgXYmp9i22iLmNl2czyI3EwFizBv0Uge1jjqlVubEbY6NiEO", + "Q9dMJbF7O4eeyRA6WAZY5tVuoFwlDe7eYXNI4VccAQmTAAo3Og66gdwjAqOfHYO11jmPluTZYf9VJwXn", + "F2wUPbNEZjtKobB6fAcpHPI0RWGhf1LH5HhKUufL4nwZYyMn78i7DTQ70qXyOok1PoEeckLAzH5imfoy", + "QEUu/okf7PxbuqsIxhtx727ukTMkS85XCrV3qrRwp9Ji4p4j9zWns0CSCubpRNys00L2z3+Ag4wyOI+4", + "tNHtu1/S7rzgGTYhYkvmcayZOBKYO27svyABaCv2gP0YtI88KjGU7NFW9VgwZqen4nvLH+70Oxin9IeR", + "Wim8hOi4jtxaJF/a+KAkVtUjcsuxEomKoqYHVndOfF/P7SY3FHtxrWZ2pXzyVYI+2Cu9Fn+bUNQd/ok/", + "1bmgR/g5PfrbpOqSK2BTAGVnvCRXLXGZ5caIbrMZ82694rspT6g0ZqxDyI4h2yVlVouMCDLOCGKge1Y6", + "tHQyPYmIW00ix49Gvmv7PnF8fpJtWKK6B/Mw15f5+Jfy+k88hUt5/SLN4lJef8sTgU+6uVzK69c8nUt5", + "/S7N6FJe/9RN6lJex8jEnxkKP+3EsYRTMr/Ac7dlJvYVnQt8FJZ06AZxtwok51jcanuoOJGXqR10+R/P", + "35RrVo3E95UdZrKxi9jQOcU9mPTs9LLauYL9RRyj0xB5cdzT5wk+vKe/k+7ENXtHhCT6M5HWw9lGid39", + "lzciBjbs8GCKSH3BYYdDD6aGtkUbvVqv8oRYGfPs7N3eVluBbI2f8JfFadnabk9Hvdc++J1JegeMARUC", + "Toy5DAxQO6JYSSzAYZZBW1tMa7jPDMUTj/2PrbicIvHa1hsupbudRRA6PKJYMBOIyU868NEIVv5ovnYI", + "NkBxX7REktV9RPJR7/mWUMIo6RRXdEAtG2bpj/MraGi1Hy/tsEOVGSeONxRMu0oCCuwrOjCjSZITG0Xo", + "cBqA6YF3WEUw7wAwtmHIvxSTHszWvmSaVk2uUu0C/u6kobIl/GmaPZz72VIm0600zC1qLigyorwcje05", + "VFGMgwdLkuv0Uy5/Np8XLJQ7Q4Aev9JzabY/GtaMjHhzFjaVirf4aJ3IpRXKe+QUqNmKxouyBnNPpu4k", + "sAwKmsJjGpJy92SlTWFNIsoG/Q6rydrQ2gIm7UTmJ/MSQiqCbpFeuR2zynopBBXJsPREtcqj9atcTA64", + "QFZsW4+sYja/fnrvfGLFLfclTiSmTdjBkXQFY2XbRmOOUXfsEzhSj/YzZkQ5OHFJNTFZ0qOrNmY92nW5", + "badJ+pT8J8UZVAMoj+3OWMLqB0NqsJCDM7Zsc82l8MTIxuNN1aSgBorkywT+a762YGi2iaP8x8EqIFXK", + "vhDYGRCpJXnyrJIL7yDu5yIaTmL1j8Tjozg3RPhsFR8eHD1AEzuYhaIoqXslXeURdu3Pu3Wh/j6KvGM5", + "XbLt3DXdAgJk4fO7OdOCa3AdcQNrBrJ0fs/SRqx4Ldm5iuHGr9M9xCs48pTo+WDfqwzKafu2FGGHOfjy", + "qYM1dNpS2qjetvS8pA9DgAfH3/XPVKZG6YGsjrXmKK1hfb/Y6xhHMG665ZvcySy6jcK9OJM8Os1bd+PJ", + "5necvBzv593YC2EpzHQU70bsqOwwco45toBkgRFbYJ2nO+3VtqmS7TtZyAvOhTLD9pqkUI/kLIHpzyK5", + "63nZ8zQm1bYN8bP72yMI0fj2Uxq1e9ZZDLtnbDLsHmTe993DZE3rHuUmv+5p3++el341Jrj1VI1R/zgE", + "f/Q9OBSly+cah8rMEtnUsjOw6yCzj2OJP9txXd1fY9V1VpoNe5sWJhHGuI9hoYHoIMryanQGpWsYfqU3", + "LLSUSw70BKLc0fihwlSpXHkVF7YNi48oCcxROcM0fgLuNihVOU9YdjY9m55RkmRlZKsn55M/Ts+mf6QC", + "LEsEHZYylfVKm9POTL1QKE0DiJFPfF1PziffqXARLV+9HO9/ODvLSqbDT6TFxGKe/p0TcBGhO4AMwgi4", + "vqGvs7tVTiyVbDgNzNpjpaeP2BjXASmqcUptsRTmRbBO+axEvnVi7VGnJ6NRg3TxoHeIengn76j9M/J9", + "2rSoN6lVoCLSXdj1JihKALlae6wv/vb77yrx729ffVeJ715/C5T0J3X9diqec+HtfxFv9J+IYPZh/WPK", + "PtLlAvgT658yOOO0Thd63gdwV1Y5Vf3ZQjb69O+tWjz029Y8+NM7dd3e99veAWEmd4CGTz8bGiLkC0h4", + "Gb2PakyHPoXD9c3Z2Vh3aX6nKa/YN0//eHDrAWKffoD/Xtcfdx3RhDM7D+jvDHG2NxEgeeTpyDJrSf4X", + "CCI85Ly539xju9AjUK5UUA44kg8TDeOxVwHdhBPaw8kQm6tsTcMV/BzRYFCVPsODoez7k7q+4LqiQDVJ", + "uulK4WOWZLLsvpEz9vElAutRBurV1icdMxPILsUtzUZQ+nSf5fKS6fO22fQ+Rpe7TXT6RLuxrtGOLM0m", + "r8g0rE6MwrnPhoUwXoP5t/KwtEiKseclfM6V3H0sRE1JYEo0l6PUaOrDI/T07Ok2mC8o2XYd41E7oMew", + "kXsTiO7I7y4vPrr1qehdXtodrjxKaDXYWNzsipJ6LH5FV0ARpEMdCeyShvhjgFnUfmCRQXFBmWm7ZMh8", + "RQ091eEr2IDNdAva3ymG9CUv6n5cBUz2sxACLsD+SIe927H7Hfhq0q4P39xUan6wuyi+zq27/wY/n81U", + "G9jxtfqkjX67Lm30GEfzGfZ4H5fwTSHHDaeQfQAWxOPalT8tHkviXk/QrY+aTgXG5eIfaBsDmAIcvfrl", + "mahlkJEBxVKpsIX/fvHD91P6M2oa/yJ9OMEnJ69fiifsrkTvflljdYeIpMdCOkX0cNNF4qNLcpzRT5RW", + "C2WUKpaflCE6BSke2SnhkQQjtW+UrP8bYnT8InYYbViQ5woTRXZFeNMyUtjxnVFOLCz3YbsKuyVaTVlo", + "X8USrqWDSPdGdxR7kJpUJawaD6njPhGiXZdJrP70rrJ0iaNUYeRTAGrpuy6d/M97SSvc0YTCJxSo2j93", + "eynpqyxe/BMOEfkWjZ2h110NiIGSTXyrG9h9YaRz7HrLxXwcIpPMsiMlp9ojL8BhqIRfULo8ehaVkGuw", + "BT1n03tv4CfsfVToHCbV5LXoDkCK3eLT4ZXdRlwztpDoDRSFpUtJrXzS92qXXOP8VLyM+t5gKVUB18IY", + "YtFpb3FlfcBz5lzzrIA0tqaUWJjYFhU6UxEbO+Rze6lAoWlkcNEypPGCxHwPwCyjjpR1qKlCBpLRtYnF", + "h0pYyCX2XiePu0NuzfsJur1Sfgddn781lgAPxCWC0t7fWwg/++Zerf/tobTs9ANMcY/I3i3/ERVrGYzL", + "MIWJdoJwd+IehzEmsDxMCl6pXdB8ox4TjOikMAJBYHOAfCHTwoBYFyZJZvQ36pGOdGapf4QD/VDgxCPL", + "wHmoVLxSp2zxO0keIUVR6UKxQ3vnHZLvz5Hf5SxS2i++eN6wL+dj7FxviP+SezePxZ3GTi9Vf3rEddIA", + "hYWmwNMBmgzLcvdP9pYcaV3tM4dCqscWFV5dX11J9Io9SpwI8nob+S5ymHx+lMvA8eVwbXQPMvU6Ysoj", + "X+091AQxap8Uf7mNHvAZi7t9oaQCoUNSLPrd0jYUHe/POZyDRPQYNO//+elRWObBlVn9Pv4JIZlPrIt/", + "Zvkeskzm/FJ7jiE8noq3En0OZzcQPt1Kn2LucZpJAQHdy5icc0wmgqKXO6XugXxCvX0mEZlT+nRdreR7", + "cnT/l7Ozarfb+8+PiM4IkLfogLON0j9ifniuu4f/Pomup/9yBjH2DRaN4/wrPuim6dI4lXZ0WsLbqJvf", + "YftEtX4yc0aVdQmbuepsChaX9HgQt567j4ufkoIQm2Y5JocVKJeqoQh1H+so4P37niOLC3jHucz/QmqO", + "xyCC/ZKiBxHDP3y2wTFMnBN5jdHEHvxzgN5xZQrrEmwxe9en3NGIUMYGNU4Hx+7KDn048Buza1xDXMCS", + "1DIb9Mocqm/SGm+Uaj3lTs9EkRVGKUVPq4UKmK7fT0dJ1PeW0os9vugaRztUckW4TkchfvoB/mN5EZ2n", + "1Tab9BKfp5EPUXDTJ/UjCXM054cKc7R8xu773729YlpbisHnFNpM5kH4CXDgtCumJhrnOXaGKoKWMeoi", + "Tu9LIFWfJByGWBEAQ9zK3b39bmnprlhtN5XW7YN/Z4Xdkvy07cf+OMS85C//X1CSAiHiJIlTO6WRu8z5", + "maQSsN5ZB1lpt+vkkkdr9s2G3Nmzwrl0+2e3/gaVmVmXOlRZ5RZKeySNgIA4gQnEmk0ceSY9h1h7vCOs", + "bQSt47qETBcqdFWCHweDuv4/wZ5IybA/aYs59cOJU7Le7ZbXJaD8ZAI1lmL2kOQt2y6Kg8go68hLJM+0", + "uEVYgHNwiDfME9PqhJ0TTi31Ykm+C79gux7Fy/PxFsEFBP1tbPQliDkPdggdf940USuQ1AzWFxZBnGrs", + "+ZFOQa/S7Bf2v0tAG+Gi1F0E1CccsQjq0w/8a4+ePof3I90de9bdX/PnZuMSFB7GyZXAeZqyqo8exkts", + "8SVOIox0KDvVmYtxBcOqsl8T/KsRiZ9Obi+rS5a8J+W/ST5zY/WDmCEAcYxzzDDXnBLcYYxIL+VLFdvI", + "LqeUDOk1DapD0Q0Pp4179TiELUu584WpGiHgOEmjJJhfSOVZPq6c72FUAiOZwM7zVA7IP1qbshCMI5I2", + "0X2N49y2aCwmnHhEAov977CA+meCkkQI7cVcNl51NUUKKS2MjYD4R6LJtrYdYf7N6FJMroP+OHnKvoqL", + "yUQ9NTyLmsKUBrDK5fdEifoJOWKqpV7Wqq4cWj93cC/7t/SdGzH0VPZBky6kdCGPRKtScovfH6l6qOMF", + "J6Y6/UA/AONjwenTD1is++NheB+/vxfaVyN1n2B8MPoNqrrSGEc+NollXc8w6G97Tjj/QybUM33sMVbK", + "UKx53pUvx3xrmO6d5ovVLz3noNWD2siYdPw9ppjdCL0w1lGrsm0zr8b+SCbObIjfxi+CEpONHScC6Re7", + "9wvHwwfbPuKRGL0JXpFJqp/LPmUrZo2/y2zq9ALIOhVV72j52jTK9xp16ckLiBds+y5m2Pwatv3xqWLU", + "D59+4F97hN2ofn5E+Bxi+eLJPhJ3lUDxMO6qBNNTtjccdp4eOIGxA/Wc8hWoXqmxlM2TOK2lNLWdz8WT", + "hZVNJWo102w5QWdieCJrQXFBqNpPHqsVMu/+GBS/MXV5zPjPtVJ4SDh80dhC1l4M1kqvbZ66HRgu5uMS", + "9xYnWU47ak0ei6LqWKbVUWp2jvTq1l+O9sJtytH88189PMjXa1iOcMZtzBJk/6Zk6VSboJxbt7/NIXoV", + "c8BwCThyTNhCoNdxkqOU8stuZQIa7mOtILDH/dY72fNL+dIbeRFVUCiwIIanfMbJgSIK9xnlAuIZjVss", + "pHakdKv0ycC/hYrYMOciZ7FiOccKcIHX6PECti6wfaACTXZ26BGUAx+errL/79ITBsG/3CJUX5sMitqV", + "0w/w3x4+Kyk6H0vhtUOQz4T4zx74iit/GFvVB94pZaU+iIA8ZNQx6vHWeooyklS6PKrQYTbxrFJg43mS", + "USS9nkKPqUAGO7vxpQKvoNeRCuk9tztEduTaDHF00W0FKQu1iUb3roL/Nnkp8T+wvEsb020/Bi3BvmGY", + "+1CTb8pqZ4QaFW0iy3qEaviHIACns65CQ1G1/qrLRqhNbrFJZponKXlmlYoTrA39omw4BisNw3OYl5oh", + "Ek0XOqAORh1TEDh0VvXrPdyj3MO4aj9agLAABF2rVIdAXGYZRVNubazWiM5FItj1DNMkYvRxrLRAFysu", + "XbOOKivCwFn+9y+EknoA5EZLVsSZ7ypYQQvyQbU4G7cuekgzOe8S1w8o1jBJB08s2LgMmkhWwqC/oEr4", + "9WxJ+4geRJHviD7cS3CHwAIMsmnsXQRLXEzSKQ6jSGHPJvfSaj7PIWTO2XcnYk2XFV+b/t4JJ9mviBPq", + "Yfx5nnC2OEH+erJT0VkV68/Eb8/7QC7uNiX2S3/R/hylqnGx+CJXZjDqeHTCQbWfGpP9iRZORsLC1f9W", + "uRM0w/kgw9cWPvmFmYuZPZHrsLRunDZfRtW4F7aPu3T9x5w3SQjoEfCuwIZ4gg50WSm9TKPhxFLWeM+j", + "6yVFBXdSHCfpIIdLgMIxGlPRjsp0Qruhnet5zDe9yWcLVJmT9WDRU5r8QoeUnh3y90SwqPoEPndSN+VM", + "DN+p8MI+ZxB+EX8q6YKe6VaacKgzR7fJXz+3CynDd6Li2ui5VjXmFo91u/A0d7UA8fLD6y45eCx6V9N0", + "7Op6CaMfFtlDSczvZRgr9cMVkj65H96QT+7HNnUsA7ZTaHi84DjdKNyGMc8kyJLw+ybatXajR+QHo7LU", + "SP0ShpHNqFILrXyM1gsYbLXhBEivTMCX8FeNXDS6ODdqHoRdh2ci8dW1UNwWGsR8/ZQGEqvyAyEGUBAj", + "1Ch5yzqhbjph6ex6sRTTKWUn8ZtVo81NhYomsnDt4DdfareP17xopF+eeAWNAokIjSR/B9ubCzryxEIh", + "1nHMWVd22o5zkb/pqXmpHTgT7kj0lZDiyMcd+72fIsxh/OU1K9/pW1UoNgcXWa1q2H9Vk+4jWHHndFCp", + "cXQGr7oa1VzNm6tz+aEiF0NmtWEtcC+GMStPeozn1abs2NILb6kW17AGPlu7YsJmZgnhhay5nMZKSYPO", + "Rc9E7WxL8Z0YnIcM2bMYYTG0oUGQnLpFFQ6PQjWEfFEjAJ2weB6L73BdyUR8ug+7WsYkkHNnpE6pIljx", + "Q87d5dRKajP0lsorM6eyx7JxStYbLimcRmK21kfXKi5w7Z8JgGxeGBtTfa9bturDsomihmV6V6J/nIW7", + "rEL9wxdToVYctkvLrHhNDIbfxKo2OOkHRLUPMryhIlFCiMUwoI6iyfuB5I3kcAyMMpf8fTmYDoB5nwjy", + "z5hjrdErHcoB5E/PHj2C/CDhBiFziFhDYeW4orhpWVz52fHXL+iAzLIjga1Ti3UjHYk2ZZ3oVFyCmht5", + "JK6rsm5JY+/1r4qg80xQrkjsKLYEws0b9+jcGfDzn5c928OA3euGflQx5gV3NEI7YUd+3ywYJrUePwQp", + "T71uWIwvCjPAnKeKhPgF5Wimj2UIcrZcUYJPxHRKJWGbdVCA0GO49oyvAae8bW6B1jPio3I3prjXxuta", + "9T6cDvLeV5j1Hqd08T+/q8QM7keUtdT7oAz6d42fn9crMncfkobx8x+Af9RE6f528c/vV81vnmcdUDfm", + "WX/QWf/j75MyoKDw5UWzlDWNiUxWMD9ljLFmrt1K1VSnkhUnWSIF9OpjV3AUi7ibE236ZUK1udVcKzVY", + "9Idl5z3suOhlKAygYdNsKso44H9ZS78cEZC0CRalymhno8LdXTnu7TVas8fMGbXiA6V6THtfVJsjgSaF", + "e08d1amlK9GucRmas7noBQBrDimL59bdyegA3U1stlSzG4BksDHa3vTExZKcWJALcfV1cnfiYBh8pXvA", + "6El8XTHLmHYmGi2fEDjJDepK4mdeheM8XIcLR3U9aCxfv3DK++Go0WrLMBtI96VEMrSTGhR772N5lqXC", + "ZKtBN93KZ43FjLKDcKBtDy/WfaXZRnEX1p1yZmNaGrITVuxpy/IuZjsgkPGTLhKTYapqQlY4FdTHIGKT", + "oUuFGqhF0pvAjPwNVSHgOQbxhDZA6Dkno435mI6rBNI4PwJEOqd1zGGbz1Kb/tB/fvX8ZYz9U6aOhgg7", + "7zfjjFeMN1ewyCteIOFFtynmuKRr6CA22BaBWefz7eBBhpCcileacRr3qGPahyqNgqoiuhDYO8M7jTzR", + "VQyDgeMCYzJib1Q4H6bJTx4rfDLyDnqHYyVrVZHGSftIWFLoLT6kJVSd1yJ306crnBUumbG5plivyBjH", + "GvVt89qzJiPLLUf5i4fQf5bpymgXQNHVRuoqc7OnsSELunvyzdm/HXfhGjIU4jWKScDc4lODgsupLTDI", + "sypVJ+RN2r4lcTJdDdB+/ErcjHvsQubwYezdSCXp7pYaqZqIV2Hns4BNz1Pt/EpILjr1//0hq50PG0Nc", + "A+n46Yyg5q9YExzfb08APiDnCb4wj8/jhKwJNqfoW5fqlA/qeaRe6YMCJXyG1LRPYKdZvUGYCAkLyoQD", + "6wYybsHK3tDn2ZO33NPHcoKTL+cdu0sLSYDLNI0VXk54dXkVUv3wLOvkF4k6+0z6S2Ifce4n+ZmM6dG2", + "vX5qJ++8kIJO6t2QcBPhSeRuu5IJNHqWqHu6pbdVmi+kmakGh3kOAzx+VNkuPIjXDmDCHYPBfP2qwCge", + "fHl5JwVM9SxRiZHaRbY6OpU5SlaZtgSMPiAsNXoWPLvtLpRB7DELwT9VTZpJrhKddXnke2FcdJhjSMOY", + "IWsvOzt6ub7+zSNYGOblUKSvnES1HFa708jSWhcK6Kbet9ZH1on+uIKmVeaOIIkN91R4C2RLqRs/kw3X", + "beuF1MHHwqwxaaado6QMzqHWh4rKwf358vLtRTUUiDphxLpoviqrtimJFq/4yyTR4ljaQ7P30Ny+ftKH", + "YhqkJWjXYSvt52Bh8kZ5NrHFMKPoc/iEgj5jXh1P7sHcCO1Dx3neaMK7hcLUGVUvzJo9i9cm/7ijKonp", + "jSMvMWe0MhhKCA1uy36C8VbO45cO8fyP43zalfaVaPuqD+WcDrj5O3vab/Qcw7BDCFPyBsCmFQoNrXKZ", + "M/6wpvk2PXj3BcnBu3tRg38YYlDIgbsN568+E23vbhvJy/v1bMW+4FHTX8hYeE4X5KVDZBtXbegHsJej", + "Oik86nED0bn33ybt2SG8X+ac9Y/B8EXSeBBhTY2H+urI18loo2DlUYdXHlS4qgsPu1NOpQCwGwUoFoRT", + "c38KbF5plqcfzMfDWL1n2MDZu+j9lfQFMoyxgRcJDl+CHsXRDqVFCfD/AMQ/ThX263Ru3c2jicNlNsTc", + "P63UnvxuMVEYe8SVsk+SWqqr29wFldGBqFKBUKqm16PFqMUBBNNmrfywA2o7s+ZWOU9mRopL88HalLiw", + "H/p1q0xtHUcpxmFgL47JtpSRqaOcqsdSrb0IHCpxgcaIXW6UaH6pq1jeQMhuTlFV3EW94ppi/zKIrh5M", + "OZvmt9bdXHTRc/83X9x+wp5l3X5UhdRBOdnKmbHz+LIuRbZWnh3gvPoF/NqwpA/jSJOVRY0Ws4RWHJ4c", + "FZ5QWx2zbDtsrlboV01wmTqMu7V3pjOCo8S4rVWS7ibLTP5YnE02whdP1/7QHGwDlAvKrbSRzUH1/Mmf", + "qys6H/GA+6iEFI1daCP8UjUNJePPvcUyx/mZbcA1h258Pac6pZmqEEkeqpdibCGxuHcRO+OgjJkzZUKz", + "Ea2DW6JmnwQY3a5Du2ZmGAi78uA03sAD9s5kkuZpTKp9z6OheFxoymoLcPVE4zVWJkgvY4FzKS55khf6", + "186pRDF41Hsd3VZonWwMR8o7s7US35ydnSWTOTQHCIa1T7EBSvqdDmxx/MN82GBPdpKSnU7KI57PsMEP", + "73ToJff07Ok2il5gobsOLTuEbZ0Ndmab37er59oQw72/ptJQtARM5hsBvRrG7BG1Vd4chWJs549x9Mes", + "exLHGKt9wu/jbTP9L7bDMbn5ToEwNuo4wLmzv6bwJVRzzpCBrqssjQJQ0qn4iwzKCVVrtieRptjnQiP2", + "XVtzFBjMI3wh06af4pwf2WCZxhlzEwUgmLSCr19aizM9dWuz2+YT/dqGm+TWJrMeqsaahRfBnsccJdpQ", + "ouA+onRiAjoUoiKAwt6CDCrGb7lNrK0ikWFQq5bKZVed9yM/9iyc0RWdJQaIfALIZiMYFHf13fpR84Tm", + "w4ypeNfmyCdYf4XoE7d9t273p9TqSyhT4mjMQh+iU8E8RQmHjyg+mmlRatfZLXOeN0Hg9AO8O6DC3Thx", + "2lHhTgC54euSSKsOWFaQc2TMbLt5EH6MJXB7GAndyKFz/kHu9BnkwWvgHI9+nMHLjmhIL/7j+Zu/4BUi", + "TRYDwgHtMVPYI50U/O9zyMNRl4RhKm0jZ5lzc15IhMABss84MDAS+N8vfvg+6nIAdbVPHnJAoZ+KYMXT", + "s7Oeh1yGUVFyJszqzWSIY6Wca+s+thwiDR+EKF8ulfkWzdhZxHlwn9+rSNPHj/9nAA==", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/git/git.go b/internal/git/git.go index 0a818ef..6e04ff6 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -298,6 +298,20 @@ func IsAncestor(dir, ancestor, descendant string) (bool, error) { return false, fmt.Errorf("git merge-base --is-ancestor %s %s: exit status %d: %s", ancestor, descendant, code, stderr) } +func MergeInProgress(dir string) (bool, error) { + _, _, code, err := runCode(dir, nil, "rev-parse", "--quiet", "--verify", "MERGE_HEAD") + return err == nil && code == 0, err +} + +func AbortMerge(dir string) error { + merging, err := MergeInProgress(dir) + if err != nil || !merging { + return err + } + _, err = run(dir, "merge", "--abort") + return err +} + func ReadFile(repo, rev, path string) (string, bool, error) { _, _, code, err := runCode(repo, nil, "cat-file", "-e", rev+":"+path) if err != nil { diff --git a/internal/server/server.go b/internal/server/server.go index 10a8843..91cdffc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -560,6 +560,24 @@ func (s *Server) DiscardTask(w http.ResponseWriter, r *http.Request, taskID stri writeJSON(w, http.StatusAccepted, t) } +func (s *Server) PostToBoard(w http.ResponseWriter, r *http.Request, taskID string) { + req := api.BoardPostCreate{} + if !readJSON(w, r, &req) { + return + } + t, _, ok := s.activeTask(w, r, taskID) + if !ok { + return + } + + err := s.agents.Post(r.Context(), t, actor(r), req.Text) + if err != nil { + writeAgentError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (s *Server) activeTask(w http.ResponseWriter, r *http.Request, taskID string) (api.Task, api.Project, bool) { t, err := s.store.GetTask(r.Context(), taskID) if err != nil { diff --git a/internal/store/discard.go b/internal/store/discard.go index 7a49a1f..903f2d6 100644 --- a/internal/store/discard.go +++ b/internal/store/discard.go @@ -125,9 +125,11 @@ func (s *Store) EndTurn(ctx context.Context, sessionID, actor string, payload ma role := "" state := DiscardNone discardSession := "" + lead := false err := tx.QueryRowContext(ctx, ` - SELECT coalesce(t.id, ''), coalesce(t.project_id, ''), s.owner, s.role, coalesce(t.discard_state, ''), coalesce(t.discard_session, '') - FROM agent_sessions s LEFT JOIN tasks t ON t.id = s.task_id WHERE s.id = ?`, sessionID).Scan(&taskID, &projectID, &owner, &role, &state, &discardSession) + SELECT coalesce(t.id, ''), coalesce(t.project_id, ''), s.owner, s.role, coalesce(t.discard_state, ''), coalesce(t.discard_session, ''), + s.member = coalesce((SELECT f.member FROM agent_sessions f WHERE f.task_id = s.task_id AND f.role = s.role ORDER BY f.created_at LIMIT 1), '') + FROM agent_sessions s LEFT JOIN tasks t ON t.id = s.task_id WHERE s.id = ?`, sessionID).Scan(&taskID, &projectID, &owner, &role, &state, &discardSession, &lead) if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } @@ -169,7 +171,7 @@ func (s *Store) EndTurn(ctx context.Context, sessionID, actor string, payload ma case state == DiscardRunning && discardSession == sessionID: next = DiscardClosing _, err = tx.ExecContext(ctx, `UPDATE tasks SET discard_state = ? WHERE id = ?`, string(DiscardClosing), taskID) - case state == DiscardNone && finalizeMerge: + case state == DiscardNone && finalizeMerge && lead: err = tx.QueryRowContext(ctx, `UPDATE tasks SET finalizing = merging_by, merging_by = '' WHERE id = ? AND merging_by != '' RETURNING finalizing`, taskID).Scan(&finalizing) if errors.Is(err, sql.ErrNoRows) { err = nil diff --git a/internal/store/store.go b/internal/store/store.go index 1f5c26d..52f8936 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -476,6 +476,9 @@ ALTER TABLE inbox_responses DROP COLUMN client; ALTER TABLE inbox_responses DROP COLUMN exit_code; `}, {"add-user-default-model", ` ALTER TABLE users ADD COLUMN default_model TEXT NOT NULL DEFAULT 'claude-opus-5-5'; +`}, {"add-team-members", ` +ALTER TABLE agent_sessions ADD COLUMN member TEXT NOT NULL DEFAULT ''; +ALTER TABLE task_inputs ADD COLUMN member TEXT NOT NULL DEFAULT ''; `}} const ( @@ -1305,7 +1308,7 @@ func (s *Store) Participants(ctx context.Context, taskID string) ([]api.Particip rows, err := s.db.QueryContext(ctx, ` SELECT u.login, u.display_name, u.git_name, u.git_email FROM events e JOIN users u ON u.login = e.actor - WHERE e.task_id = ? AND (e.kind = 'user.message' OR (e.kind = 'task.created' AND json_extract(e.payload, '$.forked_from') IS NOT NULL)) + WHERE e.task_id = ? AND (e.kind IN ('user.message', 'board.posted') OR (e.kind = 'task.created' AND json_extract(e.payload, '$.forked_from') IS NOT NULL)) GROUP BY u.login ORDER BY min(e.seq) `, taskID) if err != nil { @@ -1349,6 +1352,17 @@ func (s *Store) TaskEvents(ctx context.Context, taskID string, after int64, limi return scanEvents(rows) } +func (s *Store) BoardPosts(ctx context.Context, taskID string, after int64) ([]api.Event, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT `+eventColumns+` + FROM events WHERE task_id = ? AND kind = 'board.posted' AND seq > ? ORDER BY seq + `, taskID, after) + if err != nil { + return nil, err + } + return scanEvents(rows) +} + func (s *Store) TaskEventsTail(ctx context.Context, taskID string, limit int) ([]api.Event, error) { rows, err := s.db.QueryContext(ctx, ` SELECT * FROM (SELECT `+eventColumns+` FROM events WHERE task_id = ? ORDER BY seq DESC LIMIT ?) ORDER BY seq @@ -1478,9 +1492,9 @@ func (s *Store) CreateSession(ctx context.Context, sess api.AgentSession, projec } return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { _, err := tx.ExecContext(ctx, ` - INSERT INTO agent_sessions (id, task_id, owner, vendor, model, vendor_session_id, state, role, created_by, created_at, forked_from, forked_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, sess.Id, nullable(sess.TaskId), sess.Owner, string(sess.Vendor), sess.Model, sess.VendorSessionId, string(sess.State), string(sess.Role), sess.CreatedBy, formatTime(sess.CreatedAt), forkedFrom, forkedAt) + INSERT INTO agent_sessions (id, task_id, owner, vendor, model, vendor_session_id, state, role, member, created_by, created_at, forked_from, forked_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, sess.Id, nullable(sess.TaskId), sess.Owner, string(sess.Vendor), sess.Model, sess.VendorSessionId, string(sess.State), string(sess.Role), sess.Member, sess.CreatedBy, formatTime(sess.CreatedAt), forkedFrom, forkedAt) if err != nil { return err } @@ -1836,7 +1850,7 @@ const taskStateExpr = `CASE END FROM agent_sessions s WHERE s.task_id = tasks.id AND s.role != 'reviewer' AND s.state != 'completed' - ORDER BY s.created_at DESC LIMIT 1 + ORDER BY s.state = 'running' DESC, s.subagents > 0 DESC, s.created_at DESC LIMIT 1 ), 'no_agent') END` @@ -1895,7 +1909,7 @@ const projectColumns = `id, name, repo_path, default_branch, remote, created_by, const taskColumns = `id, project_id, title, merge_title, branch, worktree_path, base_ref, port_base, color, status, created_by, created_at, closed_at, merge_commit, todo_item, forked_from, forked_at, merge_queued_at, workflow_name, workflow_run, workflow_stage, parent_task_id, parent_merge_by, merge_after_review_by != '', state, merging_by != '' OR finalizing != '', discard_state != '' AND status = 'active', (SELECT coalesce(max(seq), 0) FROM events WHERE events.task_id = tasks.id AND kind = 'turn.ended' AND json_extract(payload, '$.summary') = 1)` -const sessionColumns = `id, coalesce(task_id, ''), owner, vendor, model, vendor_session_id, state, role, created_by, created_at, forked_from, forked_at, subagents` +const sessionColumns = `id, coalesce(task_id, ''), owner, vendor, model, vendor_session_id, state, role, member, created_by, created_at, forked_from, forked_at, subagents` type scanner interface { Scan(dest ...any) error @@ -1985,7 +1999,7 @@ func scanSession(row scanner) (api.AgentSession, error) { createdAt := "" forkedFrom := "" forkedAt := "" - err := row.Scan(&sess.Id, &sess.TaskId, &sess.Owner, &vendor, &sess.Model, &sess.VendorSessionId, &state, &role, &sess.CreatedBy, &createdAt, &forkedFrom, &forkedAt, &sess.Subagents) + err := row.Scan(&sess.Id, &sess.TaskId, &sess.Owner, &vendor, &sess.Model, &sess.VendorSessionId, &state, &role, &sess.Member, &sess.CreatedBy, &createdAt, &forkedFrom, &forkedAt, &sess.Subagents) if err != nil { return sess, err } diff --git a/internal/store/taskinputs.go b/internal/store/taskinputs.go index 09e41c2..74ebaf1 100644 --- a/internal/store/taskinputs.go +++ b/internal/store/taskinputs.go @@ -4,12 +4,14 @@ import ( "context" "database/sql" "encoding/json" + "errors" "time" ) type TaskInput struct { ID int64 TaskID string + Member string Actor string Text string Images []string @@ -26,8 +28,8 @@ func (s *Store) AddTaskInput(ctx context.Context, in TaskInput, projectID, sessi payload["images"] = in.Images } return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { - res, err := tx.ExecContext(ctx, `INSERT INTO task_inputs (task_id, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?)`, - in.TaskID, in.Actor, in.Text, string(images), now()) + res, err := tx.ExecContext(ctx, `INSERT INTO task_inputs (task_id, member, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?, ?)`, + in.TaskID, in.Member, in.Actor, in.Text, string(images), now()) if err != nil { return err } @@ -39,25 +41,28 @@ func (s *Store) AddTaskInput(ctx context.Context, in TaskInput, projectID, sessi }) } -func (s *Store) WithdrawTaskInput(ctx context.Context, taskID string, id int64, projectID, sessionID, actor string) error { - return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { - res, err := tx.ExecContext(ctx, `DELETE FROM task_inputs WHERE id = ? AND task_id = ?`, id, taskID) - if err != nil { - return err - } - n, err := res.RowsAffected() - if err != nil { - return err - } - if n == 0 { +func (s *Store) WithdrawTaskInput(ctx context.Context, taskID string, id int64, projectID, actor string) (string, error) { + sessionID := "" + err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { + member := "" + err := tx.QueryRowContext(ctx, `DELETE FROM task_inputs WHERE id = ? AND task_id = ? RETURNING member`, id, taskID).Scan(&member) + if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } + if err != nil { + return err + } + err = tx.QueryRowContext(ctx, `SELECT id FROM agent_sessions WHERE task_id = ? AND member = ? AND role = 'implementer' ORDER BY created_at DESC LIMIT 1`, taskID, member).Scan(&sessionID) + if err != nil { + return err + } return emit(sessionEvent("user.unqueued", projectID, taskID, "", sessionID, actor, map[string]any{"id": id})) }) + return sessionID, err } -func (s *Store) TaskInputs(ctx context.Context, taskID string) ([]TaskInput, error) { - rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, actor, text, images, created_at FROM task_inputs WHERE task_id = ? ORDER BY id`, taskID) +func (s *Store) TaskInputs(ctx context.Context, taskID, member string) ([]TaskInput, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, member, actor, text, images, created_at FROM task_inputs WHERE task_id = ? AND member = ? ORDER BY id`, taskID, member) if err != nil { return nil, err } @@ -68,7 +73,7 @@ func (s *Store) TaskInputs(ctx context.Context, taskID string) ([]TaskInput, err in := TaskInput{} images := "" createdAt := "" - err = rows.Scan(&in.ID, &in.TaskID, &in.Actor, &in.Text, &images, &createdAt) + err = rows.Scan(&in.ID, &in.TaskID, &in.Member, &in.Actor, &in.Text, &images, &createdAt) if err != nil { return nil, err } @@ -92,8 +97,8 @@ func (s *Store) RestoreTaskInputs(ctx context.Context, inputs []TaskInput) error if err != nil { return err } - _, err = tx.ExecContext(ctx, `INSERT INTO task_inputs (id, task_id, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?, ?)`, - in.ID, in.TaskID, in.Actor, in.Text, string(images), formatTime(in.CreatedAt)) + _, err = tx.ExecContext(ctx, `INSERT INTO task_inputs (id, task_id, member, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + in.ID, in.TaskID, in.Member, in.Actor, in.Text, string(images), formatTime(in.CreatedAt)) if err != nil { return err } diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index 5875f51..9ee6708 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -62,7 +62,14 @@ func Validate(def api.WorkflowDefinition) error { } seen[s.Name] = true - if strings.TrimSpace(s.Model) == "" { + switch { + case s.Agents != nil && strings.TrimSpace(s.Model) != "": + problems = append(problems, fmt.Sprintf("%s: model can't be given with agents, which name their own", where)) + case s.Agents != nil: + for _, p := range TeamProblems(*s.Agents) { + problems = append(problems, fmt.Sprintf("%s: %s", where, p)) + } + case strings.TrimSpace(s.Model) == "": problems = append(problems, fmt.Sprintf("%s: model is required", where)) } @@ -103,6 +110,32 @@ func Validate(def api.WorkflowDefinition) error { return nil } +func TeamProblems(members []api.TeamMember) []string { + problems := []string{} + if len(members) < 2 { + problems = append(problems, "a team needs at least two agents") + } + seen := map[string]bool{} + for i, mem := range members { + where := fmt.Sprintf("agent %d (%q)", i+1, mem.Name) + p := nameProblem(mem.Name) + if p != "" { + problems = append(problems, fmt.Sprintf("%s: name %s", where, p)) + } + if seen[mem.Name] { + problems = append(problems, fmt.Sprintf("%s: another agent has the same name", where)) + } + seen[mem.Name] = true + if strings.TrimSpace(mem.Model) == "" { + problems = append(problems, fmt.Sprintf("%s: model is required", where)) + } + if strings.TrimSpace(mem.Prompt) == "" { + problems = append(problems, fmt.Sprintf("%s: prompt is empty", where)) + } + } + return problems +} + func graphProblems(def api.WorkflowDefinition) []string { problems := []string{} finals := []string{} diff --git a/mac/Sources/Byre/Board.swift b/mac/Sources/Byre/Board.swift new file mode 100644 index 0000000..5359900 --- /dev/null +++ b/mac/Sources/Byre/Board.swift @@ -0,0 +1,70 @@ +import ByreAPI +import SwiftUI + +struct BoardView: View { + @Environment(Store.self) private var store + + var body: some View { + let taskId = store.selectedTaskId + let posts = store.transcript.filter { $0.kind == "board.posted" } + VStack(spacing: 0) { + HStack(spacing: 10) { + Text("Board").font(Fonts.font(13, weight: 700)) + Text(taskId.map { store.team($0).map(\.member).joined(separator: ", ") } ?? "") + .foregroundStyle(Palette.muted) + .lineLimit(1) + .truncationMode(.tail) + } + .modifier(HeaderBar()) + if posts.isEmpty { + EmptyText(text: taskId == nil ? "No task selected." : "No posts yet. Post from the Transcript tab's composer.") + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(posts, id: \.seq) { ev in + PostView(ev: ev, taskId: taskId) + .id(ev.seq) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .onAppear { + proxy.scrollTo(posts.last?.seq, anchor: .bottom) + } + .onChange(of: posts.count) { + proxy.scrollTo(posts.last?.seq, anchor: .bottom) + } + } + } + } + .frame(maxHeight: .infinity, alignment: .top) + } +} + +private struct PostView: View { + let ev: ByreEvent + let taskId: String? + + var body: some View { + let p = ev.payload.value + let member = str(p, "member") ?? "" + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(member.isEmpty ? ev.actor : member).font(Fonts.font(12, weight: 700)) + if !member.isEmpty { + Text(str(p, "model") ?? "").foregroundStyle(Palette.muted) + } + WhenText(when: ev.createdAt).foregroundStyle(Palette.muted) + } + .font(Fonts.font(11)) + MarkdownView(blocks: parseMarkdown(str(p, "text") ?? ""), ctx: MDContext(taskId: taskId)) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(member.isEmpty ? Palette.accentTint : Palette.raised, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border)) + } +} diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index e6a6241..45962bc 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -13,6 +13,8 @@ final class ChatModel { let kind: ChatKind let store: Store let builder: TranscriptBuilder + let member: String? + @ObservationIgnored private var members: [String: String] = [:] private(set) var scrollRequest: (seq: Int, id: Int, anchor: UnitPoint)? private(set) var fetching = false private(set) var older: [ByreEvent]? @@ -30,6 +32,7 @@ final class ChatModel { self.kind = kind self.store = store let taskId: String? = if case .task(let id) = kind { id } else { nil } + member = taskId.flatMap { store.pickedMember($0) } let session: () -> AgentSession? = { [weak store] in switch kind { case .lead: store?.currentLead() @@ -82,8 +85,26 @@ final class ChatModel { } } + var team: Bool { + member != nil + } + var placeholder: String { - kind == .lead ? "Message your lead. Paste or drop images to attach them." : "Message the agent. Paste or drop images to attach them." + if team { + return "Post to the team's board; every agent of the team gets it." + } + return kind == .lead ? "Message your lead. Paste or drop images to attach them." : "Message the agent. Paste or drop images to attach them." + } + + private func shows(_ ev: ByreEvent) -> Bool { + guard let member, let sid = ev.agentSessionId else { + return true + } + if ev.kind == "session.created" { + members[sid] = str(ev.payload.value, "member") ?? "" + } + let owner = members[sid] ?? store.sessions[taskId ?? ""]?.first { $0.id == sid }?.member ?? "" + return owner.isEmpty || owner == member } var empty: String { @@ -122,7 +143,9 @@ final class ChatModel { self?.appended(events) }) offs.append(feed.streamed.add { [weak self] ev in - self?.builder.appendDelta(ev) + if self?.shows(ev) == true { + self?.builder.appendDelta(ev) + } }) activeObserver = NotificationCenter.default.addObserver(of: NSApplication.self, for: .didBecomeActive) { [weak self] _ in self?.markShown() @@ -144,7 +167,8 @@ final class ChatModel { store.drafts?.flush() } - private func appended(_ events: [ByreEvent]) { + private func appended(_ all: [ByreEvent]) { + let events = all.filter(shows) if events.contains(where: { $0.kind == "agent.tool_result" || $0.kind == "turn.ended" }) { unmergedTimer?.cancel() unmergedTimer = Task { [weak self] in @@ -254,6 +278,13 @@ final class ChatModel { case .lead: _ = try await api.messageLead(body: .json(.init(text: text, images: images, model: sessionId != nil ? model : firstModel))).accepted case .task(let id): + if team { + if images != nil { + throw ApiError(status: 0, message: "The board takes text only; drop the images to post.") + } + _ = try await api.postToBoard(path: .init(taskId: id ?? ""), body: .json(.init(text: text))).noContent + return + } if let sessionId { _ = try await api.sendMessage(path: .init(sessionId: sessionId), body: .json(.init(text: text, images: images, model: model))).accepted return @@ -346,8 +377,11 @@ private struct ChatHead: View { } } else { let sess = store.currentSession(task.id) - let busy = sess?.state == .running + let busy = store.taskBusy(task.id) header { + if model.team { + MemberPicker(taskId: task.id, picked: model.member) + } CompactButton(sessionId: sess?.id, state: sess?.state, usage: sess.flatMap { model.builder.context[$0.id] }) SyncButton(task: task, busy: busy) Spacer(minLength: 12) @@ -372,6 +406,23 @@ private struct ChatHead: View { } } +private struct MemberPicker: View { + let taskId: String + let picked: String? + @Environment(Store.self) private var store + + var body: some View { + HStack(spacing: 4) { + ForEach(store.team(taskId), id: \.id) { s in + PaneTabButton(label: s.state == .running ? "\(s.member) ●" : s.member, active: s.member == picked) { + store.pickedMembers[taskId] = s.member + } + .help("Show \(s.member)'s transcript (\(s.model.isEmpty ? s.vendor.rawValue : s.model), \(s.state.rawValue))") + } + } + } +} + private struct SyncButton: View { let task: ByreTask let busy: Bool @@ -782,7 +833,7 @@ struct DetailsBox<Summary: View, Content: View>: View { } } -private struct WhenText: View { +struct WhenText: View { let when: Date var body: some View { diff --git a/mac/Sources/Byre/Composer.swift b/mac/Sources/Byre/Composer.swift index ce76d29..1223828 100644 --- a/mac/Sources/Byre/Composer.swift +++ b/mac/Sources/Byre/Composer.swift @@ -324,18 +324,20 @@ struct ComposerView: View { Thumbnails(key: draftKey) editor HStack(spacing: 8) { - Picker("", selection: Binding(get: { selected }, set: { v in - pick = v - modelChoices[modelKey] = v - })) { - ForEach(options, id: \.self) { m in - Text(m).tag(m) + if !chat.team { + Picker("", selection: Binding(get: { selected }, set: { v in + pick = v + modelChoices[modelKey] = v + })) { + ForEach(options, id: \.self) { m in + Text(m).tag(m) + } } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + .help("Model for the next turn") } - .labelsHidden() - .pickerStyle(.menu) - .fixedSize() - .help("Model for the next turn") Spacer() Button("Interrupt") { if let id = live?.id { @@ -458,7 +460,7 @@ struct ComposerView: View { let imgs = images.take(draftKey) text = "" let sent = drafts.startSend(draftKey, value, imgs.count) - let cancel = chat.builder.showPending(value, imgs) + let cancel = chat.team ? {} : chat.builder.showPending(value, imgs) Task { do { let ids = try await uploadImages(store.api, imgs) diff --git a/mac/Sources/Byre/MarkdownView.swift b/mac/Sources/Byre/MarkdownView.swift index b773f0b..3072413 100644 --- a/mac/Sources/Byre/MarkdownView.swift +++ b/mac/Sources/Byre/MarkdownView.swift @@ -242,7 +242,7 @@ struct InlineRenderer { s.color = taskPalette[task.color] render(inner, s, &out) append(run(" ", style), &out) - let state = store.currentSession(task.id)?.state + let state = store.taskBusy(task.id) ? .running : store.currentSession(task.id)?.state var dot = Style(link: AppLink.task(task.id), color: state == .running ? Palette.ok : Palette.muted, small: true, tip: state?.rawValue ?? "no session") append(run(state == .running ? "●" : "○", dot), &out) dot.color = Palette.muted diff --git a/mac/Sources/Byre/MergeDialog.swift b/mac/Sources/Byre/MergeDialog.swift index c08589b..1ec0d4f 100644 --- a/mac/Sources/Byre/MergeDialog.swift +++ b/mac/Sources/Byre/MergeDialog.swift @@ -76,7 +76,7 @@ func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays) async guard let task = store.findTask(taskId), task.status == .active else { return } - if store.currentSession(taskId)?.state == .running { + if store.taskBusy(taskId) { overlays.showAlert("Agent is mid-turn; merge when it's idle") return } diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index d2e1edb..5f0e07e 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -23,6 +23,7 @@ struct TabBadgeView: View { let tabLabels: [TaskTab: String] = [ .transcript: "Transcript", + .board: "Board", .changes: "Changes", .files: "Files", .terminal: "Terminal", @@ -221,7 +222,9 @@ private struct TaskPane: View { switch store.focus.tab { case .transcript: ChatView(kind: .task(store.selectedTaskId)) - .id(store.selectedTaskId ?? "") + .id("\(store.selectedTaskId ?? "")/\(store.selectedTaskId.flatMap { store.pickedMember($0) } ?? "")") + case .board: + BoardView() case .changes: ChangesView() case .files: @@ -388,11 +391,13 @@ private struct TabStrip: View { let findings = latest.map(findingCount) ?? 0 let hasReviews = transcript.contains(where: isReviewEvent) let workflow = store.selectedTaskId.flatMap { store.findTask($0) }?.workflowRun != nil + let team = store.selectedTaskId.map { !store.team($0).isEmpty } ?? false let order = (first + taskTabs.filter { !first.contains($0) }).filter { t in if t == tab { return true } switch t { + case .board: return team case .inbox: return waiting > 0 case .reviews: return hasReviews case .workflow: return workflow diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index aadf632..f03f517 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -2,7 +2,7 @@ import Foundation import Observation import SwiftData -let taskTabs: [TaskTab] = [.transcript, .changes, .files, .terminal, .inbox, .reviews, .workflow, .project] +let taskTabs: [TaskTab] = [.transcript, .board, .changes, .files, .terminal, .inbox, .reviews, .workflow, .project] private let historyPage = 1000 private let leadPage = 50 @@ -46,6 +46,7 @@ final class Store { var summaryReads: [String: Int64] = [:] private(set) var leadMore = false var todoBusy: [String: String] = [:] + var pickedMembers: [String: String] = [:] private(set) var drafts: Drafts? let images = ImageDrafts() let events = Listeners<ByreEvent>() @@ -158,7 +159,32 @@ final class Store { } func currentSession(_ taskId: String) -> AgentSession? { - sessions[taskId]?.last(where: { $0.state != .completed }) ?? sessions[taskId]?.last + let list = (sessions[taskId] ?? []).filter { $0.member == (pickedMember(taskId) ?? "") } + return list.last(where: { $0.state != .completed }) ?? list.last + } + + func team(_ taskId: String) -> [AgentSession] { + let list = sessions[taskId] ?? [] + var names: [String] = [] + for s in list where !s.member.isEmpty && !names.contains(s.member) { + names.append(s.member) + } + return names.compactMap { name in + let mine = list.filter { $0.member == name } + return mine.last(where: { $0.state != .completed }) ?? mine.last + } + } + + func pickedMember(_ taskId: String) -> String? { + let names = team(taskId).map(\.member) + if let picked = pickedMembers[taskId], names.contains(picked) { + return picked + } + return names.first + } + + func taskBusy(_ taskId: String) -> Bool { + (sessions[taskId] ?? []).contains { $0.state == .running } } func taskState(_ task: ByreTask) -> TaskState { @@ -457,8 +483,31 @@ final class Store { } func openSummary(_ task: ByreTask) { - jump = (task.id, task.summarySeq) - focusTask(task.id, .transcript) + let seq = task.summarySeq + if let ev = taskFeeds[task.id]?.events.first(where: { $0.seq == seq }) { + pickMember(task.id, ev) + jump = (task.id, seq) + focusTask(task.id, .transcript) + return + } + Task { + do { + let evs = try await api.client.listTaskEvents(path: .init(taskId: task.id), query: .init(after: seq - 1, limit: 1)).ok.body.json + if let ev = evs.first(where: { $0.seq == seq }) { + pickMember(task.id, ev) + } + } catch { + log.error("load summary event: \(errorText(error), privacy: .public)") + } + jump = (task.id, seq) + focusTask(task.id, .transcript) + } + } + + private func pickMember(_ taskId: String, _ ev: ByreEvent) { + if let member = str(ev.payload.value, "member"), !member.isEmpty { + pickedMembers[taskId] = member + } } func selectTask(_ taskId: String?) {