From b71e7218411a4c384094e370e4039c32f456cfb8 Mon Sep 17 00:00:00 2001 From: emgghost <117538888+emgghost@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:59:29 +0330 Subject: [PATCH 01/11] Workers learn lessons from being corrected (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each floor keeps standing rules ("run npm test before opening a PR") in .agent-office/lessons.json. A worker corrected for good records one with record_lesson / `office-workers lesson add`, people add, reword and take them off in ☰ → 🎓 Lessons, and every agent hired on the floor afterwards gets them ahead of its first task (the editable worker.lessons prompt). Co-authored-by: Claude Opus 5.5 --- README.md | 1 + bin/office-workers.js | 90 +++++++++++-- docs/code-layout.md | 2 +- docs/features.md | 3 +- docs/how-it-works.md | 3 +- src/client/features/hud/index.ts | 2 + src/client/state/slices/index.ts | 2 + src/client/state/slices/lessons.ts | 28 +++++ src/client/ui/help.ts | 1 + src/client/ui/lessons.css | 19 +++ src/client/ui/lessons.ts | 113 +++++++++++++++++ src/server/floor.ts | 5 + src/server/hooks/office-lessons.ts | 31 +++++ src/server/hooks/server.ts | 4 +- src/server/lessons.ts | 111 ++++++++++++++++ src/server/workers/manager.ts | 15 ++- src/server/ws/handlers/index.ts | 3 + src/server/ws/handlers/lessons.ts | 35 ++++++ src/shared/lessons.ts | 49 ++++++++ src/shared/prompts.ts | 22 +++- src/shared/protocol.ts | 8 +- src/shared/protocol/floors.ts | 3 + src/shared/protocol/lessons.ts | 13 ++ tests/client-store.test.ts | 10 +- tests/lessons.test.ts | 195 +++++++++++++++++++++++++++++ tests/office-workers.test.ts | 2 +- tests/repos.test.ts | 2 +- tests/server-dispatch.test.ts | 2 +- tests/size.test.ts | 2 +- 29 files changed, 745 insertions(+), 31 deletions(-) create mode 100644 src/client/state/slices/lessons.ts create mode 100644 src/client/ui/lessons.css create mode 100644 src/client/ui/lessons.ts create mode 100644 src/server/hooks/office-lessons.ts create mode 100644 src/server/lessons.ts create mode 100644 src/server/ws/handlers/lessons.ts create mode 100644 src/shared/lessons.ts create mode 100644 src/shared/protocol/lessons.ts create mode 100644 tests/lessons.test.ts diff --git a/README.md b/README.md index fac9441a5..a2123d8e5 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i - **From your phone, too.** `/lite` is the office in 2D: every worker and what it's waiting on, its terminal with the keys a phone keyboard lacks, and the boards. The 3D office offers it on a phone or a slow computer. - **GitHub on the walls.** Issues and pull requests hang on cork boards. Hand an issue to a worker, queue tasks, give a worker its own git worktree and open its PR with one key (if one gets deleted behind the office's back, the worker waits at its desk until you rebuild it). One task can span several projects: the worker gets a worktree of each, and a PR in each that links the others. - **Agents that manage agents.** Every worker can list, hire, message and send home the others, through an `agent-office` MCP server (Claude Code, Codex, OpenCode) or the `office-workers` command. Ask one to "send everyone whose PR merged home" and it does, deleting their worktrees and branches unless they hold unpushed work. A worker that opens its pull request itself (`gh pr create`) shows it at its desk, and one the office missed can be told which is its own (`office-workers pr`). +- **Workers that learn.** Correct a worker about how your project does things ("always add a test", "we use pnpm") and it records the rule as a lesson for its floor. Every worker hired there afterwards starts with the floor's lessons, and **☰ → 🎓 Lessons** is where you add, reword or take them off. - **Together.** Voice, chat, screen sharing on the lounge TV and a shared whiteboard. - **Other maps.** Turn the whole building into a castle: sit on a throne of iron blades while your workers line up before you when they're done, send new ones off through the Hand of the King, and watch their beards grow long and grey as they toil. Send one home and the Kingsguard runs up from the dungeon, marches it down the stairs and throws it in a cell, where it starves, dies and rots down to a skeleton. Or into a space station in orbit, the Earth turning outside its windows: you run it from the captain's chair on the bridge, and a worker sent home is marched to the airlock and blown out into space, to drift off past the observation windows with everyone who went before it. Or make a map of your own, with its own way of seeing workers off in JSON ([docs/maps.md](docs/maps.md)). diff --git a/bin/office-workers.js b/bin/office-workers.js index 5b1cea7f7..ad1fdeefe 100644 --- a/bin/office-workers.js +++ b/bin/office-workers.js @@ -1,7 +1,8 @@ #!/usr/bin/env node // office-workers: the office's workers, from inside Agent Office: who's at which desk and where their // pull requests stand, hiring one, sending some home (their worktrees and branches with them), -// telling one something and saying which pull request is one's. The office puts it on every worker's PATH and gives each its own address and +// telling one something, saying which pull request is one's, and the floor's lessons (the standing rules +// every new worker starts with). The office puts it on every worker's PATH and gives each its own address and // token in AGENT_OFFICE_HOOK_URL, AGENT_OFFICE_WORKER_ID and AGENT_OFFICE_HOOK_TOKEN; this talks to // the /office/workers endpoint with them (src/server/office-workers.ts). `office-workers mcp` is the // same as an MCP server on stdio, which the office hands the agents that take one. Plain Node, no @@ -31,6 +32,10 @@ const USAGE = `Usage: desk, from the worker's own branch, or by a Claude Code worker running gh pr create office-workers pr --none [--worker ] take that pull request off the worker again + office-workers lesson [list] [--json] the floor's lessons: the standing rules every worker + hired here starts with + office-workers lesson add "" record one (or give it on stdin): when someone + corrects how you work for good, not just this task office-workers mcp serve these as MCP tools on stdio`; /** A mistake in how the command was called: the usage is shown with it. */ @@ -42,9 +47,11 @@ export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']; /** How long the office may take to come back when it's restarting (a dev reload, an upgrade). */ const RETRY_MS = 6000; /** Sending several workers home waits on git for each; hiring may fetch from GitHub first. */ -const TIMEOUT_MS = { list: 15_000, tell: 15_000, pr: 45_000, hire: 90_000, home: 300_000 }; -/** Where each call goes, under /office/workers. */ -const PATHS = { list: '', hire: '', home: '/home', tell: '/tell', pr: '/pr' }; +const TIMEOUT_MS = { list: 15_000, tell: 15_000, pr: 45_000, hire: 90_000, home: 300_000, lessons: 15_000, learn: 15_000 }; +/** Where each call goes on the office's hook server. */ +const PATHS = { list: '/office/workers', hire: '/office/workers', home: '/office/workers/home', tell: '/office/workers/tell', pr: '/office/workers/pr', lessons: '/office/lessons', learn: '/office/lessons' }; +/** The calls that only read. */ +const GETS = ['list', 'lessons']; /** * Reads `--flag value` and `--flag=value` options, and the words that aren't options. @@ -116,6 +123,20 @@ export function parseArgs(argv) { if (none ? words.length : words.length !== 1) throw new UsageError('pr takes one pull request, its number or URL, or --none to take it off (with --worker when the worker isn\'t you)'); return { cmd: 'pr', ...(none ? { unlink: true } : { pr: words[0] }), ...(opts['--worker'] !== undefined ? { worker: String(opts['--worker']).trim() } : {}), json: opts['--json'] === true }; } + // lessons, as in the list of them. + if (cmd === 'lesson' || cmd === 'lessons') { + const [sub = 'list', ...more] = rest; + if (sub === 'list' || sub === 'ls' || sub === '--json') { + const { opts, words } = options(sub === '--json' ? rest : more, [], ['--json']); + if (words.length) throw new UsageError(`lesson list takes no arguments (got ${words.join(' ')})`); + return { cmd: 'lessons', json: opts['--json'] === true }; + } + if (sub === 'add') { + const { opts, words } = options(more, [], ['--json']); + return { cmd: 'learn', ...(words.length ? { text: words.join(' ') } : {}), json: opts['--json'] === true }; + } + throw new UsageError(`lesson takes list or add (got ${sub}). Taking a lesson off is for people, in 🎓 Lessons`); + } if (cmd === 'hire') { const { opts, words } = options(rest, ['--prompt', '--provider', '--model', '--effort', '--desk', '--issue'], ['--no-worktree', '--json']); if (words.length) throw new UsageError(`Unexpected argument: ${words[0]} (give the task on stdin or with --prompt)`); @@ -157,16 +178,16 @@ export function officeEnv(env) { /** * The HTTP request for one of the office's worker calls. - * @param {'list' | 'hire' | 'home' | 'tell' | 'pr'} what + * @param {'list' | 'hire' | 'home' | 'tell' | 'pr' | 'lessons' | 'learn'} what * @param {{ url: string, worker: string, token: string }} office * @param {Record} [body] * @returns {{ method: string, url: string, headers: Record, body?: string, timeout: number }} */ export function buildRequest(what, office, body) { - const url = new URL(`${office.url}/office/workers${PATHS[what]}`); + const url = new URL(`${office.url}${PATHS[what]}`); url.searchParams.set('worker', office.worker); const headers = { authorization: `Bearer ${office.token}` }; - if (what === 'list') return { method: 'GET', url: url.href, headers, timeout: TIMEOUT_MS.list }; + if (GETS.includes(what)) return { method: 'GET', url: url.href, headers, timeout: TIMEOUT_MS[what] }; return { method: 'POST', url: url.href, headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}), timeout: TIMEOUT_MS[what] }; } @@ -206,7 +227,7 @@ async function send(req, fetchImpl) { /** * Makes one call to the office; resolves to what it answered, or throws with why it said no. - * @param {'list' | 'hire' | 'home' | 'tell' | 'pr'} what + * @param {'list' | 'hire' | 'home' | 'tell' | 'pr' | 'lessons' | 'learn'} what * @param {Record | undefined} body * @param {{ env: Record, fetch: typeof fetch }} io */ @@ -252,6 +273,19 @@ export function formatHome(answer, merged) { .join('\n'); } +/** The floor's lessons, a line each. */ +export function formatLessons(answer) { + const lessons = answer?.lessons ?? []; + if (!lessons.length) return 'This floor has no lessons yet.'; + return lessons.map((l) => `- ${l.text} (${l.by})`).join('\n'); +} + +/** What recording a lesson came to. */ +export function formatLearned(answer) { + const text = answer?.lesson?.text ?? ''; + return answer?.known ? `The floor already has that lesson: “${text}”` : `Recorded: “${text}”. Every worker hired on this floor from now on starts with it.`; +} + /** Whose pull request is whose now, after `pr` said so. */ export function formatLinked(answer) { const w = answer?.worker ?? {}; @@ -358,6 +392,30 @@ export const TOOLS = [ }, annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, + { + name: 'list_lessons', + title: 'List lessons', + description: + "Lists this floor's lessons: the standing rules about how work is done in this project that the office's workers have been taught, mostly by being corrected. " + + 'Every worker hired on the floor starts with them in its first prompt; a worker hired without one has not seen them, so check them before you start.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + { + name: 'record_lesson', + title: 'Record a lesson', + description: + 'Records a lesson for every worker hired on this floor from now on. Use it when someone corrects how you work in a way that should hold for every task in this project, ' + + 'not just this one ("run npm test before opening a PR", "never edit the generated client in src/api"). Not for one-offs about the task at hand. ' + + 'One short rule, in the imperative, that makes sense to a worker who knows nothing of this conversation. Saying what a lesson already says adds nothing. People can reword or take any lesson off.', + inputSchema: { + type: 'object', + properties: { text: { type: 'string', description: 'The rule, in a sentence or two (400 characters at most).' } }, + required: ['text'], + additionalProperties: false, + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, ]; const INSTRUCTIONS = @@ -366,6 +424,8 @@ const INSTRUCTIONS = "rather than looking for the agents with git, ps or HTTP calls. list_workers says where each one's pull request stands (merged: true means it merged), hire_worker " + 'puts a new agent to work, send_home sends agents home and deletes their worktrees and branches, tell_worker gives one a prompt, and link_pr says which pull request is a ' + "worker's when list_workers does not show it. Everyone in the office sees who did what. " + + "The floor also keeps lessons, standing rules every new worker starts with: list_lessons shows them, and when someone corrects how you work in a way that should hold for every " + + 'task in this project, record_lesson keeps it for the workers after you. ' + 'The office-workers command on your PATH does the same from a shell.'; /** Runs a tool; resolves to its text, and whether nothing it was asked came off, or throws with why it failed. */ @@ -387,6 +447,8 @@ async function runTool(name, args, io) { return { text: `Told ${answer.worker?.name ?? a.worker}.` }; } if (name === 'link_pr') return { text: formatLinked(await call('pr', a, io)) }; + if (name === 'list_lessons') return { text: formatLessons(await call('lessons', undefined, io)) }; + if (name === 'record_lesson') return { text: formatLearned(await call('learn', { text: a.text }, io)) }; throw new Error(`Unknown tool: ${name}`); } @@ -512,6 +574,18 @@ export async function main(argv, io = {}) { err(`Told ${answer.worker?.name ?? cmd.worker}.`); return 0; } + if (cmd.cmd === 'lessons') { + const answer = await call('lessons', undefined, ctx); + out(cmd.json ? JSON.stringify(answer.lessons ?? [], null, 2) : formatLessons(answer)); + return 0; + } + if (cmd.cmd === 'learn') { + const text = (cmd.text ?? (stdin.isTTY ? '' : await readStdin(stdin))).trim(); + if (!text) throw new UsageError('Say the lesson: office-workers lesson add "…", or give it on stdin'); + const answer = await call('learn', { text }, ctx); + out(cmd.json ? JSON.stringify(answer.lesson, null, 2) : formatLearned(answer)); + return 0; + } if (cmd.cmd === 'pr') { const { cmd: _c, json: asJson, ...ask } = cmd; const answer = await call('pr', ask, ctx); diff --git a/docs/code-layout.md b/docs/code-layout.md index d76e3d42a..a6ac397a1 100644 --- a/docs/code-layout.md +++ b/docs/code-layout.md @@ -52,7 +52,7 @@ They're in `core/registry.ts`, and each is a field of `ctx`. Every registration - **`office/`** is that context (`Ctx` in `office/context.ts`: the building-wide services, sending to browsers, the floors, the people in the office, going between floors, and the checks before something happens) and the pieces that make it. - **`ws/`** takes the sockets. Each domain's messages are handled in `ws/handlers/.ts`, a map from message type to handler that `satisfies HandlerMap`, and `ws/handlers/index.ts` puts them all in one `HandlerMap`: a message type with no handler, or a handler for a type that doesn't exist, fails the typecheck. The same file lists the features that keep something per person on a floor (`features`: what each lets go of when someone is `leaving` a floor, and once their socket has `closed`) and the pieces of what someone arriving on a floor is sent (`views`, one for each field of `FloorView`, put together by `office/views.ts`). - **`http/`** answers HTTP from a route table. Each file in `http/routes/` exports its routes, and `http/routes/index.ts` lists them in the order they're tried. Every route says its `auth` (`public`, or `session` for a signed-in browser), and the `Route` type won't let you leave it out. -- **`hooks/`** is the loopback-only hook server the workers call: their agents' hook events (`/hooks/`), the board agents' queue (`/office/queue`) and `office-workers` (`/office/workers`). +- **`hooks/`** is the loopback-only hook server the workers call: their agents' hook events (`/hooks/`), the board agents' queue (`/office/queue`), `office-workers` (`/office/workers`) and the floor's lessons (`/office/lessons`). - **`workers/`** is the worker manager (`WorkerManager` in `workers/manager.ts`) and its pieces: worktrees, pull requests, tasks, terminals, ACP workers, and saving to `workers.json`. `src/server/workers.ts` re-exports it for the modules that imported it from there. - **`providers/`** holds one adapter per agent CLI (see [Adding an agent provider](#adding-an-agent-provider)). - **`tunnel/`** is `agent-office tunnel`, the one part that runs on someone's own computer instead of the office's: it asks the office for the workers' web servers (`/api/services`, in `http/routes/services.ts`) and listens on each one's port there. It imports nothing from the office but what the two say to each other (`tunnel/wire.ts`) and the cookie's name. diff --git a/docs/features.md b/docs/features.md index f320d9b99..57f8ef120 100644 --- a/docs/features.md +++ b/docs/features.md @@ -51,7 +51,8 @@ Everything in the office, room by room. Back to the [README](../README.md). - **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. -- **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. +- **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 seven 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`, `link_pr`, and `list_lessons` and `record_lesson` for the floor's lessons (below). 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. +- **Lessons: workers that learn from being corrected.** Each floor keeps lessons, standing rules about how work is done in its project ("run `npm test` before opening a PR", "never edit the generated client in `src/api`"). When you correct a worker in a way that should hold for every task there, not just the one at hand, it records the rule itself with `record_lesson` (Claude Code, Codex and OpenCode, through the `agent-office` MCP server) or `office-workers lesson add "…"` (any agent), and the floor sees a toast saying what it learned. A rule that says what one already does isn't added twice. Every agent hired on the floor afterwards, from a desk, the queue, a board or another worker, gets the lessons ahead of its first task. A worker hired without a task hasn't been told them yet; it can read them with `list_lessons` or `office-workers lesson`. **☰ → 🎓 Lessons** lists them with who taught each (🧑 a person, 🤖 a worker): write one in, reword any (✏️) or take it off (🗑️), and workers hired from then on go by the new list. Workers only add lessons; rewording and taking them off is for people. A floor keeps up to 40, each a sentence or two. How they're put to a worker is the **🎓 What workers start with** prompt in ⚙️ Settings → Prompts, which you can reword, or empty to stop sending them. - **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. - **Task queue.** A whiteboard on the north wall, between the issues and PR boards, and **📋 Task queue** in the **☰** menu. Click **Add to queue** on any issue, or type a free-text task, and walk away. Whenever a desk is free and fewer than *workers at once* of its tasks are running (3 by default; 0 pauses the queue), the next task gets a fresh worker in its own git worktree. Workers you hire yourself don't count against it; the office's worker limit, if there is one, counts everyone. The issue moves to *In progress* on the issues board as its worker sits down (and is assigned on GitHub), and the pull request is linked on the whiteboard as soon as it shows up. Finished workers stay at their desks so you can read their terminals, until the queue needs the desk for the next task. The queue survives restarts. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 471783328..d2c589053 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -31,6 +31,7 @@ browser ──HTTPS/WSS──▶ agent-office (Node) - **Whiteboard.** It syncs the way Excalidraw's own live collaboration does. Every change bumps an element's version, each browser sends the elements it changed over the office's socket, and everyone merges what arrives with Excalidraw's `reconcileElements`, keeping the newer copy of each element (equal versions go to the lower random nonce, so every copy agrees). The office applies the same rule to its own copy and saves it in the floor's `.agent-office/whiteboard/elements.json`. Deleted elements are kept for a week so a deletion reaches everyone. Pictures go up once over HTTP (`/api/whiteboard/file`, up to 6 MB each) and are fetched by id, a hash of the picture. Excalidraw, a few MB of JavaScript, only loads when someone opens the whiteboard or a floor has a drawing to show on the board, and its fonts are served by the office rather than a CDN. - **Board agents.** Each kiosk is a seat of its own (`station-issues`, `station-pulls`, `station-queue`) that the desks, the bean bags and the queue never use, and its agent doesn't count against the queue's *workers at once*. Its first prompt starts with a brief (`src/server/stations.ts`) saying what it's for. The agents reach the queue with `office-queue` (`bin/office-queue.js`, plain Node), which the office writes into the floor's `.agent-office/bin/` and puts first on their `PATH` whichever agent runs them: `office-queue list` shows each task's id, status, title, worker and PR, `office-queue add --title "…" [--issue 12]` queues a task with its prompt on stdin (or `--prompt`) and prints its id, and `office-queue remove ` takes a waiting one off. Under the hood that's `GET`, `POST` (`{title, prompt, issue?}`) or `DELETE ?task=` on `$AGENT_OFFICE_HOOK_URL/office/queue?worker=$AGENT_OFFICE_WORKER_ID` with `Authorization: Bearer $AGENT_OFFICE_HOOK_TOKEN`, all three set in their environment, over the office's loopback hook port. Only a board agent's own token is accepted; anyone else gets a 401 or 403, which the command prints. The queue agent is told to only ever add tasks, and with Claude Code it's also launched (and resumed) with `--disallowedTools Edit Write NotebookEdit`, so it can't edit the checkout's files. - **Workers managing workers.** `/office/workers` on the same loopback hook port (`src/server/office-workers.ts`, the handler beside `/office/queue` in `src/server/hooks/`) lets any running worker, not only a board agent, act on its own floor with its own `?worker=` and bearer token. `GET` lists every worker as a row: desk, status, task, worktree branch, its PR (`workerPr`), `merged` (a PR of its merged and none is open, `landedWork` in `src/server/leave-on-merge.ts`) and `staying` (why *go home once merged* wouldn't take it yet, `notLeaving`). `POST` hires one at the next free seat, running as whoever the asking worker runs as, in a worktree cut after a fresh fetch. `POST /home` takes `{workers: [names or ids]}` or `{merged: true}` (the leave-on-merge rule, with anyone skipped listed and why) and `cleanup` (`auto`, `keep`, `worktree`, `all`). It sends them home one at a time through `Floor.sendHome`, which passes the merged PRs' heads to `kill` so a squash-merged branch whose remote was deleted still counts as delivered. `POST /tell` types a prompt, or wakes a stopped worker with it. A worker can't send itself home or tell itself. `POST /pr` takes `{pr, worker?}` (a number or a URL; the asking worker when it names none) or `{unlink: true, worker?}` and sets that worker's `pr`, the same field **O** at its desk sets. The pull request has to be open, or merged and still on the floor's list (the last 30 merged), in the floor's own repository. That's for the pull requests the office can't place by itself: `workerPr` goes by `pr`, the worker's queue task, or its worktree branch, and a worker in the main checkout has no branch the office knows. A Claude Code worker's `pr` is also set from its hooks: a `PostToolUse` (or `PostToolUseFailure`) for a shell command that runs `gh pr create` and prints a pull request URL in the floor's repository (`ownPr` and `WorkerPrs.noteOwn` in `src/server/workers/pr.ts`). When it opens another, the earlier ones stay its own (`pastPrs`), so one still open keeps it from counting as landed. The other agents' hooks name the tool but not what it ran, so theirs need `POST /pr`. `bin/office-workers.js` (plain Node) is both the `office-workers` command, written into `.agent-office/bin/` and put first on every worker's `PATH` (board agents get `office-queue` from there too), and, as `office-workers mcp`, a stdio MCP server named `agent-office`. Claude Code gets it with `--mcp-config .agent-office/agent-office-mcp.json` (placed before `--settings`, which ends that flag's list), and the hook settings allow `mcp__agent-office__list_workers` without asking. Codex gets it with `-c mcp_servers.agent-office.*`, plus `env_vars` so it hands the server the `AGENT_OFFICE_*` variables, which it otherwise wouldn't. OpenCode gets it in `OPENCODE_CONFIG_CONTENT`'s `mcp`. Grok, Muse and DeepSeek Harness workers have the command only. +- **Lessons.** `/office/lessons` on the same port (`src/server/hooks/office-lessons.ts`) takes the same `?worker=` and bearer token: `GET` lists the floor's lessons, `POST {text}` records one as that worker's (`office-workers lesson add`, the `record_lesson` tool). There's no `DELETE`: agents only add. Each floor keeps them in `.agent-office/lessons.json` (`src/server/lessons.ts`, written to a temporary file and renamed over it), at most 40 of 400 characters, a lesson that differs from another only in case, spacing or punctuation counting as the same one. `WorkerManager.spawn` hands an agent's first prompt to `brief`, which the floor points at `Lessons.brief`: with lessons, the `worker.lessons` prompt (its `{{lessons}}` a line per lesson) goes ahead of the task, and ahead of a board agent's own brief. A meeting's workers, a resumed session and a worker hired without a task get nothing added; the worker's card and its task's name are still the task alone. People add, reword and remove them over the WebSocket (`lessons.add`, `lessons.edit`, `lessons.remove`), and the floor hears `lessons` after each change. - **Meetings.** A meeting (`src/server/meetings.ts`, patterns in `src/shared/meetings.ts`) seats its workers at the meeting table's chairs (`meeting-1` to `meeting-5`), which only a meeting uses, in one git worktree of its own on an `office/meeting-…` branch that everyone at the table shares. Each round is one or two steps; in each step, every worker with a part gets it as a prompt (the first one comes with a brief: the pattern, the roles, the question, the bounds), and the step is over when each of them has ended its turn (the same `Stop` hook and idle events as the status) with the file its part names written since it was handed over. Notes go in a `.meeting/` folder at the top of the worktree, where the others read them (not under `.agent-office/`, where Claude Code asks before writing in a worktree nested in the project), and are copied into the floor's own `.agent-office/meetings//` when the meeting ends. A worker that ends its turn without writing its file is reminded once; the second time, the meeting stops. Nothing stops a meeting for what it uses: the tokens and cost on the door's sign and in the meeting window are the workers' own figures added up (everything their sessions report, cache reads included, so they climb by a session's whole context with every call). When the output file is written, the office runs `git add -A` (all but `.meeting/`) and `git commit` in the worktree, or for a review panel posts the file as a review with `gh api …/pulls//reviews` (`event=COMMENT`). Workers waiting on the meeting don't jump or ding when they finish a part, and they don't count against the queue's *workers at once*. `meetings.json` keeps the meeting and the ones before it, so a meeting carries on through a restart of the office, like its workers do. Clearing the room sends the workers home and removes the worktree, keeping the branch when it has the committed output, and everything when something is left uncommitted. - **Task queue.** `queue.json` holds the tasks and their providers in order. A task is seated when a desk is free and fewer than the limit are busy (a worker that is starting, ready, working or waiting for input). It finishes when its worker ends its turn (Claude/Codex `Stop` hooks, OpenCode's idle event, or DeepSeek Harness's settled `session/prompt`), stops, or is sent home. The PR is matched by GitHub's closing-issue references (`closes #12`) or by the worker's branch. - **Bookshelf.** `src/server/docs.ts` lists a floor's Markdown (`.md` and `.markdown`) with `git ls-files --cached --others --exclude-standard`. That covers what's committed plus new files that aren't ignored, so `node_modules`, build output and `.agent-office/` stay off the shelf. A folder that isn't a git checkout is walked instead, skipping hidden, dependency and build folders. Each doc's title is its front matter `title:` or its first heading, read from its first 4 KB, and the list is cached for a few seconds (`/api/docs`). Only Markdown can be read (`/api/docs/file`, up to 2 MB), and only pictures can be fetched for a doc (`/api/docs/picture`), both inside the checkout after following links. The browser renders the doc with the same sanitizer as issue and PR text. The book in your hands is the `reading` flag the office keeps on you while the window is open. @@ -40,7 +41,7 @@ browser ──HTTPS/WSS──▶ agent-office (Node) - **Jukebox.** Each floor's `jukebox.json` says only what's on and when it started, so it keeps playing through a restart. Every browser plays it for itself: a tune is decided note by note by its bar and step, so starting from the same moment is enough for everyone to hear the same thing. To agree on that moment, each page pings the office a few times on arriving and keeps the quickest answer to line its clock up with the office's. A tune is drums, bass, an FM electric piano and a melody picked from the chord tones, with record crackle and tape wobble, in 32-bar rounds. It's scheduled a second ahead on a timer rather than each frame, which lets it carry on in a background tab. It goes through a panner at the jukebox and a filter that muffles it with distance. Most streams can't be routed through Web Audio without CORS, so a stream plays in a plain `