pi-roundtable-web 0.22.5 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -1
- package/README.md +427 -104
- package/assets/icons/192.png +0 -0
- package/assets/icons/512.png +0 -0
- package/assets/icons/apple-180.png +0 -0
- package/assets/icons/badge-96.png +0 -0
- package/assets/icons/mark-maskable.svg +1 -0
- package/assets/icons/mark.svg +1 -0
- package/assets/icons/maskable-512.png +0 -0
- package/dist/chunk-character-creator-6vdshaj9.js +1 -0
- package/dist/chunk-connectors-s33pc7b8.js +1 -0
- package/dist/chunk-conversations-zpztvesk.js +1 -0
- package/dist/chunk-entry-th44mrfe.js +1 -0
- package/dist/chunk-index-4w1rf52n.js +47 -0
- package/dist/chunk-index-5pnpf9v5.js +41 -0
- package/dist/chunk-index-dkvf85c7.css +1 -0
- package/dist/chunk-index-kkydgsrs.js +2 -0
- package/dist/chunk-index-mtcervy3.js +2 -0
- package/dist/chunk-index-x3cf4dsz.js +1 -0
- package/dist/chunk-notes-ytw8qcfk.js +1 -0
- package/dist/chunk-overview-fbea32dq.js +1 -0
- package/dist/chunk-skills-58f4qz3b.js +1 -0
- package/dist/chunk-table-ypqdgf66.js +1 -0
- package/dist/chunk-table2d-bk1khqzq.js +1 -0
- package/dist/chunk-table3d-5vpp4bzh.js +4229 -0
- package/dist/chunk-transcript-nabrrayp.js +1 -0
- package/dist/index.html +6 -2
- package/dist/sw.js +1 -0
- package/package.json +8 -3
- package/src/api-types.ts +448 -1
- package/src/app/app.ts +351 -0
- package/src/app/card-log.ts +119 -0
- package/src/app/desk.ts +533 -0
- package/src/app/files.ts +262 -0
- package/src/app/frames.ts +132 -0
- package/src/app/multipart.ts +172 -0
- package/src/app/presence.ts +73 -0
- package/src/app/protocol.ts +144 -0
- package/src/app/room-log.ts +137 -0
- package/src/app/rooms.ts +189 -0
- package/src/app/routes.ts +434 -0
- package/src/app/socket.ts +406 -0
- package/src/app/sockets.ts +84 -0
- package/src/app/surface-threads.ts +92 -0
- package/src/app/surface.ts +384 -0
- package/src/app/threads.ts +99 -0
- package/src/app/uploads.ts +363 -0
- package/src/assets.ts +1 -3
- package/src/auth/authenticator.ts +167 -0
- package/src/auth/login-page.ts +44 -0
- package/src/auth/none.ts +38 -0
- package/src/auth/password.ts +250 -0
- package/src/auth/session-key.ts +65 -0
- package/src/cleanup-api.ts +1 -1
- package/src/cloudflare-access.ts +5 -2
- package/src/console-api.ts +13 -15
- package/src/console-server.ts +77 -49
- package/src/http.ts +8 -0
- package/src/index.ts +1 -1
- package/src/options.ts +308 -12
- package/src/page-colors.ts +9 -0
- package/src/page-presentation.ts +17 -12
- package/src/push/api.ts +190 -0
- package/src/push/channel.ts +50 -0
- package/src/push/encrypt.ts +136 -0
- package/src/push/endpoint.ts +54 -0
- package/src/push/sender.ts +109 -0
- package/src/push/subscriptions.ts +140 -0
- package/src/push/test-fetch.ts +17 -0
- package/src/push/triggers.ts +155 -0
- package/src/push/vapid.ts +124 -0
- package/src/pwa.ts +130 -0
- package/src/request-body.ts +20 -0
- package/src/verifier.ts +7 -1
- package/src/web-plugin.ts +123 -11
- package/dist/chunk-82frzte9.js +0 -36
- package/dist/chunk-e8z9q554.css +0 -1
package/README.md
CHANGED
|
@@ -1,50 +1,39 @@
|
|
|
1
1
|
# pi-roundtable-web
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
It
|
|
3
|
+
The owner app for a [pi-roundtable][roundtable] host: a mobile-first web page, installable as an app, where the owner talks to the agent team, answers its approval cards, stops a turn, sends files, switches yolo and long-running mode, and gets a notification on the phone when something waits.
|
|
4
|
+
It also keeps the console that came first: browse conversations and transcripts, read and edit memory notes, and watch the team, with live updates.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
It serves one page on a route of the host's HTTP listener.
|
|
7
|
+
The page talks to the host over one WebSocket, so a message, a stream of progress, a card and its answer cross in both directions at once.
|
|
8
|
+
The plugin is a chat surface of its own (`app`), so a team of agents can live in the web alone: **a host with no Discord at all works fully**.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
- **Rooms.** Each active agent and each group has a room; Home is the coordinator's. A room is its own conversation (its own session, held actions and, for a group, log), apart from the same agent's Discord channel.
|
|
11
|
+
- **Cards.** Approvals, questions, questionnaires and the card at a turn's time limit appear in the room, and any owner's device can answer them.
|
|
12
|
+
- **Modes.** Yolo mode approves the held actions a rule allows without asking; long-running mode lets a turn go past its time limit. Each is a switch per room, and a banner shows while one is on.
|
|
13
|
+
- **An app.** A web app manifest, a service worker and Web Push make it installable and let a card wake the phone.
|
|
14
|
+
- **Looks.** Agents are drawn as characters; the page has a Modern and a Camelot look, light and dark, and a round table that seats the whole team.
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
Who may enter is the host's `access` configuration plus an authentication mode you choose: `none`, `password` or `verifier` (see [Authentication](#authentication)), and the plugin refuses to start without one.
|
|
17
|
+
Then only a person whose principal holds the owner role gets in.
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|---|---|
|
|
17
|
-
| Overview | Every active agent with its model, thinking level, state (working where, waiting, or idle), context use, schedule count, and last activity; every group with its members, host, and busy count. An agent links to its Discord channel and its transcript, and a group to its Discord channel. |
|
|
18
|
-
| Conversations | Every conversation stored on disk, in four sections: owner channels (direct messages and channels where the owner talks to the assistant), agent channels, group conversations (each member's conversation inside a group), and outside-agent conversations opened over MCP. Each row shows the channel's name (when the host has a Discord connection), when it was last active, its size, and how many archives it has. An outside-agent conversation shows its first message, when it appears in the first 256 KB of the file, and when it began. A conversation the host's registry records also shows whose it is, by their display name, and whether it is private to them or shared. Every owner sees every conversation. |
|
|
19
|
-
| Notes | A principal's memory in three tabs, core, notes, and events, with search: the signed-in owner's own by default, and any principal's the host knows through a picker that appears when there is more than one. The owner can add a note, edit its text, kind, and date, and delete it after a confirmation, in whichever memory is shown. The memory addon's own validation applies, so a refusal shows its reason and saves nothing. |
|
|
20
|
-
| Skills | The full host catalog, source, groups and carriers; each available skill opens its ordered frontmatter and Markdown body. |
|
|
21
|
-
| Connectors | Upstream gateway enabled/reachable/tool status, virtual-server tool lists, and the agents or profiles that use them; an optional administration link. |
|
|
19
|
+
## Contents
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
A turn starting or ending, a queued message, a change in the agent team, or a note written through the console reaches an open page through a server-sent event stream within about a quarter of a second, and a burst of changes arrives as one event.
|
|
30
|
-
Relative times refresh every 30 seconds.
|
|
31
|
-
Times and event dates use the host's time zone (`env.timeZone`).
|
|
32
|
-
|
|
33
|
-
Conversation cleanup is opt-in through `features.cleanup`.
|
|
34
|
-
Overview includes stored owner workspaces and outside conversations, and can include separately stored party channels through `features.party`.
|
|
35
|
-
Start-over archives a conversation through the host's hook; deletion is restricted to stored owner/outside conversations and a busy refusal becomes HTTP 409.
|
|
36
|
-
Confirmation dialogs explain that memory and schedules remain.
|
|
37
|
-
Without the cleanup hook, conversations remain read-only.
|
|
38
|
-
`GET api/dashboard` is a compatibility view of Overview with channel keys and `agentGuildId`, `channelId`, and `sessionId` aliases for existing clients.
|
|
21
|
+
- [Requirements](#requirements) and [Install](#install)
|
|
22
|
+
- [The owner app](#the-owner-app): rooms, cards, [modes](#modes-yolo-and-long-running), [dispatch threads](#dispatch-threads), [uploads](#uploads), [characters](#characters-in-the-page), [the Camelot look](#the-camelot-look), [the round table](#the-round-table)
|
|
23
|
+
- [Install on a device](#install-on-a-device) and [Notifications](#notifications)
|
|
24
|
+
- [Authentication](#authentication) and the [threat model](#threat-model)
|
|
25
|
+
- [Options](#options), [the console's panes](#the-consoles-panes) and [host feature ports](#host-feature-ports)
|
|
26
|
+
- [App protocol](#app-protocol), [Screenshots](#screenshots) and [Development](#development)
|
|
39
27
|
|
|
40
28
|
## Requirements
|
|
41
29
|
|
|
42
|
-
- Bun 1.4.4 or later, and pi-roundtable `>=0.
|
|
43
|
-
- The host must run on the machine that holds its data directory: the console reads `<dataDir>/sessions` from disk
|
|
30
|
+
- Bun 1.4.4 or later, and pi-roundtable `>=0.23.0 <0.24.0` as a peer dependency.
|
|
31
|
+
- The host must run on the machine that holds its data directory: the console reads `<dataDir>/sessions` from disk, and the app keeps its files and keys under `<dataDir>/web/`.
|
|
32
|
+
- An agent team: `agents` in the configuration, with or without `discord`. Without Discord the host also needs a surface that hosts rooms, and this plugin is one; the start fails otherwise, naming the fix.
|
|
33
|
+
- A way to authenticate the owners (see [Authentication](#authentication)): a password, Cloudflare Access or a verifier of your own in front of the listener, or `none` on a listener only the owner reaches.
|
|
34
|
+
- An owner in the host's `access` configuration. With `none` and `password` the session speaks for the primary owner (or `auth.principal`) and needs no identity of its own; with a verifier, each person is an owner through the identity the verifier reports for them, listed among their `identities` or linked with `roundtable principal link`.
|
|
35
|
+
- HTTPS for installing the app and for notifications: browsers install a web app and allow service workers and push only on a secure origin (`localhost` aside). Put the listener behind a proxy or tunnel that terminates TLS.
|
|
44
36
|
- The `memory` addon for the Notes pane (it is on by default; switch the pane off with `panes` when you run without memory).
|
|
45
|
-
- A way to authenticate the owners in front of the listener, such as Cloudflare Access, or a verifier of your own (see [Authentication](#authentication)).
|
|
46
|
-
- Each person who uses the console is an owner in the host's `access` configuration, with the identity the verifier reports for them among their `identities` or linked to them with `roundtable principal link`.
|
|
47
|
-
This holds on a host with a single owner too: the console refuses an identity linked to no one.
|
|
48
37
|
- A Discord connection is optional: with the Discord entry's `DISCORD` service the console names channels, and without one it shows channel ids.
|
|
49
38
|
|
|
50
39
|
The package ships its page prebuilt in `dist/`, so installing it needs no build step and no bundler on the host.
|
|
@@ -56,14 +45,32 @@ A git checkout has no `dist/` until you run `bun run build`, and the plugin stop
|
|
|
56
45
|
bun add pi-roundtable-web
|
|
57
46
|
```
|
|
58
47
|
|
|
59
|
-
|
|
48
|
+
A host that lives in the web alone (no Discord), behind Cloudflare Access:
|
|
60
49
|
|
|
61
50
|
```ts
|
|
62
51
|
import type { RoundtableConfig } from "pi-roundtable";
|
|
63
52
|
import { cloudflareAccess, webConsole } from "pi-roundtable-web";
|
|
64
53
|
|
|
65
54
|
export default {
|
|
66
|
-
|
|
55
|
+
name: "Studio",
|
|
56
|
+
access: {
|
|
57
|
+
owners: [
|
|
58
|
+
{
|
|
59
|
+
name: "Ada",
|
|
60
|
+
principal: "ada",
|
|
61
|
+
// The identity cloudflareAccess reports; see "Cloudflare Access" below for how to find it.
|
|
62
|
+
identities: ["oidc:<issuer>:<sub>"],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
database: { url: process.env.DATABASE_URL ?? "" },
|
|
67
|
+
dataDir: "./data",
|
|
68
|
+
model: process.env.MODEL ?? "",
|
|
69
|
+
http: { port: 3000, hostname: "127.0.0.1" },
|
|
70
|
+
agents: [
|
|
71
|
+
{ name: "guide", displayName: "Guide", prompt: "You are the owner's main assistant." },
|
|
72
|
+
],
|
|
73
|
+
coordinator: "guide", // Home is the coordinator's room; the default is the first agent
|
|
67
74
|
plugins: [
|
|
68
75
|
webConsole({
|
|
69
76
|
verifier: cloudflareAccess({
|
|
@@ -71,89 +78,213 @@ export default {
|
|
|
71
78
|
audience: process.env.CONSOLE_ACCESS_AUD ?? "",
|
|
72
79
|
email: "owner@example.com",
|
|
73
80
|
}),
|
|
74
|
-
origin: "https://
|
|
81
|
+
origin: "https://app.example.com",
|
|
75
82
|
dataDir: "./data",
|
|
76
83
|
}),
|
|
77
84
|
],
|
|
78
85
|
} satisfies RoundtableConfig;
|
|
79
86
|
```
|
|
80
87
|
|
|
81
|
-
|
|
88
|
+
A host that keeps Discord adds the plugin to the `plugins` of the configuration `init` wrote; the agents then have a Discord channel and a web room each, and the two are separate conversations.
|
|
89
|
+
On a host with a single owner who reaches the listener alone, for instance over a unix socket, `auth: { mode: "none" }` replaces `verifier` and needs no identity; read the [threat model](#threat-model) first.
|
|
82
90
|
|
|
83
|
-
The plugin adds the route `/console` (a redirect to `/console/`) and `/console/…` to the `public` listener, and a line with the
|
|
91
|
+
The plugin adds the route `/console` (a redirect to `/console/`) and `/console/…` to the `public` listener, and a line with the app's address to the dashboard message.
|
|
92
|
+
Open `<origin>/console/`; the page is the app.
|
|
93
|
+
Sign in with the mode you chose; Home, the coordinator's room, heads the room list.
|
|
84
94
|
|
|
85
|
-
##
|
|
95
|
+
## The owner app
|
|
86
96
|
|
|
87
|
-
|
|
97
|
+
The `rooms` pane is the app; it is on by default.
|
|
98
|
+
It is the same agent team as on Discord, in rooms of its own: `<surface>:agent.<name>` for an agent, `<surface>:group.<name>` for a group (surface `app` unless you change `surface`), and Home, which is the coordinator's room, so the owner keeps one main assistant with one memory.
|
|
99
|
+
A room is a conversation apart from the same agent's Discord channel: it has its own session, held actions and, for a group, its own log, so what you say in the app is not said in Discord.
|
|
100
|
+
History from before, in Discord, stays readable in the Conversations pane.
|
|
101
|
+
|
|
102
|
+
The page is built for a phone first: a bottom tab bar under 720 px, a side rail above it, and above 1024 px the room list beside the open room.
|
|
103
|
+
|
|
104
|
+
| Where | What it does |
|
|
105
|
+
|---|---|
|
|
106
|
+
| **Rooms** | Home first, then agents, groups and dispatch threads, with badges for working, a card waiting, yolo and long-running. |
|
|
107
|
+
| **A room** | Each message with its author's picture and name, Markdown (raw HTML is shown as text), files, and a live progress strip with the streamed text and tool chips. A sticky composer with upload and Stop. The header menu holds the mode switches, Profile and Start over. |
|
|
108
|
+
| **Cards** | Inline in the room: an approval with the call's details and a copy button; a question with options, an "Other" field and Cancel; a questionnaire; and the card at a turn's time limit. Answered cards show the answer given. |
|
|
109
|
+
| **Agent profile** | Display name, prompt, model and thinking level, and the character creator (see [Characters in the page](#characters-in-the-page)). |
|
|
110
|
+
| **Schedules** | Every schedule of the team, labelled by room or channel, with a confirmed cancel. |
|
|
111
|
+
| **Table** | The round table (see [The round table](#the-round-table)). |
|
|
112
|
+
| **Settings** | Color scheme and look, install, notifications for this device, and Log out (password mode). |
|
|
113
|
+
|
|
114
|
+
**Sending and stopping.** A message goes to the agent as the owner's. **Stop** ends the turn.
|
|
115
|
+
A message that cannot go shows "Not sent" with Try again and Discard; a lost connection shows a banner under the header and the page reconnects with backoff, then fetches what it missed.
|
|
116
|
+
A conversation that was started over keeps a divider, and the old turns stay in the Conversations pane's archives.
|
|
117
|
+
|
|
118
|
+
**Cards.** A turn that needs the owner opens a card in its room and every open page of every owner sees it; any owner can answer, except a card that is the speaker's alone, which only the speaker answers.
|
|
119
|
+
The turn waits `cardGraceSeconds` (120 by default, as on Discord).
|
|
120
|
+
After that it goes on without the answer, and the card stays open for a day: an answer then starts a new turn in the room that carries it.
|
|
121
|
+
`Cancel` on a question answers `declined`, as Discord does.
|
|
122
|
+
A card that ended stays in its room's history, as a card stays in a Discord channel with its answer: it is kept in `web_room_cards` with how it ended (`approved`, `declined`, `answered`, `expired`, `cancelled` or `withdrawn`), who answered, what was answered and when, and a reload, a new tab or an automatic reload shows it among the messages where it opened.
|
|
123
|
+
It holds only what the card showed, and an owner reads it as they read the messages.
|
|
124
|
+
Cards open before a restart are lost from the page, but their held calls persist, and a message that approves them still works.
|
|
125
|
+
Whether a card pushes a notification is described under [Notifications](#notifications).
|
|
126
|
+
|
|
127
|
+
### Modes: yolo and long-running
|
|
128
|
+
|
|
129
|
+
The header menu of an agent or group room has two switches, kept per room and across restarts (the core's `MODES` service). Only an owner may set them, and only for an app room: a Discord channel's modes cannot be set here, and a thread has none.
|
|
130
|
+
While a mode is on, a banner under the header says so with a button to turn it off.
|
|
131
|
+
|
|
132
|
+
| Mode | What it does | What stays under your control |
|
|
88
133
|
|---|---|---|
|
|
89
|
-
|
|
|
90
|
-
|
|
|
91
|
-
| `ownerId` | the primary owner | Deprecated. The owner's principal id that a request from a verifier reporting no actor stands for. The Notes pane no longer needs it: it shows each owner their own notes. |
|
|
92
|
-
| `dataDir` | `./data` | The host's data directory; the console reads `<dataDir>/sessions`. Set it to the `dataDir` of your configuration. |
|
|
93
|
-
| `mountPath` | `/console` | The path the console is served under: one or more segments of letters, digits, `.`, `_`, `~`, and `-`. |
|
|
94
|
-
| `listener` | `public` | The id of the listener whose address serves the console. |
|
|
95
|
-
| `panes` | `overview`, `conversations`, `notes` | Ordered subset of those panes plus `skills` and `connectors`. Disabled panes answer 404. Skills/connectors require their feature ports at setup. |
|
|
96
|
-
| `routing` | `hash` | `path` retains links such as `/console/skills`, with history navigation and an authenticated page fallback. |
|
|
97
|
-
| `features` | none | Trusted host integrations, or a factory receiving public plugin setup context; see below. |
|
|
98
|
-
| `presentation` | English | `{ locale, messages }`: locale controls dates and document language; English source strings are dictionary keys for labels, descriptions, confirmations, and API errors. |
|
|
99
|
-
| `title` | `Roundtable` | The page's heading and title. |
|
|
100
|
-
| `relayNotes` | the remote MCP note | Text that an outside agent's relayed message begins with, taken off before the owner's words are shown. Set it to the same value as the `relayNote` of [pi-roundtable-mcp][mcp] when you changed that one. |
|
|
101
|
-
| `exclude` | none | A function from a channel key to `true` for conversations the console must neither list nor read, such as the channels of another plugin that keeps its own sessions in the same directory. |
|
|
134
|
+
| **Yolo** | The server approves a held call itself, before a card opens, and writes a line in the room ("Yolo mode approved: …") for each one. | Turning it on asks to confirm. It approves only for an owner's turn (never a webhook or a report), never a call that names its approvers or is the speaker's alone, and never a destructive class: `sudo`, `rm`, disk, power, kill, service, docker, firewall, packages, force push, hard reset, `gh` and `crontab` still ask. `autonomy.yolo.allow` and `autonomy.yolo.card` in the host configuration change the list. A call held without classes always asks. A card opened before the switch stays open. |
|
|
135
|
+
| **Long-running** | A turn that reaches its time limit goes on by itself, as the owner's Continue would, up to `autonomy.longRunning.maxSeconds` of work (four hours by default). | At the cap the turn asks on a time-limit card (and, with push on, a notification says "Long-running cap reached"), or aborts when it cannot show one. `ask_user` is never answered automatically. |
|
|
102
136
|
|
|
103
|
-
|
|
137
|
+
**A plain `git push` and a write outside the workspace are approved under yolo**, because they are the non-destructive classes, and a push to `main` may deploy. List `shell.push` in `autonomy.yolo.card` to have it ask.
|
|
138
|
+
The turn's own time limits are `turnLimits` in the host configuration.
|
|
139
|
+
The classes, the rule behind each and the guardrails are in the core guide: [hold classes and yolo mode](../../docs/plugins.md#hold-classes-and-yolo-mode), [conversation modes](../../docs/plugins.md#conversation-modes-modes) and [long-running conversations](../../docs/plugins.md#long-running-conversations).
|
|
104
140
|
|
|
105
|
-
|
|
106
|
-
The host refuses to start with a message naming the missing service when one of them is absent for a pane you serve.
|
|
141
|
+
### Dispatch threads
|
|
107
142
|
|
|
108
|
-
|
|
143
|
+
The app hosts the core's dispatch threads (`ChatSurface.threads`). A background dispatch started from a room (a coding task, a delegated task, `message_agent` between agents) puts one start line in that room and opens a thread, `<surface>:thread.<uuid>`, whose room holds the dispatch's progress and report. The start line links the thread, and the room list nests each thread under the room that started it, with the finished ones folded away. Only an agent's or group's room can host a thread, never a thread itself.
|
|
109
144
|
|
|
110
|
-
`
|
|
111
|
-
Use the setup context to bind existing services; no database tables or session paths are renamed.
|
|
112
|
-
The host wrapper must declare any additional required service keys in its plugin's `requires`.
|
|
145
|
+
A thread's room is read only: a `send` to it is refused with `read_only`, and it has no modes. Its cards are the dispatch's own, such as a coding job's approvals, and any owner answers them there as in any room. Agents post in it under their own names. A closed thread stays readable (the list keeps the 30 newest closed ones), and the host closes the threads a restart left open, with a restart notice. `GET api/rooms` lists a thread with `parent` and, once closed, `closed: true`; opening and closing one sends `changed`.
|
|
113
146
|
|
|
114
|
-
|
|
115
|
-
|---|---|
|
|
116
|
-
| `party` | `contains(key)` separates party channels from owner transcripts; `list()` returns `PartyView[]` with channel, profile, enabler, enabled time, container state, busy count and optional last activity. |
|
|
117
|
-
| `schedules` | `count(key)` returns the workspace schedule count. |
|
|
118
|
-
| `cleanup` | `startFresh(key)` archives/waits for active turns and returns its kind; `deleteConversation(key)` returns `deleted` or `busy`. The package validates key shape, ownership, existence and exclusions before calling either hook. |
|
|
119
|
-
| `skills` | `catalog()` supplies public `SkillView[]`; `read(name)` reads only that catalog entry and returns `{ frontmatter, body }`. The host bounds disk reads and the API shows what `read` returns, flattening metadata in source order. A skill that cannot be read answers with a fixed message; set `errorDetail: true` to append the catalog's `missing` text or the read error's message (cut at 300 characters) when the host's reasons are safe to show. |
|
|
120
|
-
| `connectors` | `gateways()`, `servers()`, `usedBy(server)` provide status/tools/usage. Optional `adminUrl` accepts HTTP(S) or an absolute local path, never script URLs. |
|
|
147
|
+
### Uploads
|
|
121
148
|
|
|
122
|
-
`
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
The page
|
|
149
|
+
An owner may send a file of any kind in a room, up to four to a message: the button, a paste (a copied image, say) or a drop. `POST <mount>/api/uploads` takes one file as `multipart/form-data` (field `file`) and answers `{id, name, type, size}`; the message then names it in `uploads`. A long text goes as a file too: the page turns a paste of 4,000 characters or more into a `pasted-text-<n>.txt` chip, and a message over 32,000 characters into `message.txt` with the line "(long text attached)"; the server still refuses a text over 32,000 characters.
|
|
150
|
+
|
|
151
|
+
- **Streamed, not buffered.** The body is read as a stream and written to disk as it arrives, so a large file costs disk, not memory. Only the first bytes are kept to tell what the file is. A file over `uploadMaxBytes` is refused with `413` (before it is read when the length says so, as it passes the limit otherwise), and nothing is left on disk.
|
|
152
|
+
- **Any type.** Images are checked by their first bytes (a file that says it is a PNG but is not one is refused with `415`); a file that is valid UTF-8 without NUL bytes is kept as text (`text/markdown`, `text/csv` or `application/json` by its name, else `text/plain`); a PDF by its first bytes keeps `application/pdf`; everything else is kept as `application/octet-stream` under the name it came with. What the browser said the type was (often empty, or `video/mp2t` for a `.ts` file) does not decide.
|
|
153
|
+
- **Bounded.** A person may have 16 files and 400 MiB waiting for a message, counting uploads on their way (`429` beyond that); a waiting file is deleted after a day, a sent one after 30. The page deletes a chip's upload when it is removed or its send fails (`DELETE <mount>/api/uploads/<id>`, the owner's own waiting uploads only), and a body that is cut short leaves no file behind.
|
|
154
|
+
- **Served safely.** A stored file is a download, never a page (`content-disposition: attachment`, `content-security-policy: sandbox`, `nosniff`); only the four image types are shown inline.
|
|
155
|
+
- **What the agent gets.** The turn's message names each file, and the host saves it for the agent's tools: the app hands the host the stored file's path, so a large file is copied from disk, never held in memory. The agent takes a file up to `uploadMaxBytes` (the surface's `maxAttachmentBytes`), not the 25 MiB a Discord attachment is held to. Images (four at most) are also shown to the model, and a text or PDF file can be read by the agent. A file over 25 MiB is kept as a file for tools: it is not shown to the model and `read_attachment` refuses it, and the turn's attachment list marks it "too large for read_attachment; kept as a file for tools", which a tool that takes files opens as it is.
|
|
156
|
+
- **Limits in front of the app.** The upload route asks the host's listener for a request body of `uploadMaxBytes` plus 64 KiB of form framing, so a larger `uploadMaxBytes` raises the listener's limit past its 128 MiB default (a listener the host fixed with its own `maxRequestBodyBytes` decides instead, and refuses a larger body with `413`). A reverse proxy has its own limit; Cloudflare's free plan caps request bodies at 100 MB, so a larger file needs a paid plan or an upload path that skips the proxy.
|
|
157
|
+
|
|
158
|
+
### Characters in the page
|
|
159
|
+
|
|
160
|
+
Every agent is drawn as its composed character (`pi-roundtable/characters`). The page draws it from the spec the server sends, after a strict `parseCharacter` check: a spec that does not parse is logged and replaced by the look the agent's name suggests. The drawing is `composeSvg`'s output, the only markup the page injects, and it holds no inline style, so the content security policy is unchanged. The composer is a chunk of its own (about 19 KB gzipped) that starts loading with the page and is cached by the service worker.
|
|
161
|
+
|
|
162
|
+
- **Where.** The room list, the room's header and the agent's profile show the character live, the room list only while its agent is not idle; messages, the room switcher and a group's stack of up to three members stand still. The owner has no picture.
|
|
163
|
+
- **State.** `thinking`, `working`, `waiting` (a card is open), `done`, `error` and `sleepy` come from the `activity`, `presence` and prompt frames; `working` elsewhere (Discord, another room) counts, `done` lasts 2.5 s, a failed turn stays `error` until the next turn or the room is opened (at most ten minutes), and an agent quiet for eight hours, or between 01:00 and 06:00 local time with nothing in the last hour, is `sleepy`. Under `prefers-reduced-motion` the characters stand still and the state shows as a badge.
|
|
164
|
+
- **Accent.** The character's primary color, shaded to be readable in the scheme, is `--agent-accent` on the room's row, header, progress strip and composer.
|
|
165
|
+
- **Creator.** The profile's "Look" section edits a draft of the character: the 24 classes, eight palettes and custom primary, secondary and trim colors, and strips for hat, outfit, prop, hair, beard, accessory, build, skin and hair color, each drawn on the current look. "Suggest from role" and "Randomize" fill the draft, Save sends `PUT api/agents/<name>/character` and shows a refused field under its strip, and leaving with unsaved changes asks first. Every label is a message key, `character.class.<id>`, `character.<kind>.<id>` and `character.palette.<name>`, with the English name as the default.
|
|
166
|
+
- **Look.** `ConfigView.look` (the `appearance.look` option) goes on the page as `data-look` on `<html>`; Settings picks Modern or Camelot per device (`localStorage` `rt-look`), and the characters take the shield frame under Camelot.
|
|
167
|
+
|
|
168
|
+
### The Camelot look
|
|
169
|
+
|
|
170
|
+
`appearance.look: "camelot"` (or a device's own pick in Settings) dresses the page's details and leaves its skeleton alone. It is CSS and a few small elements: no image, no web font, and nothing new for the content security policy.
|
|
171
|
+
|
|
172
|
+
- **Colors.** Parchment (`#f4ead5`, surfaces `#fbf5e6`) by day and a night castle (`#0f1726`, surfaces `#16213a`) by night, with gold and a bronze accent that reads on parchment. Every text color meets 4.5:1 and every accent and control edge 3:1 on the surfaces it sits on; an agent's accent is shaded for Camelot's own surfaces (`accentOf(spec, "camelot-light")`). The scheme still follows the system or the person's pick.
|
|
173
|
+
- **Type.** Headings use the system serif stack (`Iowan Old Style`, Palatino, `Songti TC`, `Noto Serif TC`, Georgia); nothing is fetched.
|
|
174
|
+
- **Details.** Gold rules under page titles and the room header, with corner marks on the header; the shield frame on every character; a wax seal in the agent's accent that is stamped on an approval card when it turns approved while the page shows it, and sits still on a card the room shows already approved (the seal hangs over the card's lower corner, so the card keeps its height); and a candle and a row of runes on the progress strip, the candle flickering and the runes shimmering while the turn works. Each moves with `transform` or `opacity` only and stands still under `prefers-reduced-motion`.
|
|
175
|
+
- **Toolbar color.** The `theme-color` tags follow the look and the scheme: parchment or night castle under Camelot, the plain page's colors under Modern. The pre-paint script (`theme-init.js`) also puts `data-look` on the page, so a Camelot host never flashes the Modern look first.
|
|
176
|
+
|
|
177
|
+
### The round table
|
|
178
|
+
|
|
179
|
+
The `table` pane seats every active agent at a round table, the coordinator first, in every look. Each seat shows the agent's character in the shield frame and takes its accent color; a seat's state comes from the same rules as the room list (`sleepy` sits as `idle`), and the member a group round names as `agent` is lit as `speaking`. A change of state moves the seat at once; a change of face redraws that one picture once it has stood for 300 ms. A click, or Enter on a focused seat, opens the agent's room. The drawing is a WebGL scene where the browser has one and an SVG table otherwise (or after a lost context); the scene is a chunk of its own, fetched when the view first opens and cached by the service worker, never precached. It stops drawing when nothing moves, when the tab is hidden and when it is off screen, and under `prefers-reduced-motion` it is one still frame.
|
|
180
|
+
|
|
181
|
+
- **Names.** A seat's name is drawn in front of the table and of every badge, never under them, and is cut with an ellipsis only when it is longer than its plate; the seat's full name is its accessible name. A name is at least 11 px on the screen: the plates grow with the canvas' scale, as far as their neighbours leave room, and where even that is not enough (twelve seats on a phone, a narrow panel with many members) the plates carry only their color bar and the seats are listed as buttons under the table. The 2D table draws its names at 11 px or more and keeps its ring inside its box. Plates of nearer seats are drawn over those of farther ones, and the seat across the table stands a little higher so the table's far edge does not cut its head.
|
|
182
|
+
- **Phones.** On a phone held upright the canvas is as tall as it is wide plus a tenth; with more than ten seats, or when the names cannot be read at 11 px, the agents are also listed as buttons under it.
|
|
183
|
+
- **`?nogl`.** Adding `nogl` to the page address (`/console/?nogl`, or `#/table?nogl`) draws the SVG table on a browser that has WebGL, to try that path; `nogl=0` and `nogl=false` leave the choice to the browser. The camera starts high enough for the ring to fill the canvas and pinch zooms; "Reset view" brings it back.
|
|
184
|
+
- **Group rooms.** A group room's header has a "Table" button that opens the table of its members only, in a sheet on a phone and in a panel beside the room elsewhere. Under the Camelot look a compact strip of the members also stands under the header with the speaker lit; under Modern it stays collapsed.
|
|
185
|
+
|
|
186
|
+
## Install on a device
|
|
187
|
+
|
|
188
|
+
The app is a progressive web app, installable on a phone, tablet or desktop and opened in its own window.
|
|
189
|
+
|
|
190
|
+
- `<mount>/manifest.webmanifest` is generated for the mount: its `id`, `start_url` and `scope` are all `<mount>/`, `display` is `standalone`, the name is `title`, and the theme and splash colors follow `appearance.look`. The page links it with `crossorigin="use-credentials"`, so a proxy such as Cloudflare Access that sets a cookie sees it.
|
|
191
|
+
- `<mount>/sw.js` is the service worker, served `no-cache` and scoped to the mount. It precaches the page's hashed assets and `index.html`, serves assets cache-first and navigations network-first with the cached shell as the offline fallback. It never touches `api/`, `auth/`, `login`, `files/` or `avatars/`, and returns a redirect (a proxy's login) as it came. It caches the shell only, never data, so a shared device keeps nothing after you log out. The round table's 3D scene is fetched the first time that view opens and is not precached.
|
|
192
|
+
- `<mount>/icons/` serves `192.png`, `512.png`, `maskable-512.png`, `apple-180.png` and `badge-96.png`. The package ships a set; `icons.dir` replaces it with a directory that has the same five names.
|
|
193
|
+
|
|
194
|
+
To install: open the page over HTTPS in Chrome, Edge or Safari and use the browser's Install option, or press **Install the app** in Settings where the browser offers it.
|
|
195
|
+
On iPhone and iPad use Share, then Add to Home Screen, and open the app from the home screen: iOS allows notifications only to an installed app.
|
|
196
|
+
An installed page that a release has replaced while it was open reloads once by itself; if it still fails it shows a card with Reload.
|
|
197
|
+
|
|
198
|
+
## Notifications
|
|
199
|
+
|
|
200
|
+
The app can wake a device with Web Push when something waits and no page of that owner is in view.
|
|
201
|
+
Each owner turns it on per device in **Settings**, where every subscribed device is listed (with a name such as "Chrome on macOS") and can be removed; logging out unsubscribes the device.
|
|
202
|
+
|
|
203
|
+
A push goes out when:
|
|
204
|
+
|
|
205
|
+
- a card opens in an app room: "Card waiting", or "Long-running cap reached" for a time-limit card in a long-running room;
|
|
206
|
+
- an agent posts in an app room, and no message from a page started that turn: a schedule's or a job's report, or a reply that arrived after the card grace. A second reply in the same room within 15 seconds is not pushed;
|
|
207
|
+
- a notice reaches the owner: `notify` through the app's direct channel writes a line in Home and pushes it, and a notice that Discord (or another channel) delivered is pushed too, through the core's `mirror` hook.
|
|
208
|
+
|
|
209
|
+
Discord's own cards are not pushed.
|
|
210
|
+
With `push.preview` on (the default) a notification shows the first 120 characters of the message; with `push.preview: false` it says only that something waits and where.
|
|
211
|
+
Tapping a notification focuses the app or opens it at the room.
|
|
212
|
+
|
|
213
|
+
Setup:
|
|
214
|
+
|
|
215
|
+
- **`push.subject`** is the contact a push service may use about this server (the VAPID `sub`): a `mailto:` address or an `https:` URL. It defaults to `origin` when that is `https`; with an `http` origin, push stays off, the host logs a warning, and a device cannot subscribe, until you set it.
|
|
216
|
+
- **Keys.** The server makes a VAPID key pair (P-256, with WebCrypto) at its first start and keeps it in `<dataDir>/web/vapid.json`, readable by its user alone. Subscriptions are bound to the public key, so keep the file across restarts and deploys; a file that cannot be read stops the start, and deleting it means every device must subscribe again.
|
|
217
|
+
- **Push services.** A subscription's endpoint must be `https`, with no port or credentials, and on an allowed host: `fcm.googleapis.com`, `updates.push.services.mozilla.com`, `*.push.apple.com` and `*.notify.windows.com`. `push.endpointHosts` adds hosts for another browser's service; a leading `*.` allows every subdomain. Any other endpoint is refused with `400`, so an owner's session cannot make the host post to an address of its choosing.
|
|
218
|
+
- **Delivery.** The payload is encrypted for the device (RFC 8291, `aes128gcm`) and signed with a VAPID token (RFC 8292, ES256 with `jose`); there is no push dependency. A `404` or `410` from the service removes the subscription, as do five failures in a row. A person has at most 20 devices.
|
|
219
|
+
- **Who gets it.** Only a device of a person who is still an owner, and only while no open page of that owner reports itself visible.
|
|
220
|
+
|
|
221
|
+
## Authentication
|
|
222
|
+
|
|
223
|
+
The app never makes an account.
|
|
224
|
+
One authenticator checks every request under its path before anything is read, whether it is a page, the API, the event stream or the WebSocket upgrade of the owner app.
|
|
225
|
+
You choose how with the `auth` option; the console then looks the person up in the host's `IDENTITY`, reading only, and admits them only when their principal holds the owner role.
|
|
226
|
+
|
|
227
|
+
| `auth` | Who is the owner | Use it when |
|
|
228
|
+
|---|---|---|
|
|
229
|
+
| `{ mode: "verifier", verifier }`, or the `verifier` option as shorthand | The person the verifier reports, linked to an owner's principal. | A proxy such as Cloudflare Access authenticates people in front of the listener. This is the one mode whose proof an agent on the host cannot forge. |
|
|
230
|
+
| `{ mode: "password" }` | The primary owner, once they give the password. | The console is reachable from the internet with no proxy that signs people in. |
|
|
231
|
+
| `{ mode: "none" }` | The primary owner, whoever reaches the listener. | Only the owner can reach the listener: a unix socket, or loopback TCP. |
|
|
232
|
+
|
|
233
|
+
Giving both `auth` and `verifier` throws, and so does giving neither: the plugin never starts without a way to authenticate.
|
|
234
|
+
Whatever the mode, a request that changes data must carry the console's own `Origin`.
|
|
235
|
+
It answers `403 Forbidden` with no detail when the verifier refuses, throws, or rejects, when the lookup fails, and when the person is no owner.
|
|
236
|
+
|
|
237
|
+
### Password
|
|
127
238
|
|
|
128
239
|
```ts
|
|
129
240
|
webConsole({
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
panes: ["overview", "notes"],
|
|
134
|
-
presentation: { locale: "fr", messages: { "Start over": "Recommencer" } },
|
|
135
|
-
features: (context) => ({
|
|
136
|
-
cleanup: {
|
|
137
|
-
startFresh: (key) => context.conversations.startFresh(key),
|
|
138
|
-
deleteConversation: (key) => context.conversations.deleteConversation(key),
|
|
139
|
-
},
|
|
140
|
-
}),
|
|
241
|
+
auth: { mode: "password" }, // reads ROUNDTABLE_WEB_PASSWORD
|
|
242
|
+
origin: "https://console.example.com",
|
|
243
|
+
dataDir: "./data",
|
|
141
244
|
});
|
|
142
245
|
```
|
|
143
246
|
|
|
144
|
-
|
|
247
|
+
| Setting | Default | What it does |
|
|
248
|
+
|---|---|---|
|
|
249
|
+
| `passwordEnv` | `ROUNDTABLE_WEB_PASSWORD` | The environment variable that holds the password. It must be set and at least 12 characters, or the host stops at startup, naming the variable and never the value. |
|
|
250
|
+
| `sessionDays` | `30` | How long a login lasts, from 1 to 365 days. |
|
|
251
|
+
| `principal` | the primary owner | The owner's principal id the session speaks for. |
|
|
145
252
|
|
|
146
|
-
|
|
253
|
+
`<mount>/login` is a server-rendered form that needs no script.
|
|
254
|
+
A wrong password answers `401` after one second.
|
|
255
|
+
Ten wrong passwords in 15 minutes, counted for all visitors together, make every login answer `429` with `Retry-After` until the window passes, the right password included: behind a proxy the client's address is not trustworthy, so the limit is global.
|
|
256
|
+
The right one sets the cookie `rt_session`: signed, `HttpOnly`, `SameSite=Strict`, `Secure` when `origin` is https, and `Path=<mount>/`, and answers `303` to the console.
|
|
257
|
+
`POST <mount>/auth/logout` clears it.
|
|
258
|
+
Without a session a page navigation answers `303` to the login page and the API answers `401`; only `login`, `auth/login` and `auth/logout` are public.
|
|
259
|
+
The login and logout `POST`s need the console's own `Origin`.
|
|
147
260
|
|
|
148
|
-
The
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
261
|
+
The cookie is signed with a key derived from `<dataDir>/web/session.key` (32 random bytes, mode `0600`, made at the first start) and from a hash of the password, so changing the password signs everyone out, and the key file alone signs nothing a host with another password accepts.
|
|
262
|
+
A key file of the wrong size stops startup.
|
|
263
|
+
|
|
264
|
+
### None
|
|
152
265
|
|
|
153
|
-
|
|
266
|
+
```ts
|
|
267
|
+
webConsole({
|
|
268
|
+
auth: { mode: "none" }, // the listener must be a unix socket, or TCP on 127.0.0.1, ::1 or localhost
|
|
269
|
+
origin: "https://console.example.com",
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
`none` must be written out: it is never the default.
|
|
274
|
+
It reads the listener's address from the host (`context.listener`) and refuses to start for TCP on any other hostname, or with no hostname, which binds every interface, unless `trustNetwork: true` says a network in front of it keeps everyone else out.
|
|
275
|
+
A unix socket passes, with a warning when its mode lets other users write to it.
|
|
276
|
+
The console warns at every start that whoever reaches the listener is the owner.
|
|
277
|
+
It speaks for `principal` (default the primary owner) through the plugin identity `token:web-console`, which the plugin declares itself, so no `access.owners` entry is needed.
|
|
278
|
+
|
|
279
|
+
Read [Threat model](#threat-model) before choosing `none` or `password` on a host whose agents run a shell.
|
|
280
|
+
|
|
281
|
+
### Verifier
|
|
282
|
+
|
|
283
|
+
A verifier is a function that receives the `Request` and returns `{ admitted: true, actor, expiresAt? }` or `{ admitted: false, reason }`.
|
|
154
284
|
`actor` describes the person as an identity, `{ provider, subject, name }`, written `<provider>:<subject>` in `access.owners[].identities` and by `roundtable principal link`.
|
|
155
285
|
The reason goes to the host's log and is never sent to the client, so keep secrets out of it.
|
|
156
|
-
`
|
|
286
|
+
`expiresAt` says when the proxy's assertion lapses; `cloudflareAccess` fills it from the token's `exp`, and a connection that outlives requests, such as the owner app's WebSocket, ends then.
|
|
287
|
+
`admitAs(actor, expiresAt?)` and `refuse(reason)` build the two answers.
|
|
157
288
|
|
|
158
289
|
The console decides who the request comes from by what the verifier reported:
|
|
159
290
|
|
|
@@ -240,20 +371,53 @@ It may be asynchronous; a thrown error or a rejected promise counts as a refusal
|
|
|
240
371
|
|
|
241
372
|
## Threat model
|
|
242
373
|
|
|
243
|
-
**What the
|
|
374
|
+
**What the app protects.**
|
|
244
375
|
The conversations hold everything the host's people said to the assistant and what the assistant answered, including tool calls and their results, and the memory notes shape every later turn.
|
|
245
|
-
The
|
|
376
|
+
The app serves that to the host's owners and nobody else, and since 0.23 it also acts for them: a signed-in session can run turns, approve held actions, switch yolo mode on, send files and change an agent's prompt and model.
|
|
377
|
+
So the authentication guards the owner's power over the agents, not only their reading.
|
|
378
|
+
Every write, over REST or the WebSocket, needs an authenticated owner whose tier is read again, and an open socket closes (`4401`, `4403`) when the credential lapses or the person stops being an owner.
|
|
246
379
|
Every owner sees every conversation and can read and change every principal's notes; it has no per-user views for members, and no audit trail beyond the host's log, which records only that a request was refused and why.
|
|
247
380
|
|
|
248
|
-
**Authentication is the
|
|
249
|
-
Who holds the owner role is the host's `access` configuration or the `roundtable principal` CLI, never something
|
|
250
|
-
|
|
381
|
+
**Authentication is the `auth` mode; authorization is the owner role.**
|
|
382
|
+
Who holds the owner role is the host's `access` configuration or the `roundtable principal` CLI, never something the authentication reports, so one that admits a member admits no one.
|
|
383
|
+
With a verifier the console sets no cookie and keeps no session.
|
|
251
384
|
The built-in Cloudflare Access verifier checks a signature, so a client that can reach the listener directly still cannot forge a token.
|
|
252
385
|
A custom verifier that trusts a plain header is exactly as strong as the guarantee that clients cannot set that header: use a unix socket or a firewall rule so that only the proxy reaches the listener, and have the proxy strip the header from inbound requests.
|
|
253
|
-
|
|
386
|
+
With a verifier the console does not rate-limit failed requests; do that at the proxy.
|
|
387
|
+
|
|
388
|
+
**Yolo and long-running work unattended.**
|
|
389
|
+
Yolo mode approves held calls on the server, so a mistake is not caught by a person.
|
|
390
|
+
Its guardrails: it approves only for a turn whose speaker is an owner and never the host's own principal (a webhook or a report turn), never a call that names approvers or is the speaker's alone, and never a call whose hold classes are not all allowed; destructive shell classes still ask, the classes come from every command in the line (including `bash -c`), and a call held without classes always asks.
|
|
391
|
+
A plain `git push` and a write outside the workspace are approved, which can deploy; list `shell.push` in `autonomy.yolo.card` to stop that.
|
|
392
|
+
Every approval is logged, shown in the room, and a banner stays up while the mode is on.
|
|
393
|
+
Long-running work is capped per turn (`autonomy.longRunning.maxSeconds`, four hours by default) and then asks or aborts; `ask_user` is never answered for you.
|
|
394
|
+
|
|
395
|
+
**Push posts to an address the browser hands over.**
|
|
396
|
+
A subscription names a URL on a push service, so without a check any owner session could make the host fetch any address.
|
|
397
|
+
The endpoint must be `https`, with no port or credentials, on an allowed push-service host (`push.endpointHosts` extends the list); everything else is refused with `400`, and only the person who added a device can list or remove it.
|
|
398
|
+
The payload is encrypted for the device end to end, and with `push.preview: false` it carries no message text at all.
|
|
399
|
+
|
|
400
|
+
**Text and files from agents and uploads are untrusted.**
|
|
401
|
+
Agent output is Markdown without raw HTML.
|
|
402
|
+
A stored file is always served as a download with `content-security-policy: sandbox` and `nosniff`, and only four image types are shown inline.
|
|
403
|
+
The page's Content-Security-Policy is unchanged by the service worker, the manifest and the characters (`manifest-src` and `worker-src` fall back to `'self'`), and `frame-ancestors 'none'` stays.
|
|
404
|
+
A room key from a page must be one of the app's rooms whose agent or group exists; a Discord key or another surface's key is refused.
|
|
405
|
+
|
|
406
|
+
**The service worker keeps the shell only.**
|
|
407
|
+
It never caches `api/`, `auth/`, `login`, `files/` or `avatars/`, so a shared device keeps no conversation after a logout, and a logout also removes the device's push subscription.
|
|
408
|
+
|
|
409
|
+
**`none` trusts the listener, and `password` trusts the host's own user.**
|
|
410
|
+
The agents' shell runs as the service user and inherits the host's environment, so anything that user can reach, a prompt-injected agent can reach too: the environment variable that holds the password, `<dataDir>/web/session.key` and the listener's socket.
|
|
411
|
+
With `none`, an agent on the host can call the console as the owner with `curl --unix-socket`, approve its own held actions, and change modes: `Origin` is only a header to `curl`, and `curl` is not held.
|
|
412
|
+
With `password`, an agent can read the password and the session key and sign in.
|
|
413
|
+
Both resist the network, and neither resists a compromised agent on the host.
|
|
414
|
+
Only a verifier does, because an agent cannot forge a signed token it has no key for.
|
|
415
|
+
Choose `none` for a host whose agents have no shell, or whose listener a different user owns, and keep Cloudflare Access (or another signing proxy) for one whose agents run commands; the startup warning of `none` says so.
|
|
416
|
+
Password mode adds a login that cannot be brute-forced from the network (a one-second wait per failure, a global limit of ten in 15 minutes, a constant-time comparison, a 12-character minimum), a cookie a script on the page cannot read and another site cannot send (`HttpOnly`, `SameSite=Strict`, `Secure` over https), a logout, and a key that changes with the password.
|
|
417
|
+
The limit is global, so someone who guesses wrongly on purpose can keep the owner from signing in for 15 minutes; that is the price of not trusting a client's address.
|
|
254
418
|
|
|
255
419
|
**Cross-site requests.**
|
|
256
|
-
A request that changes data (`POST`, `PATCH`, `DELETE`) is refused unless its `Origin` header is exactly the configured `origin`, after the
|
|
420
|
+
A request that changes data (`POST`, `PUT`, `PATCH`, `DELETE`) is refused unless its `Origin` header is exactly the configured `origin`, after the authentication has admitted it, and the WebSocket upgrade must come from that origin too.
|
|
257
421
|
Reads change nothing.
|
|
258
422
|
A page on another site that makes the owner's browser send a request therefore cannot write notes, and it cannot read the answers either, because the console sends no CORS headers.
|
|
259
423
|
|
|
@@ -267,7 +431,7 @@ Links to Discord are built by the page from channel ids, not taken from stored t
|
|
|
267
431
|
|
|
268
432
|
**Errors reveal nothing to the client.**
|
|
269
433
|
Every API error is a fixed message.
|
|
270
|
-
It never repeats the request URL, a header, or a token, and a refusal answers `403 Forbidden` with that single word.
|
|
434
|
+
It never repeats the request URL, a header, or a token, and a refusal answers `403 Forbidden` with that single word (`401 Unauthorized` for a request without a password session).
|
|
271
435
|
The console logs a failure with the error that was thrown and nothing from the request: no URL, no headers.
|
|
272
436
|
The text of an error that your own code throws, such as a memory store's, is logged as it is.
|
|
273
437
|
|
|
@@ -286,6 +450,159 @@ Serve only the `notes` pane, or use `exclude`, when that is a concern.
|
|
|
286
450
|
Transport security (terminate TLS at the proxy), protecting the data directory on disk, and what members see are not the console's job.
|
|
287
451
|
The console is for the owners of one host; members talk to the host through its chat surfaces.
|
|
288
452
|
|
|
453
|
+
## Options
|
|
454
|
+
|
|
455
|
+
Every option is checked when the plugin is created, so a bad setting stops the host at startup.
|
|
456
|
+
|
|
457
|
+
| Option | Default | What it does |
|
|
458
|
+
|---|---|---|
|
|
459
|
+
| `auth` | none, `auth` or `verifier` required | How requests are authenticated: `{ mode: "none" }`, `{ mode: "password" }` or `{ mode: "verifier", verifier }`; its keys are in the next table, and [Authentication](#authentication) explains the modes. |
|
|
460
|
+
| `verifier` | none | Shorthand for `auth: { mode: "verifier", verifier }`: decides whether a request comes from someone your proxy authenticated, and reports who. `cloudflareAccess(...)` is built in; pass your own function to use another proxy. Giving both `auth` and `verifier` throws. |
|
|
461
|
+
| `origin` | none, required | The app's own origin as the browser sees it, such as `https://app.example.com`: scheme and host (and port), no path. Requests that change data must carry it as their `Origin`, the dashboard line links to it, and push derives its default contact from it. |
|
|
462
|
+
| `ownerId` | the primary owner | Deprecated. The owner's principal id that a request from a verifier reporting no actor stands for. The Notes pane no longer needs it: it shows each owner their own notes. |
|
|
463
|
+
| `dataDir` | `./data` | The host's data directory; the console reads `<dataDir>/sessions`, and the app keeps `web/` (the session key, the VAPID keys, uploaded and reply files) under it. Set it to the `dataDir` of your configuration. |
|
|
464
|
+
| `mountPath` | `/console` | The path the app is served under: one or more segments of letters, digits, `.`, `_`, `~`, and `-`. |
|
|
465
|
+
| `listener` | `public` | The id of the listener whose address serves the app. |
|
|
466
|
+
| `routing` | `hash` | `path` retains links such as `/console/skills`, with history navigation and an authenticated page fallback. |
|
|
467
|
+
| `panes` | `rooms`, `table`, `overview`, `schedules`, `conversations`, `notes` | Ordered subset of those panes plus `skills` and `connectors`. `schedules` and `table` need `rooms`. Disabled panes answer 404. `skills` and `connectors` require their feature ports at setup. An explicit list is kept as written, so a host that listed its panes before 0.23 adds `rooms`, `table` and `schedules` by hand. |
|
|
468
|
+
| `surface` | `app` | The key prefix of the app's rooms, a lower-case word that is not `discord`. Rooms keep their conversations under it, so changing it starts every room over. |
|
|
469
|
+
| `cardGraceSeconds` | `120` | How long a turn waits on an approval or question card before it goes on without the answer, above 0 and at most 3600. The card stays open for a day. |
|
|
470
|
+
| `uploadMaxBytes` | `104857600` (100 MiB) | The most bytes of one file a person may upload, from 1 to 4 GiB; the agent takes files up to it too. It raises the listener's request body limit. See [Uploads](#uploads). |
|
|
471
|
+
| `push` | on, with an `https` origin | Web Push; its keys are in the table below, and [Notifications](#notifications) describes the whole. |
|
|
472
|
+
| `icons` | the package's own | `{ dir }`: a directory of the five install icons, see [Install on a device](#install-on-a-device). |
|
|
473
|
+
| `appearance` | `{ look: "modern" }` | `{ look }`: `modern` or `camelot`. The page's default look and the manifest's splash colors; the owner can switch it in the app. |
|
|
474
|
+
| `title` | `Roundtable` | The page's heading, title and the installed app's name. |
|
|
475
|
+
| `presentation` | English | `{ locale, messages }`: locale controls dates and document language; English source strings are dictionary keys for labels, descriptions, confirmations, and API errors. |
|
|
476
|
+
| `features` | none | Trusted host integrations, or a factory receiving public plugin setup context; see [Host feature ports](#host-feature-ports). |
|
|
477
|
+
| `relayNotes` | the remote MCP note | Text that an outside agent's relayed message begins with, taken off before the owner's words are shown. Set it to the same value as the `relayNote` of [pi-roundtable-mcp][mcp] when you changed that one. |
|
|
478
|
+
| `exclude` | none | A function from a channel key to `true` for conversations the console must neither list nor read, such as the channels of another plugin that keeps its own sessions in the same directory. |
|
|
479
|
+
| `assetDir` | the package's `dist/` | The built page's directory. Set it only in tests. |
|
|
480
|
+
|
|
481
|
+
The `auth` keys:
|
|
482
|
+
|
|
483
|
+
| Key | Default | What it does |
|
|
484
|
+
|---|---|---|
|
|
485
|
+
| `auth.mode` | none, required | `none`, `password` or `verifier`. |
|
|
486
|
+
| `auth.trustNetwork` | `false` | `none` only: allows a TCP listener that reaches beyond this machine, when a network in front of it keeps everyone else out. |
|
|
487
|
+
| `auth.passwordEnv` | `ROUNDTABLE_WEB_PASSWORD` | `password` only: the environment variable that holds the password, which must be set and at least 12 characters. |
|
|
488
|
+
| `auth.sessionDays` | `30` | `password` only: how long a login lasts, 1 to 365 days. |
|
|
489
|
+
| `auth.principal` | the primary owner | `none` and `password`: the owner's principal id the session speaks for. |
|
|
490
|
+
| `auth.verifier` | none | `verifier` only: the request verifier, as the `verifier` option. |
|
|
491
|
+
|
|
492
|
+
The `push` keys:
|
|
493
|
+
|
|
494
|
+
| Key | Default | What it does |
|
|
495
|
+
|---|---|---|
|
|
496
|
+
| `push.preview` | `true` | Whether a notification shows the first 120 characters of the message. With `false` it says only that something waits, and where. |
|
|
497
|
+
| `push.endpointHosts` | none | Push services to post to besides the browsers' own (Google, Mozilla, Apple, Microsoft), as host names; a leading `*.` allows every subdomain. Endpoints must still be `https`. |
|
|
498
|
+
| `push.subject` | `origin`, when it is `https` | The contact a push service may use about this server (the VAPID `sub`): a `mailto:` or `https:` URL. With an `http` origin push stays off until it is set. |
|
|
499
|
+
|
|
500
|
+
The bad settings that stop the host at startup are: no `auth` or `verifier`, both of them, an unknown `auth.mode`, a password shorter than 12 characters, `none` on a listener that reaches beyond the machine, an `origin` with a path, a `mountPath` of `/` or with `..` in it, an unknown or repeated pane, `schedules` or `table` without `rooms`, a `surface` that is not a lower-case word or is `discord`, an empty `ownerId`, a `uploadMaxBytes` out of range, and a `push.subject` that is neither a `mailto:` address nor an `https:` URL.
|
|
501
|
+
|
|
502
|
+
The plugin needs, for the panes you serve: the agent server (`AGENTS`) for Overview and Rooms, the modes service (`MODES`) for Rooms, the schedule store (`SCHEDULES`) for Schedules, and the memory addon for Notes.
|
|
503
|
+
The host refuses to start with a message naming the missing service when one of them is absent.
|
|
504
|
+
|
|
505
|
+
## The console's panes
|
|
506
|
+
|
|
507
|
+
The page has eight panes; `panes` chooses which ones a host serves.
|
|
508
|
+
Rooms and Schedules belong to the [owner app](#the-owner-app), and the Round table to [its own section](#the-round-table); the rest are the console that came first, which reads what the host already stores: the conversation files under the data directory, the memory addon, and the agent team.
|
|
509
|
+
|
|
510
|
+
| Pane | What it shows |
|
|
511
|
+
|---|---|
|
|
512
|
+
| Rooms | The owner app: Home, agent and group rooms and dispatch threads (see [The owner app](#the-owner-app)). |
|
|
513
|
+
| Schedules | Every schedule of the team, labelled by room or channel, with cancel (needs `rooms`). |
|
|
514
|
+
| Overview | Every active agent with its model, thinking level, state (working where, waiting, or idle), context use, schedule count, and last activity; every group with its members, host, and busy count. An agent links to its Discord channel and its transcript, and a group to its Discord channel. |
|
|
515
|
+
| Conversations | Every conversation stored on disk, in four sections: owner channels (direct messages and channels where the owner talks to the assistant), agent channels, group conversations (each member's conversation inside a group), and outside-agent conversations opened over MCP. Each row shows the channel's name (when the host has a Discord connection), when it was last active, its size, and how many archives it has. An outside-agent conversation shows its first message, when it appears in the first 256 KB of the file, and when it began. A conversation the host's registry records also shows whose it is, by their display name, and whether it is private to them or shared. Every owner sees every conversation. |
|
|
516
|
+
| Notes | A principal's memory in three tabs, core, notes, and events, with search: the signed-in owner's own by default, and any principal's the host knows through a picker that appears when there is more than one. The owner can add a note, edit its text, kind, and date, and delete it after a confirmation, in whichever memory is shown. The memory addon's own validation applies, so a refusal shows its reason and saves nothing. |
|
|
517
|
+
| Skills | The full host catalog, source, groups and carriers; each available skill opens its ordered frontmatter and Markdown body. |
|
|
518
|
+
| Connectors | Upstream gateway enabled/reachable/tool status, virtual-server tool lists, and the agents or profiles that use them; an optional administration link. |
|
|
519
|
+
| Round table | Every active agent seated at one round table, the coordinator first, each in the state its room is in; a seat opens that agent's room (see [The round table](#the-round-table)). Needs `rooms`. |
|
|
520
|
+
|
|
521
|
+
Opening a conversation shows its **transcript**: the user's and the assistant's messages, the tools the assistant called with a short preview of their arguments, and each tool's result (collapsed).
|
|
522
|
+
A conversation that was started over keeps its archives, and the page can show any of them.
|
|
523
|
+
The system prompt and the model's private reasoning are never shown.
|
|
524
|
+
A transcript reads at most 8 MB of the newest files and returns at most the last 1000 entries, and it says when it cut the beginning.
|
|
525
|
+
|
|
526
|
+
The page follows changes without a reload.
|
|
527
|
+
A turn starting or ending, a queued message, a change in the agent team, or a note written through the console reaches an open page through a server-sent event stream within about a quarter of a second, and a burst of changes arrives as one event.
|
|
528
|
+
Relative times refresh every 30 seconds.
|
|
529
|
+
Times and event dates use the host's time zone (`env.timeZone`).
|
|
530
|
+
|
|
531
|
+
Conversation cleanup is opt-in through `features.cleanup`.
|
|
532
|
+
Overview includes stored owner workspaces and outside conversations, and can include separately stored party channels through `features.party`.
|
|
533
|
+
Start-over archives a conversation through the host's hook; deletion is restricted to stored owner/outside conversations and a busy refusal becomes HTTP 409.
|
|
534
|
+
Confirmation dialogs explain that memory and schedules remain.
|
|
535
|
+
Without the cleanup hook, conversations remain read-only.
|
|
536
|
+
`GET api/dashboard` is a compatibility view of Overview with channel keys and `agentGuildId`, `channelId`, and `sessionId` aliases for existing clients.
|
|
537
|
+
|
|
538
|
+
## Host feature ports
|
|
539
|
+
|
|
540
|
+
`features` may be an object or a synchronous/asynchronous factory `(context) => features`.
|
|
541
|
+
Use the setup context to bind existing services; no database tables or session paths are renamed.
|
|
542
|
+
The host wrapper must declare any additional required service keys in its plugin's `requires`.
|
|
543
|
+
|
|
544
|
+
| Port | Contract |
|
|
545
|
+
|---|---|
|
|
546
|
+
| `party` | `contains(key)` separates party channels from owner transcripts; `list()` returns `PartyView[]` with channel, profile, enabler, enabled time, container state, busy count and optional last activity. |
|
|
547
|
+
| `schedules` | `count(key)` returns the workspace schedule count. |
|
|
548
|
+
| `cleanup` | `startFresh(key)` archives/waits for active turns and returns its kind; `deleteConversation(key)` returns `deleted` or `busy`. The package validates key shape, ownership, existence and exclusions before calling either hook. |
|
|
549
|
+
| `skills` | `catalog()` supplies public `SkillView[]`; `read(name)` reads only that catalog entry and returns `{ frontmatter, body }`. The host bounds disk reads and the API shows what `read` returns, flattening metadata in source order. A skill that cannot be read answers with a fixed message; set `errorDetail: true` to append the catalog's `missing` text or the read error's message (cut at 300 characters) when the host's reasons are safe to show. |
|
|
550
|
+
| `connectors` | `gateways()`, `servers()`, `usedBy(server)` provide status/tools/usage. Optional `adminUrl` accepts HTTP(S) or an absolute local path, never script URLs. |
|
|
551
|
+
|
|
552
|
+
`sessionSummary(directory)` is a public helper for a trusted host to summarize separately stored party session files.
|
|
553
|
+
It reports live bytes, archive count and last activity without revealing message contents.
|
|
554
|
+
Generic source and defaults are English; keep application names and localized wording in the host's presentation dictionary.
|
|
555
|
+
Authenticated HTML carries inert, escaped presentation metadata so the first API failure is localized too, without inline scripts.
|
|
556
|
+
The page ships prebuilt, so application adapters do not need their own React build or a second server implementation.
|
|
557
|
+
|
|
558
|
+
```ts
|
|
559
|
+
webConsole({
|
|
560
|
+
// ...verifier, origin, ownerId...
|
|
561
|
+
mountPath: "/console",
|
|
562
|
+
routing: "path",
|
|
563
|
+
panes: ["overview", "notes"],
|
|
564
|
+
presentation: { locale: "fr", messages: { "Start over": "Recommencer" } },
|
|
565
|
+
features: (context) => ({
|
|
566
|
+
cleanup: {
|
|
567
|
+
startFresh: (key) => context.conversations.startFresh(key),
|
|
568
|
+
deleteConversation: (key) => context.conversations.deleteConversation(key),
|
|
569
|
+
},
|
|
570
|
+
}),
|
|
571
|
+
});
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
When serving Skills and Connectors, provide those hooks as well; a missing port fails startup rather than silently dropping a pane.
|
|
575
|
+
|
|
576
|
+
## App protocol
|
|
577
|
+
|
|
578
|
+
The `rooms` pane's page and any client of your own talk to the host in this protocol. A page opens one WebSocket at `<mount>/api/socket` with the subprotocol `roundtable.app.v1`. The upgrade passes the same authenticator as every request and must come from the console's own origin; the socket closes with `4401` when the credential lapses.
|
|
579
|
+
|
|
580
|
+
- Page to server: `hello {visible, focus?}`, `visibility`, `focus {room?}`, `send {id, room, text, uploads?}`, `stop {room}`, `approval {prompt, approved}`, `answer {prompt, answer}`, `answers {prompt, answers, note?}`, `cancel {prompt}`.
|
|
581
|
+
- Server to page: `ready {protocol, principal, name, uploads: {perMessage, maxBytes}}` (then every open card), `accepted`, `refused {id?, prompt?, code}` (`id` names a refused `send`, `prompt` a refused answer to a card), `message`, `progress` (only for the focused room), `activity {room, typing, stoppable, phase?, agent?, outcome?}` (`phase` is `working` during a tool call and `thinking` otherwise; `agent` names the member speaking in a group round; `outcome` is `done` or `failed` and comes only on the frame where `typing` turns false), `presence {agents: {<name>: {busy}}}` (after `ready` and when it changes; `busy` includes work in Discord channels), `edited` (a stored message changed its text, such as a thread's start line once the thread exists), `prompt`, `prompt_closed {prompt, outcome, note?, by?, answer?, closedAt?}`, `mode`, `auto_approved`, `changed`.
|
|
582
|
+
- REST under `<mount>/api/`: `session`, `rooms`, `rooms/<key>/messages?before=&limit=` (`{messages, more, cards}`: the cards of the room that opened within the page's span, from the first message's time on, or from the beginning when `more` is false, and before the first message of the next page), `PUT rooms/<key>/mode`, `POST rooms/<key>/start-over`, `POST uploads`, `DELETE uploads/<id>`, `files/<id>`, `schedules` (`GET`, and `DELETE schedules/<id>`), `agents/<name>` (`GET`, `PATCH`), `PUT agents/<name>/character` (body `{character}`, at most 4 KiB; `400 {error, field}` for a look that is not a character), `models`, and for push `GET push/key`, `GET`, `POST` and `DELETE push/subscriptions` and `DELETE push/subscriptions/<id>`. The agents' pictures are at `<mount>/avatars/<hash>.png`.
|
|
583
|
+
- Beside the API: `<mount>/manifest.webmanifest`, `<mount>/sw.js`, `<mount>/icons/<name>.png`, `<mount>/theme-init.js`, and in password mode `<mount>/login`, `POST <mount>/auth/login` and `POST <mount>/auth/logout`.
|
|
584
|
+
|
|
585
|
+
A room key from a page must be one of the app's rooms whose agent or group exists; every other key, Discord's included, is refused. Every write needs an owner, whose tier is read again, and REST writes also need the console's `Origin`. A card can be answered by any owner, except a card that is the speaker's alone, which only the speaker's principal answers. After `cardGraceSeconds` (default 120) a turn goes on without the answer and the card stays open for a day; an answer then starts a new turn in the room. The types are in `src/api-types.ts`.
|
|
586
|
+
|
|
587
|
+
## Screenshots
|
|
588
|
+
|
|
589
|
+
The images of the app are not in the repository yet; they come from the visual check of a release.
|
|
590
|
+
Each slot is a PNG in `docs/images/web/`, named `<look>-<scheme>-<device>.png`, taken on the fixture host (`bun run fixture:app`) at 390 × 844 for a phone and 1280 × 800 for a desktop:
|
|
591
|
+
|
|
592
|
+
| File | Look | Scheme | Device |
|
|
593
|
+
|---|---|---|---|
|
|
594
|
+
| `docs/images/web/modern-light-phone.png` | Modern | light | phone |
|
|
595
|
+
| `docs/images/web/modern-light-desktop.png` | Modern | light | desktop |
|
|
596
|
+
| `docs/images/web/modern-dark-phone.png` | Modern | dark | phone |
|
|
597
|
+
| `docs/images/web/modern-dark-desktop.png` | Modern | dark | desktop |
|
|
598
|
+
| `docs/images/web/camelot-light-phone.png` | Camelot | light | phone |
|
|
599
|
+
| `docs/images/web/camelot-light-desktop.png` | Camelot | light | desktop |
|
|
600
|
+
| `docs/images/web/camelot-dark-phone.png` | Camelot | dark | phone |
|
|
601
|
+
| `docs/images/web/camelot-dark-desktop.png` | Camelot | dark | desktop |
|
|
602
|
+
|
|
603
|
+
Each shows a room with an approval card open and a character at work, with the round table beside it on the desktop shots.
|
|
604
|
+
Until the files exist, this list is the only reference to them: nothing embeds an image.
|
|
605
|
+
|
|
289
606
|
## Development
|
|
290
607
|
|
|
291
608
|
This package lives in `packages/web` in the pi-roundtable workspace.
|
|
@@ -297,13 +614,19 @@ bun run --cwd packages/web build # builds the page into dist/
|
|
|
297
614
|
bun run --cwd packages/web typecheck # the server and the page
|
|
298
615
|
bun run --cwd packages/web lint
|
|
299
616
|
bun run --cwd packages/web test
|
|
300
|
-
bun run --cwd packages/web fixture # a local host with fixture data
|
|
617
|
+
bun run --cwd packages/web fixture # a local host with fixture data (the console panes)
|
|
618
|
+
bun run --cwd packages/web fixture:app # a host with scripted agents, to try the owner app
|
|
301
619
|
```
|
|
302
620
|
|
|
303
|
-
`bun run fixture` starts the plugin on pi-roundtable's test harness over fixture conversations on port 4173, behind a stand-in for the authenticating proxy that signs a test Access token, and the same listener without it on port 4174, which must answer 403.
|
|
621
|
+
`bun run fixture` starts the plugin on pi-roundtable's test harness over fixture conversations on port 4173, behind a stand-in for the authenticating proxy that signs a test Access token, and the same listener without it on port 4174, which must answer 403. `FIXTURE_AUTH=cloudflare|none|password` picks the mode it runs in, and it serves the Overview, Conversations and Notes panes only.
|
|
304
622
|
`/fixture/message` appends a message to the owner's conversation and `/fixture/work` toggles the agent between working and idle, so the live updates can be watched.
|
|
305
623
|
The fixture host has two owners, so the Notes pane shows its principal picker.
|
|
306
624
|
|
|
625
|
+
`bun run fixture:app` starts the owner app over a scratch PostgreSQL database (`ROUNDTABLE_TEST_DATABASE_URL`, whose server may create a database; the fixture makes one and drops it when it stops) with two scripted agents and a group, no Discord, and no authentication on a loopback port (4180).
|
|
626
|
+
Start a message with a keyword and the agent acts it out: `approve` (a held action with details), `ask`, `quiz` (a questionnaire), `timeout` (the time-limit card), `late` (a card the turn stops waiting on), `stream` (streamed text and tool chips), `dispatch` (a coding job in a thread) or `exchange` (the agent asks the scout in a thread).
|
|
627
|
+
`LOOK=camelot` starts the host in the Camelot look, and `FIXTURE_AGENTS` seats up to twelve agents at the round table.
|
|
628
|
+
The header of `scripts/fixture-app.ts` lists the control port that sends a message as the owner and sets a room's modes.
|
|
629
|
+
|
|
307
630
|
The page is React, bundled with Bun's bundler into relative URLs, so it works under any `mountPath`.
|
|
308
631
|
Pages use URL fragments by default (`#/notes`, `#/conversations/<key>`); `routing: "path"` uses mount-relative page paths instead.
|
|
309
632
|
|