From 31c5d966879d0512cad8d615e30925bf92b2e1c5 Mon Sep 17 00:00:00 2001 From: chrisbewz Date: Wed, 21 Oct 2026 09:56:48 -0300 Subject: [PATCH] Configurable providers for the PR and Issues board agents An admin can now give the Issues agent and the PR agent a provider, model and effort of their own, in Settings - Workers, instead of always starting on the office's default worker. The Queue agent keeps running on the default, as do the tasks it adds. Choices are validated the way the default worker's are, kept in .agent-office/prompts.json, and one the office can no longer start is forgotten on the next boot. The first prompt at a board hires its agent on the choice made for that station, and the sign-in gate follows the provider picked. --- docs/agents.md | 2 +- docs/features.md | 2 +- src/client/ui/settings-boards.ts | 110 +++++++++++++++++++++++++++++ src/client/ui/settings.ts | 8 +++ src/server/prompts.ts | 34 ++++++++- src/server/stations.ts | 17 ++++- src/server/workers/manager.ts | 4 +- src/server/ws/handlers/settings.ts | 17 +++++ src/server/ws/handlers/workers.ts | 5 +- src/shared/layout.ts | 3 + src/shared/protocol/settings.ts | 7 +- tests/prompts.test.ts | 42 ++++++++++- tests/server-dispatch.test.ts | 9 +++ tests/workers.test.ts | 38 ++++++++++ 14 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 src/client/ui/settings-boards.ts diff --git a/docs/agents.md b/docs/agents.md index 02016b48b..b86a504ee 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -2,7 +2,7 @@ Back to the [README](../README.md). -Every new worker starts on the office's **Default worker**: a provider, model and effort an admin picks in ⚙️ Settings → **🤖 Workers** (the `--agent` with its own default model until someone does). The hire, ask, queue, issue and meeting windows show it on one line; click **✏️ Edit** there to pick **Claude Code**, **OpenCode**, **Codex**, **Grok**, **Muse**, **DeepSeek Harness**, **Pi** or **Cursor**, a model and an effort for just that worker or task, and **↺ Use the default** to go back. Board agents and tasks the Queue agent adds, which nobody picks for, start on the default too. Existing workers keep their provider when prompted or resumed, and queued tasks keep their choice when retried or restored after a restart. +Every new worker starts on the office's **Default worker**: a provider, model and effort an admin picks in ⚙️ Settings → **🤖 Workers** (the `--agent` with its own default model until someone does). The hire, ask, queue, issue and meeting windows show it on one line; click **✏️ Edit** there to pick **Claude Code**, **OpenCode**, **Codex**, **Grok**, **Muse**, **DeepSeek Harness**, **Pi** or **Cursor**, a model and an effort for just that worker or task, and **↺ Use the default** to go back. The Issues and PR agents start on the default too, unless an admin gives one its own: under ⚙️ Settings → **🤖 Workers**, each has a line with its own **✏️ Edit** and **↺ Use the default**. The Queue agent, and the tasks it adds, always run on the default. Existing workers keep their provider when prompted or resumed, and queued tasks keep their choice when retried or restored after a restart. Every harness takes both a **Model** and an **Effort**, each optional: leave one on **Default** and the harness uses its own settings. diff --git a/docs/features.md b/docs/features.md index f320d9b99..e0cbcc31f 100644 --- a/docs/features.md +++ b/docs/features.md @@ -50,7 +50,7 @@ Everything in the office, room by room. Back to the [README](../README.md). - **PR board.** A second tack board shows pull requests in *Draft*, *In review*, *Approved*, *Merged* and *Closed*, with CI status and diff size. Its column headers filter by label too, and the box under each header by title. Click a PR to read its description, comments, reviews and checks, and comment on it. **Files changed** shows the diff, with the files listed on the left as a folder tree or a flat list, and review comments under the lines they're on. Tick a file **Reviewed** (or press **V**; **J**/**K** step through files) and it folds away. Your ticks are kept in your browser, and a file you ticked that changes again is flagged. **Merge…** squashes, merges or rebases it, and can have GitHub merge it once its checks pass. **Close PR…** closes it without merging, with an optional comment, and can delete its branch. **Review** and **Fix comments & merge** hand the PR to a worker; the second one addresses the review comments, gets the checks green and merges. When the PR conflicts with its base, that button becomes **Fix conflicts & merge**: a new worker is picked by default, merges the base in, resolves the conflicts and merges. Its labels change the same way as an issue's: **🏷️ Edit** in the PR window, or the 🏷️ on its card. - **The merge gong.** A brass gong stands by the elevator, on the far side from the PR board. When a pull request merges, it rings for everyone on the floor, confetti rains down from the ceiling all over the floor (the loft too) and bursts over the desk the PR came from (with no worker there, over the gong), and every worker that's awake climbs up on its desk for a quick dance, its light flashing like a disco ball, before it hops back into its seat. Workers on bean bags dance on the bag, and the board agents on their kiosks. It rings however the PR merged: from the PR window, by a worker's `gh pr merge`, by auto-merge or on GitHub itself. When the last task on the task queue gets done, the gong rings three times and confetti flies over every desk. Walk up and press **E** to bang it yourself. - **Ask a worker about anything on a board.** Every issue and PR has **✍️ Ask a worker…**: type your own prompt, and the worker gets it along with which issue or PR it's about. Send it to a new worker at a free desk, or to one already sitting at a desk. -- **Board agents.** An agent stands at a little kiosk beside each of the issues board, the PR board and the task queue. Walk up and press **E**, type what you want, and it runs as a Claude Code (or your configured) agent that already knows its job. The issues agent files, finds, triages, labels and closes issues with `gh`. The PR agent sums up, reviews, comments on and merges pull requests. The queue agent turns what you ask for into tasks on the queue, one per piece of work, and can tell you what's queued or take tasks off. It never does the work itself, not even a one-line fix: it queues it and tells you what it queued. The first prompt hires it; after that your prompts go into the same session, so you can follow up, and a sleeping one wakes up with your prompt. Press **O** there to watch its terminal and **X** to send it home, and the next prompt starts a fresh one. The agents work in the floor's main checkout, so they're told not to change code there: work that needs code changed goes on the queue, where a fresh worker does it in its own worktree. +- **Board agents.** An agent stands at a little kiosk beside each of the issues board, the PR board and the task queue. Walk up and press **E**, type what you want, and it runs as a Claude Code (or your configured) agent that already knows its job. The issues agent files, finds, triages, labels and closes issues with `gh`. The PR agent sums up, reviews, comments on and merges pull requests. The queue agent turns what you ask for into tasks on the queue, one per piece of work, and can tell you what's queued or take tasks off. It never does the work itself, not even a one-line fix: it queues it and tells you what it queued. It starts on the office's default worker, or on a provider, model and effort an admin gives it under ⚙️ Settings → **🤖 Workers** (the queue agent always runs on the default). The first prompt hires it; after that your prompts go into the same session, so you can follow up, and a sleeping one wakes up with your prompt. Press **O** there to watch its terminal and **X** to send it home, and the next prompt starts a fresh one. The agents work in the floor's main checkout, so they're told not to change code there: work that needs code changed goes on the queue, where a fresh worker does it in its own worktree. - **Agents that manage agents.** Any worker can see and run the office's other workers. Ask one to "send everyone whose PR merged home", "hire someone to fix the flaky test" or "tell Mochi to rebase", and it can. Claude Code, Codex and OpenCode workers get an `agent-office` MCP server with five tools: `list_workers` (who's at which desk, their status and task, their branch, and where their pull request stands), `hire_worker`, `send_home`, `tell_worker` and `link_pr`. Every worker, whichever agent it runs, also has the same thing as the `office-workers` command (`office-workers list`, `office-workers home --merged`, and so on). Sending home works as it does from a desk: each worker's worktree and branch are deleted unless they hold work that isn't on GitHub (what a merged PR delivered counts as on GitHub), and the answer says what was kept and why. Everyone on the floor sees a toast saying which worker did what, and nobody can send themselves home. - **Whose pull request is whose.** A worker's desk shows where its pull request stands (open, or merged and free to go home) rather than just *ready* or *done*. The office knows the pull request when it was opened from the desk (**O**), from the branch the office gave the worker, or by a queue task. A Claude Code worker that runs `gh pr create` itself is linked to what that opened too, whatever branch it pushed, which is how a worker hired without its own worktree gets one. For the ones it can't see (another agent that opened its own from a branch of its own, or one opened by hand), ask any worker to "link the PRs to the workers that opened them": it matches them up and says so with `link_pr` (`office-workers pr 12 --worker Mochi`, or `--none` to take a wrong one off). - **The meeting room.** Under the boss office there's a glass meeting room with a long table. Press **E** at it (or **🤝 Meeting room** in the **☰** menu, which sits on the top bar while a meeting is on), or **🤝 Meeting…** on an issue or **🤝 Review panel…** on a PR, to call a meeting: 2 to 5 workers step out of the elevator one after another, walk over and sit down round the table, each with a role you can rename, and work through one question or task together in a pattern. **Debate**: everyone proposes, then critiques the others, and the head of the table writes the decision. **Lead & team**: the lead splits the task into a part for each of the others, they each do theirs, and the lead merges the work. **Map-reduce**: the same task over a list of parts (files, modules, issues) shared out between the mappers, then one worker combines what they found. **Red / blue**: red attacks the change (bugs, security holes, edge cases) and blue fixes what holds up, round after round, until red finds nothing more. **Review panel**: reviewers each read a PR through their own lens (correctness, security, performance…), and the head of the table merges them into one review, every finding tagged with its lens, which the office posts on the PR. Every meeting declares its output file up front (`docs/decisions/….md`, a review…) and ends when that file is written: the office commits it on the meeting's own branch (**🔀 Open PR** in the meeting window opens a pull request for it). Meetings are bounded by a round limit per pattern, and have no token limit. You pick the round limit for a debate (2 to 4) or red / blue (1 to 5). The other patterns always run the same rounds, so the form says so in place of a limit to set (**🔒 3 rounds · fixed by the Lead & team workflow**) and names them: planning, execution and merge for lead & team, map and reduce for map-reduce, the reviews and the combined review for a review panel. When a worker won't write its part, or when someone is sent home, the meeting stops and says why; **⛔ Stop meeting** stops one by hand. The sign on the door shows the pattern, the round, and the tokens and cost so far, and a one-line summary once it's over; the board on the back wall shows the output as it's written, and the card over each worker says whose turn it is and which round. The workers stay at the table so you can read their terminals until **🧹 Clear the room** or the next meeting sends them home. diff --git a/src/client/ui/settings-boards.ts b/src/client/ui/settings-boards.ts new file mode 100644 index 000000000..468c5d1a0 --- /dev/null +++ b/src/client/ui/settings-boards.ts @@ -0,0 +1,110 @@ +// ⚙️ Settings' Board agents, under Workers: what the Issues and PR agents start on when nobody +// picks for them there. An admin can give each one a provider, model and effort of its own; left +// alone, they run on the office's Default worker (see server/stations.ts). The Queue agent is never +// picked for here: it always runs on the default. +import type { Net } from '../net'; +import { store } from '../state'; +import type { AgentChoice } from '../../shared/protocol'; +import { STATION_AGENT, type ConfigurableStation } from '../../shared/layout.js'; +import { h } from './dom'; +import { agentFields, choiceLabel, officeChoice } from './provider.js'; + +/** The boards an admin picks a worker for, in the order they stand here. */ +export const BOARD_AGENTS: readonly ConfigurableStation[] = ['issues', 'pulls'] as const; + +/** One board agent's row: what it starts on, and the fields to pick another for it. */ +export interface BoardAgentRow { + /** What it starts on: the station's own pick, or the office's default worker. */ + now: HTMLElement; + fields: HTMLElement; + /** Save, and ↺ Use the default. */ + actions: HTMLElement; + /** Repaints it from the store; called again whenever ⚙️ Settings changes. */ + paint: () => void; +} + +/** + * A board agent's provider, model and effort, in ⚙️ Settings → 🤖 Workers. It reads what the station + * starts on — its own pick, else the office's default — and offers ✏️ Edit to pick another, Save to + * send it, and ↺ Use the default to drop it. Only admins get the buttons; for everyone else it's a + * line saying what that agent runs on. + */ +export function boardAgentRow(net: Net, kind: ConfigurableStation): BoardAgentRow { + const name = STATION_AGENT[kind].name; + const id = `station-${kind}`; + const fields = agentFields(store.project, id, stationChoice(kind)); + let editing = false; + /** What the fields were opened on: ✏️ Edit with nothing changed sends nothing. */ + let opened: AgentChoice | undefined; + const now = h('p.outside-now'); + const edit = h('button.btn', { type: 'button', 'aria-expanded': 'false' }); + const save = h('button.btn.primary', { type: 'button' }, 'Save'); + const back = h('button.btn', { type: 'button' }, '↺ Use the default'); + const actions = h('div.seg', { style: 'margin-top:8px' }, edit, save, back); + + /** The station's own pick, when an admin made one this project can still run. */ + const own = () => store.prompts.stations?.[kind] as (AgentChoice & { by: string; at: number }) | undefined; + + edit.addEventListener('click', () => { + editing = !editing; + opened = editing ? stationChoice(kind) : undefined; + // It opens on what the agent runs on now, and closing without saving goes back to it. + if (editing) fields.set(stationChoice(kind)); + paint(); + if (editing) (fields.element.querySelector('select') as HTMLSelectElement | null)?.focus(); + }); + save.addEventListener('click', () => { + if (!fields.valid()) return; + const choice = fields.choice(); + // Nothing changed: saving would only restamp who set it. + if (opened && sameChoice(opened, choice)) { + editing = false; + paint(); + return; + } + net.send({ t: 'prompts.station', kind, choice }); + editing = false; + paint(); + }); + back.addEventListener('click', () => { + net.send({ t: 'prompts.station', kind, choice: null }); + editing = false; + paint(); + }); + + function paint(): void { + const admin = store.me.admin; + const mine = own(); + const choice = stationChoice(kind); + fields.element.classList.toggle('hidden', !admin || !editing); + actions.classList.toggle('hidden', !admin); + edit.textContent = editing ? '↺ Cancel' : '✏️ Edit'; + edit.title = editing ? `Leave the ${name.toLowerCase()} on ${choiceLabel(stationChoice(kind))}` : `Pick a provider, model or effort for the ${name.toLowerCase()}`; + edit.setAttribute('aria-expanded', String(editing)); + save.classList.toggle('hidden', !editing); + // ↺ Use the default only means something while there's a pick to drop, and not while picking. + back.classList.toggle('hidden', editing || !mine); + now.classList.toggle('hidden', editing); + now.textContent = choiceLabel(choice); + now.title = mine + ? `The ${name}'s own worker, set by ${mine.by}: it starts on this instead of the office's default` + : `The office's default worker; an admin can give the ${name.toLowerCase()} one of its own in ⚙️ Settings`; + fields.element.setAttribute('aria-label', `${name} worker`); + } + paint(); + // The default or a pick can change while this is open; it goes once ⚙️ Settings has closed. + const off = store.on('prompts', () => (now.isConnected ? paint() : off())); + return { now, fields: fields.element, actions, paint }; +} + +/** What a board agent starts on: its own pick, or the office's default worker. */ +export function stationChoice(kind: ConfigurableStation): AgentChoice { + const mine = store.prompts.stations?.[kind]; + if (mine) return { provider: mine.provider, ...(mine.model ? { model: mine.model } : {}), ...(mine.effort ? { effort: mine.effort } : {}) }; + return officeChoice(store.project); +} + +/** Whether two picks would start a worker the same way. */ +function sameChoice(a: AgentChoice, b: AgentChoice): boolean { + return a.provider === b.provider && (a.model ?? '') === (b.model ?? '') && (a.effort ?? '') === (b.effort ?? ''); +} diff --git a/src/client/ui/settings.ts b/src/client/ui/settings.ts index ba8dae5c2..ad2ca1462 100644 --- a/src/client/ui/settings.ts +++ b/src/client/ui/settings.ts @@ -6,7 +6,9 @@ import { askNotifyPermission, notifyPermission, type DesktopNotifier } from '../ import type { ThemePick, WebhookKind } from '../../shared/protocol'; import { THEME_PICKS } from '../../shared/theme'; import { mapChoices } from '../../shared/maps'; +import { STATION_AGENT } from '../../shared/layout'; import { dogSetting } from './settings-dog'; +import { BOARD_AGENTS, boardAgentRow } from './settings-boards'; import { h, openModal, timeAgo } from './dom'; import { agentFields, choiceLabel, officeChoice } from './provider'; import { openPromptEditor, rewrittenPrompts } from './prompts'; @@ -297,6 +299,10 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting const agentActions = h('div.seg', { style: 'margin-top:8px' }, agentSave, agentBack); const agentNow = h('p.outside-now'); const agentNote = h('p.setting-note'); + // What the Issues and PR agents start on: each one's own pick, or the default above (see settings-boards.ts). + const boardRows = BOARD_AGENTS.map((kind) => ({ kind, row: boardAgentRow(net, kind) })); + const boardNote = h('p.setting-note'); + boardNote.textContent = 'What the Issues and PR agents start on when nobody is there yet: leave them on the default worker above, or press ✏️ Edit to give one a provider, model and effort of its own. The Queue agent always runs on the default.'; const paintAgent = () => { const admin = store.me.admin; const picked = store.prompts.agent; @@ -468,6 +474,8 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting ], workers: [ setting('Default worker', 'office', agentNow, agent.element, agentActions, agentNote), + ...boardRows.map(({ kind, row }) => setting(STATION_AGENT[kind].name, 'office', row.now, row.fields, row.actions)), + setting('Board agents', 'office', boardNote), setting('Worker limit', 'office', limitRow, limitNote), setting('Workers whose pull request merged', 'office', leaveRow, leaveNote), setting('Prompts', 'office', promptsOpen, promptsNote), diff --git a/src/server/prompts.ts b/src/server/prompts.ts index c537804cc..45da53a57 100644 --- a/src/server/prompts.ts +++ b/src/server/prompts.ts @@ -1,6 +1,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { isAgentEffort, isAgentProvider, type AgentChoice, type AgentProvider, type PromptsState } from '../shared/protocol.js'; +import type { ConfigurableStation } from '../shared/layout.js'; import { PROMPTS, PROMPT_MAX, fillPrompt, isPromptId, promptText, type PromptId, type PromptVars } from '../shared/prompts.js'; import { validateWorkerEffort, validateWorkerModel } from './agents.js'; @@ -9,6 +10,8 @@ export interface PromptSource { text(id: PromptId): string; /** The worker picked in ⚙️ Settings, when one was. */ agent(): AgentChoice | undefined; + /** What an admin picked for a board agent (the Issues or PR one), when they picked one. */ + station(kind: ConfigurableStation): AgentChoice | undefined; } /** A prompt's text, from `source` when there is one, else the default. */ @@ -36,7 +39,13 @@ export class OfficePrompts implements PromptSource { } state(): PromptsState { - return { custom: { ...this.saved.custom }, ...(this.saved.agent ? { agent: { ...this.saved.agent } } : {}) }; + const { custom, agent, stations } = this.saved; + return { custom: { ...custom }, ...(agent ? { agent: { ...agent } } : {}), ...(stations ? { stations: { ...stations } } : {}) }; + } + + station(kind: ConfigurableStation): AgentChoice | undefined { + const s = this.saved.stations?.[kind]; + return s && { provider: s.provider, ...(s.model ? { model: s.model } : {}), ...(s.effort ? { effort: s.effort } : {}) }; } text(id: PromptId): string { @@ -75,6 +84,22 @@ export class OfficePrompts implements PromptSource { return undefined; } + /** Picks what a board agent starts on; null puts it back on the office's default worker. Returns why it can't, if it can't. */ + setStation(kind: unknown, choice: AgentChoice | null, by: string): string | undefined { + if (kind !== 'issues' && kind !== 'pulls') return 'Unknown board agent'; + if (!choice) { + if (this.saved.stations) delete this.saved.stations[kind]; + this.changed(); + return undefined; + } + const why = this.problem(choice); + if (why) return why; + const { provider, model, effort } = choice; + (this.saved.stations ??= {})[kind] = { provider, ...(model ? { model } : {}), ...(effort ? { effort } : {}), by, at: Date.now() }; + this.changed(); + return undefined; + } + private problem(c: AgentChoice): string | undefined { if (!isAgentProvider(c.provider) || !this.providers.list.includes(c.provider)) return 'Unknown agent provider'; if (c.provider === 'custom' && this.providers.configured !== 'custom') return 'Custom is not the configured agent provider'; @@ -103,6 +128,13 @@ export class OfficePrompts implements PromptSource { // One the office can't start any more (it was started with another --agent) is forgotten. if (!this.problem(choice)) this.saved.agent = { ...choice, by: typeof a.by === 'string' ? a.by : 'someone', at: typeof a.at === 'number' ? a.at : 0 }; } + for (const [kind, s] of Object.entries(raw?.stations ?? {})) { + if ((kind !== 'issues' && kind !== 'pulls') || !s || !isAgentProvider(s.provider)) continue; + const choice: AgentChoice = { provider: s.provider, model: typeof s.model === 'string' ? s.model : undefined, effort: isAgentEffort(s.effort) ? s.effort : undefined }; + // One the office can't start any more is forgotten, as the default worker's is. + if (this.problem(choice)) continue; + (this.saved.stations ??= {})[kind] = { ...choice, by: typeof s.by === 'string' ? s.by : 'someone', at: typeof s.at === 'number' ? s.at : 0 }; + } } private persist() { diff --git a/src/server/stations.ts b/src/server/stations.ts index a488eb81d..5823035ee 100644 --- a/src/server/stations.ts +++ b/src/server/stations.ts @@ -3,12 +3,27 @@ // first one follows this brief in the same prompt. The briefs themselves are prompts the office can // rewrite in ⚙️ Settings (shared/prompts.ts). -import type { StationKind } from '../shared/layout.js'; +import type { ConfigurableStation, StationKind } from '../shared/layout.js'; +import type { AgentChoice, AgentEffort, AgentProvider } from '../shared/protocol.js'; import { officePrompt, type PromptSource } from './prompts.js'; export function stationBrief(kind: StationKind, prompts?: PromptSource): string { return officePrompt(prompts, `station.${kind}`); } +/** + * What the agent at a board starts on: what an admin picked for the Issues or PR agent, else the + * office's default worker (the Queue agent's always). + */ +export function stationChoice( + kind: StationKind | undefined, + prompts: { station(kind: ConfigurableStation): AgentChoice | undefined } | undefined, + officeDefault: AgentChoice, +): [AgentProvider, string | undefined, AgentEffort | undefined] { + const picked = kind && kind !== 'queue' ? prompts?.station(kind) : undefined; + const c = picked ?? officeDefault; + return [c.provider, c.model, c.effort]; +} + /** Claude Code tools the queue agent is launched without, so it can't edit the checkout even by mistake. */ export const QUEUE_AGENT_DISALLOWED_TOOLS = ['Edit', 'Write', 'NotebookEdit']; diff --git a/src/server/workers/manager.ts b/src/server/workers/manager.ts index b19b2b3cd..3aeca0969 100644 --- a/src/server/workers/manager.ts +++ b/src/server/workers/manager.ts @@ -4,7 +4,7 @@ import type { AgentChoice, AgentEffort, AgentProvider, TerminalHit, WorkerInfo, import { AGENT_PROVIDERS, takesEffort, takesModel } from '../../shared/providers.js'; import { Worktrees, workspaceOf, type WorktreeCleanup, type WorktreeState } from '../worktrees.js'; import { DESK_BY_ID, STATION_AGENT, deskBuilt } from '../../shared/layout.js'; -import { stationBrief } from '../stations.js'; +import { stationBrief, stationChoice } from '../stations.js'; import type { PromptSource } from '../prompts.js'; import type { GhAs } from '../signins.js'; import type { ServiceOwner } from '../services.js'; @@ -339,7 +339,7 @@ export class WorkerManager { if (!clean) return 'Empty prompt'; const w = [...this.workers.values()].find((x) => x.info.deskId === deskId); if (!w) { - const info = this.spawn(deskId, by, clean, false, 'agent', undefined, undefined, undefined, undefined, owner); + const info = this.spawn(deskId, by, clean, false, 'agent', ...stationChoice(DESK_BY_ID.get(deskId)?.station, this.prompts, this.officeDefault), undefined, owner); return typeof info === 'string' ? info : { info, hired: true }; } // Typed into the question it's asking, the prompt would answer it. diff --git a/src/server/ws/handlers/settings.ts b/src/server/ws/handlers/settings.ts index 06ab3bac3..748446599 100644 --- a/src/server/ws/handlers/settings.ts +++ b/src/server/ws/handlers/settings.ts @@ -6,6 +6,7 @@ import { MAX_WORKER_LIMIT, parseWorkerLimit } from '../../machine.js'; import { OFFICE_MAP } from '../../../shared/maps/index.js'; import { isThemePick } from '../../../shared/theme.js'; import { PROMPTS, PROMPT_MAX, isPromptId } from '../../../shared/prompts.js'; +import { STATION_AGENT } from '../../../shared/layout.js'; import type { SettingsClientMsg } from '../../../shared/protocol.js'; import type { Ctx } from '../../office/context.js'; import { str } from '../../office/input.js'; @@ -133,4 +134,20 @@ export const settingsHandlers = { if (err) return ctx.warn(c, err); ctx.toastAll(choice ? `🤖 ${who} set the office’s default worker` : `🤖 ${who} put the office’s default worker back to ${path.basename(ctx.cfg.agentCmd)}`); }, + 'prompts.station'(ctx, c, msg) { + const who = c.peer.name; + if (!ctx.meOf(c.accountId).admin) return ctx.warn(c, 'Only admins can pick a board agent’s provider'); + const kind = msg.kind; + if (kind !== 'issues' && kind !== 'pulls') return; + const ch = msg.choice; + if (ch !== null && (!ch || typeof ch !== 'object')) return; + const choice = ch && { + provider: ch.provider, + model: ch.model === undefined || ch.model === '' ? undefined : str(ch.model, OPEN_CODE_MODEL_MAX + 1), + effort: ch.effort === undefined ? undefined : ch.effort, + }; + const err = ctx.prompts.setStation(kind, choice, who); + if (err) return ctx.warn(c, err); + ctx.toastAll(choice ? `🤖 ${who} set the ${STATION_AGENT[kind].name}’s provider` : `🤖 ${who} put the ${STATION_AGENT[kind].name} back on the office’s default worker`); + }, } satisfies HandlerMap; diff --git a/src/server/ws/handlers/workers.ts b/src/server/ws/handlers/workers.ts index 3e468890c..11de03d09 100644 --- a/src/server/ws/handlers/workers.ts +++ b/src/server/ws/handlers/workers.ts @@ -3,7 +3,9 @@ import { MAX_REPOS, type RepoSource } from '../../workers.js'; import { OPEN_CODE_MODEL_MAX } from '../../../shared/providers.js'; import { isAgentEffort, isAgentProvider, type WorkerClientMsg } from '../../../shared/protocol.js'; +import { DESK_BY_ID } from '../../../shared/layout.js'; import { issueNumber, num, str } from '../../office/input.js'; +import { stationChoice } from '../../stations.js'; import { here, workerOf } from './common.js'; import type { FeatureHooks, HandlerMap, ViewPieces } from './types.js'; @@ -131,7 +133,8 @@ export const workerHandlers = { const deskId = str(msg.deskId, 32); // Nobody there yet: whoever asks first hires it, on their own sign-ins. const hires = !floor.workers.deskOccupied(deskId); - ctx.withSignIn(c, hires ? ctx.claudeFor(floor.workers.officeDefault.provider) : undefined, () => { + const [provider] = stationChoice(DESK_BY_ID.get(deskId)?.station, ctx.prompts, floor.workers.officeDefault); + ctx.withSignIn(c, hires ? ctx.claudeFor(provider) : undefined, () => { const r = floor.workers.station(deskId, who, str(msg.prompt, 20000), c.accountId); if (typeof r === 'string') ctx.warn(c, r); else if (r.hired) ctx.toastFloor(floor, `${who} asked the ${r.info.name} something`); diff --git a/src/shared/layout.ts b/src/shared/layout.ts index a9e3bba45..0449eed0e 100644 --- a/src/shared/layout.ts +++ b/src/shared/layout.ts @@ -138,6 +138,9 @@ export const SEATS: DeskDef[] = [...DESKS, ...WING_DESKS, ...BEANBAGS]; /** The boards with an agent standing by: the Issues board, the PR board and the task queue. */ export type StationKind = 'issues' | 'pulls' | 'queue'; +/** The board agents an admin can give their own provider, model and effort: the Issues and PR agents (the Queue agent always runs on the office's default worker). */ +export type ConfigurableStation = Exclude; + /** * The board agents: a worker standing behind a little kiosk just west of each of those boards (see * BOARDS), there for anyone to prompt about it. (x, z) is the kiosk. They face into the room, so at diff --git a/src/shared/protocol/settings.ts b/src/shared/protocol/settings.ts index 5ad8a4438..0b585a5b1 100644 --- a/src/shared/protocol/settings.ts +++ b/src/shared/protocol/settings.ts @@ -1,6 +1,7 @@ // ⚙️ Settings and the building's services: notifications, the machine, upgrades, the sky, holidays, maps, prompts. import type { CustomMap } from '../maps/index.js'; +import type { ConfigurableStation } from '../layout.js'; import type { PromptId } from '../prompts.js'; import type { AgentChoice } from './agents.js'; @@ -16,6 +17,8 @@ export interface PromptsState { * started with (--agent), on its own default model. */ agent?: AgentChoice & { by: string; at: number }; + /** What the Issues and PR agents start on, by station kind, when an admin picked something for one; the rest start on the office's default worker. */ + stations?: Partial>; } /** Where a team webhook posts: Slack and Discord get their own message format, anything else plain JSON. */ @@ -190,7 +193,9 @@ export type SettingsClientMsg = /** Rewrite one of the office's prompts (admins only); null puts the default back. */ | { t: 'prompts.set'; id: PromptId; text: string | null } /** Pick the worker everyone starts on (admins only); null goes back to the office's --agent. */ - | { t: 'prompts.agent'; choice: AgentChoice | null }; + | { t: 'prompts.agent'; choice: AgentChoice | null } + /** Pick what one of the board agents starts on (admins only); null puts it back on the office's default worker. */ + | { t: 'prompts.station'; kind: ConfigurableStation; choice: AgentChoice | null }; export type SettingsServerMsg = | { t: 'upgrade'; state: UpgradeState } diff --git a/tests/prompts.test.ts b/tests/prompts.test.ts index ae46714c2..5c8da29fb 100644 --- a/tests/prompts.test.ts +++ b/tests/prompts.test.ts @@ -104,8 +104,48 @@ test('the default worker is checked before it is kept, and one the office can no assert.ok(readFileSync(file, 'utf8')); }); +test('a board agent’s own worker is checked before it is kept, and one the office can no longer start is forgotten', (t) => { + const dir = scratch(t); + const book = new OfficePrompts(dir, { list: ['claude', 'opencode', 'codex'], configured: 'claude' }, () => {}); + assert.equal(book.station('issues'), undefined); + // Only the Issues and PR agents have one: the Queue agent always runs on the office's default. + assert.match(book.setStation('queue', { provider: 'claude' }, 'Ada') ?? '', /Unknown board agent/); + assert.match(book.setStation({ provider: 'claude' }, { provider: 'claude' }, 'Ada') ?? '', /Unknown board agent/); + assert.match(book.setStation('issues', { provider: 'custom' }, 'Ada') ?? '', /Unknown agent provider/); + assert.match(book.setStation('issues', { provider: 'claude', model: 'gpt-9' }, 'Ada') ?? '', /Invalid Claude model/); + assert.match(book.setStation('pulls', { provider: 'codex', effort: 'enormous' as never }, 'Ada') ?? '', /Invalid effort/); + assert.equal(book.station('issues'), undefined); + + assert.equal(book.setStation('issues', { provider: 'claude', model: 'opus', effort: 'high' }, 'Ada'), undefined); + assert.equal(book.setStation('pulls', { provider: 'opencode', model: 'xai/grok-4' }, 'Grace'), undefined); + assert.deepEqual(book.station('issues'), { provider: 'claude', model: 'opus', effort: 'high' }); + assert.deepEqual(book.station('pulls'), { provider: 'opencode', model: 'xai/grok-4' }); + assert.deepEqual(book.state().stations?.issues, { provider: 'claude', model: 'opus', effort: 'high', by: 'Ada', at: book.state().stations!.issues!.at }); + assert.equal(book.state().stations?.issues?.by, 'Ada'); + + // It's there after a restart. + const again = new OfficePrompts(dir, { list: ['claude', 'opencode', 'codex'], configured: 'claude' }, () => {}); + assert.deepEqual(again.station('issues'), { provider: 'claude', model: 'opus', effort: 'high' }); + assert.deepEqual(again.station('pulls'), { provider: 'opencode', model: 'xai/grok-4' }); + + // Null puts that one back on the office's default worker, and the other stays. + assert.equal(book.setStation('issues', null, 'Ada'), undefined); + assert.equal(book.station('issues'), undefined); + assert.deepEqual(book.station('pulls'), { provider: 'opencode', model: 'xai/grok-4' }); + assert.deepEqual(new OfficePrompts(dir, { list: ['claude', 'opencode'], configured: 'claude' }, () => {}).station('issues'), undefined); + + // One the office can't start any more (another --agent) is forgotten, as the default worker's is. + const file = path.join(dir, 'prompts.json'); + writeFileSync(file, JSON.stringify({ custom: {}, stations: { issues: { provider: 'custom', by: 'Ada', at: 1 }, pulls: { provider: 'codex', model: 'gpt-5.5', effort: 'low', by: 'Ada', at: 2 } } })); + const withCustom = new OfficePrompts(dir, { list: ['claude', 'custom'], configured: 'custom' }, () => {}); + assert.deepEqual(withCustom.station('issues'), { provider: 'custom' }); + const without = new OfficePrompts(dir, { list: ['claude', 'codex'], configured: 'claude' }, () => {}); + assert.equal(without.station('issues'), undefined); + assert.deepEqual(without.state().stations, { pulls: { provider: 'codex', model: 'gpt-5.5', effort: 'low', by: 'Ada', at: 2 } }); +}); + test('a board agent is told its rewritten brief', () => { - const source: PromptSource = { text: (id) => (id === 'station.pulls' ? 'You review PRs. The request:' : PROMPTS[id].text), agent: () => undefined }; + const source: PromptSource = { text: (id) => (id === 'station.pulls' ? 'You review PRs. The request:' : PROMPTS[id].text), agent: () => undefined, station: () => undefined }; assert.equal(stationBrief('pulls', source), 'You review PRs. The request:'); assert.equal(stationBrief('issues', source), stationBrief('issues')); }); diff --git a/tests/server-dispatch.test.ts b/tests/server-dispatch.test.ts index 2331c41fc..f7a267490 100644 --- a/tests/server-dispatch.test.ts +++ b/tests/server-dispatch.test.ts @@ -404,6 +404,15 @@ test('settings, accounts, sign-ins and the boards answer as before', async () => a.send({ t: 'prompts.set', id: 'nope', text: 'x' }); a.send({ t: 'prompts.agent', choice: null }); assert.equal(await told('🤖'), '🤖 Eve put the office’s default worker back to claude'); + // A board agent's own worker: set per station, and dropped back onto the default. + a.send({ t: 'prompts.station', kind: 'issues', choice: { provider: 'opencode' } }); + assert.equal(await told('🤖'), '🤖 Eve set the Issues agent’s provider'); + assert.deepEqual((await a.take('prompts', (m) => !!m.state.stations?.issues)).state.stations?.issues?.provider, 'opencode'); + a.send({ t: 'prompts.station', kind: 'issues', choice: null }); + assert.equal(await told('🤖'), '🤖 Eve put the Issues agent back on the office’s default worker'); + assert.equal((await a.take('prompts', (m) => !m.state.stations?.issues)).state.stations?.issues, undefined); + // The Queue agent is never picked for, and a provider the office can't run is refused. + a.send({ t: 'prompts.station', kind: 'queue', choice: { provider: 'opencode' } }); a.send({ t: 'notify.webhook', url: 'not a url' }); assert.match((await a.take('toast', (m) => m.level === 'warn')).text, /./); a.send({ t: 'upgrade.check' }); diff --git a/tests/workers.test.ts b/tests/workers.test.ts index 59a637b6b..1936d71a2 100644 --- a/tests/workers.test.ts +++ b/tests/workers.test.ts @@ -1086,6 +1086,7 @@ test('a worker nobody picked a model for starts on the office default, and a boa const prompts: PromptSource = { text: (id) => (id === 'station.issues' ? 'You triage issues. The request:' : PROMPTS[id].text), agent: () => ({ provider: 'claude', model: 'sonnet', effort: 'low' }), + station: () => undefined, }; const workers = new WorkerManager(f.root, f.data, f.claude, ['--from-test'], { url: 'http://127.0.0.1:1', token: '' }, events([]), ledger(f.data), undefined, prompts); t.after(() => workers.shutdown()); @@ -1702,3 +1703,40 @@ test("a worker whose worktree was deleted outside the office waits, marked lost, assert.equal(after.get(gone.id)?.lost, undefined); assert.deepEqual(toasts, []); }); + +test('a board agent starts on the worker an admin picked for its station, and the queue agent never does', async (t) => { + const f = fixture(); + isolateProviderEnvironment(f, t); + const previousLog = process.env.FAKE_AGENT_LOG; + process.env.FAKE_AGENT_LOG = f.log; + t.after(() => { + if (previousLog === undefined) delete process.env.FAKE_AGENT_LOG; + else process.env.FAKE_AGENT_LOG = previousLog; + f.close(); + }); + const prompts: PromptSource = { + text: (id) => PROMPTS[id].text, + agent: () => ({ provider: 'claude', model: 'opus', effort: 'high' }), + station: (kind) => (kind === 'issues' ? { provider: 'claude', model: 'sonnet', effort: 'low' } : kind === 'pulls' ? { provider: 'codex', model: 'gpt-5.5' } : undefined), + }; + const workers = new WorkerManager(f.root, f.data, f.claude, ['--from-test'], { url: 'http://127.0.0.1:1', token: '' }, events([]), ledger(f.data), undefined, prompts); + t.after(() => workers.shutdown()); + + // The Issues agent: its own station's pick, not the office default. + const issues = workers.station('station-issues', 'Ada', 'File one about the dog'); + assert.equal(typeof issues, 'object'); + if (typeof issues === 'string') return; + assert.deepEqual([issues.info.provider, issues.info.model, issues.info.effort], ['claude', 'sonnet', 'low']); + + // The PR agent: its own, on another provider. + const pulls = workers.station('station-pulls', 'Ada', 'Review #12'); + assert.equal(typeof pulls, 'object'); + if (typeof pulls === 'string') return; + assert.deepEqual([pulls.info.provider, pulls.info.model, pulls.info.effort], ['codex', 'gpt-5.5', undefined]); + + // The Queue agent ignores station picks: it runs on the office's default worker. + const queue = workers.station('station-queue', 'Ada', 'Build it'); + assert.equal(typeof queue, 'object'); + if (typeof queue === 'string') return; + assert.deepEqual([queue.info.provider, queue.info.model, queue.info.effort], ['claude', 'opus', 'high']); +});