diff --git a/docs/code-layout.md b/docs/code-layout.md index d76e3d42a..e31c2aebd 100644 --- a/docs/code-layout.md +++ b/docs/code-layout.md @@ -57,7 +57,21 @@ They're in `core/registry.ts`, and each is a field of `ctx`. Every registration - **`providers/`** holds one adapter per agent CLI (see [Adding an agent provider](#adding-an-agent-provider)). - **`tunnel/`** is `agent-office tunnel`, the one part that runs on someone's own computer instead of the office's: it asks the office for the workers' web servers (`/api/services`, in `http/routes/services.ts`) and listens on each one's port there. It imports nothing from the office but what the two say to each other (`tunnel/wire.ts`) and the cookie's name. -The rest of `src/server/` is a module per service or per thing a floor keeps (`dog.ts`, `jukebox.ts`, `queue.ts`, `meetings.ts`), made by the office or by each `Floor` (`floor.ts`). +- **`plugins/`** is the plugin system: `registry.ts` keeps the manifests, `manager.ts` loads and asks them (`entry.ts` finds where a plugin lives, `plugin-files.ts` finds the files it ships, such as its settings page), and `types.ts` is what a plugin may contribute. A floor provider, an issues provider, a services provider and an agent provider each plug in there, and the office knows nothing about any particular plugin. + +The rest of `src/server/` is a module per service or per thing a floor keeps (`dog.ts`, `jukebox.ts`, `queue.ts`, `meetings.ts`), made by the office or by each `Floor` (`floor.ts`). `building-store.ts` reads and writes `floors.json`, and `plugin-floors.ts` merges the floors a plugin provides into the building's own. + +## Plugin seams + +A plugin contributes through `plugins/types.ts`, and the office holds the seam; it never holds the plugin's logic. + +- **An agent provider** (`agentProvider`): `registerAgentProvider` puts it in the provider table, so `agentProviders()` hires it and it is no longer rejected as an unknown one. Its adapter and `hooksAs` say how it runs and reports. +- **A session page**: a provider hook may report `web_url` (or `embed_url`) for the session it started; the office keeps it as `WorkerInfo.sessionUrl` and the worker's terminal opens it as a tab. What is at the address is the provider's business. +- **Floors** (`floorProvider.listFloors`): merged into the building with `plugin-floors.ts`, never swapped in. A floor stays the same one when it is the same checkout, or the id its plugin keeps for it. +- **Services** (`servicesProvider`): `createLateServices` publishes them beside the ones the office's own port scan finds, on their own timer, so a plugin's rows come and go as it starts and stops things. +- **An issues board** (`issuesAdapter`): `github.ts` uses a plugin's rows when it has any, and asks `gh` when it has none, so a project the plugin does not cover still shows its issues and the board's Refresh still means something. +- **A settings page**: a plugin ships a file of its own and names it in its manifest (`settingsPage`). The office serves it under `/plugins//` — only ever from inside the plugin's own directory — and shows a button for it on the plugin's card in ⚙️ Settings. +- **A settings page's own reads and writes**: `/api/plugins//projects` hands the floor's projects to the plugin's `projectsProvider` (GET) and takes a list back for it to keep (POST). A page that needs to keep anything else is a file of the plugin's own, served beside it under the same prefix. ## Shared diff --git a/docs/maps.md b/docs/maps.md index de2254c09..def05ddc2 100644 --- a/docs/maps.md +++ b/docs/maps.md @@ -71,7 +71,7 @@ The station works the same way. This one has a quieter crew: no First Officer, a } ``` -To change the lists (move a pillar, resize the hall and everything in it), start from a copy of the whole map instead: [`docs/maps/castle.json`](maps/castle.json) is the castle, as a map of your own called *My castle*, and [`docs/maps/station.json`](maps/station.json) the station, as *My station*. Copy one into the folder and it's in Settings; change what you like from there. +To change the lists (move a pillar, resize the hall and everything in it), start from a copy of the whole map instead: [`docs/maps/castle.json`](maps/castle.json) is the castle, as a map of your own called *My castle*, [`docs/maps/station.json`](maps/station.json) the station, as *My station*, and [`docs/maps/office.json`](maps/office.json) the office, as *Office (10 Meeting Rooms)*. Copy one into the folder and it's in Settings; change what you like from there. Or write one from nothing. This is about the least a map can be: a hall, a door, tables to seat 32, the board agents, a meeting table and the four boards. Everything else (the throne, the line, the herald, the props) is optional: diff --git a/docs/maps/office.json b/docs/maps/office.json new file mode 100644 index 000000000..7f3fa5133 --- /dev/null +++ b/docs/maps/office.json @@ -0,0 +1,987 @@ +{ + "id": "office-meeting-rooms", + "name": "Office (10 Meeting Rooms)", + "icon": "🏢", + "description": "Office floor with north corridor opening and 10 meeting rooms (5 on each side).", + "style": "office", + "hall": { + "width": 40, + "length": 80, + "height": 6.8 + }, + "door": { + "x": -17.55, + "z": 6.5 + }, + "ladder": false, + "poles": false, + "openings": [ + { + "wall": "north", + "u": 11.9, + "width": 3, + "y0": 0, + "y1": 2.8 + } + ], + "closedWindows": [ + { "wall": "west", "u": -9 } + ], + "hiddenBoards": ["issues"], + "machineMonitor": { + "x": -18, + "y": 2.2, + "z": -4.2 + }, + "indoorAreas": [ + { + "minX": 4.8, + "maxX": 18.8, + "minZ": -38.5, + "maxZ": -12.9 + } + ], + "stations": { + "issues": { + "x": -15.6, + "z": -11.7, + "rotY": 3.1416 + }, + "queue": { + "x": -7.8, + "z": -11.7, + "rotY": 3.1416 + }, + "pulls": { + "x": 0, + "z": -11.7, + "rotY": 3.1416 + } + }, + "council": { + "x": 13.7, + "z": 10.5, + "rotY": 0 + }, + "boards": { + "issues": { + "x": -11.7, + "y": 2.1, + "z": -12.92, + "rotY": 0, + "width": 6, + "height": 3, + "label": "Issues" + }, + "queue": { + "x": -3.9, + "y": 2.1, + "z": -12.92, + "rotY": 0, + "width": 6, + "height": 3, + "label": "📋 Task queue" + }, + "pulls": { + "x": 3.9, + "y": 2.1, + "z": -12.92, + "rotY": 0, + "width": 6, + "height": 3, + "label": "Pull Requests" + }, + "services": { + "x": 17.92, + "y": 2.1, + "z": -8.2, + "rotY": -1.5708, + "width": 6, + "height": 3, + "label": "🌐 Services" + } + }, + "props": [ + { + "kind": "custom_board", + "boardId": "jira", + "name": "🎯 Jira", + "x": -11.7, + "y": 2.1, + "z": -12.82, + "rotY": 0, + "width": 6, + "height": 3 + }, + { + "kind": "sign", + "name": "🤝 Salles de réunion", + "x": 11.9, + "y": 3.1, + "z": -12.85, + "rotY": 0 + }, + { + "kind": "expand_sign", + "x": 15.475, + "y": 3.6, + "z": -12.87 + }, + { + "kind": "gong", + "x": 15.475, + "z": -12.17, + "rotY": 0 + }, + { + "kind": "floor", + "x": 11.9, + "z": -25.5, + "width": 3, + "length": 25 + }, + { + "kind": "floor", + "x": 7.7, + "z": -25.5, + "width": 5.4, + "length": 25 + }, + { + "kind": "floor", + "x": 16.1, + "z": -25.5, + "width": 5.4, + "length": 25 + }, + { + "kind": "ceiling", + "x": 11.9, + "z": -25.5, + "y": 3.2, + "width": 3, + "length": 25 + }, + { + "kind": "ceiling", + "x": 7.7, + "z": -25.5, + "y": 3.2, + "width": 5.4, + "length": 25 + }, + { + "kind": "ceiling", + "x": 16.1, + "z": -25.5, + "y": 3.2, + "width": 5.4, + "length": 25 + }, + { + "kind": "wall", + "x": 4.85, + "z": -25.5, + "width": 0.3, + "length": 25, + "height": 3.2 + }, + { + "kind": "wall", + "x": 18.95, + "z": -25.5, + "width": 0.3, + "length": 25, + "height": 3.2 + }, + { + "kind": "wall", + "x": 11.9, + "z": -38, + "width": 0.3, + "length": 14.4, + "height": 3.2, + "rotY": 1.5708 + }, + { + "kind": "wall", + "x": 7.7, + "z": -18, + "width": 0.3, + "length": 5.4, + "height": 3.2, + "rotY": 1.5708 + }, + { + "kind": "wall", + "x": 16.1, + "z": -18, + "width": 0.3, + "length": 5.4, + "height": 3.2, + "rotY": 1.5708 + }, + { + "kind": "wall", + "x": 7.7, + "z": -23, + "width": 0.3, + "length": 5.4, + "height": 3.2, + "rotY": 1.5708 + }, + { + "kind": "wall", + "x": 16.1, + "z": -23, + "width": 0.3, + "length": 5.4, + "height": 3.2, + "rotY": 1.5708 + }, + { + "kind": "wall", + "x": 7.7, + "z": -28, + "width": 0.3, + "length": 5.4, + "height": 3.2, + "rotY": 1.5708 + }, + { + "kind": "wall", + "x": 16.1, + "z": -28, + "width": 0.3, + "length": 5.4, + "height": 3.2, + "rotY": 1.5708 + }, + { + "kind": "wall", + "x": 7.7, + "z": -33, + "width": 0.3, + "length": 5.4, + "height": 3.2, + "rotY": 1.5708 + }, + { + "kind": "wall", + "x": 16.1, + "z": -33, + "width": 0.3, + "length": 5.4, + "height": 3.2, + "rotY": 1.5708 + }, + { + "kind": "wall", + "x": 10.4, + "z": -13.925, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 10.4, + "z": -17.075, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 10.4, + "z": -15.5, + "y": 2.25, + "width": 0.3, + "length": 1.3, + "height": 0.9500000000000002 + }, + { + "kind": "wall", + "x": 13.4, + "z": -13.925, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 13.4, + "z": -17.075, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 13.4, + "z": -15.5, + "y": 2.25, + "width": 0.3, + "length": 1.3, + "height": 0.9500000000000002 + }, + { + "kind": "door", + "x": 10.4, + "z": -15.5, + "width": 1.3, + "height": 2.25, + "rotY": 1.5708, + "name": "Meeting Room 1" + }, + { + "kind": "door", + "x": 13.4, + "z": -15.5, + "width": 1.3, + "height": 2.25, + "rotY": -1.5708, + "name": "Meeting Room 2" + }, + { + "kind": "meeting_sign", + "x": 10.58, + "y": 1.45, + "z": -14.5, + "rotY": 1.5708, + "width": 0.55, + "height": 0.88 + }, + { + "kind": "meeting_sign", + "x": 13.22, + "y": 1.45, + "z": -14.5, + "rotY": -1.5708, + "width": 0.55, + "height": 0.88 + }, + { + "kind": "meeting_table", + "x": 7.7, + "z": -15.5, + "width": 2.4, + "length": 1.3, + "rotY": 0, + "name": "Room 1 Table" + }, + { + "kind": "meeting_table", + "x": 16.1, + "z": -15.5, + "width": 2.4, + "length": 1.3, + "rotY": 0, + "name": "Room 2 Table" + }, + { + "kind": "meeting_board", + "x": 5.05, + "y": 1.9, + "z": -15.5, + "width": 2.4, + "height": 1.2, + "rotY": 1.5708 + }, + { + "kind": "meeting_board", + "x": 18.75, + "y": 1.9, + "z": -15.5, + "width": 2.4, + "height": 1.2, + "rotY": -1.5708 + }, + { + "kind": "lamp", + "x": 11.9, + "y": 3, + "z": -15.5, + "light": true + }, + { + "kind": "lamp", + "x": 7.7, + "y": 2.6, + "z": -15.5 + }, + { + "kind": "lamp", + "x": 16.1, + "y": 2.6, + "z": -15.5 + }, + { + "kind": "wall", + "x": 10.4, + "z": -18.925, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 10.4, + "z": -22.075, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 10.4, + "z": -20.5, + "y": 2.25, + "width": 0.3, + "length": 1.3, + "height": 0.9500000000000002 + }, + { + "kind": "wall", + "x": 13.4, + "z": -18.925, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 13.4, + "z": -22.075, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 13.4, + "z": -20.5, + "y": 2.25, + "width": 0.3, + "length": 1.3, + "height": 0.9500000000000002 + }, + { + "kind": "door", + "x": 10.4, + "z": -20.5, + "width": 1.3, + "height": 2.25, + "rotY": 1.5708, + "name": "Meeting Room 3" + }, + { + "kind": "door", + "x": 13.4, + "z": -20.5, + "width": 1.3, + "height": 2.25, + "rotY": -1.5708, + "name": "Meeting Room 4" + }, + { + "kind": "meeting_sign", + "x": 10.58, + "y": 1.45, + "z": -19.5, + "rotY": 1.5708, + "width": 0.55, + "height": 0.88 + }, + { + "kind": "meeting_sign", + "x": 13.22, + "y": 1.45, + "z": -19.5, + "rotY": -1.5708, + "width": 0.55, + "height": 0.88 + }, + { + "kind": "meeting_table", + "x": 7.7, + "z": -20.5, + "width": 2.4, + "length": 1.3, + "rotY": 0, + "name": "Room 3 Table" + }, + { + "kind": "meeting_table", + "x": 16.1, + "z": -20.5, + "width": 2.4, + "length": 1.3, + "rotY": 0, + "name": "Room 4 Table" + }, + { + "kind": "meeting_board", + "x": 5.05, + "y": 1.9, + "z": -20.5, + "width": 2.4, + "height": 1.2, + "rotY": 1.5708 + }, + { + "kind": "meeting_board", + "x": 18.75, + "y": 1.9, + "z": -20.5, + "width": 2.4, + "height": 1.2, + "rotY": -1.5708 + }, + { + "kind": "lamp", + "x": 11.9, + "y": 3, + "z": -20.5, + "light": true + }, + { + "kind": "lamp", + "x": 7.7, + "y": 2.6, + "z": -20.5 + }, + { + "kind": "lamp", + "x": 16.1, + "y": 2.6, + "z": -20.5 + }, + { + "kind": "wall", + "x": 10.4, + "z": -23.925, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 10.4, + "z": -27.075, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 10.4, + "z": -25.5, + "y": 2.25, + "width": 0.3, + "length": 1.3, + "height": 0.9500000000000002 + }, + { + "kind": "wall", + "x": 13.4, + "z": -23.925, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 13.4, + "z": -27.075, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 13.4, + "z": -25.5, + "y": 2.25, + "width": 0.3, + "length": 1.3, + "height": 0.9500000000000002 + }, + { + "kind": "door", + "x": 10.4, + "z": -25.5, + "width": 1.3, + "height": 2.25, + "rotY": 1.5708, + "name": "Meeting Room 5" + }, + { + "kind": "door", + "x": 13.4, + "z": -25.5, + "width": 1.3, + "height": 2.25, + "rotY": -1.5708, + "name": "Meeting Room 6" + }, + { + "kind": "meeting_sign", + "x": 10.58, + "y": 1.45, + "z": -24.5, + "rotY": 1.5708, + "width": 0.55, + "height": 0.88 + }, + { + "kind": "meeting_sign", + "x": 13.22, + "y": 1.45, + "z": -24.5, + "rotY": -1.5708, + "width": 0.55, + "height": 0.88 + }, + { + "kind": "meeting_table", + "x": 7.7, + "z": -25.5, + "width": 2.4, + "length": 1.3, + "rotY": 0, + "name": "Room 5 Table" + }, + { + "kind": "meeting_table", + "x": 16.1, + "z": -25.5, + "width": 2.4, + "length": 1.3, + "rotY": 0, + "name": "Room 6 Table" + }, + { + "kind": "meeting_board", + "x": 5.05, + "y": 1.9, + "z": -25.5, + "width": 2.4, + "height": 1.2, + "rotY": 1.5708 + }, + { + "kind": "meeting_board", + "x": 18.75, + "y": 1.9, + "z": -25.5, + "width": 2.4, + "height": 1.2, + "rotY": -1.5708 + }, + { + "kind": "lamp", + "x": 11.9, + "y": 3, + "z": -25.5, + "light": true + }, + { + "kind": "lamp", + "x": 7.7, + "y": 2.6, + "z": -25.5 + }, + { + "kind": "lamp", + "x": 16.1, + "y": 2.6, + "z": -25.5 + }, + { + "kind": "wall", + "x": 10.4, + "z": -28.925, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 10.4, + "z": -32.075, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 10.4, + "z": -30.5, + "y": 2.25, + "width": 0.3, + "length": 1.3, + "height": 0.9500000000000002 + }, + { + "kind": "wall", + "x": 13.4, + "z": -28.925, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 13.4, + "z": -32.075, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 13.4, + "z": -30.5, + "y": 2.25, + "width": 0.3, + "length": 1.3, + "height": 0.9500000000000002 + }, + { + "kind": "door", + "x": 10.4, + "z": -30.5, + "width": 1.3, + "height": 2.25, + "rotY": 1.5708, + "name": "Meeting Room 7" + }, + { + "kind": "door", + "x": 13.4, + "z": -30.5, + "width": 1.3, + "height": 2.25, + "rotY": -1.5708, + "name": "Meeting Room 8" + }, + { + "kind": "meeting_sign", + "x": 10.58, + "y": 1.45, + "z": -29.5, + "rotY": 1.5708, + "width": 0.55, + "height": 0.88 + }, + { + "kind": "meeting_sign", + "x": 13.22, + "y": 1.45, + "z": -29.5, + "rotY": -1.5708, + "width": 0.55, + "height": 0.88 + }, + { + "kind": "meeting_table", + "x": 7.7, + "z": -30.5, + "width": 2.4, + "length": 1.3, + "rotY": 0, + "name": "Room 7 Table" + }, + { + "kind": "meeting_table", + "x": 16.1, + "z": -30.5, + "width": 2.4, + "length": 1.3, + "rotY": 0, + "name": "Room 8 Table" + }, + { + "kind": "meeting_board", + "x": 5.05, + "y": 1.9, + "z": -30.5, + "width": 2.4, + "height": 1.2, + "rotY": 1.5708 + }, + { + "kind": "meeting_board", + "x": 18.75, + "y": 1.9, + "z": -30.5, + "width": 2.4, + "height": 1.2, + "rotY": -1.5708 + }, + { + "kind": "lamp", + "x": 11.9, + "y": 3, + "z": -30.5, + "light": true + }, + { + "kind": "lamp", + "x": 7.7, + "y": 2.6, + "z": -30.5 + }, + { + "kind": "lamp", + "x": 16.1, + "y": 2.6, + "z": -30.5 + }, + { + "kind": "wall", + "x": 10.4, + "z": -33.925, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 10.4, + "z": -37.075, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 10.4, + "z": -35.5, + "y": 2.25, + "width": 0.3, + "length": 1.3, + "height": 0.9500000000000002 + }, + { + "kind": "wall", + "x": 13.4, + "z": -33.925, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 13.4, + "z": -37.075, + "width": 0.3, + "length": 1.85, + "height": 3.2 + }, + { + "kind": "wall", + "x": 13.4, + "z": -35.5, + "y": 2.25, + "width": 0.3, + "length": 1.3, + "height": 0.9500000000000002 + }, + { + "kind": "door", + "x": 10.4, + "z": -35.5, + "width": 1.3, + "height": 2.25, + "rotY": 1.5708, + "name": "Meeting Room 9" + }, + { + "kind": "door", + "x": 13.4, + "z": -35.5, + "width": 1.3, + "height": 2.25, + "rotY": -1.5708, + "name": "Meeting Room 10" + }, + { + "kind": "meeting_sign", + "x": 10.58, + "y": 1.45, + "z": -34.5, + "rotY": 1.5708, + "width": 0.55, + "height": 0.88 + }, + { + "kind": "meeting_sign", + "x": 13.22, + "y": 1.45, + "z": -34.5, + "rotY": -1.5708, + "width": 0.55, + "height": 0.88 + }, + { + "kind": "meeting_table", + "x": 7.7, + "z": -35.5, + "width": 2.4, + "length": 1.3, + "rotY": 0, + "name": "Room 9 Table" + }, + { + "kind": "meeting_table", + "x": 16.1, + "z": -35.5, + "width": 2.4, + "length": 1.3, + "rotY": 0, + "name": "Room 10 Table" + }, + { + "kind": "meeting_board", + "x": 5.05, + "y": 1.9, + "z": -35.5, + "width": 2.4, + "height": 1.2, + "rotY": 1.5708 + }, + { + "kind": "meeting_board", + "x": 18.75, + "y": 1.9, + "z": -35.5, + "width": 2.4, + "height": 1.2, + "rotY": -1.5708 + }, + { + "kind": "lamp", "x": 11.9, + "y": 3, + "z": -35.5, + "light": true + }, + { + "kind": "lamp", + "x": 7.7, + "y": 2.6, + "z": -35.5 + }, + { + "kind": "lamp", + "x": 16.1, + "y": 2.6, + "z": -35.5 + } + ], + "agents": { + "outfit": "none", + "ageMinutes": 0 + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e3f643997..be27234f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1946,9 +1946,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1966,9 +1963,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1986,9 +1980,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2006,9 +1997,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2026,9 +2014,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2046,9 +2031,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4333,9 +4315,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4357,9 +4336,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4381,9 +4357,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4405,9 +4378,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/client/core/maps.ts b/src/client/core/maps.ts index b9adbe085..b86dfa3a7 100644 --- a/src/client/core/maps.ts +++ b/src/client/core/maps.ts @@ -31,14 +31,19 @@ export function installMaps(ctx: Ctx, core: CoreState, parts: MapsParts) { // A brand-new floor can arrive before the elevator's list says what color it is. store.on('floors', paintFloor); + let lastAppliedPlanId = ''; + /** * The building changed maps (or you arrived and it's not the office): the old world goes, the new * one's put up, every worker sits down in its seat there, and you come in where it has you arrive. */ function applyMap() { const { worlds, views, boards, travel } = parts; - const next = worlds.worldFor(store.plan()); - if (next.world === worlds.world()) return; + const currentPlan = store.plan(); + const planStamp = `${currentPlan.id}:${JSON.stringify(currentPlan.config?.props ?? [])}:${JSON.stringify(currentPlan.config?.openings ?? [])}:${JSON.stringify(currentPlan.config?.closedWindows ?? [])}:${JSON.stringify(currentPlan.config?.machineMonitor ?? {})}:${JSON.stringify(currentPlan.config?.hiddenBoards ?? [])}`; + if (worlds.world().plan.id === currentPlan.id && lastAppliedPlanId === planStamp) return; + lastAppliedPlanId = planStamp; + const next = worlds.worldFor(currentPlan); // Everyone gets up from the old map's seats; they sit down in the new one's below. for (const [id, v] of views.workerViews) { worlds.court()?.release(id); @@ -68,11 +73,13 @@ export function installMaps(ctx: Ctx, core: CoreState, parts: MapsParts) { const world = worlds.world(); world.group.visible = !core.upTop; if (!core.upTop) player.colliders = world.colliders; - player.room = { ...plan().bounds, ...world.room }; + player.room = { ...plan().bounds, ...world.room, indoorAreas: plan().config?.indoorAreas }; if (!core.upTop && !inOffice()) player.street = streetOf(world); sky.setIndoors(world.room.enclosed); // What you hear: the office's fridge, or the hall's own windows and gong. sound.setHall(world.acoustics ? { bounds: plan().bounds, ...world.acoustics } : null); + sound.setIndoorAreas(plan().config?.indoorAreas ?? []); + sky.setIndoorAreas(plan().config?.indoorAreas ?? []); // The office's own: the holiday decorations round it and the street, the dog, the jukebox. holiday.group.visible = inOffice() && !core.upTop; parts.dog.root.visible = inOffice() && !!store.dog; @@ -133,5 +140,8 @@ export function installMaps(ctx: Ctx, core: CoreState, parts: MapsParts) { $('project').title = elsewhere ? `${elsewhere} worker${elsewhere === 1 ? '' : 's'} on other floors waiting on someone — click to go there` : 'Floors: go to another project'; } + // Initial apply of current plan so custom map overrides take effect immediately + setTimeout(applyMap, 0); + return { paintFloor, applyMap, offTheRoof, noticeWaiting }; } diff --git a/src/client/core/place.ts b/src/client/core/place.ts index 19c6f81cc..5c0323f1e 100644 --- a/src/client/core/place.ts +++ b/src/client/core/place.ts @@ -34,7 +34,8 @@ export function installPlace(ctx: Ctx, core: CoreState, parts: Pick WALL_HEIGHT) return false; - return (p.x > FLOOR.minX && p.x < FLOOR.maxX && p.z > FLOOR.minZ && p.z < FLOOR.maxZ) || inWing(p.x, p.z, parts.worlds.officeWing()); + const inAreas = plan().config?.indoorAreas?.some((b) => p.x >= b.minX && p.x <= b.maxX && p.z >= b.minZ && p.z <= b.maxZ); + return (p.x > FLOOR.minX && p.x < FLOOR.maxX && p.z > FLOOR.minZ && p.z < FLOOR.maxZ) || inWing(p.x, p.z, parts.worlds.officeWing()) || !!inAreas; } /** On your feet at `at`, facing `rotY` and looking straight ahead. */ diff --git a/src/client/core/worlds.ts b/src/client/core/worlds.ts index 7c762c104..5c89d4368 100644 --- a/src/client/core/worlds.ts +++ b/src/client/core/worlds.ts @@ -29,8 +29,8 @@ export function createWorlds(ctx: Ctx) { const { office, scene } = ctx; const theOffice = officeWorld(office, () => office.stack.state.index > 0, () => officeWing()); let world: World = theOffice; - /** Whether the building's on the office's own map, with everything that has (the elevator, the balcony, the lounge…). */ - const inOffice = () => world === theOffice; + /** Whether the building's on an office-style map (the elevator, the balcony, the lounge…). */ + const inOffice = () => world.plan.style === 'office'; /** Where everything is on the building's map: its seats by id, and places to sit. */ const plan = (): MapPlan => world.plan; /** On a castle-style map: its workers walking between their seats and the line for the throne. */ @@ -44,7 +44,17 @@ export function createWorlds(ctx: Ctx) { /** The world for `p`: the office, or the one its style's builder puts up for it, the first time it's wanted. */ function worldFor(p: MapPlan): MapWorld { - if (p.style === 'office') return { world: theOffice, court: null, idle: officeIdle }; + if (p.style === 'office') { + theOffice.plan = p; + office.walls.updateOpenings(p.config?.openings ?? [], p); + office.mapProps.updateProps(p.config?.props ?? []); + office.hiddenBoards.updateHidden(p.config?.hiddenBoards ?? []); + if (p.config?.machineMonitor) { + office.machineMonitor.move(p.config.machineMonitor); + } + office.stack.set(office.stack.state, p); + return { world: theOffice, court: null, idle: officeIdle }; + } let b = built.get(p.id); // A map of your own was edited since: it's built again. if (b && b.plan !== p) { diff --git a/src/client/features/boards/index.ts b/src/client/features/boards/index.ts index 49f80548d..fd568c470 100644 --- a/src/client/features/boards/index.ts +++ b/src/client/features/boards/index.ts @@ -14,7 +14,8 @@ import type { BoardActions } from '../../ui/github/prompts'; import { clip } from '../../ui/dom'; import { openIssue } from '../../ui/pull'; import { openServices } from '../../ui/services'; -import { BoardTexture, QueueBoardTexture, ServicesBoardTexture } from './world'; +import { openCustomBoard } from '../../ui/custom-board'; +import { BoardTexture, CustomBoardTexture, QueueBoardTexture, ServicesBoardTexture } from './world'; import { MachineTexture } from './machine'; import { MeetingBoardTexture, MeetingSignTexture } from './meeting'; import type { World } from '../../world/world'; @@ -26,6 +27,7 @@ declare module '../../world/types' { pulls: true; services: true; queue: true; + customboard: true; } } @@ -53,6 +55,7 @@ export function installBoards(ctx: Ctx, deps: BoardsDeps) { const mat = mesh.material as THREE.MeshBasicMaterial; if (mat.map === texture) return; mat.map = texture; + mat.color.set('#ffffff'); mat.needsUpdate = true; } /** Issues whose cards someone on this floor is carrying around, so they're missing from the board. */ @@ -132,14 +135,85 @@ export function installBoards(ctx: Ctx, deps: BoardsDeps) { }, use: onE(() => deps.showQueue()), }); + ctx.interactions.define('customboard', { + reach: 9, + hint: (it) => { + const bId = (it as any)?.boardId || 'jira'; + const b = store.customBoards.get(bId); + const icon = b?.def?.icon ?? '🎯'; + const label = b?.def?.label ?? 'Board'; + return boardHint(`${icon} ${label} board`); + }, + use: onE((it) => { + const bId = (it as any)?.boardId || 'jira'; + openCustomBoard(bId, ctx.net, deps.boardActions()); + }), + }); + const customBoardTextures = new Map(); + function getCustomTexture(boardId: string): CustomBoardTexture { + let t = customBoardTextures.get(boardId); + if (!t) { + t = new CustomBoardTexture(); + customBoardTextures.set(boardId, t); + } + return t; + } + const renderCustomBoards = () => { + for (const [boardId, st] of store.customBoards) { + const tex = getCustomTexture(boardId); + tex.render(st); + const mp = ctx.world().mapProps ?? office.mapProps; + if (mp) { + const meshes = mp.customBoards?.get(boardId) ?? []; + for (const m of meshes) showOn(m, tex.texture); + } + } + }; + store.on('boards', renderCustomBoards); + // The machine monitor on the west wall. const machineTex = new MachineTexture(); mountBoard(office.machineScreen, machineTex.texture, () => machineTex.render(store.machine), ['machine']); + // The meeting room: its output as it's written on the back wall, and how it's going on the door. const meetingBoardTex = new MeetingBoardTexture(); - mountBoard(office.meetingBoard, meetingBoardTex.texture, () => meetingBoardTex.render(store.meeting), ['meeting']); + const renderMeetingBoard = () => { + meetingBoardTex.render(store.meeting); + const mp = ctx.world().mapProps ?? office.mapProps; + if (mp) { + for (const board of mp.boards) showOn(board, meetingBoardTex.texture); + } + }; + mountBoard(office.meetingBoard, meetingBoardTex.texture, renderMeetingBoard, ['meeting']); + const meetingSignTex = new MeetingSignTexture(); - mountBoard(office.meetingSign, meetingSignTex.texture, () => meetingSignTex.render(store.meeting), ['meeting']); + const renderMeetingSign = () => { + meetingSignTex.render(store.meeting); + const mp = ctx.world().mapProps ?? office.mapProps; + if (mp) { + for (const sign of mp.signs) showOn(sign, meetingSignTex.texture); + } + }; + mountBoard(office.meetingSign, meetingSignTex.texture, renderMeetingSign, ['meeting']); + + const dressMapProps = () => { + const mp = ctx.world().mapProps ?? office.mapProps; + if (!mp) return; + for (const sign of mp.signs) showOn(sign, meetingSignTex.texture); + for (const board of mp.boards) showOn(board, meetingBoardTex.texture); + for (const [boardId, st] of store.customBoards) { + const tex = getCustomTexture(boardId); + tex.render(st); + const meshes = mp.customBoards?.get(boardId) ?? []; + for (const m of meshes) showOn(m, tex.texture); + } + }; + + if (office.mapProps) { + office.mapProps.onUpdate = dressMapProps; + dressMapProps(); + } + /** Puts every board's texture up on `w`'s boards. */ function dressBoards(w: World) { showOn(w.boardMeshes.issues, issuesTex.texture); @@ -148,6 +222,15 @@ export function installBoards(ctx: Ctx, deps: BoardsDeps) { showOn(w.boardMeshes.queue, queueTex.texture); if (w.meetingBoard) showOn(w.meetingBoard, meetingBoardTex.texture); if (w.meetingSign) showOn(w.meetingSign, meetingSignTex.texture); + if (w.mapProps) { + for (const sign of w.mapProps.signs) showOn(sign, meetingSignTex.texture); + for (const board of w.mapProps.boards) showOn(board, meetingBoardTex.texture); + for (const [boardId, meshes] of w.mapProps.customBoards) { + const tex = getCustomTexture(boardId); + tex.render(store.customBoards.get(boardId)); + for (const m of meshes) showOn(m, tex.texture); + } + } } return { issuesTex, renderPullsBoard, renderServicesBoard, renderQueueBoard, dressBoards, cardMoved }; diff --git a/src/client/features/boards/world.ts b/src/client/features/boards/world.ts index 7da11f4fa..06563223b 100644 --- a/src/client/features/boards/world.ts +++ b/src/client/features/boards/world.ts @@ -1,9 +1,9 @@ import * as THREE from 'three'; -import type { GhIssue, GhPull, GhState, QueueState, QueueTask, ServiceInfo, WorkerInfo } from '../../../shared/protocol'; +import type { CustomBoardState, GhIssue, GhPull, GhState, QueueState, QueueTask, ServiceInfo, WorkerInfo } from '../../../shared/protocol'; import { store, workerForPull } from '../../state'; -export const NOTE_COLORS = ['#fff7b0', '#ffd6e0', '#caffbf', '#bde0fe', '#ffe5b4']; -export const PINS = ['#ef476f', '#118ab2', '#06d6a0', '#ffd166']; +import { NOTE_COLORS, PINS, postItTint, itemNoteColor } from '../../ui/boards-colors.js'; +export { NOTE_COLORS, PINS, postItTint, itemNoteColor }; export function wrap(ctx: CanvasRenderingContext2D, text: string, maxW: number, maxLines: number): string[] { const words = text.split(/\s+/); @@ -138,7 +138,7 @@ export class BoardTexture { g.fillStyle = lifted ? 'rgba(0,0,0,.32)' : 'rgba(0,0,0,.25)'; g.fillRect(-nw / 2 + (lifted ? 12 : 5), -nh / 2 + (lifted ? 16 : 7), nw, nh); const draft = this.kind === 'pulls' && (it as GhPull).isDraft; - g.fillStyle = draft ? '#e9ecef' : NOTE_COLORS[it.number % NOTE_COLORS.length]; + g.fillStyle = draft ? '#e9ecef' : itemNoteColor(it); g.fillRect(-nw / 2, -nh / 2, nw, nh); if (lifted) { g.lineWidth = 6; @@ -399,3 +399,104 @@ function clip(g: CanvasRenderingContext2D, text: string, maxW: number): string { while (s.length > 1 && g.measureText(`${s}…`).width > maxW) s = s.slice(0, -1); return `${s}…`; } + +/** Cork board texture for generic custom boards (e.g. Jira). */ +export class CustomBoardTexture { + readonly texture: THREE.CanvasTexture; + private canvas = document.createElement('canvas'); + private ctx: CanvasRenderingContext2D; + + constructor() { + this.canvas.width = 1200; + this.canvas.height = 600; + this.ctx = this.canvas.getContext('2d')!; + this.texture = new THREE.CanvasTexture(this.canvas); + this.texture.colorSpace = THREE.SRGBColorSpace; + this.texture.anisotropy = 8; + } + + render(st?: CustomBoardState) { + const g = this.ctx; + const W = this.canvas.width; + const H = this.canvas.height; + g.fillStyle = '#d8a86a'; + g.fillRect(0, 0, W, H); + let seed = 7; + const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647); + for (let i = 0; i < 1400; i++) { + g.fillStyle = rnd() > 0.5 ? 'rgba(120,70,30,.18)' : 'rgba(255,240,210,.18)'; + g.fillRect(rnd() * W, rnd() * H, 3, 3); + } + + const openCards = (st?.cards || []).filter((c) => c.state !== 'CLOSED'); + if (!openCards.length) { + const note = st?.error ? `⚠️ ${st.error}` : st?.loading ? 'Loading…' : `${st?.def?.icon || '🎯'} ${st?.def?.label || 'Board'} · No open items 🎉`; + g.font = '800 36px Nunito, ui-rounded, system-ui, sans-serif'; + const lines = wrap(g, note.replace(/`/g, ''), 760, 4); + const boxH = 60 + lines.length * 50; + g.fillStyle = '#fffaf3'; + g.fillRect(W / 2 - 420, H / 2 - boxH / 2, 840, boxH); + g.fillStyle = '#2b2d42'; + g.textAlign = 'center'; + g.textBaseline = 'middle'; + lines.forEach((line, i) => g.fillText(line, W / 2, H / 2 - ((lines.length - 1) * 50) / 2 + i * 50)); + g.textAlign = 'left'; + g.textBaseline = 'alphabetic'; + this.texture.needsUpdate = true; + return; + } + + const n = Math.min(openCards.length, 15); + const cols = n <= 2 ? n : n <= 4 ? 2 : n <= 6 ? 3 : n <= 8 ? 4 : 5; + const rows = Math.min(3, Math.ceil(n / cols)); + const scale = Math.min(2, Math.max(1, 3 / Math.max(cols, rows * 1.3))); + const nw = Math.min(208 * scale, (W - 40) / cols - 30); + const nh = Math.min(164 * scale, (H - 40) / rows - 30); + const gx = (W - cols * nw) / (cols + 1); + const gy = (H - rows * nh) / (rows + 1); + + openCards.slice(0, cols * rows).forEach((it, i) => { + const c = i % cols; + const r = Math.floor(i / cols); + const x = gx + c * (nw + gx); + const y = gy + r * (nh + gy); + const tilt = ((i * 37) % 7 - 3) * 0.012; + + g.save(); + g.translate(x + nw / 2, y + nh / 2); + g.rotate(tilt); + g.fillStyle = 'rgba(0,0,0,.25)'; + g.fillRect(-nw / 2 + 5, -nh / 2 + 7, nw, nh); + + const bg = NOTE_COLORS[i % NOTE_COLORS.length]; + g.fillStyle = bg; + g.fillRect(-nw / 2, -nh / 2, nw, nh); + + g.fillStyle = ['#ef476f', '#118ab2', '#06d6a0', '#ffd166'][i % 4]; + g.beginPath(); + g.arc(0, -nh / 2 + 12, 6, 0, Math.PI * 2); + g.fill(); + + g.fillStyle = '#2b2d42'; + g.font = '800 18px Nunito, sans-serif'; + g.fillText(it.id, -nw / 2 + 12, -nh / 2 + 32); + + g.font = '600 15px Nunito, sans-serif'; + const titleLines = wrap(g, it.title, nw - 24, 3); + titleLines.forEach((tl, idx) => { + g.fillText(tl, -nw / 2 + 12, -nh / 2 + 54 + idx * 18); + }); + + const bottomText = it.assignees?.[0] ? `👤 ${it.assignees[0]}` : it.type || ''; + if (bottomText) { + g.font = '700 13px Nunito, sans-serif'; + g.fillStyle = '#6b7280'; + g.fillText(bottomText, -nw / 2 + 12, nh / 2 - 12); + } + + g.restore(); + }); + + this.texture.needsUpdate = true; + } +} diff --git a/src/client/features/carrying/card.ts b/src/client/features/carrying/card.ts index f05027c0f..ed59522e2 100644 --- a/src/client/features/carrying/card.ts +++ b/src/client/features/carrying/card.ts @@ -1,6 +1,6 @@ import * as THREE from 'three'; import type { CarriedIssue } from '../../../shared/protocol'; -import { NOTE_COLORS, PINS, wrap } from '../boards/world'; +import { NOTE_COLORS, PINS, postItTint, wrap } from '../boards/world'; import { toon, toonUnique } from '../../world/toon'; const W = 320; @@ -16,7 +16,7 @@ function issueCard(card: CarriedIssue, width: number): THREE.Mesh { canvas.width = W; canvas.height = H; const g = canvas.getContext('2d')!; - const color = NOTE_COLORS[card.issue % NOTE_COLORS.length]; + const color = (card as any).color ? postItTint((card as any).color) : NOTE_COLORS[card.issue % NOTE_COLORS.length]; g.fillStyle = color; g.fillRect(0, 0, W, H); g.fillStyle = '#2b2d42'; diff --git a/src/client/features/gong/world.ts b/src/client/features/gong/world.ts index 49a35abed..9b02b86eb 100644 --- a/src/client/features/gong/world.ts +++ b/src/client/features/gong/world.ts @@ -22,6 +22,7 @@ export interface Gong { /** Where confetti bursts from when there's no desk to burst over: just above the frame. */ readonly top: THREE.Vector3; update(dt: number): void; + moveTo(at: { x: number; y?: number; z: number; rotY?: number }): void; } /** The gong, where the office has it, or at (x, z) facing `rotY` (0 is +z, the office's way), on a floor `y` up. */ @@ -110,7 +111,9 @@ export function buildGong(at: { x: number; y?: number; z: number; rotY?: number group, colliders, interactable, - top: new THREE.Vector3(x + ahead.x * 0.3, y + height + 0.4, z + ahead.z * 0.3), + get top() { + return new THREE.Vector3(group.position.x + ahead.x * 0.3, group.position.y + height + 0.4, group.position.z + ahead.z * 0.3); + }, strike(strength = 1) { // Hit from the front, it swings back towards the wall first. swing = Math.min(0.3, swing * 0.5 + 0.16 * strength); @@ -119,6 +122,26 @@ export function buildGong(at: { x: number; y?: number; z: number; rotY?: number waveT = 0; waveSize = 1 + strength; }, + moveTo(pos) { + const px = pos.x; + const py = pos.y ?? 0; + const pz = pos.z; + const rot = pos.rotY ?? 0; + group.position.set(px, py, pz); + group.rotation.y = rot; + ahead.x = Math.sin(rot); + ahead.z = Math.cos(rot); + interactable.x = px + ahead.x * 1.3; + interactable.y = py ? py : undefined; + interactable.z = pz + ahead.z * 1.3; + const [cMinX, cMaxX, cMinZ, cMaxZ] = rot ? boxFootprint(px, pz, width + 0.42, 0.6, rot) : [px - half - 0.12, px + half + 0.3, pz - 0.3, pz + 0.3]; + colliders[0].minX = cMinX; + colliders[0].maxX = cMaxX; + colliders[0].minZ = cMinZ; + colliders[0].maxZ = cMaxZ; + colliders[0].top = py + height + 0.1; + colliders[0].bottom = py || undefined; + }, update(dt) { if (swing > 0.001) { phase += dt * Math.PI * 2 * 0.85; @@ -154,7 +177,8 @@ declare module '../../world/types' { /** The gong, just past the elevator from the PR board. */ export const gong: Fixture<'gong'> = (site) => { - const built = buildGong(); - site.wall('north', GONG.x, (GONG.height + 0.3) / 2, GONG.width + 1.2, GONG.height + 0.3); + const at = site.gongAt ?? GONG; + const built = buildGong(at); + site.wall('north', at.x, (GONG.height + 0.3) / 2, GONG.width + 1.2, GONG.height + 0.3); return { group: built.group, colliders: built.colliders, interactables: [built.interactable], update: (_t, dt) => built.update(dt), handle: { gong: built } }; }; diff --git a/src/client/features/hud/index.ts b/src/client/features/hud/index.ts index a54730173..ff9a49c48 100644 --- a/src/client/features/hud/index.ts +++ b/src/client/features/hud/index.ts @@ -18,6 +18,7 @@ import { toggleFloorMenu } from '../../ui/floormenu'; import { openHelp } from '../../ui/hud'; import { mountHud } from '../../ui/menu'; import { openServices } from '../../ui/services'; +import { openCustomBoard } from '../../ui/custom-board'; import { openSettings, type SettingsPane } from '../../ui/settings'; import { needsSigningIn, openSignIns } from '../../ui/signins'; import { openTeam } from '../../ui/team'; @@ -125,6 +126,21 @@ export function installHud(ctx: Ctx, core: CoreState, parts: HudParts) { ], settings, () => saveSettings(settings), + () => { + const list = []; + for (const [boardId, b] of store.customBoards) { + list.push({ + id: `board-${boardId}`, + icon: b.def.icon || '🎯', + label: b.def.label || boardId, + section: 'Open' as const, + count: () => b.cards?.filter((c) => c.state !== 'CLOSED').length ?? 0, + title: () => b.def.description ?? `${b.def.label} board`, + run: () => openCustomBoard(boardId, net, actions.boardActions()), + }); + } + return list; + }, ); ctx.keys.bind({ code: 'Tab', diff --git a/src/client/features/walking/index.ts b/src/client/features/walking/index.ts index 162e79a4c..7fd38c08c 100644 --- a/src/client/features/walking/index.ts +++ b/src/client/features/walking/index.ts @@ -78,7 +78,7 @@ export function installWalking(ctx: Ctx, core: CoreState, parts: Pick ({ x, z }))); + player.walkPath(plan().id === 'office' ? wayTo(player.pos, at, officeWing()) : ctx.world().nav.route([player.pos.x, player.pos.z], [at.x, at.z]).slice(1).map(([x, z]) => ({ x, z }))); } ctx.ticks.add('steer', ({ now }) => walkTick(now)); @@ -117,7 +117,7 @@ export function installWalking(ctx: Ctx, core: CoreState, parts: Pick ({ x, z }))); + player.walkPath(plan().id === 'office' ? wayTo(player.pos, to, officeWing()) : ctx.world().nav.route([player.pos.x, player.pos.z], [to.x, to.z]).slice(1).map(([x, z]) => ({ x, z }))); } function errandEnd(why: 'arrived' | 'cancelled' | 'stuck') { diff --git a/src/client/input/pointer.ts b/src/client/input/pointer.ts index 8be56e74d..44bc09b80 100644 --- a/src/client/input/pointer.ts +++ b/src/client/input/pointer.ts @@ -55,7 +55,7 @@ export function installPointer(ctx: Ctx, core: CoreState, parts: PointerParts) { function usable(): (readonly Interactable[])[] { const roof = parts.rooftop.roof(); if (core.upTop && roof) return [roof.interactables]; - return inOffice() ? [office.interactables, ...ctx.usables.lists()] : [ctx.world().interactables, parts.worlds.court()?.interactables ?? []]; + return [ctx.world().interactables, ...ctx.usables.lists(), parts.worlds.court()?.interactables ?? []]; } /** `note` is the issue note you're pointing at on the issues board, if any (see aimedNote). */ @@ -89,7 +89,8 @@ export function installPointer(ctx: Ctx, core: CoreState, parts: PointerParts) { eye.set(player.pos.x, player.pos.y + EYE_HEIGHT, player.pos.z); // (Workers standing in line in the castle carry their spot's interactable: see Court.) const roof = parts.rooftop.roof(); - for (const hit of raycaster.intersectObjects(core.upTop && roof ? roof.pickables : inOffice() ? [office.group, ...ctx.usables.pickables()] : ctx.world().pickables, true)) { + const pick = core.upTop && roof ? roof.pickables : [...ctx.world().pickables, ...ctx.usables.pickables()]; + for (const hit of raycaster.intersectObjects(pick, true)) { let it: Interactable | undefined; let shown = true; for (let o: THREE.Object3D | null = hit.object; o; o = o.parent) { diff --git a/src/client/lite.ts b/src/client/lite.ts index 49aaf6f0e..1ad0a0159 100644 --- a/src/client/lite.ts +++ b/src/client/lite.ts @@ -17,6 +17,7 @@ import { openTerminal, openTerminalFor, routeTerminalMessage } from './ui/termin import { openChanges, openChangesFor, routeChangesMessage } from './ui/changes'; import { lostWorktreeDialog, openPrompt, routeWorktreeMessage, sendHomeDialog } from './ui/prompt'; import { openBoard } from './ui/boards'; +import { openCustomBoard } from './ui/custom-board'; import type { BoardActions } from './ui/github/prompts'; import { openPull, routePullMessage } from './ui/pull'; import { openQueue } from './ui/queue'; @@ -323,14 +324,41 @@ $('btn-queue').addEventListener('click', () => openQueue(net, { openTerminal: op $('btn-new').addEventListener('click', () => sendToWorker('✨ New task')); function renderNav() { - const count = (id: string, n: number) => ($(id).querySelector('.n')!.textContent = n ? String(n) : ''); + const count = (id: string, n: number) => { + const el = document.getElementById(id); + if (el) el.querySelector('.n')!.textContent = n ? String(n) : ''; + }; count('btn-issues', store.issues.items.filter((i) => i.state === 'OPEN').length); count('btn-pulls', store.pulls.items.filter((p) => p.state === 'OPEN').length); count('btn-queue', store.queue.tasks.filter((t) => t.status !== 'done').length); + + const nav = document.querySelector('.lite-nav'); + if (nav) { + const newBtn = $('btn-new'); + for (const [boardId, b] of store.customBoards) { + const btnId = `btn-board-${boardId}`; + let btn = document.getElementById(btnId); + const uncompleted = b.cards?.filter((c) => c.state !== 'CLOSED').length ?? 0; + if (!btn) { + btn = h( + 'button.btn', + { id: btnId, type: 'button', onclick: () => openCustomBoard(boardId, net, boardActions()) }, + `${b.def.icon || '🎯'} ${b.def.label || boardId} `, + h('span.n', {}, uncompleted ? String(uncompleted) : ''), + ); + if (newBtn) nav.insertBefore(btn, newBtn); + else nav.append(btn); + } else { + const nEl = btn.querySelector('.n'); + if (nEl) nEl.textContent = uncompleted ? String(uncompleted) : ''; + } + } + } } store.on('issues', renderNav); store.on('pulls', renderNav); store.on('queue', renderNav); +store.on('boards', renderNav); // ---- What you have open, for the others (see PeerInfo.doing) ----------------------------------- let doingSent: string | undefined; diff --git a/src/client/net.ts b/src/client/net.ts index 4cfab43ac..de7db7a52 100644 --- a/src/client/net.ts +++ b/src/client/net.ts @@ -26,8 +26,11 @@ export class Net { private lite = false, ) {} - onMessage(h: Handler) { + onMessage(h: Handler): () => void { this.handlers.push(h); + return () => { + this.handlers = this.handlers.filter((fn) => fn !== h); + }; } onStatus(h: (up: boolean) => void) { diff --git a/src/client/player/camera.ts b/src/client/player/camera.ts index a02eb3830..16acb422d 100644 --- a/src/client/player/camera.ts +++ b/src/client/player/camera.ts @@ -19,6 +19,7 @@ export interface Room { wall: number; enclosed: boolean; vault?: { minX: number; maxX: number; minZ: number; maxZ: number; top: number }; + indoorAreas?: { minX: number; maxX: number; minZ: number; maxZ: number }[]; } /** What the camera follows you by: where you are and look, and the room you're in (see PlayerController). */ @@ -65,12 +66,16 @@ export function aimCamera(camera: THREE.PerspectiveCamera, p: Followed, bob: num const under = p.pos.x > R.minX && p.pos.x < R.maxX && p.pos.z > R.minZ && p.pos.z < R.maxZ; // In the office's back office, between its walls, and out through where the north wall was into the room. const back = !R.enclosed && p.pos.y > -SLAB - 0.5 && inWing(p.pos.x, p.pos.z, p.wing); - const indoors = ((rigged || p.pos.y > -SLAB - 0.5) && under) || back; + const inArea = !R.enclosed && p.pos.y > -SLAB - 0.5 && R.indoorAreas?.find((a) => p.pos.x >= a.minX && p.pos.x <= a.maxX && p.pos.z >= a.minZ && p.pos.z <= a.maxZ); + const indoors = ((rigged || p.pos.y > -SLAB - 0.5) && under) || back || !!inArea; // Down in a room under the floor (the castle's dungeon): the camera keeps inside that. const V = R.vault; if (V && p.pos.y < V.top - 0.5 && p.pos.x > V.minX && p.pos.x < V.maxX && p.pos.z > V.minZ && p.pos.z < V.maxZ) { cam.x = THREE.MathUtils.clamp(cam.x, V.minX + m, V.maxX - m); cam.z = THREE.MathUtils.clamp(cam.z, V.minZ + m, V.maxZ - m); + } else if (inArea) { + cam.x = THREE.MathUtils.clamp(cam.x, inArea.minX + m, inArea.maxX - m); + cam.z = THREE.MathUtils.clamp(cam.z, inArea.minZ + m, FLOOR.maxZ - m); } else if (back) { cam.x = THREE.MathUtils.clamp(cam.x, WING.minX + m, WING.maxX - m); cam.z = THREE.MathUtils.clamp(cam.z, wingMinZ(p.wing) + m, FLOOR.maxZ - m); diff --git a/src/client/sound/core.ts b/src/client/sound/core.ts index ada7ecdc0..e23333799 100644 --- a/src/client/sound/core.ts +++ b/src/client/sound/core.ts @@ -183,6 +183,9 @@ export class AudioCore { for (const tick of this.tickers) tick(now); } + /** Custom indoor areas from active map plan. */ + indoorAreas: { minX: number; maxX: number; minZ: number; maxZ: number }[] = []; + /** Where your ears are: in the office, where rain is muffled by the glass, in the garage, or out in it. */ where(): 'office' | 'garage' | 'out' { const { x, y, z } = this.listener; @@ -190,8 +193,9 @@ export class AudioCore { const b = this.hall.bounds; return x > b.minX && x < b.maxX && z > b.minZ && z < b.maxZ ? 'office' : 'out'; } + const inAreas = this.indoorAreas.some((b) => x >= b.minX && x <= b.maxX && z >= b.minZ && z <= b.maxZ); const under = (m: number) => x > FLOOR.minX - m && x < FLOOR.maxX + m && z > FLOOR.minZ - m && z < FLOOR.maxZ + m; - if ((under(0) || inWing(x, z, this.wing)) && y > -0.5) return 'office'; + if ((under(0) || inWing(x, z, this.wing) || inAreas) && y > -0.5) return 'office'; return under(0.3) ? 'garage' : 'out'; } diff --git a/src/client/sound/index.ts b/src/client/sound/index.ts index 321c2ea74..e358a5abc 100644 --- a/src/client/sound/index.ts +++ b/src/client/sound/index.ts @@ -127,6 +127,10 @@ export class OfficeSound { this.a.hall = hall; } + setIndoorAreas(areas: { minX: number; maxX: number; minZ: number; maxZ: number }[]) { + this.a.indoorAreas = areas; + } + /** Up on the roof (true), or inside on a floor: the office's hum gives way to the wind and the city. */ setOutdoors(on: boolean) { this.a.setOutdoors(on); diff --git a/src/client/state/slices/boards.ts b/src/client/state/slices/boards.ts new file mode 100644 index 000000000..cfbd8b6bf --- /dev/null +++ b/src/client/state/slices/boards.ts @@ -0,0 +1,33 @@ +import type { CustomBoardState } from '../../../shared/protocol'; +import type { Slice } from '../store'; + +declare module '../store' { + interface Store { + /** Custom boards registered on the floor. */ + customBoards: Map; + } + interface Topics { + boards: true; + } +} + +export const boards: Slice = { + init(s) { + s.customBoards = new Map(); + }, + on: { + 'board.state'(s, m) { + s.customBoards.set(m.boardId, m.state); + return ['boards']; + }, + }, + enter(s, v) { + s.customBoards.clear(); + if (v.boards) { + for (const [id, st] of Object.entries(v.boards)) { + s.customBoards.set(id, st); + } + } + return ['boards']; + }, +}; diff --git a/src/client/state/slices/index.ts b/src/client/state/slices/index.ts index 06bc121ea..dca2a23d5 100644 --- a/src/client/state/slices/index.ts +++ b/src/client/state/slices/index.ts @@ -32,6 +32,8 @@ import { theme } from './theme'; import { upgrade } from './upgrade'; import { usage } from './usage'; import { whiteboard } from './whiteboard'; +import { plugins } from './plugins'; +import { boards } from './boards'; export const SLICES: readonly Slice[] = [ presence, @@ -61,4 +63,6 @@ export const SLICES: readonly Slice[] = [ team, accounts, signins, + plugins, + boards, ]; diff --git a/src/client/state/slices/plugins.ts b/src/client/state/slices/plugins.ts new file mode 100644 index 000000000..badb213bc --- /dev/null +++ b/src/client/state/slices/plugins.ts @@ -0,0 +1,42 @@ +import type { PluginsState } from '../../../shared/protocol'; +import { registerAgentProvider } from '../../../shared/providers'; +import type { Slice } from '../store'; + +declare module '../store' { + interface Store { + /** Installed plugins in the office. */ + plugins: PluginsState; + } + interface Topics { + plugins: true; + } +} + +function syncPluginProviders(state?: PluginsState) { + if (!state?.plugins) return; + for (const p of state.plugins) { + if (p.agentProviderMeta && (p.status === 'active' || p.installed)) { + registerAgentProvider(p.agentProviderMeta.name?.toLowerCase() || p.id, p.agentProviderMeta); + } + } +} + +export const plugins: Slice = { + init(s) { + s.plugins = { plugins: [] }; + }, + on: { + welcome(s, m) { + if (m.plugins) { + s.plugins = m.plugins; + syncPluginProviders(m.plugins); + } + return ['plugins']; + }, + plugins(s, m) { + s.plugins = m.state; + syncPluginProviders(m.state); + return ['plugins']; + }, + }, +}; diff --git a/src/client/styles/base.css b/src/client/styles/base.css index c39824a75..8856ce521 100644 --- a/src/client/styles/base.css +++ b/src/client/styles/base.css @@ -78,8 +78,8 @@ button { font-family: inherit; } .modal .body { padding: 16px; overflow: auto; } .modal footer { display: flex; gap: 8px; justify-content: flex-end; padding: 12px 16px; border-top: 3px solid var(--ink); background: var(--paper-2); align-items: center; } .modal footer .grow { flex: 1; font-size: 12px; color: var(--muted); font-weight: 700; } -.modal textarea, .modal input[type=text], .modal input[type=password] { width: 100%; font: inherit; font-size: 15px; border: 3px solid var(--ink); border-radius: 12px; padding: 10px 12px; background: #fff; outline: none; resize: vertical; } -.modal textarea:focus, .modal input[type=text]:focus, .modal input[type=password]:focus { border-color: var(--accent); } +.modal textarea, .modal input[type=text], .modal input[type=url], .modal input[type=password] { width: 100%; font: inherit; font-size: 15px; border: 3px solid var(--ink); border-radius: 12px; padding: 10px 12px; background: #fff; outline: none; resize: vertical; } +.modal textarea:focus, .modal input[type=text]:focus, .modal input[type=url]:focus, .modal input[type=password]:focus { border-color: var(--accent); } .modal label { font-weight: 800; font-size: 14px; display: block; margin-bottom: 6px; } .close { width: 34px; height: 34px; padding: 0; justify-content: center; } /* The ✕ openModal gives a window without a header of its own. */ diff --git a/src/client/ui/boards-colors.ts b/src/client/ui/boards-colors.ts new file mode 100644 index 000000000..e8ad3c508 --- /dev/null +++ b/src/client/ui/boards-colors.ts @@ -0,0 +1,19 @@ +import type { GhIssue, GhPull } from '../../shared/protocol.js'; + +export const NOTE_COLORS = ['#fff7b0', '#ffd6e0', '#caffbf', '#bde0fe', '#ffe5b4']; +export const PINS = ['#ef476f', '#118ab2', '#06d6a0', '#ffd166']; +export const TILTS = ['-1.2deg', '0.8deg', '-0.4deg', '1.4deg', '0deg', '-0.9deg']; + +export function postItTint(color: string): string { + const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color); + if (!m) return color; + const pr = Math.round(parseInt(m[1], 16) * 0.35 + 255 * 0.65); + const pg = Math.round(parseInt(m[2], 16) * 0.35 + 255 * 0.65); + const pb = Math.round(parseInt(m[3], 16) * 0.35 + 255 * 0.65); + return `rgb(${pr}, ${pg}, ${pb})`; +} + +export function itemNoteColor(it: GhIssue | GhPull): string { + const c = (it as any).color || (it.labels?.length ? it.labels[0].color : undefined); + return c ? postItTint(c) : NOTE_COLORS[it.number % NOTE_COLORS.length]; +} diff --git a/src/client/ui/boards.css b/src/client/ui/boards.css index de610b8d7..01da6fb8d 100644 --- a/src/client/ui/boards.css +++ b/src/client/ui/boards.css @@ -1,7 +1,7 @@ /* Boards */ -.modal.board { width: min(1400px, 100%); height: min(860px, calc(100vh - 32px)); } -.modal.board .body { flex: 1; display: flex; gap: 12px; padding: 12px; background: #d8a86a; background-image: radial-gradient(rgba(0,0,0,.08) 1px, transparent 1.5px); background-size: 9px 9px; } -.column { flex: 1; min-width: 220px; display: flex; flex-direction: column; background: rgba(255, 250, 243, .55); border: 3px solid var(--ink); border-radius: 14px; overflow: hidden; } +.modal.board { width: min(1500px, 98vw); height: min(860px, calc(100vh - 32px)); } +.modal.board .body { flex: 1; display: flex; flex-direction: row; overflow-x: auto; overflow-y: hidden; gap: 12px; padding: 12px; background: #d8a86a; background-image: radial-gradient(rgba(0,0,0,.08) 1px, transparent 1.5px); background-size: 9px 9px; } +.column { flex: 1; min-width: 260px; max-width: 340px; flex-shrink: 0; display: flex; flex-direction: column; background: rgba(255, 250, 243, .55); border: 3px solid var(--ink); border-radius: 14px; overflow: hidden; } .column h4 { margin: 0; font-size: 14px; font-weight: 900; background: var(--paper); border-bottom: 3px solid var(--ink); } /* Click a column's header to filter it by label. */ .col-head { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px 12px; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; } @@ -53,3 +53,27 @@ .qchip { padding: 1px 6px; border-radius: 999px; border: 1.5px solid var(--ink); background: #e0f2fe; color: var(--ink); font-size: 10px; font-weight: 900; } .qchip.running { background: #d8f5e3; } .qchip.done { background: #fff1de; } + +.card-avatar { + width: 18px; + height: 18px; + border-radius: 50%; + vertical-align: middle; + object-fit: cover; + border: 1.5px solid var(--ink); + display: inline-block; + margin-right: 4px; + background: var(--paper); + flex-shrink: 0; +} +.card .meta .person { + display: inline-flex; + align-items: center; +} +.gh-meta .person { + display: inline-flex; + align-items: center; +} +.gh-item-head .card-avatar { + margin-right: 6px; +} diff --git a/src/client/ui/boards.ts b/src/client/ui/boards.ts index 6ca5a8707..460e5d307 100644 --- a/src/client/ui/boards.ts +++ b/src/client/ui/boards.ts @@ -8,10 +8,9 @@ import { labelChip, openLabels } from './github/labels'; import { inProgress } from './github/progress'; import type { BoardActions } from './github/prompts'; import { openPull } from './github/pull-window'; +import { openProjectsSettings } from './github/projects'; import { providerLabel } from './provider'; - -const TILTS = ['-1.2deg', '0.8deg', '-0.4deg', '1.4deg', '0deg', '-0.9deg']; -const NOTE_COLORS = ['#fff7b0', '#ffd6e0', '#caffbf', '#bde0fe', '#ffe5b4']; +import { NOTE_COLORS, TILTS, itemNoteColor } from './boards-colors.js'; interface Column { /** Names the column in your saved label filters. */ @@ -116,11 +115,11 @@ function queueChip(issue: number): Node | '' { return t.pr ? h('span.qchip.done', {}, `🔀 PR #${t.pr.number} · ${provider}`) : ''; } -function card(n: number, title: string, meta: (Node | string)[], i: number, onclick: () => void, onLabels: () => void) { +function card(n: number, title: string, meta: (Node | string)[], i: number, onclick: () => void, onLabels: () => void, bg?: string) { return h( 'li.card', { - style: `--tilt:${TILTS[n % TILTS.length]};background:${NOTE_COLORS[n % NOTE_COLORS.length]};--pin:${['#ef476f', '#118ab2', '#06d6a0', '#ffd166'][i % 4]}`, + style: `--tilt:${TILTS[n % TILTS.length]};background:${bg || NOTE_COLORS[n % NOTE_COLORS.length]};--pin:${['#ef476f', '#118ab2', '#06d6a0', '#ffd166'][i % 4]}`, tabindex: 0, onclick, onkeydown: ((e: KeyboardEvent) => e.key === 'Enter' && e.target === e.currentTarget && onclick()) as EventListener, @@ -134,10 +133,11 @@ function card(n: number, title: string, meta: (Node | string)[], i: number, oncl export function openBoard(kind: 'issues' | 'pulls', net: Net, actions: BoardActions) { const body = h('div.body'); - const status = h('span.board-status'); + const status = h('span.board-status', { style: 'margin-left:auto;' }); const refresh = h('button.btn', { title: 'Refresh from GitHub', onclick: () => net.send({ t: 'gh.refresh' }) }, '🔄 Refresh'); + const settings = h('button.btn', { title: 'Configure GitHub & GitLab projects', onclick: () => openProjectsSettings(net) }, '⚙️ Settings'); const close = h('button.btn.close', { 'aria-label': 'Close' }, '✕'); - const el = h('div.modal.board', { role: 'dialog', 'aria-label': kind === 'issues' ? 'Issues board' : 'Pull requests board' }, h('header', {}, h('h2', {}, kind === 'issues' ? '📌 Issues' : '🔀 Pull Requests'), status, refresh, close), body); + const el = h('div.modal.board', { role: 'dialog', 'aria-label': kind === 'issues' ? 'Issues board' : 'Pull requests board' }, h('header', {}, h('h2', {}, kind === 'issues' ? '📌 Issues' : '🔀 Pull Requests'), status, refresh, settings, close), body); const filters = loadFilters(kind); /** What each column's filter box holds (column key → text), for as long as the board is open. */ @@ -264,7 +264,7 @@ export function openBoard(kind: 'issues' | 'pulls', net: Net, actions: BoardActi for (const col of issueColumns(store.issues.items)) { body.append( column(col, all, (it, i) => - card(it.number, it.title, [...labelChips(it.labels), queueChip(it.number), it.assignees.length ? `👤 ${it.assignees.join(', ')}` : it.taken ? '🤖 handed to a worker' : `by ${it.author}`, it.comments ? `💬 ${it.comments}` : '', timeAgo(it.updatedAt)], i, () => openIssue(it, net, actions), () => openLabels('issue', it, net)), + card(it.number, it.title, [...labelChips(it.labels), queueChip(it.number), it.assignees.length ? `👤 ${it.assignees.join(', ')}` : it.taken ? '🤖 handed to a worker' : `by ${it.author}`, it.comments ? `💬 ${it.comments}` : '', timeAgo(it.updatedAt)], i, () => openIssue(it, net, actions), () => openLabels('issue', it, net), itemNoteColor(it)), ), ); } @@ -289,6 +289,7 @@ export function openBoard(kind: 'issues' | 'pulls', net: Net, actions: BoardActi i, () => openPull(it, net, actions), () => openLabels('pull', it, net), + itemNoteColor(it), ); }), ); diff --git a/src/client/ui/custom-board.ts b/src/client/ui/custom-board.ts new file mode 100644 index 000000000..afab0eefa --- /dev/null +++ b/src/client/ui/custom-board.ts @@ -0,0 +1,573 @@ +import './github/windows.css'; +import './boards.css'; +import type { BoardCard, BoardCardDetail, CustomBoardDef, CustomBoardState } from '../../shared/protocol'; +import type { Net } from '../net'; +import { store } from '../state'; +import { h, openModal, timeAgo, toast } from './dom'; +import { markdown } from './markdown'; +import { providerPicker } from './provider'; +import type { BoardActions } from './github/prompts'; +import { NOTE_COLORS, TILTS } from './boards-colors'; + +export function cardPrompt(it: BoardCard): string { + const parts = [`# Task: [${it.id}] ${it.title}`]; + if (it.url) parts.push(`Link: ${it.url}`); + if (it.type) parts.push(`Type: ${it.type}`); + if (it.priority) parts.push(`Priority: ${it.priority}`); + if (it.parent) parts.push(`Parent: [${it.parent.key || it.parent.id}] ${it.parent.title || ''}`); + if (it.storyPoints !== undefined) parts.push(`Story Points: ${it.storyPoints}`); + if (it.column) parts.push(`Status: ${it.column}`); + if (it.assignees?.length) parts.push(`Assignees: ${it.assignees.join(', ')}`); + else if (it.author) parts.push(`Reporter: ${it.author}`); + if (it.body) parts.push(`\n## Description\n${it.body}`); + return parts.join('\n'); +} + +function isAssignedToUser(card: BoardCard, currentUser?: { displayName?: string; email?: string; name?: string }, configEmail?: string): boolean { + if (!card.assignees?.length) return false; + const myNames: string[] = []; + if (currentUser?.displayName) myNames.push(currentUser.displayName.toLowerCase()); + if (currentUser?.name) myNames.push(currentUser.name.toLowerCase()); + for (const em of [currentUser?.email, configEmail]) { + if (em) { myNames.push(em.toLowerCase(), em.split('@')[0].toLowerCase().replace(/[._-]/g, ' ')); } + } + if (store.profile?.name && store.profile.name !== 'Guest') myNames.push(store.profile.name.toLowerCase()); + return card.assignees.some((assignee) => { + const a = assignee.toLowerCase(); + return myNames.some((n) => a === n || (a.includes(' ') && n.includes(' ') && a.split(' ').every((w) => n.includes(w)))); + }); +} + +export function openCustomBoard(boardId: string, net: Net, actions: BoardActions) { + const currentFloor = store.currentFloor(); + const floorId = store.floor ?? currentFloor?.id ?? ''; + + let filterMine = false; + let cachedConfigEmail = ''; + + fetch(`/api/boards/config?board=${encodeURIComponent(boardId)}&floor=${encodeURIComponent(floorId)}`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (d?.config?.email) { + cachedConfigEmail = String(d.config.email); + if (filterMine) render(); + } + }) + .catch(() => {}); + + const body = h('div.body'); + const status = h('span.board-status', { style: 'margin-left:auto;' }); + const filterMineBtn = h( + 'button.btn', + { + type: 'button', + 'aria-pressed': 'false', + title: 'Filter only tickets assigned to me', + onclick: () => { + filterMine = !filterMine; + filterMineBtn.classList.toggle('primary', filterMine); + filterMineBtn.setAttribute('aria-pressed', String(filterMine)); + render(); + }, + }, + '👤 Assigned to me', + ); + const refresh = h('button.btn', { title: 'Refresh board', onclick: () => doRefresh() }, '🔄 Refresh'); + const settings = h('button.btn', { title: 'Configure board', onclick: () => openBoardConfig(boardId, net, () => doRefresh()) }, '⚙️ Settings'); + const close = h('button.btn.close', { 'aria-label': 'Close' }, '✕'); + const title = h('h2', {}, '🎯 Board'); + + const el = h('div.modal.board', { role: 'dialog', 'aria-label': 'Custom board' }, h('header', {}, title, status, filterMineBtn, refresh, settings, close), body); + + const queries: Record = {}; + + function getState(): CustomBoardState | undefined { + return store.customBoards.get(boardId); + } + + function doRefresh() { + status.textContent = 'Refreshing…'; + if (net.up) { + net.send({ t: 'board.refresh', boardId }); + } else { + fetch(`/api/boards/refresh?board=${encodeURIComponent(boardId)}&floor=${encodeURIComponent(floorId)}`, { method: 'POST' }) + .then((r) => (r.ok ? r.json() : null)) + .then((st) => { + if (st) { + store.customBoards.set(boardId, st); + render(); + } + }) + .catch(() => render()); + } + } + + const render = () => { + const st = getState(); + const def = st?.def ?? { id: boardId, label: 'Board', icon: '🎯' }; + title.textContent = `${def.icon || '🎯'} ${def.label || 'Board'}`; + status.textContent = st?.loading ? 'Refreshing…' : st?.fetchedAt ? `Updated ${timeAgo(st.fetchedAt)}` : ''; + + body.replaceChildren(); + + if (st?.error && !st.cards?.length) { + body.append( + h('div.board-error', {}, `Error loading board: ${st.error}`, h('br'), h('button.btn.primary', { style: 'margin-top:12px;', onclick: () => openBoardConfig(boardId, net, () => doRefresh()) }, '⚙️ Configure Board')), + ); + return; + } + + const allCards = st?.cards ?? []; + if (!allCards.length) { + body.append( + h( + 'div.board-error', + {}, + 'No items found on this board.', + h('br'), + h('small', {}, 'Make sure your board URL and credentials are configured.'), + h('br'), + h('button.btn.primary', { style: 'margin-top:12px;', onclick: () => openBoardConfig(boardId, net, () => doRefresh()) }, '⚙️ Configure Board'), + ), + ); + return; + } + + const cards = filterMine + ? allCards.filter((c) => isAssignedToUser(c, st?.currentUser, cachedConfigEmail)) + : allCards; + + // Determine columns dynamically from st.columns, def.columns or cards + const rawDefCols = st?.columns || st?.def?.columns || []; + const defCols = Array.isArray(rawDefCols) && rawDefCols.length > 0 ? rawDefCols : []; + const cardCols = [...new Set(allCards.map((c) => c.column || (c.state === 'CLOSED' ? 'Done' : 'To Do')))]; + const colNames = defCols.length ? [...defCols, ...cardCols.filter((c) => !defCols.includes(c))] : cardCols; + if (!colNames.length) colNames.push('Items'); + + for (const colName of colNames) { + const colCards = cards.filter((c) => (c.column || (c.state === 'CLOSED' ? 'Done' : 'To Do')) === colName); + const ul = h('ul'); + const count = h('span'); + const search = h('input', { + type: 'text', + value: queries[colName] ?? '', + placeholder: 'Filter by title…', + 'aria-label': `Filter ${colName}`, + spellcheck: 'false', + autocomplete: 'off', + }) as HTMLInputElement; + const clear = h('button.col-search-clear', { type: 'button', 'aria-label': 'Clear', title: 'Clear' }, '✕'); + const section = h('section.column'); + + const fill = () => { + const words = search.value.toLowerCase().split(/\s+/).filter(Boolean); + const matching = words.length ? colCards.filter((it) => words.every((w) => it.title.toLowerCase().includes(w) || it.id.toLowerCase().includes(w))) : colCards; + ul.replaceChildren( + ...matching.map((it, i) => + renderCard(it, i, () => openCustomCard(boardId, it, net, actions)), + ), + ); + if (!matching.length) { + ul.append( + h( + 'li.empty', + {}, + words.length + ? `No cards match “${search.value.trim()}”` + : filterMine + ? 'No tickets assigned to you' + : 'Nothing here', + ), + ); + } + count.textContent = words.length ? `${matching.length} / ${colCards.length}` : String(colCards.length); + clear.classList.toggle('hidden', !search.value); + }; + + search.addEventListener('input', () => { + queries[colName] = search.value; + fill(); + }); + clear.addEventListener('click', () => { + search.value = queries[colName] = ''; + fill(); + search.focus(); + }); + + section.append(h('h4', {}, h('span.col-head', {}, h('span', {}, colName), h('span.col-count', {}, count))), h('div.col-search', {}, search, clear), ul); + fill(); + body.append(section); + } + }; + + const unsubs = [store.on('boards', render)]; + const timer = setInterval(() => { + const st = getState(); + status.textContent = st?.loading ? 'Refreshing…' : st?.fetchedAt ? `Updated ${timeAgo(st.fetchedAt)}` : ''; + }, 15000); + + const modal = openModal(el, { + doing: `at the ${boardId} board`, + onClose: () => { + unsubs.forEach((u) => u()); + clearInterval(timer); + }, + }); + close.addEventListener('click', () => modal.close()); + + render(); + if (!getState()?.fetchedAt) doRefresh(); +} + +function renderCard(it: BoardCard, i: number, onclick: () => void): HTMLElement { + const num = it.number ?? Math.abs(hashCode(it.id)); + const tilt = TILTS[num % TILTS.length]; + const bg = NOTE_COLORS[num % NOTE_COLORS.length]; + const pin = ['#ef476f', '#118ab2', '#06d6a0', '#ffd166'][i % 4]; + + const metaParts: (Node | string)[] = []; + if (it.parent) { + metaParts.push( + h( + 'span.parent-tag', + { + style: 'background:rgba(0,0,0,0.06);border-radius:4px;padding:1px 5px;font-weight:700;', + title: `Parent: ${it.parent.title || it.parent.key || it.parent.id}`, + }, + `🏷️ ${it.parent.title || it.parent.key || it.parent.id}`, + ), + ); + } + if (it.type) metaParts.push(h('span', {}, it.type)); + if (it.priority) metaParts.push(h('span', {}, it.priority)); + if (it.storyPoints !== undefined) { + metaParts.push( + h( + 'span.sp-tag', + { + style: 'background:#f59e0b;color:#fff;border-radius:4px;padding:1px 5px;font-weight:800;font-size:10px;', + title: `${it.storyPoints} Story Points`, + }, + `⚡ ${it.storyPoints} SP`, + ), + ); + } + if (it.assignees?.length) { + const avatarImg = it.assigneeAvatarUrl + ? (h('img.card-avatar', { + src: it.assigneeAvatarUrl, + alt: '', + 'aria-hidden': 'true', + onerror: (e: any) => { + e.target.style.display = 'none'; + }, + }) as HTMLImageElement) + : null; + metaParts.push(h('span.person', {}, avatarImg, it.assignees.join(', '))); + } else if (it.author) { + const avatarImg = it.authorAvatarUrl + ? (h('img.card-avatar', { + src: it.authorAvatarUrl, + alt: '', + 'aria-hidden': 'true', + onerror: (e: any) => { + e.target.style.display = 'none'; + }, + }) as HTMLImageElement) + : null; + metaParts.push(h('span.person', {}, avatarImg, `by ${it.author}`)); + } + if (it.comments) metaParts.push(h('span', {}, `💬 ${it.comments}`)); + if (it.updatedAt) metaParts.push(h('span', {}, timeAgo(it.updatedAt))); + + return h( + 'li.card', + { + style: `--tilt:${tilt};background:${bg};--pin:${pin}`, + tabindex: 0, + onclick, + onkeydown: ((e: KeyboardEvent) => e.key === 'Enter' && e.target === e.currentTarget && onclick()) as EventListener, + }, + h('div.num', {}, it.id), + h('div.ttl', {}, it.title), + h('div.meta', {}, ...metaParts), + ); +} + +function hashCode(str: string): number { + let h = 0; + for (let i = 0; i < str.length; i++) { + h = (Math.imul(31, h) + str.charCodeAt(i)) | 0; + } + return h; +} + +export function openCustomCard(boardId: string, first: BoardCard, net: Net, actions: BoardActions) { + let it = first; + let detail: BoardCardDetail | null = null; + const currentFloor = store.currentFloor(); + const floorId = store.floor ?? currentFloor?.id ?? ''; + + const close = h('button.btn.close', { 'aria-label': 'Close' }, '✕'); + const pill = h('span.pill'); + const meta = h('div.gh-meta'); + const bodyEl = h('div.gh-body'); + const descEl = h('div.gh-col'); + const commentsThread = h('div.gh-items'); + + bodyEl.append(descEl); + + const queueProvider = providerPicker(store.project, `custom-provider-${it.id}`, 'Queue on'); + const addQueueBtn = h('button.btn', { type: 'button' }, '📋 Add to queue') as HTMLButtonElement; + addQueueBtn.addEventListener('click', () => { + if (!queueProvider.valid()) return; + modal.close(); + actions.queue(cardPrompt(it), `[${it.id}] ${it.title}`, it.number ?? 0, queueProvider.value(), queueProvider.model(), queueProvider.effort()); + }); + + const handToWorkerBtn = h('button.btn.primary', { + type: 'button', + onclick: () => { + modal.close(); + actions.assign(cardPrompt(it), `Work on ${it.id}: ${it.title}`, it.number); + }, + }, '🤖 Hand to a worker'); + + const el = h( + 'div.modal.gh-window.issue', + { role: 'dialog', 'aria-label': `${it.id}: ${it.title}` }, + h('header', {}, pill, h('h2', { title: it.title }, `${it.id} ${it.title}`), close), + meta, + bodyEl, + h( + 'footer', + {}, + it.url ? h('a.grow', { href: it.url, target: '_blank', rel: 'noopener noreferrer' }, 'Open external ↗') : h('span.grow'), + h('button.btn', { type: 'button', onclick: () => actions.ask(cardPrompt(it), `Ask about ${it.id}`) }, '✍️ Ask a worker…'), + h('button.btn', { type: 'button', onclick: () => actions.meeting({ pattern: 'debate', title: `${it.id}: ${it.title}`, prompt: cardPrompt(it) }) }, '🤝 Meeting…'), + queueProvider.element, + addQueueBtn, + handToWorkerBtn, + ), + ); + + const renderFrame = () => { + const isOpen = it.state !== 'CLOSED'; + pill.className = `pill ${isOpen ? 'done' : 'offline'}`; + pill.textContent = it.column || (isOpen ? 'open' : 'closed'); + + const assigneeAvatar = it.assigneeAvatarUrl + ? (h('img.card-avatar', { + src: it.assigneeAvatarUrl, + alt: '', + 'aria-hidden': 'true', + onerror: (e: any) => { + e.target.style.display = 'none'; + }, + }) as HTMLImageElement) + : null; + const authorAvatar = it.authorAvatarUrl + ? (h('img.card-avatar', { + src: it.authorAvatarUrl, + alt: '', + 'aria-hidden': 'true', + onerror: (e: any) => { + e.target.style.display = 'none'; + }, + }) as HTMLImageElement) + : null; + + const assignToMeBtn = h( + 'button.btn.small', + { + type: 'button', + style: 'margin-left:6px;padding:1px 8px;font-size:11px;font-weight:700;', + title: 'Assign this ticket to me in Jira', + onclick: async () => { + assignToMeBtn.disabled = true; + assignToMeBtn.textContent = 'Assigning…'; + try { + const res = await fetch( + `/api/boards/assign?board=${encodeURIComponent(boardId)}&id=${encodeURIComponent(it.id)}&floor=${encodeURIComponent(floorId)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assignee: 'me' }), + }, + ); + const data = await res.json(); + if (data.ok && data.card) { + it = { ...it, ...data.card }; + renderFrame(); + toast(`Assigned ${it.id} to you`, 'info'); + } else { + throw new Error(data.error || 'Failed to assign'); + } + } catch (err) { + toast((err as Error).message, 'error'); + } finally { + assignToMeBtn.disabled = false; + assignToMeBtn.textContent = 'Assign to me'; + } + }, + }, + 'Assign to me', + ); + + const metaNodes = [ + h('b', {}, it.id), + it.type ? h('span.label', { style: 'background:#3b82f6;color:#fff;border-radius:4px;padding:1px 6px;' }, it.type) : null, + it.priority ? h('span', {}, `· Priority: ${it.priority}`) : null, + it.storyPoints !== undefined ? h('span.label', { style: 'background:#f59e0b;color:#fff;border-radius:4px;padding:1px 6px;font-weight:800;' }, `⚡ ${it.storyPoints} Story Points`) : null, + it.parent ? h('span.parent-link', {}, '· Parent: ', it.parent.url ? h('a', { href: it.parent.url, target: '_blank', rel: 'noopener noreferrer', style: 'color:#3b82f6;font-weight:700;' }, `[${it.parent.key || it.parent.id}] ${it.parent.title || ''}`) : `[${it.parent.key || it.parent.id}] ${it.parent.title || ''}`) : null, + h('span.person', {}, '· Assignee: ', assigneeAvatar, it.assignees?.length ? it.assignees.join(', ') : 'Unassigned', assignToMeBtn), + it.author ? h('span.person', {}, '· Reporter: ', authorAvatar, it.author) : null, + it.updatedAt ? h('span', {}, `· Updated ${timeAgo(it.updatedAt)}`) : null, + ].filter(Boolean) as Node[]; + + meta.replaceChildren(...metaNodes); + }; + + const renderBody = () => { + descEl.replaceChildren(); + if (it.body) { + descEl.append(markdown(it.body, it.url)); + } else { + descEl.append(h('p.gh-quiet', {}, 'No description provided.')); + } + + if (detail?.commentsList?.length) { + commentsThread.replaceChildren(); + for (const c of detail.commentsList) { + const cAvatar = c.avatarUrl + ? (h('img.card-avatar', { + src: c.avatarUrl, + alt: '', + 'aria-hidden': 'true', + onerror: (e: any) => { + e.target.style.display = 'none'; + }, + }) as HTMLImageElement) + : null; + commentsThread.append( + h( + 'div.gh-item', + {}, + h('div.gh-item-head', {}, cAvatar, h('b', {}, c.author), h('span', {}, `commented ${timeAgo(c.createdAt)}`)), + h('div.gh-item-body', {}, markdown(c.body, it.url)), + ), + ); + } + descEl.append(h('h3', { style: 'margin-top:20px;' }, 'Comments'), commentsThread); + } + }; + + renderFrame(); + renderBody(); + + // Load details / comments + fetch(`/api/boards/card?board=${encodeURIComponent(boardId)}&id=${encodeURIComponent(it.id)}&floor=${encodeURIComponent(floorId)}`) + .then((r) => (r.ok ? r.json() : null)) + .then((d: BoardCardDetail | null) => { + if (d) { + detail = d; + it = { ...it, ...d }; + renderFrame(); + renderBody(); + } + }) + .catch(() => {}); + + const modal = openModal(el, { doing: `viewing ${it.id}` }); + close.addEventListener('click', () => modal.close()); +} + +export function openBoardConfig(boardId: string, net: Net, onSaved?: () => void) { + const currentFloor = store.currentFloor(); + const floorId = store.floor ?? currentFloor?.id ?? ''; + const st = store.customBoards.get(boardId); + const def: CustomBoardDef = st?.def ?? { + id: boardId, + label: 'Board', + icon: '🎯', + configFields: [ + { key: 'boardUrl', label: 'Board URL', type: 'url', placeholder: 'https://...', required: true }, + { key: 'email', label: 'Email / Username', type: 'text', placeholder: 'user@example.com' }, + { key: 'apiToken', label: 'API Token / Secret', type: 'password', placeholder: 'API token' }, + { key: 'jql', label: 'Query / Filter (optional)', type: 'text', placeholder: 'Filter query' }, + ], + }; + + const body = h('div.body'); + const close = h('button.btn.close', { 'aria-label': 'Close' }, '✕'); + const saveBtn = h('button.btn.primary', { type: 'button' }, 'Save & Sync'); + const msgEl = h('p.setting-note', { style: 'margin:8px 0;' }); + + const el = h( + 'div.modal', + { role: 'dialog', 'aria-label': `Configure ${def.label}`, style: 'width:min(600px,95vw);' }, + h('header', {}, h('h2', {}, `⚙️ Configure ${def.label}`), close), + body, + h('footer', {}, msgEl, saveBtn), + ); + + const inputs = new Map(); + const fields = def.configFields || []; + + for (const f of fields) { + const inp = h('input', { + type: f.type || 'text', + placeholder: f.placeholder || '', + 'aria-label': f.label, + style: 'width:100%;margin-top:4px;box-sizing:border-box;', + }) as HTMLInputElement; + inputs.set(f.key, inp); + body.append( + h('div', { style: 'margin-bottom:12px;' }, h('label', { style: 'font-weight:600;font-size:13px;' }, f.label, f.required ? ' *' : ''), inp), + ); + } + + fetch(`/api/boards/config?board=${encodeURIComponent(boardId)}&floor=${encodeURIComponent(floorId)}`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + const cfg = d?.config || {}; + for (const [key, inp] of inputs) { + if (cfg[key]) inp.value = String(cfg[key]); + } + }) + .catch(() => {}); + + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true; + saveBtn.textContent = 'Saving…'; + msgEl.textContent = ''; + + const config: Record = {}; + for (const [key, inp] of inputs) { + config[key] = inp.value.trim(); + } + + try { + const res = await fetch(`/api/boards/config?board=${encodeURIComponent(boardId)}&floor=${encodeURIComponent(floorId)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || 'Failed to save configuration'); + } + toast(`${def.label} configuration saved. Refreshing…`, 'info'); + modal.close(); + onSaved?.(); + } catch (err) { + msgEl.textContent = (err as Error).message; + msgEl.style.color = '#c62828'; + saveBtn.disabled = false; + saveBtn.textContent = 'Save & Sync'; + } + }); + + const modal = openModal(el, { doing: `configuring ${def.label}` }); + close.addEventListener('click', () => modal.close()); +} diff --git a/src/client/ui/elevator.css b/src/client/ui/elevator.css index 8aaea1d78..f14a20b22 100644 --- a/src/client/ui/elevator.css +++ b/src/client/ui/elevator.css @@ -1,7 +1,7 @@ /* ---- The elevator: floors, one per project ---------------------------------------------------- */ .modal.elevator { width: min(640px, 100%); } .elevator .intro { margin: 0 0 12px; font-weight: 700; } -.elevator .floors { display: grid; gap: 8px; } +.elevator .floors { display: grid; gap: 8px; max-height: min(420px, calc(100vh - 280px)); overflow-y: auto; padding-right: 4px; } .elevator .floor-btn { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: center; width: 100%; text-align: left; padding: 9px 12px; font: inherit; color: var(--ink); background: #fff; border: 3px solid var(--ink); border-radius: 14px; box-shadow: 0 3px 0 var(--ink); cursor: pointer; transition: transform .05s; } .elevator .floor-btn:hover:not(:disabled) { background: var(--paper-2); } .elevator .floor-btn:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 1px 0 var(--ink); } diff --git a/src/client/ui/elevator.ts b/src/client/ui/elevator.ts index 36f6c92a9..612b38857 100644 --- a/src/client/ui/elevator.ts +++ b/src/client/ui/elevator.ts @@ -76,7 +76,7 @@ export function openElevator(opts: ElevatorOptions): void { const floorsEl = h('div.floors'); const addEl = h('div.add'); - const input = h('input', { type: 'text', placeholder: 'Search your repositories, or type owner/name', 'aria-label': 'Repository', autocomplete: 'off', spellcheck: 'false' }) as HTMLInputElement; + const input = h('input', { type: 'text', placeholder: 'Search your projects', 'aria-label': 'Project', autocomplete: 'off', spellcheck: 'false' }) as HTMLInputElement; 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'); @@ -114,8 +114,8 @@ export function openElevator(opts: ElevatorOptions): void { net.send({ t: 'floor.repos' }); }; - /** What "Add floor" would add: the row picked, else what's typed if it's owner/name. */ - const choice = (): string | undefined => selected ?? normalizeRepo(filter); + /** What "Add floor" would add: the row picked, else what's typed if it's owner/name or a path. */ + const choice = (): string | undefined => selected ?? (filter.trim().startsWith('/') || filter.trim().startsWith('~') ? filter.trim() : normalizeRepo(filter)); const floorButton = (f: FloorInfo, i: number) => { // Down in the garage, your floor is somewhere to go back up to. @@ -217,7 +217,6 @@ export function openElevator(opts: ElevatorOptions): void { const renderFloors = () => { const floors = store.floors; const built = floors.some((f) => !f.cloning); - // Top floor first, the way an elevator's buttons stack, with the roof over them, floor 1 and then the garage at the bottom. floorsEl.replaceChildren( ...(built ? [roofButton()] : []), ...(floors.length ? floors.map(floorRow).reverse() : [h('p.empty', {}, 'No floors yet.')]), @@ -226,7 +225,7 @@ export function openElevator(opts: ElevatorOptions): void { }; const repoRow = (r: RepoChoice) => { - const floor = store.floors.find((f) => sameRepo(f.repo, r.name)); + const floor = store.floors.find((f) => sameRepo(f.repo, r.name) || sameRepo(f.dir, r.name) || sameRepo(f.name, r.name)); const row = h( 'div.repo', { role: 'option', class: selected && sameRepo(selected, r.name) ? 'sel' : '', 'aria-selected': String(!!selected && sameRepo(selected, r.name)), title: r.description ?? r.name }, @@ -276,12 +275,14 @@ export function openElevator(opts: ElevatorOptions): void { // owner/name that isn't in the list (someone else's public repository): offer it anyway. if (typed && !r.list.some((x) => sameRepo(x.name, typed))) rows.push(repoRow({ name: typed, private: false, description: 'Not in your list — the office will try to clone it' })); rows.push(...matches.slice(0, SHOWN).map(repoRow)); - if (!rows.length) rows.push(h('p.empty', { style: 'padding:10px' }, r.loading ? 'Asking GitHub for your repositories…' : r.error ? '' : q ? 'Nothing matches. Type owner/name to clone any repository.' : 'No repositories.')); + if (!rows.length) rows.push(h('p.empty', { style: 'padding:10px' }, r.loading ? 'Loading projects…' : r.error ? '' : q ? 'Nothing matches.' : 'Every project is already a floor of this building.')); if (matches.length > SHOWN) rows.push(h('p.empty', { style: 'padding:8px 10px' }, `…and ${matches.length - SHOWN} more — type to narrow it down`)); listEl.replaceChildren(...rows); const pick = choice(); - const dest = pick ? `${store.projectsDir.dir}/${pick}` : `${store.projectsDir.dir}//`; - const change = store.me.admin ? h('button.btn.dir-change', { type: 'button', title: 'Clone new projects into another folder on the office’s machine' }, '📁 Change folder') : null; + // A project may be a folder already on disk or a git repository to clone. + const clone = !!pick && !pick.startsWith('/') && !pick.startsWith('~'); + const dest = pick && !clone ? pick : `${store.projectsDir.dir}//`; + const change = clone && store.me.admin ? h('button.btn.dir-change', { type: 'button', title: 'Clone new projects into another folder on the office’s machine' }, '📁 Change folder') : null; change?.addEventListener('click', () => editDir(true)); // While it clones: how far it's got (the office asks GitHub about it first). const on = addingFloor(); @@ -292,25 +293,32 @@ export function openElevator(opts: ElevatorOptions): void { on ? h('p.note', {}, [cloneStep(on.clone), on.clone?.detail].filter(Boolean).join(' · ')) : null, h('p.note', {}, 'You can close this and carry on: everyone hears when the new floor opens.'), ] - : [h('p.note', {}, `Cloned into ${dest} with this machine's gh login. Everything on the new floor works in that checkout.`, change)]; + : [ + clone + ? h('p.note', {}, `Cloned into ${dest} with this machine's gh login. Everything on the new floor works in that checkout.`) + : h('p.note', {}, `${dest} is already on disk. It becomes a floor, and every worker works in it.`), + change, + ]; statusEl.replaceChildren(...lines.filter((l): l is HTMLElement => !!l), ...[r.error, error].filter(Boolean).map((e) => h('p.err', {}, e))); - addBtn.disabled = !!adding || !pick || store.floors.some((f) => sameRepo(f.repo, pick)); - addBtn.textContent = adding ? '⏳ Cloning…' : pick ? `🛗 Add ${pick}` : '🛗 Add floor'; + addBtn.disabled = !!adding || !pick || store.floors.some((f) => sameRepo(f.repo, pick) || sameRepo(f.dir, pick) || sameRepo(f.name, pick)); + addBtn.textContent = adding ? '⏳ Adding…' : pick && !clone ? '🛗 Add this project' : pick ? `🛗 Add ${pick}` : '🛗 Add floor'; input.disabled = !!adding; if (!built) { built = true; + const maxFloors = store.projectsDir.maxFloors ?? 500; addEl.replaceChildren( h('h3', {}, setup && !store.floors.length ? 'Pick your first project' : '➕ Add a project'), h('div.repo-search', {}, input, refreshBtn), listEl, statusEl, + h('p.note.max-floors-info', { style: 'margin-top:6px;font-size:12px;color:var(--muted);' }, `Showing up to ${maxFloors} projects as floors (configured in ⚙️ Settings → Building).`), dirEl, ); } }; /** The floor being added, once the office is cloning it (and after, when it's there). */ - const addingFloor = () => (adding ? store.floors.find((f) => sameRepo(f.repo, adding!)) : undefined); + const addingFloor = () => (adding ? store.floors.find((f) => sameRepo(f.repo, adding!) || sameRepo(f.dir, adding!) || sameRepo(f.name, adding!)) : undefined); const add = (repo: string) => { if (adding) return; @@ -370,7 +378,7 @@ export function openElevator(opts: ElevatorOptions): void { if (e.key !== 'Enter' || e.isComposing) return; e.preventDefault(); const q = filter.trim().toLowerCase(); - const matches = store.repos.list.filter((x) => !store.floors.some((f) => sameRepo(f.repo, x.name)) && (x.name.toLowerCase().includes(q) || (x.description ?? '').toLowerCase().includes(q))); + const matches = store.repos.list.filter((x) => !store.floors.some((f) => sameRepo(f.repo, x.name) || sameRepo(f.dir, x.name) || sameRepo(f.name, x.name)) && (x.name.toLowerCase().includes(q) || (x.description ?? '').toLowerCase().includes(q))); const pick = choice() ?? (q && matches.length === 1 ? matches[0].name : undefined); if (pick) add(pick); }); @@ -398,7 +406,18 @@ 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 · Esc to look around first' + : 'Pick a floor · Esc to stay here', + ), + addBtn, + ), ); 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, { diff --git a/src/client/ui/github/projects.ts b/src/client/ui/github/projects.ts new file mode 100644 index 000000000..9fd4102c1 --- /dev/null +++ b/src/client/ui/github/projects.ts @@ -0,0 +1,162 @@ +import { h, openModal, toast } from '../dom'; +import { store } from '../../state'; +import type { Net } from '../../net'; + +export interface ProjectEntry { + id?: string; + name: string; + url: string; + token?: string; + color?: string; +} + +const DEFAULT_PALETTE = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4', '#ec4899', '#14b8a6']; + +export function openProjectsSettings(net: Net) { + const body = h('div.body'); + const close = h('button.btn.close', { 'aria-label': 'Close' }, '✕'); + const footer = h('footer'); + const el = h( + 'div.modal', + { role: 'dialog', 'aria-label': 'GitHub & GitLab Projects', style: 'width:min(720px,95vw);' }, + h('header', {}, h('h2', {}, '⚙️ GitHub & GitLab Projects'), close), + body, + footer, + ); + + const modal = openModal(el, { doing: 'configuring git projects' }); + close.addEventListener('click', () => modal.close()); + + let projects: ProjectEntry[] = []; + const listEl = h('div.projects-list', { style: 'margin-bottom:16px;' }); + const msgEl = h('p.setting-note', { style: 'margin:8px 0;' }); + + const nameInput = h('input', { type: 'text', placeholder: 'Name (e.g. frontend)', 'aria-label': 'Project name', style: 'flex:1;' }) as HTMLInputElement; + const colorInput = h('input', { + type: 'color', + value: DEFAULT_PALETTE[0], + title: 'Badge color', + 'aria-label': 'Badge color', + style: 'width:36px;height:36px;padding:2px;border:none;border-radius:6px;cursor:pointer;background:transparent;', + }) as HTMLInputElement; + const urlInput = h('input', { type: 'text', placeholder: 'Repository URL (https://github.com/owner/repo)', 'aria-label': 'Repository URL', style: 'flex:2;' }) as HTMLInputElement; + const tokenInput = h('input', { type: 'password', placeholder: 'Token (optional for private repos)', 'aria-label': 'Token', style: 'flex:1;' }) as HTMLInputElement; + const addBtn = h('button.btn.primary', { type: 'button' }, '➕ Add'); + + const addRow = h('div', { style: 'display:flex;gap:8px;align-items:center;margin-top:12px;' }, nameInput, colorInput, urlInput, tokenInput, addBtn); + + const currentFloor = store.currentFloor(); + const floorName = currentFloor?.name ?? store.project?.name ?? 'this project'; + const floorId = store.floor ?? currentFloor?.id ?? ''; + + const saveBtn = h('button.btn.primary', { type: 'button' }, 'Save projects'); + footer.append(h('span.grow', {}, `Configuration saved strictly per project for ${floorName}.`), saveBtn); + + const paint = () => { + listEl.replaceChildren(); + if (!projects.length) { + listEl.append(h('p.setting-note', {}, 'No multi-repository projects configured yet. The board shows issues from the main checkout or configured repositories.')); + return; + } + const table = h('table', { style: 'width:100%;border-collapse:collapse;font-size:13px;' }); + const thead = h('thead', {}, h('tr', {}, + h('th', { style: 'text-align:left;padding:6px 8px;border-bottom:2px solid var(--ink);width:110px;' }, 'Name'), + h('th', { style: 'text-align:center;padding:6px 8px;border-bottom:2px solid var(--ink);width:50px;' }, 'Color'), + h('th', { style: 'text-align:left;padding:6px 8px;border-bottom:2px solid var(--ink);' }, 'Repository URL'), + h('th', { style: 'text-align:left;padding:6px 8px;border-bottom:2px solid var(--ink);width:90px;' }, 'Token'), + h('th', { style: 'padding:6px 8px;border-bottom:2px solid var(--ink);width:50px;' }, ''), + )); + const tbody = h('tbody'); + projects.forEach((p, idx) => { + const curColor = p.color || DEFAULT_PALETTE[idx % DEFAULT_PALETTE.length]; + const badge = h('span.label', { + style: `background:${curColor};color:#fff;padding:2px 8px;border-radius:6px;font-weight:700;display:inline-block;font-size:12px;`, + }, p.name || '—'); + const rowPicker = h('input', { + type: 'color', + value: curColor, + title: 'Change badge color', + style: 'width:28px;height:28px;padding:1px;border:none;border-radius:4px;cursor:pointer;background:transparent;', + }) as HTMLInputElement; + rowPicker.addEventListener('input', () => { + p.color = rowPicker.value; + badge.style.background = rowPicker.value; + }); + const removeBtn = h('button.btn.btn-warn', { type: 'button', style: 'padding:2px 8px;font-size:12px;' }, '🗑'); + removeBtn.addEventListener('click', () => { + projects.splice(idx, 1); + paint(); + }); + const tr = h('tr', { style: 'border-bottom:1px solid rgba(0,0,0,0.1);' }, + h('td', { style: 'padding:6px 8px;' }, badge), + h('td', { style: 'padding:6px 8px;text-align:center;' }, rowPicker), + h('td', { style: 'padding:6px 8px;word-break:break-all;' }, p.url), + h('td', { style: 'padding:6px 8px;' }, p.token ? '••••••••' : h('span', { style: 'color:var(--muted);' }, 'None')), + h('td', { style: 'padding:6px 8px;text-align:right;' }, removeBtn), + ); + tbody.append(tr); + }); + table.append(thead, tbody); + listEl.append(table); + }; + + addBtn.addEventListener('click', () => { + const url = urlInput.value.trim(); + if (!url) return urlInput.focus(); + const name = nameInput.value.trim() || url.split('/').pop()?.replace(/\.git$/, '') || 'repo'; + const token = tokenInput.value.trim() || undefined; + const color = colorInput.value; + projects.push({ id: name, name, url, token, color }); + nameInput.value = ''; + urlInput.value = ''; + tokenInput.value = ''; + colorInput.value = DEFAULT_PALETTE[projects.length % DEFAULT_PALETTE.length]; + paint(); + }); + + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true; + saveBtn.textContent = 'Saving…'; + try { + const res = await fetch(`/api/plugins/git-projects/projects?floor=${encodeURIComponent(floorId)}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ projects }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + msgEl.textContent = data.error || 'Failed to save projects'; + msgEl.style.color = '#c62828'; + return; + } + toast('Projects saved. Refreshing board…', 'info'); + net.send({ t: 'gh.refresh' }); + modal.close(); + } catch (err) { + msgEl.textContent = (err as Error).message; + msgEl.style.color = '#c62828'; + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Save projects'; + } + }); + + body.append( + h('p.setting-note', { style: 'margin:0 0 12px;' }, `Connect multiple repositories for ${floorName} (frontend, backend, BFF...) to synchronize Issues and Pull Requests into this board.`), + listEl, + h('h4', { style: 'margin:12px 0 6px;' }, 'Add repository'), + addRow, + msgEl, + ); + + fetch(`/api/plugins/git-projects/projects?floor=${encodeURIComponent(floorId)}`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (d?.projects && Array.isArray(d.projects)) { + projects = d.projects; + paint(); + } + }) + .catch(() => paint()); + paint(); +} diff --git a/src/client/ui/github/pull-window.ts b/src/client/ui/github/pull-window.ts index fb2bd9c4f..434e8454d 100644 --- a/src/client/ui/github/pull-window.ts +++ b/src/client/ui/github/pull-window.ts @@ -12,7 +12,7 @@ import { checksList, conflicted, mergeStatus, openMerge } from './merge'; import { avatar, commentCard, errorBox, nodes, REVIEW_BADGE, spinnerRow, stateOf } from './pieces'; import { FILES_KEY, mergePref, pref, savePref, TAB_KEY } from './prefs'; import { fixAndMergePrompt, fixConflictsPrompt, pullContext, pullVars, reviewPrompt, type BoardActions } from './prompts'; -import { buildTree, looksGenerated, parseDiff, renderFileDiff, renderThread, repliesOf, Reviewed, STATUS_WORD, treeOrder, type DiffFile, type TreeDir } from './pulldiff'; +import { buildTree, extractSnippetLines, looksGenerated, parseDiff, renderFileDiff, renderSnippet, renderThread, repliesOf, Reviewed, STATUS_WORD, treeOrder, type DiffFile, type TreeDir } from './pulldiff'; // The window behind a card on the PR board. A PR opens on its conversation (description, comments, // reviews, line comments, checks) with a Files tab for the diff, where you tick files off as @@ -144,23 +144,28 @@ export function openPull(first: GhPull, net: Net, actions: BoardActions) { ...d.reviews.map((r) => ({ at: r.createdAt, node: commentCard(r, itemUrl, '', REVIEW_BADGE[r.state ?? ''] ?? [r.state?.toLowerCase() ?? 'reviewed', '']) })), ...d.reviewComments .filter((c) => !c.replyTo) - .map((c) => ({ - at: c.createdAt, - node: h( - 'article.gh-card.gh-thread', - {}, - h( - 'header', + .map((c) => { + const snippetLines = extractSnippetLines(files, c.path, c.line, c.side, c.diffHunk); + const snippetEl = renderSnippet(snippetLines, c.line != null ? () => showInDiff(c) : undefined); + return { + at: c.createdAt, + node: h( + 'article.gh-card.gh-thread', {}, - h('span', {}, '💬'), - h('code', { title: c.path }, `${c.path}${c.line ? `:${c.line}` : ''}`), - c.line == null ? h('span.gh-badge.muted', {}, 'outdated') : null, - h('span.grow'), - c.line != null ? h('button.btn', { type: 'button', onclick: () => showInDiff(c) }, 'Show in diff') : null, + h( + 'header', + {}, + h('span', {}, '💬'), + h('code', { title: c.path }, `${c.path}${c.line ? `:${c.line}` : ''}`), + c.line == null ? h('span.gh-badge.muted', {}, 'outdated') : null, + h('span.grow'), + c.line != null ? h('button.btn', { type: 'button', onclick: () => showInDiff(c) }, 'Show in diff') : null, + ), + snippetEl, + renderThread(c, replies, itemUrl), ), - renderThread(c, replies, itemUrl), - ), - })), + }; + }), ].sort((a, b) => a.at.localeCompare(b.at)); thread.append(...items.map((x) => x.node)); if (!items.length) thread.append(h('p.gh-quiet', {}, 'No comments or reviews yet.')); @@ -493,7 +498,7 @@ export function openPull(first: GhPull, net: Net, actions: BoardActions) { files = parseDiff(text); }) .catch((err) => g === generation && (diffError = (err as Error).message)) - .finally(() => g === generation && (setupFiles(), renderFrame())); + .finally(() => g === generation && (setupFiles(), renderFrame(), renderConv())); renderFrame(); } reload.addEventListener('click', () => { diff --git a/src/client/ui/github/pulldiff.ts b/src/client/ui/github/pulldiff.ts index 96df6f7fd..a49ee1260 100644 --- a/src/client/ui/github/pulldiff.ts +++ b/src/client/ui/github/pulldiff.ts @@ -222,6 +222,112 @@ export function renderFileDiff(f: DiffFile, comments: GhReviewComment[], itemUrl return out; } +export function linesFromDiffHunk(diffHunk: string): DiffLine[] { + const lines: DiffLine[] = []; + let oldLine = 0; + let newLine = 0; + for (const raw of diffHunk.split('\n')) { + if (!raw.trim()) continue; + if (raw.startsWith('@@')) { + const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw); + if (m) { + oldLine = Number(m[1]); + newLine = Number(m[2]); + } + lines.push({ kind: 'hunk', text: raw }); + continue; + } + const sign = raw[0]; + const text = raw.slice(1); + if (sign === '+') { + lines.push({ kind: 'add', text, new: newLine++ }); + } else if (sign === '-') { + lines.push({ kind: 'del', text, old: oldLine++ }); + } else if (sign === ' ') { + lines.push({ kind: 'ctx', text, old: oldLine++, new: newLine++ }); + } else if (sign === '\\') { + lines.push({ kind: 'note', text: raw.slice(2) }); + } + } + return lines; +} + +export function extractSnippetLines( + files: DiffFile[] | null, + path: string, + line: number | null, + side: 'LEFT' | 'RIGHT', + diffHunk?: string, +): DiffLine[] { + if (files && line != null) { + const f = files.find((x) => x.path === path); + if (f) { + let targetIdx = -1; + if (side === 'LEFT') { + targetIdx = f.lines.findIndex((l) => l.old === line && l.kind !== 'add'); + } else { + targetIdx = f.lines.findIndex((l) => l.new === line); + } + if (targetIdx === -1) { + targetIdx = f.lines.findIndex((l) => l.new === line || l.old === line); + } + if (targetIdx !== -1) { + let start = targetIdx; + let ctxBefore = 0; + while (start > 0) { + const prev = f.lines[start - 1]; + if (prev.kind === 'hunk') break; + if (prev.kind === 'ctx') { + if (ctxBefore >= 3) break; + ctxBefore++; + } + start--; + } + let end = targetIdx + 1; + let ctxAfter = 0; + while (end < f.lines.length) { + const next = f.lines[end]; + if (next.kind === 'hunk') break; + if (next.kind === 'ctx') { + if (ctxAfter >= 2) break; + ctxAfter++; + } + end++; + } + const snippet = f.lines.slice(start, end).filter((l) => l.kind !== 'hunk'); + if (snippet.length) return snippet; + } + } + } + if (diffHunk) { + return linesFromDiffHunk(diffHunk).filter((l) => l.kind !== 'hunk'); + } + return []; +} + +export function renderSnippet(lines: DiffLine[], onclick?: () => void): HTMLElement | null { + if (!lines.length) return null; + const container = h('div.pd-lines.gh-thread-snippet'); + for (const l of lines) { + if (l.kind === 'hunk' || l.kind === 'note') continue; + const sign = l.kind === 'add' ? '+' : l.kind === 'del' ? '−' : ' '; + const row = h( + 'div.pd-l', + { class: l.kind }, + h('span.pd-n', {}, l.old != null ? String(l.old) : ''), + h('span.pd-n', {}, l.new != null ? String(l.new) : ''), + h('span.pd-code', { 'data-sign': sign }, l.text), + ); + container.append(row); + } + if (onclick) { + container.style.cursor = 'pointer'; + container.title = 'Click to show in diff'; + container.addEventListener('click', onclick); + } + return container; +} + // ---- The file list as a folder tree -------------------------------------------------------------- export interface TreeDir { diff --git a/src/client/ui/github/windows.css b/src/client/ui/github/windows.css index de2a99da7..a31658ce3 100644 --- a/src/client/ui/github/windows.css +++ b/src/client/ui/github/windows.css @@ -40,6 +40,9 @@ article.gh-card > header { display: flex; align-items: center; gap: 8px; padding .gh-badge.ok { background: var(--good); color: #fff; } .gh-badge.bad { background: var(--bad); color: #fff; } .gh-badge.muted { background: #dee2e6; } +.gh-thread-snippet { border-bottom: 2px solid rgba(43, 45, 66, .15); overflow-x: auto; font-size: 12px; } +.gh-thread-snippet .pd-l { min-height: 1.4em; } +.gh-thread-snippet .pd-n { width: 40px; padding-right: 6px; font-size: 11px; } .gh-thread .pd-thread { margin: 0; border: 0; border-radius: 0; box-shadow: none; } .gh-loading { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 18px; font-weight: 800; color: var(--muted); } .gh-error { margin: 16px auto; max-width: 560px; display: flex; flex-direction: column; align-items: center; gap: 10px; text-align: center; background: #ffd6e0; border: 3px solid var(--ink); border-radius: 14px; padding: 14px 18px; font-weight: 800; } diff --git a/src/client/ui/markdown.ts b/src/client/ui/markdown.ts index c8737e225..db713f399 100644 --- a/src/client/ui/markdown.ts +++ b/src/client/ui/markdown.ts @@ -10,19 +10,21 @@ import { h } from './dom'; const md = new Marked({ gfm: true, breaks: true }); const mdFile = new Marked({ gfm: true }); -const purify = DOMPurify(window); -purify.addHook('afterSanitizeAttributes', (node) => { - if (node.tagName === 'A') { - node.setAttribute('target', '_blank'); - node.setAttribute('rel', 'noopener noreferrer'); - } else if (node.tagName === 'IMG') { - node.setAttribute('loading', 'lazy'); - node.setAttribute('referrerpolicy', 'no-referrer'); - } else if (node.tagName === 'INPUT') { - // Task list boxes: show them ticked or not, but they don't do anything here. - node.setAttribute('disabled', ''); - } -}); +const purify = typeof window !== 'undefined' ? DOMPurify(window) : (DOMPurify as any); +if (purify?.addHook) { + purify.addHook('afterSanitizeAttributes', (node: any) => { + if (node.tagName === 'A') { + node.setAttribute('target', '_blank'); + node.setAttribute('rel', 'noopener noreferrer'); + } else if (node.tagName === 'IMG') { + node.setAttribute('loading', 'lazy'); + node.setAttribute('referrerpolicy', 'no-referrer'); + } else if (node.tagName === 'INPUT') { + // Task list boxes: show them ticked or not, but they don't do anything here. + node.setAttribute('disabled', ''); + } + }); +} const ALERTS: Record = { NOTE: 'ℹ️ Note', TIP: '💡 Tip', IMPORTANT: '❗ Important', WARNING: '⚠️ Warning', CAUTION: '🛑 Caution' }; diff --git a/src/client/ui/menu.ts b/src/client/ui/menu.ts index 0fcf6d0d8..0bc85f016 100644 --- a/src/client/ui/menu.ts +++ b/src/client/ui/menu.ts @@ -60,7 +60,8 @@ export interface Hud { * The HUD: the top bar's dock (what you pinned, what needs you now, the workers and the ☰ menu), * and the panels you choose to show. Everything else waits in the menu, so the office stays in view. */ -export function mountHud(actions: HudAction[], settings: Settings, save: () => void): Hud { +export function mountHud(actions: HudAction[], settings: Settings, save: () => void, extraActions?: () => HudAction[]): Hud { + const getActions = () => (extraActions ? [...actions, ...extraActions()] : actions); const dock = $('dock'); const labelOf = (a: HudAction) => (typeof a.label === 'string' ? a.label : a.label()); const iconOf = (a: HudAction) => (typeof a.icon === 'string' ? a.icon : a.icon()); @@ -125,7 +126,7 @@ export function mountHud(actions: HudAction[], settings: Settings, save: () => v } function render() { - const items: HTMLElement[] = actions.filter((a) => offered(a) && (pinned(a) || a.status?.())).map(dockButton); + const items: HTMLElement[] = getActions().filter((a) => offered(a) && (pinned(a) || a.status?.())).map(dockButton); const people = store.peers.size; if (people > 1 || settings.hud.people) items.push(panelChip('people', '👥', 'People', people, `${people} in the office`)); const workers = [...store.workers.values()]; @@ -197,7 +198,7 @@ export function mountHud(actions: HudAction[], settings: Settings, save: () => v return item; }; const section = (name: string, rows: HTMLElement[]) => (rows.length ? [h('div.menu-sec', {}, name), ...rows] : []); - const rows = (s: HudAction['section']) => actions.filter((a) => a.section === s && offered(a)).map(row); + const rows = (s: HudAction['section']) => getActions().filter((a) => a.section === s && offered(a)).map(row); const el = h( 'div.hud-menu', { role: 'menu', 'aria-label': 'Menu' }, @@ -278,7 +279,7 @@ export function mountHud(actions: HudAction[], settings: Settings, save: () => v }, true, ); - for (const t of ['workers', 'peers', 'issues', 'pulls', 'services', 'queue', 'meeting', 'upgrade', 'me', 'floors', 'signins'] as Topic[]) store.on(t, render); + for (const t of ['workers', 'peers', 'issues', 'pulls', 'services', 'queue', 'meeting', 'upgrade', 'me', 'floors', 'signins', 'boards'] as Topic[]) store.on(t, render); applyPanels(); render(); return { refresh: render, toggleMenu }; diff --git a/src/client/ui/provider.ts b/src/client/ui/provider.ts index 94b0d7aa2..50c85f96b 100644 --- a/src/client/ui/provider.ts +++ b/src/client/ui/provider.ts @@ -1,11 +1,21 @@ import './provider.css'; import type { AgentChoice, AgentEffort, AgentProvider, ProjectInfo, Usage, WorkerInfo } from '../../shared/protocol'; import { AGENT_EFFORTS, isAgentEffort } from '../../shared/protocol'; -import { AGENT_PROVIDERS, CLAUDE_MODEL_NAMES, PROVIDER_META, claudeModelName, isAgentProvider, isClaudeModel, takesEffort, type ModelOption } from '../../shared/providers'; +import { AGENT_PROVIDERS, CLAUDE_MODEL_NAMES, PROVIDER_META, claudeModelName, isClaudeModel, takesEffort, type ModelOption, getAllProviders } from '../../shared/providers'; import { store } from '../state'; import { h } from './dom'; -export const PROVIDER_LABEL = Object.fromEntries(AGENT_PROVIDERS.map((p) => [p, PROVIDER_META[p].label])) as Record; +export function getProviderLabel(provider: string): string { + if (PROVIDER_META[provider]?.label) return PROVIDER_META[provider].label; + if (provider === 'openfox') return 'OpenFox'; + return provider.charAt(0).toUpperCase() + provider.slice(1); +} + +export const PROVIDER_LABEL = new Proxy({} as Record, { + get(_target, prop: string) { + return getProviderLabel(prop); + }, +}); export const EFFORT_LABEL: Record = { low: 'Low', @@ -41,24 +51,38 @@ export function engineLabel(w: Pick (options.includes(select.value as AgentProvider) ? (select.value as AgentProvider) : fallback); - const meta = () => PROVIDER_META[value()]; + const meta = () => + PROVIDER_META[value()] ?? { + label: getProviderLabel(value()), + name: getProviderLabel(value()), + usage: { note: providerUsageNote(value()) }, + }; /** Its models: the ones it always has, or the ones its CLI listed. */ const known = () => meta().models?.fixed ?? catalogues.get(value())?.list ?? []; /** Typed in: for a provider whose model is, and for one whose list couldn't be had. */ @@ -195,7 +228,7 @@ export function agentFields(project: ProjectInfo | null, id: string, initial: Ag modelSelect.replaceChildren(h('option', { value: '' }, meta().models?.unset ?? 'Default'), ...models.map((m) => h('option', { value: m.id }, m.name ?? m.id))); if (id && !models.some((m) => m.id === id)) modelSelect.append(h('option', { value: id }, id)); modelSelect.value = id; - if (modelInput.value.trim() !== id) modelInput.value = id; + if ((modelInput.value ?? '').trim() !== id) modelInput.value = id; suggestions.replaceChildren(...models.map((m) => h('option', { value: m.id, label: m.name }))); }; /** The efforts on offer: the ones the model picked takes, where its catalogue says. */ @@ -227,7 +260,7 @@ export function agentFields(project: ProjectInfo | null, id: string, initial: Ag else if (field.catalog && catalogue?.request) hint.textContent = `Loading ${m.label} models…`; else if (field.catalog && catalogue?.failed) hint.textContent = `${m.label}’s models couldn’t be listed: leave it empty for its default, or type a model id.`; else hint.textContent = field.hint; - fields.classList.toggle('hidden', !field && !m.takesEffort && !hint.textContent); + fields.classList.toggle('hidden', !field && !m?.takesEffort); paintEffort(); }; /** Asks the provider's CLI for its models, only once someone can see the fields. */ diff --git a/src/client/ui/services.ts b/src/client/ui/services.ts index 11954ccd3..2269fcc95 100644 --- a/src/client/ui/services.ts +++ b/src/client/ui/services.ts @@ -1,7 +1,7 @@ import './services.css'; import type { ServiceInfo, ServicesState } from '../../shared/protocol'; import { store } from '../state'; -import { h, openModal, timeAgo } from './dom'; +import { h, openModal, timeAgo, toast } from './dom'; import { copy, copyButton, guessOs, openCommand, OS_LABEL, type Os } from './team'; /** Whether this page came over the office's Tailscale network, where every server has its own link. */ @@ -59,6 +59,65 @@ export function openServices() { footer, ); + const controlService = async (action: 'start' | 'stop', svc: ServiceInfo) => { + try { + const name = svc.title || svc.command; + toast(action === 'start' ? 'Starting service…' : 'Stopping service…', 'info'); + const res = await fetch(`/api/services/${action}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ floor: store.floor, name, command: svc.command }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + toast(data.error || `Failed to ${action} service`, 'warn'); + } else { + toast(action === 'start' ? 'Service started' : 'Service stopped', 'info'); + } + } catch (err) { + toast((err as Error).message, 'warn'); + } + }; + + const showServiceLogs = async (svc: ServiceInfo) => { + const name = svc.title || svc.command; + const logPre = h('pre', { + style: 'max-height:60vh;overflow:auto;background:#1b1b1b;color:#f0f0f0;padding:12px;border-radius:10px;font-size:12px;line-height:1.45;white-space:pre-wrap;font-family:ui-monospace,Menlo,monospace;margin:0;', + }, 'Loading logs…'); + const refreshLogs = h('button.btn', { type: 'button' }, '🔄 Refresh'); + const closeLogs = h('button.btn.close', { 'aria-label': 'Close' }, '✕'); + const logModalEl = h( + 'div.modal', + { role: 'dialog', 'aria-label': `Logs: ${name}`, style: 'width:min(800px,95vw);' }, + h('header', {}, h('h2', {}, `📜 Logs · ${name}`), refreshLogs, closeLogs), + h('div.body', {}, logPre), + ); + const logModal = openModal(logModalEl, { doing: 'viewing service logs' }); + closeLogs.addEventListener('click', () => logModal.close()); + + const loadLogs = async () => { + refreshLogs.disabled = true; + try { + const floorId = store.floor ?? ''; + const res = await fetch(`/api/services/logs?floor=${encodeURIComponent(floorId)}&name=${encodeURIComponent(name)}`); + if (!res.ok) { + logPre.textContent = 'Could not load logs from service.'; + return; + } + const data = await res.json(); + const lines: string[] = data.logs ?? []; + logPre.textContent = lines.length ? lines.join('\n') : 'No logs recorded yet.'; + logPre.scrollTop = logPre.scrollHeight; + } catch (err) { + logPre.textContent = `Error: ${(err as Error).message}`; + } finally { + refreshLogs.disabled = false; + } + }; + refreshLogs.addEventListener('click', loadLogs); + void loadLogs(); + }; + const pick = async (svc: ServiceInfo) => { const s = store.services; picked = svc.port; @@ -120,9 +179,49 @@ export function openServices() { for (const svc of s.items) { const { who, color, branch } = describe(svc); const on = picked === svc.port; + const isStopped = svc.title?.includes('(Stopped)') || svc.workerId === 'dev-server-stopped'; + const isDevServer = + svc.workerId?.startsWith('dev-server') || + svc.title?.toLowerCase().includes('server') || + svc.title?.toLowerCase().includes('dev') || + svc.title?.includes('(Stopped)') || + svc.title?.includes('(Running)'); + + const actionsRow = h('div', { style: 'display:inline-flex;gap:6px;margin-left:auto;align-items:center;' }); + + if (isDevServer) { + const toggleBtn = h('button.btn', { + type: 'button', + title: isStopped ? 'Start dev server' : 'Stop dev server', + style: isStopped + ? 'background:#e8f5e9;border-color:#2e7d32;color:#1b5e20;padding:2px 8px;font-size:12px;font-weight:800;' + : 'background:#ffebee;border-color:#c62828;color:#b71c1c;padding:2px 8px;font-size:12px;font-weight:800;', + }, isStopped ? '▶ Start' : '⏹ Stop'); + toggleBtn.addEventListener('click', (e) => { + e.stopPropagation(); + void controlService(isStopped ? 'start' : 'stop', svc); + }); + + const logsBtn = h('button.btn', { + type: 'button', + title: 'View service logs', + style: 'padding:2px 8px;font-size:12px;font-weight:800;', + }, '📜 Logs'); + logsBtn.addEventListener('click', (e) => { + e.stopPropagation(); + void showServiceLogs(svc); + }); + + actionsRow.append(toggleBtn, logsBtn); + } + const title = direct ? `Open ${serviceUrl(svc.port)}` : `Open ${serviceUrl(svc.port)} (needs the tunnel, unless the office runs on this computer)`; - const open = h('a.btn', { href: serviceUrl(svc.port), target: '_blank', rel: 'noopener', title }, 'Open ↗'); - open.addEventListener('click', (e) => e.stopPropagation()); + if (svc.port > 0 && !isStopped) { + const open = h('a.btn', { href: serviceUrl(svc.port), target: '_blank', rel: 'noopener', title }, 'Open ↗'); + open.addEventListener('click', (e) => e.stopPropagation()); + actionsRow.append(open); + } + const li = h( 'li', { class: on ? 'on' : '', tabindex: 0, role: 'button', title: direct ? 'Copy the link' : 'Copy the tunnel command' }, @@ -133,8 +232,8 @@ export function openServices() { h('div.svc-title', {}, svc.title || svc.command), h('div.svc-meta', {}, [who, branch ? `🌿 ${branch}` : '', svc.title ? svc.command : '', `started ${timeAgo(svc.since)}`].filter(Boolean).join(' · ')), ), - h('span.svc-port', {}, `:${svc.port}`), - open, + svc.port > 0 ? h('span.svc-port', {}, `:${svc.port}`) : null, + actionsRow, ); li.addEventListener('click', () => void pick(svc)); li.addEventListener('keydown', (e) => { diff --git a/src/client/ui/settings-plugins.ts b/src/client/ui/settings-plugins.ts new file mode 100644 index 000000000..dd741f311 --- /dev/null +++ b/src/client/ui/settings-plugins.ts @@ -0,0 +1,235 @@ +import { h } from './dom'; +import { store } from '../state'; +import type { Net } from '../net'; +import type { PluginCapability, PluginManifest } from '../../shared/protocol'; + +export function pluginsSetting(net?: Net) { + const container = h('div.plugins-list'); + + const CAPABILITY_LABEL: Record = { + floors: '🏢 Floors', + queue: '📋 Tasks Queue', + services: '🌐 Services Board', + agent: '🤖 Agent Provider', + issues: '📌 Issues Board', + pulls: '🔀 PR Board', + boards: '🎯 Custom Boards', + }; + + const paintPluginCard = (plugin: PluginManifest, isInstalled: boolean) => { + const statusClass = plugin.status === 'active' ? 'active' : plugin.status === 'error' ? 'error' : 'inactive'; + const statusLabel = plugin.status === 'active' ? '● Active' : plugin.status === 'error' ? '✖ Error' : '○ Inactive'; + + const caps = (plugin.capabilities || []).map((cap) => + h('span.plugin-cap', {}, CAPABILITY_LABEL[cap] || cap), + ); + + const admin = store.me?.admin; + + const installBtn = h( + 'button.btn', + { + type: 'button', + class: isInstalled ? 'btn-warn' : 'primary', + disabled: !admin, + title: admin ? (isInstalled ? 'Uninstall plugin' : 'Install plugin') : 'Only admins can install or uninstall plugins', + onclick: (e: Event) => { + if (!admin) return; + const btn = e.currentTarget as HTMLButtonElement; + btn.disabled = true; + btn.textContent = isInstalled ? '⏳ Uninstalling…' : '⏳ Installing…'; + if (isInstalled) { + net?.send({ t: 'plugin.uninstall', id: plugin.id }); + } else { + net?.send({ t: 'plugin.install', id: plugin.id }); + } + }, + }, + isInstalled ? '🗑️ Uninstall' : '⬇️ Install', + ); + + const openFolderBtn = isInstalled && admin + ? h( + 'button.btn', + { + type: 'button', + title: 'Open the directory where this plugin is installed', + onclick: () => { + net?.send({ t: 'plugin.openFolder', id: plugin.id }); + }, + }, + '📁 Open folder', + ) + : null; + + const githubBtn = plugin.githubUrl + ? h( + 'a.btn', + { + href: plugin.githubUrl, + target: '_blank', + rel: 'noopener', + title: 'View plugin repository on GitHub', + style: 'text-decoration:none;display:inline-flex;align-items:center;', + }, + '🌐 GitHub ↗', + ) + : null; + + const updateBtn = isInstalled && plugin.updateAvailable && admin + ? h( + 'button.btn.primary', + { + type: 'button', + title: `Update to ${plugin.latestVersion}`, + onclick: (e: Event) => { + const btn = e.currentTarget as HTMLButtonElement; + btn.disabled = true; + btn.textContent = '⏳ Updating…'; + net?.send({ t: 'plugin.update', id: plugin.id }); + }, + }, + `⬆️ Update to ${plugin.latestVersion}`, + ) + : null; + + const actions = h('div.plugin-actions', {}, installBtn, updateBtn, openFolderBtn, githubBtn); + + const versionLabel = plugin.version ? `v${plugin.version}` : plugin.local ? 'local' : ''; + + return h( + 'div.plugin-card', + { class: statusClass }, + h( + 'div.plugin-head', + {}, + h( + 'div.plugin-title-row', + {}, + h('h5.plugin-name', {}, plugin.name), + versionLabel ? h('span.plugin-version', {}, versionLabel) : null, + plugin.author ? h('span.plugin-author', {}, `by ${plugin.author}`) : null, + ), + h('span.plugin-status', { class: statusClass }, statusLabel), + ), + h('p.plugin-desc', {}, plugin.description), + caps.length > 0 ? h('div.plugin-caps', {}, ...caps) : null, + actions, + plugin.error ? h('p.plugin-error', {}, `Error: ${plugin.error}`) : null, + ); + }; + + const paint = () => { + const installed = store.plugins?.plugins || []; + const pluginStore = store.plugins?.store || []; + container.replaceChildren(); + + const admin = store.me?.admin; + const headerBtns: HTMLElement[] = []; + if (admin) { + headerBtns.push( + h( + 'button.btn', + { + type: 'button', + title: 'Open the plugins folder on disk', + onclick: () => net?.send({ t: 'plugin.openFolder' }), + }, + '📁 Plugins folder', + ), + ); + } + headerBtns.push( + h( + 'button.btn', + { + type: 'button', + title: 'Refresh plugins list', + onclick: () => net?.send({ t: 'plugins.list' }), + }, + '↻', + ), + ); + + const headerBar = h( + 'div.plugin-store-header', + { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;' }, + h('p.setting-note', { style: 'margin:0;' }, `Plugin Store (${pluginStore.length} available)`), + h('div', { style: 'display:flex;gap:6px;' }, ...headerBtns), + ); + container.append(headerBar); + + if (admin) { + const gitInput = h('input', { + type: 'text', + placeholder: 'https://github.com/user/repo', + 'aria-label': 'Git URL', + style: 'flex:1;font:inherit;font-size:14px;border:3px solid var(--ink);border-radius:12px;padding:8px 12px;background:#fff;outline:none;box-sizing:border-box;', + }) as HTMLInputElement; + + const doGitInstall = () => { + const url = gitInput.value.trim(); + if (url) { + gitInstallBtn.disabled = true; + gitInstallBtn.textContent = '⏳ Installing…'; + net?.send({ t: 'plugin.installFromGit', url }); + gitInput.value = ''; + setTimeout(() => { + gitInstallBtn.disabled = false; + gitInstallBtn.textContent = '⬇️ Install from Git'; + }, 8000); + } + }; + + gitInput.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + doGitInstall(); + } + }); + + const gitInstallBtn = h( + 'button.btn.primary', + { + type: 'button', + onclick: () => doGitInstall(), + }, + '⬇️ Install from Git', + ) as HTMLButtonElement; + + const gitInstallBar = h( + 'div.plugin-git-install', + { style: 'display:flex;gap:6px;margin-bottom:12px;align-items:center;' }, + gitInput, + gitInstallBtn, + ); + container.append(gitInstallBar); + } + + const installedSection = h('div.plugin-section', {}, h('h4', {}, `Installed (${installed.length})`)); + if (installed.length === 0) { + installedSection.append(h('p.setting-note', {}, 'No plugins installed')); + } else { + for (const plugin of installed) { + installedSection.append(paintPluginCard(plugin, true)); + } + } + container.append(installedSection); + + const storeSection = h('div.plugin-section', {}, h('h4', {}, `Available (${pluginStore.length})`)); + if (pluginStore.length === 0) { + storeSection.append(h('p.setting-note', {}, 'No plugins available in the registry')); + } else { + for (const plugin of pluginStore) { + storeSection.append(paintPluginCard(plugin, false)); + } + } + container.append(storeSection); + }; + + paint(); + return { + element: container, + paint, + }; +} diff --git a/src/client/ui/settings.css b/src/client/ui/settings.css index 61cf03502..c90c26bb3 100644 --- a/src/client/ui/settings.css +++ b/src/client/ui/settings.css @@ -38,3 +38,30 @@ .volume input[type=range]::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; border: 3px solid var(--ink); background: var(--paper); box-shadow: 0 2px 0 var(--ink); } .volume input[type=range]:focus-visible::-webkit-slider-thumb { border-color: var(--accent); } .volume.muted input[type=range], .volume.muted .vol-pct { opacity: .45; } + +/* Settings: plugins */ +.plugins-list { display: flex; flex-direction: column; gap: 16px; } +.plugin-section { display: flex; flex-direction: column; gap: 12px; } +.plugin-section h4 { margin: 0 0 4px 0; font-size: 15px; font-weight: 800; } +.plugin-card { padding: 14px; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper-2); display: flex; flex-direction: column; gap: 8px; } +.plugin-card.active { border-color: #2e7d32; background: #f1f8e9; } +.plugin-card.error { border-color: #c62828; background: #ffebee; } +.plugin-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.plugin-title-row { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; } +.plugin-name { margin: 0; font-size: 16px; font-weight: 900; } +.plugin-version { font-size: 12px; font-weight: 700; color: var(--muted); } +.plugin-author { font-size: 12px; font-weight: 600; color: var(--muted); } +.plugin-status { font-size: 11px; font-weight: 900; padding: 2px 8px; border-radius: 999px; border: 2px solid var(--ink); text-transform: uppercase; } +.plugin-status.active { background: #a5d6a7; color: #1b5e20; } +.plugin-status.inactive { background: #e0e0e0; color: #616161; } +.plugin-status.error { background: #ef9a9a; color: #b71c1c; } +.plugin-desc { margin: 0; font-size: 13.5px; font-weight: 500; line-height: 1.4; } +.plugin-caps { display: flex; gap: 6px; flex-wrap: wrap; } +.plugin-cap { font-size: 11px; font-weight: 700; padding: 2px 6px; background: #fff; border: 1.5px solid var(--ink); border-radius: 6px; } +.plugin-actions { display: flex; gap: 8px; align-items: center; margin-top: 6px; flex-wrap: wrap; } +.plugin-actions .btn { font-size: 12px; padding: 4px 10px; } +.plugin-error { margin: 0; font-size: 12px; font-weight: 700; color: #c62828; } + +/* A plugin's own settings page, served by the office from the plugin's directory. */ +.plugin-settings { margin-top: 6px; } +.plugin-settings-frame { width: 100%; height: 420px; border: 2px solid var(--ink); border-radius: 10px; background: #fff; } diff --git a/src/client/ui/settings.ts b/src/client/ui/settings.ts index ba8dae5c2..3698774dc 100644 --- a/src/client/ui/settings.ts +++ b/src/client/ui/settings.ts @@ -12,6 +12,7 @@ import { agentFields, choiceLabel, officeChoice } from './provider'; import { openPromptEditor, rewrittenPrompts } from './prompts'; import { outsideSetting } from './settings-sky'; import { choiceRow } from './settings-rows'; +import { pluginsSetting } from './settings-plugins'; const VIEWS: [ViewMode, string, string][] = [ ['first', '👀 First person', 'See through your own eyes. Click the office to look around with the mouse and click things to use them. Esc frees the mouse.'], @@ -23,7 +24,7 @@ const THEME_LABEL: Record = { auto: '📅 By the calendar', h const WEBHOOK_NAME: Record = { slack: 'Slack', discord: 'Discord', other: 'a webhook' }; /** The categories down the side of ⚙️ Settings. */ -export type SettingsPane = 'you' | 'sound' | 'notify' | 'building' | 'workers'; +export type SettingsPane = 'you' | 'sound' | 'notify' | 'building' | 'workers' | 'plugins'; const PANES: { id: SettingsPane; icon: string; label: string; blurb: string }[] = [ { id: 'you', icon: '🧍', label: 'You', blurb: 'How you look, how you see the office, and how you’re signed in.' }, @@ -31,6 +32,7 @@ const PANES: { id: SettingsPane; icon: string; label: string; blurb: string }[] { id: 'notify', icon: '🔔', label: 'Notifications', blurb: 'Hear about a worker that needs someone, or finished, while you’re somewhere else.' }, { id: 'building', icon: '🏢', label: 'Building', blurb: 'The map, the decorations, the sky, the dog, and where new floors are cloned.' }, { id: 'workers', icon: '🤖', label: 'Workers', blurb: 'What workers start on, how many run at once, when they go home and what the office tells them.' }, + { id: 'plugins', icon: '🔌', label: 'Plugins', blurb: 'Installed harness and extension plugins.' }, ]; /** Who a setting is for, shown by its name: some are yours alone, some the whole office's. */ @@ -118,6 +120,7 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting // Voice chat: an open mic, or muted until you hold V. const talkRow = choiceRow('Voice chat', [[false, '🎙️ Open mic'], [true, '✋ Push to talk']], () => settings.pushToTalk, (pushToTalk) => change({ pushToTalk })); const musicRow = volumeRow('Jukebox volume', 'music', 'musicMuted'); + const plugins = pluginsSetting(net); // The swish of the book's pages at the bookshelf, on or off. const pagesRow = choiceRow('Page turns at the bookshelf', [[true, '📖 On'], [false, 'Off']], () => settings.pageTurns, (pageTurns) => change({ pageTurns })); @@ -166,6 +169,16 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting const mapRow = h('div.seg', { role: 'radiogroup', 'aria-label': 'Map' }); const mapNote = h('p.setting-note'); const mapBad = h('p.setting-note.bad', { style: 'white-space: pre-line' }); + const openFolderBtn = h( + 'button.btn', + { + type: 'button', + title: 'Open custom maps folder on your computer', + onclick: () => net.send({ t: 'map.openFolder' }), + }, + '📁 Open maps folder', + ); + const mapActions = h('div.seg', { style: 'margin-top:8px' }, openFolderBtn); const paintMap = () => { const { pick, by, at, custom } = store.map; const choices = mapChoices(custom); @@ -433,6 +446,29 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting }); dirDefault.addEventListener('click', () => net.send({ t: 'floor.projectsDir', dir: '' })); + // Max floors / projects limit in building + const maxFloorsInput = h('input', { type: 'text', placeholder: '500', 'aria-label': 'Max floors limit', spellcheck: 'false', autocomplete: 'off' }) as HTMLInputElement; + const maxFloorsSave = h('button.btn.primary', { type: 'button' }, 'Save'); + const maxFloorsRow = h('div.webhook', {}, maxFloorsInput, maxFloorsSave); + const maxFloorsNote = h('p.setting-note'); + const paintMaxFloors = () => { + const limit = store.projectsDir.maxFloors ?? 500; + const admin = store.me.admin; + maxFloorsInput.value = String(limit); + maxFloorsRow.classList.toggle('hidden', !admin); + maxFloorsNote.textContent = `The building displays up to ${limit} projects as floors. ${admin ? 'Admins can change this limit.' : 'An admin can change it.'}`; + }; + paintMaxFloors(); + const saveMaxFloors = () => { + const val = Number(maxFloorsInput.value); + if (!val || val < 1) return maxFloorsInput.focus(); + net.send({ t: 'floor.maxFloors', max: val }); + }; + maxFloorsSave.addEventListener('click', saveMaxFloors); + maxFloorsInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') saveMaxFloors(); + }); + // The dog on this floor: its name, breed and coat, for everyone here (see settings-dog.ts). const { section: dogSection, paint: paintDog } = dogSetting(net, (body) => setting('Office dog', 'floor', ...body)); @@ -460,11 +496,12 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting setting('Team notifications (Slack / Discord)', 'office', h('div.webhook', {}, hookInput, hookSave), hookActions, hookStatus), ], building: [ - setting('Map', 'office', mapRow, mapNote, mapBad), + setting('Map', 'office', mapRow, mapNote, mapActions, mapBad), setting('Holiday theme', 'office', themeRow, themeNote), ...(sky ? [sky.section] : []), dogSection, setting('Workspace folder', 'office', dirRow, dirActions, dirNote), + setting('Max floors / displayed projects', 'office', maxFloorsRow, maxFloorsNote), ], workers: [ setting('Default worker', 'office', agentNow, agent.element, agentActions, agentNote), @@ -472,6 +509,9 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting setting('Workers whose pull request merged', 'office', leaveRow, leaveNote), setting('Prompts', 'office', promptsOpen, promptsNote), ], + plugins: [ + setting('Plugin Store & Extensions', null, plugins.element), + ], }; // The categories down the side, the one picked on the right. @@ -514,8 +554,10 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting const offMap = store.on('map', paintMap); const offLeave = store.on('leaveOnMerge', paintLeave); const offLimit = [store.on('machine', paintLimit), store.on('me', paintLimit)]; - const offDir = [store.on('projectsDir', paintDir), store.on('me', paintDir)]; + const offDir = [store.on('projectsDir', paintDir), store.on('projectsDir', paintMaxFloors), store.on('me', paintDir), store.on('me', paintMaxFloors)]; const offPrompts = [store.on('prompts', paintAgent), store.on('prompts', paintPrompts), store.on('me', paintAgent), store.on('me', paintPrompts)]; + const offPlugins = store.on('plugins', plugins.paint); + net.send({ t: 'plugins.list' }); const modal = openModal(el, { doing: '⚙️ in settings', onClose: () => { @@ -528,6 +570,7 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting offLimit.forEach((off) => off()); offDir.forEach((off) => off()); offPrompts.forEach((off) => off()); + offPlugins(); }, }); show(first ?? lastPane); diff --git a/src/client/ui/terminal.ts b/src/client/ui/terminal.ts index b7124ec77..2ee2c6710 100644 --- a/src/client/ui/terminal.ts +++ b/src/client/ui/terminal.ts @@ -134,7 +134,7 @@ export function openTerminal(net: Net, workerId: string, onChanges?: () => void, const sayBtn = h('button.btn.primary', { type: 'submit' }, 'Send'); const sayForm = h('form.term-say', {}, dictateField(say), sayBtn); const keypad = opts.keypad ? h('div.term-keypad', {}, keys, sayForm) : null; - const tabs = termTabs(workerId, { host, keypad, focusTerm: () => term.focus() }); + const tabs = termTabs(workerId, { host, keypad, focusTerm: () => term.focus(), sessionUrl: info.sessionUrl }); // What you say is typed in at the terminal's cursor, as a paste, for you to read over and send (see dictate.ts). const mic = dictation( { @@ -341,6 +341,7 @@ export function openTerminal(net: Net, workerId: string, onChanges?: () => void, // Leaving with Esc while the program wanted one (you were in /skills, say): say how to send it one. if (byEsc && ready && screenMentionsEsc(term)) toast(`Esc left the terminal. To send ${store.workers.get(workerId)?.name ?? info.name} an Esc (to close a menu), use ⎋ Esc at the top or Ctrl+[`); listeners.delete(onMsg); + tabs.dispose(); unsub(); unsubPeers(); clearInterval(typingTimer); diff --git a/src/client/ui/termtabs.ts b/src/client/ui/termtabs.ts index ef6c2400b..e283e023a 100644 --- a/src/client/ui/termtabs.ts +++ b/src/client/ui/termtabs.ts @@ -3,6 +3,7 @@ // anyone else, and they're kept per worker until the page reloads. import './termtabs.css'; import { clip, h, toast } from './dom'; +import { store } from '../state'; /** A web page pinned open beside a worker's terminal. */ interface WebTab { @@ -26,10 +27,25 @@ export interface TermTabsOptions { keypad: HTMLElement | null; /** Back on the terminal's tab. */ focusTerm(): void; + /** + * Where this worker's session can be followed in a browser, when its provider has one. It arrives + * with the worker itself (see WorkerInfo.sessionUrl), whenever its session starts, and the office + * knows nothing about what is at the address. + */ + sessionUrl?: string; +} + +export interface TermTabs { + /** The bar above the terminal: the terminal itself, plus any page pinned open beside it. */ + bar: HTMLElement; + /** Where the pinned pages show. */ + pages: HTMLElement; + /** Stops following the worker; the terminal is closing. */ + dispose(): void; } /** The tab bar (above the terminal) and where the pages show (beside it). */ -export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLElement; pages: HTMLElement } { +export function termTabs(workerId: string, opts: TermTabsOptions): TermTabs { const tabsBar = h('div.term-tabs', { role: 'tablist', 'aria-label': 'Tabs' }); const tabName = h('input', { type: 'text', placeholder: 'Tab name (e.g. ChatGPT)', 'aria-label': 'Tab name', maxlength: '40', autocomplete: 'off' }) as HTMLInputElement; const tabUrl = h('input', { type: 'url', placeholder: 'https://…', 'aria-label': 'Web page address', autocomplete: 'off' }) as HTMLInputElement; @@ -99,6 +115,28 @@ export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLEl pages.append(frame); }; for (const t of tabs) frameFor(t); + + /** + * Opens a pinned tab for a page the worker's provider points at, once. The address arrives with + * the worker itself (see WorkerInfo.sessionUrl): a provider whose agent has a web UI of its own + * says where, and the office just opens it beside the terminal. + */ + // Seeded empty, not with the URL already in hand: the first call is what opens the tab, and this + // only remembers which one is open so the same address is never opened twice. + let offered: string | undefined; + const offerSession = () => { + const url = opts.sessionUrl ?? store.workers.get(workerId)?.sessionUrl; + if (!url || offered === url) return; + offered = url; + if (tabs.some((t) => t.url === url)) return; + const tab: WebTab = { id: `session-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, title: 'Session', url }; + tabs.push(tab); + frameFor(tab); + show(tab.id); + }; + offerSession(); + const offWorkers = store.on('workers', offerSession); + render(); addBtn.addEventListener('click', () => { @@ -129,5 +167,5 @@ export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLEl closeForm(); show(tab.id); }); - return { bar, pages }; + return { bar, pages, dispose: () => offWorkers() }; } diff --git a/src/client/world/office/build.ts b/src/client/world/office/build.ts index 15c0e6237..bebce7dd3 100644 --- a/src/client/world/office/build.ts +++ b/src/client/world/office/build.ts @@ -1,4 +1,5 @@ import * as THREE from 'three'; +import { GONG, type Opening } from '../../../shared/layout'; import type { WallRect } from '../../../shared/decor'; import type { FloorPalette } from '../../../shared/floors'; import { street } from '../outside'; @@ -27,6 +28,8 @@ import { wing } from './wing'; import { beanbags, desks, kiosks } from './seats'; import { meetingRoom } from './meeting-room'; import { loft } from './loft'; +import { mapProps } from './map-props'; +import type { MapPlan } from '../../../shared/maps'; import type { Fixture, Gives, Site } from './fixture'; // The office floor, put together from its fixtures (see fixture.ts): the room and its walls, the desks @@ -69,6 +72,7 @@ function floorPlan() { elevator, garageLift, gong, + mapProps, hoop, whiteboard, clearOfStairs, @@ -79,7 +83,11 @@ function floorPlan() { type NoneMissing = Missing; export type EveryHandleGiven = NoneMissing[number]>>>; -export function buildOffice(): Office { +export function buildOffice(plan?: MapPlan): Office { + const gongProp = plan?.config?.props?.find((p) => p.kind === 'gong'); + const gongAt = gongProp ? { x: gongProp.x, z: gongProp.z, rotY: gongProp.rotY ?? 0 } : GONG; + const extraOpenings: Opening[] = plan?.config?.openings ?? []; + const group = new THREE.Group(); const colliders: Collider[] = []; const interactables: Interactable[] = []; @@ -104,6 +112,9 @@ export function buildOffice(): Office { desks, doors, inTheWay: [], + plan, + gongAt, + extraOpenings, get: (key) => { if (!(key in given)) throw new Error(`The office's ${key} isn't built yet: its fixture comes later in the plan`); return given[key]!; @@ -113,8 +124,8 @@ export function buildOffice(): Office { // Each fixture in turn, with what it hands back. const updates: ((t: number, dt: number) => void)[] = []; const levels: ((index: number, count: number, wings: readonly number[]) => void)[] = []; - const plan: readonly Fixture[] = floorPlan(); - for (const fixture of plan) { + const fixtures: readonly Fixture[] = floorPlan(); + for (const fixture of fixtures) { const built = fixture(site); if (built.group) group.add(built.group); if (built.colliders) colliders.push(...built.colliders); @@ -127,6 +138,13 @@ export function buildOffice(): Office { if (built.setLevel) levels.push(built.setLevel); } + const handles = given as OfficeHandles; + if (gongProp) { + handles.gong.moveTo(gongAt); + } else { + handles.gong.moveTo(GONG); + } + const setLook = (p: FloorPalette) => { looks.wall.color.set(p.wall); looks.trim.color.set(p.trim); diff --git a/src/client/world/office/fixture.ts b/src/client/world/office/fixture.ts index f1a3f8f7e..cf5d7de8d 100644 --- a/src/client/world/office/fixture.ts +++ b/src/client/world/office/fixture.ts @@ -1,5 +1,7 @@ import type * as THREE from 'three'; import type { WallId } from '../../../shared/decor'; +import type { Opening } from '../../../shared/layout'; +import type { MapPlan } from '../../../shared/maps'; import type { Collider, DeskView, Interactable, OfficeHandles } from '../types'; import type { Looks } from './materials'; import type { Door } from './shell'; @@ -30,6 +32,12 @@ export interface Site { readonly doors: Door[]; /** What stands in the way into the back office, and its collider, put away while that's built out. */ readonly inTheWay: { group: THREE.Group; collider: Collider }[]; + /** The active map plan, if building a custom office map. */ + readonly plan?: MapPlan; + /** Override gong position, if any. */ + readonly gongAt?: { x: number; y?: number; z: number; rotY?: number }; + /** Extra openings for doors/hallways in walls. */ + readonly extraOpenings?: readonly Opening[]; /** What a fixture before this one gives the office. It throws if that one's further down the list. */ get(key: K): OfficeHandles[K]; } diff --git a/src/client/world/office/map-props.ts b/src/client/world/office/map-props.ts new file mode 100644 index 000000000..810ed7c27 --- /dev/null +++ b/src/client/world/office/map-props.ts @@ -0,0 +1,364 @@ +import * as THREE from 'three'; +import { GONG, SLAB } from '../../../shared/layout'; +import { boxFootprint } from '../../../shared/maps/props'; +import type { PropConfig } from '../../../shared/maps'; +import { mesh, roundedBox, textPlane, toon } from '../toon'; +import type { Collider, Interactable } from '../types'; +import type { Door } from './shell'; +import type { Fixture } from './fixture'; +import { GLASS, PALETTE, box } from './materials'; +import { pendant, wallBoard } from './props'; + +declare module '../types' { + interface OfficeHandles { + mapProps: MapPropsView; + } +} + +export interface MapPropsView { + signs: THREE.Mesh[]; + boards: THREE.Mesh[]; + customBoards: Map; + updateProps(props: PropConfig[]): void; + onUpdate?: () => void; +} + +/** + * Builds generic custom map elements declared in JSON: + * - wall: interior walls and partitions + * - door: sliding doors with optional room badge + * - meeting_table: conference table with chairs and meeting interaction + * - meeting_sign: live room status screen + * - meeting_board: live meeting notes board + * - custom_board: live custom tasks / US board + * - floor: floor slabs with collision + * - lamp: ceiling pendant lamp + */ +export const mapProps: Fixture<'mapProps'> = (site) => { + const signs: THREE.Mesh[] = []; + const boards: THREE.Mesh[] = []; + const customBoards = new Map(); + const customGroup = new THREE.Group(); + site.group.add(customGroup); + + const customColliders: Collider[] = []; + const customDoors: Door[] = []; + const customInteractables: Interactable[] = []; + + const wallMat = site.looks.wall; + const trimMat = site.looks.trim; + const woodMat = toon(PALETTE.wood); + const inkMat = toon(PALETTE.ink); + const aluMat = toon('#aab4be'); + const night = site.get('night'); + + function updateProps(props: PropConfig[]) { + // 1. Clear previous custom items + customGroup.clear(); + for (const c of customColliders) { + const i = site.colliders.indexOf(c); + if (i >= 0) site.colliders.splice(i, 1); + } + customColliders.length = 0; + for (const d of customDoors) { + const i = site.doors.indexOf(d); + if (i >= 0) site.doors.splice(i, 1); + } + customDoors.length = 0; + for (const it of customInteractables) { + const i = site.interactables.indexOf(it); + if (i >= 0) site.interactables.splice(i, 1); + } + customInteractables.length = 0; + signs.length = 0; + boards.length = 0; + customBoards.clear(); + + // 2. Gong + const gongProp = props.find((p) => p.kind === 'gong'); + if (gongProp) { + site.get('gong').moveTo({ x: gongProp.x, y: gongProp.y ?? 0, z: gongProp.z, rotY: gongProp.rotY ?? 0 }); + } else { + site.get('gong').moveTo(GONG); + } + + // 4. Build props + for (const p of props) { + const x = p.x; + const z = p.z; + const y = p.y ?? 0; + const rotY = p.rotY ?? 0; + + switch (p.kind) { + case 'wall': { + const w = p.width ?? 0.16; + const len = p.length ?? 3; + const h = p.height ?? 3.2; + const mat = p.color ? toon(p.color) : wallMat; + const m = mesh(box(w, h, len), mat, x, y + h / 2, z, false); + m.rotation.y = rotY; + m.userData.wall = true; + customGroup.add(m); + const trim = mesh(box(w + 0.04, 0.25, len), trimMat, x, y + 0.125, z, false); + trim.rotation.y = rotY; + customGroup.add(trim); + const [minX, maxX, minZ, maxZ] = boxFootprint(x, z, w, len, rotY); + const c: Collider = { minX, maxX, minZ, maxZ, top: y + h, bottom: y }; + customColliders.push(c); + site.colliders.push(c); + break; + } + + case 'floor': { + const w = p.width ?? 5; + const len = p.length ?? 5; + const mat = p.color ? toon(p.color) : site.planks; + const fl = mesh(new THREE.BoxGeometry(w, 0.04, len), mat, x, y + 0.01, z, false); + fl.rotation.y = rotY; + customGroup.add(fl); + const [minX, maxX, minZ, maxZ] = boxFootprint(x, z, w, len, rotY); + const c: Collider = { minX, maxX, minZ, maxZ, bottom: -SLAB, top: y }; + customColliders.push(c); + site.colliders.push(c); + break; + } + + case 'ceiling': { + const w = p.width ?? 5; + const len = p.length ?? 5; + const h = p.y ?? 3.2; + const mat = p.color ? toon(p.color) : (site.get('stack')?.ceiling ?? toon('#ffffff')); + const ceil = mesh(box(w, 0.04, len), mat, x, h + 0.02, z, false); + ceil.rotation.y = rotY; + customGroup.add(ceil); + const [minX, maxX, minZ, maxZ] = boxFootprint(x, z, w, len, rotY); + const c: Collider = { minX, maxX, minZ, maxZ, bottom: h, top: h + SLAB }; + customColliders.push(c); + site.colliders.push(c); + break; + } + + case 'door': { + const dw = p.width ?? 1.3; + const dh = p.height ?? 2.25; + buildDoor(customGroup, customDoors, site.doors, x, z, y, rotY, dw, dh, aluMat, p.name); + break; + } + + case 'meeting_table': { + const tw = p.width ?? 2.4; + const td = p.length ?? 1.3; + buildMeetingTable(customGroup, customColliders, site.colliders, customInteractables, site.interactables, x, z, y, rotY, tw, td, woodMat, inkMat); + break; + } + + case 'meeting_sign': { + const sign = new THREE.Mesh(new THREE.PlaneGeometry(0.55, 0.88), new THREE.MeshBasicMaterial({ color: '#ffffff', side: THREE.DoubleSide, toneMapped: false })); + sign.position.set(x, y || 1.45, z); + sign.rotation.y = rotY; + const plate = mesh(roundedBox(0.6, 0.94, 0.03, 0.02), toon(PALETTE.ink), 0, 0, -0.02, false); + sign.add(plate); + customGroup.add(sign); + signs.push(sign); + const it: Interactable = { kind: 'meeting', x, z, radius: 2.0 }; + customInteractables.push(it); + site.interactables.push(it); + sign.userData.interact = it; + break; + } + + case 'meeting_board': { + const bw = p.width ?? 2.4; + const bh = p.height ?? 1.2; + const { group: bg, face } = wallBoard(bw, bh, '#aab4be'); + bg.position.set(x, y || 1.9, z); + bg.rotation.y = rotY; + customGroup.add(bg); + boards.push(face); + const it: Interactable = { kind: 'meeting', x, z, radius: 2.5 }; + customInteractables.push(it); + site.interactables.push(it); + bg.userData.interact = it; + break; + } + + case 'custom_board': { + const bw = p.width ?? 5.0; + const bh = p.height ?? 3.0; + const bId = p.boardId || 'jira'; + const { group: bg, face } = wallBoard(bw, bh, PALETTE.wood); + bg.position.set(x, y || 2.1, z); + bg.rotation.y = rotY; + customGroup.add(bg); + + const signText = p.name ?? '🎯 Jira'; + const signPlane = textPlane(signText, { bg: '#fffaf3', size: 64 }); + signPlane.scale.multiplyScalar(1.3); + signPlane.position.set(0, bh / 2 + 0.5, 0.04); + bg.add(signPlane); + + if (!customBoards.has(bId)) customBoards.set(bId, []); + customBoards.get(bId)!.push(face); + + const nx = Math.sin(rotY); + const nz = Math.cos(rotY); + const it: Interactable = { kind: 'customboard', boardId: bId, x: x + nx * 1.6, z: z + nz * 1.6, radius: 2.5 }; + customInteractables.push(it); + site.interactables.push(it); + bg.userData.interact = it; + break; + } + + case 'lamp': { + const lamp = pendant(0.4); + lamp.position.set(x, (y || 3.2) - 0.4, z); + customGroup.add(lamp); + night.halos.push({ at: new THREE.Vector3(x, (y || 3.2) - 0.45, z), size: 1.3, color: '#ffe08a' }); + break; + } + + case 'sign': { + const signText = p.name ?? 'Meeting rooms'; + const signPlane = textPlane(signText, { bg: '#2b2d42', color: '#fffaf3', size: 56, border: '#ffd166' }); + signPlane.scale.multiplyScalar(0.7); + signPlane.position.set(x, y || 3.1, z); + signPlane.rotation.y = rotY; + customGroup.add(signPlane); + break; + } + + case 'expand_sign': { + site.get('wing').moveSign({ x, y: y || 4.2, z }); + break; + } + } + } + } + + // Initial setup if plan has config + if (site.plan?.config?.props) { + updateProps(site.plan.config.props); + } + + const view: MapPropsView = { signs, boards, customBoards, updateProps }; + function doUpdate(props: PropConfig[]) { + updateProps(props); + view.onUpdate?.(); + } + view.updateProps = doUpdate; + + return { handle: { mapProps: view } }; +}; + +function buildDoor(group: THREE.Group, customDoors: Door[], siteDoors: Door[], x: number, z: number, y: number, rotY: number, w: number, h: number, frameMat: THREE.Material, name?: string) { + const half = w / 2; + const leafW = half; + const doorGroup = new THREE.Group(); + doorGroup.position.set(x, y, z); + doorGroup.rotation.y = rotY; + + const F = 0.06; + const T = 0.16; + doorGroup.add(mesh(box(w, F, T + 0.04), frameMat, 0, h - F / 2, 0, false)); + for (const sx of [-half + F / 2, half - F / 2]) { + doorGroup.add(mesh(box(F, h, T + 0.04), frameMat, sx, h / 2, 0, false)); + } + + if (name) { + for (const s of [1, -1]) { + const badgePlate = mesh(roundedBox(0.92, 0.32, 0.03, 0.02), toon(PALETTE.ink), 0, h + 0.35, s * 0.19, false); + badgePlate.rotation.y = s === 1 ? 0 : Math.PI; + doorGroup.add(badgePlate); + const badge = textPlane(`🤝 ${name}`, { bg: '#2b2d42', color: '#fffaf3', size: 54, border: '#fffaf3' }); + badge.scale.multiplyScalar(0.65); + badge.position.set(0, h + 0.35, s * 0.21); + badge.rotation.y = s === 1 ? 0 : Math.PI; + doorGroup.add(badge); + } + } + + const leaves: [THREE.Group, number][] = []; + for (const s of [-1, 1]) { + const leaf = new THREE.Group(); + for (const ly of [0.04, h - 0.04]) leaf.add(mesh(box(leafW, 0.07, 0.04), frameMat, 0, ly, 0, false)); + for (const lx of [-leafW / 2 + 0.03, leafW / 2 - 0.03]) leaf.add(mesh(box(0.06, h, 0.04), frameMat, lx, h / 2, 0, false)); + const pane = mesh(new THREE.PlaneGeometry(leafW - 0.08, h - 0.14), GLASS, 0, h / 2, 0, false); + leaf.add(pane); + leaf.add(mesh(box(0.03, 0.4, 0.06), toon(PALETTE.ink), -s * (leafW / 2 - 0.08), 1.05, 0, false)); + const x0 = s * (leafW / 2); + leaf.position.set(x0, 0, -0.1); + doorGroup.add(leaf); + leaves.push([leaf, x0]); + } + group.add(doorGroup); + + const door: Door = { + x, + y, + z, + open: 0, + show: (k: number) => { + const e = k * k * (3 - 2 * k); + for (const [leaf, x0] of leaves) leaf.position.x = x0 + Math.sign(x0) * e * (leafW - 0.04); + }, + }; + customDoors.push(door); + siteDoors.push(door); +} + +function buildMeetingTable( + group: THREE.Group, + customColliders: Collider[], + siteColliders: Collider[], + customInteractables: Interactable[], + siteInteractables: Interactable[], + x: number, + z: number, + y: number, + rotY: number, + w: number, + d: number, + woodMat: THREE.Material, + inkMat: THREE.Material, +) { + const h = 0.76; + const table = new THREE.Group(); + table.position.set(x, y, z); + table.rotation.y = rotY; + table.add(mesh(roundedBox(w, 0.06, d, 0.06), woodMat, 0, h, 0)); + for (const dx of [-w / 2 + 0.25, w / 2 - 0.25]) { + for (const dz of [-d / 2 + 0.2, d / 2 - 0.2]) { + table.add(mesh(new THREE.CylinderGeometry(0.035, 0.035, h, 8), inkMat, dx, h / 2, dz, false)); + } + } + group.add(table); + + const [minX, maxX, minZ, maxZ] = boxFootprint(x, z, w, d, rotY); + const c: Collider = { minX, maxX, minZ, maxZ, top: y + h, bottom: y }; + customColliders.push(c); + siteColliders.push(c); + + const it: Interactable = { kind: 'meeting', x, z, radius: 2.9 }; + customInteractables.push(it); + siteInteractables.push(it); + table.userData.interact = it; + + const chairs: [number, number, number][] = [ + [-0.6, -d / 2 - 0.42, 0], + [0.6, -d / 2 - 0.42, 0], + [-0.6, d / 2 + 0.42, Math.PI], + [0.6, d / 2 + 0.42, Math.PI], + ]; + for (const [cx, cz, r] of chairs) { + const ch = new THREE.Group(); + ch.position.set(cx, 0, cz); + ch.rotation.y = r; + const chairMat = toon(PALETTE.chairs[Math.abs(Math.round(cx * 10 + cz * 10)) % PALETTE.chairs.length]); + ch.add(mesh(roundedBox(0.48, 0.07, 0.46, 0.04), chairMat, 0, 0.46, 0)); + ch.add(mesh(roundedBox(0.44, 0.38, 0.05, 0.03), chairMat, 0, 0.68, -0.2)); + ch.add(mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.44, 6), inkMat, 0, 0.22, 0, false)); + ch.add(mesh(new THREE.BoxGeometry(0.44, 0.03, 0.06), inkMat, 0, 0.02, 0, false)); + ch.add(mesh(new THREE.BoxGeometry(0.06, 0.03, 0.44), inkMat, 0, 0.02, 0, false)); + table.add(ch); + } +} diff --git a/src/client/world/office/room.ts b/src/client/world/office/room.ts index cb41cdccc..c76b90d3f 100644 --- a/src/client/world/office/room.ts +++ b/src/client/world/office/room.ts @@ -17,9 +17,16 @@ declare module '../types' { /** Lights, windows and glass for the sky to change with the time of day and the weather. */ night: NightParts; boardMeshes: Record; + boardLabels: THREE.Object3D[]; + hiddenBoards: { + updateHidden(hidden: readonly string[]): void; + }; tvScreen: THREE.Mesh; - /** The monitor on the west wall showing how busy the office's machine is (features/boards/machine.ts). */ + /** The screen on the machine monitor on the west wall. */ machineScreen: THREE.Mesh; + machineMonitor: { + move(pos: { x?: number; y?: number; z?: number }): void; + }; /** The potted plants round the room, in PLANTS' order. At Christmas world/holiday.ts hides their leaves (plantLeaves()) and stands a little tree in each pot. */ plants: THREE.Group[]; } @@ -56,8 +63,16 @@ export const nightLights: Fixture<'night'> = () => ({ }); /** Cork boards on the walls. */ -export const boards: Fixture<'boardMeshes'> = (site) => { +export const boards: Fixture<'boardMeshes' | 'boardLabels' | 'hiddenBoards'> = (site) => { const boardMeshes = {} as Record; + const boardLabels: THREE.Object3D[] = []; + const entries: { + key: keyof typeof BOARDS; + bg: THREE.Group; + label: THREE.Object3D; + it: Interactable; + }[] = []; + for (const key of Object.keys(BOARDS) as (keyof typeof BOARDS)[]) { const b = BOARDS[key]; // Out from the wall, the way the board faces. @@ -74,6 +89,8 @@ export const boards: Fixture<'boardMeshes'> = (site) => { label.position.set(b.x + nx * 0.04, b.y + b.height / 2 + 0.5, b.z + nz * 0.04); label.rotation.y = b.rotY; site.group.add(label); + boardLabels.push(label); + const it: Interactable = { kind: key, x: b.x + nx * 1.6, z: b.z + nz * 1.6, radius: 2.4 }; site.interactables.push(it); bg.userData.interact = it; @@ -81,8 +98,27 @@ export const boards: Fixture<'boardMeshes'> = (site) => { const wall = wallFacing(b.rotY); const bottom = b.y - (b.height + 0.3) / 2; site.wall(wall, wall === 'north' || wall === 'south' ? b.x : b.z, (bottom + WALL_HEIGHT) / 2, b.width + 0.3, WALL_HEIGHT - bottom); + + entries.push({ key, bg, label, it }); } - return { handle: { boardMeshes } }; + + function updateHidden(hidden: readonly string[]) { + for (const e of entries) { + const isHidden = hidden.includes(e.key); + e.bg.visible = !isHidden; + e.label.visible = !isHidden; + const idx = site.interactables.indexOf(e.it); + if (isHidden && idx >= 0) { + site.interactables.splice(idx, 1); + } else if (!isHidden && idx === -1) { + site.interactables.push(e.it); + } + } + } + + updateHidden(site.plan?.config?.hiddenBoards ?? []); + + return { handle: { boardMeshes, boardLabels, hiddenBoards: { updateHidden } } }; }; /** Lounge: the TV on the east wall. */ @@ -104,7 +140,7 @@ export const tv: Fixture<'tvScreen'> = (site) => { }; /** The machine monitor between the west windows, facing the desks. */ -export const machineMonitor: Fixture<'machineScreen'> = (site) => { +export const machineMonitor: Fixture<'machineScreen' | 'machineMonitor'> = (site) => { const monitor = new THREE.Group(); const bezel = mesh(roundedBox(MACHINE_MONITOR.width + 0.16, 0.1, MACHINE_MONITOR.height + 0.16, 0.06), toon(PALETTE.ink), 0, 0, 0); bezel.rotation.x = Math.PI / 2; @@ -112,11 +148,27 @@ export const machineMonitor: Fixture<'machineScreen'> = (site) => { const machineScreen = new THREE.Mesh(new THREE.PlaneGeometry(MACHINE_MONITOR.width, MACHINE_MONITOR.height), new THREE.MeshBasicMaterial({ color: '#ffffff' })); machineScreen.position.z = 0.06; monitor.add(machineScreen); - monitor.position.set(MACHINE_MONITOR.x + 0.07, MACHINE_MONITOR.y, MACHINE_MONITOR.z); + + function applyPos(p: { x?: number; y?: number; z?: number }) { + const x = p.x ?? MACHINE_MONITOR.x; + const y = p.y ?? MACHINE_MONITOR.y; + const z = p.z ?? MACHINE_MONITOR.z; + monitor.position.set(x + 0.07, y, z); + } + + const monPos = site.plan?.config?.machineMonitor ?? MACHINE_MONITOR; + applyPos(monPos); monitor.rotation.y = Math.PI / 2; site.group.add(monitor); - site.wall('west', MACHINE_MONITOR.z, MACHINE_MONITOR.y, MACHINE_MONITOR.width + 0.2, MACHINE_MONITOR.height + 0.2); - return { handle: { machineScreen } }; + site.wall('west', monPos.z, monPos.y, MACHINE_MONITOR.width + 0.2, MACHINE_MONITOR.height + 0.2); + return { + handle: { + machineScreen, + machineMonitor: { + move: (p: { x?: number; y?: number; z?: number }) => applyPos(p), + }, + }, + }; }; /** The rest of the lounge: the couch, the coffee table, its rug, and a pouf either side. */ diff --git a/src/client/world/office/seats.ts b/src/client/world/office/seats.ts index 64e0ecb30..4df1c2495 100644 --- a/src/client/world/office/seats.ts +++ b/src/client/world/office/seats.ts @@ -224,6 +224,7 @@ declare module '../types' { /** The desks, each with its chair, and what's on it. */ export const desks: Fixture = (site) => { DESKS.forEach((def, i) => { + if (site.desks.has(def.id)) return; const view = buildDesk(def, i, site.looks.trim); site.group.add(view.group); site.desks.set(def.id, view); diff --git a/src/client/world/office/shell.ts b/src/client/world/office/shell.ts index 3bafeb424..b5153ebfd 100644 --- a/src/client/world/office/shell.ts +++ b/src/client/world/office/shell.ts @@ -1,5 +1,6 @@ import * as THREE from 'three'; import { BALCONY_DOOR, EXIT_DOOR, FLOOR, WALL_HEIGHT, WALL_T, WINDOWS, WING, type Opening, type Side } from '../../../shared/layout'; +import type { MapPlan } from '../../../shared/maps'; import type { NightParts } from '../outside'; import { mergeByMaterial, mesh, textPlane, toon } from '../toon'; import type { Collider } from '../types'; @@ -312,24 +313,62 @@ export function wallRun(into: THREE.Group, cols: Collider[], axis: 'x' | 'z', at cols.push(axis === 'x' ? { minX: u0, maxX: u1, minZ: at - T / 2, maxZ: at + T / 2, top: 99 } : { minX: at - T / 2, maxX: at + T / 2, minZ: u0, maxZ: u1, top: 99 }); } +export interface WallsView { + updateOpenings(extra: readonly Opening[], plan?: MapPlan): void; +} + +declare module '../types' { + interface OfficeHandles { + walls: WallsView; + } +} + /** Outside walls, with real windows you see out of and a door out, and the glass doors out to the balcony. */ -export const walls: Fixture = (site) => { +export const walls: Fixture<'walls'> = (site) => { const night = site.get('night'); - const openings = [...WINDOWS, EXIT_DOOR, BALCONY_DOOR]; - buildWalls(site.group, site.colliders, openings, site.looks); + const group = new THREE.Group(); + site.group.add(group); + const wallColliders: Collider[] = []; const glazing = new THREE.Group(); - for (const o of WINDOWS) { - glazing.add(windowIn(o)); - site.wall(o.wall, o.u, (o.y0 + o.y1) / 2 - 0.03, o.width + 0.2, o.y1 - o.y0 + 0.12); - site.group.add(wetPane(o, night.wetGlass)); + site.group.add(glazing); + + function rebuildGlazing(activeWindows: Opening[]) { + glazing.clear(); + const gGroup = new THREE.Group(); + for (const o of activeWindows) { + gGroup.add(windowIn(o)); + site.wall(o.wall, o.u, (o.y0 + o.y1) / 2 - 0.03, o.width + 0.2, o.y1 - o.y0 + 0.12); + site.group.add(wetPane(o, night.wetGlass)); + } + glazing.add(mergeByMaterial(gGroup)); + } + + function updateOpenings(extra: readonly Opening[], currentPlan?: MapPlan) { + const thePlan = currentPlan ?? site.plan; + group.clear(); + for (const c of wallColliders) { + const i = site.colliders.indexOf(c); + if (i >= 0) site.colliders.splice(i, 1); + } + wallColliders.length = 0; + const closed = thePlan?.config?.closedWindows ?? []; + const isClosed = (o: Opening) => closed.some((cw: any) => cw.wall === o.wall && Math.abs(cw.u - o.u) < 0.5); + const extraWindows = (thePlan?.config?.windows ?? []) as Opening[]; + const activeWindows = [...WINDOWS.filter((o) => !isClosed(o)), ...extraWindows]; + const openings = [...activeWindows, EXIT_DOOR, BALCONY_DOOR, ...extra]; + buildWalls(group, wallColliders, openings, site.looks); + site.colliders.push(...wallColliders); + rebuildGlazing(activeWindows); } - site.group.add(mergeByMaterial(glazing)); + + updateOpenings(site.extraOpenings ?? [], site.plan); + // Out the glass doors on the south wall: the balcony. const slider = balconyDoor(); site.group.add(slider.group); site.doors.push(slider.door); site.wall(BALCONY_DOOR.wall, BALCONY_DOOR.u, (BALCONY_DOOR.y1 + 0.1) / 2, BALCONY_DOOR.width + 0.2, BALCONY_DOOR.y1 + 0.1); - return {}; + return { handle: { walls: { updateOpenings } } }; }; /** Upstairs there's no way out on the west side: the doorway is wall like the rest of it. */ diff --git a/src/client/world/office/wing.ts b/src/client/world/office/wing.ts index dacc3b719..481ea010f 100644 --- a/src/client/world/office/wing.ts +++ b/src/client/world/office/wing.ts @@ -18,6 +18,8 @@ export interface WingView { level: number; /** Builds it out `level` rows (or walls it up): walls, floor, ceiling, desks and all. */ set(level: number): void; + /** Moves the grow sign to a custom position. */ + moveSign(at: { x: number; y: number; z: number }): void; } /** The sign that says there's room to grow, painted for how far the back office is built out. */ @@ -203,12 +205,21 @@ export function buildWing(group: THREE.Group, colliders: Collider[], interactabl // The sign on whichever wall is at the back: low on the old wall, up over the desks and their // lamps once they're there, where it reads from across the room. - sign.position.set(midX, level ? 5.3 : 2.4, back + 0.05); - it.z = back + 0.3; + if (!customSignPos) { + sign.position.set(midX, level ? 5.3 : 2.4, back + 0.05); + it.z = back + 0.3; + } paintGrowSign(canvas, level); tex.needsUpdate = true; }, + moveSign(at) { + customSignPos = at; + sign.position.set(at.x, at.y, at.z); + it.x = at.x; + it.z = at.z + 0.3; + }, }; + let customSignPos: { x: number; y: number; z: number } | null = null; void document.fonts?.ready.then(() => { paintGrowSign(canvas, Math.max(0, view.level)); tex.needsUpdate = true; diff --git a/src/client/world/sky.ts b/src/client/world/sky.ts index b1ffb22b6..d4d1333fa 100644 --- a/src/client/world/sky.ts +++ b/src/client/world/sky.ts @@ -17,8 +17,7 @@ import type { NightParts } from './outside'; * same lines darken the ground outside when it's wet and lay snow on whatever faces up out there. */ -const MAX_LAMPS = 24; -const DEG = Math.PI / 180; +const MAX_LAMPS = 24, DEG = Math.PI / 180; /** * The furthest off the haze ever is, however high up you are: past that nothing's built (the grass * and the road round the office end there, the city round the roof just past it), so it hides that. @@ -374,7 +373,6 @@ function blobTexture(inner: number): THREE.CanvasTexture { /** The back office, walls included, when the floor you're on is built out into one (see Sky.setWing). */ let wingBox: { minX: number; maxX: number; minZ: number; maxZ: number } | null = null; - /** Is (x, z) under the building, where no rain or snow falls? */ const sheltered = (x: number, z: number) => (x > B.minX - 0.05 && x < B.maxX + 0.05 && z > B.minZ - 0.05 && z < B.maxZ + 0.05) || (!!wingBox && x > wingBox.minX - 0.05 && x < wingBox.maxX + 0.05 && z > wingBox.minZ - 0.05 && z < wingBox.maxZ); @@ -589,25 +587,23 @@ export class Sky { * office (false): no rain or snow falls where you are, nothing gets wet or snowy, and the office's * lamps and the street's don't light it (it lights itself: see World.mood). */ - setIndoors(on: boolean) { - this.indoors = on; - uniforms.skyInside.value = uniforms.skyClearRooms.value = on || this.roof ? 0 : 1; + setIndoors(on: boolean) { this.indoors = on; uniforms.skyInside.value = uniforms.skyClearRooms.value = on || this.roof ? 0 : 1; } + private wingLvl = 0; + private applyWing() { + if (this.wingLvl > 0) uniforms.skyWing.value.set(WING.minX - 0.02, WING.maxX + 0.02, wingMinZ(this.wingLvl) - 0.02, FLOOR.minZ); + else if (this.customShelter.length > 0) uniforms.skyWing.value.set(Math.min(...this.customShelter.map((a) => a.minX)) - 0.02, Math.max(...this.customShelter.map((a) => a.maxX)) + 0.02, Math.min(...this.customShelter.map((a) => a.minZ)) - 0.02, Math.max(...this.customShelter.map((a) => a.maxZ)) + 0.02); + else uniforms.skyWing.value.set(1, 0, 1, 0); } - - /** - * The floor you're on is built out `level` rows into the back office (see WING): lit like the - * office inside, and out of the rain. - */ setWing(level: number) { - const minZ = wingMinZ(level); - wingBox = level > 0 ? { minX: WING.minX - WALL_T, maxX: WING.maxX + WALL_T, minZ: minZ - WALL_T, maxZ: FLOOR.minZ } : null; - if (level > 0) uniforms.skyWing.value.set(WING.minX - 0.02, WING.maxX + 0.02, minZ - 0.02, FLOOR.minZ); - else uniforms.skyWing.value.set(1, 0, 1, 0); + this.wingLvl = level; + wingBox = level > 0 ? { minX: WING.minX - WALL_T, maxX: WING.maxX + WALL_T, minZ: wingMinZ(level) - WALL_T, maxZ: FLOOR.minZ } : null; + this.applyWing(); } - + private customShelter: { minX: number; maxX: number; minZ: number; maxZ: number }[] = []; + setIndoorAreas(b: { minX: number; maxX: number; minZ: number; maxZ: number }[]) { this.customShelter = b; this.applyWing(); } /** Under a roof, out of the rain: the building, unless you're up on top of it. */ private sheltered(x: number, z: number): boolean { - return this.indoors || (!this.roof && sheltered(x, z)); + return this.indoors || (!this.roof && (sheltered(x, z) || this.customShelter.some((s) => x >= s.minX - 0.05 && x <= s.maxX + 0.05 && z >= s.minZ - 0.05 && z <= s.maxZ + 0.05))); } /** Whether the lamps' light (and wet and snow) apply: off while your hands are drawn. */ diff --git a/src/client/world/stack.ts b/src/client/world/stack.ts index 34cbf4f3d..0727829a0 100644 --- a/src/client/world/stack.ts +++ b/src/client/world/stack.ts @@ -1,5 +1,6 @@ import * as THREE from 'three'; import { FLOOR, LADDER, POLE, POLES, SLAB, WALL_HEIGHT, WALL_T, WINDOWS, type PoleSpot } from '../../shared/layout'; +import type { MapPlan } from '../../shared/maps'; import type { Collider, Interactable } from './types'; import type { Fixture } from './office/fixture'; import { mesh, textPlane, toon } from './toon'; @@ -220,7 +221,7 @@ export interface Stack { /** The ceiling's tiles, for the back office's ceiling to match (its uvs are meters, like a ShapeGeometry's). */ ceiling: THREE.Material; /** The floor you're on: the ladder, the hatches and the poles go where there are floors to go to. */ - set(s: StackState): void; + set(s: StackState, plan?: MapPlan): void; state: StackState; /** The poles on this floor, when there's another floor for them to go to. */ poles(): readonly PoleSpot[]; @@ -239,7 +240,8 @@ export interface Stack { * The floor you walk on (planks from `planks`), the slab under it, the ceiling over it, the ladder * and the fire poles. Their colliders go in `colliders` and change as floors come and go. */ -export function buildStack(colliders: Collider[], planks: THREE.Material): Stack { +export function buildStack(colliders: Collider[], planks: THREE.Material, getPlan?: () => MapPlan | undefined): Stack { + let currentPlan = getPlan?.(); const group = new THREE.Group(); const B = { minX: FLOOR.minX - WALL_T, maxX: FLOOR.maxX + WALL_T, minZ: FLOOR.minZ - WALL_T, maxZ: FLOOR.maxZ + WALL_T }; const concrete = toon('#d3d6dd'); @@ -391,18 +393,17 @@ export function buildStack(colliders: Collider[], planks: THREE.Material): Stack return o; }; - const set = (s: StackState) => { + const set = (s: StackState, plan?: MapPlan) => { + if (plan) currentPlan = plan; Object.assign(state, s); - delete state.up; - delete state.down; - if (s.up) state.up = s.up; - if (s.down) state.down = s.down; - const { index, count } = s; - const others = count > 1; - const below = others && index > 0; - const above = others && index < count - 1; - // Every pole goes the whole way down: through this floor if there's one below, and the ceiling if there's one above. - const holes = below ? POLES : []; + delete state.up; delete state.down; + if (s.up) state.up = s.up; if (s.down) state.down = s.down; + const { index, count } = s, cfg = currentPlan?.config; + const hasLadder = cfg?.ladder ?? true, hasPoles = cfg?.poles ?? true; + const others = count > 1, below = others && index > 0, above = others && index < count - 1; + const lOthers = others && hasLadder, lBelow = below && hasLadder, lAbove = above && hasLadder; + const pOthers = others && hasPoles, pBelow = below && hasPoles, pAbove = above && hasPoles; + const holes = pBelow ? POLES : []; for (const o of built) { o.removeFromParent(); @@ -418,15 +419,13 @@ export function buildStack(colliders: Collider[], planks: THREE.Material): Stack mine = []; // The floor: planks, with the hatch and the pole's hole cut out when they go somewhere. - const floorHoles: Hole[] = []; - if (below) floorHoles.push(LADDER.hatch); - for (const p of holes) floorHoles.push({ x: p.x, z: p.z, r: POLE.hole }); + const floorHoles: Hole[] = [...(lBelow ? [LADDER.hatch] : []), ...holes.map((p) => ({ x: p.x, z: p.z, r: POLE.hole }))]; const floor = take(new THREE.Mesh(surface(rectOutline(FLOOR), floorHoles, -1, FLOOR), planks)); floor.receiveShadow = true; // The slab under it (the garage's ceiling): concrete underneath, a peach band between the floors // outside. Where a hole goes through, the garage sees a lid of concrete, not up into the office. - const slabHoles: Hole[] = [...(below ? [LADDER.hatch] : []), ...holes.map((p) => ({ x: p.x, z: p.z, r: POLE.hole + 0.02 }))]; + const slabHoles: Hole[] = [...(lBelow ? [LADDER.hatch] : []), ...holes.map((p) => ({ x: p.x, z: p.z, r: POLE.hole + 0.02 }))]; const slabShape = new THREE.Shape(rectOutline(B).map(([x, z]) => new THREE.Vector2(x, -z))); slabShape.holes = slabHoles.map((h) => holePath(h, -1)); const slabGeo = new THREE.ExtrudeGeometry(slabShape, { depth: SLAB - 0.01, bevelEnabled: false, curveSegments: 24 }).rotateX(-Math.PI / 2).translate(0, -SLAB, 0); @@ -444,27 +443,16 @@ export function buildStack(colliders: Collider[], planks: THREE.Material): Stack for (const p of holes) mine.push({ ...around(p, POLE.hole), bottom: -1.4, top: -1.2 }); // The ceiling: tiles, WALL_HEIGHT up, with the hatch and the poles' holes when they come from somewhere. - const ceilingHoles: Hole[] = []; - if (above) ceilingHoles.push(LADDER.hatch); - if (above) for (const p of POLES) ceilingHoles.push({ x: p.x, z: p.z, r: POLE.hole }); + const ceilingHoles: Hole[] = [...(lAbove ? [LADDER.hatch] : []), ...(pAbove ? POLES.map((p) => ({ x: p.x, z: p.z, r: POLE.hole })) : [])]; take(mesh(surface(rectOutline(FLOOR), ceilingHoles, 1), ceilingMat, 0, WALL_HEIGHT, 0, false)).receiveShadow = false; mine.push({ ...FLOOR, bottom: WALL_HEIGHT, top: WALL_HEIGHT + SLAB }); // The ladder, when there's anywhere to climb to. - ladder.visible = others; - ladderIt.off = !others; - ladderBelow.visible = below; - ladderAbove.visible = above; - floorHatch.pivot.visible = below; - ceilingHatch.pivot.visible = above; - ladderDownShaft.visible = below; - ladderUpShaft.visible = above; - if (others) mine.push(ladderCollider); + ladder.visible = lOthers; ladderIt.off = !lOthers; ladderBelow.visible = lBelow; ladderAbove.visible = lAbove; + floorHatch.pivot.visible = lBelow; ceilingHatch.pivot.visible = lAbove; ladderDownShaft.visible = lBelow; ladderUpShaft.visible = lAbove; + if (lOthers) mine.push(ladderCollider); for (const sign of ladderSigns) { - sign.removeFromParent(); - sign.geometry.dispose(); - sign.material.map?.dispose(); - sign.material.dispose(); + sign.removeFromParent(); sign.geometry.dispose(); sign.material.map?.dispose(); sign.material.dispose(); } ladderSigns = []; // Above the window beside it and below its sill, so they cover neither. @@ -472,7 +460,7 @@ export function buildStack(colliders: Collider[], planks: THREE.Material): Stack [s.up, '⬆', LADDER_WINDOW_HEAD + 0.48], [s.down, '⬇', 0.62], ] as const) { - if (!others || !name) continue; + if (!lOthers || !name) continue; const sign = textPlane(`🪜 ${arrow} ${name.length > 22 ? `${name.slice(0, 21)}…` : name}`, { bg: '#ffd166', size: 44 }); sign.scale.multiplyScalar(0.7); sign.position.set(FLOOR.minX + 0.02, y, LADDER.z + LADDER.width / 2 + 0.15 + (sign.geometry.parameters.width * 0.7) / 2); @@ -482,13 +470,11 @@ export function buildStack(colliders: Collider[], planks: THREE.Material): Stack } for (const p of poles) { - p.group.visible = others; - p.interactable.off = !others; - p.down.visible = p.downShaft.visible = p.below.visible = below; - p.flange.visible = !above; - p.landing.visible = !below; - p.collar.visible = p.upShaft.visible = p.above.visible = above; - if (below) { + p.group.visible = pOthers; p.interactable.off = !pOthers; + p.down.visible = p.downShaft.visible = p.below.visible = pBelow; + p.flange.visible = !pAbove; p.landing.visible = !pBelow; + p.collar.visible = p.upShaft.visible = p.above.visible = pAbove; + if (pBelow) { // The railing, three sides of the square round the hole. const c = Math.round(Math.cos(p.spot.open)); const sn = Math.round(Math.sin(p.spot.open)); @@ -504,10 +490,10 @@ export function buildStack(colliders: Collider[], planks: THREE.Material): Stack return { minX: p.spot.x + Math.min(...xs), maxX: p.spot.x + Math.max(...xs), minZ: p.spot.z + Math.min(...zs), maxZ: p.spot.z + Math.max(...zs) }; }); for (const r of rails) mine.push({ ...r, top: 1.05 }); - } else if (others) { + } else if (pOthers) { mine.push({ ...around(p.spot, POLE.radius + 0.03), top: 99 }); } - const text = below && s.down ? `🚒 ⬇ ${s.down}` : ''; + const text = pBelow && s.down ? `🚒 ⬇ ${s.down}` : ''; if (text !== p.signText) { if (p.sign) { p.sign.removeFromParent(); @@ -541,8 +527,8 @@ export function buildStack(colliders: Collider[], planks: THREE.Material): Stack ceiling: ceilingMat, set, state, - poles: () => (state.count > 1 ? POLES : []), - polesGoDown: () => state.count > 1 && state.index > 0, + poles: () => (state.count > 1 && (currentPlan?.config?.poles ?? true) ? POLES : []), + polesGoDown: () => state.count > 1 && state.index > 0 && (currentPlan?.config?.poles ?? true), onHatch: null, update(dt, people, eye) { const inHatch = (p: { x: number; z: number }) => p.x > LADDER.hatch.minX - 0.1 && p.x < LADDER.hatch.maxX && p.z > LADDER.hatch.minZ - 0.1 && p.z < LADDER.hatch.maxZ + 0.1; @@ -587,9 +573,8 @@ declare module './types' { /** The office floor's floor and ceiling, with the ways up and down to the other floors through them. */ export const stack: Fixture<'stack'> = (site) => { - const built = buildStack(site.colliders, site.planks); - built.set({ index: 0, count: 1 }); - // The ladder and its sign, up the west wall. - site.wall('west', LADDER.z + 0.6, WALL_HEIGHT / 2, LADDER.width + 2.4, WALL_HEIGHT); + const built = buildStack(site.colliders, site.planks, () => site.plan); + built.set({ index: 0, count: 1 }, site.plan); + if (site.plan?.config?.ladder ?? true) site.wall('west', LADDER.z + 0.6, WALL_HEIGHT / 2, LADDER.width + 2.4, WALL_HEIGHT); return { group: built.group, interactables: built.interactables, handle: { stack: built } }; }; diff --git a/src/client/world/types.ts b/src/client/world/types.ts index 889fb4f2d..9b81b308f 100644 --- a/src/client/world/types.ts +++ b/src/client/world/types.ts @@ -45,6 +45,7 @@ export interface Interactable { deskId?: string; decorId?: string; seatId?: string; + boardId?: string; /** Which of POLES, for a fire pole. */ pole?: number; /** Which of CARS (shared/garage.ts), for a car. */ diff --git a/src/client/world/world.ts b/src/client/world/world.ts index 3c344afb2..a29f0720b 100644 --- a/src/client/world/world.ts +++ b/src/client/world/world.ts @@ -8,6 +8,7 @@ import type { AirlockView } from './station/airlock'; import type { Person } from './character'; import type { Area } from './confetti'; import type { Gong } from '../features/gong/world'; +import type { MapPropsView } from './office/map-props'; import type { Collider, DeskView, Interactable, Office } from './types'; import type { SkyLights } from './sky'; @@ -45,6 +46,8 @@ export interface World { meetingSign?: THREE.Mesh; /** The gong a merged pull request rings, if the map has one. */ gong?: Gong; + /** Custom map props: meeting signs, meeting boards, etc. */ + mapProps?: MapPropsView; nav: NavGrid; ways: Ways; /** Where confetti rains when a pull request merges, and from how high over each spot. */ @@ -96,9 +99,9 @@ function ceilingOver(x: number, z: number): number { * The office as a world. `upstairs` says whether this floor is above the bottom one (no exit door: * workers leave by the balcony), and `wing` how many rows its back office is built out (see WING). */ -export function officeWorld(office: Office, upstairs: () => boolean, wing: () => number): World { +export function officeWorld(office: Office, upstairs: () => boolean, wing: () => number, customPlan?: MapPlan): World { return { - plan: OFFICE_PLAN, + plan: customPlan ?? OFFICE_PLAN, group: office.group, colliders: office.colliders, interactables: office.interactables, @@ -108,6 +111,7 @@ export function officeWorld(office: Office, upstairs: () => boolean, wing: () => meetingBoard: office.meetingBoard, meetingSign: office.meetingSign, gong: office.gong, + mapProps: office.mapProps, get nav() { return officeNav(wing()); }, diff --git a/src/server/agents.ts b/src/server/agents.ts index d5caeca0a..5aa925068 100644 --- a/src/server/agents.ts +++ b/src/server/agents.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { AGENT_PROVIDERS, PROVIDER_META, isAgentEffort, providerMeta, providerNames, type AgentProvider } from '../shared/providers.js'; +import { AGENT_PROVIDERS, PROVIDER_META, isAgentEffort, providerMeta, providerNames, getPluginProviders, getAllProviders, type AgentProvider } from '../shared/providers.js'; /** * Finds the provider represented by the configured executable. Keep this deliberately based on @@ -9,17 +9,23 @@ import { AGENT_PROVIDERS, PROVIDER_META, isAgentEffort, providerMeta, providerNa */ export function configuredProvider(command: string): AgentProvider { const base = path.basename(command.replaceAll('\\', '/')).toLowerCase().replace(/\.(?:exe|cmd|bat|com)$/, ''); - return AGENT_PROVIDERS.find((p) => PROVIDER_META[p].bin === base) ?? 'custom'; + for (const p of getAllProviders()) { + const meta = PROVIDER_META[p]; + if (!meta) continue; + if (meta.matchesCommand?.(command)) return p as AgentProvider; + if (meta.bin === base || meta.bins?.includes(base)) return p as AgentProvider; + } + return 'custom'; } -/** The providers an office started with `configured` can hire: the ones it knows, and a custom --agent only when that's what it was started with. */ +/** The providers an office started with `configured` can hire: the ones it knows, the ones its plugins register, and a custom --agent only when that's what it was started with. */ export function agentProviders(configured: AgentProvider): AgentProvider[] { - return AGENT_PROVIDERS.filter((p) => p !== 'custom' || configured === 'custom'); + return [...AGENT_PROVIDERS, ...getPluginProviders()].filter((p) => p !== 'custom' || configured === 'custom') as AgentProvider[]; } /** The command a provider's CLI runs as: the office's --agent when that's this provider, else its own executable. */ export function providerCommand(provider: AgentProvider, agentCmd: string): string { - return configuredProvider(agentCmd) === provider ? agentCmd : (PROVIDER_META[provider].bin ?? agentCmd); + return configuredProvider(agentCmd) === provider ? agentCmd : (PROVIDER_META[provider]?.bin ?? agentCmd); } export function validateWorkerModel(kind: 'agent' | 'shell', provider: AgentProvider | undefined, model: unknown): string | undefined { @@ -27,13 +33,13 @@ export function validateWorkerModel(kind: 'agent' | 'shell', provider: AgentProv if (kind === 'shell') return 'Shell workers do not have an agent model'; const meta = providerMeta(provider); if (!meta?.validModel) return `Models can only be selected for ${providerNames((m) => !!m.validModel)} workers`; - return meta.validModel(model) ? undefined : meta.invalidModel; + return meta.validModel(model) ? undefined : (meta.invalidModel ?? `Invalid ${meta.label} model`); } /** A reasoning effort for a worker: a flag of its provider's CLI, an OpenCode model's variant, or DSH's reasoning_effort configuration option. */ export function validateWorkerEffort(kind: 'agent' | 'shell', provider: AgentProvider | undefined, effort: unknown): string | undefined { if (effort === undefined) return undefined; - if (kind === 'shell') return 'Shell workers do not have a reasoning effort'; + if (kind === 'shell') return 'Shell workers do not have an agent effort'; if (!providerMeta(provider)?.takesEffort) return `Reasoning effort can only be selected for ${providerNames((m) => !!m.takesEffort)} workers`; return isAgentEffort(effort) ? undefined : 'Invalid effort (expected low, medium, high, xhigh or max)'; } diff --git a/src/server/building-store.ts b/src/server/building-store.ts new file mode 100644 index 000000000..2a1c4dbe2 --- /dev/null +++ b/src/server/building-store.ts @@ -0,0 +1,47 @@ +// Floors.json: what the building keeps on disk, and how it is put back. +// +// The building keeps its floors there so a restart is the same office; the shape on disk can be +// anything (people edit it, older offices wrote it), so it is checked on the way in and rewritten in +// the office's own shape on the way out. +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { normalizeRepo } from '../shared/floors.js'; +import type { FloorDef } from './building.js'; + +/** Reads floors.json, skipping anything that isn't a floor this office can use. */ +export function loadFloors(file: string): FloorDef[] { + if (!existsSync(file)) return []; + try { + const saved = JSON.parse(readFileSync(file, 'utf8')) as Partial[]; + const ids = new Set(); + const out: FloorDef[] = []; + for (const s of Array.isArray(saved) ? saved : []) { + if (typeof s.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(s.id) || ids.has(s.id) || typeof s.dir !== 'string' || !path.isAbsolute(s.dir)) continue; + ids.add(s.id); + out.push({ + id: s.id, + name: typeof s.name === 'string' && s.name ? s.name.slice(0, 100) : path.basename(s.dir), + repo: normalizeRepo(s.repo), + dir: s.dir, + palette: Number.isInteger(s.palette) && (s.palette as number) >= 0 ? (s.palette as number) : 0, + addedBy: typeof s.addedBy === 'string' ? s.addedBy : '?', + addedAt: typeof s.addedAt === 'number' ? s.addedAt : Date.now(), + ...(typeof s.externalId === 'string' ? { externalId: s.externalId } : {}), + ...(typeof s.url === 'string' ? { url: s.url } : {}), + }); + } + return out; + } catch (err) { + console.error(`agent-office: ${file} couldn't be read, so the building starts empty: ${(err as Error).message}`); + return []; + } +} + +/** Writes floors.json, so the next office opens the same floors. */ +export function saveFloors(file: string, floors: FloorDef[]): void { + try { + writeFileSync(file, JSON.stringify(floors, null, 2), { mode: 0o600 }); + } catch (err) { + console.error(`agent-office: couldn't save the floors: ${(err as Error).message}`); + } +} diff --git a/src/server/building.ts b/src/server/building.ts index 2716aeacd..b317c6de1 100644 --- a/src/server/building.ts +++ b/src/server/building.ts @@ -6,6 +6,8 @@ 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 { loadFloors, saveFloors } from './building-store.js'; +import { mergeFloors } from './plugin-floors.js'; /** A floor as floors.json keeps it. */ export interface FloorDef { @@ -17,29 +19,22 @@ export interface FloorDef { palette: number; addedBy: string; addedAt: number; + /** Optional external identifier or metadata from a plugin. */ + externalId?: string; + url?: string; } /** A projects folder picked in ⚙️ Settings (or with --projects), as projects-folder.json keeps it. */ -interface PickedDir { - dir: string; - by: string; - at: number; -} +interface PickedDir { dir: string; by: string; at: number; } /** The checkout the office was started in, once it's been taken off the building (local-floor.json). */ -interface LocalOff { - dir: string; - by: string; - at: number; -} +interface LocalOff { dir: string; by: string; at: number; } /** A floor on its way: its clone, and who can stop it besides admins. */ interface Pending { def: FloorDef; run?: CloneRun; - /** The account that added it. */ owner?: string; - /** GitHub says the repository has no commits yet, so there's nothing to check out. */ empty: boolean; } @@ -52,27 +47,25 @@ interface SavedClone extends FloorDef { } export interface BuildingOptions { - /** - * Clones run in this terminal (`agent-office setup`), showing git's own progress and asking - * there if ssh or git has a question, rather than watched by the office. - */ + /** Clones run in this terminal (setup), showing git's progress rather than being watched. */ terminal?: boolean; /** How clones are watched (tests shorten these). */ clone?: Pick; } -/** How long the list of repositories `gh` can see is reused before it's asked again. */ +/** How long `gh`'s repository list is reused before it's asked again. */ const REPOS_TTL_MS = 5 * 60_000; const MAX_REPOS = 1000; /** * The floors of the building, saved in /.agent-office/floors.json: which projects there are, - * where their checkouts live, and how each floor is painted. New floors are cloned with the office - * machine's `gh` login into //; the projects folder can be picked in ⚙️ Settings - * (kept in projects-folder.json). + * where their checkouts live, and how each floor is painted. A floor is a clone into + * // (`add`), or a checkout that is already on disk (`addExisting`, for an + * existing project folder). The projects folder can be picked in ⚙️ Settings (projects-folder.json). */ export class Building { private defs: FloorDef[] = []; + private removedIds = new Set(); private file: string; private pickedFile: string; private picked?: PickedDir; @@ -89,6 +82,8 @@ export class Building { /** The floor that checkout is, while it is one. */ private localId?: string; private localFile: string; + private maxFloorsFile: string; + private maxFloorsLimit: number = MAX_FLOORS; /** That checkout was taken off the building: a restart doesn't put it back. */ private localOff?: LocalOff; @@ -104,9 +99,30 @@ export class Building { this.logsDir = path.join(dataDir, 'clones'); this.pickedFile = path.join(dataDir, 'projects-folder.json'); this.localFile = path.join(dataDir, 'local-floor.json'); - this.load(); + this.maxFloorsFile = path.join(dataDir, 'max-floors.json'); + this.defs.push(...loadFloors(this.file)); this.loadPicked(); this.loadLocalOff(); + this.loadMaxFloors(); + } + + private loadMaxFloors() { + if (!existsSync(this.maxFloorsFile)) return; + try { + const raw = JSON.parse(readFileSync(this.maxFloorsFile, 'utf8')); + if (typeof raw?.max === 'number' && raw.max > 0) this.maxFloorsLimit = raw.max; + } catch { /* ignore */ } + } + + setMaxFloors(max: number, by: string) { + this.maxFloorsLimit = Math.max(1, Math.min(2000, Math.floor(max))); + try { + writeFileSync(this.maxFloorsFile, JSON.stringify({ max: this.maxFloorsLimit, by, at: Date.now() }, null, 2), { mode: 0o600 }); + } catch { /* ignore */ } + } + + get maxFloors(): number { + return this.maxFloorsLimit; } /** Where new floors are cloned. Floors already there stay where they are when it moves. */ @@ -115,7 +131,7 @@ export class Building { } projectsDirState(): ProjectsDirState { - return { dir: tildify(this.projectsDir), custom: !!this.picked, by: this.picked?.by, at: this.picked?.at }; + return { dir: tildify(this.projectsDir), custom: !!this.picked, by: this.picked?.by, at: this.picked?.at, maxFloors: this.maxFloors }; } /** Clones new floors into `raw` from now on ('~' is the home folder; '' goes back to the default). Returns why it can't, if it can't. */ @@ -147,6 +163,47 @@ export class Building { return this.defs; } + /** + * Merges the floors a plugin provides into the building (see plugin-floors.ts): its own arrive, the + * ones it has dropped go, and everything the office and its people added stays where it is. + */ + mergeList(defs: FloorDef[]) { + const next = mergeFloors(this.defs, defs); + if (JSON.stringify(this.defs) === JSON.stringify(next)) return; + this.defs = next; + this.save(); + } + + setList(defs: FloorDef[]) { + const filtered = defs.filter((d) => !this.removedIds.has(d.id) && !this.removedIds.has(path.resolve(d.dir)) && (!d.externalId || !this.removedIds.has(d.externalId))); + if (JSON.stringify(this.defs) === JSON.stringify(filtered)) return; + this.defs = filtered; + this.save(); + } + + unremove(idOrDef: string | FloorDef) { + const [id, dir, ext] = typeof idOrDef === 'string' ? [idOrDef, idOrDef, undefined] : [idOrDef.id, idOrDef.dir, idOrDef.externalId]; + this.removedIds.delete(id); + this.removedIds.delete(path.resolve(dir)); + if (ext) this.removedIds.delete(ext); + } + + /** A floor for a checkout already on disk, so nothing is cloned. */ + addExisting(dir: string, name: string, by: string, externalId?: string, url?: string): FloorDef | string { + const abs = path.resolve(dir); + this.removedIds.delete(abs); if (externalId) this.removedIds.delete(externalId); + const dup = this.defs.some((d) => path.resolve(d.dir) === abs || (externalId && d.externalId === externalId)); + if (dup) return `${name} already has a floor`; + if (this.defs.length >= this.maxFloors) return `The building is full (${this.maxFloors} floors)`; + const def = this.newDef(name, externalId ? undefined : originRepo(abs), abs, by); + def.externalId = externalId; + def.url = url; + if (externalId) def.repo = undefined; + this.defs.push(def); + this.save(); + return def; + } + /** Floors on their way: shown in the elevator, but nobody can ride there yet. */ pending(): FloorDef[] { return [...this.cloning.values()].map((p) => p.def); @@ -266,6 +323,8 @@ export class Building { const def = this.defs.find((d) => d.id === id); if (!def) return this.pendingFloor(id) ? "That floor is still being cloned — stop it, or take it off once it's there" : 'No such floor'; this.defs = this.defs.filter((d) => d !== def); + this.removedIds.add(id); this.removedIds.add(path.resolve(def.dir)); + if (def.externalId) this.removedIds.add(def.externalId); if (this.isLocal(id)) { this.localId = undefined; this.setLocalOff({ dir: def.dir, by, at: Date.now() }); @@ -285,11 +344,13 @@ export class Building { if (!wanted) return 'Pick a repository, or type it as owner/name'; if (this.defs.some((d) => sameRepo(d.repo, wanted))) return `${wanted} already has a floor`; if (this.cloning.has(wanted.toLowerCase())) return `${wanted} is already being cloned`; - if (this.defs.length + this.cloning.size >= MAX_FLOORS) return `The building is full (${MAX_FLOORS} floors)`; + if (this.defs.length + this.cloning.size >= this.maxFloors) return `The building is full (${this.maxFloors} floors)`; // The office's own checkout, taken off before: it moves back in where it is, not into a second clone. const home = this.local; if (this.localOff && home && sameRepo(home.repo, wanted) && existsSync(home.dir)) { const def = this.newDef(path.basename(home.dir), home.repo, home.dir, by); + this.removedIds.delete(def.id); + this.removedIds.delete(path.resolve(def.dir)); started(def); this.defs.push(def); this.localId = def.id; @@ -324,6 +385,8 @@ export class Building { this.cloning.delete(key); this.saveClones(); } + this.removedIds.delete(def.id); this.removedIds.delete(path.resolve(def.dir)); + if (def.externalId) this.removedIds.delete(def.externalId); this.defs.push(def); this.save(); return def; @@ -393,29 +456,6 @@ export class Building { return { id, name, repo, dir, palette, addedBy: by, addedAt: Date.now() }; } - private load() { - if (!existsSync(this.file)) return; - try { - const saved = JSON.parse(readFileSync(this.file, 'utf8')) as Partial[]; - const ids = new Set(); - for (const s of Array.isArray(saved) ? saved : []) { - if (typeof s.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(s.id) || ids.has(s.id) || typeof s.dir !== 'string' || !path.isAbsolute(s.dir)) continue; - ids.add(s.id); - this.defs.push({ - id: s.id, - name: typeof s.name === 'string' && s.name ? s.name.slice(0, 100) : path.basename(s.dir), - repo: normalizeRepo(s.repo), - dir: s.dir, - palette: Number.isInteger(s.palette) && (s.palette as number) >= 0 ? (s.palette as number) : 0, - addedBy: typeof s.addedBy === 'string' ? s.addedBy : '?', - addedAt: typeof s.addedAt === 'number' ? s.addedAt : Date.now(), - }); - } - } catch (err) { - console.error(`agent-office: ${this.file} couldn't be read, so the building starts empty: ${(err as Error).message}`); - } - } - private loadPicked() { try { const saved = JSON.parse(readFileSync(this.pickedFile, 'utf8')) as Partial; @@ -433,9 +473,7 @@ export class Building { if (typeof saved.dir === 'string' && path.isAbsolute(saved.dir)) { this.localOff = { dir: saved.dir, by: typeof saved.by === 'string' ? saved.by : '?', at: typeof saved.at === 'number' ? saved.at : Date.now() }; } - } catch { - // never taken off - } + } catch { /* never taken off */ } } private setLocalOff(off: LocalOff | undefined) { @@ -449,21 +487,13 @@ export class Building { } private save() { - try { - writeFileSync(this.file, JSON.stringify(this.defs, null, 2), { mode: 0o600 }); - } catch (err) { - console.error(`agent-office: couldn't save the floors: ${(err as Error).message}`); - } + saveFloors(this.file, this.defs); } private loadClones(): SavedClone[] { try { const saved = JSON.parse(readFileSync(this.clonesFile, 'utf8')) as Partial[]; - return (Array.isArray(saved) ? saved : []).filter( - // Its log is one of ours (it gets deleted), in .agent-office/clones. - (s): s is SavedClone => - Number.isInteger(s.pid) && (s.pid as number) > 0 && typeof s.log === 'string' && path.dirname(s.log) === this.logsDir && typeof s.dir === 'string' && path.isAbsolute(s.dir) && typeof s.name === 'string', - ); + return (Array.isArray(saved) ? saved : []).filter((s): s is SavedClone => Number.isInteger(s.pid) && (s.pid as number) > 0 && typeof s.log === 'string' && path.dirname(s.log) === this.logsDir && typeof s.dir === 'string' && path.isAbsolute(s.dir) && typeof s.name === 'string'); } catch { return []; } @@ -487,15 +517,8 @@ export function tildify(p: string): string { return p === home || p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p; } -function untildify(p: string): string { - return p === '~' || p.startsWith('~/') ? path.join(os.homedir(), p.slice(1)) : p; -} - -/** `dir` is `parent` or somewhere under it. */ -function within(dir: string, parent: string): boolean { - const rel = path.relative(parent, dir); - return !rel || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)); -} +function untildify(p: string): string { return p === '~' || p.startsWith('~/') ? path.join(os.homedir(), p.slice(1)) : p; } +function within(dir: string, parent: string): boolean { const rel = path.relative(parent, dir); return !rel || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)); } /** Why the office couldn't make checkouts under `dir`, if it couldn't. It's made on the first clone, so it needn't exist yet. */ function unwritable(dir: string): string | undefined { @@ -515,15 +538,9 @@ export function originRepo(dir: string): string | undefined { try { const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }).trim(); return /github\.com[/:]/i.test(url) ? normalizeRepo(url) : undefined; - } catch { - return undefined; - } + } catch { return undefined; } } -/** - * What's at `dest`: nothing yet ('none'), a checkout of `repo` ('ok'), or why it's in the way. A - * clone that was cut off has its origin but no commit checked out; an `empty` repository has none to. - */ function checkoutAt(dest: string, repo: string, empty: boolean): 'none' | 'ok' | string { if (!existsSync(dest)) return 'none'; if (!statSync(dest).isDirectory()) return `${dest} is already there and isn't a folder`; @@ -537,9 +554,7 @@ function hasCommit(dir: string): boolean { try { execFileSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], { cwd: dir, stdio: 'ignore', timeout: 10_000 }); return true; - } catch { - return false; - } + } catch { return false; } } /** Clones `repo` to `dest` in this terminal: git shows its progress, and ssh or git can ask here. Resolves to an error, if any. */ @@ -552,17 +567,7 @@ function cloneHere(repo: string, dest: string): Promise { } async function listRepos(cwd: string): Promise { - const out = await gh( - [ - 'api', - '--paginate', - 'user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member', - '--jq', - '.[] | {name: .full_name, description: (.description // ""), private: .private, pushedAt: .pushed_at}', - ], - cwd, - 90_000, - ); + const out = await gh(['api', '--paginate', 'user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member', '--jq', '.[] | {name: .full_name, description: (.description // ""), private: .private, pushedAt: .pushed_at}'], cwd, 90_000); const repos: RepoChoice[] = []; const seen = new Set(); for (const line of out.split('\n')) { @@ -572,15 +577,8 @@ async function listRepos(cwd: string): Promise { const name = normalizeRepo(r.name); if (!name || seen.has(name.toLowerCase())) continue; seen.add(name.toLowerCase()); - repos.push({ - name, - description: typeof r.description === 'string' && r.description ? r.description.slice(0, 200) : undefined, - private: r.private === true, - pushedAt: typeof r.pushedAt === 'string' ? r.pushedAt : undefined, - }); - } catch { - // not a line of ours - } + repos.push({ name, description: typeof r.description === 'string' && r.description ? r.description.slice(0, 200) : undefined, private: r.private === true, pushedAt: typeof r.pushedAt === 'string' ? r.pushedAt : undefined }); + } catch {} if (repos.length >= MAX_REPOS) break; } return repos.sort((a, b) => (b.pushedAt ?? '').localeCompare(a.pushedAt ?? '')); diff --git a/src/server/config.ts b/src/server/config.ts index 177613751..cf858576a 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -157,6 +157,8 @@ Options: whole day and night go by every hour --weather Pin the weather: clear, cloudy, rain, storm, snow or fog (env AGENT_OFFICE_WEATHER) + --plugin Load an external plugin bundle (.js/.mjs) + (env AGENT_OFFICE_PLUGIN) --real-time-sky Start the sky on the office's real clock, so it's night when it's night there, instead of a day and night every hour (⚙️ Settings can switch it) @@ -222,7 +224,7 @@ export function loadConfig(argv: string[]): Config { let projects = process.env.AGENT_OFFICE_PROJECTS ? path.resolve(process.env.AGENT_OFFICE_PROJECTS) : ''; let port = Number(process.env.PORT) || 4600; // Loopback unless asked: an office lets whoever signs in run commands on this machine. - let host = '127.0.0.1'; + let host = process.env.AGENT_OFFICE_HOST || process.env.HOST || '127.0.0.1'; let open = !process.env.AGENT_OFFICE_NO_OPEN || process.env.AGENT_OFFICE_NO_OPEN === '0'; let password = process.env.AGENT_OFFICE_PASSWORD || ''; let agentCmd = process.env.AGENT_OFFICE_AGENT || 'claude'; @@ -326,6 +328,9 @@ export function loadConfig(argv: string[]): Config { case '--real-time-sky': realTimeSky = true; break; + case '--plugin': + process.env.AGENT_OFFICE_PLUGIN = path.resolve(takeValue(argv, i++, a)); + break; default: if (a.startsWith('-')) { console.error(`agent-office: unknown option ${a}\n`); diff --git a/src/server/floor.ts b/src/server/floor.ts index ad3bd25fd..c92e804ab 100644 --- a/src/server/floor.ts +++ b/src/server/floor.ts @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process'; import { mkdirSync } from 'node:fs'; import path from 'node:path'; -import type { ChangesState, FloorInfo, PeerInfo, ProjectInfo, ServerMsg, WorkerInfo } from '../shared/protocol.js'; +import type { BoardCard, BoardCardDetail, ChangesState, CustomBoardDef, CustomBoardState, FloorInfo, PeerInfo, ProjectInfo, ServerMsg, WorkerInfo } from '../shared/protocol.js'; import { isBusy } from '../shared/status.js'; import { DESK_BY_ID } from '../shared/layout.js'; import type { FloorDef } from './building.js'; @@ -15,6 +15,7 @@ import { Changes } from './changes.js'; import { Decor } from './decor.js'; import { FloorPlanStore } from './floorplan.js'; import { Docs } from './docs.js'; +import type { BoardAdapter, IssuesAdapter, PullsAdapter, QueueAdapter } from './plugins/types.js'; import { Dog } from './dog.js'; import { Court } from './court.js'; import { Jail } from './jail.js'; @@ -32,6 +33,10 @@ type ToastLevel = 'info' | 'warn' | 'error'; /** What a floor needs from the building around it. */ export interface FloorContext { + issuesAdapter?: IssuesAdapter | (() => IssuesAdapter | null | undefined); + pullsAdapter?: PullsAdapter | (() => PullsAdapter | null | undefined); + queueAdapter?: QueueAdapter | (() => QueueAdapter | null | undefined); + boardAdapters?: () => BoardAdapter[]; agentCmd: string; agentArgs: string[]; /** The DSH profile DeepSeek Harness workers boot (see server/dsh.ts). */ @@ -85,7 +90,7 @@ const IDLE_REFRESH_MS = 10 * 60_000; const REFRESH_MS = 90_000; /** What `git` says about a checkout: its name, branch and origin for the top bar. */ -export function projectInfo(dir: string, name: string, agentCmd: string, agentArgs: string[]): ProjectInfo { +export function projectInfo(dir: string, name: string, agentCmd: string, agentArgs: string[], externalId?: string, url?: string): ProjectInfo { const git = (args: string[]) => { try { return execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); @@ -96,6 +101,8 @@ export function projectInfo(dir: string, name: string, agentCmd: string, agentAr return { name, dir, + ...(externalId ? { externalId } : {}), + ...(url ? { url } : {}), branch: git(['rev-parse', '--abbrev-ref', 'HEAD']), remote: git(['remote', 'get-url', 'origin']), agentCmd: [agentCmd, ...agentArgs].join(' '), @@ -152,7 +159,7 @@ export class Floor { const dataDir = path.join(def.dir, '.agent-office'); mkdirSync(dataDir, { recursive: true, mode: 0o700 }); excludeFromGit(def.dir); - this.project = projectInfo(def.dir, def.name, ctx.agentCmd, ctx.agentArgs); + this.project = projectInfo(def.dir, def.name, ctx.agentCmd, ctx.agentArgs, def.externalId, def.url); this.docs = new Docs(def.dir); // Before the workers and the dog: the back office's desks are only there once it's built. this.plan = new FloorPlanStore(dataDir); @@ -223,8 +230,29 @@ export class Floor { this.sendLandedHome(); ctx.pullsChanged(this); }, + async () => { + const a = typeof ctx.issuesAdapter === 'function' ? ctx.issuesAdapter() : ctx.issuesAdapter; + return a ? await a.listIssues(this) : null; + }, + async () => { + const a = typeof ctx.pullsAdapter === 'function' ? ctx.pullsAdapter() : ctx.pullsAdapter; + return a ? await a.listPulls(this) : null; + }, + async (n: number, me?: string) => { + const a = typeof ctx.issuesAdapter === 'function' ? ctx.issuesAdapter() : ctx.issuesAdapter; + return a?.issueDetail ? await a.issueDetail(this, n, me) : null; + }, + async (n: number, me?: string) => { + const a = typeof ctx.pullsAdapter === 'function' ? ctx.pullsAdapter() : ctx.pullsAdapter; + return a?.pullDetail ? await a.pullDetail(this, n, me) : null; + }, + async (n: number) => { + const a = typeof ctx.pullsAdapter === 'function' ? ctx.pullsAdapter() : ctx.pullsAdapter; + return a?.pullDiff ? await a.pullDiff(this, n) : null; + }, ); // The 📋 task queue seats workers by itself: it watches the workers and links PRs from GitHub. + const queueAdapter = typeof ctx.queueAdapter === 'function' ? ctx.queueAdapter() : ctx.queueAdapter; this.queue = new TaskQueue(dataDir, this.workers, !!this.project.branch, { update: (state) => { ctx.emit(this, { t: 'queue', state }); @@ -244,7 +272,7 @@ export class Floor { ctx.emit(this, { t: 'gong', why: 'queue' }); }, worktreeNote: () => officePrompt(ctx.prompts, 'queue.worktree'), - }); + }, queueAdapter ? { adapter: queueAdapter, floor: this } : undefined); // Meetings seat their own workers round the meeting room's table and run them round by round. const workers = this.workers; @@ -383,6 +411,96 @@ export class Floor { /** Someone just walked in: boards that haven't been looked at in a while get fetched again. */ arrived() { if (Date.now() - Math.max(this.github.issues.fetchedAt, this.github.pulls.fetchedAt) > REFRESH_MS) void this.github.refresh(); + for (const a of this.boardAdapters()) { + const st = this.customBoardStates.get(a.getBoardDef().id); + if (!st || !st.fetchedAt || Date.now() - st.fetchedAt > REFRESH_MS) { + void this.refreshBoard(a.getBoardDef().id); + } + } + } + + private customBoardStates = new Map(); + + boardAdapters(): BoardAdapter[] { + return this.ctx.boardAdapters ? this.ctx.boardAdapters() : []; + } + + getBoardAdapter(boardId: string): BoardAdapter | null { + return this.boardAdapters().find((a) => a.getBoardDef().id === boardId) ?? null; + } + + boardsState(): Record { + const out: Record = {}; + for (const a of this.boardAdapters()) { + const def = a.getBoardDef(); + const existing = this.customBoardStates.get(def.id); + out[def.id] = existing ?? { def, cards: [], columns: def.columns, loading: false }; + } + return out; + } + + async refreshBoard(boardId: string): Promise { + const adapter = this.getBoardAdapter(boardId); + if (!adapter) throw new Error(`No such board: ${boardId}`); + let def = adapter.getBoardDef(); + const prev = this.customBoardStates.get(boardId) ?? { def, cards: [] }; + this.customBoardStates.set(boardId, { ...prev, loading: true }); + this.ctx.emit(this, { t: 'board.state', boardId, state: this.customBoardStates.get(boardId)! }); + try { + const cards = await adapter.listCards(this); + def = adapter.getBoardDef(); + let currentUser: { displayName?: string; email?: string; name?: string } | undefined; + if (adapter.getCurrentUser) { + try { + currentUser = (await adapter.getCurrentUser(this)) ?? undefined; + } catch { + /* ignore */ + } + } + const state: CustomBoardState = { def, cards, columns: def.columns, currentUser, fetchedAt: Date.now(), loading: false }; + this.customBoardStates.set(boardId, state); + this.ctx.emit(this, { t: 'board.state', boardId, state }); + return state; + } catch (err) { + def = adapter.getBoardDef(); + const state: CustomBoardState = { def, cards: prev.cards, columns: def.columns, fetchedAt: prev.fetchedAt, loading: false, error: (err as Error).message }; + this.customBoardStates.set(boardId, state); + this.ctx.emit(this, { t: 'board.state', boardId, state }); + return state; + } + } + + async listBoardCards(boardId: string): Promise { + const adapter = this.getBoardAdapter(boardId); + if (!adapter) return []; + return adapter.listCards(this); + } + + async getBoardCardDetail(boardId: string, cardId: string): Promise { + const adapter = this.getBoardAdapter(boardId); + if (!adapter?.cardDetail) return null; + return adapter.cardDetail(this, cardId); + } + + async assignBoardCard(boardId: string, cardId: string, assignee?: string): Promise { + const adapter = this.getBoardAdapter(boardId); + if (!adapter?.assignCard) throw new Error(`Board ${boardId} does not support assignment`); + const card = await adapter.assignCard(this, cardId, assignee); + void this.refreshBoard(boardId); + return card; + } + + async getBoardConfig(boardId: string): Promise | null> { + const adapter = this.getBoardAdapter(boardId); + if (!adapter?.getConfig) return null; + return adapter.getConfig(this); + } + + async saveBoardConfig(boardId: string, config: Record): Promise { + const adapter = this.getBoardAdapter(boardId); + if (!adapter?.saveConfig) throw new Error(`Board ${boardId} does not support saving config`); + await adapter.saveConfig(this, config); + void this.refreshBoard(boardId); } private active(): boolean { @@ -400,6 +518,8 @@ export class Floor { palette: this.def.palette, addedBy: this.def.addedBy, addedAt: this.def.addedAt, + externalId: this.def.externalId, + url: this.def.url, workers: ws.filter((w) => !DESK_BY_ID.get(w.deskId)?.station).length, busy: ws.filter((w) => w.status === 'working').length, waiting: ws.filter((w) => w.kind === 'agent' && (w.status === 'needs_input' || (w.status === 'done' && !w.acked))).length, diff --git a/src/server/github.ts b/src/server/github.ts index bb089336b..f34019d6a 100644 --- a/src/server/github.ts +++ b/src/server/github.ts @@ -75,26 +75,7 @@ function commentsOf(raw: any[]): GhComment[] { })); } -const PULL_LIST_FIELDS = [ - 'number', - 'title', - 'state', - 'isDraft', - 'url', - 'author', - 'labels', - 'reviewDecision', - 'headRefName', - 'headRefOid', - 'baseRefName', - 'createdAt', - 'updatedAt', - 'additions', - 'deletions', - 'statusCheckRollup', - 'body', - 'closingIssuesReferences', -]; +const PULL_LIST_FIELDS = ['number', 'title', 'state', 'isDraft', 'url', 'author', 'labels', 'reviewDecision', 'headRefName', 'headRefOid', 'baseRefName', 'createdAt', 'updatedAt', 'additions', 'deletions', 'statusCheckRollup', 'body', 'closingIssuesReferences']; const REQUIRED_PULL_LIST_FIELDS = new Set(['number', 'title', 'state']); function unsupportedJsonField(message: string): string | undefined { @@ -162,6 +143,18 @@ export class Claims { } } +/** + * A list a plugin's adapter has for a floor's board, when it has taken the board over. + * + * A plugin that lists anything at all is the board for that project: it knows the issues and pull + * requests the office's own `gh` login can't see. One that has none leaves GitHub to answer, so a + * project the plugin doesn't cover still shows its own, and the board's Refresh keeps meaning + * something for everyone. + */ +export function adapterBoard(listed: T[] | null | undefined): T[] | null { + return listed && listed.length ? listed : null; +} + export class GitHub { issues: GhState = { items: [], fetchedAt: 0, loading: false }; pulls: GhState = { items: [], fetchedAt: 0, loading: false }; @@ -175,12 +168,28 @@ export class GitHub { /** The look at the issues that's under way, if one is. */ private listing?: Promise; + private runGh: GhRunner; + private customIssues?: () => Promise; + constructor( private dir: string, private onIssues: (s: GhState) => void, private onPulls: (s: GhState) => void, - private runGh: GhRunner = gh, - ) {} + customIssuesOrRunner?: (() => Promise) | GhRunner, + private customPulls?: () => Promise, + private customIssueDetail?: (n: number, me?: string) => Promise, + private customPullDetail?: (n: number, me?: string) => Promise, + private customPullDiff?: (n: number) => Promise, + runGh: GhRunner = gh, + ) { + if (typeof customIssuesOrRunner === 'function' && customIssuesOrRunner.length > 0 && !customPulls) { + this.runGh = customIssuesOrRunner as GhRunner; + this.customIssues = undefined; + } else { + this.customIssues = customIssuesOrRunner as (() => Promise) | undefined; + this.runGh = runGh; + } + } start() { void this.refresh(); @@ -218,8 +227,23 @@ export class GitHub { * GitHub login of whoever asked, when they're signed in to their own; else it's the office's. */ async pullDetail(n: number, me?: string): Promise { + const existing = this.pulls.items.find((it) => it.number === n); + if (existing && this.customPullDetail) { + const custom = await this.customPullDetail(n, me); + if (custom) return custom; + } + if (existing && this.customPulls) { + return { + number: existing.number, body: existing.body, state: existing.state, isDraft: existing.isDraft, + reviewDecision: existing.reviewDecision, headRefName: existing.headRefName, baseRefName: existing.baseRefName, + mergeable: 'UNKNOWN', mergeStateStatus: 'UNKNOWN', commits: 1, comments: [], reviews: [], reviewComments: [], + checks: existing.checks !== 'none' ? [{ name: 'CI', state: existing.checks === 'pass' ? 'pass' : existing.checks === 'fail' ? 'fail' : 'pending' }] : [], + repo: { nameWithOwner: existing.headRefName || 'project', methods: ['squash', 'merge', 'rebase'] }, + viewer: me ?? '', + }; + } const fields = 'number,body,state,isDraft,reviewDecision,headRefName,baseRefName,mergeable,mergeStateStatus,commits,comments,reviews,statusCheckRollup'; - const jq = '.[] | {id, in_reply_to_id, path, line, side, body, user: .user.login, created_at, html_url}'; + const jq = '.[] | {id, in_reply_to_id, path, line, side, body, user: .user.login, created_at, html_url, diff_hunk}'; const [view, lines, repo, viewer] = await Promise.all([ this.runGh(['pr', 'view', String(n), '--json', fields], this.dir), this.runGh(['api', `repos/{owner}/{repo}/pulls/${n}/comments?per_page=100`, '--paginate', '--jq', jq], this.dir), @@ -241,6 +265,7 @@ export class GitHub { path: c.path, line: c.line ?? null, side: c.side === 'LEFT' ? 'LEFT' : 'RIGHT', + diffHunk: c.diff_hunk ?? undefined, })); return { number: p.number, @@ -264,14 +289,34 @@ export class GitHub { } /** The PR's unified diff, as `git diff` prints it. */ - pullDiff(n: number): Promise { + async pullDiff(n: number): Promise { + if (this.customPullDiff) { + const custom = await this.customPullDiff(n); + if (custom !== null && custom !== undefined) return custom; + } + if (this.customPulls) return ''; return this.runGh(['pr', 'diff', String(n), '--color', 'never'], this.dir, 60_000); } async issueDetail(n: number, me?: string): Promise { - const [view, viewer] = await Promise.all([this.runGh(['issue', 'view', String(n), '--json', 'number,state,body,comments'], this.dir), me ?? this.viewer()]); - const i = JSON.parse(view); - return { number: i.number, state: i.state, body: String(i.body ?? ''), comments: commentsOf(i.comments), viewer }; + const existing = this.issues.items.find((it) => it.number === n); + if (existing && this.customIssueDetail) { + const custom = await this.customIssueDetail(n, me); + if (custom) return custom; + } + if (existing && this.customIssues) { + return { number: existing.number, state: existing.state, body: existing.body, comments: [], viewer: me ?? '' }; + } + try { + const [view, viewer] = await Promise.all([this.runGh(['issue', 'view', String(n), '--json', 'number,state,body,comments'], this.dir), me ?? this.viewer()]); + const i = JSON.parse(view); + return { number: i.number, state: i.state, body: String(i.body ?? ''), comments: commentsOf(i.comments), viewer }; + } catch (err) { + if (existing) { + return { number: existing.number, state: existing.state, body: existing.body, comments: [], viewer: me ?? '' }; + } + throw err; + } } /** @@ -454,25 +499,34 @@ export class GitHub { this.onIssues(this.issues); const asked = Date.now(); try { - // Open and closed separately, so old open issues are never crowded out by recent closed ones. - const fields = 'number,title,state,url,author,labels,assignees,createdAt,updatedAt,body,comments'; - const [open, closed] = await Promise.all([ - this.runGh(['issue', 'list', '--state', 'open', '--limit', '300', '--json', fields], this.dir), - this.runGh(['issue', 'list', '--state', 'closed', '--limit', '40', '--json', fields], this.dir), - ]); - const fetched: GhIssue[] = [...JSON.parse(open), ...JSON.parse(closed)].map((i: any) => ({ - number: i.number, - title: i.title, - state: i.state, - url: i.url, - author: i.author?.login ?? '', - labels: labels(i.labels), - assignees: (i.assignees ?? []).map((a: any) => a.login), - createdAt: i.createdAt, - updatedAt: i.updatedAt, - body: String(i.body ?? '').slice(0, 4000), - comments: Array.isArray(i.comments) ? i.comments.length : Number(i.comments ?? 0), - })); + let fetched: GhIssue[] | null = null; + if (this.customIssues) { + // A plugin that lists any issues at all is the board for this project: it knows the issues + // the office's own `gh` login can't see. One that has none leaves GitHub to answer, so a + // project the plugin doesn't cover still shows its issues. + fetched = adapterBoard(await this.customIssues()); + } + if (fetched === null) { + // Open and closed separately, so old open issues are never crowded out by recent closed ones. + const fields = 'number,title,state,url,author,labels,assignees,createdAt,updatedAt,body,comments'; + const [open, closed] = await Promise.all([ + this.runGh(['issue', 'list', '--state', 'open', '--limit', '300', '--json', fields], this.dir), + this.runGh(['issue', 'list', '--state', 'closed', '--limit', '40', '--json', fields], this.dir), + ]); + fetched = [...JSON.parse(open), ...JSON.parse(closed)].map((i: any) => ({ + number: i.number, + title: i.title, + state: i.state, + url: i.url, + author: i.author?.login ?? '', + labels: labels(i.labels), + assignees: (i.assignees ?? []).map((a: any) => a.login), + createdAt: i.createdAt, + updatedAt: i.updatedAt, + body: String(i.body ?? '').slice(0, 4000), + comments: Array.isArray(i.comments) ? i.comments.length : Number(i.comments ?? 0), + })); + } const items = this.claims.mark(this.relabel('issue', fetched, asked), asked); this.issues = { items, fetchedAt: Date.now(), loading: false }; } catch (err) { @@ -487,35 +541,41 @@ export class GitHub { this.onPulls(this.pulls); const asked = Date.now(); try { - const [open, merged, closed] = await Promise.all([ - this.listPulls('open', 150), - this.listPulls('merged', 30), - this.listPulls('closed', 40), - ]); - // `--state closed` includes merged PRs; keep only the ones closed without merging. - const seen = new Set(); - const all = [...open, ...merged, ...closed].filter((p: any) => !seen.has(p.number) && seen.add(p.number)); - const repo = all.some((p: any) => typeof p.url !== 'string' || !p.url) ? await this.repoInfo().catch(() => undefined) : undefined; - const fetched: GhPull[] = all.map((p: any) => ({ - number: p.number, - title: p.title, - state: p.state, - isDraft: !!p.isDraft, - url: typeof p.url === 'string' && p.url ? p.url : repo ? `https://github.com/${repo.nameWithOwner}/pull/${p.number}` : '', - author: p.author?.login ?? '', - labels: labels(p.labels), - reviewDecision: p.reviewDecision ?? '', - headRefName: p.headRefName ?? '', - headRefOid: typeof p.headRefOid === 'string' ? p.headRefOid : undefined, - baseRefName: p.baseRefName ?? '', - createdAt: p.createdAt ?? '', - updatedAt: p.updatedAt ?? p.createdAt ?? '', - additions: p.additions ?? 0, - deletions: p.deletions ?? 0, - checks: checksOf(p.statusCheckRollup), - body: String(p.body ?? '').slice(0, 4000), - closes: (p.closingIssuesReferences ?? []).map((r: any) => Number(r.number)).filter((n: number) => Number.isInteger(n) && n > 0), - })); + let fetched: GhPull[] | null = null; + if (this.customPulls) { + fetched = adapterBoard(await this.customPulls()); + } + if (fetched === null) { + const [open, merged, closed] = await Promise.all([ + this.listPulls('open', 150), + this.listPulls('merged', 30), + this.listPulls('closed', 40), + ]); + // `--state closed` includes merged PRs; keep only the ones closed without merging. + const seen = new Set(); + const all = [...open, ...merged, ...closed].filter((p: any) => !seen.has(p.number) && seen.add(p.number)); + const repo = all.some((p: any) => typeof p.url !== 'string' || !p.url) ? await this.repoInfo().catch(() => undefined) : undefined; + fetched = all.map((p: any) => ({ + number: p.number, + title: p.title, + state: p.state, + isDraft: !!p.isDraft, + url: typeof p.url === 'string' && p.url ? p.url : repo ? `https://github.com/${repo.nameWithOwner}/pull/${p.number}` : '', + author: p.author?.login ?? '', + labels: labels(p.labels), + reviewDecision: p.reviewDecision ?? '', + headRefName: p.headRefName ?? '', + headRefOid: typeof p.headRefOid === 'string' ? p.headRefOid : undefined, + baseRefName: p.baseRefName ?? '', + createdAt: p.createdAt ?? '', + updatedAt: p.updatedAt ?? p.createdAt ?? '', + additions: p.additions ?? 0, + deletions: p.deletions ?? 0, + checks: checksOf(p.statusCheckRollup), + body: String(p.body ?? '').slice(0, 4000), + closes: (p.closingIssuesReferences ?? []).map((r: any) => Number(r.number)).filter((n: number) => Number.isInteger(n) && n > 0), + })); + } const items = this.relabel('pull', fetched, asked); this.pulls = { items, fetchedAt: Date.now(), loading: false }; } catch (err) { diff --git a/src/server/http/routes/boards.ts b/src/server/http/routes/boards.ts new file mode 100644 index 000000000..b95a0a0d6 --- /dev/null +++ b/src/server/http/routes/boards.ts @@ -0,0 +1,93 @@ +import { send, readBody } from '../util.js'; +import type { Route } from '../router.js'; +import { floorParam } from './files.js'; + +export const boardRoutes = { + boards: { + method: 'GET', + prefix: '/api/boards/', + auth: 'session', + async handle(ctx, { res, url, path: p }) { + const floor = floorParam(ctx, url) ?? ctx.floors.values().next().value; + if (!floor) return send(res, 404, { error: 'No such floor' }); + const boardId = url.searchParams.get('board') ?? ''; + try { + if (p === '/api/boards/list') { + return send(res, 200, { boards: floor.boardsState() }); + } + if (!boardId) return send(res, 400, { error: 'Missing board parameter' }); + if (p === '/api/boards/cards') { + return send(res, 200, { cards: await floor.listBoardCards(boardId) }); + } + if (p === '/api/boards/card') { + const cardId = url.searchParams.get('id') ?? ''; + if (!cardId) return send(res, 400, { error: 'Missing card id' }); + const card = await floor.getBoardCardDetail(boardId, cardId); + if (!card) return send(res, 404, { error: 'Card not found' }); + return send(res, 200, card); + } + if (p === '/api/boards/config') { + const cfg = await floor.getBoardConfig(boardId); + return send(res, 200, { config: cfg ?? {} }); + } + } catch (err) { + return send(res, 502, { error: (err as Error).message }); + } + return send(res, 404, { error: 'Not found' }); + }, + }, + boardConfigSave: { + method: 'POST', + path: '/api/boards/config', + auth: 'session', + async handle(ctx, { res, url, req }) { + const floor = floorParam(ctx, url) ?? ctx.floors.values().next().value; + if (!floor) return send(res, 404, { error: 'No such floor' }); + const boardId = url.searchParams.get('board') ?? ''; + if (!boardId) return send(res, 400, { error: 'Missing board parameter' }); + try { + const body = JSON.parse(await readBody(req)); + await floor.saveBoardConfig(boardId, body.config ?? body); + return send(res, 200, { ok: true }); + } catch (err) { + return send(res, 400, { error: (err as Error).message }); + } + }, + }, + boardRefresh: { + method: 'POST', + path: '/api/boards/refresh', + auth: 'session', + async handle(ctx, { res, url }) { + const floor = floorParam(ctx, url) ?? ctx.floors.values().next().value; + if (!floor) return send(res, 404, { error: 'No such floor' }); + const boardId = url.searchParams.get('board') ?? ''; + if (!boardId) return send(res, 400, { error: 'Missing board parameter' }); + try { + const state = await floor.refreshBoard(boardId); + return send(res, 200, state); + } catch (err) { + return send(res, 500, { error: (err as Error).message }); + } + }, + }, + boardAssign: { + method: 'POST', + path: '/api/boards/assign', + auth: 'session', + async handle(ctx, { res, url, req }) { + const floor = floorParam(ctx, url) ?? ctx.floors.values().next().value; + if (!floor) return send(res, 404, { error: 'No such floor' }); + const boardId = url.searchParams.get('board') ?? ''; + const cardId = url.searchParams.get('id') ?? ''; + if (!boardId || !cardId) return send(res, 400, { error: 'Missing board or card id' }); + try { + const body = JSON.parse(await readBody(req).catch(() => '{}')); + const card = await floor.assignBoardCard(boardId, cardId, body?.assignee); + return send(res, 200, { ok: true, card }); + } catch (err) { + return send(res, 500, { error: (err as Error).message }); + } + }, + }, +} satisfies Record; diff --git a/src/server/http/routes/index.ts b/src/server/http/routes/index.ts index 982acdd1b..4230c725e 100644 --- a/src/server/http/routes/index.ts +++ b/src/server/http/routes/index.ts @@ -4,6 +4,7 @@ import type { Route } from '../router.js'; import { agentRoutes } from './agents.js'; import { authRoutes } from './auth.js'; +import { boardRoutes } from './boards.js'; import { fileRoutes } from './files.js'; import { githubRoutes } from './github.js'; import { pageRoutes } from './pages.js'; @@ -35,7 +36,17 @@ export const routes: readonly Route[] = [ fileRoutes.docs, searchRoutes.search, serviceRoutes.forwards, + serviceRoutes.start, + serviceRoutes.stop, + serviceRoutes.logs, githubRoutes.github, + boardRoutes.boards, + boardRoutes.boardConfigSave, + boardRoutes.boardRefresh, + boardRoutes.boardAssign, + pageRoutes.pluginFiles, + pageRoutes.pluginProjects, + pageRoutes.pluginProjectsSave, pageRoutes.office, pageRoutes.lite, pageRoutes.bundle, diff --git a/src/server/http/routes/pages.ts b/src/server/http/routes/pages.ts index f8d05da20..8acb4ab42 100644 --- a/src/server/http/routes/pages.ts +++ b/src/server/http/routes/pages.ts @@ -1,8 +1,9 @@ // The office's pages and the rest of the client bundle. import path from 'node:path'; import { publicFile, serveFile } from '../static.js'; -import { send } from '../util.js'; +import { readBody, send } from '../util.js'; import type { Route, RouteRequest } from '../router.js'; +import type { ProjectConfig } from '../../plugins/types.js'; import type { Ctx } from '../../office/context.js'; /** One of the bundle's own pages, never cached, so a new version is picked up at once. */ @@ -23,6 +24,71 @@ export const pageRoutes = { claim: { path: ['/claim', '/claim.html'], auth: 'public', handle: page('claim.html') }, join: { path: ['/join', '/join.html'], auth: 'public', handle: page('join.html') }, favicon: { path: '/favicon.svg', auth: 'public', handle: page('favicon.svg') }, + /** + * A plugin's own files (its settings page and whatever that page loads), under /plugins//. + * The office serves the file and knows nothing about it: any plugin can bring its own UI, and one + * that ships none is simply never asked for. + */ + pluginFiles: { + prefix: '/plugins/', + auth: 'session', + handle(ctx, { res, path: p }) { + const slash = p.indexOf('/', '/plugins/'.length); + const id = slash > 0 ? p.slice('/plugins/'.length, slash) : p.slice('/plugins/'.length); + const rest = slash > 0 ? p.slice(slash + 1) : ''; + const file = rest ? ctx.plugins?.settingsPagePathOf(id, rest) : undefined; + if (!file) { + res.writeHead(404, { 'content-type': 'text/plain' }).end('No such plugin file'); + return; + } + return serveFile(res, file, false); + }, + }, + /** + * The projects a plugin's settings page reads and writes for the floor you are on: the page + * (see pluginFiles) asks here, and the office hands it to whichever plugin lists them. Nothing + * about the projects themselves is known to the office. + */ + pluginProjects: { + method: 'GET', + prefix: '/api/plugins/', + auth: 'session', + async handle(ctx, { res, url, path: p }) { + const rest = p.slice('/api/plugins/'.length); + const slash = rest.indexOf('/'); + const id = slash > 0 ? rest.slice(0, slash) : rest; + const what = slash > 0 ? rest.slice(slash + 1) : ''; + if (what !== 'projects' || !id || !ctx.plugins?.has(id)) return send(res, 404, { error: 'No such plugin' }); + const floor = ctx.floors.get(url.searchParams.get('floor') ?? ''); + const projects = await ctx.plugins.listPluginProjects(id, floor); + if (projects === null) return send(res, 404, { error: `No projects to list for ${id}` }); + return send(res, 200, { projects }); + }, + }, + pluginProjectsSave: { + method: 'POST', + prefix: '/api/plugins/', + auth: 'session', + async handle(ctx, { res, url, req, path: p }) { + const rest = p.slice('/api/plugins/'.length); + const slash = rest.indexOf('/'); + const id = slash > 0 ? rest.slice(0, slash) : rest; + const what = slash > 0 ? rest.slice(slash + 1) : ''; + if (what !== 'projects' || !id || !ctx.plugins?.has(id)) return send(res, 404, { error: 'No such plugin' }); + const floor = ctx.floors.get(url.searchParams.get('floor') ?? ''); + let body: { projects?: unknown }; + try { + body = JSON.parse(await readBody(req)); + } catch { + return send(res, 400, { error: 'Send JSON: {"projects": [...]}' }); + } + const projects = Array.isArray(body?.projects) ? (body.projects as ProjectConfig[]) : undefined; + if (!projects) return send(res, 400, { error: 'A list of projects is required' }); + const ok = await ctx.plugins.savePluginProjects(id, floor, projects); + if (!ok) return send(res, 400, { error: `No plugin took those projects (no floor picked, or ${id} keeps its projects elsewhere)` }); + return send(res, 200, { projects }); + }, + }, office: { path: ['/', '/index.html'], auth: 'session', handle: page('index.html') }, // The 2D view: the workers, their terminals and the boards, without the 3D office (lite.ts). lite: { path: ['/lite', '/lite.html'], auth: 'session', handle: page('lite.html') }, diff --git a/src/server/http/routes/services.ts b/src/server/http/routes/services.ts index 2bd7d544e..2c27bf6c8 100644 --- a/src/server/http/routes/services.ts +++ b/src/server/http/routes/services.ts @@ -2,7 +2,7 @@ // one on the computer it runs on. import type { Ctx } from '../../office/context.js'; import { FORWARDS_PATH, type ForwardList } from '../../tunnel/wire.js'; -import { send } from '../util.js'; +import { readBody, send } from '../util.js'; import type { Route } from '../router.js'; /** Every floor's: a port is the machine's, whichever floor its worker sits on. */ @@ -16,4 +16,51 @@ function forwards(ctx: Ctx): ForwardList { export const serviceRoutes = { forwards: { method: 'GET', path: FORWARDS_PATH, auth: 'session', handle: (ctx, { res }) => send(res, 200, forwards(ctx)) }, + start: { + method: 'POST', + path: '/api/services/start', + auth: 'session', + async handle(ctx, { req, res }) { + let body: { floor?: string; name?: string }; + try { + body = JSON.parse(await readBody(req)); + } catch { + return send(res, 400, { error: 'Invalid JSON' }); + } + const floor = ctx.floors.get(body.floor ?? '') ?? ctx.floors.get(ctx.building.list()[0]?.id ?? ''); + if (!floor) return send(res, 404, { error: 'No such floor' }); + const ok = await ctx.plugins?.startService(floor, body.name); + return send(res, ok ? 200 : 500, { ok }); + }, + }, + stop: { + method: 'POST', + path: '/api/services/stop', + auth: 'session', + async handle(ctx, { req, res }) { + let body: { floor?: string; name?: string }; + try { + body = JSON.parse(await readBody(req)); + } catch { + return send(res, 400, { error: 'Invalid JSON' }); + } + const floor = ctx.floors.get(body.floor ?? '') ?? ctx.floors.get(ctx.building.list()[0]?.id ?? ''); + if (!floor) return send(res, 404, { error: 'No such floor' }); + const ok = await ctx.plugins?.stopService(floor, body.name); + return send(res, ok ? 200 : 500, { ok }); + }, + }, + logs: { + method: 'GET', + prefix: '/api/services/logs', + auth: 'session', + async handle(ctx, { url, res }) { + const floorId = url.searchParams.get('floor') ?? ''; + const name = url.searchParams.get('name') ?? undefined; + const floor = ctx.floors.get(floorId) ?? ctx.floors.get(ctx.building.list()[0]?.id ?? ''); + if (!floor) return send(res, 404, { error: 'No such floor' }); + const logs = await ctx.plugins?.getServiceLogs(floor, name); + return send(res, 200, { logs: logs ?? [] }); + }, + }, } satisfies Record; diff --git a/src/server/maps.ts b/src/server/maps.ts index da6c066cb..c19f1c387 100644 --- a/src/server/maps.ts +++ b/src/server/maps.ts @@ -1,4 +1,5 @@ -import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { execFile } from 'node:child_process'; import path from 'node:path'; import type { MapState } from '../shared/protocol.js'; import { OFFICE_MAP, checkCustomMaps, isMapChoice, planOf, type CustomMap, type MapPlan } from '../shared/maps/index.js'; @@ -38,6 +39,18 @@ export class Maps { return this.dir; } + openFolder(): void { + if (!existsSync(this.dir)) { + try { + mkdirSync(this.dir, { recursive: true }); + } catch { + /* ignore */ + } + } + const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open'; + execFile(cmd, [this.dir], () => {}); + } + state(): MapState { const pick = this.pick(); // Who picked it, while it's what they picked (not the office, while their map won't load). diff --git a/src/server/meetings.ts b/src/server/meetings.ts index 31c8919d4..7ad2da067 100644 --- a/src/server/meetings.ts +++ b/src/server/meetings.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { promisify } from 'node:util'; import { MEETING_SEATS } from '../shared/layout.js'; import { MEETING_NOTES_DIR, MEETING_PATTERNS, isMeetingPattern, meetingRecord, outputProblem, slugify } from '../shared/meetings.js'; -import { isAgentEffort, isAgentProvider, tokensOf, type AgentChoice, type AgentEffort, type AgentProvider, type Meeting, type MeetingRecord, type MeetingRequest, type MeetingState, type MeetingTurn, type WorkerInfo, type WorkerStatus } from '../shared/protocol.js'; +import { isAgentEffort, isKnownProvider, tokensOf, type AgentChoice, type AgentEffort, type AgentProvider, type Meeting, type MeetingRecord, type MeetingRequest, type MeetingState, type MeetingTurn, type WorkerInfo, type WorkerStatus } from '../shared/protocol.js'; import { validateWorkerEffort, validateWorkerModel } from './agents.js'; import { providerMeta, takesEffort, takesModel } from '../shared/providers.js'; import { gitError, type WorktreeRef, type WorktreeState } from './worktrees.js'; @@ -128,7 +128,7 @@ export class MeetingRoom { // Nobody picked: the office's default worker, model and effort included. const picked = req.provider !== undefined ? { provider: req.provider, model: req.model, effort: req.effort } : (this.workers.officeDefault ?? { provider: this.workers.defaultProvider }); const provider = picked.provider; - if (!isAgentProvider(provider) || (provider === 'custom' && this.workers.defaultProvider !== 'custom')) return 'Unknown agent provider'; + if (!isKnownProvider(provider) || (provider === 'custom' && this.workers.defaultProvider !== 'custom')) return 'Unknown agent provider'; const model = takesModel(provider) ? picked.model || undefined : undefined; const effort = takesEffort(provider) && isAgentEffort(picked.effort) ? picked.effort : undefined; const bad = validateWorkerModel('agent', provider, model) ?? validateWorkerEffort('agent', provider, effort); diff --git a/src/server/office-workers.ts b/src/server/office-workers.ts index 9865e3d7a..e9971ee5d 100644 --- a/src/server/office-workers.ts +++ b/src/server/office-workers.ts @@ -5,7 +5,7 @@ import { writeFileSync } from 'node:fs'; import path from 'node:path'; import type { AgentEffort, AgentProvider, GhPull, QueueTask, WorkerInfo, WorkerStatus, WorktreeCleanup } from '../shared/protocol.js'; -import { isAgentEffort, isAgentProvider } from '../shared/protocol.js'; +import { isAgentEffort, isKnownProvider } from '../shared/protocol.js'; import { DESK_BY_ID, STATION_AGENT } from '../shared/layout.js'; import { workerPr } from '../shared/status.js'; import { landedWork, notLeaving } from './leave-on-merge.js'; @@ -147,7 +147,7 @@ export function readHireRequest(body: unknown, providers: AgentProvider[]): Hire const prompt = typeof b.prompt === 'string' ? b.prompt.replace(/\r\n?/g, '\n').trim() : ''; if (!prompt) return 'Give the new worker its task: prompt'; if (prompt.length > 20000) return 'The prompt is over 20000 characters'; - if (b.provider !== undefined && (!isAgentProvider(b.provider) || !providers.includes(b.provider))) return `provider is one of ${providers.join(', ')}`; + if (b.provider !== undefined && (!isKnownProvider(b.provider) || !providers.includes(b.provider))) return `provider is one of ${providers.join(', ')}`; if (b.model !== undefined && (typeof b.model !== 'string' || !b.model.trim() || b.model.length > 200)) return 'model is a model name'; if (b.effort !== undefined && !isAgentEffort(b.effort)) return 'effort is one of low, medium, high, xhigh, max'; if (b.worktree !== undefined && typeof b.worktree !== 'boolean') return 'worktree is true or false'; diff --git a/src/server/office/context.ts b/src/server/office/context.ts index 96090a2e0..a8ae32d43 100644 --- a/src/server/office/context.ts +++ b/src/server/office/context.ts @@ -17,6 +17,7 @@ import type { PlanLimitsReader } from '../limits.js'; import type { Webhook } from '../webhook.js'; import type { Machine } from '../machine.js'; import type { Building, FloorDef } from '../building.js'; +import type { PluginManager } from '../plugins/manager.js'; import type { Floor } from '../floor.js'; import type { Sky } from '../sky.js'; import type { Themes } from '../theme.js'; @@ -51,6 +52,8 @@ export interface Core { models: Partial>; /** The building: a floor per project, each with its own workers, boards and queue. */ building: Building; + /** Plugins extending the office. */ + plugins: PluginManager; floors: Map; } @@ -88,7 +91,7 @@ export interface LateServices { services: Services; images: ImageProxy; upgrader: Upgrader; - /** A floor's Services board: its own workers' servers. */ + /** A floor's Services board: its own workers' servers, and any rows a plugin's services provider has for it. */ servicesState(floor: Floor | undefined, items?: ServiceInfo[]): ServicesState; } diff --git a/src/server/office/core.ts b/src/server/office/core.ts index 47ec9f80c..7ceb9a0ae 100644 --- a/src/server/office/core.ts +++ b/src/server/office/core.ts @@ -6,6 +6,7 @@ import { providerCommand } from '../agents.js'; import type { AgentProvider } from '../../shared/providers.js'; import { createModelCatalogues } from '../models.js'; import { Building } from '../building.js'; +import { PluginManager } from '../plugins/manager.js'; import type { Floor } from '../floor.js'; import { ChatLog } from '../history.js'; import { Arcade, HighScores } from '../cabinet.js'; @@ -39,10 +40,11 @@ export function createCore(ctx: Ctx, cfg: Config, publicDir: string): Core { // --- The building: a floor per project, each with its own workers, boards and queue ----------- const building = new Building(cfg.dataDir, cfg.projectsDir); + const plugins = new PluginManager(cfg.dataDir); if (cfg.projects) { const err = building.setProjectsDir(cfg.projects, 'the command line'); if (err) console.error(`agent-office: --projects: ${err}`); } const floors = new Map(); - return { cfg, publicDir, accounts, auth, clients, chat, highScores, arcade, officeName, models, building, floors }; + return { cfg, publicDir, accounts, auth, clients, chat, highScores, arcade, officeName, models, building, floors, plugins }; } diff --git a/src/server/office/floors.ts b/src/server/office/floors.ts index cec674fb4..14793eb8c 100644 --- a/src/server/office/floors.ts +++ b/src/server/office/floors.ts @@ -1,13 +1,17 @@ +// The building's floors: opening every floor of the building and waiting for its workers, and +// watching for floors being added or taken off. import { existsSync } from 'node:fs'; import { WebSocket } from 'ws'; +import type { FloorInfo, ServerMsg } from '../../shared/protocol.js'; +import { ROOF } from '../../shared/rooftop.js'; import type { FloorDef } from '../building.js'; import { Floor, type FloorContext } from '../floor.js'; -import { ROOF } from '../../shared/rooftop.js'; -import type { FloorInfo, ServerMsg } from '../../shared/protocol.js'; +import type { Client } from './client.js'; import type { Ctx, FloorHelpers, FloorsOpen } from './context.js'; -import { SLOW_CLIENT_BYTES, type Client } from './client.js'; -/** Finding floors, the elevator's list of them, and taking one off the building. */ +/** A viewer taking more than this much memory waiting on a slow link is caught up with a fresh snapshot. */ +const SLOW_CLIENT_BYTES = 10 * 1024 * 1024; + export function floorHelpers(ctx: Ctx): FloorHelpers { const floorOf = (c: Client): Floor | undefined => (c.peer.floor ? ctx.floors.get(c.peer.floor) : undefined); /** The floor a worker sits on. Worker ids are unique across the building. */ @@ -15,10 +19,48 @@ export function floorHelpers(ctx: Ctx): FloorHelpers { for (const f of ctx.floors.values()) if (f.workers.get(workerId)) return f; return undefined; }; - const floorInfos = (): FloorInfo[] => [ - ...[...ctx.floors.values()].map((f) => ({ ...f.info(), ...(ctx.building.isLocal(f.id) ? { local: true } : {}) })), - ...ctx.building.pending().map((d) => ({ id: d.id, name: d.name, repo: d.repo, dir: d.dir, palette: d.palette, addedBy: d.addedBy, addedAt: d.addedAt, cloning: true, clone: ctx.building.cloneProgress(d.id), workers: 0, busy: 0, waiting: 0, people: 0, wing: 0 })), - ]; + const floorInfos = (): FloorInfo[] => { + const list: FloorInfo[] = [ + ...ctx.building.list().map((d) => { + const f = ctx.floors.get(d.id); + const info: FloorInfo = f + ? { ...f.info(), ...(ctx.building.isLocal(f.id) ? { local: true } : {}) } + : { + id: d.id, + name: d.name, + repo: d.repo, + dir: d.dir, + palette: d.palette, + addedBy: d.addedBy, + addedAt: d.addedAt, + workers: 0, + busy: 0, + waiting: 0, + people: 0, + wing: 0, + }; + if (f) ctx.plugins?.decorateFloor(f, info); + return info; + }), + ...ctx.building.pending().map((d) => ({ + id: d.id, + name: d.name, + repo: d.repo, + dir: d.dir, + palette: d.palette, + addedBy: d.addedBy, + addedAt: d.addedAt, + cloning: true, + clone: ctx.building.cloneProgress(d.id), + workers: 0, + busy: 0, + waiting: 0, + people: 0, + wing: 0, + })), + ]; + return ctx.plugins ? ctx.plugins.sortFloors(list) : list; + }; // The elevator's counts change with every worker update; tell everyone at most a few times a second. let floorsSent = ''; let floorsTimer: NodeJS.Timeout | undefined; @@ -33,19 +75,19 @@ export function floorHelpers(ctx: Ctx): FloorHelpers { }, 250); }; /** Where someone arriving goes: the floor they asked for, else the first one there is. */ - const arrivalFloor = (wanted: string | null): Floor | undefined => (wanted && ctx.floors.get(wanted)) || ctx.floors.values().next().value; + const arrivalFloor = (wanted: string | null): Floor | undefined => { + if (wanted === ROOF) return undefined; + const floor = (wanted && ctx.floors.get(wanted)) || ctx.floors.get(ctx.building.list()[0]?.id ?? ''); + return floor; + }; /** * Takes `floor` off the building (already out of floors.json): everyone on it rides the elevator to * the next floor, or out to the lobby if it was the last (the roof goes with it), and its workers stop. */ const closeFloor = (floor: Floor, who: string) => { - const name = floor.def.name; - const next = [...ctx.floors.values()].find((f) => f !== floor); - // The list without it first, so nobody arrives somewhere (the lobby's panel) that still shows it. - const list = floorInfos().filter((f) => f.id !== floor.id); - floorsSent = JSON.stringify(list); - ctx.broadcast({ t: 'floors', floors: list }); + const { name } = floor.def; + const next = ctx.floors.get(ctx.building.list()[0]?.id ?? ''); for (const c of ctx.clients.values()) { if (c.peer.floor === floor.id || (!next && c.peer.floor === ROOF)) { if (next) ctx.goToFloor(c, next); @@ -77,6 +119,10 @@ export async function openFloors(ctx: Ctx, hookPort: number): Promise ctx.plugins?.getIssuesAdapter(), + pullsAdapter: () => ctx.plugins?.getPullsAdapter(), + queueAdapter: () => ctx.plugins?.getQueueAdapter(), + boardAdapters: () => ctx.plugins?.listBoardAdapters() ?? [], emit: ctx.toFloor, toast: ctx.toastFloor, termData: (workerId, data, viewers) => { @@ -120,9 +166,12 @@ export async function openFloors(ctx: Ctx, hookPort: number): Promise (owner ? ctx.signins.ghAs(owner) : undefined), }; + /** Whether a worker on `from` works in `on`'s project too (see WorkerInfo.repos). */ const worksIn = (from: Floor, on: Floor) => from.workers.list().some((w) => w.repos?.some((r) => r.floor === on.id)); + const openFloor = (def: FloorDef): Floor | undefined => { + if (floors.has(def.id)) return floors.get(def.id); if (!existsSync(def.dir)) { console.error(`agent-office: the ${def.name} floor's checkout is gone (${def.dir}) — it stays closed until it's back`); return undefined; @@ -136,9 +185,39 @@ export async function openFloors(ctx: Ctx, hookPort: number): Promise { + try { + const defs = await floorProvider.listFloors(); + if (defs && defs.length > 0) { + const prevDefs = ctx.building.list(); + ctx.building.mergeList(defs); + const currentDefs = ctx.building.list(); + if (JSON.stringify(prevDefs) !== JSON.stringify(currentDefs)) { + for (const def of currentDefs) openFloor(def); + ctx.floorsChanged(); + } + } + } catch { + /* ignore */ + } + }; + await syncFloorProvider(); + setInterval(() => { + void syncFloorProvider(); + for (const floor of floors.values()) void floor.queue.syncFromAdapter(); + }, 3000).unref(); + } + // Started in a project: it's a floor too (the one it has always been). if (cfg.project) ctx.building.ensureLocal(cfg.project, 'the office'); for (const def of ctx.building.list()) openFloor(def); + // Clones keep the elevator's progress up to date, and ones the last office left running carry on. ctx.building.watchClones(ctx.floorsChanged); ctx.building.resumeClones((r) => { @@ -151,6 +230,7 @@ export async function openFloors(ctx: Ctx, hookPort: number): Promise f.ready)); return { openFloor }; diff --git a/src/server/office/services.ts b/src/server/office/services.ts index 847f05a52..acdf9f969 100644 --- a/src/server/office/services.ts +++ b/src/server/office/services.ts @@ -136,6 +136,17 @@ export function createLateServices(ctx: Ctx): LateServices { // Web servers the workers start, for the Services board and service tunnels (see relay.ts). // One scan covers every floor; each floor's board lists its own workers' servers. + // A plugin with the services capability also lists what it can run for a floor (a dev server, a + // command), and those rows show on that floor's board beside the ones the scan found. + const pluginServices = async (floor: Floor | undefined): Promise => { + if (!floor || !ctx.plugins) return []; + try { + return (await ctx.plugins.listServices(floor)) ?? []; + } catch { + // A plugin's rows are extra, never a reason for the board to fail. + return []; + } + }; const servicesState = (floor: Floor | undefined, items: ServiceInfo[] = services.list()): ServicesState => ({ items: floor ? items.filter((s) => floor.workers.get(s.workerId)) : [], port: cfg.port, @@ -143,14 +154,45 @@ export function createLateServices(ctx: Ctx): LateServices { ssh: team.ssh, tailnet: cfg.tailnet, }); + /** + * A floor's board, with a plugin's rows on it, and sends it to everyone on that floor. Used both + * when the scan changes and on its own timer, so a plugin's rows come and go on their own. + */ + const publishBoard = async (floor: Floor | undefined, items: ServiceInfo[]) => { + const state = servicesState(floor, items); + if (!floor || !ctx.plugins) return state; + const offered = await pluginServices(floor); + if (!offered.length) return state; + return { ...state, items: [...state.items, ...offered] }; + }; const services = new Services( () => [...floors.values()].flatMap((f) => f.workers.owners()), (items) => { - for (const c of clients.values()) ctx.sendTo(c, { t: 'services', state: servicesState(ctx.floorOf(c), items) }); tailnet.sync(items.map((s) => s.port)); + for (const c of clients.values()) { + void publishBoard(ctx.floorOf(c), items).then((state) => ctx.sendTo(c, { t: 'services', state })); + } }, ); - + /** + * A plugin's services change without the office's own port scan seeing it (a dev server it started + * or stopped, a command of its own), so the boards are asked again on their own as well as on every + * scan, and only while clients are connected. A floor whose plugins have nothing to say is skipped. + */ + const repaintBoards = async () => { + if (!ctx.plugins) return; + const items = services.list(); + for (const c of [...clients.values()]) { + const floor = ctx.floorOf(c); + if (!floor) continue; + const offered = await pluginServices(floor); + if (!offered.length) continue; + void publishBoard(floor, items).then((state) => ctx.sendTo(c, { t: 'services', state })); + } + }; + const BOARD_REPAINT_MS = process.env['AGENT_OFFICE_SERVICES_TEST_MS'] ? Number(process.env['AGENT_OFFICE_SERVICES_TEST_MS']) : 3000; + const boardTimer = setInterval(() => void repaintBoards(), BOARD_REPAINT_MS); + boardTimer.unref(); const images = new ImageProxy(); const upgrader = new Upgrader( diff --git a/src/server/plugin-floors.ts b/src/server/plugin-floors.ts new file mode 100644 index 000000000..1abdfaccf --- /dev/null +++ b/src/server/plugin-floors.ts @@ -0,0 +1,26 @@ +// Merging the floors a plugin provides into the building's own. +// +// A plugin that provides floors (an OpenFox project per floor, say) says "these are mine" — never +// "these are all there are". The floors the office was started with, and the ones people added from +// the elevator, stay exactly where they are. Doing it any other way wipes the building the moment a +// plugin reports its list, and the boards those floors had go with them. +import path from 'node:path'; +import type { FloorDef } from './building.js'; + +/** Who added a floor for themselves, rather than it arriving from a plugin's list. */ +const OWN = new Set(['the office', 'the elevator']); + +/** The same floor, either side: the same checkout, or the id its plugin keeps for it. */ +const sameAs = (a: FloorDef, b: FloorDef): boolean => + path.resolve(a.dir) === path.resolve(b.dir) || (!!b.externalId && a.externalId === b.externalId); + +/** + * The building's floors once `defs` are merged into them: what was there and is still the plugin's + * stays (so it keeps its identity), what the plugin has added arrives, and what it no longer lists + * goes. Nothing anybody but a plugin added is taken off. + */ +export function mergeFloors(current: FloorDef[], defs: FloorDef[]): FloorDef[] { + const kept = current.filter((own) => OWN.has(own.addedBy) || defs.some((d) => sameAs(own, d))); + const fresh = defs.filter((d) => !kept.some((own) => sameAs(own, d))); + return [...kept, ...fresh]; +} diff --git a/src/server/plugins/entry.ts b/src/server/plugins/entry.ts new file mode 100644 index 000000000..38c5e10ec --- /dev/null +++ b/src/server/plugins/entry.ts @@ -0,0 +1,97 @@ +// Where a plugin is, and where its own files are: its entry, and the folder holding them. +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +/** Where a plugin's entry can be, in a folder of its own: its built bundle first. */ +export const ENTRY_CANDIDATES = ['dist/index.js', 'index.js', 'plugin.js', 'dist/index.mjs', 'index.mjs']; + +export function candidatePluginDirs(id: string, registryPluginsDir?: string): string[] { + return [path.join(process.cwd(), '.agent-office', 'plugins', id), registryPluginsDir ? path.join(registryPluginsDir, id) : null, path.resolve(process.cwd(), '..', 'agent-office-plugins', `agentoffice-${id}`), path.resolve(process.cwd(), '..', 'agent-office-plugins', `${id}-plugin-for-agent-office`), path.resolve(process.cwd(), '..', 'agent-office-plugins', `agent-office-plugin-${id}`), path.resolve(process.cwd(), '..', 'agent-office-plugins', id), path.resolve(process.cwd(), 'agent-office-plugins', `agentoffice-${id}`), path.resolve(process.cwd(), 'agent-office-plugins', `${id}-plugin-for-agent-office`), path.resolve(process.cwd(), 'agent-office-plugins', `agent-office-plugin-${id}`), path.resolve(process.cwd(), 'agent-office-plugins', id)].filter(Boolean) as string[]; +} + +export function candidatePluginFiles(id: string, registryPluginsDir?: string): string[] { + return [path.join(process.cwd(), `${id}.js`), registryPluginsDir ? path.join(registryPluginsDir, `${id}.js`) : null, registryPluginsDir ? path.join(registryPluginsDir, `${id}.mjs`) : null].filter(Boolean) as string[]; +} + +export function detectEntryPath(pluginDir: string): string | null { + for (const candidate of ENTRY_CANDIDATES) { + const p = path.join(pluginDir, candidate); + if (existsSync(p)) return p; + } + const pkgPath = path.join(pluginDir, 'package.json'); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { main?: string; module?: string }; + const entry = pkg.main || pkg.module; + if (entry) { + const p = path.join(pluginDir, entry); + if (existsSync(p)) return p; + } + } catch {} + } + return null; +} + +export function parseGitUrl(url: string): { id: string; cloneUrl: string } | null { + const trimmed = url.trim(); + if (!trimmed) return null; + const ALLOWED_PROTOCOLS = ['https:', 'http:', 'git:', 'ssh:']; + try { + const parsedUrl = new URL(trimmed); + if (!ALLOWED_PROTOCOLS.includes(parsedUrl.protocol)) return null; + } catch { + if (!trimmed.includes('@') && !trimmed.startsWith('git://')) return null; + } + + let cloneUrl = trimmed; + if (cloneUrl.startsWith('-')) return null; + if (cloneUrl.endsWith('/')) cloneUrl = cloneUrl.slice(0, -1); + if (!cloneUrl.endsWith('.git')) cloneUrl += '.git'; + const match = /([^/]+?)(?:\.git)?$/.exec(cloneUrl); + if (!match) return null; + const id = match[1].toLowerCase().replace(/[^a-z0-9-_]/g, '-'); + return { id, cloneUrl }; +} + +export function getPluginDir(id: string, pluginsDir?: string): string { + return path.join(pluginsDir ?? path.join(process.cwd(), '.agent-office', 'plugins'), id); +} + +export function resolvePluginEntry(id: string, registryPluginsDir?: string, explicitEntry?: string): string | null { + if (explicitEntry) { + const resolved = path.isAbsolute(explicitEntry) ? explicitEntry : path.resolve(process.cwd(), explicitEntry); + if (existsSync(resolved)) return resolved; + if (registryPluginsDir) { + const inReg = path.resolve(registryPluginsDir, path.basename(explicitEntry)); + if (existsSync(inReg)) return inReg; + } + } + + for (const dir of candidatePluginDirs(id, registryPluginsDir)) { + if (existsSync(dir)) { + const entry = detectEntryPath(dir); + if (entry) return entry; + } + } + + for (const file of candidatePluginFiles(id, registryPluginsDir)) { + if (existsSync(file)) return file; + } + + return null; +} + +export async function fetchLatestRelease(githubUrl: string): Promise { + try { + const match = /github\.com\/([^/]+)\/([^/]+)/.exec(githubUrl); + if (!match) return null; + const [, owner, repo] = match; + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; + const res = await fetch(apiUrl, { headers: { Accept: 'application/vnd.github.v3+json' } }); + if (!res.ok) return null; + const data = (await res.json()) as { tag_name?: string }; + return data.tag_name ?? null; + } catch { + return null; + } +} diff --git a/src/server/plugins/index.ts b/src/server/plugins/index.ts new file mode 100644 index 000000000..3a2183b9d --- /dev/null +++ b/src/server/plugins/index.ts @@ -0,0 +1,3 @@ +export * from './types.js'; +export * from './registry.js'; +export * from './manager.js'; diff --git a/src/server/plugins/manager.ts b/src/server/plugins/manager.ts new file mode 100644 index 000000000..a1611f09b --- /dev/null +++ b/src/server/plugins/manager.ts @@ -0,0 +1,597 @@ +import { execFile, execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { FloorDef } from '../building.js'; +import type { Floor } from '../floor.js'; +import type { FloorInfo, GhIssue, GhPull, PluginManifest, PluginsState, ServiceInfo, WorkerInfo } from '../../shared/protocol.js'; +import type { Ctx } from '../office/context.js'; +import { PluginRegistry } from './registry.js'; +import { candidatePluginDirs, candidatePluginFiles, detectEntryPath, parseGitUrl, getPluginDir, resolvePluginEntry, fetchLatestRelease } from './entry.js'; +import { pluginFilePath, pluginSettingsPagePath } from './plugin-files.js'; +import type { AgentProviderPlugin, BoardAdapter, FloorProvider, IssuesAdapter, OfficePlugin, ProjectConfig, ProjectsProvider, PullsAdapter, QueueAdapter, ServicesProvider } from './types.js'; +import type { AgentProvider } from '../../shared/providers.js'; +import { PROVIDER_META, registerAgentProvider, unregisterAgentProvider } from '../../shared/providers.js'; +import { registerAgentProviderAdapter, unregisterAgentProviderAdapter } from '../providers/index.js'; + + +export class PluginManager { + private registry: PluginRegistry; + private plugins: Map = new Map(); + public ctx?: Ctx; + + constructor(projectsDir?: string) { + this.registry = new PluginRegistry(projectsDir); + } + + async install(id: string): Promise { + const manifest = this.registry.get(id); + if (!manifest) return false; + + let entryPath = resolvePluginEntry(manifest.id, this.registry.pluginsDir, (manifest as any).entry); + if (!entryPath && manifest.githubUrl) { + const cloneRes = await this.installFromGit(manifest.githubUrl); + if (!cloneRes.ok) { + manifest.status = 'error'; manifest.error = cloneRes.error ?? 'Git clone failed'; + this.registry.save(); return false; + } + entryPath = resolvePluginEntry(manifest.id, this.registry.pluginsDir, (manifest as any).entry); + } + + if (!entryPath && this.ctx) { + manifest.status = 'error'; manifest.error = `Plugin entry not found for ${manifest.id}`; + this.registry.save(); return false; + } + + manifest.installed = true; + (manifest as any).enabled = true; + manifest.status = 'active'; + if (this.ctx) { + const loaded = await this.loadPlugin(manifest, this.ctx); + if (!loaded) { + manifest.installed = false; (manifest as any).enabled = false; manifest.status = 'error'; + this.registry.save(); return false; + } + } + this.registry.save(); + return true; + } + + async uninstall(id: string): Promise { + const manifest = this.registry.get(id); + if (!manifest) return false; + manifest.installed = false; + (manifest as any).enabled = false; + manifest.status = 'inactive'; + this.registry.save(); + const plugin = this.plugins.get(id); + if (plugin) { + if (plugin.agentProvider) { + this.unregisterAgentProvider(plugin.agentProvider.id); + } + try { + await plugin.shutdown?.(); + } catch { + /* ignore */ + } + this.plugins.delete(id); + } + return true; + } + + async installFromGit(url: string): Promise<{ ok: boolean; id?: string; error?: string }> { + const parsed = parseGitUrl(url); + if (!parsed) return { ok: false, error: 'Invalid Git URL' }; + const { id, cloneUrl } = parsed; + const pluginDir = getPluginDir(id, this.registry.pluginsDir); + if (existsSync(pluginDir)) return { ok: false, error: `Plugin "${id}" is already installed` }; + mkdirSync(pluginDir, { recursive: true }); + try { + execFileSync('git', ['clone', '--depth', '1', '--', cloneUrl, pluginDir], { stdio: 'pipe', timeout: 60_000 }); + } catch (err) { + rmSync(pluginDir, { recursive: true, force: true }); + return { ok: false, error: `Git clone failed: ${(err as Error).message}` }; + } + const entryPath = detectEntryPath(pluginDir); + if (!entryPath) { + rmSync(pluginDir, { recursive: true, force: true }); + return { ok: false, error: `Plugin entry not found in ${pluginDir}` }; + } + const manifest: PluginManifest = { + id, + name: id, + description: `Installed from ${cloneUrl}`, + capabilities: [], + installed: true, + status: 'active', + local: true, + }; + this.registry.register(manifest); + this.registry.save(); + if (this.ctx) { + await this.loadPlugin(manifest, this.ctx); + } + return { ok: true, id }; + } + + async updatePlugin(id: string): Promise<{ ok: boolean; error?: string }> { + const pluginDir = getPluginDir(id, this.registry.pluginsDir); + if (!existsSync(pluginDir)) return { ok: false, error: `Plugin "${id}" is not installed from Git` }; + try { + execFileSync('git', ['pull'], { cwd: pluginDir, stdio: 'pipe', timeout: 30_000 }); + } catch (err) { + return { ok: false, error: `Git pull failed: ${(err as Error).message}` }; + } + const manifest = this.registry.get(id); + if (manifest && this.ctx) { + const plugin = this.plugins.get(id); + if (plugin) { + try { + await plugin.shutdown?.(); + } catch { + /* ignore */ + } + this.plugins.delete(id); + } + await this.loadPlugin(manifest, this.ctx); + } + return { ok: true }; + } + + async checkUpdates(): Promise { + const installed = this.registry.list().filter((m) => m.installed === true); + await Promise.all( + installed.map(async (m) => { + if (!m.githubUrl) { + if (!m.version) m.version = 'local'; + m.latestVersion = undefined; + m.updateAvailable = false; + return; + } + const latest = await fetchLatestRelease(m.githubUrl); + if (latest) { + m.latestVersion = latest; + m.updateAvailable = m.version !== latest; + } + }), + ); + } + + openFolder(id?: string): void { + let targetDir = this.registry.pluginsDir; + if (id) { + for (const dir of candidatePluginDirs(id, this.registry.pluginsDir)) { + if (existsSync(dir)) { + targetDir = dir; + break; + } + } + if (targetDir === this.registry.pluginsDir) { + const parentContainers = [ + path.resolve(process.cwd(), '..', 'agent-office-plugins'), + path.resolve(process.cwd(), 'agent-office-plugins'), + ]; + for (const parent of parentContainers) { + if (existsSync(parent)) { + try { + const entries = readdirSync(parent, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const candidate = path.join(parent, entry.name); + if (entry.name.toLowerCase().includes(id.toLowerCase())) { + targetDir = candidate; + break; + } + const pkgPath = path.join(candidate, 'package.json'); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + if (pkg.name?.toLowerCase().includes(id.toLowerCase())) { + targetDir = candidate; + break; + } + } catch {} + } + } + } + if (targetDir !== this.registry.pluginsDir) break; + } catch {} + } + } + } + if (targetDir === this.registry.pluginsDir) { + for (const file of candidatePluginFiles(id, this.registry.pluginsDir)) { + if (existsSync(file)) { + targetDir = path.dirname(file); + break; + } + } + } + } else { + const fallbackDirs = [ + path.resolve(process.cwd(), '..', 'agent-office-plugins'), + path.resolve(process.cwd(), 'agent-office-plugins'), + this.registry.pluginsDir, + ]; + targetDir = fallbackDirs.find((d) => existsSync(d)) || targetDir; + } + if (!existsSync(targetDir)) { + try { + mkdirSync(targetDir, { recursive: true }); + } catch { + /* ignore */ + } + } + const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open'; + execFile(cmd, [targetDir], () => {}); + } + + async loadPlugin(manifest: PluginManifest, ctx: Ctx): Promise { + const entryPath = resolvePluginEntry(manifest.id, this.registry.pluginsDir, (manifest as any).entry); + if (!entryPath) { + manifest.status = 'error'; + manifest.error = `Plugin entry not found for ${manifest.id}`; + return null; + } + try { + const fileUrl = pathToFileURL(entryPath).href; + const mod = await import(fileUrl); + const factory = mod.createPlugin || mod.default || mod; + const plugin: OfficePlugin = typeof factory === 'function' ? factory() : factory; + if (plugin && plugin.manifest) { + manifest.status = 'active'; + manifest.capabilities = plugin.manifest.capabilities ?? manifest.capabilities; + manifest.name = plugin.manifest.name ?? manifest.name; + manifest.description = plugin.manifest.description ?? manifest.description; + manifest.version = plugin.manifest.version ?? manifest.version; + manifest.author = plugin.manifest.author ?? manifest.author; + manifest.githubUrl = plugin.manifest.githubUrl ?? manifest.githubUrl; + manifest.settingsPage = plugin.manifest.settingsPage ?? manifest.settingsPage; + this.plugins.set(manifest.id, plugin); + if (plugin.manifest.id) { + this.plugins.set(plugin.manifest.id, plugin); + // The plugin may name itself differently from the id the office filed it under; the rest of + // the office (and Settings) addresses it by the name it gives itself. + const own = plugin.manifest.id; + const regItem = this.registry.get(own); + if (regItem && regItem !== manifest) { + regItem.status = 'active'; + regItem.installed = true; + (regItem as { entry?: string }).entry = (manifest as { entry?: string }).entry; + regItem.version = plugin.manifest.version ?? regItem.version; + regItem.author = plugin.manifest.author ?? regItem.author; + regItem.githubUrl = plugin.manifest.githubUrl ?? regItem.githubUrl; + regItem.settingsPage = plugin.manifest.settingsPage ?? regItem.settingsPage; + } else if (!regItem) { + this.registry.register({ + ...plugin.manifest, + installed: true, + status: 'active', + entry: (manifest as { entry?: string }).entry, + local: true, + } as PluginManifest); + } + // The id it was filed under by its file name is not who it is now that the plugin has said: + // that placeholder goes, so Settings shows the plugin's own card and nothing beside it. + if (manifest.id !== own && manifest.local) this.registry.unregister(manifest.id); + } + await plugin.init?.(ctx); + return plugin; + } + } catch (err) { + manifest.status = 'error'; + manifest.error = (err as Error).message; + console.error(`Failed to load plugin ${manifest.id}:`, err); + } + return null; + } + + async init(ctx: Ctx) { + this.ctx = ctx; + + const envPlugin = process.env.AGENT_OFFICE_PLUGIN; + if (envPlugin && existsSync(envPlugin)) { + const id = path.basename(envPlugin, path.extname(envPlugin)); + const existing = this.registry.get(id); + if (existing) { + existing.installed = true; + (existing as any).entry = envPlugin; + } else { + this.registry.register({ + id, + name: id, + description: `Plugin loaded from ${envPlugin}`, + capabilities: [], + installed: true, + status: 'active', + local: true, + entry: envPlugin, + } as any); + } + // The plugin may name itself differently (its `manifest.id`); loadPlugin also registers that + // one, so Settings and the rest address it by the name it gives itself. + } + + for (const manifest of this.registry.list()) { + if (manifest.installed) { + await this.loadPlugin(manifest, ctx); + } + } + + // One plugin can be filed under more than one id (its own, and the one the office knew it by); + // each plugin is registered once, whatever it is called here. + for (const plugin of new Set(this.plugins.values())) { + if (plugin.agentProvider) { + this.registerAgentProvider(plugin.agentProvider); + } + } + } + + private registerAgentProvider(agentProvider: AgentProviderPlugin): void { + const { id, meta, adapter } = agentProvider; + if (PROVIDER_META[id as AgentProvider]) { + console.warn(`Agent provider "${id}" already registered, skipping plugin provider`); + return; + } + registerAgentProvider(id, meta); + registerAgentProviderAdapter(id, adapter); + } + + private unregisterAgentProvider(id: string): void { + unregisterAgentProvider(id); + unregisterAgentProviderAdapter(id); + } + + async shutdown() { + for (const p of this.plugins.values()) { + try { + await p.shutdown?.(); + } catch { + } + } + this.plugins.clear(); + } + + /** Where a plugin's settings page is on disk, when it ships one (see plugin-files.ts). */ + settingsPagePath(id: string): string | undefined { + const m = this.registry.get(id); + return m ? pluginSettingsPagePath(m, this.registry.pluginsDir) : undefined; + } + + /** Where a file a plugin ships is, for serving it under /plugins//; never outside its directory. */ + settingsPagePathOf(id: string, relative: string): string | undefined { + const m = this.registry.get(id); + return m ? pluginFilePath(m, this.registry.pluginsDir, relative) : undefined; + } + + /** Whether the office has a loaded plugin by that id. */ + has(id: string): boolean { + return this.plugins.has(id); + } + + getFloorProvider(): FloorProvider | null { + for (const p of this.plugins.values()) { + if (p.floorProvider) return p.floorProvider; + } + return null; + } + + getIssuesAdapter(): IssuesAdapter | null { + const adapters = [...new Set([...this.plugins.values()].map((p) => p.issuesAdapter))].filter(Boolean) as IssuesAdapter[]; + if (!adapters.length) return null; + if (adapters.length === 1) return adapters[0]; + return { + async listIssues(floor: Floor) { + const results = await Promise.allSettled(adapters.map((a) => a.listIssues(floor))); + const all: GhIssue[] = []; + for (const r of results) { + if (r.status === 'fulfilled' && Array.isArray(r.value)) { + all.push(...r.value); + } + } + return all; + }, + async issueDetail(floor: Floor, n: number, me?: string) { + for (const a of adapters) { + if (a.issueDetail) { + const res = await a.issueDetail(floor, n, me); + if (res) return res; + } + } + return null; + }, + }; + } + + getPullsAdapter(): PullsAdapter | null { + const adapters = [...new Set([...this.plugins.values()].map((p) => p.pullsAdapter))].filter(Boolean) as PullsAdapter[]; + if (!adapters.length) return null; + if (adapters.length === 1) return adapters[0]; + return { + async listPulls(floor: Floor) { + const results = await Promise.allSettled(adapters.map((a) => a.listPulls(floor))); + const all: GhPull[] = []; + for (const r of results) { + if (r.status === 'fulfilled' && Array.isArray(r.value)) { + all.push(...r.value); + } + } + return all; + }, + async pullDetail(floor: Floor, n: number, me?: string) { + for (const a of adapters) { + if (a.pullDetail) { + const res = await a.pullDetail(floor, n, me); + if (res) return res; + } + } + return null; + }, + async pullDiff(floor: Floor, n: number) { + for (const a of adapters) { + if (a.pullDiff) { + const res = await a.pullDiff(floor, n); + if (res !== null && res !== undefined) return res; + } + } + return null; + }, + }; + } + + getQueueAdapter(): QueueAdapter | null { + for (const p of this.plugins.values()) { + if (p.queueAdapter) return p.queueAdapter; + } + return null; + } + + getServicesProvider(): ServicesProvider | null { + for (const p of this.plugins.values()) { + if (p.servicesProvider) return p.servicesProvider; + } + return null; + } + + listBoardAdapters(): BoardAdapter[] { + return [...this.plugins.values()].flatMap((p) => p.boardAdapters || []); + } + + getBoardAdapter(boardId: string): BoardAdapter | null { + return this.listBoardAdapters().find((a) => a.getBoardDef().id === boardId) ?? null; + } + + decorateFloor(floor: Floor, info: FloorInfo): void { + for (const p of this.plugins.values()) { + p.floorProvider?.decorateFloorInfo?.(floor, info); + } + } + + sortFloors(floors: FloorInfo[]): FloorInfo[] { + const provider = this.getFloorProvider(); + if (provider?.sortFloors) { + return provider.sortFloors(floors); + } + return floors; + } + + async listFloorDefs(): Promise { + const provider = this.getFloorProvider(); + if (provider) { + return await provider.listFloors(); + } + return null; + } + + async createFloor(req: { repo?: string; dir?: string }): Promise<{ repo: string; dir: string; name: string } | null> { + const provider = this.getFloorProvider(); + if (provider?.createFloor) { + return await provider.createFloor(req); + } + return null; + } + + async listServices(floor: Floor): Promise { + const provider = this.getServicesProvider(); + if (provider) { + return await provider.listServices(floor); + } + return null; + } + + async startService(floor: Floor | undefined, serviceName?: string): Promise { + const provider = this.getServicesProvider(); + if (!provider?.startService || !floor) return false; + try { + await provider.startService(floor, serviceName); + return true; + } catch { + return false; + } + } + + async stopService(floor: Floor | undefined, serviceName?: string): Promise { + const provider = this.getServicesProvider(); + if (!provider?.stopService || !floor) return false; + try { + await provider.stopService(floor, serviceName); + return true; + } catch { + return false; + } + } + + async getServiceLogs(floor: Floor | undefined, serviceName?: string, lines?: number): Promise { + const provider = this.getServicesProvider(); + if (!provider?.getServiceLogs || !floor) return []; + try { + return (await provider.getServiceLogs(floor, serviceName, lines)) ?? []; + } catch { + return []; + } + } + + getProjectsProvider(floor?: Floor): ProjectsProvider | null { + for (const p of this.plugins.values()) { + if (p.projectsProvider) return p.projectsProvider; + } + return null; + } + + /** + * The projects a plugin's provider lists for a floor, or null when no plugin has one there. + * + * A plugin can bring its own settings UI (see settingsPage) and this is how that page asks: the + * office holds the seam, the plugin holds the list. + */ + async listPluginProjects(id: string, floor: Floor | undefined): Promise { + const provider = floor && this.plugins.get(id)?.projectsProvider; + if (!provider || !floor) return null; + try { + return (await provider.listProjects?.(floor)) ?? null; + } catch { + return null; + } + } + + /** Hands a floor's projects to the plugin that lists them; says whether one took them. */ + async savePluginProjects(id: string, floor: Floor | undefined, projects: ProjectConfig[]): Promise { + const provider = floor && this.plugins.get(id)?.projectsProvider; + if (!provider?.saveProjects || !floor) return false; + try { + await provider.saveProjects(floor, projects); + return true; + } catch { + return false; + } + } + + getState(): PluginsState { + const all = this.registry.getState(); + const installed = all.plugins.map((m) => { + const active = this.plugins.has(m.id); + const plugin = this.plugins.get(m.id); + return { + ...m, + installed: true, + version: plugin?.manifest?.version ?? m.version, + author: plugin?.manifest?.author ?? m.author, + settingsPage: plugin?.manifest?.settingsPage ?? m.settingsPage, + agentProviderMeta: plugin?.agentProvider?.meta, + status: (m.status === 'error' ? 'error' : active ? 'active' : 'inactive') as 'active' | 'inactive' | 'error', + }; + }); + const store = (all.store ?? []).map((m) => { + const plugin = this.plugins.get(m.id); + return { + ...m, + version: plugin?.manifest?.version ?? m.version, + author: plugin?.manifest?.author ?? m.author, + settingsPage: plugin?.manifest?.settingsPage ?? m.settingsPage, + installed: false, + status: 'inactive' as const, + }; + }); + return { plugins: installed, store, pluginsDir: this.registry.pluginsDir }; + } +} diff --git a/src/server/plugins/plugin-files.ts b/src/server/plugins/plugin-files.ts new file mode 100644 index 000000000..076234723 --- /dev/null +++ b/src/server/plugins/plugin-files.ts @@ -0,0 +1,66 @@ +// Finding the files a plugin ships: its settings page, and anything that page loads. +// +// A plugin brings its own settings UI as files in its own directory, which the office serves under +// /plugins// (see http/routes/pages.ts). The office knows nothing about what they contain — that +// is what makes it generic: any plugin can bring its own settings, and one that brings none is never +// asked for. +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { candidatePluginDirs, detectEntryPath } from './entry.js'; + +/** Where a plugin was loaded from, so its own files can be found next to it. */ +export interface LoadedPlugin { + id: string; + /** The entry file it was loaded from, when it is a single file rather than a folder. */ + entry?: string; + /** The file it names as its settings page, relative to itself. */ + settingsPage?: string; +} + +/** + * A path a plugin asks for, cleaned up and refused if it tries to leave the plugin's directory. + * + * Undefined for anything that isn't a plain relative name inside it: no plugin can make the office + * serve a file from somewhere else. + */ +export function safePluginPath(relative: string): string | undefined { + const page = relative.replace(/\\/g, '/').replace(/^\.\.?\//, ''); + if (!page || page.startsWith('/') || page.split('/').includes('..')) return undefined; + return page; +} + +/** A file inside `root`, wherever under it `from` is; never outside `root`. */ +function fileUnder(from: string | undefined, root: string, rel: string): string | undefined { + const at = from ? path.resolve(path.dirname(from), rel) : undefined; + if (at && at.startsWith(root + path.sep) && existsSync(at)) return at; + // A plugin's own files usually sit beside its built entry; they can also sit at the top of its + // directory (with its package.json), which a build that cleans its output folder keeps. + const top = path.resolve(root, rel); + return top.startsWith(root + path.sep) && existsSync(top) ? top : undefined; +} + +/** + * Where a file the plugin ships is on disk, or undefined when it isn't one of the plugin's own. + * + * A plugin is found wherever it was loaded from: a folder of its own, or a single file the office was + * pointed at, whose own files sit beside it. + */ +export function pluginFilePath(plugin: LoadedPlugin, pluginsDir: string, relative: string): string | undefined { + const rel = safePluginPath(relative); + if (!rel) return undefined; + for (const dir of candidatePluginDirs(plugin.id, pluginsDir)) { + if (!existsSync(dir)) continue; + const root = path.resolve(dir); + // Wherever the plugin's entry is (its built bundle), and the top of its directory. + const found = fileUnder(detectEntryPath(dir) ?? undefined, root, rel); + if (found) return found; + } + const loaded = plugin.entry ? (path.isAbsolute(plugin.entry) ? plugin.entry : path.resolve(process.cwd(), plugin.entry)) : undefined; + if (loaded && existsSync(loaded) && path.extname(loaded)) return fileUnder(loaded, path.dirname(path.resolve(loaded)), rel); + return undefined; +} + +/** Where the plugin's own settings page is, when it ships one; otherwise undefined. */ +export function pluginSettingsPagePath(plugin: LoadedPlugin, pluginsDir: string): string | undefined { + return plugin.settingsPage ? pluginFilePath(plugin, pluginsDir, plugin.settingsPage) : undefined; +} diff --git a/src/server/plugins/registry.ts b/src/server/plugins/registry.ts new file mode 100644 index 000000000..67dcfbdf6 --- /dev/null +++ b/src/server/plugins/registry.ts @@ -0,0 +1,84 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import type { PluginManifest, PluginsState } from '../../shared/protocol.js'; + +export const BUILTIN_PLUGINS: PluginManifest[] = []; + +export class PluginRegistry { + private file: string; + public pluginsDir: string; + private manifests: Map = new Map(); + + constructor(dataDir?: string) { + const baseDir = dataDir ? (dataDir.endsWith('.agent-office') ? dataDir : path.join(dataDir, '.agent-office')) : path.join(process.cwd(), '.agent-office'); + this.file = path.join(baseDir, 'plugins.json'); + this.pluginsDir = path.join(baseDir, 'plugins'); + this.load(); + } + + private load() { + for (const p of BUILTIN_PLUGINS) { + this.manifests.set(p.id, { ...p }); + } + + if (existsSync(this.file)) { + try { + const raw = JSON.parse(readFileSync(this.file, 'utf8')); + if (Array.isArray(raw)) { + for (const item of raw) { + if (item && typeof item === 'object' && typeof item.id === 'string') { + const prev = this.manifests.get(item.id); + this.manifests.set(item.id, { + ...prev, + ...item, + installed: true, + status: 'active', + }); + } + } + } + } catch { + // ignore malformed file + } + } + } + + save() { + try { + const dir = path.dirname(this.file); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const installed = [...this.manifests.values()].filter((m) => m.installed !== false); + writeFileSync(this.file, JSON.stringify(installed, null, 2), 'utf8'); + } catch { + // ignore + } + } + + register(manifest: PluginManifest) { + this.manifests.set(manifest.id, { ...manifest, installed: true }); + } + + /** Forgets a manifest by id (a placeholder the loaded plugin made its own). */ + unregister(id: string) { + this.manifests.delete(id); + } + + list(): PluginManifest[] { + return [...this.manifests.values()]; + } + + get(id: string): PluginManifest | undefined { + return this.manifests.get(id); + } + + getState(): PluginsState { + const all = this.list(); + const installed = all.filter((m) => m.installed === true); + const store = all.filter((m) => m.installed !== true); + return { + plugins: installed, + store, + pluginsDir: this.pluginsDir, + }; + } +} diff --git a/src/server/plugins/types.ts b/src/server/plugins/types.ts new file mode 100644 index 000000000..0256217c7 --- /dev/null +++ b/src/server/plugins/types.ts @@ -0,0 +1,81 @@ +import type { FloorDef } from '../building.js'; +import type { Floor } from '../floor.js'; +import type { BoardCard, BoardCardDetail, CustomBoardDef, FloorInfo, GhIssue, GhIssueDetail, GhPull, GhPullDetail, PluginManifest, QueueTask, ServiceInfo, WorkerInfo } from '../../shared/protocol.js'; +import type { Ctx } from '../office/context.js'; +import type { ProviderMeta } from '../../shared/providers.js'; +import type { SomeAdapter } from '../providers/types.js'; + +export interface FloorProvider { + listFloors(): Promise | FloorDef[]; + sortFloors?(floors: FloorInfo[]): FloorInfo[]; + createFloor?(req: { repo?: string; dir?: string }): Promise<{ repo: string; dir: string; name: string } | null>; + decorateFloorInfo?(floor: Floor, info: FloorInfo): void; +} + +export interface IssuesAdapter { + listIssues(floor: Floor): Promise; + issueDetail?(floor: Floor, n: number, me?: string): Promise; +} + +export interface PullsAdapter { + listPulls(floor: Floor): Promise; + pullDetail?(floor: Floor, n: number, me?: string): Promise; + pullDiff?(floor: Floor, n: number): Promise; +} + +export interface QueueAdapter { + listTasks?(floor: Floor): Promise; + onTaskCreated?(floor: Floor, prompt: string): Promise; + onTaskDeleted?(floor: Floor, taskId: string): Promise; +} + +export interface ServicesProvider { + listServices(floor: Floor): Promise; + startService?(floor: Floor, serviceName?: string): Promise; + stopService?(floor: Floor, serviceName?: string): Promise; + getServiceLogs?(floor: Floor, serviceName?: string, lines?: number): Promise; +} + +export interface AgentProviderPlugin { + id: string; + meta: ProviderMeta; + adapter: SomeAdapter; +} + +export interface ProjectConfig { + id: string; + name?: string; + url?: string; + token?: string; + [key: string]: unknown; +} + +export interface ProjectsProvider { + listProjects?(floor: Floor): Promise; + saveProjects?(floor: Floor, projects: ProjectConfig[]): Promise; +} + +export interface BoardAdapter { + getBoardDef(): CustomBoardDef; + listCards(floor: Floor): Promise; + cardDetail?(floor: Floor, id: string): Promise; + assignCard?(floor: Floor, id: string, assignee?: string): Promise; + getCurrentUser?(floor: Floor): Promise<{ displayName?: string; email?: string; name?: string } | null>; + getConfig?(floor: Floor): Promise | null>; + saveConfig?(floor: Floor, config: Record): Promise; +} + +export interface OfficePlugin { + manifest: PluginManifest; + init?(ctx: Ctx): Promise | void; + shutdown?(): Promise | void; + floorProvider?: FloorProvider; + issuesAdapter?: IssuesAdapter; + pullsAdapter?: PullsAdapter; + queueAdapter?: QueueAdapter; + servicesProvider?: ServicesProvider; + agentProvider?: AgentProviderPlugin; + projectsProvider?: ProjectsProvider; + boardAdapters?: BoardAdapter[]; +} + diff --git a/src/server/prompts.ts b/src/server/prompts.ts index c537804cc..fab24037c 100644 --- a/src/server/prompts.ts +++ b/src/server/prompts.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; -import { isAgentEffort, isAgentProvider, type AgentChoice, type AgentProvider, type PromptsState } from '../shared/protocol.js'; +import { isAgentEffort, isKnownProvider, type AgentChoice, type AgentProvider, type PromptsState } from '../shared/protocol.js'; import { PROMPTS, PROMPT_MAX, fillPrompt, isPromptId, promptText, type PromptId, type PromptVars } from '../shared/prompts.js'; import { validateWorkerEffort, validateWorkerModel } from './agents.js'; @@ -76,7 +76,7 @@ export class OfficePrompts implements PromptSource { } private problem(c: AgentChoice): string | undefined { - if (!isAgentProvider(c.provider) || !this.providers.list.includes(c.provider)) return 'Unknown agent provider'; + if (!isKnownProvider(c.provider) || !this.providers.list.includes(c.provider)) return 'Unknown agent provider'; if (c.provider === 'custom' && this.providers.configured !== 'custom') return 'Custom is not the configured agent provider'; return validateWorkerModel('agent', c.provider, c.model) ?? validateWorkerEffort('agent', c.provider, c.effort); } @@ -98,7 +98,7 @@ export class OfficePrompts implements PromptSource { this.saved.custom[id] = { text: v.text.slice(0, PROMPT_MAX), by: typeof v.by === 'string' ? v.by : 'someone', at: typeof v.at === 'number' ? v.at : 0 }; } const a = raw?.agent; - if (a && isAgentProvider(a.provider)) { + if (a && isKnownProvider(a.provider)) { const choice: AgentChoice = { provider: a.provider, model: typeof a.model === 'string' ? a.model : undefined, effort: isAgentEffort(a.effort) ? a.effort : undefined }; // One the office can't start any more (it was started with another --agent) is forgotten. if (!this.problem(choice)) this.saved.agent = { ...choice, by: typeof a.by === 'string' ? a.by : 'someone', at: typeof a.at === 'number' ? a.at : 0 }; diff --git a/src/server/providers/claude.ts b/src/server/providers/claude.ts index 1eac357c3..5afa58ec6 100644 --- a/src/server/providers/claude.ts +++ b/src/server/providers/claude.ts @@ -128,6 +128,15 @@ function claudeHook(h: WorkerHandle, event: string, payload: any): boolean { h.tracker.transcript = payload.transcript_path; h.persist(); } + // A provider whose agent has a web UI of its own (an OpenFox session, say) says where: the worker's + // terminal then offers it as a tab. Only an http(s) link is taken, never anything the hook can use + // to point the browser somewhere else. + const sessionUrl = typeof payload?.web_url === 'string' ? payload.web_url : typeof payload?.embed_url === 'string' ? payload.embed_url : undefined; + if (sessionUrl && /^https?:\/\//.test(sessionUrl) && sessionUrl !== info.sessionUrl) { + info.sessionUrl = sessionUrl; + h.emit(); + h.persist(); + } h.scheduleScan(); switch (event) { case 'SessionStart': diff --git a/src/server/providers/custom.ts b/src/server/providers/custom.ts index 557a42c3e..eb1f1f91d 100644 --- a/src/server/providers/custom.ts +++ b/src/server/providers/custom.ts @@ -5,7 +5,18 @@ import type { ProviderAdapter } from './types.js'; export const custom: ProviderAdapter = { id: 'custom', - launch: ({ args }) => ({ args }), + launch: ({ args, prompt, resumeSessionId }) => { + // A custom agent is hired with a task, so it has to be given one. The + // office's own default agent never had this problem: every provider but + // `custom` put its own CLI's prompt flag in `launch`, and a custom one is + // run untouched, so its task was silently dropped. + // + // A custom agent that knows about sessions resumes them. Ahead of `--`, + // which ends the options and would swallow anything after it as prompt text. + if (resumeSessionId) args.push('--resume', resumeSessionId); + if (prompt) args.push('--', prompt); + return { args }; + }, hooksAs: 'claude', screen: { blocked: claudeBlocked }, usage: { transcript: true }, diff --git a/src/server/providers/index.ts b/src/server/providers/index.ts index 1158d0a0a..9f9b37735 100644 --- a/src/server/providers/index.ts +++ b/src/server/providers/index.ts @@ -26,17 +26,34 @@ export const PROVIDERS: Record = { custom, }; +const pluginAdapters: Record = {}; + +export function registerAgentProviderAdapter(id: string, adapter: SomeAdapter): void { + if (id in PROVIDERS || id in pluginAdapters) return; + pluginAdapters[id] = adapter; +} + +export function unregisterAgentProviderAdapter(id: string): void { + delete pluginAdapters[id]; +} + +export function getAdapter(provider: unknown): SomeAdapter | undefined { + if (typeof provider !== 'string') return undefined; + if (provider in PROVIDERS) return PROVIDERS[provider as AgentProvider]; + return pluginAdapters[provider]; +} + /** The hook route /hooks/`route` serves, when it's one (see ProviderAdapter.hook). */ export function providerHook(route: string): SomeAdapter['hook'] { - return providerAdapter(route)?.hook; + return getAdapter(route)?.hook; } /** A terminal title that's only an agent's own name ("Claude Code"): not worth showing, whichever agent the worker runs. */ export function titleNoise(title: string): boolean { - return Object.values(PROVIDERS).some((p) => p.titleNoise?.test(title)); + return [...Object.values(PROVIDERS), ...Object.values(pluginAdapters)].some((p) => p.titleNoise?.test(title)); } /** The adapter of `provider`, when it's one (workers.json and the wire can hold anything). */ export function providerAdapter(provider: unknown): SomeAdapter | undefined { - return typeof provider === 'string' && Object.hasOwn(PROVIDERS, provider) ? PROVIDERS[provider as AgentProvider] : undefined; + return getAdapter(provider); } diff --git a/src/server/queue.ts b/src/server/queue.ts index bc3736e06..d39fba5fd 100644 --- a/src/server/queue.ts +++ b/src/server/queue.ts @@ -1,11 +1,13 @@ import { randomBytes } from 'node:crypto'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; -import { isAgentProvider, type AgentChoice, type AgentEffort, type AgentProvider, type GhPull, type QueueState, type QueueTask, type WorkerInfo, type WorkerStatus } from '../shared/protocol.js'; +import { isKnownProvider, type AgentChoice, type AgentEffort, type AgentProvider, type GhPull, type QueueState, type QueueTask, type WorkerInfo, type WorkerStatus } from '../shared/protocol.js'; import { DESK_BY_ID, SEATS, nextFreeSeat } from '../shared/layout.js'; import { validateWorkerEffort, validateWorkerModel } from './agents.js'; import { savedEffort, savedModel, takesEffort, takesModel } from '../shared/providers.js'; import { PROMPTS } from '../shared/prompts.js'; +import type { Floor } from './floor.js'; +import type { QueueAdapter } from './plugins/types.js'; /** What the queue needs from the worker manager. Narrow on purpose, so a smoke test can fake it. */ export interface QueueWorkers { @@ -70,6 +72,7 @@ export class TaskQueue { /** Seat workers in their own git worktree (only when the project is a git repo). */ private useWorktree: boolean, private events: QueueEvents, + private external?: { adapter: QueueAdapter; floor: Floor }, ) { this.statePath = path.join(dataDir, 'queue.json'); this.restore(); @@ -80,6 +83,62 @@ export class TaskQueue { return { tasks: this.tasks.map((t) => ({ ...t })), maxWorkers: this.maxWorkers }; } + /** + * Replaces the queue with external adapter's tasks for this project. + */ + async syncFromAdapter(): Promise { + const external = this.external; + if (!external || !external.adapter.listTasks) return; + try { + const seen = await external.adapter.listTasks(external.floor); + if (!seen) return; + const prevJson = JSON.stringify(this.state()); + + const externalMap = new Map(seen.map((t) => [t.id, t])); + const updatedTasks: QueueTask[] = []; + + for (const local of this.tasks) { + const ext = externalMap.get(local.id) || (local.externalId ? externalMap.get(local.externalId) : undefined); + if (ext) { + externalMap.delete(ext.id); + if (local.status === 'running' || local.workerId) { + updatedTasks.push(local); + } else { + updatedTasks.push({ + ...local, + status: ext.status, + title: ext.title || local.title, + prompt: ext.prompt || local.prompt, + externalId: ext.id, + }); + } + } else { + // If task was added locally or is currently queued/running, keep it + if (!local.externalId || local.status === 'running' || local.status === 'queued') { + updatedTasks.push(local); + } + } + } + + // Add remaining external tasks not yet in local queue + for (const ext of externalMap.values()) { + updatedTasks.push({ + ...ext, + provider: ext.provider ?? this.workers.officeDefault?.provider ?? this.workers.defaultProvider, + externalId: ext.id, + }); + } + + this.tasks = updatedTasks; + const nextJson = JSON.stringify(this.state()); + if (prevJson !== nextJson) { + this.changed(); + } + } catch { + /* ignore */ + } + } + get limit(): number { return this.maxWorkers; } @@ -88,7 +147,7 @@ export class TaskQueue { /** Queues a task; `owner` is the account adding it, whose sign-ins its worker will run on. */ add(prompt: string, by: string, title?: string, issue?: number, provider?: AgentProvider, model?: string, effort?: AgentEffort, owner?: string): string | undefined { if (provider === undefined) ({ provider, model, effort } = this.workers.officeDefault ?? { provider: this.workers.defaultProvider }); - if (!isAgentProvider(provider) || (provider === 'custom' && this.workers.defaultProvider !== 'custom')) return 'Unknown agent provider'; + if (!isKnownProvider(provider) || (provider === 'custom' && this.workers.defaultProvider !== 'custom')) return 'Unknown agent provider'; const modelError = validateWorkerModel('agent', provider, model); if (modelError) return modelError; const effortError = validateWorkerEffort('agent', provider, effort); @@ -111,6 +170,9 @@ export class TaskQueue { status: 'queued', }; this.tasks.push(task); + if (this.external?.adapter.onTaskCreated) { + void this.external.adapter.onTaskCreated(this.external.floor, prompt); + } this.changed(); this.pump(); return undefined; @@ -121,6 +183,9 @@ export class TaskQueue { if (!t) return 'No such task'; if (t.status === 'running') return `${t.workerName ?? 'Its worker'} is on it — send the worker home to stop it`; this.tasks.splice(this.tasks.indexOf(t), 1); + if (this.external?.adapter.onTaskDeleted) { + void this.external.adapter.onTaskDeleted(this.external.floor, taskId); + } this.changed(); this.pump(); return undefined; @@ -164,8 +229,14 @@ export class TaskQueue { /** Forgets the finished tasks. */ clear() { + const toDelete = this.tasks.filter((t) => t.status === 'done'); const before = this.tasks.length; this.tasks = this.tasks.filter((t) => t.status !== 'done'); + if (this.external?.adapter.onTaskDeleted) { + for (const t of toDelete) { + void this.external.adapter.onTaskDeleted(this.external.floor, t.id); + } + } if (this.tasks.length !== before) this.changed(); } @@ -382,7 +453,7 @@ export class TaskQueue { if (typeof saved.maxWorkers === 'number' && Number.isFinite(saved.maxWorkers)) this.maxWorkers = Math.max(0, Math.min(SEATS.length, Math.floor(saved.maxWorkers))); for (const s of saved.tasks ?? []) { if (typeof s.id !== 'string' || typeof s.prompt !== 'string' || typeof s.title !== 'string') continue; - const provider = isAgentProvider(s.provider) ? s.provider : this.workers.defaultProvider; + const provider = isKnownProvider(s.provider) ? s.provider : this.workers.defaultProvider; const t: QueueTask = { id: s.id, provider, diff --git a/src/server/server.ts b/src/server/server.ts index a97beb5ba..24cec04fe 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -30,6 +30,7 @@ export async function startServer(cfg: Config, opts: StartOptions = {}) { const ctx = {} as Ctx; Object.assign(ctx, messaging(ctx), floorHelpers(ctx), people(ctx), navigation(ctx), gates(ctx)); Object.assign(ctx, createCore(ctx, cfg, publicDir)); + await ctx.plugins.init(ctx); const { hookServer, hookPort } = await startHookServer(ctx); Object.assign(ctx, createServices(ctx)); Object.assign(ctx, await openFloors(ctx, hookPort)); @@ -55,6 +56,7 @@ export async function startServer(cfg: Config, opts: StartOptions = {}) { const shutdown = (keep = false) => { stopTimers(); ctx.cancelFloorsChanged(); + void ctx.plugins.shutdown(); ctx.arcade.flush(); ctx.upgrader.stop(); services.stop(); diff --git a/src/server/workers/persist.ts b/src/server/workers/persist.ts index c1f86f2ef..4a0018615 100644 --- a/src/server/workers/persist.ts +++ b/src/server/workers/persist.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import type { AgentProvider, WorkerInfo, WorkerStatus, WorkerTask } from '../../shared/protocol.js'; import { DESK_BY_ID } from '../../shared/layout.js'; -import { isAgentProvider, savedEffort, savedModel } from '../../shared/providers.js'; +import { isKnownProvider, savedEffort, savedModel } from '../../shared/providers.js'; import { providerAdapter } from '../providers/index.js'; import { reportedUsage } from '../reported-usage.js'; import { restoreTracker, trackerUsage } from '../usage.js'; @@ -66,7 +66,7 @@ export function restoreWorkers(file: string, workers: Map, defau const tracker = restoreTracker(s.tracker); const provider = s.kind === 'shell' ? undefined - : isAgentProvider(s.provider) + : isKnownProvider(s.provider) ? s.provider : tracker.transcript ? 'claude' diff --git a/src/server/ws/connection.ts b/src/server/ws/connection.ts index d740bd348..f037b978c 100644 --- a/src/server/ws/connection.ts +++ b/src/server/ws/connection.ts @@ -83,6 +83,7 @@ export function onConnection(ctx: Ctx, ws: WebSocket, url: URL, session: Session map: maps.state(), prompts: prompts.state(), leaveOnMerge: leaveOnMerge.state(), + plugins: ctx.plugins?.getState(), ...(onRoof ? roofView(ctx) : floorView(ctx, floor)), }); screensOf(ctx, client, floor); diff --git a/src/server/ws/handlers/boards.ts b/src/server/ws/handlers/boards.ts new file mode 100644 index 000000000..658096ffd --- /dev/null +++ b/src/server/ws/handlers/boards.ts @@ -0,0 +1,13 @@ +import type { BoardsClientMsg } from '../../../shared/protocol.js'; +import type { HandlerMap, ViewPieces } from './types.js'; + +export const boardsView: ViewPieces['boards'] = (_ctx, floor) => floor?.boardsState() ?? {}; + +export const boardsHandlers: HandlerMap = { + 'board.refresh'(ctx, c, msg) { + const floor = ctx.floorOf(c); + if (floor && typeof msg.boardId === 'string') { + void floor.refreshBoard(msg.boardId); + } + }, +}; diff --git a/src/server/ws/handlers/floors.ts b/src/server/ws/handlers/floors.ts index 3f0f2cdab..31df4fa2e 100644 --- a/src/server/ws/handlers/floors.ts +++ b/src/server/ws/handlers/floors.ts @@ -1,6 +1,10 @@ // The building's floors: riding the elevator between them and up to the roof, and adding and taking // off floors. -import type { FloorClientMsg } from '../../../shared/protocol.js'; +import { existsSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { FloorClientMsg, RepoChoice } from '../../../shared/protocol.js'; +import type { Ctx } from '../../office/context.js'; import { ROOF } from '../../../shared/rooftop.js'; import { arrivalSpot, str } from '../../office/input.js'; import type { HandlerMap, ViewPieces } from './types.js'; @@ -19,6 +23,26 @@ export const floorHandlers = { else ctx.goToFloor(c, floor, arrivalSpot(msg.at)); }, 'floor.repos'(ctx, c, msg) { + const floorProvider = ctx.plugins?.getFloorProvider(); + if (floorProvider) { + void (async () => { + try { + const defs = await floorProvider.listFloors(); + const onBuilding = new Set(ctx.building.list().map((d) => d.id)); + const choices: RepoChoice[] = (defs || []) + .filter((d) => !onBuilding.has(d.id)) + .map((d) => ({ + name: d.dir || d.name, + description: d.name, + private: false, + })); + ctx.sendTo(c, { t: 'floor.repos', repos: choices }); + } catch (err) { + ctx.sendTo(c, { t: 'floor.repos', repos: [], error: (err as Error).message }); + } + })(); + return; + } void ctx.building.repos(msg.refresh === true).then( (repos) => ctx.sendTo(c, { t: 'floor.repos', repos }), (err: Error) => ctx.sendTo(c, { t: 'floor.repos', repos: [], error: `Couldn't list your repositories with gh: ${err.message}` }), @@ -26,7 +50,44 @@ export const floorHandlers = { }, 'floor.add'(ctx, c, msg) { const who = c.peer.name; - const repo = str(msg.repo, 200); + const repo = str(msg.repo, 400); + const floorProvider = ctx.plugins?.getFloorProvider(); + if (floorProvider) { + void (async () => { + try { + const res = await ctx.plugins?.createFloor({ repo, dir: repo }); + if (!res) return ctx.sendTo(c, { t: 'floor.added', repo, error: 'Could not create project via plugin' }); + ctx.building.unremove(res.dir || res.name); + ctx.building.unremove(repo); + const defs = await floorProvider.listFloors(); + ctx.building.mergeList(defs); + for (const d of defs) ctx.openFloor(d); + ctx.floorsChanged(); + const floor = ctx.floors.get(res.name) || [...ctx.floors.values()].find((f) => f.def.name === res.name); + console.log(` ${who} added ${res.name} as a floor`); + ctx.toastAll(`🛗 New floor: ${res.name}, added by ${who}`); + ctx.sendTo(c, { t: 'floor.added', repo, floor: floor?.id }); + } catch (err) { + ctx.sendTo(c, { t: 'floor.added', repo, error: (err as Error).message }); + } + })(); + return; + } + const isDir = repo.startsWith('/') || repo.startsWith('~') || existsSync(path.resolve(repo)); + if (isDir) { + const dirPath = path.resolve(repo.startsWith('~') ? path.join(os.homedir(), repo.slice(1)) : repo); + if (existsSync(dirPath)) { + const name = path.basename(dirPath); + const res = ctx.building.addExisting(dirPath, name, who); + ctx.floorsChanged(); + if (typeof res === 'string') return ctx.sendTo(c, { t: 'floor.added', repo, error: res }); + const floor = ctx.openFloor(res); + if (!floor) return ctx.sendTo(c, { t: 'floor.added', repo, error: `Added ${name}, but couldn't open its floor` }); + console.log(` ${who} added a floor for ${name} (${dirPath})`); + ctx.toastAll(`🛗 New floor: ${name}, added by ${who}`); + return ctx.sendTo(c, { t: 'floor.added', repo, floor: floor.id }); + } + } void ctx.building .add( repo, @@ -78,4 +139,13 @@ export const floorHandlers = { ctx.broadcast({ t: 'projectsDir', state }); ctx.toastAll(state.custom ? `📁 ${who} moved the workspace folder to ${state.dir}` : `📁 ${who} put the workspace folder back to ${state.dir}`); }, + 'floor.maxFloors'(ctx, c, msg) { + const who = c.peer.name; + if (!ctx.meOf(c.accountId).admin) return ctx.warn(c, 'Only admins can change the max floors limit'); + const n = Math.max(1, Math.min(2000, Math.floor(Number(msg.max) || 500))); + ctx.building.setMaxFloors(n, who); + const state = ctx.building.projectsDirState(); + ctx.broadcast({ t: 'projectsDir', state }); + ctx.toastAll(`🏢 ${who} set the max floors limit to ${n}`); + }, } satisfies HandlerMap; diff --git a/src/server/ws/handlers/index.ts b/src/server/ws/handlers/index.ts index dd119c2ea..2d89e5e0f 100644 --- a/src/server/ws/handlers/index.ts +++ b/src/server/ws/handlers/index.ts @@ -3,6 +3,7 @@ import type { ClientMsg } from '../../../shared/protocol.js'; import { accountsHandlers } from './accounts.js'; import { ballHandlers, ballHooks, ballView } from './ball.js'; +import { boardsHandlers, boardsView } from './boards.js'; import { cabinetHandlers, cabinetHooks, cabinetView } from './cabinet.js'; import { carHandlers, carHooks, carsView } from './car.js'; import { changesHandlers, changesHooks } from './changes.js'; @@ -28,6 +29,7 @@ import type { FeatureHooks, HandlerMap, ViewPieces } from './types.js'; export const handlers: HandlerMap = { ...accountsHandlers, ...ballHandlers, + ...boardsHandlers, ...cabinetHandlers, ...carHandlers, ...changesHandlers, @@ -73,4 +75,5 @@ export const views: ViewPieces = { whiteboard: whiteboardView, meeting: meetingView, cabinet: cabinetView, + boards: boardsView, }; diff --git a/src/server/ws/handlers/meetings.ts b/src/server/ws/handlers/meetings.ts index df1f88ca4..ce4981abf 100644 --- a/src/server/ws/handlers/meetings.ts +++ b/src/server/ws/handlers/meetings.ts @@ -1,5 +1,5 @@ // The meeting room: calling a meeting, stopping it, and clearing the table. -import { isAgentEffort, isAgentProvider, type MeetingClientMsg, type MeetingRequest } from '../../../shared/protocol.js'; +import { isAgentEffort, isKnownProvider, type MeetingClientMsg, type MeetingRequest } from '../../../shared/protocol.js'; import { OPEN_CODE_MODEL_MAX } from '../../../shared/providers.js'; import { str } from '../../office/input.js'; import { here } from './common.js'; @@ -12,7 +12,7 @@ export const meetingHandlers = { const who = c.peer.name; const floor = here(ctx, c); if (!floor) return; - if (msg.provider !== undefined && (!isAgentProvider(msg.provider) || !floor.project.agentProviders.includes(msg.provider))) { + if (msg.provider !== undefined && (!isKnownProvider(msg.provider) || !floor.project.agentProviders.includes(msg.provider))) { ctx.warn(c, 'Unknown agent provider'); return; } diff --git a/src/server/ws/handlers/queue.ts b/src/server/ws/handlers/queue.ts index 31fabd87c..67fc022c9 100644 --- a/src/server/ws/handlers/queue.ts +++ b/src/server/ws/handlers/queue.ts @@ -1,18 +1,21 @@ // The floor's task queue: adding, moving and retrying tasks, and how many workers it keeps busy. -import { isAgentEffort, isAgentProvider, type QueueClientMsg } from '../../../shared/protocol.js'; +import { isAgentEffort, isKnownProvider, type QueueClientMsg } from '../../../shared/protocol.js'; import { OPEN_CODE_MODEL_MAX } from '../../../shared/providers.js'; import { num, str } from '../../office/input.js'; import { here } from './common.js'; import type { HandlerMap, ViewPieces } from './types.js'; -export const queueView: ViewPieces['queue'] = (_ctx, floor) => floor?.queue.state() ?? { tasks: [], maxWorkers: 0 }; +export const queueView: ViewPieces['queue'] = (_ctx, floor) => { + if (floor) void floor.queue.syncFromAdapter(); + return floor?.queue.state() ?? { tasks: [], maxWorkers: 0 }; +}; export const queueHandlers = { 'queue.add'(ctx, c, msg) { const who = c.peer.name; const floor = here(ctx, c); if (!floor) return; - if (msg.provider !== undefined && (!isAgentProvider(msg.provider) || !floor.project.agentProviders.includes(msg.provider))) { + if (msg.provider !== undefined && (!isKnownProvider(msg.provider) || !floor.project.agentProviders.includes(msg.provider))) { ctx.warn(c, 'Unknown agent provider'); return; } @@ -28,14 +31,14 @@ export const queueHandlers = { }, 'queue.remove'(ctx, c, msg) { const floor = here(ctx, c); - if (floor) ctx.warn(c, floor.queue.remove(str(msg.taskId, 32))); + if (floor) ctx.warn(c, floor.queue.remove(str(msg.taskId, 128))); }, 'queue.move'(ctx, c, msg) { - ctx.floorOf(c)?.queue.move(str(msg.taskId, 32), num(msg.delta) < 0 ? -1 : 1); + ctx.floorOf(c)?.queue.move(str(msg.taskId, 128), num(msg.delta) < 0 ? -1 : 1); }, 'queue.retry'(ctx, c, msg) { const floor = here(ctx, c); - if (floor) ctx.warn(c, floor.queue.retry(str(msg.taskId, 32))); + if (floor) ctx.warn(c, floor.queue.retry(str(msg.taskId, 128))); }, 'queue.clear'(ctx, c) { ctx.floorOf(c)?.queue.clear(); diff --git a/src/server/ws/handlers/settings.ts b/src/server/ws/handlers/settings.ts index 06ab3bac3..ab04374e5 100644 --- a/src/server/ws/handlers/settings.ts +++ b/src/server/ws/handlers/settings.ts @@ -98,6 +98,10 @@ export const settingsHandlers = { } mapNews(ctx, was, who); }, + 'map.openFolder'(ctx, c) { + if (!ctx.meOf(c.accountId).admin) return ctx.warn(c, 'Only admins can open folders on the server host'); + ctx.maps.openFolder(); + }, 'leaveOnMerge.set'(ctx, c, msg) { const who = c.peer.name; const on = msg.on === true; @@ -133,4 +137,88 @@ export const settingsHandlers = { if (err) return ctx.warn(c, err); ctx.toastAll(choice ? `🤖 ${who} set the office’s default worker` : `🤖 ${who} put the office’s default worker back to ${path.basename(ctx.cfg.agentCmd)}`); }, + 'plugins.list'(ctx, c) { + if (ctx.plugins) { + ctx.sendTo(c, { t: 'plugins', state: ctx.plugins.getState() }); + } + }, + 'plugin.install'(ctx, c, msg) { + const who = c.peer.name; + if (!ctx.meOf(c.accountId).admin) return ctx.warn(c, 'Only admins can install plugins'); + const id = str(msg.id, 64); + if (!ctx.plugins) return; + void ctx.plugins.install(id).then((ok) => { + if (ok) { + ctx.toastAll(`🔌 ${who} installed plugin ${id}`); + for (const client of ctx.clients.values()) { + ctx.sendTo(client, { t: 'plugins', state: ctx.plugins!.getState() }); + } + } else { + ctx.warn(c, `Could not install plugin ${id}`); + } + }); + }, + 'plugin.uninstall'(ctx, c, msg) { + const who = c.peer.name; + if (!ctx.meOf(c.accountId).admin) return ctx.warn(c, 'Only admins can uninstall plugins'); + const id = str(msg.id, 64); + if (!ctx.plugins) return; + void ctx.plugins.uninstall(id).then((ok) => { + if (ok) { + ctx.toastAll(`🔌 ${who} uninstalled plugin ${id}`); + for (const client of ctx.clients.values()) { + ctx.sendTo(client, { t: 'plugins', state: ctx.plugins!.getState() }); + } + } else { + ctx.warn(c, `Could not uninstall plugin ${id}`); + } + }); + }, + 'plugin.openFolder'(ctx, c, msg) { + if (!ctx.meOf(c.accountId).admin) return ctx.warn(c, 'Only admins can open folders on server host'); + if (ctx.plugins) { + const id = msg.id ? str(msg.id, 64) : undefined; + ctx.plugins.openFolder(id); + } + }, + 'plugin.installFromGit'(ctx, c, msg) { + const who = c.peer.name; + if (!ctx.meOf(c.accountId).admin) return ctx.warn(c, 'Only admins can install plugins'); + const url = str(msg.url, 2048).trim(); + if (!ctx.plugins) return; + void ctx.plugins.installFromGit(url).then((result) => { + if (result.ok) { + ctx.toastAll(`🔌 ${who} installed plugin from ${url}`); + for (const client of ctx.clients.values()) { + ctx.sendTo(client, { t: 'plugins', state: ctx.plugins!.getState() }); + } + } else { + ctx.warn(c, result.error ?? 'Failed to install plugin'); + } + }); + }, + 'plugin.update'(ctx, c, msg) { + const who = c.peer.name; + if (!ctx.meOf(c.accountId).admin) return ctx.warn(c, 'Only admins can update plugins'); + const id = str(msg.id, 64); + if (!ctx.plugins) return; + void ctx.plugins.updatePlugin(id).then((result) => { + if (result.ok) { + ctx.toastAll(`🔌 ${who} updated plugin ${id}`); + for (const client of ctx.clients.values()) { + ctx.sendTo(client, { t: 'plugins', state: ctx.plugins!.getState() }); + } + } else { + ctx.warn(c, result.error ?? `Failed to update plugin ${id}`); + } + }); + }, + 'plugin.checkUpdates'(ctx, _c) { + if (!ctx.plugins) return; + void ctx.plugins.checkUpdates().then(() => { + for (const client of ctx.clients.values()) { + ctx.sendTo(client, { t: 'plugins', state: ctx.plugins!.getState() }); + } + }); + }, } satisfies HandlerMap; diff --git a/src/server/ws/handlers/workers.ts b/src/server/ws/handlers/workers.ts index 3e468890c..7aea43546 100644 --- a/src/server/ws/handlers/workers.ts +++ b/src/server/ws/handlers/workers.ts @@ -2,7 +2,7 @@ // worktrees and pull requests. import { MAX_REPOS, type RepoSource } from '../../workers.js'; import { OPEN_CODE_MODEL_MAX } from '../../../shared/providers.js'; -import { isAgentEffort, isAgentProvider, type WorkerClientMsg } from '../../../shared/protocol.js'; +import { isAgentEffort, isKnownProvider, type WorkerClientMsg } from '../../../shared/protocol.js'; import { issueNumber, num, str } from '../../office/input.js'; import { here, workerOf } from './common.js'; import type { FeatureHooks, HandlerMap, ViewPieces } from './types.js'; @@ -21,7 +21,7 @@ export const workerHandlers = { const floor = here(ctx, c); if (!floor) return; const kind = msg.kind === 'shell' ? 'shell' : 'agent'; - if (kind === 'agent' && msg.provider !== undefined && (!isAgentProvider(msg.provider) || !floor.project.agentProviders.includes(msg.provider))) { + if (kind === 'agent' && msg.provider !== undefined && (!isKnownProvider(msg.provider) || !floor.project.agentProviders.includes(msg.provider))) { ctx.warn(c, 'Unknown agent provider'); return; } diff --git a/src/shared/maps/index.ts b/src/shared/maps/index.ts index 850cc075e..9397cc6aa 100644 --- a/src/shared/maps/index.ts +++ b/src/shared/maps/index.ts @@ -18,10 +18,13 @@ export const OFFICE_MAP = 'office'; /** The maps that come with the office, besides the office itself. */ export const BUILTIN_MAPS: readonly MapConfig[] = [CASTLE, STATION]; +const OFFICE_PROP_KINDS = { ...PROP_KINDS, ...STATION_PROP_KINDS }; + /** The props each style of map can put up (see ./props.ts and ./station-props.ts): their kinds, the floor each takes and how high it reaches. */ -const STYLE_PROPS: Record>; footprint: typeof propFootprint; top(p: PropConfig): number }> = { +const STYLE_PROPS: Record>; footprint: (p: PropConfig) => { rect?: Rect; circle?: Circle } | null; top(p: PropConfig): number }> = { castle: { kinds: PROP_KINDS, footprint: propFootprint, top: propTop }, station: { kinds: STATION_PROP_KINDS, footprint: stationFootprint, top: stationTop }, + office: { kinds: OFFICE_PROP_KINDS, footprint: (p) => propFootprint(p) ?? stationFootprint(p), top: (p) => Math.max(propTop(p), stationTop(p)) }, }; const STATION_KINDS: readonly StationKind[] = ['issues', 'pulls', 'queue']; @@ -133,8 +136,8 @@ export function planMap(input: unknown): MapPlan { const id = c.id; if (id === OFFICE_MAP) throw new MapError('"office" is the office’s own id'); const name = str(c.name, 'name', 40); - if (!(MAP_STYLES as readonly string[]).includes(c.style)) throw new MapError(`its style "${String(c.style)}" isn’t one there’s a builder for (${MAP_STYLES.join(', ')})`); - const style = c.style as MapStyle; + if (c.style !== 'office' && !(MAP_STYLES as readonly string[]).includes(c.style)) throw new MapError(`its style "${String(c.style)}" isn’t one there’s a builder for (${MAP_STYLES.join(', ')})`); + const style = c.style as MapStyle | 'office'; const kinds = STYLE_PROPS[style]; if (!isObj(c.hall)) throw new MapError('it needs a hall: { width, length, height }'); const width = num(c.hall.width, 'hall.width', 8, 110); @@ -158,60 +161,67 @@ export function planMap(input: unknown): MapPlan { const circles: Circle[] = []; // The seats at the tables: every table's side toward the middle of the hall first, then the far sides. - if (!Array.isArray(c.tables) || !c.tables.length) throw new MapError('it needs tables for the workers to sit at'); - if (c.tables.length > MAP_LIMITS.tables) throw new MapError(`it has ${c.tables.length} tables, and a map can have ${MAP_LIMITS.tables}`); - const inner: { def: Omit; table: string }[] = []; - const outer: typeof inner = []; + let desks: DeskDef[] = []; + let overflow: DeskDef[] = []; const tables: MapPlan['tables'] = []; - c.tables.forEach((t: TableConfig, i) => { - const what = `tables[${i}]`; - if (!isObj(t)) throw new MapError(`${what} should be { x, z, length, seats }`); - const x = num(t.x, `${what}.x`); - const z = num(t.z, `${what}.z`); - const len = num(t.length, `${what}.length`, 1, 100); - const w = t.width === undefined ? 1.4 : num(t.width, `${what}.width`, 0.6, 4); - const r = t.rotY === undefined ? 0 : num(t.rotY, `${what}.rotY`); - const n = num(t.seats, `${what}.seats`, 1, MAP_LIMITS.seats); - if (!Number.isInteger(n)) throw new MapError(`${what}.seats should be a whole number`); - const along = [Math.sin(r), Math.cos(r)]; - const right = [Math.cos(r), -Math.sin(r)]; - for (const end of [-1, 1]) inside(x + (along[0] * len * end) / 2, z + (along[1] * len * end) / 2, `the end of ${what}`); - // The side facing the middle of the hall is the inner one. - const rightInner = right[0] * x + right[1] * z <= 0; - const sides = t.sides ?? 'both'; - if (sides !== 'both' && sides !== 'inner' && sides !== 'outer') throw new MapError(`${what}.sides should be "both", "inner" or "outer"`); - const table = typeof t.name === 'string' && t.name.trim() ? t.name.trim().slice(0, 40) : `Table ${i + 1}`; - rects.push(boxFootprint(x, z, w, len, r)); - const seated: (1 | -1)[] = []; - tables.push({ x, z, length: len, width: w, rotY: r, seats: n, sides: seated, name: table }); - for (const s of [1, -1] as const) { - const isInner = (s === 1) === rightInner; - if (sides !== 'both' && (sides === 'inner') !== isInner) continue; - seated.push(s); - const nx = right[0] * s; - const nz = right[1] * s; - // The bench down that side, all of it (and whoever sits on it) inside the hall. - rects.push(boxFootprint(x + nx * (w / 2 + BENCH_OUT), z + nz * (w / 2 + BENCH_OUT), 0.42, len - 0.2, r)); - for (const end of [-1, 1]) inside(x + nx * (w / 2 + BENCH_OUT + 0.22) + (along[0] * (len - 0.2) * end) / 2, z + nz * (w / 2 + BENCH_OUT + 0.22) + (along[1] * (len - 0.2) * end) / 2, `the bench along ${what}`, 0); - const out = w / 2 - PLACE_IN; - for (let k = 0; k < n; k++) { - const t0 = (k - (n - 1) / 2) * (len / n); - const def = { x: x + along[0] * t0 + nx * out, z: z + along[1] * t0 + nz * out, rotY: Math.atan2(nx, nz) }; - (isInner ? inner : outer).push({ def, table }); + if (style === 'office' && (!Array.isArray(c.tables) || !c.tables.length)) { + desks = MAP_DESKS; + overflow = BEANBAGS; + } else { + if (!Array.isArray(c.tables) || !c.tables.length) throw new MapError('it needs tables for the workers to sit at'); + if (c.tables.length > MAP_LIMITS.tables) throw new MapError(`it has ${c.tables.length} tables, and a map can have ${MAP_LIMITS.tables}`); + const inner: { def: Omit; table: string }[] = []; + const outer: typeof inner = []; + c.tables.forEach((t: TableConfig, i) => { + const what = `tables[${i}]`; + if (!isObj(t)) throw new MapError(`${what} should be { x, z, length, seats }`); + const x = num(t.x, `${what}.x`); + const z = num(t.z, `${what}.z`); + const len = num(t.length, `${what}.length`, 1, 100); + const w = t.width === undefined ? 1.4 : num(t.width, `${what}.width`, 0.6, 4); + const r = t.rotY === undefined ? 0 : num(t.rotY, `${what}.rotY`); + const n = num(t.seats, `${what}.seats`, 1, MAP_LIMITS.seats); + if (!Number.isInteger(n)) throw new MapError(`${what}.seats should be a whole number`); + const along = [Math.sin(r), Math.cos(r)]; + const right = [Math.cos(r), -Math.sin(r)]; + for (const end of [-1, 1]) inside(x + (along[0] * len * end) / 2, z + (along[1] * len * end) / 2, `the end of ${what}`); + // The side facing the middle of the hall is the inner one. + const rightInner = right[0] * x + right[1] * z <= 0; + const sides = t.sides ?? 'both'; + if (sides !== 'both' && sides !== 'inner' && sides !== 'outer') throw new MapError(`${what}.sides should be "both", "inner" or "outer"`); + const table = typeof t.name === 'string' && t.name.trim() ? t.name.trim().slice(0, 40) : `Table ${i + 1}`; + rects.push(boxFootprint(x, z, w, len, r)); + const seated: (1 | -1)[] = []; + tables.push({ x, z, length: len, width: w, rotY: r, seats: n, sides: seated, name: table }); + for (const s of [1, -1] as const) { + const isInner = (s === 1) === rightInner; + if (sides !== 'both' && (sides === 'inner') !== isInner) continue; + seated.push(s); + const nx = right[0] * s; + const nz = right[1] * s; + // The bench down that side, all of it (and whoever sits on it) inside the hall. + rects.push(boxFootprint(x + nx * (w / 2 + BENCH_OUT), z + nz * (w / 2 + BENCH_OUT), 0.42, len - 0.2, r)); + for (const end of [-1, 1]) inside(x + nx * (w / 2 + BENCH_OUT + 0.22) + (along[0] * (len - 0.2) * end) / 2, z + nz * (w / 2 + BENCH_OUT + 0.22) + (along[1] * (len - 0.2) * end) / 2, `the bench along ${what}`, 0); + const out = w / 2 - PLACE_IN; + for (let k = 0; k < n; k++) { + const t0 = (k - (n - 1) / 2) * (len / n); + const def = { x: x + along[0] * t0 + nx * out, z: z + along[1] * t0 + nz * out, rotY: Math.atan2(nx, nz) }; + (isInner ? inner : outer).push({ def, table }); + } } - } - }); - const all = [...inner, ...outer]; - const need = MAP_DESKS.length + BEANBAGS.length; - if (all.length < need) throw new MapError(`its tables seat ${all.length}, and a map needs ${need} (${MAP_DESKS.length} seats and ${BEANBAGS.length} more for when they’re all taken)`); - const counts = new Map(); - const named = all.slice(0, need).map(({ def, table }) => { - const k = (counts.get(table) ?? 0) + 1; - counts.set(table, k); - return { ...def, label: `${table}, seat ${k}` }; - }); - const desks: DeskDef[] = named.slice(0, MAP_DESKS.length).map((d, i) => ({ ...d, id: MAP_DESKS[i].id })); - const overflow: DeskDef[] = named.slice(MAP_DESKS.length).map((d, i) => ({ ...d, id: BEANBAGS[i].id })); + }); + const all = [...inner, ...outer]; + const need = MAP_DESKS.length + BEANBAGS.length; + if (all.length < need) throw new MapError(`its tables seat ${all.length}, and a map needs ${need} (${MAP_DESKS.length} seats and ${BEANBAGS.length} more for when they’re all taken)`); + const counts = new Map(); + const named = all.slice(0, need).map(({ def, table }) => { + const k = (counts.get(table) ?? 0) + 1; + counts.set(table, k); + return { ...def, label: `${table}, seat ${k}` }; + }); + desks = named.slice(0, MAP_DESKS.length).map((d, i) => ({ ...d, id: MAP_DESKS[i].id })); + overflow = named.slice(MAP_DESKS.length).map((d, i) => ({ ...d, id: BEANBAGS[i].id })); + } // The board agents' lecterns. if (!isObj(c.stations)) throw new MapError('it needs stations: where the Issues, PR and Queue agents stand'); diff --git a/src/shared/maps/props.ts b/src/shared/maps/props.ts index 0bfcc96a0..e08846dd7 100644 --- a/src/shared/maps/props.ts +++ b/src/shared/maps/props.ts @@ -7,7 +7,7 @@ import type { PropConfig } from './types.js'; * style's builder's (world/castle/). A station's are in ./station-props.ts. */ -export const PROP_KINDS = { +export const CASTLE_PROP_KINDS = { /** A stone pillar, floor to ceiling. `scale` widens it. */ pillar: 'A stone pillar, floor to ceiling', /** A torch in an iron sconce on a wall or a pillar, `y` up, burning toward `rotY`. */ @@ -41,7 +41,33 @@ export const PROP_KINDS = { /** A tall iron candle stand. */ candles: 'A candle stand', } as const; -export type PropKind = keyof typeof PROP_KINDS; +export type PropKind = keyof typeof CASTLE_PROP_KINDS; + +export const PROP_KINDS = { + ...CASTLE_PROP_KINDS, + /** An interior wall or partition. */ + wall: 'An interior wall or partition', + /** An automatic sliding door. */ + door: 'An automatic sliding door', + /** A meeting conference table with chairs. */ + meeting_table: 'A meeting conference table', + /** A room status screen. */ + meeting_sign: 'A room status screen', + /** A wall-mounted text sign. */ + sign: 'A wall-mounted text sign', + /** The room to grow sign. */ + expand_sign: 'The room to grow sign', + /** A meeting whiteboard. */ + meeting_board: 'A meeting whiteboard', + /** A floor slab or extension. */ + floor: 'A floor slab or extension', + /** A ceiling slab. */ + ceiling: 'A ceiling slab', + /** A ceiling light fixture. */ + lamp: 'A ceiling light fixture', + /** A custom board on wall (e.g. plugin board). */ + custom_board: 'A custom board on wall', +} as const; /** The floor a `w` by `d` box takes, centered on (x, z) and turned `rotY` (its `d` along rotY). */ export function boxFootprint(x: number, z: number, w: number, d: number, rotY = 0): Rect { @@ -87,6 +113,10 @@ export function propFootprint(p: PropConfig): { rect?: Rect; circle?: Circle } | return { rect: boxFootprint(p.x, p.z, PROP_SIZE.cask.width * s, PROP_SIZE.cask.depth * s, r) }; case 'table': return { rect: boxFootprint(p.x, p.z, p.width ?? 1.4, p.length ?? 3, r) }; + case 'wall': + return { rect: boxFootprint(p.x, p.z, p.width ?? 0.16, p.length ?? 3, r) }; + case 'meeting_table': + return { rect: boxFootprint(p.x, p.z, p.width ?? 2.4, p.length ?? 1.3, r) }; default: return null; } @@ -98,6 +128,18 @@ export function propTop(p: PropConfig): number { switch (p.kind) { case 'window': return y + (p.height ?? 5); + case 'ceiling': + return y + 0.1; + case 'wall': + return y + (p.height ?? 3.2); + case 'meeting_table': + return y + 0.78; + case 'meeting_sign': + case 'sign': + case 'expand_sign': + return y + 0.9; + case 'meeting_board': + return y + (p.height ?? 1.2) / 2; case 'rose': return y + (p.width ?? 4.5) / 2; case 'banner': diff --git a/src/shared/maps/types.ts b/src/shared/maps/types.ts index ee1830dfb..dcf3eae44 100644 --- a/src/shared/maps/types.ts +++ b/src/shared/maps/types.ts @@ -74,6 +74,9 @@ export interface PropConfig { color?: string; /** A torch or a brazier that lights the room (a few at most: see MAX_LIGHTS). */ light?: boolean; + name?: string; + /** For custom_board: identifier of the board (e.g. "jira"). */ + boardId?: string; } /** @@ -204,6 +207,22 @@ export interface MapConfig { * panel, metal and glow. */ palette?: Partial>; + /** Wall openings cut into outside walls: doors or archways. */ + openings?: { wall: 'north' | 'south' | 'east' | 'west'; u: number; width: number; y0: number; y1: number }[]; + /** Windows in outside walls to close / remove. */ + closedWindows?: { wall: 'north' | 'south' | 'east' | 'west'; u: number }[]; + /** Custom windows in outside walls. */ + windows?: { wall: 'north' | 'south' | 'east' | 'west'; u: number; width: number; y0: number; y1: number }[]; + /** Board ids to hide on the wall (e.g. replaced by a custom board). */ + hiddenBoards?: string[]; + /** Position override for machine monitor screen. */ + machineMonitor?: { x: number; y: number; z: number }; + /** Bounding boxes of interior sheltered/indoor areas on this map: [minX, maxX, minZ, maxZ]. */ + indoorAreas?: Bounds[]; + /** Whether the building's ladder (on the west wall) is enabled. Defaults to true. */ + ladder?: boolean; + /** Whether the building's fire poles are enabled. Defaults to true. */ + poles?: boolean; /** A dungeon under the hall, with cells to lock workers up in (a castle's). */ dungeon?: DungeonConfig | null; /** An airlock in a wall, to blow workers out of (a station's). */ diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index ee0d258ef..b10030263 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -5,6 +5,7 @@ // unions every frame is one of. import type { AccountsClientMsg, AccountsServerMsg, SignInsClientMsg, TeamClientMsg } from './protocol/accounts.js'; +import type { BoardsClientMsg, BoardsServerMsg } from './protocol/boards.js'; import type { ChangesClientMsg, ChangesServerMsg } from './protocol/changes.js'; import type { FloorClientMsg, FloorServerMsg, PlanClientMsg } from './protocol/floors.js'; import type { GitHubClientMsg, GitHubServerMsg } from './protocol/github.js'; @@ -19,10 +20,12 @@ import type { WorkerClientMsg, WorkerServerMsg } from './protocol/workers.js'; export * from './protocol/accounts.js'; export * from './protocol/agents.js'; +export * from './protocol/boards.js'; export * from './protocol/changes.js'; export * from './protocol/floors.js'; export * from './protocol/github.js'; export * from './protocol/meetings.js'; +export * from './protocol/plugins.js'; export * from './protocol/presence.js'; export * from './protocol/queue.js'; export * from './protocol/rooftop.js'; @@ -36,6 +39,7 @@ export type ClientMsg = | RooftopClientMsg | WorkerClientMsg | GitHubClientMsg + | BoardsClientMsg | QueueClientMsg | MeetingClientMsg | FloorClientMsg @@ -59,6 +63,7 @@ export type ServerMsg = | RooftopServerMsg | WorkerServerMsg | GitHubServerMsg + | BoardsServerMsg | QueueServerMsg | MeetingServerMsg | FloorServerMsg diff --git a/src/shared/protocol/agents.ts b/src/shared/protocol/agents.ts index c25031e3f..643585be8 100644 --- a/src/shared/protocol/agents.ts +++ b/src/shared/protocol/agents.ts @@ -3,7 +3,7 @@ import type { AgentEffort, AgentProvider } from '../providers.js'; -export { AGENT_EFFORTS, CLAUDE_MODELS, isAgentEffort, isAgentProvider, isClaudeModel, type AgentEffort, type AgentProvider, type ClaudeModel } from '../providers.js'; +export { AGENT_EFFORTS, CLAUDE_MODELS, isAgentEffort, isAgentProvider, isClaudeModel, isKnownProvider, type AgentEffort, type AgentProvider, type ClaudeModel } from '../providers.js'; /** Which agent a worker runs: its provider, and optionally the model and the reasoning effort (see PROVIDER_META for which each takes). */ export interface AgentChoice { diff --git a/src/shared/protocol/boards.ts b/src/shared/protocol/boards.ts new file mode 100644 index 000000000..f9d583855 --- /dev/null +++ b/src/shared/protocol/boards.ts @@ -0,0 +1,67 @@ +// Custom boards provided by plugins (e.g. task boards, issue trackers). + +export interface BoardParent { + id: string; + key?: string; + title?: string; + url?: string; +} + +export interface BoardCard { + id: string; + number?: number; + title: string; + body?: string; + column?: string; + state?: 'OPEN' | 'CLOSED'; + author?: string; + authorAvatarUrl?: string; + assignees?: string[]; + assigneeAvatarUrl?: string; + parent?: BoardParent; + storyPoints?: number; + url?: string; + labels?: Array<{ name: string; color?: string }>; + comments?: number; + priority?: string; + type?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface BoardCardDetail extends BoardCard { + commentsList?: Array<{ author: string; avatarUrl?: string; body: string; createdAt: string }>; +} + +export interface BoardFieldConfig { + key: string; + label: string; + type?: 'text' | 'password' | 'url'; + placeholder?: string; + required?: boolean; +} + +export interface CustomBoardDef { + id: string; + label: string; + icon: string; + description?: string; + columns?: string[]; + configFields?: BoardFieldConfig[]; +} + +export interface CustomBoardState { + def: CustomBoardDef; + cards: BoardCard[]; + columns?: string[]; + currentUser?: { displayName?: string; email?: string; name?: string }; + fetchedAt?: number; + loading?: boolean; + error?: string; +} + +export type BoardsClientMsg = + | { t: 'board.refresh'; boardId: string }; + +export type BoardsServerMsg = + | { t: 'board.state'; boardId: string; state: CustomBoardState }; diff --git a/src/shared/protocol/floors.ts b/src/shared/protocol/floors.ts index ce908eaa2..f0b6494d5 100644 --- a/src/shared/protocol/floors.ts +++ b/src/shared/protocol/floors.ts @@ -9,6 +9,7 @@ import type { BallState } from '../hoop.js'; import type { JukeboxState } from '../jukebox.js'; import type { WhiteboardView } from '../whiteboard.js'; import type { AgentProvider } from './agents.js'; +import type { CustomBoardState } from './boards.js'; import type { GhIssue, GhPull, GhState } from './github.js'; import type { MeetingState } from './meetings.js'; import type { PeerInfo } from './presence.js'; @@ -18,11 +19,15 @@ import type { JailState, WorkerInfo } from './workers.js'; export interface ProjectInfo { name: string; + /** Optional external identifier (e.g. from an external harness plugin). */ + externalId?: string; + url?: string; dir: string; branch?: string; remote?: string; agentCmd: string; defaultProvider: AgentProvider; + /** Providers this project can hire: the office's built-ins, plus any a plugin has registered. */ agentProviders: AgentProvider[]; } @@ -50,6 +55,8 @@ export interface FloorInfo { local?: boolean; addedBy: string; addedAt: number; + externalId?: string; + url?: string; /** * For the elevator panel: who's there and what they're up to. `workers` counts the ones hired onto * desks, bean bags and the meeting room's table, not the board agents at their kiosks. @@ -81,6 +88,8 @@ export interface ProjectsDirState { custom: boolean; by?: string; at?: number; + /** Configured max floors limit (default: 500). */ + maxFloors?: number; } /** A repository the office's `gh` login can clone, for the elevator's "add a project". */ @@ -124,6 +133,8 @@ export interface FloorView { cars: CarState[]; /** Workers sent home and locked up in the dungeon, on a map that has one. */ jail: JailState; + /** Custom boards provided by plugins (e.g. Jira, task boards). */ + boards?: Record; } export type FloorClientMsg = @@ -142,7 +153,9 @@ export type FloorClientMsg = /** Take a floor off the building (admins only). Its checkout stays on disk; everyone on it rides to another floor. */ | { t: 'floor.remove'; floor: string } /** Where new floors are cloned from now on (admins only); '' goes back to the default. */ - | { t: 'floor.projectsDir'; dir: string }; + | { t: 'floor.projectsDir'; dir: string } + /** Configure max floors / projects limit (admins only). */ + | { t: 'floor.maxFloors'; max: number }; export type PlanClientMsg = /** Hang a sign over a desk on your floor (a SIGN_COLORS color), or take it down with no text. */ diff --git a/src/shared/protocol/github.ts b/src/shared/protocol/github.ts index 8d083e795..b2362f9b9 100644 --- a/src/shared/protocol/github.ts +++ b/src/shared/protocol/github.ts @@ -90,6 +90,7 @@ export interface GhReviewComment { line: number | null; /** LEFT is the old file's line numbers, RIGHT the new file's. */ side: 'LEFT' | 'RIGHT'; + diffHunk?: string; } export interface GhCheck { diff --git a/src/shared/protocol/plugins.ts b/src/shared/protocol/plugins.ts new file mode 100644 index 000000000..ccc8d8562 --- /dev/null +++ b/src/shared/protocol/plugins.ts @@ -0,0 +1,34 @@ +// Manifest and configuration for Agent Office plugins. + +import type { ProviderMeta } from '../providers.js'; + +export type PluginCapability = 'floors' | 'queue' | 'services' | 'agent' | 'issues' | 'pulls' | 'boards'; + +export interface PluginManifest { + id: string; + name: string; + author?: string; + description: string; + capabilities: PluginCapability[]; + githubUrl?: string; + agentProviderMeta?: ProviderMeta; + /** + * A file of the plugin's own (relative to its directory) holding its settings page, shown in + * ⚙️ Settings → Plugins. The office serves the file and knows nothing about what is in it, so + * every plugin can bring its own settings UI. + */ + settingsPage?: string; + installed?: boolean; + status?: 'active' | 'inactive' | 'error'; + error?: string; + version?: string; + latestVersion?: string; + updateAvailable?: boolean; + local?: boolean; +} + +export interface PluginsState { + plugins: PluginManifest[]; + store?: PluginManifest[]; + pluginsDir?: string; +} diff --git a/src/shared/protocol/presence.ts b/src/shared/protocol/presence.ts index 8bc66a2f1..00c5c524b 100644 --- a/src/shared/protocol/presence.ts +++ b/src/shared/protocol/presence.ts @@ -6,6 +6,7 @@ import type { EmoteId } from '../emotes.js'; import type { DrinkId } from '../rooftop.js'; import type { Me } from './accounts.js'; import type { FloorInfo, FloorView, ProjectsDirState } from './floors.js'; +import type { PluginsState } from './plugins.js'; import type { LeaveOnMergeState, MachineState, MapState, NotifyState, PromptsState, SkyState, ThemeState, UpgradeState } from './settings.js'; import type { PlanLimits, UsageState } from './usage.js'; @@ -135,6 +136,7 @@ export type PresenceServerMsg = /** The office's prompts and the worker everyone starts on. */ prompts: PromptsState; leaveOnMerge: LeaveOnMergeState; + plugins?: PluginsState; } & FloorView) | { t: 'peer.join'; peer: PeerInfo } | { t: 'peer.update'; peer: PeerInfo } diff --git a/src/shared/protocol/queue.ts b/src/shared/protocol/queue.ts index 2a54530f9..d09d2d0c8 100644 --- a/src/shared/protocol/queue.ts +++ b/src/shared/protocol/queue.ts @@ -8,6 +8,8 @@ export type TaskStatus = 'queued' | 'running' | 'done'; export interface QueueTask { id: string; provider?: AgentProvider; + /** Optional external task / session identifier. */ + externalId?: string; /** Model requested for this task, instead of the office's configured default: an id its provider takes (see WorkerInfo.model). */ model?: string; /** Reasoning effort requested for this task, when one was chosen (Claude only). */ diff --git a/src/shared/protocol/settings.ts b/src/shared/protocol/settings.ts index 5ad8a4438..8272076cf 100644 --- a/src/shared/protocol/settings.ts +++ b/src/shared/protocol/settings.ts @@ -3,6 +3,7 @@ import type { CustomMap } from '../maps/index.js'; import type { PromptId } from '../prompts.js'; import type { AgentChoice } from './agents.js'; +import type { PluginsState } from './plugins.js'; /** * The prompts the office writes for workers by itself (shared/prompts.ts) and the worker everyone @@ -185,12 +186,28 @@ export type SettingsClientMsg = | { t: 'sky.clock'; real: boolean } /** Change the building's map (see MapState), or with no map, read the custom maps' folder again. */ | { t: 'map.set'; map?: string } + /** Open custom maps directory in system file explorer */ + | { t: 'map.openFolder' } /** Workers whose pull request merged go home by themselves (true), or wait to be sent home. */ | { t: 'leaveOnMerge.set'; on: boolean } /** Rewrite one of the office's prompts (admins only); null puts the default back. */ | { t: 'prompts.set'; id: PromptId; text: string | null } /** Pick the worker everyone starts on (admins only); null goes back to the office's --agent. */ - | { t: 'prompts.agent'; choice: AgentChoice | null }; + | { t: 'prompts.agent'; choice: AgentChoice | null } + /** Request installed plugins list */ + | { t: 'plugins.list' } + /** Install or enable a plugin by ID */ + | { t: 'plugin.install'; id: string } + /** Uninstall or disable a plugin by ID */ + | { t: 'plugin.uninstall'; id: string } + /** Open plugins directory in system file explorer */ + | { t: 'plugin.openFolder'; id?: string } + /** Install a plugin from a Git URL */ + | { t: 'plugin.installFromGit'; url: string } + /** Update a plugin to the latest release */ + | { t: 'plugin.update'; id: string } + /** Check for plugin updates */ + | { t: 'plugin.checkUpdates' }; export type SettingsServerMsg = | { t: 'upgrade'; state: UpgradeState } @@ -201,4 +218,5 @@ export type SettingsServerMsg = | { t: 'theme'; state: ThemeState } | { t: 'map'; state: MapState } | { t: 'prompts'; state: PromptsState } - | { t: 'leaveOnMerge'; state: LeaveOnMergeState }; + | { t: 'leaveOnMerge'; state: LeaveOnMergeState } + | { t: 'plugins'; state: PluginsState }; diff --git a/src/shared/protocol/workers.ts b/src/shared/protocol/workers.ts index 1f83346fb..f82981a42 100644 --- a/src/shared/protocol/workers.ts +++ b/src/shared/protocol/workers.ts @@ -73,6 +73,12 @@ export interface WorkerInfo { prOpening?: boolean; title?: string; sessionId?: string; + /** + * Where its session can be followed in a browser, when the provider running it has one: its own + * web UI, say. A worker's terminal then offers it as a pinned tab, and the office knows nothing + * about what is at the address. + */ + sessionUrl?: string; exitCode?: number; cols: number; rows: number; diff --git a/src/shared/providers.ts b/src/shared/providers.ts index 720dd8725..30b5f9e56 100644 --- a/src/shared/providers.ts +++ b/src/shared/providers.ts @@ -6,12 +6,21 @@ export const AGENT_PROVIDERS = ['claude', 'opencode', 'codex', 'grok', 'muse', 'dsh', 'pi', 'cursor', 'custom'] as const; -export type AgentProvider = (typeof AGENT_PROVIDERS)[number]; +export type AgentProvider = (typeof AGENT_PROVIDERS)[number] | (string & {}); export function isAgentProvider(value: unknown): value is AgentProvider { return (AGENT_PROVIDERS as readonly unknown[]).includes(value); } +/** + * Whether `value` names a provider the office can run: one of the built-in ones, or one a plugin has + * registered (see registerAgentProvider). The wire and saved state can hold anything, so anything + * that isn't one of these is a provider the office no longer knows. + */ +export function isKnownProvider(value: unknown): value is AgentProvider { + return typeof value === 'string' && (isAgentProvider(value) || pluginProviders.includes(value)); +} + /** A Claude model alias the hire dialog and queue can request explicitly (see server/agents.ts). */ export type ClaudeModel = 'fable' | 'opus' | 'sonnet' | 'haiku'; export const CLAUDE_MODELS: readonly ClaudeModel[] = ['fable', 'opus', 'sonnet', 'haiku']; @@ -156,6 +165,10 @@ export interface ProviderMeta { name: string; /** The executable it runs as: an office started with `--agent ` runs it (see server/agents.ts). None for a custom --agent. */ bin?: string; + /** Alternate executable names or script basenames that also identify this provider. */ + bins?: readonly string[]; + /** Custom matcher for an arbitrary `--agent` command path or invocation. */ + matchesCommand?: (command: string) => boolean; /** Takes a model picked for each worker: which ids (they end up on its command line, so they're checked). */ validModel?: (value: unknown) => value is string; /** What asking for a model `validModel` turns down says. */ @@ -183,7 +196,7 @@ export interface ProviderMeta { }; } -export const PROVIDER_META: Record = { +export const PROVIDER_META: Record = { claude: { label: 'Claude Code', name: 'Claude Code', @@ -330,7 +343,7 @@ export const PROVIDER_META: Record = { /** What the table says about `provider`, when it's one (saved state and the wire can hold anything). */ export function providerMeta(provider: unknown): ProviderMeta | undefined { - return isAgentProvider(provider) ? PROVIDER_META[provider] : undefined; + return typeof provider === 'string' ? PROVIDER_META[provider] : undefined; } /** Whether a model can be picked for a worker on `provider`. */ @@ -358,3 +371,26 @@ export function providerNames(has: (meta: ProviderMeta) => boolean): string { const names = AGENT_PROVIDERS.map((p) => PROVIDER_META[p]).filter(has).map((m) => m.name); return names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names[names.length - 1]}` : names.join(''); } + +const pluginProviders: string[] = []; + +export function registerAgentProvider(id: string, meta: ProviderMeta): void { + if (PROVIDER_META[id]) return; + PROVIDER_META[id] = meta; + pluginProviders.push(id); +} + +export function unregisterAgentProvider(id: string): void { + if (!pluginProviders.includes(id)) return; + delete PROVIDER_META[id]; + const idx = pluginProviders.indexOf(id); + if (idx >= 0) pluginProviders.splice(idx, 1); +} + +export function getPluginProviders(): string[] { + return [...pluginProviders]; +} + +export function getAllProviders(): string[] { + return [...AGENT_PROVIDERS, ...pluginProviders]; +} diff --git a/tests/agents.test.ts b/tests/agents.test.ts index 1223f797d..1ab925efd 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -91,7 +91,7 @@ test('model validation follows the provider', () => { assert.equal(validateWorkerModel('agent', 'codex', 'gpt-oss:20b'), undefined); assert.match(validateWorkerModel('agent', 'codex', '--yolo') ?? '', /Invalid Codex model/); assert.match(validateWorkerModel('agent', 'codex', 'gpt 5.5') ?? '', /Invalid Codex model/); - // Custom still takes none. + // Custom takes none in the office UI (managed in its own configuration). assert.match(validateWorkerModel('agent', 'custom', 'anything') ?? '', /Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi or Cursor/); }); @@ -111,7 +111,7 @@ test('every provider the office knows the CLI of takes a model and (but for Curs for (const provider of AGENT_PROVIDERS) { const meta = PROVIDER_META[provider]; if (provider === 'custom') { - // A custom --agent is run as it is: the hire dialog says where its model comes from instead. + // A custom --agent runs as it is: its settings are configured outside the office. assert.equal(meta.validModel, undefined); assert.equal(meta.takesEffort, undefined); assert.ok(meta.unpicked); @@ -156,3 +156,22 @@ test('Claude models go by the name of the model, not only its family', () => { assert.equal(claudeModelName('gpt-5.5'), undefined); assert.equal(claudeModelName(''), undefined); }); + +test('a plugin-registered agent provider becomes hirable', async () => { + const { registerAgentProvider, unregisterAgentProvider, getPluginProviders, getAllProviders, providerMeta } = await import('../src/shared/providers.js'); + // Register a provider the way a plugin does. + registerAgentProvider('openfox', { label: 'OpenFox', name: 'OpenFox', bin: 'openfox', usage: { reports: true, note: 'OpenFox usage is tracked by the OpenFox server.' } }); + try { + assert.ok(getPluginProviders().includes('openfox')); + assert.ok(getAllProviders().includes('openfox')); + assert.equal(providerMeta('openfox')?.label, 'OpenFox'); + // The office's hirable provider list now includes it, whatever the configured agent is. + assert.ok(agentProviders('claude').includes('openfox')); + assert.ok(agentProviders('custom').includes('openfox')); + // And the built-ins are all still there. + for (const p of AGENT_PROVIDERS.filter((x) => x !== 'custom')) assert.ok(agentProviders('claude').includes(p)); + } finally { + unregisterAgentProvider('openfox'); + } + assert.ok(!getPluginProviders().includes('openfox'), 'unregistered again'); +}); diff --git a/tests/building.test.ts b/tests/building.test.ts index c7a0fc97e..b6825073f 100644 --- a/tests/building.test.ts +++ b/tests/building.test.ts @@ -83,3 +83,19 @@ test('the floor the office was started in comes off too, stays off after a resta assert.equal(third.ensureLocal(defs[0].dir, 'the office')?.id, 'api'); assert.deepEqual(third.list().map((d) => d.id), ['web', 'docs', 'api']); }); + +test('Building.unremove allows re-adding a floor when setList is called by a provider', (t) => { + const { root, dataDir, defs } = office(t); + const building = new Building(dataDir, root); + building.remove('web'); + assert.deepEqual(building.list().map((d) => d.id), ['api', 'docs']); + + // Without unremove, setList filters out 'web' + building.setList(defs); + assert.deepEqual(building.list().map((d) => d.id), ['api', 'docs']); + + // With unremove, setList permits 'web' to be added back + building.unremove(defs[1]); + building.setList(defs); + assert.deepEqual(building.list().map((d) => d.id), ['api', 'web', 'docs']); +}); diff --git a/tests/client-store.test.ts b/tests/client-store.test.ts index adf83062c..5a27c6f90 100644 --- a/tests/client-store.test.ts +++ b/tests/client-store.test.ts @@ -225,7 +225,7 @@ test('what the browser remembers keeps its keys and shapes', () => { test("the store's keys are its state, as window.__office shows them", () => { // As the office had them before its store was split into slices: methods and the slices aren't among them. - assert.deepEqual(Object.keys(store).sort(), ['accounts', 'ball', 'cabinet', 'cabinetFrame', 'cars', 'carsAt', 'chat', 'clock', 'decor', 'dog', 'dogStart', 'drawing', 'floor', 'floorPlan', 'floors', 'ice', 'invites', 'issues', 'jail', 'jukebox', 'leaveOnMerge', 'limits', 'machine', 'map', 'me', 'meeting', 'notify', 'peers', 'profile', 'project', 'projectsDir', 'prompts', 'pulls', 'queue', 'repos', 'screens', 'services', 'signins', 'sky', 'subs', 'team', 'theme', 'upgrade', 'usage', 'whiteboard', 'workers', 'you']); + assert.deepEqual(Object.keys(store).sort(), ['accounts', 'ball', 'cabinet', 'cabinetFrame', 'cars', 'carsAt', 'chat', 'clock', 'customBoards', 'decor', 'dog', 'dogStart', 'drawing', 'floor', 'floorPlan', 'floors', 'ice', 'invites', 'issues', 'jail', 'jukebox', 'leaveOnMerge', 'limits', 'machine', 'map', 'me', 'meeting', 'notify', 'peers', 'plugins', 'profile', 'project', 'projectsDir', 'prompts', 'pulls', 'queue', 'repos', 'screens', 'services', 'signins', 'sky', 'subs', 'team', 'theme', 'upgrade', 'usage', 'whiteboard', 'workers', 'you']); }); test('a new store starts every field where it always has', async () => { @@ -249,7 +249,7 @@ test('a new store starts every field where it always has', async () => { dog: null, dogStart: 0, jukebox: { on: false, track: JUKEBOX_TUNES[0].id, startedAt: 0, elapsed: 0, since: 0 }, clock: '', whiteboard: [], drawing: [], cabinet: { player: null, scores: [] }, cabinetFrame: null, ball: {}, cars: parked(), carsAt: [], jail: { prisoners: [], bones: 0 }, - team: null, accounts: null, signins: null, + team: null, accounts: null, signins: null, plugins: { plugins: [] }, customBoards: [], }, ); }); diff --git a/tests/custom.test.ts b/tests/custom.test.ts new file mode 100644 index 000000000..fd50267c5 --- /dev/null +++ b/tests/custom.test.ts @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { custom } from '../src/server/providers/custom.js'; + +test('a custom worker is given its task, and it is never read as an option', () => { + const launch = (prompt?: string, resumeSessionId?: string) => + custom.launch({ + h: { info: {} } as never, + args: [], + prompt, + resumeSessionId, + cwd: '/project', + setup: undefined, + }).args; + + // Without this the task was silently dropped: every provider but `custom` + // puts its own CLI's prompt flag in launch, and a custom one ran untouched. + assert.deepEqual(launch('fix the login redirect'), ['--', 'fix the login redirect']); + assert.deepEqual(launch('- fix login'), ['--', '- fix login']); + + // A worker hired with no task waits at its prompt for someone to type one. + assert.deepEqual(launch(), []); + + // A custom agent that knows sessions resumes them rather than starting over. + assert.deepEqual(launch('carry on', 'session-1'), ['--resume', 'session-1', '--', 'carry on']); +}); + +test('a custom worker reports on the office as Claude Code does', () => { + assert.equal(custom.hooksAs, 'claude'); +}); \ No newline at end of file diff --git a/tests/floor-merge.test.ts b/tests/floor-merge.test.ts new file mode 100644 index 000000000..1f3f6352e --- /dev/null +++ b/tests/floor-merge.test.ts @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { Building } from '../src/server/building.js'; +import type { FloorDef } from '../src/server/building.js'; + +/** + * A plugin that provides its own floors (an OpenFox project per floor, say) adds to the building: + * the floors the office already has, and the ones people added themselves, stay where they are. + * Merging is what the plugin's list means: "these are mine", not "these are all there are". + */ + +function def(id: string, dir: string, extra: Partial = {}): FloorDef { + return { id, name: id, repo: id, dir, palette: 0, addedBy: 'plugin', addedAt: 1, ...extra }; +} + +test("a plugin's floors are merged into the building, not swapped for it", () => { + const dataDir = mkdtempSync(path.join(tmpdir(), 'floors-merge-')); + try { + const building = new Building(dataDir, '/'); + // The office's own floors: one a project folder on disk, one a clone of a repository. + building.addExisting('/disk/web', 'web', 'the office'); + building.addExisting('/disk/api', 'api', 'the office'); + const before = building.list().map((d) => d.id); + assert.equal(before.length, 2); + + // The plugin's list, as it comes back from listFloors(): its own projects only. + building.mergeList([def('openfox-a', '/disk/web'), def('openfox-b', '/home/me/b')]); + const after = building.list().map((d) => d.id); + // Both of the office's own floors are still there... + assert.ok(after.includes(building.list().find((d) => d.dir === '/disk/web')!.id), 'the project folder floor stays'); + assert.ok(after.includes(building.list().find((d) => d.dir === '/disk/api')!.id), 'the repository floor stays'); + // ...and the plugin's new one arrived. + assert.ok(after.includes('openfox-b'), "the plugin's own floor arrives"); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("a floor the office already has, under another name, is not duplicated", () => { + const dataDir = mkdtempSync(path.join(tmpdir(), 'floors-dup-')); + try { + const building = new Building(dataDir, '/'); + const added = building.addExisting('/disk/web', 'web', 'the office'); + assert.equal(typeof added, 'object'); + const own = (added as FloorDef).id; + building.mergeList([def('openfox-web', '/disk/web')]); + // Same checkout as a floor the building already has: it is not added a second time. + assert.equal(building.list().filter((d) => path.resolve(d.dir) === path.resolve('/disk/web')).length, 1); + assert.equal(building.list()[0]!.id, own, 'the floor it already had keeps its identity'); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("a plugin's floor that was there and is gone from its list is taken off", () => { + const dataDir = mkdtempSync(path.join(tmpdir(), 'floors-gone-')); + try { + const building = new Building(dataDir, '/'); + building.mergeList([def('openfox-a', '/p/a'), def('openfox-b', '/p/b')]); + assert.equal(building.list().length, 2); + building.mergeList([def('openfox-a', '/p/a')]); + assert.deepEqual(building.list().map((d) => d.id), ['openfox-a'], 'the one it no longer lists goes'); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("a floor the office added itself is never taken off by a plugin's list", () => { + const dataDir = mkdtempSync(path.join(tmpdir(), 'floors-theirs-')); + try { + const building = new Building(dataDir, '/'); + building.addExisting('/disk/api', 'api', 'the office'); + // The plugin lists several of its own; the office's floor is in none of them. + building.mergeList([def('openfox-a', '/p/a')]); + const stillThere = building.list().some((d) => path.resolve(d.dir) === path.resolve('/disk/api')); + assert.ok(stillThere, 'a floor nobody but the office added stays'); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +}); diff --git a/tests/github.test.ts b/tests/github.test.ts index 6f13019ba..57b6f31c4 100644 --- a/tests/github.test.ts +++ b/tests/github.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { Claims, GitHub, MergeWatch, friendlyGhError } from '../src/server/github.js'; +import { Claims, GitHub, MergeWatch, adapterBoard, friendlyGhError } from '../src/server/github.js'; import type { GhIssue, GhPull, GhState } from '../src/shared/protocol.js'; const pull = (number: number, state: string): GhPull => ({ @@ -73,6 +73,15 @@ test('handed over twice, the first answer failing leaves the second one standing assert.deepEqual(taken(c.mark([issue(7, ['octocat'])], 3500)), []); }); +test("a plugin's board rows are used only when it has any", () => { + // A plugin that lists something knows the issues the office's `gh` login cannot see: its list is the board. + assert.deepEqual(adapterBoard([issue(1), issue(2)]), [issue(1), issue(2)]); + // One that has nothing leaves GitHub to answer, so a project it doesn't cover still shows its issues. + assert.equal(adapterBoard([]), null); + assert.equal(adapterBoard(null), null); + assert.equal(adapterBoard(undefined), null); +}); + test('gh JSON field errors keep the field that failed', () => { assert.equal( friendlyGhError('Unknown JSON field: "headRefOid"\nAvailable fields:\n title\n updatedAt\n url'), diff --git a/tests/maps.test.ts b/tests/maps.test.ts index a1aa0d5c5..e8b03c9ce 100644 --- a/tests/maps.test.ts +++ b/tests/maps.test.ts @@ -1,7 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { Maps } from '../src/server/maps.js'; import { CASTLE } from '../src/shared/maps/castle.js'; import { STATION } from '../src/shared/maps/station.js'; import { DESK_BY_ID } from '../src/shared/layout.js'; @@ -196,3 +198,34 @@ test('every map in docs/maps.md loads', () => { const checked = checkCustomMaps(maps.map((json) => ({ file: `${json.id}.json`, json }))); for (const m of checked) assert.equal(m.error, undefined, `${m.file}: ${m.error}`); }); + +test('docs/maps/office.json loads and plans cleanly with declarative props', () => { + const json = JSON.parse(readFileSync(path.join(import.meta.dirname, '..', 'docs', 'maps', 'office.json'), 'utf8')); + const [checked] = checkCustomMaps([{ file: 'office.json', json }]); + assert.equal(checked.error, undefined); + const plan = planOf('office-meeting-rooms', [checked]); + assert.equal(plan.name, 'Office (10 Meeting Rooms)'); + assert.equal(plan.style, 'office'); + assert.ok(plan.desks.length >= 20); + assert.deepEqual(plan.config?.hiddenBoards, ['issues']); + const jiraProp = plan.config?.props?.find((p) => p.kind === 'custom_board' && p.boardId === 'jira'); + assert.ok(jiraProp, 'jira custom_board prop must exist'); + assert.equal(jiraProp.x, -11.7); + assert.equal(jiraProp.y, 2.1); + assert.equal(jiraProp.z, -12.82); + assert.equal(jiraProp.width, 6); + assert.equal(jiraProp.height, 3); +}); + +test('Maps.openFolder creates maps directory if it does not exist', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'maps-test-')); + try { + const maps = new Maps(dir); + assert.equal(maps.folder, path.join(dir, 'maps')); + assert.equal(existsSync(maps.folder), false); + maps.openFolder(); + assert.equal(existsSync(maps.folder), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/meetings.test.ts b/tests/meetings.test.ts index 77270a909..95a93aca2 100644 --- a/tests/meetings.test.ts +++ b/tests/meetings.test.ts @@ -338,3 +338,18 @@ test('a pattern with a set number of rounds says so, and names them; a range is assert.equal(f.start({ pattern: 'lead', rounds: 5 }), undefined); assert.equal(f.room.state().current!.rounds, 3); }); + +test('a meeting seats workers with a plugin-registered provider', async (t) => { + const { registerAgentProvider, unregisterAgentProvider } = await import('../src/shared/providers.js'); + registerAgentProvider('openfox', { label: 'OpenFox', name: 'OpenFox', bin: 'openfox', usage: { reports: true, note: 'tracked by OpenFox' } }); + const f = fixture(); + t.after(() => { + unregisterAgentProvider('openfox'); + f.close(); + }); + assert.equal(f.start({ provider: 'openfox' as never }), undefined); + assert.equal(f.room.state().current?.provider, 'openfox'); + assert.ok(f.workers.length > 0); + assert.equal(f.workers[0].provider, 'openfox'); +}); + diff --git a/tests/office-plugins-e2e.test.ts b/tests/office-plugins-e2e.test.ts new file mode 100644 index 000000000..0e779f579 --- /dev/null +++ b/tests/office-plugins-e2e.test.ts @@ -0,0 +1,149 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import net from 'node:net'; +import net from 'node:net'; +import { loadConfig } from '../src/server/config.js'; +import { startServer } from '../src/server/server.js'; +import { PROVIDER_META } from '../src/shared/providers.js'; + +const PLUGIN = path.resolve('../agent-office-plugins/agentoffice-openfox/dist/index.js'); +const GIT_PROJECTS = path.resolve('../agent-office-plugins/agentoffice-git-projects/dist/index.js'); + +/** Signs a browser in over HTTP, and gives back the cookie it keeps. */ +async function signIn(port: number, password = 'e2e-pw'): Promise { + const res = await fetch(`http://127.0.0.1:${port}/api/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password }), + }); + const cookie = (res.headers.get('set-cookie') ?? '').split(';')[0]; + assert.ok(cookie.includes('='), `no cookie: ${res.status}`); + return cookie; +} + +/** A port nothing is on yet. */ +const freePort = () => + new Promise((res) => { + const s = net.createServer(); + s.listen(0, '127.0.0.1', () => { + const picked = (s.address() as net.AddressInfo).port; + s.close(() => res(picked)); + }); + }); + +/** + * The office boots the way cli.ts does, with the OpenFox plugin loaded, and the wiring the plugin + * declares reaches the office: its provider is one the office can hire, its services are on the + * floor's board, and its floors sit in the building next to the office's own. + * + * Uses the loaded plugin where the office has one on disk, and skips what needs a live OpenFox, so + * the office's own floors and adapters are what this checks. + */ +test('the openfox plugin reaches the office: provider, services, floors', async (t) => { + if (!existsSync(PLUGIN)) return t.skip('the openfox plugin is not built'); + const tmp = mkdtempSync(path.join(tmpdir(), 'office-plugin-e2e-')); + try { + const project = path.join(tmp, 'project'); + mkdirSync(project, { recursive: true }); + writeFileSync(path.join(project, 'README.md'), 'demo\n', 'utf8'); + execFileSync('git', ['init', '-q', project], { stdio: 'ignore' }); + execFileSync('git', ['-C', project, 'add', '.'], { stdio: 'ignore' }); + execFileSync('git', ['-C', project, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init'], { stdio: 'ignore' }); + + // The plugin reaches OpenFox for the floors and services it provides; point it at the one here. + const openfox = process.env['OPENFOX_PORT'] ?? '10369'; + const prev = process.env['OPENFOX_PORT']; + process.env['OPENFOX_PORT'] = openfox; + const port = await freePort(); + const cfg = loadConfig([ + project, + '--no-open', + '--password', 'e2e-pw', + '--port', String(port), + '--projects', tmp, + '--plugin', PLUGIN, + ]); + const office = await startServer(cfg); + try { + // Open the plugin's own floors (an OpenFox project per floor) and the office's own, then ask + // for one of them: what a browser is sent on arriving there is what this checks. + // The plugin's floors are merged in on the openFloors pass (see ceiling), the first of which + // has run by the time the office answers. + const floor = office.floors().find((f) => f.def.addedBy === 'openfox') ?? office.floors()[0]!; + // 10: the provider the plugin registers becomes one the office can hire. + assert.equal(PROVIDER_META['openfox']?.label, 'OpenFox', `the plugin's provider is registered: ${Object.keys(PROVIDER_META).join(',')}`); + + // The project a browser sees on the plugin's floor: the providers it can hire there. + const providers = floor.project.agentProviders; + assert.ok(providers.includes('openfox'), `openfox is hireable on the plugin's floor: ${JSON.stringify(providers)}`); + // 5: the plugin's floors are in the building, next to the office's own, not instead of them. + const dirs = office.floors().map((f) => f.def.dir); + assert.ok(dirs.includes(path.resolve(project)), `the office's own floor is still there: ${JSON.stringify(dirs)}`); + const fromPlugin = office.floors().filter((f) => f.def.addedBy === 'openfox'); + assert.ok(fromPlugin.length > 0, 'the plugin brought its own floors'); + console.log('E2E plugin floors:', fromPlugin.length, '| office floors:', office.floors().length, '| providers:', JSON.stringify(providers)); + } finally { + office.shutdown(); + if (prev === undefined) delete process.env['OPENFOX_PORT']; + else process.env['OPENFOX_PORT'] = prev; + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +/** + * A plugin with its own settings page and a projects provider (the GitHub & GitLab projects plugin): + * the office serves the page from the plugin's own directory, and the page's reads and writes reach + * the plugin wherever it keeps them. The office knows nothing about what any of it is. + */ +test('a plugin reaches its own settings page and projects through the office', async (t) => { + if (!existsSync(GIT_PROJECTS)) return t.skip('the git-projects plugin is not built'); + const tmp = mkdtempSync(path.join(tmpdir(), 'office-gitprojects-e2e-')); + try { + const project = path.join(tmp, 'project'); + mkdirSync(project, { recursive: true }); + writeFileSync(path.join(project, 'README.md'), 'demo\n', 'utf8'); + execFileSync('git', ['init', '-q', project], { stdio: 'ignore' }); + execFileSync('git', ['-C', project, 'add', '.'], { stdio: 'ignore' }); + execFileSync('git', ['-C', project, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init'], { stdio: 'ignore' }); + // Its own projects, where the plugin keeps them: in the checkout's .agent-office folder. + mkdirSync(path.join(project, '.agent-office'), { recursive: true }); + writeFileSync(path.join(project, '.agent-office', 'git-projects.json'), JSON.stringify([{ id: 'front', name: 'front', url: 'https://github.com/owner/front' }]), 'utf8'); + + const port = await freePort(); + const cfg = loadConfig([project, '--no-open', '--password', 'e2e-pw', '--port', String(port), '--projects', tmp, '--plugin', GIT_PROJECTS]); + const office = await startServer(cfg); + try { + const cookie = await signIn(port); + const floor = office.floors()[0]!; + + // The floor's projects, as the plugin lists them, and a save the plugin takes. + const listed = await fetch(`http://127.0.0.1:${port}/api/plugins/git-projects/projects?floor=${encodeURIComponent(floor.id)}`, { headers: { cookie } }); + assert.equal(listed.status, 200, 'the projects are listed'); + const { projects } = (await listed.json()) as { projects: { url: string }[] }; + assert.deepEqual(projects.map((p) => p.url), ['https://github.com/owner/front'], 'what the plugin keeps is what the office hands back'); + + const saved = await fetch(`http://127.0.0.1:${port}/api/plugins/git-projects/projects?floor=${encodeURIComponent(floor.id)}`, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ projects: [{ id: 'front', name: 'front', url: 'https://github.com/owner/front' }, { id: 'bff', name: 'bff', url: 'https://github.com/owner/bff' }] }), + }); + assert.equal(saved.status, 200, 'the plugin took the projects'); + + // A floor nobody picked has no projects to list. + const none = await fetch(`http://127.0.0.1:${port}/api/plugins/git-projects/projects`, { headers: { cookie } }); + assert.equal(none.status, 404, 'no floor, no projects'); + console.log('E2E git-projects: settings page served, projects listed and saved'); + } finally { + office.shutdown(); + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/tests/plugin-settings.test.ts b/tests/plugin-settings.test.ts new file mode 100644 index 000000000..7a6eb48aa --- /dev/null +++ b/tests/plugin-settings.test.ts @@ -0,0 +1,142 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { PluginManager } from '../src/server/plugins/manager.js'; + +/** + * A plugin can bring its own settings page: any file it ships under its own directory, served by + * the office at /plugins//. The office knows nothing about what it contains, so any + * plugin can have its own settings UI, and one without is simply absent from ⚙️ Settings → Plugins. + */ +test('a plugin ships a settings page the office serves from its own directory', async () => { + const tmp = mkdtempSync(path.join(tmpdir(), 'plugin-settings-')); + const dir = path.join(tmp, 'plugins', 'demo'); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'settings.html'), '

demo settings

', 'utf8'); + writeFileSync( + path.join(dir, 'index.js'), + 'export function createPlugin(){return {manifest:{id:"demo",name:"Demo",description:"d",capabilities:[],enabled:true,settingsPage:"settings.html"}}};', + 'utf8', + ); + process.env['AGENT_OFFICE_PLUGIN'] = path.join(dir, 'index.js'); + try { + const mgr = new PluginManager(tmp); + await mgr.init({} as never); + // The manifest field travels through to the state the browsers get, under the plugin's own id. + const found = mgr.getState().plugins.find((p) => p.id === 'demo'); + assert.ok(found, `demo not in ${JSON.stringify(mgr.getState().plugins.map((p) => p.id))}`); + assert.equal(found.status, 'active'); + assert.equal(found.settingsPage, 'settings.html'); + // And the office can say where that file is, for serving it to the browser. + assert.equal(mgr.settingsPagePath('demo'), path.join(dir, 'settings.html')); + await mgr.shutdown(); + } finally { + delete process.env['AGENT_OFFICE_PLUGIN']; + } +}); + +test('a plugin with no settings page has no path, and the core says nothing about it', async () => { + const tmp = mkdtempSync(path.join(tmpdir(), 'plugin-nosettings-')); + process.env['AGENT_OFFICE_PLUGIN'] = path.join(tmp, 'nothing.js'); + try { + const mgr = new PluginManager(tmp); + await mgr.init({} as never); + assert.equal(mgr.settingsPagePath('demo'), undefined); + await mgr.shutdown(); + } finally { + delete process.env['AGENT_OFFICE_PLUGIN']; + } +}); + +test("a plugin's settings page sits at the top of its directory, beside its package.json", async () => { + // A plugin's files usually sit beside its built entry, but a build that cleans its output folder + // (tsup with clean: true) keeps only the bundle, so the page is often at the top of the directory. + const tmp = mkdtempSync(path.join(tmpdir(), 'plugin-settings-root-')); + const dir = path.join(tmp, '.agent-office', 'plugins', 'demo'); + mkdirSync(dir, { recursive: true }); + mkdirSync(path.join(dir, 'dist'), { recursive: true }); + writeFileSync(path.join(dir, 'settings.html'), '

at the top

', 'utf8'); + writeFileSync(path.join(dir, 'dist', 'index.js'), 'export function createPlugin(){return {manifest:{id:"demo",name:"Demo",description:"d",capabilities:[],enabled:true,settingsPage:"settings.html"}}};', 'utf8'); + writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'demo', main: 'dist/index.js' }), 'utf8'); + process.env['AGENT_OFFICE_PLUGIN'] = path.join(dir, 'dist', 'index.js'); + try { + const mgr = new PluginManager(tmp); + await mgr.init({} as never); + assert.equal(mgr.settingsPagePath('demo'), path.join(dir, 'settings.html'), 'found at the top of the directory'); + await mgr.shutdown(); + } finally { + delete process.env['AGENT_OFFICE_PLUGIN']; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('a settings page asking for a file outside its plugin is refused', () => { + const tmp = mkdtempSync(path.join(tmpdir(), 'plugin-settings-esc-')); + const dir = path.join(tmp, 'plugins', 'demo'); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'index.js'), 'export function createPlugin(){return {manifest:{id:"demo",name:"Demo",description:"d",capabilities:[],enabled:true}}}'); + // A file outside the plugin's directory, one level up. + writeFileSync(path.join(tmp, 'secret.txt'), 'not yours', 'utf8'); + try { + const mgr = new PluginManager(tmp); + assert.equal(mgr.settingsPagePathOf('demo', '../secret.txt'), undefined); + assert.equal(mgr.settingsPagePathOf('demo', '/etc/passwd'), undefined); + assert.equal(mgr.settingsPagePathOf('demo', ''), undefined); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('a plugin loaded from a file appears under its own id, once', async () => { + // The office files a plugin under the name of the file it was pointed at, but a plugin names itself + // in its manifest. Settings shows the name it gives itself, not the file's. + const tmp = mkdtempSync(path.join(tmpdir(), 'plugin-own-id-')); + const dir = path.join(tmp, 'plugins', 'git-projects'); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'settings.html'), '

mine

', 'utf8'); + writeFileSync(path.join(dir, 'index.js'), 'export function createPlugin(){return {manifest:{id:"git-projects",name:"GitHub & GitLab Projects",description:"d",capabilities:["issues","pulls"],enabled:true,settingsPage:"settings.html"}}};', 'utf8'); + process.env['AGENT_OFFICE_PLUGIN'] = path.join(dir, 'index.js'); + try { + const mgr = new PluginManager(tmp); + await mgr.init({} as never); + const state = mgr.getState(); + const ids = state.plugins.map((p) => p.id); + assert.ok(ids.includes('git-projects'), `its own id is there: ${JSON.stringify(ids)}`); + assert.equal(ids.filter((id) => id === 'git-projects').length, 1, 'exactly once'); + // The file name is not a card of its own: nothing is served for it either. + assert.equal(ids.includes('index'), false, `the file name is not a plugin of its own: ${JSON.stringify(ids)}`); + assert.equal(mgr.settingsPagePathOf('index', 'settings.html'), undefined); + // The card that is there has the plugin's own settings page, wherever that one is on disk. + const found = mgr.settingsPagePath('git-projects'); + assert.ok(found, 'the page is found'); + assert.match(found, /settings\.html$/); + // And its card carries the page, the way the settings pane reads it. + const card = state.plugins.find((p) => p.id === 'git-projects'); + assert.equal(card?.settingsPage, 'settings.html'); + await mgr.shutdown(); + } finally { + delete process.env['AGENT_OFFICE_PLUGIN']; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('a plugin whose file is named for itself keeps that card', async () => { + // Nothing to give way: the file name and the manifest id are the same one. + const tmp = mkdtempSync(path.join(tmpdir(), 'plugin-same-id-')); + const dir = path.join(tmp, 'plugins', 'demo'); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'demo.js'), 'export function createPlugin(){return {manifest:{id:"demo",name:"Demo",description:"d",capabilities:[],enabled:true}}};', 'utf8'); + process.env['AGENT_OFFICE_PLUGIN'] = path.join(dir, 'demo.js'); + try { + const mgr = new PluginManager(tmp); + await mgr.init({} as never); + const ids = mgr.getState().plugins.map((p) => p.id); + assert.deepEqual(ids, ['demo']); + await mgr.shutdown(); + } finally { + delete process.env['AGENT_OFFICE_PLUGIN']; + rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts new file mode 100644 index 000000000..e61ce26ee --- /dev/null +++ b/tests/plugins.test.ts @@ -0,0 +1,129 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { PluginManager } from '../src/server/plugins/manager.js'; +import { PluginRegistry } from '../src/server/plugins/registry.js'; + +test('PluginRegistry registers and lists plugins', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'registry-test-')); + try { + const reg = new PluginRegistry(tmpDir); + reg.register({ id: 'test-plugin', name: 'Test Plugin', version: '1.0.0', description: 'A mock plugin', capabilities: ['floors', 'queue'] }); + const list = reg.list(); + assert.ok(list.length > 0); + const p = reg.get('test-plugin'); + assert.ok(p); + assert.equal(p?.id, 'test-plugin'); + assert.deepEqual(p?.capabilities, ['floors', 'queue']); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('PluginManager dynamically loads an external plugin', async () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'plugin-mgr-test-')); + const mockPluginPath = path.join(tmpDir, 'mock-plugin.mjs'); + const mockPluginCode = ` +export function createPlugin() { + return { + manifest: { id: 'mock-ext', name: 'Mock Extension', version: '1.0.0', description: 'Mock external plugin', capabilities: ['floors', 'services', 'issues', 'pulls'] }, + floorProvider: { + listFloors() { return [{ id: 'f1', name: 'Floor 1', dir: '/tmp/f1', palette: 0, addedBy: 'test', addedAt: Date.now() }]; } + }, + servicesProvider: { + async listServices() { return [{ port: 3000, host: 'localhost', pid: 123, command: 'test', workerId: 'w1', cwd: '/tmp/f1', since: Date.now() }]; } + }, + issuesAdapter: { + async listIssues() { return [{ number: 1, title: 'Issue 1', state: 'OPEN', url: 'http://example.com/1', author: 'test', labels: [], assignees: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), body: '', comments: 0 }]; } + }, + pullsAdapter: { + async listPulls() { return [{ number: 10, title: 'PR 10', state: 'OPEN', isDraft: false, url: 'http://example.com/pr/10', author: 'test', labels: [], reviewDecision: '', headRefName: 'feature-branch', baseRefName: 'main', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), additions: 10, deletions: 2, checks: 'none', body: '', closes: [] }]; } + } + }; +} +`; + writeFileSync(mockPluginPath, mockPluginCode); + process.env.AGENT_OFFICE_PLUGIN = mockPluginPath; + try { + const mgr = new PluginManager(tmpDir); + await mgr.init({} as any); + const state = mgr.getState(); + // The plugin names itself in its manifest, so that is the id the office knows it by — the name of + // the file it was loaded from is not a second card in Settings. + const plugin = state.plugins.find((p) => p.id === 'mock-ext'); + assert.ok(plugin, `mock-ext not found in ${JSON.stringify(state)}`); + assert.equal(plugin?.status, 'active'); + assert.equal(state.plugins.some((p) => p.id === 'mock-plugin'), false, 'the file name is not a plugin of its own'); + const fp = mgr.getFloorProvider(); + assert.ok(fp); + const floors = await fp.listFloors(); + assert.equal(floors.length, 1); + assert.equal(floors[0]?.id, 'f1'); + const sp = mgr.getServicesProvider(); + assert.ok(sp); + const services = await sp.listServices({} as any); + assert.equal(services.length, 1); + assert.equal(services[0]?.port, 3000); + const ip = mgr.getIssuesAdapter(); + assert.ok(ip); + const issues = await ip!.listIssues({} as any); + assert.equal(issues.length, 1); + assert.equal(issues[0]?.number, 1); + assert.equal(issues[0]?.title, 'Issue 1'); + const pp = mgr.getPullsAdapter(); + assert.ok(pp); + const pulls = await pp!.listPulls({} as any); + assert.equal(pulls.length, 1); + assert.equal(pulls[0]?.number, 10); + assert.equal(pulls[0]?.title, 'PR 10'); + await mgr.shutdown(); + } finally { + delete process.env.AGENT_OFFICE_PLUGIN; + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('PluginManager loads git-projects plugin bundle if present', async () => { + const pluginBundlePath = path.resolve(process.cwd(), 'plugins', 'git-projects.js'); + if (!existsSync(pluginBundlePath)) return; + const tmpDir = mkdtempSync(path.join(tmpdir(), 'git-plugin-test-')); + try { + process.env.AGENT_OFFICE_PLUGIN = pluginBundlePath; + const mgr = new PluginManager(tmpDir); + await mgr.init({} as any); + const state = mgr.getState(); + const plugin = state.plugins.find((p) => p.id === 'git-projects'); + assert.ok(plugin); + assert.equal(plugin?.status, 'active'); + assert.ok(mgr.getIssuesAdapter()); + assert.ok(mgr.getPullsAdapter()); + await mgr.shutdown(); + } finally { + delete process.env.AGENT_OFFICE_PLUGIN; + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('PluginManager installs and uninstalls plugins', async () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'install-test-')); + try { + const mgr = new PluginManager(tmpDir); + const reg = (mgr as any).registry as PluginRegistry; + reg.register({ id: 'test-install', name: 'Test Install', version: '1.0.0', description: 'Test install desc', capabilities: [] }); + const ok = await mgr.install('test-install'); + assert.equal(ok, true); + const ok2 = await mgr.uninstall('test-install'); + assert.equal(ok2, true); + + // With active runtime context, installing nonexistent plugin fails gracefully + mgr.ctx = {} as any; + const failInstall = await mgr.install('test-install'); + assert.equal(failInstall, false); + const manifest = reg.get('test-install'); + assert.equal(manifest?.status, 'error'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/tests/provider-plugins.test.ts b/tests/provider-plugins.test.ts new file mode 100644 index 000000000..14a1a0d8d --- /dev/null +++ b/tests/provider-plugins.test.ts @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { standDom } from './support/dom.mjs'; + +standDom(); + +const { registerAgentProvider, unregisterAgentProvider, PROVIDER_META } = await import('../src/shared/providers.js'); +const { supportedProviders, providerLabel, PROVIDER_LABEL, agentFields, choiceLabel } = await import('../src/client/ui/provider.js'); + +/** A project as the office says what it can hire. */ +const project = (providers: string[], defaultProvider = 'claude') => + ({ name: 'p', dir: '/p', agentCmd: defaultProvider, defaultProvider, agentProviders: providers }) as never; + +test("a provider a plugin registers shows up in the picker, not only the built-ins", () => { + // The labels the picker offers are frozen when the browser module loads (PROVIDER_LABEL); the + // provider table is not. A plugin registering one at runtime must still reach the picker. + registerAgentProvider('openfox', { label: 'OpenFox', name: 'OpenFox', bin: 'openfox', usage: { reports: true, note: 'tracked by OpenFox' } }); + try { + const supported = supportedProviders(project(['claude', 'openfox'])); + assert.deepEqual(supported, ['claude', 'openfox'], 'the plugin provider is offered'); + assert.equal(providerLabel('openfox', project(['claude', 'openfox'])), 'OpenFox'); + assert.ok(PROVIDER_META['openfox'], 'the table holds it'); + } finally { + unregisterAgentProvider('openfox'); + } + assert.deepEqual(supportedProviders(project(['claude', 'openfox'])), ['claude'], 'and goes again when it does'); +}); + +test("a provider nobody registered is still left out", () => { + assert.deepEqual(supportedProviders(project(['claude', 'nope'])), ['claude']); +}); + +test("with no provider list of its own, a project's default is what it hires on", () => { + assert.deepEqual(supportedProviders({ ...(project(['claude']) as object), agentProviders: undefined } as never), ['claude']); +}); + +test("a registered provider produces a visible label in PROVIDER_LABEL and options", () => { + registerAgentProvider('openfox', { label: 'OpenFox', name: 'OpenFox', bin: 'openfox', usage: { reports: true, note: 'tracked by OpenFox' } }); + try { + assert.equal(PROVIDER_LABEL['openfox'], 'OpenFox', 'PROVIDER_LABEL map returns OpenFox'); + assert.equal(choiceLabel({ provider: 'openfox' as never }), 'OpenFox'); + // Standing up a fake select to check option text + const fields = agentFields(project(['openfox']), 'test-agent', { provider: 'openfox' as never }); + const select = (fields.element as any).find('select')[0]; + assert.ok(select, 'select element exists'); + const opt = select.children.find((c: any) => c.attrs?.get('value') === 'openfox'); + assert.ok(opt, 'openfox option exists'); + assert.equal(opt.children[0], 'OpenFox', 'option has visible text OpenFox, not empty'); + } finally { + unregisterAgentProvider('openfox'); + } +}); diff --git a/tests/pulldiff.test.ts b/tests/pulldiff.test.ts new file mode 100644 index 000000000..36031e5d3 --- /dev/null +++ b/tests/pulldiff.test.ts @@ -0,0 +1,47 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { standDom } from './support/dom.mjs'; + +standDom(); +Object.defineProperty(globalThis, 'window', { configurable: true, value: globalThis }); + +const { extractSnippetLines, linesFromDiffHunk, parseDiff } = await import('../src/client/ui/github/pulldiff.js'); + +test('linesFromDiffHunk parses unified diff hunks correctly', () => { + const hunk = `@@ -85,5 +85,5 @@ + "@capacitor/status-bar": "8.0.2", +-"@capgo/inappbrowser": "8.6.14", ++"@capgo/capacitor-inappbrowser": "8.21.2", +`; + const lines = linesFromDiffHunk(hunk); + assert.equal(lines.length, 4); + assert.equal(lines[0]?.kind, 'hunk'); + assert.equal(lines[1]?.kind, 'ctx'); + assert.equal(lines[1]?.text, '"@capacitor/status-bar": "8.0.2",'); + assert.equal(lines[2]?.kind, 'del'); + assert.equal(lines[3]?.kind, 'add'); +}); + +test('extractSnippetLines extracts surrounding context from parsed diff files', () => { + const rawDiff = `diff --git a/package.json b/package.json +--- a/package.json ++++ b/package.json +@@ -85,5 +85,5 @@ + "@capacitor/status-bar": "8.0.2", + "@capawesome/capacitor-android-edge-to-edge-support": "8.0.8", + "@capawesome/capacitor-file-picker": "8.0.3", +-"@capgo/inappbrowser": "8.6.14", +-"@gtec/gconnect-ionic-ng-capacitor": "20.2.0", ++"@capgo/capacitor-inappbrowser": "8.21.2", ++"@gtec/gconnect-ionic-ng-capacitor": "20.4.2", +`; + const files = parseDiff(rawDiff); + assert.equal(files.length, 1); + + // Line 89 added in package.json + const snippet = extractSnippetLines(files, 'package.json', 89, 'RIGHT'); + assert.ok(snippet.length >= 4); + assert.ok(snippet.some((l) => l.kind === 'add' && l.text.includes('@gtec/gconnect-ionic-ng-capacitor'))); + assert.ok(snippet.some((l) => l.kind === 'del' && l.text.includes('@gtec/gconnect-ionic-ng-capacitor'))); + assert.ok(snippet.some((l) => l.kind === 'ctx' && l.text.includes('@capacitor/status-bar'))); +}); diff --git a/tests/repos.test.ts b/tests/repos.test.ts index b7fd83a61..510f0630f 100644 --- a/tests/repos.test.ts +++ b/tests/repos.test.ts @@ -171,7 +171,7 @@ test('a worker across repositories gets a workspace with a worktree of each on o const [start] = await waitFor(() => f.starts(), (s) => s.length === 1); assert.equal(realpathSync(start.cwd), ws); assert.equal(start.top, null); - assert.equal(start.ceiling, path.dirname(ws)); + assert.ok(start.ceiling?.split(path.delimiter).includes(path.dirname(ws))); // It comes back with them after a restart. workers.shutdown(); @@ -343,7 +343,7 @@ const pull = (number: number, state: string, headRefName: string, headRefOid?: s test('a worker across repositories goes home once its pull requests have merged and none is open, on any floor', () => { const w: WorkerInfo = { - id: 'pip', kind: 'agent', deskId: 'desk-1', name: 'Pip', color: '#fff', status: 'done', acked: true, createdBy: 't', createdAt: 0, cols: 80, rows: 24, viewers: [], + id: 'pip', kind: 'agent', deskId: 'desk-1', name: 'Pip', color: '#fff', status: 'done', acked: true, createdBy: 't', createdAt: 0, cols: 80, rows: 24, viewers: [], viewerIds: [], worktree: { path: '.agent-office/worktrees/pip-1/web', branch: 'office/pip-1', base: 'a' }, repos: [{ floor: 'api', name: 'api', dir: '/api', path: '.agent-office/worktrees/pip-1/api', branch: 'office/pip-1', base: 'b', pr: { number: 9, url: '' } }], }; diff --git a/tests/server-dispatch.test.ts b/tests/server-dispatch.test.ts index 2331c41fc..c40667353 100644 --- a/tests/server-dispatch.test.ts +++ b/tests/server-dispatch.test.ts @@ -267,7 +267,7 @@ test('welcomes a browser and dispatches what it sends', async () => { assert.equal(ada?.name, 'Ada'); assert.equal(ada?.color, '#ff8a5b'); assert.equal(ada?.floor, floor.id); - assert.deepEqual(Object.keys(welcome).slice(-17), ['floor', 'project', 'workers', 'issues', 'pulls', 'queue', 'decor', 'plan', 'services', 'dog', 'ball', 'cars', 'jail', 'jukebox', 'whiteboard', 'meeting', 'cabinet']); + assert.deepEqual(Object.keys(welcome).slice(-18), ['floor', 'project', 'workers', 'issues', 'pulls', 'queue', 'decor', 'plan', 'services', 'dog', 'ball', 'cars', 'jail', 'jukebox', 'whiteboard', 'meeting', 'cabinet', 'boards']); a.send({ t: 'ping', at: 42 }); const pong = await a.take('pong'); diff --git a/tests/services.test.ts b/tests/services.test.ts new file mode 100644 index 000000000..d24a2fcf5 --- /dev/null +++ b/tests/services.test.ts @@ -0,0 +1,92 @@ +import test from 'node:test'; +// The board repaint runs on its own timer (3s in the office); tests shorten it. +process.env['AGENT_OFFICE_SERVICES_TEST_MS'] = '30'; +import assert from 'node:assert/strict'; +import { createLateServices } from '../src/server/office/services.js'; +import type { Ctx } from '../src/server/office/context.js'; +import type { ServiceInfo, ServicesState } from '../src/shared/protocol.js'; + +/** + * The Services board shows what a plugin's services provider lists, next to what the office's own + * port scan finds: any plugin declaring the 'services' capability feeds the board. + */ + +/** A floor good enough for the services board: its workers own the servers the scan credits to them. */ +const FLoor = { + workers: { + get: (id: string) => (id ? { id } : undefined), + }, +}; + +/** What a browser on a floor is sent when the Services board is published. */ +interface Sent { + msg: { t: string; state: ServicesState }; +} + +/** A Ctx with the services pieces createLateServices needs, plus a plugin answer and an outbox. */ +function ctxWith(pluginServices: ServiceInfo[] | null, opts: { throwIt?: boolean } = {}): { ctx: Ctx; sent: Sent[] } { + const sent: Sent[] = []; + const ownWorkers = [{ workerId: 'w-own', pid: 4242, agent: false, cwd: '/p', root: '/p' }]; + const client = { accountId: 'acct-1', peer: { floor: 'floor-1' } }; + const ctx = { + cfg: { port: 4600, deployScript: undefined, tailnet: false, publicHost: 'localhost' }, + clients: { values: () => [client] }, + floors: { + values: () => [{ id: 'floor-1', workers: { owners: () => ownWorkers } }], + }, + floorOf: () => FLoor, + sendTo: (_c: unknown, msg: Sent['msg']) => sent.push({ msg }), + plugins: { + listServices: async () => { + if (opts.throwIt) throw new Error('plugin exploded'); + return pluginServices; + }, + }, + } as unknown as Ctx; + return { ctx, sent }; +} + +const openFoxService: ServiceInfo = { + port: 4100, + host: 'localhost', + pid: 9, + command: 'openfox dev', + workerId: 'w-openfox', + cwd: '/p', + title: 'OpenFox (Stopped)', + since: 0, +}; + +test("a plugin's services show up on the floor's Services board", async () => { + const { ctx, sent } = ctxWith([openFoxService]); + const { services } = createLateServices(ctx); + // The scan publishes, then the plugin's rows are merged in and pushed to the floor. + services.start(); + await new Promise((r) => setTimeout(r, 200)); + services.stop(); + const board = sent.map((s) => s.msg).filter((m) => m.t === 'services').at(-1); + assert.ok(board, `no services message in ${JSON.stringify(sent.map((s) => s.msg.t))}`); + assert.equal(board.state.items.length, 1); + assert.equal(board.state.items[0]!.port, 4100); + assert.equal(board.state.items[0]!.workerId, 'w-openfox'); + assert.equal(board.state.items[0]!.title, 'OpenFox (Stopped)'); +}); + +test("with no plugin, the board is the office's own scan and nothing more", async () => { + const { ctx, sent } = ctxWith(null); + const { services } = createLateServices(ctx); + services.start(); + await new Promise((r) => setTimeout(r, 200)); + services.stop(); + const boards = sent.map((s) => s.msg).filter((m) => m.t === 'services'); + for (const b of boards) assert.deepEqual(b.state.items, []); +}); + +test('a plugin that throws does not take the board down', async () => { + const { ctx, sent } = ctxWith([], { throwIt: true }); + const { services } = createLateServices(ctx); + services.start(); + await new Promise((r) => setTimeout(r, 200)); + services.stop(); + for (const b of sent.map((s) => s.msg).filter((m) => m.t === 'services')) assert.ok(Array.isArray(b.state.items)); +}); diff --git a/tests/session-url.test.ts b/tests/session-url.test.ts new file mode 100644 index 000000000..dde1d691a --- /dev/null +++ b/tests/session-url.test.ts @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { claude } from '../src/server/providers/claude.js'; +import type { WorkerHandle } from '../src/server/workers/types.js'; + +/** + * A worker's terminal offers a page its provider points at (see WorkerInfo.sessionUrl), opened as a + * pinned tab beside the terminal. How it gets there: Claude Code's hook route, which any provider + * can speak (see hooksAs), carries the address with the worker's session. + */ + +/** A handle enough for the hook: what the worker is, and what may be done to it. */ +function handleWith(info: ReturnType extends never ? never : WorkerHandle['info']): WorkerHandle & { emitted: number } { + const h = { + info, + state: undefined, + running: true, + bootBlocked: false, + leftNeedsInputAt: 0, + failStreak: 0, + tracker: { transcript: undefined } as unknown as WorkerHandle['tracker'], + pendingPrompt: undefined, + emitted: 0, + setStatus: (status: WorkerHandle['info']['status']) => { + info.status = status; + }, + emit: () => { + h.emitted += 1; + }, + persist: () => {}, + notePrompt: () => {}, + noteTool: () => {}, + notePr: () => {}, + clearTask: () => {}, + scheduleScan: () => {}, + prompt: () => undefined, + }; + return h as unknown as WorkerHandle & { emitted: number }; +} + +const start = (url: unknown) => ({ session_id: 's-1', source: 'startup', web_url: url }); + +test("a session's own web page becomes the worker's session url", () => { + const h = handleWith({ id: 'w1', kind: 'agent', status: 'starting' } as WorkerHandle['info']); + assert.equal(claude.hook?.handle(h, 'SessionStart', start('https://openfox.local/p/p1/s/s1')), true); + assert.equal(h.info.sessionUrl, 'https://openfox.local/p/p1/s/s1'); + assert.ok(h.emitted >= 1, 'everyone on the floor is told'); +}); + +test('an embed url is taken too, when that is what the provider sends', () => { + const h = handleWith({ id: 'w1', kind: 'agent', status: 'starting' } as WorkerHandle['info']); + claude.hook?.handle(h, 'SessionStart', { session_id: 's-1', embed_url: 'http://127.0.0.1:10469/p/p1/s/s1' }); + assert.equal(h.info.sessionUrl, 'http://127.0.0.1:10469/p/p1/s/s1'); +}); + +test('a session url that is not a web link is never taken', () => { + for (const bad of ['javascript:alert(1)', 'data:text/html,