diff --git a/README.md b/README.md index fac9441a5..80191890c 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,14 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i - **A floor per project.** Ride the elevator, pick one of your GitHub repos, and the office clones it (showing how far along it is) and opens a floor for it. Every worker, board and queue on that floor works in that checkout. - **Workers at desks.** Walk up to an empty desk, press **E**, and pick Claude Code, Codex, OpenCode, Grok, Muse, DeepSeek Harness, Pi or Cursor, each with its model and reasoning effort. The agent's live terminal shows on its laptop, and anyone can open it and type. +- **A software company's departments.** Ride the elevator to 🏢 **Departments**, a hall of its own with ten glass rooms of four desks each: ⚙️ Backend, 🖥️ Frontend, 🧪 QA, 🛡️ Security (privacy too), 🚀 DevOps, 🧭 Product Owner, 🎨 Designer, ✍️ Persian Content, 📚 Technical Writer and 🏛️ Tech Lead. A worker hired in one wears its color and gear, starts in its role and automatically gets its department's default model (unless you specify one), and any worker can ask a department something: whoever is there answers, or the office hires someone there to. The office's own desks are open desks again, anyone's. +- **A manager to talk to.** 💼 The Manager stands at its desk in a glass booth at the foot of the stairs up to the boss office. Tell it what you want ("add password reset by email") and it runs the departments for you: it gets the story from the Product Owner, a design from the Designer and an approach from the Tech Lead, hires Backend and Frontend to build it, QA to test it, Security to review it and the Technical Writer to document it, hears from each worker as it finishes or gets stuck, and tells you how it's going. You only talk to the Manager; a worker waiting on a permission prompt still waits for you, and DevOps never deploys or touches a live server without your go-ahead. - **Talk instead of typing.** Hold **Ctrl+Space** (or the **🎤**) in a worker's terminal or a prompt box and say what you want: it's typed in for you to send. Your browser does the listening, so there's nothing to install. - **You can't miss who needs you.** A worker that stops to ask you something lights a red beacon over its desk, puts a banner on your screen saying who and what for, and sounds an alarm. One that has finished jumps up and down and dings. Press **N** to go straight to whoever is waiting. -- **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. +- **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, and **☰ → 🖥️ Graphics** shows whether the browser is using your graphics card and how to switch it over (Windows laptops give browsers the built-in graphics by default). - **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`). +- **Agents that manage agents.** Every worker can list, hire, message, question 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`). A worker that needs something only another worker knows asks it (`ask_worker`): the question is typed to the other worker once it's free, and the answer comes back to the one that asked. +- **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..acca009c6 100644 --- a/bin/office-workers.js +++ b/bin/office-workers.js @@ -1,7 +1,9 @@ #!/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, asking one a question and answering one asked of you, 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 @@ -17,7 +19,12 @@ const USAGE = `Usage: office-workers hire [options] <<'EOF' hire a worker at a free desk; its task on stdin …the task… (or --prompt "…"). Options: --provider EOF --model --effort --desk --issue - --no-worktree + --no-worktree --department (backend, + frontend, qa, security, devops, po, design, + content, docs, techlead: a free desk of that + department in the Departments hall; auto-assigns + its role and default model unless --model is + specified) office-workers home ... [--cleanup auto|keep|worktree|all] send workers home. auto (the default) deletes each one's worktree and branch unless they hold work @@ -31,6 +38,22 @@ 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 ask [--wait ] <<'EOF' + …the question… ask another worker something you can't find out + EOF yourself (or --question "…"); waits up to 45s + for the answer, else it's typed into your terminal + office-workers ask --department … ask a department instead (backend, frontend, qa, + security, devops, po, design, content, docs, + techlead): whoever is there, or someone hired + there for it + office-workers answer <<'EOF' answer a question asked of you (or --answer "…") + …the answer… + EOF + office-workers questions [--json] the questions asked of you, and the ones you asked 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 +65,22 @@ 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, ask: 60_000, answer: 15_000, questions: 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', + ask: '/office/workers/ask', + answer: '/office/workers/ask/answer', + questions: '/office/workers/ask', +}; +/** The calls that only read. */ +const GETS = ['list', 'lessons', 'questions']; /** * Reads `--flag value` and `--flag=value` options, and the words that aren't options. @@ -116,8 +152,45 @@ 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 }; } + if (cmd === 'ask') { + const { opts, words } = options(rest, ['--question', '--wait', '--department'], ['--json']); + const department = opts['--department'] !== undefined ? String(opts['--department']).trim() : undefined; + if (department ? words.length : words.length !== 1) throw new UsageError('ask takes one worker, its name or id, or --department , with the question on stdin or --question "…"'); + const out = { cmd: 'ask', ...(department ? { department } : { worker: words[0] }), json: opts['--json'] === true }; + if (opts['--question'] !== undefined) out.question = opts['--question']; + if (opts['--wait'] !== undefined) { + const wait = Number(opts['--wait']); + if (!Number.isFinite(wait) || wait < 0) throw new UsageError(`--wait takes seconds, e.g. --wait 30 (got ${opts['--wait']})`); + out.wait = wait; + } + return out; + } + if (cmd === 'answer') { + const { opts, words } = options(rest, ['--answer'], []); + if (words.length !== 1) throw new UsageError('answer takes the question\'s id (office-workers questions lists them), with the answer on stdin or --answer "…"'); + return { cmd: 'answer', id: words[0], ...(opts['--answer'] !== undefined ? { answer: opts['--answer'] } : {}) }; + } + if (cmd === 'questions') { + const { opts, words } = options(rest, [], ['--json']); + if (words.length) throw new UsageError(`questions takes no arguments (got ${words.join(' ')})`); + return { cmd: 'questions', 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']); + const { opts, words } = options(rest, ['--prompt', '--provider', '--model', '--effort', '--desk', '--issue', '--department'], ['--no-worktree', '--json']); if (words.length) throw new UsageError(`Unexpected argument: ${words[0]} (give the task on stdin or with --prompt)`); /** @type {Record} */ const out = { cmd: 'hire', json: opts['--json'] === true }; @@ -125,6 +198,7 @@ export function parseArgs(argv) { if (opts['--provider'] !== undefined) out.provider = String(opts['--provider']).trim(); if (opts['--model'] !== undefined) out.model = String(opts['--model']).trim(); if (opts['--desk'] !== undefined) out.desk = String(opts['--desk']).trim(); + if (opts['--department'] !== undefined) out.department = String(opts['--department']).trim(); if (opts['--no-worktree']) out.worktree = false; if (opts['--effort'] !== undefined) { if (!EFFORTS.includes(String(opts['--effort']))) throw new UsageError(`--effort is one of ${EFFORTS.join(', ')}`); @@ -157,16 +231,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' | 'ask' | 'answer' | 'questions'} 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 +280,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' | 'ask' | 'answer' | 'questions'} what * @param {Record | undefined} body * @param {{ env: Record, fetch: typeof fetch }} io */ @@ -218,7 +292,7 @@ export async function call(what, body, io) { /** One worker, in a line. */ export function formatWorker(w) { - const tags = [w.you ? 'you' : '', w.board ?? '', w.meeting ? 'meeting' : '', w.kind === 'shell' ? 'shell' : w.provider ?? ''].filter(Boolean); + const tags = [w.you ? 'you' : '', w.board ?? '', w.department ?? '', w.meeting ? 'meeting' : '', w.kind === 'shell' ? 'shell' : w.provider ?? ''].filter(Boolean); const parts = [`${w.name}${tags.length ? ` (${tags.join(', ')})` : ''}`, w.status, w.desk]; if (w.worktree) parts.push(`branch ${w.worktree.branch}${w.worktree.deleted ? ', worktree deleted: rebuild it at its desk' : ''}`); if (w.repos?.length) parts.push(`also in ${w.repos.map((r) => r.name).join(', ')}`); @@ -252,6 +326,35 @@ 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.`; +} + +/** Where a question stands: its answer, or that the answer will come by terminal. */ +export function formatAsked(answer) { + const q = answer?.question ?? {}; + if (q.status === 'answered') return `${q.to} answered (question ${q.id}):\n\n${q.answer}`; + if (q.status === 'gone') return `${q.to} went home without answering question ${q.id}.`; + return `Asked ${q.to} (question ${q.id}); no answer yet. When it answers, the answer is typed into your terminal as your next message: carry on with what doesn't need it meanwhile.`; +} + +/** The questions asked of a worker and by it. */ +export function formatQuestions(view) { + const line = (q, who) => `${q.id} ${who} · ${q.status}: “${q.question.replace(/\s+/g, ' ').slice(0, 100)}”${q.answer ? `\n ↳ ${q.answer.replace(/\s+/g, ' ').slice(0, 160)}` : ''}`; + const of = (view?.askedOfYou ?? []).map((q) => line(q, `from ${q.from}`)); + const by = (view?.askedByYou ?? []).map((q) => line(q, `to ${q.to}`)); + return [`Asked of you${of.length ? '' : ': none'}`, ...of, `Asked by you${by.length ? '' : ': none'}`, ...by].join('\n'); +} + /** Whose pull request is whose now, after `pr` said so. */ export function formatLinked(answer) { const w = answer?.worker ?? {}; @@ -289,10 +392,15 @@ export const TOOLS = [ properties: { prompt: { type: 'string', description: 'The task, complete on its own.' }, provider: { type: 'string', description: "Which agent runs it (claude, codex, opencode...; list_workers' providers). Default: the office's." }, - model: { type: 'string', description: "A model for it, instead of the provider's default." }, + model: { type: 'string', description: "A model for it, instead of the provider's or department's default." }, effort: { type: 'string', enum: EFFORTS, description: 'Reasoning effort, for agents that take one.' }, worktree: { type: 'boolean', description: 'Its own git worktree and branch (default true in a git checkout).' }, desk: { type: 'string', description: 'A desk or bean bag id (desk-3). Default: the next free one.' }, + department: { + type: 'string', + enum: ['backend', 'frontend', 'qa', 'security', 'devops', 'po', 'design', 'content', 'docs', 'techlead'], + description: "Hire it in a department instead of naming a desk: at a free desk of that department's; auto-assigns the department's role and default model unless model is specified.", + }, issue: { type: 'integer', minimum: 1, description: 'The GitHub issue it works on, assigned when it starts.' }, }, required: ['prompt'], @@ -358,6 +466,76 @@ 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 }, + }, + { + name: 'ask_worker', + title: 'Ask a worker', + description: + "Asks another worker in this office a question, or a department (backend, frontend, qa, security, devops, po, design, content, docs, techlead), when you need to know something you can't find out yourself: what another worker changed or decided, " + + "how something in its part works, information only it has access to. The office types the question into that worker's terminal once it's free (it carries on with its own task after answering). " + + "Waits up to 45 seconds for the answer; when it comes later, it's typed into your terminal as your next message, so carry on with what doesn't need it. " + + WORKER_NOTE, + inputSchema: { + type: 'object', + properties: { + worker: { type: 'string', description: 'Who to ask, by name or id. Leave it out to ask a department.' }, + department: { + type: 'string', + enum: ['backend', 'frontend', 'qa', 'security', 'devops', 'po', 'design', 'content', 'docs', 'techlead'], + description: "A department to ask instead of a worker: whoever is in it answers, and when nobody is, the office hires someone there for the question (list_workers shows each worker's department).", + }, + question: { type: 'string', description: 'The question, complete on its own: the other worker knows nothing of your task.' }, + }, + required: ['question'], + additionalProperties: false, + }, + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, + }, + { + name: 'answer_question', + title: 'Answer a question', + description: 'Answers a question another worker asked you (its id is in the message that asked it, e.g. q3f9a1c). The answer goes straight back to the worker that asked.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: "The question's id." }, + answer: { type: 'string', description: "Your answer: short and concrete. If you can't answer it, say so and who might." }, + }, + required: ['id', 'answer'], + additionalProperties: false, + }, + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, + }, + { + name: 'list_questions', + title: 'List questions', + description: 'Lists the questions other workers asked you (answered or not) and the ones you asked, with their answers.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, ]; const INSTRUCTIONS = @@ -366,6 +544,10 @@ 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. ' + + "When you need something you can't find out yourself (what another worker changed or knows, information only it has), ask_worker asks it and brings the answer back; " + + 'when another worker asks you something, answer it with answer_question. ' + '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 +569,14 @@ 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)) }; + if (name === 'ask_worker') return { text: formatAsked(await call('ask', { ...(a.worker ? { worker: a.worker } : { department: a.department }), question: a.question }, io)) }; + if (name === 'answer_question') { + const answer = await call('answer', { id: a.id, answer: a.answer }, io); + return { text: `Answered ${answer.question?.from ?? ''} (question ${answer.question?.id ?? a.id}).` }; + } + if (name === 'list_questions') return { text: formatQuestions(await call('questions', undefined, io)) }; throw new Error(`Unknown tool: ${name}`); } @@ -512,6 +702,38 @@ 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 === 'ask') { + const question = (cmd.question !== undefined ? String(cmd.question) : stdin.isTTY ? '' : await readStdin(stdin)).trim(); + if (!question) throw new UsageError(`Give the question on stdin (office-workers ask ${cmd.worker ?? `--department ${cmd.department}`} <<'EOF' … EOF) or with --question "…"`); + const who = cmd.department ? { department: cmd.department } : { worker: cmd.worker }; + const answer = await call('ask', { ...who, question, ...(cmd.wait !== undefined ? { wait: cmd.wait } : {}) }, ctx); + out(cmd.json ? JSON.stringify(answer.question, null, 2) : formatAsked(answer)); + return 0; + } + if (cmd.cmd === 'answer') { + const text = (cmd.answer !== undefined ? String(cmd.answer) : stdin.isTTY ? '' : await readStdin(stdin)).trim(); + if (!text) throw new UsageError(`Give the answer on stdin (office-workers answer ${cmd.id} <<'EOF' … EOF) or with --answer "…"`); + const answer = await call('answer', { id: cmd.id, answer: text }, ctx); + err(`Answered ${answer.question?.from ?? ''} (question ${answer.question?.id ?? cmd.id}).`); + return 0; + } + if (cmd.cmd === 'questions') { + const view = await call('questions', undefined, ctx); + out(cmd.json ? JSON.stringify(view, null, 2) : formatQuestions(view)); + 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..7f1acb614 100644 --- a/docs/features.md +++ b/docs/features.md @@ -28,7 +28,7 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Isolated branches.** When you hire with a task, you can tick *own git worktree*. The worker then gets its own `office/` branch under `.agent-office/worktrees/`, so parallel workers never share a checkout. The branch starts from the latest on GitHub, pull requests merged since anyone last pulled included, so a task never undoes the one before it. When you send that worker home you choose whether to keep the worktree and branch, delete the worktree, or delete both, and the office warns you first about uncommitted changes or commits no remote has. `agent-office prune` clears out whatever was kept, once it is safe. If a worker's worktree is deleted outside the office (`rm -rf`, `git worktree remove`, a cleanup script), the worker doesn't keep failing to start: it waits at its desk under a 🌿 *worktree deleted* bubble. **E** there (or clicking it in the **Workers** list) says what's left and offers **Rebuild worktree**, which checks its branch out again in the same place (origin's copy when only the pushed one is left, or a fresh branch from where it started when the branch is gone too) and lets it carry on its conversation, **Rebuild all** for every worker on the floor that lost one, or **Send home…**. Under **⚙️** → *Workers whose pull request merged*, anyone can have them go home by themselves once their pull request merges: as soon as one isn't working or waiting on you and nobody has its terminal open, it leaves and its worktree and branch are deleted. Uncommitted changes, or commits that aren't on GitHub, keep the worktree, as when you send it home. It's off until someone turns it on, and turning it on sends home the ones already merged. - **One task across several projects.** Many systems are split over repositories (an api, an admin, a web app), and one change often needs two or three of them. When you hire a worker in its own worktree, **🗂️ Also work in** lists the building's other floors: pick the projects the task spans (in the hire dialog, or in **🤖 Hand to a worker** / **✍️ Ask a worker** from the boards). The worker starts in a workspace folder with a worktree of each project in it, all on the same `office/` branch, and a short brief (as `CLAUDE.md` and `AGENTS.md`) saying which folder is which. It works across them, tests them together and commits in each. **O** then opens a pull request in every project it committed to, and each description lists all of them, so they're reviewed and merged as one change. The issue the task came from is closed by the home floor's PR and linked from the others. Press **O** again for each project's PR, with a button to open the ones still missing. The Changes window has a tab per project, sending the worker home checks and deletes every worktree, and *Workers whose pull request merged* waits until the PRs in every project have merged. The brief can be rewritten in ⚙️ Settings like the office's other prompts. - **One-click PRs.** When a worktree worker is done, press **O** at its desk. The office pushes the branch and opens a pull request with a title and body drafted from the task (work handed over from the issues board gets a `Closes #n`). The PR shows up on the PR board right away, tagged with the worker's desk, and **Go to desk** takes you there. Press **O** again to see the PR. -- **Shared terminals.** Press **E** at an occupied desk to open the real terminal (a PTY, over WebSockets). Several people can type into the same session at once, and anyone who joins late gets the full scrollback. The terminal's header shows a face for everyone who has it open, and a line like *Sam is typing…* while someone else types, so you don't talk over each other. The Esc key leaves the terminal, so to send the program an Esc (to close a menu like Claude's `/skills`, or to interrupt it) press **Ctrl+[** or click **⎋ Esc** in the header. Drag a screenshot (or any file) onto an open terminal, or paste a screenshot into it, and the office keeps a copy on its own machine and types its path, as a terminal does with a file dragged into it: Claude Code and Codex attach the picture as *[Image #1]*. The copies are kept in `.agent-office/drops/` until the worker goes home. +- **Shared terminals.** Press **E** at an occupied desk to open the real terminal (a PTY, over WebSockets). Several people can type into the same session at once, and anyone who joins late gets the full scrollback. The terminal's header shows a face for everyone who has it open, and a line like *Sam is typing…* while someone else types, so you don't talk over each other. The Esc key leaves the terminal, so to send the program an Esc (to close a menu like Claude's `/skills`, or to interrupt it) press **Ctrl+[** or click **⎋ Esc** in the header. Drag a screenshot (or any file) onto an open terminal, or paste a screenshot into it, and the office keeps a copy on its own machine and types its path, as a terminal does with a file dragged into it: Claude Code and Codex attach the picture as *[Image #1]*. The copies are kept in `.agent-office/drops/` until the worker goes home. Persian, Arabic and Hebrew read right to left in the terminal, with their letters joined, inside lines that otherwise run left to right (a file name or a command in the middle of a Persian sentence stays where it belongs). - **Dictation.** Talk to a worker instead of typing. In a worker's terminal, or any box you write a prompt in (hiring, **P** at a desk, a board agent, an issue's *Ask a worker*, the task queue, a meeting), hold **Ctrl+Space** or the **🎤** and say it. The words show as they're heard and are typed in at the cursor when you let go; a quick tap instead of a hold leaves it listening, hands free, until the next tap. Nothing is sent for you: read it over, fix what it misheard, and press Enter yourself. There's nothing to install, because the listening is your browser's own speech recognition: Chrome, Edge and Safari have one, and where a browser doesn't (Firefox) there's no 🎤. The office never gets the audio, but the browser's maker does: Chrome and Edge send it to their speech service, unless the browser already has its on-device model for your language, which is used instead. It listens for your browser's language. Like voice, it needs HTTPS or localhost. In voice with an open mic, you're muted while you dictate. On a Mac with more than one keyboard language, Ctrl+Space switches between them, so use the 🎤 there. In the 2D view the 🎤 is on the prompt box under the terminal. - **Web page tabs.** Click **+ Web page** on a terminal to pin a web page open beside it, like a linked ChatGPT conversation or docs for the task at hand: give it a name and an address and it opens in its own tab next to **💻 Terminal**. Switch between tabs without losing your place in either, and close a web page's tab with the **×** on it. Plenty of sites won't show inside another page (ChatGPT is one): **↗ New tab** opens the page you're on in a browser tab of its own. The terminal keeps running behind the scenes while you're looking at a web page tab. Tabs are remembered for that worker until you reload the page. - **See what teammates are up to.** A line under each person's name tag says what they have open (*💻 in Pixel's terminal*, *🔀 reading PR #12*, *📋 at the issues board*) or where they are (*🌇 on the balcony*, *👔 in the boss's office*, *🛋️ on the couch*). The same line shows under their name in the sidebar. Click someone there to walk over to them, riding the elevator first if they're on another floor. Any key of your own takes over. @@ -39,6 +39,7 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Next worker that needs you.** Press **N** to go straight to the first worker waiting on someone (it needs input, or it's done and nobody has looked), standing behind it and looking at its laptop. Press **N** again for the next one, and after the last you start over: the ones that need input come first, then the ones that are done, and whoever has waited longest is first among each. A pin at the edge of the screen points the way to each waiting worker you can't see, red for *needs input* and green for *done*, and the top bar counts them (`🙋 2 need you · ✅ 1 done`, in red and pulsing while anyone needs you; click it to do the same as **N**). With nobody waiting on your floor, **N** tells you which floor has someone. - **Notifications.** You don't have to be in the office tab. The tab title counts the workers waiting on someone, like `(2) my-project · Agent Office`. Allow desktop notifications (the office asks when you first hire a worker, or turn them on under **⚙️**) and a worker that needs you or finishes while you're in another tab or app pops one up (*🙋 Byte needs you*, with what it's asking; that one stays up until it's dealt with). Click it to go straight to that worker: the office comes to the front with you at its desk and its terminal open. For the whole team, paste a Slack or Discord incoming webhook under **⚙️** → *Team notifications* (or start with `--webhook `), and the office posts to that channel when a worker has needed input or been done for a few seconds with nobody at its terminal. - **The 2D view, for your phone.** Open `/lite` (or **📱 2D view** in the **☰** menu) for the office without the 3D. It lists every worker on the floor, the ones waiting on someone first, each with what it's asking (*🙋 Wants permission: Bash: npm test*) or doing, its branch and PR, and who typed to it last. Tap one for its terminal, with a row of the keys a phone keyboard hasn't got (**1** **2** **3** to answer a question or a permission prompt, the arrows, Enter, Tab, Esc and Ctrl+C) and a box to send it a prompt. **✍️** on a card sends one without opening the terminal. **📌 Issues**, **🔀 PRs**, **📋 Queue** and **✨ New task** open the same windows as in the office, and the floor at the top switches projects, with a button straight to any floor where someone's waiting. The tab title counts who's waiting, and once you allow notifications (**🔔**) a worker that needs you pops one up and buzzes the phone. The 3D office offers the 2D view as it loads on a phone, and on a computer where it draws fewer than 20 frames a second for ten seconds. **Stay in 3D** stops it asking in that browser. A browser that can't draw the 3D office at all (no WebGL) goes straight to the 2D view, and signing in from `/lite` brings you back there. Teammates see you under *In the office* as *📱 on the 2D view*, not standing anywhere in it. +- **Is it on the graphics card?** **☰ → 🖥️ Graphics** says what the browser draws the 3D office with (*NVIDIA GeForce RTX 3060 Laptop GPU*, *Intel(R) UHD Graphics 620*, or software), and how to get the graphics card when it isn't using it. The office asks the browser for the fast graphics, but on a Windows laptop with a graphics card beside the built-in graphics, Chrome and Edge run on whichever one Windows gives them, the built-in one unless you tell it otherwise. The window walks you through **Settings → System → Display → Graphics → High performance** for your browser (or the NVIDIA Control Panel), and through switching hardware acceleration back on when the browser is drawing in software. The menu entry shows red while the browser isn't on a graphics card, and the slow-computer offer of the 2D view offers **🖥️ Use the graphics card** first. - **Task cards.** A card over each worker's head names what it's on ("Fix Login Redirect") and says in one line what it's doing right now. For Claude workers, Claude Haiku writes it from prompts and recent tools through the `claude` CLI. OpenCode, Codex, Grok, Muse, DeepSeek Harness and Cursor use local task summaries and never invoke Claude to name their tasks. - **Survives restarts.** Workers are saved to disk with their agent choice and session ID, and their terminals run in a small process of their own. When the server restarts (a dev-server reload, a `kill` of its process), the agents keep working through it, and the new server picks every terminal back up, scrollback and all. Open terminals reconnect by themselves. Ctrl+C stops the workers; the next start wakes them back up in their sessions, and **R** resumes the Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi or Cursor session of one that exited. DeepSeek Harness workers are the exception to keeping working through a restart: their ACP session is a child of the office process, so it stops with the office and comes back *offline*, resumable with **R**. When a restart does take a worker's terminal with it (the machine rebooted, the terminal process died or was replaced by a newer one), a worker that was in the middle of something is resumed with *continue*, so it carries on by itself. The chat and each worker's last 3,000 lines of terminal are kept too, so after a restart (or a crash) you can still scroll back through what the workers printed and what everyone said. - **Search.** Press **/** (or **🔎 Search** in the **☰** menu) to search the chat and every worker's terminal at once, including what they showed before the office restarted. Click a terminal line to open that terminal scrolled to it, with the line lit up. @@ -51,7 +52,11 @@ 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 ten 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`, `ask_worker`, `answer_question` and `list_questions` (below), 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. +- **Departments.** The elevator has a stop of its own on every floor, 🏢 **Departments** (from the hall, your floor's own button takes you back to the office): a long, windowless hall on the office's level, with a lobby by the lift, a board saying which department is where, and down either side of a corridor ten glass-walled rooms of four desks each, two back to back rows of two, each room its own floor, its name over the door and the kit of its trade in the corner. North of the corridor, west to east: ⚙️ **Backend** (a database), 🖥️ **Frontend** (a screen mid-layout), 🧪 **QA** (a test-results board), 🛡️ **Security** (a server rack with a padlock; privacy and compliance are its too) and 🚀 **DevOps** (a pipeline board: CI, builds, releases and runbooks, but never a live server without your go-ahead). South: 🧭 **Product Owner** (a roadmap), 🎨 **Designer** (an easel of swatches), ✍️ **Persian Content** (a sheet of Persian copy, right to left, its length counted), 📚 **Technical Writer** (a bookcase of the project's docs) and 🏛️ **Tech Lead** (the architecture on a board). Press **E** at the lift in the lobby to ride back. The office's own desks are open desks, anyone's, as are the back office's and the bean bags. A worker hired in a department walks in out of the lift; it is its color and wears its gear (a beanie, a cap on backwards, lab goggles, shades, a hard hat, a crown, a beret, a scarf and a quill, an eyeshade and a pencil, round glasses and a tie) and its emoji on a badge; at Christmas the elf's hat goes on instead of its headwear. It starts in the department's role, ahead of the floor's lessons and its task, and hired without a task it says it's ready and waits for one, still in its role. Hiring a worker in a department auto-assigns the department's role and default model unless a model is specified. Each role is a prompt you can rewrite under **⚙️ Settings → Prompts → 🏢 Departments**, or empty to hire there like anywhere else. The hire window at a department's desk says which one it is and what it does. Departments ask one another: `ask_worker` with `department: "security"` (`office-workers ask --department security`) goes to whoever there is readiest to take it (free before busy, never one in the middle of a question of its own), and when nobody is there, the office hires someone at a free desk of the department with the question as its first task, in its role, and everyone on the floor hears who was hired for what. `list_workers` says each worker's department. Some names find their department too: `ops`, `ci` or `infra` is DevOps, `persian`, `copy` or `l10n` is Persian Content, `writer` is the Technical Writer, `architect` the Tech Lead, `privacy` Security. The hall is every floor's, whatever map the office is drawn on. +- **The Manager.** 💼 The agent you run the office through. It stands at a desk of its own in a gold-framed glass booth at the foot of the stairs up to the boss office, low enough that its name tag and card show over it, with a door that slides open toward the room (on a map of your own it stands beside the Queue agent, unless the map says where: `stations.manager`). Press **E** there and tell it what you want; **O** opens its terminal to read its answers, as at the board agents. It doesn't do the work itself (a Claude Code Manager runs without its file-editing tools, like the Queue agent): it plans it and hands it to the departments, a worker at a time, with `hire_worker` in a `department` (a free desk of that department, so the worker starts in its role), asks them things (`ask_worker`), corrects them (`tell_worker`), sends them home once their work has merged, and records lessons. The workers it hires are its own (WorkerInfo `via: "manager"`, kept through a restart): when one finishes its turn, stops at a question or a permission prompt, or stops, the office types a 📣 report into the Manager's terminal, a line a worker with its department, its task and its pull request, gathered for a moment so several come as one, and never while the Manager is in the middle of a question of its own. It acts on it and tells you what you need to know. A permission prompt still needs you: the Manager says who is waiting and for what, and the usual banner and alarm go off. Its brief and the reports' wording are **💼 Manager's brief** and **💼 Reports to the Manager** under ⚙️ Settings → Prompts → 🧑‍💼 Board agents. `office-workers hire --department qa` hires in a department from any worker's shell. +- **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. +- **Workers ask each other.** A worker that needs something it can't find out itself (what another worker changed or decided, how its part works, information only that worker has) asks it with `ask_worker` (`office-workers ask Bolt <<'EOF' … EOF` from a shell). The question is typed into the other worker's terminal as soon as it can take it: straight away while it's idle or working, after it's done with a permission prompt or question of its own, and a stopped worker is woken with it. It answers with `answer_question` (`office-workers answer `) and carries on with its own task. The asking worker waits up to 45 seconds for the answer; when it comes later, it's typed into the asking worker's terminal as its next message, so nothing sits blocked. A worker sent home with questions unanswered tells whoever asked. Everyone on the floor sees a toast for each question and answer. `list_questions` (`office-workers questions`) lists the ones asked of a worker and by it. What's typed to each worker is in ⚙️ Settings → Prompts → 💬 Questions between workers. Claude Code workers answer without a permission prompt. - **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..15fa653d5 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -30,7 +30,11 @@ browser ──HTTPS/WSS──▶ agent-office (Node) - **Pictures.** WebGL can only draw an image from another site if that site sends CORS headers, and most don't. So the office fetches each picture itself (`/api/image`, images up to 15 MB) and serves it from its own origin. Any image link works, and a picture on a worker's dev server does too. Browsers shrink each one to 1024 px before it goes on the wall. - **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. +- **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`, `list_questions` and `answer_question` without asking (`MCP_ALLOWED`). 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. +- **Departments.** `src/shared/departments.ts` lists the ten departments and says whose each hall desk is (`hall--1` to `-4`); each defines a default model (`defaultModel`) that `resolveDepartmentModel` (`src/server/departments.ts`) auto-assigns when hiring in that department (or asking it), provided the provider accepts that model. `src/shared/hall-plan.ts` says where the Departments hall is (600 m north of the office, on its level, with no windows) and where its rooms and desks are, with nothing imported at run time, so `layout.ts` takes the hall's desks into `DESK_BY_ID` (but not `SEATS`: an ordinary hire never lands there). `src/shared/hall.ts` is getting about it: its own `NavGrid`, with the rooms' glass (each door left open), the kit and the desks in the way, and the walks in from and home to its lift. The 3D office builds it (`features/departments/hall.ts`, the `departmentHall` fixture in `world/office/build.ts`), standing on its floor as on the office's, and the elevator rides there as its `@hall` stop (`core/travel.ts`, `place.ts`'s `placeInHall`); walking to a desk across the divide rides the lift. `features/departments/` dresses the workers at their desks. `WorkerManager.spawn` colors a worker by its desk's department and hands its first prompt, or none, to `brief`, which the floor points at `firstPrompt` (`src/server/departments.ts`): the `dept.` prompt, then the floor's lessons, then the task, or a line saying it has none yet. A question for a department (`{department}` instead of `{worker}` on `/office/workers/ask`) goes to the agent at its desks with the best status for it (idle or done, then working, then starting; not the one asking), and with none, `Asks.askHiring` hires one at a free desk of its, running as the asking worker runs, with the `worker.question` prompt as its first task. +- **The Manager.** A fourth board agent (`StationKind` `manager`, its kiosk `station-manager` in `STATIONS`; a map's `stations.manager`, or beside its Queue agent when it gives none). Its brief is the `station.manager` prompt (`shared/manager-prompts.ts`), and Claude Code runs it with `--disallowedTools` like the Queue agent. A worker it hires through `/office/workers` (`department` picks a free desk of that department) or for a question to an empty department gets `via: 'manager'` (`hiredVia` in `src/server/manager.ts`), which `workers.json` keeps. Each floor's `ManagerReports` follows every worker update: one of the Manager's going to `done`, `needs_input` (with what it's asking, `alertDetail`) or `exited` is a line, the latest a worker, and 1.5 s after the first, while the Manager isn't `starting` or `needs_input`, the lines go to it in the `manager.report` prompt through `WorkerManager.prompt` (or `resume`, when it's stopped). With no Manager on the floor, they're dropped. +- **Questions between workers.** `/office/workers/ask` on the same port (`src/server/hooks/office-asks.ts`, routed ahead of `/office/workers`) takes the same `?worker=` and token. `POST {worker, question, wait?}` asks one (not yourself, not a shell) and holds the request up to `wait` seconds (45 at most, under Codex's minute-long tool timeout) for the answer. `POST /answer {id, answer}` answers one asked of you; only the worker asked can. `GET` lists the questions asked of and by you. Each floor's `Asks` (`src/server/asks.ts`, in memory, the last 200) types the `worker.question` prompt to the worker asked through `WorkerManager.prompt`, or `resume` when it's stopped, but never while it's `starting` or `needs_input`, where typing would answer its own prompt: `Floor` hands it every worker update, and it delivers then. An answer goes to the waiting request; when there's none (it timed out, or the agent's call was cut off and the request closed) it's owed, and the `worker.answer` prompt is typed to the asking worker the same way. A worker removed from the floor marks the questions asked of it `gone` and tells whoever asked. - **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 +44,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 `