diff --git a/README.md b/README.md index fac9441a5..dd6a44515 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ and jump into any of them together. Every GitHub repo is a floor of the building [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey?style=flat-square)](#run-locally) [![Built with TypeScript](https://img.shields.io/badge/built%20with-TypeScript-3178c6?style=flat-square)](https://www.typescriptlang.org) -[**Run locally**](#run-locally) · [**Deploy to AWS**](#deploy-to-aws-ec2) · [**Azure**](#deploy-to-azure) · [**Railway**](#deploy-to-railway) · [**Fly.io**](#deploy-to-flyio) · [**Dokploy**](#deploy-to-dokploy) · [**Coolify**](#deploy-to-coolify) · [**Any server**](#deploy-to-any-ubuntu-or-debian-server) · [**Add users**](#add-users) · [**Controls**](#controls) · [**Features**](docs/features.md) · [**How it works**](docs/how-it-works.md) +[**Run locally**](#run-locally) · [**Deploy to AWS**](#deploy-to-aws-ec2) · [**Azure**](#deploy-to-azure) · [**Railway**](#deploy-to-railway) · [**Fly.io**](#deploy-to-flyio) · [**Dokploy**](#deploy-to-dokploy) · [**Coolify**](#deploy-to-coolify) · [**Any server**](#deploy-to-any-ubuntu-or-debian-server) · [**Add users**](#add-users) · [**Controls**](#controls) · [**Features**](docs/features.md) · [**System overview**](docs/roadmap/system-overview.md) · [**How it works**](docs/how-it-works.md) ```sh curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/install.sh | bash @@ -32,7 +32,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i ## What it is -- **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. +- **A floor per project.** Ride the elevator to add a GitHub repo (the office clones it and shows progress) or register an existing local project by its path without moving 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. - **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. @@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i - **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)). -There's a lot more (a rooftop bar, an office dog, an arcade, supercars in the garage to drive round a scenic loop past a farm, pines, mountains and a beach): see [docs/features.md](docs/features.md). +There's a lot more (a rooftop bar, an office dog, an arcade, supercars in the garage to drive round a scenic loop past a farm, pines, mountains and a beach): see [docs/features.md](docs/features.md). Open **☰ → 📚 Docs** from anywhere to read the current project's docs without walking to the bookshelf. The bookshelf renders Mermaid diagrams in Markdown. Beside the Issues, Task queue and Pull Requests, the **🗺️ System Roadmap** board opens `docs/roadmap/` in Markdown; its **🧭 System Agent** kiosk has an **Ask me** prompt for questions about those documents. ## Requirements @@ -77,7 +77,7 @@ The first time it starts, it walks you through setting up, right in the terminal Press Enter to skip a step: the elevator in the office asks for your first project too. Then the office opens in your browser, **already signed in**, with a link that works once. The terminal also prints the office password, for signing in from another browser (it's saved in `~/agent-office/.agent-office/config.json`). -Walk to an empty desk, press **E** and hire a worker. +Walk to an empty desk, press **E** and hire a worker. From the elevator, admins can add existing local projects with **📁 Add an existing local project**; enter the absolute path as seen by the office process. The folder stays in place and is not cloned. In a container, mount the project folder into the container first. Common options: diff --git a/docs/agents.md b/docs/agents.md index 02016b48b..73773f9c6 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -74,4 +74,4 @@ DeepSeek Harness metrics come from ACP `usage_update`: tokens in context and the ## The office's prompts -Everything the office tells a worker by itself can be rewritten in ⚙️ Settings → **🤖 Workers** → **📝 Edit the prompts…**: what **🤖 Hand to a worker**, **🔍 Review**, **Fix up & merge**, **Fix conflicts & merge** and **✍️ Ask a worker** send from the boards, what a meeting about an issue or a review panel starts with, the note the queue adds to a worktree task, the three board agents' briefs, every part the meeting room hands out, and the instructions for the model that writes the signs over workers' heads. Each one lists its `{{placeholders}}` (the issue number, the PR's branch, the file a meeting waits for…), which the office fills in when it sends it, and warns when one the office counts on is missing. **↺ Default** puts the office's own wording back. Admins edit them; they're the same on every floor and kept in `.agent-office/prompts.json` with the Default worker. +Everything the office tells a worker by itself can be rewritten in ⚙️ Settings → **🤖 Workers** → **📝 Edit the prompts…**: what **🤖 Hand to a worker**, **🔍 Review**, **Fix up & merge**, **Fix conflicts & merge** and **✍️ Ask a worker** send from the boards, what a meeting about an issue or a review panel starts with, the note the queue adds to a worktree task, the board agents' briefs (including the System Agent's roadmap guide), every part the meeting room hands out, and the instructions for the model that writes the signs over workers' heads. Each one lists its `{{placeholders}}` (the issue number, the PR's branch, the file a meeting waits for…), which the office fills in when it sends it, and warns when one the office counts on is missing. **↺ Default** puts the office's own wording back. Admins edit them; they're the same on every floor and kept in `.agent-office/prompts.json` with the Default worker. diff --git a/docs/features.md b/docs/features.md index f320d9b99..812521c1a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -2,9 +2,10 @@ Everything in the office, room by room. Back to the [README](../README.md). -- **A floor per project.** The first time the office starts in a terminal it walks you through picking your first project (see [Run locally](../README.md#run-locally)). Otherwise you start inside the elevator, and it asks for one: pick one of the repositories your `gh` login can see (or type `owner/name`) and the office clones it into the workspace folder, `~/agent-office//` unless you pick another (**📁 Change folder** right there, for admins). While it clones, its floor on the panel says how far along it is (*Downloading 64% · 231 MiB · 1.5 MiB/s*), and you can close the panel and carry on: everyone hears when the floor opens. Whoever added it, or an admin, can stop it with the **⏹️** beside it. A clone that hears nothing from GitHub for 3 minutes is stopped as stalled, one that would have to ask something (an ssh host key, a password) fails straight away and says what to run on the office's machine, and restarting the office doesn't stop one. To add another project, or go to one, walk into the elevator on the north wall and press **E**. Or click the project name in the top-left corner for the list of floors: pick one and you're there in a blink, standing on the same spot in the office. From outside it (the balcony, the street, the garage) it takes you in, and you step out of that floor's elevator. Each floor has its own desks, workers, issues and PR boards, task queue, services and pictures, and its own wall and floor colors, so you always know where you are. You only see and hear the people on your floor. The elevator panel shows how many workers are busy or waiting on someone on each floor, and you get a heads-up when a worker on another floor starts waiting. An admin can take a project off the building with the **🗑** beside its floor in the elevator: its workers stop, and everyone on it rides the elevator to another floor. Nothing is deleted. The checkout stays on disk with its workers, queue and pictures, and adding the same repository again moves back into it (as long as the workspace folder hasn't moved since). That goes for the project the office was started in (`agent-office `) too: it stays off after a restart, and the office keeps its own data in that folder as before. +- **A floor per project.** The first time the office starts in a terminal it walks you through picking your first project (see [Run locally](../README.md#run-locally)). Otherwise you start inside the elevator, and it asks for one: pick a repository your `gh` login can see (or type `owner/name`) to clone it into `~/agent-office//`, or an admin can choose **📁 Add an existing local project** and enter an absolute path on the office machine. A local project stays in its own location and isn't cloned or moved; in a container, its directory must be mounted into the container. While a GitHub repo clones, its floor on the panel says how far along it is (*Downloading 64% · 231 MiB · 1.5 MiB/s*), and you can close the panel and carry on: everyone hears when the floor opens. Whoever added it, or an admin, can stop it with the **⏹️** beside it. A clone that hears nothing from GitHub for 3 minutes is stopped as stalled, one that would have to ask something (an ssh host key, a password) fails straight away and says what to run on the office's machine, and restarting the office doesn't stop one. To add another project, or go to one, walk into the elevator on the north wall and press **E**. Or click the project name in the top-left corner for the list of floors: pick one and you're there in a blink, standing on the same spot in the office. From outside it (the balcony, the street, the garage) it takes you in, and you step out of that floor's elevator. Each floor has its own desks, workers, issues and PR boards, task queue, services and pictures, and its own wall and floor colors, so you always know where you are. You only see and hear the people on your floor. The elevator panel shows how many workers are busy or waiting on someone on each floor, and you get a heads-up when a worker on another floor starts waiting. An admin can take a project off the building with the **🗑** beside its floor in the elevator: its workers stop, and everyone on it rides the elevator to another floor. Nothing is deleted. The checkout stays on disk with its workers, queue and pictures. That goes for the project the office was started in (`agent-office `) too: it stays off after a restart, and the office keeps its own data in that folder as before. - **Walk around.** Use WASD, Space to jump, and drag the mouse to orbit the camera. Everyone in the office sees everyone else move in real time. -- **A quiet screen.** The office stays in view: the top bar holds just the floor you're on, **🤖 Workers** and the **☰** menu (or **Tab**). The menu opens every window (issues, PRs, the queue, services, the whiteboard, search, voice, screen sharing, settings) and lists what can show on screen: the workers, who's in the office, spend, Claude limits, the chat and the floor details. Turn each one on or off there, or hide a panel with its **✕**. Pin anything you use a lot to the top bar. The choices are kept in your browser. Chat lines fade after a few seconds; hover the chat or press **T** to read back. What needs you shows up on the top bar by itself: the workers waiting on someone (click to go to them, like **N**), the mute button while you're in voice, **Sharing** while you share your screen, and **⬆️ Update** when there's a new version. +- **System Roadmap.** A roadmap board and **System Agent** kiosk stand beside Issues, Task queue and Pull Requests. Click the board and press **E** to read the current project's Markdown files under `docs/roadmap/` in the bookshelf. Use the System Agent kiosk's **Ask me** prompt to ask about those files; it cites their paths and headings and won't guess beyond what's documented. The prompt goes directly to the System Agent, with no recipient picker. Each project maintains its own roadmap files. +- **A quiet screen.** The office stays in view: the top bar holds just the floor you're on, **🤖 Workers** and the **☰** menu (or **Tab**). The menu opens every window (issues, PRs, the queue, services, the whiteboard, search, voice, screen sharing, settings) and lists what can show on screen: the workers, who's in the office, spend, Claude limits, the chat and the floor details. **☰ → 📚 Docs** opens the current project's Markdown docs from anywhere, including the office map, without walking to the bookshelf. The bookshelf renders Mermaid diagrams; see the [system overview](roadmap/system-overview.md) for this project's system flow map. Turn each panel on or off there, or hide it with its **✕**. Pin anything you use a lot to the top bar. The choices are kept in your browser. Chat lines fade after a few seconds; hover the chat or press **T** to read back. What needs you shows up on the top bar by itself: the workers waiting on someone (click to go to them, like **N**), the mute button while you're in voice, **Sharing** while you share your screen, and **⬆️ Update** when there's a new version. - **Coffee.** Press **E** at the coffee machine in the kitchen for a mug and a minute of quicker walking and higher jumps. A little meter under the project name shows how much buzz is left. Drink a third cup before the last one wears off and you get the jitters for a few seconds. - **Boss office.** Stairs along the back wall climb to a glass-walled office on the loft in the corner. From up there you can look down over every desk and watch your workers go. - **Ladder and fire poles.** The floors are stacked like a real building, with a ceiling over each. A ladder up the west wall goes through trapdoors in the ceiling and the floor: press **E** to grab it, **W** / **S** to climb, and **E** (or **Space**) to get off. Climb past the ceiling and you come up through the floor of the floor above. To go down in a hurry, walk into the hole round the fire pole (or press **E** at it). You slide down it spinning and come out of the ceiling below. The pole runs the whole way down the building, so a slide takes you one floor down and swings you off beside its hole there, ready to go again. On the bottom floor you land on a mat and a firehouse bell rings, and pressing **E** at the pole there gives you a twirl. @@ -58,7 +59,7 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Services board.** When a worker starts a web server (`npm run dev`, a preview build, `python -m http.server`), it appears within a few seconds on the **🌐 Services** board, which hangs on the wall by the lounge and is also in the **☰** menu. Each entry shows the worker, its branch and the page's title. Click a row to copy one command that opens that server on your own computer. Or run `agent-office tunnel` on your computer once and leave it running: every server a worker starts opens there on the same port, by itself, and closes when the worker stops it ([Workers' servers on your own computer](tunnel.md)). Hire a worker with its own worktree, ask it to run the dev server, and your designer can review the branch in their own browser. - **Pictures on the walls.** Press **F** (or **🖼️ Hang a picture** in the **☰** menu) and paste a link to any image online: a team photo, a diagram, a meme. Pick one of six frames, then aim at a wall and click to hang it. Scroll to size it first. Where it can't go, over a window or a board, the preview turns red. Everyone sees it right away, and it stays up across restarts. Look at a picture and press **E** for a closer look, or to move, edit or take it down. - **A whiteboard to draw on together.** A whiteboard on wheels stands out on the floor between the desks and the lounge. Press **E** at it (or **📝 Whiteboard** in the **☰** menu) and [Excalidraw](https://excalidraw.com) opens, with shapes, arrows, freehand, text and pasted pictures. Everyone on the floor who opens it draws on the same board at once: you see their strokes as they draw, their cursors with their names, and what they have selected. The top of the window shows who's drawing, and so does the hint at the board. Whatever is drawn shows on the whiteboard in the office for everyone walking past, and stays there across restarts. Each floor has its own. Press Esc once to finish what you're doing and again to close it. -- **A bookshelf of the project's docs.** A bookshelf stands against the south wall, between the balcony doors and the window. Press **E** at it to read every Markdown file in the project: the README, the docs folder, each package's notes. Type in the filter box to narrow the list down. The letters only need to be in order, so `gs` finds `getting-started.md`, and each extra word narrows it again. Use **↑ ↓** and **Enter** to open one, and **Page Up/Down** to read on without leaving the box. Docs look the way GitHub shows them, with tables, code, callouts, task lists and the project's own pictures. A link to another doc opens it right there, **☰ Contents** jumps to any heading, and the shelf reopens on the doc you last read. While you read, your character holds an open book, turning a page now and then. Everyone else sees it, along with what you're reading under your name tag. A page turns with a soft swish when you open a doc or scroll a screenful. Turn the swish off with the 🔈 at the top of the bookshelf, or under **⚙️ → Sound & voice**. +- **A bookshelf of the project's docs.** A bookshelf stands against the south wall, between the balcony doors and the window. Press **E** at it to read every Markdown file in the project: the README, the docs folder, each package's notes. Type in the filter box to narrow the list down. The letters only need to be in order, so `gs` finds `getting-started.md`, and each extra word narrows it again. Use **↑ ↓** and **Enter** to open one, and **Page Up/Down** to read on without leaving the box. Docs look the way GitHub shows them, with tables, code, callouts, task lists, the project's own pictures, and rendered Mermaid diagrams. Mermaid stays optional and is loaded only for a document that contains a Mermaid code block; invalid diagrams keep their source visible. A link to another doc opens it right there, **☰ Contents** jumps to any heading, and the shelf reopens on the doc you last read. While you read, your character holds an open book, turning a page now and then. Everyone else sees it, along with what you're reading under your name tag. A page turns with a soft swish when you open a doc or scroll a screenful. Turn the swish off with the 🔈 at the top of the bookshelf, or under **⚙️ → Sound & voice**. - **An office dog.** Every floor has a dog. It naps under the desks of workers who are busy, trots after people for a while, sniffs around and hangs out on the lounge rug. When a worker needs input, it drops everything, runs to that desk and barks until someone opens that worker's terminal, so you can see from across the room who's waiting. It stays there until the worker is answered. Walk up and press **E** to pet it, and everyone sees it wag. Name it under **⚙️** → *Office dog*, and pick its breed (a pup, a corgi, a dachshund, a pug, a shiba or a pomeranian) and its coat there too: they're for everyone on that floor. Until someone picks them, each floor's dog is a breed and coat of its own. - **Sent home with a box.** A worker you send home (**X** at its desk) doesn't just vanish. Its light goes out, its face falls, and its things go into a cardboard box: a plant, a framed photo, its mug and a rubber duck. Its laptop snaps shut, and it hops off its chair and trudges out with the box in its arms, out the exit door, down the steps and off along the sidewalk. Floors above the bottom one have no exit door, so there it walks out onto the balcony instead, climbs up on the railing and jumps: its parachute pops open and it circles down onto the lot out front, box and all, then walks off along the sidewalk. The desk is free straight away, so you can hire someone new while the last one is still on the way out. In the castle it goes to the dungeon instead and on the space station out of the airlock, and a map of your own can script its own way of seeing workers off ([Sending workers home](maps.md#sending-workers-home)). - **Voice.** Browser-to-browser WebRTC voice. Volume depends on how close you stand, but people are never fully silent. **V** joins, and once you're in, **V** is push to talk: hold it to talk, and you're muted when you let go. **M** mutes and unmutes. Pick **Push to talk** under **⚙️** to join muted, or keep an open mic. Leave voice from the **☰** menu. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 471783328..716d8baa0 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -29,7 +29,7 @@ browser ──HTTPS/WSS──▶ agent-office (Node) - **Services.** Every 4 seconds the office lists the TCP ports its user's processes listen on (`ss`, or `lsof` on macOS). It credits each port to the worker whose terminal started it. It goes by the process tree first. For a server that detached from it, it uses the `AGENT_OFFICE_WORKER_ID` the process inherited (Linux), then whether it runs inside that worker's worktree. Ports that answer HTTP are shown. A request for `localhost:` that reaches the office's own port (that's what a service tunnel does) is relayed to that server, WebSockets included, so hot reload works. On a Tailscale office (`AGENT_OFFICE_TAILSCALE_HOST`, set by `deploy/provision.sh --tailscale`), a request for `.ts.net:` is relayed the same way, with `localhost:` as its Host so dev servers that check it let it in. Whenever the list changes, the office has Tailscale Serve point exactly those ports at itself, through the `agent-office-serve` helper (`src/server/tailnet.ts`). `agent-office tunnel` ([Workers' servers on your own computer](tunnel.md), `src/server/tunnel/`) runs on someone's own computer: it asks the office for the list every 2 seconds (`/api/services`), listens on each port there, and sends what arrives to the office with its own session and an `x-agent-office-service: ` header, which the office relays like a request for `localhost:`. That header is how it works whatever address the office has. In a container (Railway, Fly.io, Dokploy) the image brings `ss` along, which the scan needs. - **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. +- **Board agents.** Each kiosk is a seat of its own (`station-issues`, `station-pulls`, `station-queue`, `station-system`) 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 Issues, PR and Queue agents reach the task 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. The System Agent reads `docs/roadmap/` and explains the system; it does not edit files or queue implementation work. - **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. - **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. diff --git a/docs/maps.md b/docs/maps.md index de2254c09..341df9c31 100644 --- a/docs/maps.md +++ b/docs/maps.md @@ -139,7 +139,7 @@ A map that won't load (bad JSON, something outside the hall, too few seats, a pr ### Seats -Every map has the same seats, by id, so that the server, the task queue, meetings and saved workers work on any of them: 16 regular seats (`desk-1` to `desk-16`), 4 for the office's back office (`desk-17` to `desk-20`, only sat at once that floor's back office is built out that far), 12 more that come out once those are taken (`beanbag-1` to `beanbag-12`), the three board agents' places and the five meeting chairs. The tables' seats are handed out in order: first the side of every table toward the middle of the hall (its inner side), table by table in the order they're listed, then their other sides the same way. Along a table they go from one end to the other, north to south for one that runs along z. So `desk-1` is the first seat on the first table's inner side, and a map's tables must seat at least 32 between them; any seats past that are just bench. +Every map has the same seats, by id, so that the server, the task queue, meetings and saved workers work on any of them: 16 regular seats (`desk-1` to `desk-16`), 4 for the office's back office (`desk-17` to `desk-20`, only sat at once that floor's back office is built out that far), 12 more that come out once those are taken (`beanbag-1` to `beanbag-12`), four board-agent places and the five meeting chairs. The System Agent station may be omitted from older custom map files; in that case it appears in the center aisle. The tables' seats are handed out in order: first the side of every table toward the middle of the hall (its inner side), table by table in the order they're listed, then their other sides the same way. Along a table they go from one end to the other, north to south for one that runs along z. So `desk-1` is the first seat on the first table's inner side, and a map's tables must seat at least 32 between them; any seats past that are just bench. ### Props diff --git a/docs/maps/castle.json b/docs/maps/castle.json index 5837d30a9..82a11863e 100644 --- a/docs/maps/castle.json +++ b/docs/maps/castle.json @@ -63,6 +63,11 @@ "x": 11.3, "z": -15, "rotY": 1.5708 + }, + "system": { + "x": 11.3, + "z": -3, + "rotY": 1.5708 } }, "council": { diff --git a/docs/maps/station.json b/docs/maps/station.json index 813f4e415..79ff8c75f 100644 --- a/docs/maps/station.json +++ b/docs/maps/station.json @@ -64,6 +64,11 @@ "x": 13.2, "z": -14, "rotY": 1.5708 + }, + "system": { + "x": 13.2, + "z": -3, + "rotY": 1.5708 } }, "council": { diff --git a/docs/roadmap/system-overview.md b/docs/roadmap/system-overview.md new file mode 100644 index 000000000..7629cc6cb --- /dev/null +++ b/docs/roadmap/system-overview.md @@ -0,0 +1,51 @@ +# System overview + +Back to the [README](../../README.md). This is the quick map of how a person, a project floor and its workers fit together. For implementation detail, see [How it works](../how-it-works.md) and [Code layout](../code-layout.md). + +```mermaid +flowchart LR + person[Teammate] -->|browser: HTTPS + WebSocket| client[Client
HUD, 3D world, windows] + client <-->|messages and state| office[Agent Office server
Node.js] + + office --> floors[Floor manager] + floors -->|one floor per project| project[Project checkout
boards, queue, docs, settings] + project -->|GitHub CLI| github[GitHub
issues, pull requests, branches] + + office --> manager[Worker manager] + manager -->|PTY or ACP session| agent[Agent CLI
Claude, Codex, OpenCode, etc.] + agent -->|reads and changes files| project + agent -->|hooks / worker tools| hooks[Loopback hook server
status, tasks, worker actions] + hooks --> manager + + office --> storage[Office data
accounts, floors, chat, settings] + manager --> workerdata[Project data
sessions, queue, worktrees] + workerdata --> project + + client -->|live presence, chat, voice signaling| peers[Other teammates] + office -->|WebRTC signaling| peers +``` + +## Typical task flow + +```mermaid +sequenceDiagram + actor Teammate + participant Browser as Browser client + participant Office as Agent Office server + participant Floor as Project floor + participant Worker as Agent worker + participant GitHub + + Teammate->>Browser: Choose a project and assign work + Browser->>Office: Send task over WebSocket + Office->>Floor: Create worker and optional worktree + Floor->>Worker: Start agent CLI in project directory + Worker->>Floor: Read and edit project files + Worker-->>Office: Report status through hooks or ACP + Office-->>Browser: Stream status and terminal updates + Worker->>GitHub: Push branch and open pull request + GitHub-->>Floor: Pull request data is refreshed + Floor-->>Browser: Show the PR on the board and worker desk +``` + +The project agent should keep this page aligned with the actual system when architecture changes. Mermaid source stays ordinary Markdown, so it can also be reviewed and edited in GitHub. diff --git a/package-lock.json b/package-lock.json index e3f643997..aa64a1e15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@xterm/addon-serialize": "^0.14.0", "@xterm/headless": "^6.0.0", "selfsigned": "^5.5.0", + "svg-pan-zoom": "^3.6.2", "ws": "^8.21.3" }, "bin": { @@ -31,6 +32,7 @@ "concurrently": "^10.0.5", "dompurify": "^3.4.16", "marked": "^18.0.14", + "mermaid": "^11.17.2", "playwright-core": "^1.63.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -5202,6 +5204,12 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/svg-pan-zoom": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/svg-pan-zoom/-/svg-pan-zoom-3.6.2.tgz", + "integrity": "sha512-JwnvRWfVKw/Xzfe6jriFyfey/lWJLq4bUh2jwoR5ChWQuQoOH8FEh1l/bEp46iHHKHEJWIyFJETbazraxNWECg==", + "license": "BSD-2-Clause" + }, "node_modules/three": { "version": "0.186.1", "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz", diff --git a/package.json b/package.json index 66e7bd2e9..d2da8789d 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@xterm/addon-serialize": "^0.14.0", "@xterm/headless": "^6.0.0", "selfsigned": "^5.5.0", + "svg-pan-zoom": "^3.6.2", "ws": "^8.21.3" }, "devDependencies": { @@ -47,6 +48,7 @@ "concurrently": "^10.0.5", "dompurify": "^3.4.16", "marked": "^18.0.14", + "mermaid": "^11.17.2", "playwright-core": "^1.63.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/client/core/stations.ts b/src/client/core/stations.ts index 38bd24343..ca9040b3f 100644 --- a/src/client/core/stations.ts +++ b/src/client/core/stations.ts @@ -10,6 +10,7 @@ export const STATION_INFO: Record { @@ -56,5 +57,5 @@ export function installBookshelf(ctx: Ctx) { turnedAt = now; } - return { showBookshelf }; + return { showBookshelf, showRoadmap: () => showBookshelf('docs/roadmap') }; } diff --git a/src/client/features/bookshelf/ui.css b/src/client/features/bookshelf/ui.css index a31dce25b..f5a81bdf2 100644 --- a/src/client/features/bookshelf/ui.css +++ b/src/client/features/bookshelf/ui.css @@ -8,6 +8,9 @@ .modal .bs-find input[type=text] { font-size: 14px; padding: 8px 12px; } .bs-count { font-size: 12px; font-weight: 800; color: var(--muted); padding: 0 16px 6px; min-height: 20px; } .bs-list { list-style: none; margin: 0; padding: 0 8px 12px; overflow: auto; flex: 1; min-height: 0; } +.bs-group { list-style: none; margin: 8px 0 12px; } +.bs-group-title { position: sticky; top: 0; z-index: 1; padding: 5px 10px; background: var(--paper-2); color: var(--muted); font-size: 11px; font-weight: 900; letter-spacing: .06em; text-transform: uppercase; } +.bs-group-items { list-style: none; margin: 0; padding: 0; } .bs-item { padding: 7px 10px; border-radius: 10px; cursor: pointer; border: 2px solid transparent; min-width: 0; } .bs-item + .bs-item { margin-top: 2px; } .bs-item:hover { background: rgba(255, 255, 255, .7); } diff --git a/src/client/features/bookshelf/ui.ts b/src/client/features/bookshelf/ui.ts index 091bebec1..77dc3b03a 100644 --- a/src/client/features/bookshelf/ui.ts +++ b/src/client/features/bookshelf/ui.ts @@ -1,7 +1,7 @@ import './ui.css'; import { isDocPath, resolveDocLink, type DocFile, type DocList, type DocText } from '../../../shared/docs'; import { clip, h, openModal, setDoing, timeAgo, toast } from '../../ui/dom'; -import { markdownFile } from '../../ui/markdown'; +import { markdownFile, renderMermaidDiagrams } from '../../ui/markdown'; // The bookshelf: every Markdown file in the floor's project, to read without leaving the office. // The filter box over the list picks docs out as you type (the letters in order, not necessarily @@ -17,6 +17,8 @@ export interface ShelfDeps { project?: string; /** The project on GitHub, for links to files that aren't docs. */ repoUrl?: string; + /** Restrict this view to one project folder, e.g. docs/roadmap. */ + folder?: string; /** You turned a page (opened a doc, or scrolled a screenful): the book in your hands turns one too. */ onTurn(): void; /** Whether a page turning makes a sound (⚙️'s setting), and the 🔈 up top that turns it on or off. */ @@ -94,6 +96,19 @@ function shelfOrder(files: DocFile[]): DocFile[] { return [...files].sort((a, b) => rank(a.path) - rank(b.path) || a.path.localeCompare(b.path)); } +/** Keep files together by their containing folder, preserving the shelf or search order. */ +function folderGroups(hits: Hit[]): { folder: string; hits: { hit: Hit; index: number }[] }[] { + const groups = new Map(); + for (const [index, hit] of hits.entries()) { + const slash = hit.doc.path.lastIndexOf('/'); + const folder = slash < 0 ? '' : hit.doc.path.slice(0, slash); + const group = groups.get(folder) ?? []; + group.push({ hit, index }); + groups.set(folder, group); + } + return [...groups].map(([folder, grouped]) => ({ folder, hits: grouped })); +} + /** `text` with the letters at `at` marked. */ function marked(text: string, at: Set): (string | HTMLElement)[] { if (!at.size) return [text]; @@ -125,18 +140,22 @@ function size(bytes: number): string { return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB`; } -function lastRead(floor: string): string | undefined { +function scopeKey(floor: string, folder?: string): string { + return folder ? `${floor}:${folder}` : floor; +} + +function lastRead(floor: string, folder?: string): string | undefined { try { - return JSON.parse(localStorage.getItem(LAST_KEY) ?? '{}')[floor]; + return JSON.parse(localStorage.getItem(LAST_KEY) ?? '{}')[scopeKey(floor, folder)]; } catch { return undefined; } } -function rememberRead(floor: string, path: string) { +function rememberRead(floor: string, path: string, folder?: string) { try { const all = JSON.parse(localStorage.getItem(LAST_KEY) ?? '{}'); - all[floor] = path; + all[scopeKey(floor, folder)] = path; localStorage.setItem(LAST_KEY, JSON.stringify(all)); } catch { // Private mode, or storage is full: it just won't reopen where you were. @@ -151,6 +170,7 @@ async function getJson(url: string): Promise { export function openBookshelf(deps: ShelfDeps) { const { floor, repoUrl } = deps; + const folder = deps.folder?.replace(/^\/+|\/+$/g, ''); const q = (params: Record) => new URLSearchParams({ floor, ...params }).toString(); const filter = h('input', { type: 'text', placeholder: 'Filter the docs…', 'aria-label': 'Filter the docs', spellcheck: 'false', autocomplete: 'off' }) as HTMLInputElement; @@ -176,7 +196,7 @@ export function openBookshelf(deps: ShelfDeps) { const el = h( 'div.modal.bookshelf', { role: 'dialog', 'aria-label': 'Bookshelf' }, - h('header', {}, h('h2', {}, '📚 Bookshelf', deps.project ? h('span.bs-project', {}, ` · ${deps.project}`) : ''), soundBtn), + h('header', {}, h('h2', {}, folder ? '🗺️ System Roadmap' : '📚 Bookshelf', deps.project ? h('span.bs-project', {}, ` · ${deps.project}`) : ''), soundBtn), h( 'div.body', {}, @@ -202,22 +222,28 @@ export function openBookshelf(deps: ShelfDeps) { const renderList = () => { count.textContent = !files.length ? '' : filter.value.trim() ? `${shown.length} of ${files.length} docs` : `${files.length} doc${files.length === 1 ? '' : 's'}`; list.replaceChildren( - ...shown.map((hit, i) => { - const { doc } = hit; - const title = doc.title ?? nameOf(doc.path); - const li = h( - 'li.bs-item', - { role: 'option', 'aria-selected': String(i === sel), class: `${i === sel ? 'sel' : ''} ${doc.path === current ? 'open' : ''}`, title: doc.path }, - h('div.bs-title', {}, ...(doc.title ? marked(title, hit.title) : marked(title, new Set([...hit.path].map((p) => p - (doc.path.length - title.length)))))), - h('div.bs-path', {}, ...marked(doc.path, hit.path)), - ); - li.addEventListener('mousedown', (e) => e.preventDefault()); - li.addEventListener('click', () => { - sel = i; - void openDoc(doc.path); - }); - return li; - }), + ...folderGroups(shown).map(({ folder, hits }) => h( + 'li.bs-group', + {}, + h('div.bs-group-title', {}, folder || 'Project root'), + h('ul.bs-group-items', { role: 'group', 'aria-label': folder || 'Project root' }, ...hits.map(({ hit, index: i }) => { + const { doc } = hit; + const title = doc.title ?? nameOf(doc.path); + const titleMarks = doc.title ? marked(title, hit.title) : marked(title, new Set([...hit.path].map((p) => p - (doc.path.length - title.length)))); + const li = h( + 'li.bs-item', + { role: 'option', 'aria-selected': String(i === sel), class: `${i === sel ? 'sel' : ''} ${doc.path === current ? 'open' : ''}`, title: doc.path }, + h('div.bs-title', {}, ...titleMarks), + h('div.bs-path', {}, ...marked(doc.path, hit.path)), + ); + li.addEventListener('mousedown', (e) => e.preventDefault()); + li.addEventListener('click', () => { + sel = i; + void openDoc(doc.path); + }); + return li; + })), + )), ); if (!files.length) list.append(h('li.bs-none', {}, 'No Markdown files in this project yet.')); else if (!shown.length) list.append(h('li.bs-none', {}, 'No doc matches that.')); @@ -234,7 +260,7 @@ export function openBookshelf(deps: ShelfDeps) { if (!shown.length) return; sel = (sel + by + shown.length) % shown.length; renderList(); - list.children[sel]?.scrollIntoView({ block: 'nearest' }); + list.querySelectorAll('.bs-item')[sel]?.scrollIntoView({ block: 'nearest' }); }; filter.addEventListener('input', refilter); @@ -317,11 +343,12 @@ export function openBookshelf(deps: ShelfDeps) { } if (mine !== opening || !el.isConnected) return; current = path; - rememberRead(floor, path); + rememberRead(floor, path, folder); const info = files.find((f) => f.path === path); const body = doc.text.trim() ? markdownFile(doc.text) : h('div.md', {}, h('p.none', {}, 'This file is empty.')); wire(body, path); page.replaceChildren(body); + void renderMermaidDiagrams(body); const dir = path.slice(0, path.length - nameOf(path).length); crumbs.replaceChildren(dir ? h('span.dir', {}, dir) : '', nameOf(path)); crumbs.title = path; @@ -359,12 +386,12 @@ export function openBookshelf(deps: ShelfDeps) { getJson(`/api/docs?${q({})}`) .then((r) => { if (!el.isConnected) return; - files = r.files; + files = folder ? r.files.filter((file) => file.path.startsWith(`${folder}/`)) : r.files; refilter(); if (r.more) count.textContent += ` (the first ${files.length})`; - const start = [lastRead(floor), ...shelfOrder(files).map((f) => f.path)].find((p) => p && files.some((f) => f.path === p)); + const start = [lastRead(floor, folder), ...shelfOrder(files).map((f) => f.path)].find((p) => p && files.some((f) => f.path === p)); if (start) void openDoc(start); - else page.replaceChildren(h('div.bs-empty', {}, '📭 Nothing to read here: this project has no Markdown files yet.')); + else page.replaceChildren(h('div.bs-empty', {}, folder ? `📭 No Markdown files in ${folder} yet.` : '📭 Nothing to read here: this project has no Markdown files yet.')); }) .catch((err: Error) => { if (!el.isConnected) return; diff --git a/src/client/features/hud/index.ts b/src/client/features/hud/index.ts index a54730173..f8159996b 100644 --- a/src/client/features/hud/index.ts +++ b/src/client/features/hud/index.ts @@ -66,8 +66,8 @@ export function installHud(ctx: Ctx, core: CoreState, parts: HudParts) { run: () => parts.meeting.showMeeting(), }, { id: 'search', icon: '🔎', label: 'Search', section: 'Open', key: '/', title: () => 'Search the chat and every terminal', run: waiting.showSearch }, - // The office has its bookshelf for them; a map of its own may not. - { id: 'docs', icon: '📚', label: 'Docs', section: 'Open', shown: () => !inOffice(), title: () => 'Read the project’s docs', run: parts.bookshelf.showBookshelf }, + // Keep project docs one click away from the office map too; the bookshelf reads the current floor. + { id: 'docs', icon: '📚', label: 'Docs', section: 'Open', title: () => 'Read the current project’s docs', run: parts.bookshelf.showBookshelf }, { id: 'elevator', icon: '🛗', label: () => (inOffice() ? 'Elevator' : 'Floors'), section: 'Open', count: () => store.floors.reduce((n, f) => n + (f.id === store.floor ? 0 : f.waiting), 0), title: () => (inOffice() ? 'Ride to another project' : 'Go to another project, or add one'), run: travel.showElevator }, { id: 'roof', icon: '🍸', label: 'Rooftop bar', section: 'Open', shown: () => !core.upTop && inOffice() && builtFloors().length > 0, title: () => 'Ride the elevator up to the roof: a DJ, drinks and the city', run: () => travel.ride(ROOF) }, // In voice, V is push to talk, so leaving is only from here. diff --git a/src/client/features/system-roadmap/index.ts b/src/client/features/system-roadmap/index.ts new file mode 100644 index 000000000..3fda3e00d --- /dev/null +++ b/src/client/features/system-roadmap/index.ts @@ -0,0 +1,20 @@ +import type { Ctx } from '../../core/context'; +import { aside, hintTitle, key, onE } from '../../core/hint'; + +declare module '../../world/types' { + interface InteractKinds { + systemroadmap: true; + } +} + +export interface SystemRoadmapDeps { + showRoadmap(): void; +} + +export function installSystemRoadmap(ctx: Ctx, deps: SystemRoadmapDeps) { + ctx.interactions.define('systemroadmap', { + reach: 8, + hint: () => ({ k: '', parts: [hintTitle('🗺️ System Roadmap'), aside('Read this project’s roadmap'), key('E', 'Open roadmap')] }), + use: onE(() => deps.showRoadmap()), + }); +} diff --git a/src/client/features/system-roadmap/world.ts b/src/client/features/system-roadmap/world.ts new file mode 100644 index 000000000..81d88e1c2 --- /dev/null +++ b/src/client/features/system-roadmap/world.ts @@ -0,0 +1,95 @@ +import * as THREE from 'three'; +import { ROADMAP_BOARD } from '../../../shared/layout'; +import { mesh, toon } from '../../world/toon'; +import type { Interactable } from '../../world/types'; +import { wallBoard } from '../../world/office/props'; +import type { Fixture } from '../../world/office/fixture'; + +// A dedicated roadmap display; the System Agent stands at its registered kiosk (see STATIONS). +export const ROADMAP_FOLDER = 'docs/roadmap'; + +export const systemRoadmap: Fixture = () => { + const { x, y, z, width, height, rotY } = ROADMAP_BOARD; + const { group: board, face } = wallBoard(width, height, '#aab4be'); + board.position.set(x, y, z); + board.rotation.y = rotY; + const group = new THREE.Group(); + group.add(board); + + // Put the display on its own rolling stand beside the elevator, facing the elevator entry. + const standColor = toon('#aab4be'); + const postHeight = y + height / 2 + 0.12; + for (const side of [-1, 1]) { + const px = x + side * (width / 2 + 0.1); + group.add(mesh(new THREE.CylinderGeometry(0.035, 0.035, postHeight, 10), standColor, px, postHeight / 2, z)); + group.add(mesh(new THREE.BoxGeometry(0.12, 0.07, 0.95), standColor, px, 0.12, z)); + for (const dz of [-0.38, 0.38]) group.add(mesh(new THREE.SphereGeometry(0.055, 10, 8), standColor, px, 0.055, z + dz, false)); + } + group.add(mesh(new THREE.BoxGeometry(width + 0.2, 0.06, 0.1), standColor, x, 0.22, z)); + + const canvas = document.createElement('canvas'); + canvas.width = 1040; + canvas.height = 600; + const g = canvas.getContext('2d')!; + g.fillStyle = '#fffdf8'; + g.fillRect(0, 0, canvas.width, canvas.height); + g.fillStyle = '#2b2d42'; + g.font = '900 58px Nunito, system-ui, sans-serif'; + g.fillText('SYSTEM ROADMAP', 48, 84); + g.fillStyle = '#6c757d'; + g.font = '700 30px Nunito, system-ui, sans-serif'; + g.fillText('Project flow and architecture', 50, 132); + const nodes = [ + { x: 56, title: 'USER', detail: 'goals & needs', color: '#e9c46a' }, + { x: 310, title: 'PRODUCT', detail: 'scope & priorities', color: '#f4a261' }, + { x: 564, title: 'SYSTEM', detail: 'services & data', color: '#8ecae6' }, + { x: 818, title: 'DELIVERY', detail: 'work & release', color: '#95d5b2' }, + ]; + for (const [i, n] of nodes.entries()) { + const bx = n.x; + g.fillStyle = n.color; + g.beginPath(); + g.roundRect(bx, 210, 166, 138, 18); + g.fill(); + g.fillStyle = '#2b2d42'; + g.font = '900 28px Nunito, system-ui, sans-serif'; + g.textAlign = 'center'; + g.fillText(n.title, bx + 83, 267); + g.font = '700 18px Nunito, system-ui, sans-serif'; + g.fillText(n.detail, bx + 83, 307); + if (i < nodes.length - 1) { + g.strokeStyle = '#6c757d'; + g.lineWidth = 8; + g.beginPath(); + g.moveTo(bx + 174, 279); + g.lineTo(bx + 240, 279); + g.stroke(); + g.fillStyle = '#6c757d'; + g.beginPath(); + g.moveTo(bx + 240, 279); + g.lineTo(bx + 222, 265); + g.lineTo(bx + 222, 293); + g.fill(); + } + } + g.textAlign = 'left'; + g.fillStyle = '#2b2d42'; + g.font = '800 32px Nunito, system-ui, sans-serif'; + g.fillText('Press E to read docs/roadmap', 50, 460); + g.fillStyle = '#6c757d'; + g.font = '600 25px Nunito, system-ui, sans-serif'; + g.fillText('The project team maintains these Markdown documents.', 50, 510); + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 4; + face.material = new THREE.MeshBasicMaterial({ map: texture, toneMapped: false }); + + const boardUse: Interactable = { kind: 'systemroadmap', x: x + Math.sin(rotY) * 1.65, z: z + Math.cos(rotY) * 1.65, radius: 2.5 }; + board.userData.interact = boardUse; + + return { + group, + colliders: [{ minX: x - width / 2 - 0.18, maxX: x + width / 2 + 0.18, minZ: z - 0.5, maxZ: z + 0.5, top: postHeight }], + interactables: [boardUse], + }; +}; diff --git a/src/client/main.ts b/src/client/main.ts index 7cc7da7d8..70ea12ec5 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -62,6 +62,7 @@ import { installVoice } from './features/voice'; import { installWaiting } from './features/waiting'; import { installWalking } from './features/walking'; import { installWhiteboard } from './features/whiteboard'; +import { installSystemRoadmap } from './features/system-roadmap'; import { installWorkerActions } from './features/workers/actions'; import { installWorkerViews } from './features/workers/views'; @@ -148,6 +149,9 @@ parts.needsYou = installNeedsYou(ctx, parts); installPalette(ctx, parts); parts.meeting = installMeeting(ctx, parts); parts.bookshelf = installBookshelf(ctx); +installSystemRoadmap(ctx, { + showRoadmap: () => parts.bookshelf.showRoadmap(), +}); installHerald(ctx, parts); parts.bar = installBar(ctx, { roof: parts.rooftop.roof, djAt: parts.rooftop.djAt, reach }); diff --git a/src/client/ui/elevator.css b/src/client/ui/elevator.css index 8aaea1d78..c8da564b3 100644 --- a/src/client/ui/elevator.css +++ b/src/client/ui/elevator.css @@ -36,4 +36,6 @@ .elevator .note.busy { color: var(--ink); } /* The folder's Change button: styles/base.css, after the button rules it restyles. */ .elevator .dir-pick { margin-top: 8px; } +.elevator .local-open, .elevator .local-back { margin-top: 10px; } +.elevator .local-path input { width: 100%; } .elevator .err { margin: 8px 0 0; font-size: 13px; font-weight: 800; color: var(--bad); overflow-wrap: anywhere; } diff --git a/src/client/ui/elevator.ts b/src/client/ui/elevator.ts index 36f6c92a9..431ca5a40 100644 --- a/src/client/ui/elevator.ts +++ b/src/client/ui/elevator.ts @@ -35,9 +35,16 @@ const START_MS = 60_000; /** Panels waiting on a clone; each says whether the answer was for it. */ const addedWaiters = new Set<(msg: Extract) => boolean>(); +const localAddedWaiters = new Set<(msg: Extract) => boolean>(); /** Main feeds server messages through here, so a panel waiting on its clone hears back. */ export function routeElevatorMessage(msg: ServerMsg) { + if (msg.t === 'floor.localAdded') { + let heard = false; + for (const fn of localAddedWaiters) heard = fn(msg) || heard; + if (!heard && msg.error) toast(`🛗 ${msg.error}`, 'warn'); + return; + } if (msg.t !== 'floor.added') return; let heard = false; for (const fn of addedWaiters) heard = fn(msg) || heard; @@ -66,9 +73,13 @@ export function openElevator(opts: ElevatorOptions): void { let filter = ''; let selected: string | null = null; let adding: string | null = null; + let addingLocal: string | null = null; + let localError = ''; + let localOpen = false; /** The office has started cloning `adding` (it's on the floor list). */ let seen = false; let startTimer: number | undefined; + let localTimer: number | undefined; let error = ''; let showAdd = setup || !store.floors.length; /** The search box and list are in place (rebuilding them would lose the focus mid-typing). */ @@ -80,6 +91,9 @@ export function openElevator(opts: ElevatorOptions): void { const listEl = h('div.repo-list', { role: 'listbox', 'aria-label': 'Repositories' }); const statusEl = h('div'); const addBtn = h('button.btn.primary', { type: 'button' }, '🛗 Add floor'); + const localAddBtn = h('button.btn.primary.hidden', { type: 'button' }, '📁 Add local project'); + const localInput = h('input', { type: 'text', placeholder: '/home/you/projects/my-project', 'aria-label': 'Local project path', autocomplete: 'off', spellcheck: 'false' }) as HTMLInputElement; + const localStatus = h('div'); const refreshBtn = h('button.btn', { type: 'button', title: 'Ask GitHub for the list again' }, '↻'); const close = h('button.btn.close', { type: 'button', 'aria-label': 'Close', title: 'Close (Esc)' }, '✕'); @@ -267,7 +281,28 @@ export function openElevator(opts: ElevatorOptions): void { addBtn.classList.add('hidden'); return; } + if (localOpen) { + addBtn.classList.add('hidden'); + localAddBtn.classList.remove('hidden'); + localInput.disabled = !!addingLocal; + localAddBtn.disabled = !!addingLocal || !localInput.value.trim(); + localAddBtn.textContent = addingLocal ? '⏳ Adding…' : '📁 Add local project'; + localStatus.replaceChildren( + h('p.note', {}, 'Enter an absolute path on the office machine. It stays where it is; the office only registers it as a floor. For a container, mount the folder into it first.'), + ...(addingLocal ? [h('p.note.busy', {}, `⏳ Adding ${addingLocal}…`)] : []), + ...(localError ? [h('p.err', {}, localError)] : []), + ); + const back = h('button.btn.local-back', { type: 'button' }, '← Back to GitHub projects'); + back.addEventListener('click', () => { + localOpen = false; + built = false; + renderAdd(); + }); + addEl.replaceChildren(h('h3', {}, '📁 Add a local project'), h('div.local-path', {}, localInput), localStatus, back); + return; + } addBtn.classList.remove('hidden'); + localAddBtn.classList.add('hidden'); const r = store.repos; const q = filter.trim().toLowerCase(); const typed = normalizeRepo(filter); @@ -305,7 +340,14 @@ export function openElevator(opts: ElevatorOptions): void { listEl, statusEl, dirEl, + store.me.admin ? h('button.btn.local-open', { type: 'button' }, '📁 Add an existing local project…') : '', ); + addEl.querySelector('.local-open')?.addEventListener('click', () => { + localOpen = true; + localError = ''; + renderAdd(); + setTimeout(() => localInput.focus(), 0); + }); } }; @@ -347,6 +389,22 @@ export function openElevator(opts: ElevatorOptions): void { settle(msg.error ? undefined : msg.floor, msg.error); return true; }; + const settleLocal = (floor: string | undefined, why?: string) => { + addingLocal = null; + clearTimeout(localTimer); + if (floor) { + modal.close(); + opts.ride(floor); + return; + } + localError = why ?? 'The local project could not be added'; + renderAdd(); + }; + const onLocalAdded = (msg: Extract) => { + if (!addingLocal || msg.dir !== addingLocal) return false; + settleLocal(msg.error ? undefined : msg.floor, msg.error); + return true; + }; /** * The floor list changed. The office answers the one who asked with floor.added, but if it @@ -359,6 +417,7 @@ export function openElevator(opts: ElevatorOptions): void { else if (seen) settle(f?.id, `Cloning ${adding} stopped before it finished — add it again`); }; addedWaiters.add(onAdded); + localAddedWaiters.add(onLocalAdded); input.addEventListener('input', () => { filter = input.value; @@ -374,6 +433,29 @@ export function openElevator(opts: ElevatorOptions): void { const pick = choice() ?? (q && matches.length === 1 ? matches[0].name : undefined); if (pick) add(pick); }); + localInput.addEventListener('input', () => (localAddBtn.disabled = !!addingLocal || !localInput.value.trim())); + localInput.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' || e.isComposing) return; + e.preventDefault(); + addLocal(); + }); + function addLocal() { + if (addingLocal || !store.me.admin) return; + const dir = localInput.value.trim(); + if (!dir) return localInput.focus(); + addingLocal = dir; + localError = ''; + renderAdd(); + net.send({ t: 'floor.localAdd', dir }); + clearTimeout(localTimer); + localTimer = window.setTimeout(() => { + if (addingLocal !== dir) return; + addingLocal = null; + localError = 'The office did not answer. Check that the path is available to it, then try again.'; + renderAdd(); + }, START_MS); + } + localAddBtn.addEventListener('click', addLocal); addBtn.addEventListener('click', () => { const pick = choice(); if (pick) add(pick); @@ -398,7 +480,7 @@ export function openElevator(opts: ElevatorOptions): void { { role: 'dialog', 'aria-label': 'Elevator' }, h('header', {}, h('h2', {}, setup ? '🏢 Welcome to Agent Office' : '🛗 Elevator'), close), h('div.body', {}, intro, floorsEl, addEl), - h('footer', {}, h('span.grow', {}, setup ? 'Your office, one floor per project · Esc to look around first' : 'Pick a floor · Esc to stay here'), addBtn), + h('footer', {}, h('span.grow', {}, setup ? 'Your office, one floor per project · Esc to look around first' : 'Pick a floor · Esc to stay here'), addBtn, localAddBtn), ); const unsubs = [store.on('floors', () => (checkAdding(), renderFloors(), renderAdd())), store.on('repos', renderAdd), store.on('projectsDir', () => (editDir(false), renderAdd())), store.on('floor', renderFloors), store.on('peers', renderFloors), store.on('me', () => (renderFloors(), renderAdd()))]; const modal = openModal(el, { @@ -408,7 +490,9 @@ export function openElevator(opts: ElevatorOptions): void { onClose: () => { current = null; clearTimeout(startTimer); + clearTimeout(localTimer); addedWaiters.delete(onAdded); + localAddedWaiters.delete(onLocalAdded); for (const off of unsubs) off(); }, }); diff --git a/src/client/ui/markdown.css b/src/client/ui/markdown.css index e9bfc34e6..4f5e7cf85 100644 --- a/src/client/ui/markdown.css +++ b/src/client/ui/markdown.css @@ -15,6 +15,11 @@ .md code { font: 12.5px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; background: #f3ebe0; border-radius: 6px; padding: 1px 5px; } .md pre { background: #1e1f2e; color: #e9ecef; border: 2px solid var(--ink); border-radius: 10px; padding: 12px 14px; overflow: auto; } .md pre code { background: none; padding: 0; color: inherit; font-size: 12.5px; line-height: 1.5; } +/* A fixed-height frame gives svg-pan-zoom a real viewport: it strips the svg's viewBox, so an + auto-height figure would collapse to the 150px default and clip the diagram. */ +.md .mermaid-diagram { margin: 0 0 12px; padding: 12px; height: clamp(180px, 38vh, 480px); overflow: hidden; border: 1.5px solid #ddd0bf; border-radius: 10px; background: #fffaf3; } +.md .mermaid-diagram svg { display: block; width: 100%; height: 100%; margin: 0 auto; } +.md .mermaid-error-note { margin: 0 0 6px; color: var(--muted); font-size: 12px; font-style: italic; } .md blockquote { margin-left: 0; margin-right: 0; padding: 2px 14px; border-left: 4px solid #d9cbb8; color: var(--muted); } .md ul, .md ol { padding-left: 26px; } .md li + li { margin-top: 3px; } diff --git a/src/client/ui/markdown.ts b/src/client/ui/markdown.ts index c8737e225..fae61a0e2 100644 --- a/src/client/ui/markdown.ts +++ b/src/client/ui/markdown.ts @@ -2,6 +2,7 @@ import './markdown.css'; import { Marked } from 'marked'; import DOMPurify from 'dompurify'; import { h } from './dom'; +import svgPanZoom from 'svg-pan-zoom'; // GitHub-flavored markdown for issue and PR text: rendered by marked, then sanitized by DOMPurify // before it touches the page, since anyone who can open an issue writes it. @@ -9,6 +10,9 @@ import { h } from './dom'; // In issue and PR comments GitHub turns a single newline into a line break, unlike in .md files. const md = new Marked({ gfm: true, breaks: true }); const mdFile = new Marked({ gfm: true }); +let mermaid: Promise | undefined; +let mermaidInitialized = false; +let mermaidId = 0; const purify = DOMPurify(window); purify.addHook('afterSanitizeAttributes', (node) => { @@ -117,6 +121,66 @@ export function markdownFile(src: string): HTMLElement { return el; } +/** Render fenced Mermaid blocks in project docs; ordinary Markdown stays synchronous and lightweight. */ +export async function renderMermaidDiagrams(root: HTMLElement): Promise { + const blocks = [...root.querySelectorAll('pre > code.language-mermaid')]; + if (!blocks.length) return; + + try { + const load = (mermaid ??= import('mermaid').then((module) => module.default)); + const renderer = await load; + if (!mermaidInitialized) { + renderer.initialize({ + startOnLoad: false, + securityLevel: 'strict', + theme: 'default', + htmlLabels: false, + maxTextSize: 100_000, + maxEdges: 500, + suppressErrorRendering: true, + }); + mermaidInitialized = true; + } + + for (const code of blocks) { + const pre = code.parentElement; + if (!pre) continue; + try { + const { svg } = await renderer.render(`agent-office-mermaid-${++mermaidId}`, code.textContent ?? ''); + const figure = h('figure.mermaid-diagram', { role: 'img', 'aria-label': 'Mermaid diagram' }); + figure.append(purify.sanitize(svg, { + RETURN_DOM_FRAGMENT: true, + USE_PROFILES: { svg: true, svgFilters: true }, + FORBID_TAGS: ['script', 'foreignObject'], + FORBID_ATTR: ['onload', 'onclick'], + })); + pre.replaceWith(figure); + + // Enable pan and zoom + const svgElement = figure.querySelector('svg'); + if (svgElement) { + // The frame is the viewport: fill it, and drop Mermaid's cap at the diagram's natural width. + svgElement.style.width = '100%'; + svgElement.style.height = '100%'; + svgElement.style.maxWidth = 'none'; + svgPanZoom(svgElement, { + zoomEnabled: true, + controlIconsEnabled: true, + fit: true, + center: true, + contain: true, + }); + } + } catch { + pre.classList.add('mermaid-error'); + pre.before(h('p.mermaid-error-note', {}, 'This diagram could not be rendered. Its source is shown below.')); + } + } + } catch { + // If Mermaid cannot load, the sanitized fenced source remains readable in the document. + } +} + /** https://github.com/owner/repo from an issue or PR URL. */ export function repoUrlOf(itemUrl: string): string { return itemUrl.replace(/\/(pull|issues)\/\d+.*$/, ''); diff --git a/src/client/world/castle/seats.ts b/src/client/world/castle/seats.ts index eb4e336e7..4f6d921ee 100644 --- a/src/client/world/castle/seats.ts +++ b/src/client/world/castle/seats.ts @@ -133,7 +133,7 @@ export function buildCouncil(kit: Kit, plan: MapPlan): { board?: THREE.Mesh; sig return { board, sign }; } -const LECTERN_SIGN: Record = { issues: '📜 Ask me', pulls: '🔀 Ask me', queue: '📋 Ask me' }; +const LECTERN_SIGN: Record = { issues: '📜 Ask me', pulls: '🔀 Ask me', queue: '📋 Ask me', system: '🧭 Ask me' }; /** A board agent's lectern (a scribe's desk): the agent stands behind it, as at the office's kiosk. */ export function lectern(kit: Kit, def: DeskDef): DeskView { diff --git a/src/client/world/office/build.ts b/src/client/world/office/build.ts index 15c0e6237..15291636e 100644 --- a/src/client/world/office/build.ts +++ b/src/client/world/office/build.ts @@ -11,6 +11,7 @@ import { jukebox } from '../../features/jukebox/world'; import { bookshelf } from '../../features/bookshelf/world'; import { cabinet } from '../../features/cabinet/world'; import { whiteboard } from '../../features/whiteboard/world'; +import { systemRoadmap } from '../../features/system-roadmap/world'; import { green, tee } from '../../features/golf/world'; import { stack } from '../stack'; import { tower } from '../tower'; @@ -52,6 +53,7 @@ function floorPlan() { beanbags, kiosks, boards, + systemRoadmap, // The lounge: the TV, the couch and its table and poufs, and the jukebox and the arcade in the corner. tv, machineMonitor, diff --git a/src/client/world/office/seats.ts b/src/client/world/office/seats.ts index 64e0ecb30..d250a0bcc 100644 --- a/src/client/world/office/seats.ts +++ b/src/client/world/office/seats.ts @@ -162,7 +162,7 @@ export function buildBeanbag(def: DeskDef, index: number): DeskView { return { def, group, laptopAnchor, seatAnchor, stage, chair: bag, vacancy, vacancyY }; } -const KIOSK_SIGN: Record = { issues: '📌 Ask me', pulls: '🔀 Ask me', queue: '📋 Ask me' }; +const KIOSK_SIGN: Record = { issues: '📌 Ask me', pulls: '🔀 Ask me', queue: '📋 Ask me', system: '🧭 Ask me' }; /** * A board agent's kiosk: a little counter in its color with a sign on the front, and the agent standing @@ -280,19 +280,22 @@ export const kiosks: Fixture = (site) => { const view = buildKiosk(def); site.group.add(view.group); site.desks.set(def.id, view); - // The kiosk and the agent behind it, back to the wall (they all stand by the north wall) so - // nobody squeezes in behind, and up over the agent's head so nobody hops on it. + // The kiosk and agent make one small obstacle, up over the agent's head so nobody hops on it. const corners = [-1, 1].flatMap((t) => [-KIOSK.depth / 2, KIOSK.stand + 0.35].map((sz) => deskPoint(def, (t * KIOSK.width) / 2, sz))); const xs = corners.map(([x]) => x); const zs = corners.map(([, z]) => z); - site.colliders.push({ minX: Math.min(...xs), maxX: Math.max(...xs), minZ: FLOOR.minZ, maxZ: Math.max(...zs), top: 1.5, fence: true }); + if (def.station === 'system') { + site.colliders.push({ minX: Math.min(...xs), maxX: Math.max(...xs), minZ: Math.min(...zs), maxZ: Math.max(...zs), top: 1.5 }); + } else { + site.colliders.push({ minX: Math.min(...xs), maxX: Math.max(...xs), minZ: FLOOR.minZ, maxZ: Math.max(...zs), top: 1.5, fence: true }); + } // Walk up to its front. const [fx, fz] = deskPoint(def, 0, -1); const it: Interactable = { kind: 'station', deskId: def.id, x: fx, z: fz, radius: 1.3 }; site.interactables.push(it); view.group.userData.interact = it; // The agent, its name tag and the card over its head, up against the wall. - site.wall('north', def.x, 1.45, 1.4, 2.9); + if (def.station !== 'system') site.wall('north', def.x, 1.45, 1.4, 2.9); } return {}; }; diff --git a/src/client/world/station/seats.ts b/src/client/world/station/seats.ts index d052f40a8..9247f5391 100644 --- a/src/client/world/station/seats.ts +++ b/src/client/world/station/seats.ts @@ -71,7 +71,7 @@ export function placeSetting(kit: Kit, def: DeskDef, overflow: boolean): { view: return { view: { def, group: g, laptopAnchor, seatAnchor, stage, chair: new THREE.Group(), vacancy, vacancyY: 1.35 }, it }; } -const CONSOLE_SIGN: Record = { issues: '📡 Ask me', pulls: '🔀 Ask me', queue: '📋 Ask me' }; +const CONSOLE_SIGN: Record = { issues: '📡 Ask me', pulls: '🔀 Ask me', queue: '📋 Ask me', system: '🧭 Ask me' }; /** A board agent's console: a slanted panel on a pedestal, lit in the agent's color; it stands behind it, as at the office's kiosk. */ export function agentConsole(kit: Kit, def: DeskDef): DeskView { diff --git a/src/server/building.ts b/src/server/building.ts index 2716aeacd..579be70e8 100644 --- a/src/server/building.ts +++ b/src/server/building.ts @@ -6,6 +6,7 @@ import { FLOOR_PALETTES, MAX_FLOORS, normalizeRepo, sameRepo } from '../shared/f import type { CloneProgress, ProjectsDirState, RepoChoice } from '../shared/protocol.js'; import { CloneRun, dropLog, whyCloneFailed, type CloneEnd, type CloneRunOptions } from './clone.js'; import { gh } from './github.js'; +import { localProjectDir } from './local-project.js'; /** A floor as floors.json keeps it. */ export interface FloorDef { @@ -328,6 +329,18 @@ export class Building { this.save(); return def; } + /** Adds a checkout already on this machine as a floor, without cloning or moving it. */ + addLocal(input: string, by: string): FloorDef | string { + const local = localProjectDir(input); + if (typeof local === 'string') return local; + const { dir } = local; + if (this.defs.some((d) => path.resolve(d.dir) === dir)) return `${dir} is already a floor`; + if (this.defs.length + this.cloning.size >= MAX_FLOORS) return `The building is full (${MAX_FLOORS} floors)`; + const def = this.newDef(path.basename(dir), originRepo(dir), dir, by); + this.defs.push(def); + this.save(); + return def; + } /** Clones a floor on its way, or checks that what's already there is its repository. Resolves to an error, if any. */ private async clone(p: Pending): Promise { diff --git a/src/server/local-project.ts b/src/server/local-project.ts new file mode 100644 index 000000000..9922eed9a --- /dev/null +++ b/src/server/local-project.ts @@ -0,0 +1,16 @@ +import { accessSync, constants, realpathSync, statSync } from 'node:fs'; +import path from 'node:path'; + +/** Resolves a path from the office machine to an existing directory, or explains what's wrong. */ +export function localProjectDir(input: string): { dir: string } | string { + const entered = input.trim(); + if (!path.isAbsolute(entered)) return 'Enter an absolute path on the office machine'; + try { + const dir = realpathSync(entered); + if (!statSync(dir).isDirectory()) return `${dir} is not a folder`; + accessSync(dir, constants.R_OK | constants.W_OK | constants.X_OK); + return { dir }; + } catch { + return `${entered} does not exist or cannot be opened`; + } +} diff --git a/src/server/stations.ts b/src/server/stations.ts index a488eb81d..70c0e80e7 100644 --- a/src/server/stations.ts +++ b/src/server/stations.ts @@ -1,7 +1,7 @@ // What the board agents are told when they're hired: the agents standing by the Issues board, the PR -// board and the task queue (STATIONS in shared/layout.ts). Whoever walks up types them a request; the -// first one follows this brief in the same prompt. The briefs themselves are prompts the office can -// rewrite in ⚙️ Settings (shared/prompts.ts). +// board, the task queue and the System Roadmap (STATIONS in shared/layout.ts). Whoever walks up types +// them a request; the first one follows this brief in the same prompt. The briefs themselves are +// prompts the office can rewrite in ⚙️ Settings (shared/prompts.ts). import type { StationKind } from '../shared/layout.js'; import { officePrompt, type PromptSource } from './prompts.js'; diff --git a/src/server/ws/handlers/floors.ts b/src/server/ws/handlers/floors.ts index 3f0f2cdab..baae117cd 100644 --- a/src/server/ws/handlers/floors.ts +++ b/src/server/ws/handlers/floors.ts @@ -47,6 +47,19 @@ export const floorHandlers = { ctx.sendTo(c, { t: 'floor.added', repo, floor: floor.id }); }); }, + 'floor.localAdd'(ctx, c, msg) { + const dir = str(msg.dir, 4096); + if (!ctx.meOf(c.accountId).admin) return ctx.sendTo(c, { t: 'floor.localAdded', dir, error: 'Only admins can add a local project' }); + const who = c.peer.name; + const r = ctx.building.addLocal(dir, who); + ctx.floorsChanged(); + if (typeof r === 'string') return ctx.sendTo(c, { t: 'floor.localAdded', dir, error: r }); + const floor = ctx.openFloor(r); + if (!floor) return ctx.sendTo(c, { t: 'floor.localAdded', dir, error: `Added ${r.name}, but couldn't open its floor — see the office's log` }); + console.log(` ${who} added the local project ${r.name} (${r.dir})`); + ctx.toastAll(`🛗 New local project: ${r.name}, added by ${who}`); + ctx.sendTo(c, { t: 'floor.localAdded', dir, floor: floor.id }); + }, 'floor.cancel'(ctx, c, msg) { const who = c.peer.name; const admin = ctx.meOf(c.accountId).admin; diff --git a/src/shared/layout.ts b/src/shared/layout.ts index a9e3bba45..0ee2772c9 100644 --- a/src/shared/layout.ts +++ b/src/shared/layout.ts @@ -135,13 +135,16 @@ export const BEANBAGS: DeskDef[] = ( /** Everywhere a worker can sit: the desks, the back office's once it's built out (see deskBuilt), then the bean bags. */ export const SEATS: DeskDef[] = [...DESKS, ...WING_DESKS, ...BEANBAGS]; -/** The boards with an agent standing by: the Issues board, the PR board and the task queue. */ -export type StationKind = 'issues' | 'pulls' | 'queue'; +/** The boards with an agent standing by: Issues, PRs, the task queue and the system roadmap. */ +export type StationKind = 'issues' | 'pulls' | 'queue' | 'system'; +/** The agents that have lecterns on the alternate maps. The System Agent's kiosk is office-specific. */ +export type MapStationKind = Exclude; /** - * The board agents: a worker standing behind a little kiosk just west of each of those boards (see - * BOARDS), there for anyone to prompt about it. (x, z) is the kiosk. They face into the room, so at - * rotY PI the worker stands on the wall side of it. Nobody hires them from the desks or the queue. + * The board agents: a worker standing behind a little kiosk by each board (see BOARDS), there for + * anyone to prompt about it. (x, z) is the kiosk. The System Agent kiosk faces the elevator, beside + * its rolling roadmap display; the three wall kiosks face into the room. Nobody hires them from the + * desks or the queue. */ export const STATIONS: DeskDef[] = [ // Between the plant in the north-west corner and the Issues board. @@ -150,6 +153,8 @@ export const STATIONS: DeskDef[] = [ { id: 'station-pulls', station: 'pulls', x: 0, z: FLOOR.minZ + 1.3, rotY: Math.PI, label: 'PR board' }, // Between the Issues board and the task queue. { id: 'station-queue', station: 'queue', x: -7.8, z: FLOOR.minZ + 1.3, rotY: Math.PI, label: 'Task queue' }, + // By the rolling System Roadmap display, clear of the elevator doors and merge gong. + { id: 'station-system', station: 'system', x: 7.5, z: FLOOR.minZ + 6.2, rotY: 0, label: 'System Roadmap' }, ]; /** A board agent's kiosk: its top, and how far behind its middle (toward the wall) the agent stands. */ export const KIOSK = { width: 0.8, depth: 0.5, height: 0.55, stand: 0.55 } as const; @@ -158,6 +163,7 @@ export const STATION_AGENT: Record issues: { name: 'Issues agent', color: '#ef476f' }, pulls: { name: 'PR agent', color: '#118ab2' }, queue: { name: 'Queue agent', color: '#06d6a0' }, + system: { name: 'System Agent', color: '#7654a8' }, }; /** The upstairs office: a glass-walled loft on posts in the south-east corner, looking down on the desks. */ @@ -246,13 +252,16 @@ export const BOARDS = { // Side by side along the north wall, the way work goes: an issue goes on the task queue (the // whiteboard in the middle), and its worker's pull request comes out the other side. Each has its // board agent's kiosk just west of it (see STATIONS). - issues: { x: -11.7, y: 2.1, z: FLOOR.minZ + 0.08, rotY: 0, width: 6, height: 3, label: 'Issues' }, - queue: { x: -3.9, y: 2.1, z: FLOOR.minZ + 0.08, rotY: 0, width: 6, height: 3, label: '📋 Task queue' }, - pulls: { x: 3.9, y: 2.1, z: FLOOR.minZ + 0.08, rotY: 0, width: 6, height: 3, label: 'Pull Requests' }, + issues: { x: -11.7, y: 2.1, z: FLOOR.minZ + 0.08, rotY: 0, width: 4.8, height: 3, label: 'Issues' }, + queue: { x: -3.9, y: 2.1, z: FLOOR.minZ + 0.08, rotY: 0, width: 4.8, height: 3, label: '📋 Task queue' }, + pulls: { x: 3.9, y: 2.1, z: FLOOR.minZ + 0.08, rotY: 0, width: 4.8, height: 3, label: 'Pull Requests' }, // East wall, north of the lounge TV. services: { x: FLOOR.maxX - 0.08, y: 2.1, z: -8.2, rotY: -Math.PI / 2, width: 6, height: 3, label: '🌐 Services' }, } as const; +/** System roadmap display on a stand in the office, visible from the elevator and west of the merge gong. */ +export const ROADMAP_BOARD = { x: 5, y: 2.1, z: FLOOR.minZ + 5.5, rotY: Math.PI, width: 3.6, height: 2.1, label: '🗺️ System Roadmap' } as const; + /** The big TV on the east wall that shows whoever is screen sharing. */ export const TV = { x: FLOOR.maxX - 0.1, y: 2.2, z: 0, width: 6.4, height: 3.6 } as const; /** diff --git a/src/shared/maps/castle.ts b/src/shared/maps/castle.ts index 21e3df004..226008527 100644 --- a/src/shared/maps/castle.ts +++ b/src/shared/maps/castle.ts @@ -101,6 +101,7 @@ export const CASTLE: MapConfig = { issues: { x: -11.3, z: -15, rotY: -Math.PI / 2 }, queue: { x: -11.3, z: -3, rotY: -Math.PI / 2 }, pulls: { x: 11.3, z: -15, rotY: Math.PI / 2 }, + system: { x: 11.3, z: -3, rotY: Math.PI / 2 }, }, council: { x: 6.6, z: -18.8, rotY: 0 }, boards: { diff --git a/src/shared/maps/index.ts b/src/shared/maps/index.ts index 850cc075e..e67ef5178 100644 --- a/src/shared/maps/index.ts +++ b/src/shared/maps/index.ts @@ -24,7 +24,7 @@ const STYLE_PROPS: Record>; f station: { kinds: STATION_PROP_KINDS, footprint: stationFootprint, top: stationTop }, }; -const STATION_KINDS: readonly StationKind[] = ['issues', 'pulls', 'queue']; +const STATION_KINDS: readonly StationKind[] = ['issues', 'pulls', 'queue', 'system']; /** How far in from a table's edge a seat's place setting is; the worker sits 0.85 out from it (see deskSeat), on the bench. */ const PLACE_IN = 0.35; /** How far out from a table's edge the middle of the bench down that side is. */ @@ -216,7 +216,7 @@ export function planMap(input: unknown): MapPlan { // The board agents' lecterns. if (!isObj(c.stations)) throw new MapError('it needs stations: where the Issues, PR and Queue agents stand'); const stations: DeskDef[] = STATION_KINDS.map((kind) => { - const p = place(c.stations[kind], `stations.${kind}`); + const p = place(c.stations[kind] ?? (kind === 'system' ? { x: 0, z: 0, rotY: 0 } : undefined), `stations.${kind}`); const def = { id: `station-${kind}`, station: kind, x: p.x, z: p.z, rotY: p.rotY, label: STATION_AGENT[kind].name }; // The agent stands behind its lectern: that's in the hall too. inside(p.x + Math.sin(p.rotY) * 0.9, p.z + Math.cos(p.rotY) * 0.9, `where the ${STATION_AGENT[kind].name} stands`, 0); diff --git a/src/shared/maps/station.ts b/src/shared/maps/station.ts index 98c918a40..651dc16cf 100644 --- a/src/shared/maps/station.ts +++ b/src/shared/maps/station.ts @@ -86,6 +86,7 @@ export const STATION: MapConfig = { issues: { x: -13.2, z: BOARDS_Z[0], rotY: -Math.PI / 2 }, queue: { x: -13.2, z: BOARDS_Z[1], rotY: -Math.PI / 2 }, pulls: { x: 13.2, z: BOARDS_Z[0], rotY: Math.PI / 2 }, + system: { x: 13.2, z: BOARDS_Z[1], rotY: Math.PI / 2 }, }, // The briefing table, aft on the starboard side. council: { x: 10, z: 24.8, rotY: Math.PI }, diff --git a/src/shared/maps/types.ts b/src/shared/maps/types.ts index ee1830dfb..5dac294d6 100644 --- a/src/shared/maps/types.ts +++ b/src/shared/maps/types.ts @@ -1,4 +1,4 @@ -import type { DeskDef, SeatDef, StationKind } from '../layout.js'; +import type { DeskDef, MapStationKind, SeatDef } from '../layout.js'; import type { Bounds, Obstacles, Pt, Rect } from '../nav.js'; /* @@ -192,7 +192,8 @@ export interface MapConfig { /** Where the workers sit. The sides toward the middle of the hall fill first. */ tables: TableConfig[]; /** The board agents, each at a lectern: where the lectern is, and `rotY` from it to where the agent stands (it faces back across the lectern, into the hall). */ - stations: Record; + /** Existing map configs may omit the later-added System Agent kiosk. */ + stations: Record & Partial>; /** The meeting table: five chairs round it, the head of the table's on its `rotY` side (facing back across it), and the easel on the other. */ council: Place; boards: Record; diff --git a/src/shared/prompts.ts b/src/shared/prompts.ts index e6a09af93..c7990062f 100644 --- a/src/shared/prompts.ts +++ b/src/shared/prompts.ts @@ -40,12 +40,14 @@ const BOARD: Record = { issues: 'the 📌 Issues board', pulls: 'the 🔀 Pull Requests board', queue: 'the 📋 task queue', + system: 'the 🗺️ System Roadmap board', }; const JOB: Record = { issues: `You look after this repository's GitHub issues with the gh CLI: file new ones (a clear title, what's wrong or wanted, and how to reproduce it when that applies), find and sum them up, triage, label, comment on, close and reopen them. To get an issue worked on, put it on the task queue with its number.`, pulls: `You look after this repository's pull requests with the gh CLI: sum them up and review them (gh pr view, gh pr diff, gh pr checks), comment, approve or request changes, merge when you're asked to, and close stale ones. Read a PR's code with gh pr diff rather than checking its branch out here. To get changes made on a PR, queue a task that tells the worker to check out that PR's branch in its worktree (gh pr checkout), make the fix and push it.`, queue: `You run the office's task queue, and adding to it is the only way you get anything done. Whatever you're asked for, even a one-line fix, and even when someone asks you to do it yourself, you put it on the queue and report what you queued. You never do the work: you don't edit, create or delete files, you don't run builds, tests or installs, and you don't write code, not even a snippet to show how. Read the code and gh issue list only as far as it takes to write a good task. Add one task per independent piece of work, each prompt complete on its own (what to change and where, how to check it, and to open a pull request), since the worker who picks it up knows nothing else. Link a task to its GitHub issue when it's for one. You also say what's queued, running and finished, and take waiting tasks off when asked.`, + system: `You are a guide to this project's architecture and system flows. Read the Markdown files under docs/roadmap/ in the current project checkout. Explain how the parts fit together in clear language, cite the relevant file path and heading, and say when the roadmap does not cover something instead of guessing. Do not edit project files or queue implementation work.`, }; /** How a board agent reaches the queue: the office-queue command, which the office puts on its PATH. */ @@ -59,6 +61,14 @@ const QUEUE_API = `The task queue gives each task a fresh worker in its own git /** What a board agent is told ahead of the first request typed to it. */ function stationDefault(kind: StationKind): string { + if (kind === 'system') { + return [ + `You're the ${STATION_AGENT[kind].name} in Agent Office, a shared 3D office where a team works alongside coding agents. You stand at a kiosk by ${BOARD[kind]}, and whoever walks up types you a request. The first one is at the end of this message.`, + JOB[kind], + 'Answer questions from the roadmap documents in the current project checkout. Keep explanations concise, name the relevant Markdown file and heading, and distinguish documented facts from anything the roadmap leaves unclear.', + 'The request:', + ].join('\n\n'); + } const queue = kind === 'queue'; return [ `You're the ${STATION_AGENT[kind].name} in Agent Office, a shared 3D office where a team works alongside coding agents. You stand at a kiosk by ${BOARD[kind]}, and whoever walks up types you a request. The first one is at the end of this message.`, @@ -206,6 +216,7 @@ const DEFS = { 'station.issues': station('issues'), 'station.pulls': station('pulls'), 'station.queue': station('queue'), + 'station.system': station('system'), // --- 🤝 Meeting room --- 'meeting.brief': { diff --git a/src/shared/protocol/floors.ts b/src/shared/protocol/floors.ts index ce908eaa2..79da052b7 100644 --- a/src/shared/protocol/floors.ts +++ b/src/shared/protocol/floors.ts @@ -137,6 +137,8 @@ export type FloorClientMsg = | { t: 'floor.repos'; refresh?: boolean } /** Clone a repository and make it a new floor; answered with `floor.added` once it's there. */ | { t: 'floor.add'; repo: string } + /** Add an existing directory on the office machine as a floor, without cloning it. */ + | { t: 'floor.localAdd'; dir: string } /** Stop a floor's clone before it's there (admins, or whoever added it); the one who added it hears `floor.added` with why. */ | { t: 'floor.cancel'; floor: string } /** Take a floor off the building (admins only). Its checkout stays on disk; everyone on it rides to another floor. */ @@ -159,6 +161,8 @@ export type FloorServerMsg = | { t: 'floor.repos'; repos: RepoChoice[]; error?: string } /** Sent to whoever asked for the floor, once it's cloned (or couldn't be). */ | { t: 'floor.added'; repo: string; floor?: string; error?: string } + /** Sent to whoever asked to add an existing local project. */ + | { t: 'floor.localAdded'; dir: string; floor?: string; error?: string } /** The projects folder moved (see floor.projectsDir). */ | { t: 'projectsDir'; state: ProjectsDirState } /** Your floor's signs changed, or its back office was built out or walled up. */ diff --git a/tests/building.test.ts b/tests/building.test.ts index c7a0fc97e..8f3a387f7 100644 --- a/tests/building.test.ts +++ b/tests/building.test.ts @@ -46,6 +46,32 @@ test("floors that aren't there can't be taken off", (t) => { assert.deepEqual(saved(dataDir), ['api', 'web', 'docs']); }); +test('an existing local project becomes a floor in place and survives a restart', (t) => { + const { root, dataDir } = office(t); + const dir = path.join(root, 'separate-workspace', 'private-project'); + mkdirSync(dir, { recursive: true }); + const building = new Building(dataDir, root); + const added = building.addLocal(dir, 'Sam'); + assert.equal(typeof added, 'object'); + assert.equal((added as FloorDef).dir, dir); + assert.equal((added as FloorDef).name, 'private-project'); + assert.equal((added as FloorDef).repo, undefined); + assert.ok(existsSync(dir), 'the project stays where it was'); + assert.deepEqual(saved(dataDir), ['api', 'web', 'docs', 'private-project']); + assert.equal(new Building(dataDir, root).list().at(-1)?.dir, dir); +}); + +test('adding a local project requires an existing absolute directory and rejects duplicates', (t) => { + const { root, dataDir } = office(t); + const dir = path.join(root, 'local-project'); + mkdirSync(dir); + const building = new Building(dataDir, root); + assert.match(String(building.addLocal('relative/project', 'Sam')), /absolute path/); + assert.match(String(building.addLocal(path.join(root, 'missing'), 'Sam')), /does not exist/); + assert.equal(typeof building.addLocal(dir, 'Sam'), 'object'); + assert.match(String(building.addLocal(dir, 'Sam')), /already a floor/); +}); + test('the floor the office was started in comes off too, stays off after a restart, and moves back in when its repository is added again', async (t) => { const { root, dataDir, defs } = office(t); // The office's own checkout, with its GitHub origin (how it's recognised once it's no longer a floor). diff --git a/tests/floorplan.test.ts b/tests/floorplan.test.ts index 3234030be..6ca9c701a 100644 --- a/tests/floorplan.test.ts +++ b/tests/floorplan.test.ts @@ -39,7 +39,7 @@ test('signs go up over desks, get changed and come down, and stay across restart test('only desks get signs: not bean bags, kiosks or the meeting table', () => { withDir((dir) => { const plan = new FloorPlanStore(dir); - for (const id of ['beanbag-1', 'station-queue', 'meeting-1', 'nope']) assert.equal(typeof plan.label(id, 'Ops', undefined, 'Ada'), 'string', id); + for (const id of ['beanbag-1', 'station-queue', 'station-system', 'meeting-1', 'nope']) assert.equal(typeof plan.label(id, 'Ops', undefined, 'Ada'), 'string', id); // The back office's desks can have one before they're built, ready for when they are. assert.equal(typeof plan.label(WING_DESKS[0].id, 'Ops', undefined, 'Ada'), 'object'); }); diff --git a/tests/maps.test.ts b/tests/maps.test.ts index a1aa0d5c5..e7eb74662 100644 --- a/tests/maps.test.ts +++ b/tests/maps.test.ts @@ -23,6 +23,14 @@ test('every built-in map places every seat the office has, by the same ids', () } }); +test('a saved map from before the System Agent station still gets one in the center aisle', () => { + const { system: _system, ...oldStations } = CASTLE.stations; + const plan = planMap({ ...CASTLE, stations: oldStations }); + const system = plan.stations.find((s) => s.id === 'station-system'); + assert.ok(system); + assert.deepEqual([system.x, system.z], [0, 0]); +}); + test('in the castle every worker can walk from its seat to the door and to the front of the line', () => { const plan = planOf('castle'); const nav = new NavGrid(plan.bounds, plan.obstacles!); diff --git a/tests/server-dispatch.test.ts b/tests/server-dispatch.test.ts index 2331c41fc..daa8c9a78 100644 --- a/tests/server-dispatch.test.ts +++ b/tests/server-dispatch.test.ts @@ -459,6 +459,13 @@ test('settings, accounts, sign-ins and the boards answer as before', async () => assert.match(await told('🧱'), /^🧱 Eve walled the back office back up, and Desk \d+ and Desk \d+ went with it$/); a.send({ t: 'floor.projectsDir', dir: 'relative/dir' }); await warned('Use a full path, like ~/Workspace'); + const localDir = path.join(tmp, 'local-project'); + mkdirSync(localDir); + a.send({ t: 'floor.localAdd', dir: localDir }); + const localAdded = await a.take('floor.localAdded', (m) => m.dir === localDir); + assert.ok(localAdded.floor); + assert.equal(office.floors().find((f) => f.id === localAdded.floor)?.def.dir, localDir); + assert.match(await told('🛗'), /New local project: local-project, added by Eve/); a.send({ t: 'floor.remove', floor: 'nope' }); assert.match((await a.take('toast', (m) => m.level === 'warn')).text, /./); a.send({ t: 'floor.go', floor: office.floors()[0].id }); diff --git a/tests/stations.test.ts b/tests/stations.test.ts index 7c5f47fd4..1ee13f7c4 100644 --- a/tests/stations.test.ts +++ b/tests/stations.test.ts @@ -45,6 +45,15 @@ test('the issues and PR agents keep their jobs, and may still be asked for somet } }); +test('the System Agent answers from roadmap docs without queueing or editing the project', () => { + const brief = stationBrief('system'); + assert.match(brief, /System Agent/); + assert.match(brief, /docs\/roadmap\//); + assert.match(brief, /cite the relevant file path and heading/); + assert.match(brief, /Do not edit project files or queue implementation work/); + assert.doesNotMatch(brief, /office-queue|Send to/); +}); + test('the queue agent is launched without the file-editing tools', () => { assert.deepEqual(QUEUE_AGENT_DISALLOWED_TOOLS, ['Edit', 'Write', 'NotebookEdit']); });