pi-roundtable 0.7.19 → 0.8.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +44 -11
  3. package/README.zh-TW.md +44 -9
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/plugins.md +267 -23
  6. package/examples/discord-adapter.test.ts +42 -0
  7. package/examples/discord-adapter.ts +26 -0
  8. package/examples/echo-runtime.ts +1 -1
  9. package/examples/headless.test.ts +72 -0
  10. package/examples/headless.ts +31 -0
  11. package/examples/http.test.ts +31 -1
  12. package/examples/http.ts +39 -0
  13. package/package.json +3 -2
  14. package/src/cli/cli.ts +15 -4
  15. package/src/cli/doctor.ts +21 -5
  16. package/src/cli/init.ts +9 -1
  17. package/src/cli/project.ts +28 -2
  18. package/src/cli/templates.ts +46 -14
  19. package/src/core/agents/agent-team-fixture.ts +6 -0
  20. package/src/core/agents/team-turns.ts +6 -1
  21. package/src/core/builtin/agent-server.ts +30 -95
  22. package/src/core/builtin/modules.ts +133 -34
  23. package/src/core/config/adapters.ts +57 -0
  24. package/src/core/config/config.ts +174 -77
  25. package/src/core/contract/channels.ts +4 -0
  26. package/src/core/contract/providers.ts +3 -2
  27. package/src/core/contract/runtime.ts +8 -4
  28. package/src/core/contract/surface.ts +13 -0
  29. package/src/core/conversations/conversation-registry.ts +42 -0
  30. package/src/core/conversations/conversation-store.ts +113 -0
  31. package/src/core/conversations/conversations-plugin.ts +29 -0
  32. package/src/core/define-roundtable.ts +124 -54
  33. package/src/core/discord/discord-adapter.ts +9 -0
  34. package/src/core/discord/owner-cards.ts +19 -8
  35. package/src/core/domain/conversation.ts +5 -0
  36. package/src/core/domain/owner-prompts.ts +1 -1
  37. package/src/core/domain/ports.ts +6 -0
  38. package/src/core/domain/progress.ts +10 -0
  39. package/src/core/events.ts +1 -0
  40. package/src/core/host.ts +6 -3
  41. package/src/core/http/listeners.ts +174 -21
  42. package/src/core/http/websocket.ts +316 -0
  43. package/src/core/i18n/cards.ts +5 -7
  44. package/src/core/ops/error-reporter.ts +24 -12
  45. package/src/core/plugin.ts +14 -0
  46. package/src/core/registry/providers.ts +1 -1
  47. package/src/core/routing/channel-router.ts +8 -0
  48. package/src/core/routing/conversation-turns.ts +55 -1
  49. package/src/core/routing/surface-port.ts +3 -0
  50. package/src/core/runtime/extensions/confirmation-gate.ts +10 -2
  51. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  52. package/src/core/runtime/pending-confirmation-store.ts +42 -4
  53. package/src/core/runtime/pi-agent-runtime.ts +13 -6
  54. package/src/core/runtime/prompt-slot.ts +2 -2
  55. package/src/core/runtime/runtime-plugin.ts +160 -0
  56. package/src/core/runtime/turn-progress.ts +116 -0
  57. package/src/core/services.ts +15 -1
  58. package/src/core/shared/unix-server.ts +35 -5
  59. package/src/core/testing/modules.ts +47 -23
  60. package/src/core/testing/surface-contract.ts +383 -0
  61. package/src/core/testing/test-host.ts +27 -9
  62. package/src/discord/index.ts +2 -0
  63. package/src/index.ts +18 -0
  64. package/src/testing.ts +41 -5
  65. package/templates/adapters/web/.env.example +24 -0
  66. package/templates/adapters/web/README.md +15 -0
  67. package/templates/adapters/web/package.json.tmpl +26 -0
  68. package/templates/adapters/web/persona/assistant.md +2 -0
  69. package/templates/adapters/web/roundtable.config.ts +55 -0
  70. package/templates/package.json.tmpl +1 -0
  71. package/templates/plugins/self-compact.ts +11 -0
  72. package/templates/roundtable.config.ts +2 -1
@@ -1,8 +1,16 @@
1
1
  import { chmodSync } from "node:fs";
2
- import type { Server } from "bun";
2
+ import type { Server, WebSocketHandler } from "bun";
3
3
  import { PluginError } from "../errors.ts";
4
4
  import type { Logger } from "../log.ts";
5
5
  import { serveUnix } from "../shared/unix-server.ts";
6
+ import {
7
+ type Connection,
8
+ DEFAULT_MAX_BUFFERED_BYTES,
9
+ DEFAULT_MAX_MESSAGE_BYTES,
10
+ isUpgrade,
11
+ SocketSet,
12
+ type WebSocketRoute,
13
+ } from "./websocket.ts";
6
14
 
7
15
  /** A handler a plugin attaches to one of the host's configured listeners. */
8
16
  export interface HttpRoute {
@@ -13,6 +21,11 @@ export interface HttpRoute {
13
21
  /** Omitted: every method, for a handler that answers the rest itself. */
14
22
  methods?: readonly string[];
15
23
  handle(request: Request): Response | Promise<Response>;
24
+ /**
25
+ * Takes the route's WebSocket upgrade requests (GET with `Upgrade: websocket`); every other
26
+ * request still reaches `handle`.
27
+ */
28
+ websocket?: WebSocketRoute;
16
29
  }
17
30
 
18
31
  /** Where a listener serves: a unix socket, reached from outside through a tunnel, or a TCP port. */
@@ -44,6 +57,37 @@ function overlaps(a: HttpRoute, b: HttpRoute): boolean {
44
57
  return matches(a, pathOf(b)) || matches(b, pathOf(a));
45
58
  }
46
59
 
60
+ /** A non-special scheme's origin, such as `chrome-extension://<id>`, which URL reports as "null". */
61
+ const OPAQUE_ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/[^/?#@:\s]+(?::\d+)?$/;
62
+
63
+ /**
64
+ * Whether `origin` is `scheme://host[:port]` exactly as a browser sends it: a special scheme's
65
+ * origin, or a non-special one in lowercase without path, query, fragment, or user.
66
+ */
67
+ const isOrigin = (origin: string) => {
68
+ const url = URL.parse(origin);
69
+ if (!url) return false;
70
+ return url.origin === "null"
71
+ ? OPAQUE_ORIGIN.test(origin)
72
+ : url.origin === origin;
73
+ };
74
+
75
+ /** Refuses a WebSocket limit that is not a positive integer, naming the route and field. */
76
+ function validateLimits(name: string, websocket: WebSocketRoute): void {
77
+ const limits: [string, number | undefined][] = [
78
+ ["maxMessageBytes", websocket.maxMessageBytes],
79
+ ["maxBufferedBytes", websocket.maxBufferedBytes],
80
+ ["maxConnections", websocket.maxConnections],
81
+ ["rate.messages", websocket.rate?.messages],
82
+ ["rate.perMs", websocket.rate?.perMs],
83
+ ];
84
+ for (const [field, value] of limits)
85
+ if (value !== undefined && !(Number.isSafeInteger(value) && value > 0))
86
+ throw new PluginError(
87
+ `route ${name} sets websocket ${field} to ${value}, which is not a positive integer`,
88
+ );
89
+ }
90
+
47
91
  /** Refuses routes on unknown listeners, reused names, and any request two routes could both take. */
48
92
  function validate(
49
93
  listeners: readonly ListenerConfig[],
@@ -53,6 +97,18 @@ function validate(
53
97
  if (ids.size !== listeners.length)
54
98
  throw new PluginError("a listener is configured twice");
55
99
  routes.forEach((route, index) => {
100
+ if (route.websocket && route.methods && !route.methods.includes("GET"))
101
+ throw new PluginError(
102
+ `route ${route.name} takes WebSockets, so its methods must include GET`,
103
+ );
104
+ if (route.websocket) validateLimits(route.name, route.websocket);
105
+ const { origins } = route.websocket ?? {};
106
+ if (origins && origins !== "any")
107
+ for (const origin of origins)
108
+ if (!isOrigin(origin))
109
+ throw new PluginError(
110
+ `route ${route.name} lists origin ${JSON.stringify(origin)}, which is not a scheme://host[:port] origin`,
111
+ );
56
112
  if (!ids.has(route.listener))
57
113
  throw new PluginError(
58
114
  `route ${route.name} needs listener ${route.listener}, which is not configured`,
@@ -71,6 +127,16 @@ function validate(
71
127
  const serverError = () =>
72
128
  new Response("Internal Server Error", { status: 500 });
73
129
 
130
+ /** The route that takes a request, when one does. */
131
+ const routeFor = (routes: readonly HttpRoute[], request: Request) => {
132
+ // pi-lens-ignore: unchecked-throwing-call -- the server builds request.url, always an absolute URL
133
+ const path = new URL(request.url).pathname;
134
+ return routes.find(
135
+ (r) =>
136
+ matches(r, path) && (!r.methods || r.methods.includes(request.method)),
137
+ );
138
+ };
139
+
74
140
  /**
75
141
  * Routes one listener's request; a request no route takes is 404, and a route that throws, or
76
142
  * whose promise rejects, is 500 with a fixed body. The failure is logged with the route's name
@@ -82,12 +148,7 @@ export async function routeRequest(
82
148
  listener: string,
83
149
  logger: Logger,
84
150
  ): Promise<Response> {
85
- // pi-lens-ignore: unchecked-throwing-call -- the server builds request.url, always an absolute URL
86
- const path = new URL(request.url).pathname;
87
- const route = routes.find(
88
- (r) =>
89
- matches(r, path) && (!r.methods || r.methods.includes(request.method)),
90
- );
151
+ const route = routeFor(routes, request);
91
152
  if (!route) return new Response("Not found", { status: 404 });
92
153
  try {
93
154
  return await route.handle(request);
@@ -97,39 +158,87 @@ export async function routeRequest(
97
158
  }
98
159
  }
99
160
 
161
+ /** What every request on one listener is served with. */
162
+ interface ListenerContext {
163
+ listener: string;
164
+ logger: Logger;
165
+ sockets: SocketSet;
166
+ }
167
+
168
+ /**
169
+ * Routes one listener's request like `routeRequest`, except that a WebSocket upgrade for a route
170
+ * that takes them goes to its `websocket`; a failing `accept` answers and logs like a failing
171
+ * `handle`. Answers nothing once the socket is upgraded.
172
+ */
173
+ async function serveRequest(
174
+ routes: readonly HttpRoute[],
175
+ request: Request,
176
+ server: Server<Connection>,
177
+ { listener, logger, sockets }: ListenerContext,
178
+ ): Promise<Response | undefined> {
179
+ const route = routeFor(routes, request);
180
+ if (!route?.websocket || !isUpgrade(request))
181
+ return routeRequest(routes, request, listener, logger);
182
+ try {
183
+ return await sockets.upgrade(route.name, route.websocket, request, server);
184
+ } catch (err) {
185
+ logger.error({ err, route: route.name, listener }, "route failed");
186
+ return serverError();
187
+ }
188
+ }
189
+
190
+ type Fetch = (
191
+ request: Request,
192
+ server: Server<Connection>,
193
+ ) => Promise<Response | undefined>;
194
+
100
195
  /** Serves HTTP on a TCP port with the same idle setting as the unix sockets. */
101
196
  function serveTcp(
102
197
  port: number,
103
198
  hostname: string | undefined,
104
- fetch: (request: Request) => Response | Promise<Response>,
105
- ): Server<undefined> {
106
- return Bun.serve({
199
+ fetch: Fetch,
200
+ websocket: WebSocketHandler<Connection> | undefined,
201
+ ): Server<Connection> {
202
+ const options = {
107
203
  port,
108
204
  ...(hostname ? { hostname } : {}),
109
205
  idleTimeout: 0,
110
206
  fetch,
111
207
  error: serverError,
112
- });
208
+ ...(websocket ? { websocket } : {}),
209
+ };
210
+ // SAFETY: a listener without WebSocket routes passes no handler and never upgrades.
211
+ return Bun.serve(
212
+ options as Parameters<typeof Bun.serve<Connection>>[0],
213
+ ) as Server<Connection>;
113
214
  }
114
215
 
216
+ /** How long a stop waits for WebSocket routes' handlers to settle before it returns anyway. */
217
+ const CLOSE_TIMEOUT_MS = 5_000;
218
+
115
219
  /** The host's HTTP listeners, each serving only the routes attached to it. */
116
- // pi-lens-ignore: large-class — three members: validation in the constructor, start, and stop
220
+ // pi-lens-ignore: large-class — four members: validation in the constructor, start, the WebSocket handler, and stop
117
221
  export class HttpListeners {
118
222
  readonly #listeners: readonly ListenerConfig[];
119
223
  readonly #routes: readonly HttpRoute[];
120
224
  readonly #logger: Logger;
121
- #servers: Server<undefined>[] = [];
225
+ readonly #sockets: SocketSet;
226
+ readonly #closeTimeoutMs: number;
227
+ #servers: Pick<Server<unknown>, "stop">[] = [];
122
228
 
123
- /** Validates every route before any socket opens. */
229
+ /** Validates every route before any socket opens; `closeTimeoutMs` bounds how long a stop waits on WebSocket handlers. */
124
230
  constructor(
125
231
  listeners: readonly ListenerConfig[],
126
232
  routes: readonly HttpRoute[],
127
233
  logger: Logger,
234
+ options: { closeTimeoutMs?: number } = {},
128
235
  ) {
129
236
  validate(listeners, routes);
130
237
  this.#logger = logger;
131
238
  this.#listeners = listeners;
132
239
  this.#routes = routes;
240
+ this.#sockets = new SocketSet(logger);
241
+ this.#closeTimeoutMs = options.closeTimeoutMs ?? CLOSE_TIMEOUT_MS;
133
242
  }
134
243
 
135
244
  /** Opens every listener; when one fails, the ones already open close again before the error is thrown. */
@@ -139,28 +248,72 @@ export class HttpListeners {
139
248
  const routes = this.#routes.filter(
140
249
  (route) => route.listener === listener.id,
141
250
  );
142
- const handle = (request: Request) =>
143
- routeRequest(routes, request, listener.id, this.#logger);
251
+ const handle: Fetch = (request, server) =>
252
+ serveRequest(routes, request, server, {
253
+ listener: listener.id,
254
+ logger: this.#logger,
255
+ sockets: this.#sockets,
256
+ });
257
+ const websocket = this.#websocketHandler(listener.id, routes);
144
258
  if ("socketPath" in listener) {
145
259
  this.#servers.push(
146
- serveUnix(listener.socketPath, handle, { error: serverError }),
260
+ websocket
261
+ ? serveUnix(listener.socketPath, handle, {
262
+ error: serverError,
263
+ websocket,
264
+ })
265
+ : serveUnix(
266
+ listener.socketPath,
267
+ (request) =>
268
+ routeRequest(routes, request, listener.id, this.#logger),
269
+ { error: serverError },
270
+ ),
147
271
  );
148
272
  chmodSync(listener.socketPath, listener.mode ?? 0o660);
149
273
  } else {
150
274
  this.#servers.push(
151
- serveTcp(listener.port, listener.hostname, handle),
275
+ serveTcp(listener.port, listener.hostname, handle, websocket),
152
276
  );
153
277
  }
154
278
  }
155
279
  } catch (error) {
156
- this.stop();
280
+ void this.stop();
157
281
  throw error;
158
282
  }
159
283
  }
160
284
 
161
- /** Stops accepting at once and cuts open connections, such as event streams, without waiting on them. */
162
- stop(): void {
285
+ /** The listener's WebSocket handler, sized for its largest route limits; none without WebSocket routes. */
286
+ #websocketHandler(
287
+ listener: string,
288
+ routes: readonly HttpRoute[],
289
+ ): WebSocketHandler<Connection> | undefined {
290
+ const websockets = routes.flatMap((route) =>
291
+ route.websocket ? [route.websocket] : [],
292
+ );
293
+ if (websockets.length === 0) return undefined;
294
+ return this.#sockets.handler(listener, {
295
+ maxMessageBytes: Math.max(
296
+ ...websockets.map(
297
+ (w) => w.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES,
298
+ ),
299
+ ),
300
+ maxBufferedBytes: Math.max(
301
+ ...websockets.map(
302
+ (w) => w.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES,
303
+ ),
304
+ ),
305
+ });
306
+ }
307
+
308
+ /**
309
+ * Stops accepting at once and cuts open connections, such as event streams, without waiting on
310
+ * them. Open WebSockets get close code 1001 first, and the stop resolves once their routes'
311
+ * handlers have settled, or after the close timeout.
312
+ */
313
+ async stop(): Promise<void> {
314
+ this.#sockets.closeAll();
163
315
  for (const server of this.#servers) void server.stop(true);
164
316
  this.#servers = [];
317
+ await this.#sockets.drain(this.#closeTimeoutMs);
165
318
  }
166
319
  }
@@ -0,0 +1,316 @@
1
+ import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
2
+ import type { Logger } from "../log.ts";
3
+
4
+ /**
5
+ * What became of one `send`: written to the connection, `"queued"` behind a client that reads
6
+ * slower than the route sends, or `"dropped"` because the socket is closed, or because
7
+ * `maxBufferedBytes` were already waiting and the socket was cut for it.
8
+ */
9
+ export type WebSocketSendResult = "sent" | "queued" | "dropped";
10
+
11
+ /** One socket a WebSocket route accepted, with the data its `accept` gave. */
12
+ export interface RouteSocket<Data = unknown> {
13
+ readonly data: Data;
14
+ send(message: string | Uint8Array): WebSocketSendResult;
15
+ close(code?: number, reason?: string): void;
16
+ }
17
+
18
+ /** What `accept` answers: the socket's data to upgrade with, or a response that refuses it. */
19
+ export type WebSocketAccept<Data = unknown> =
20
+ | { data: Data; headers?: Record<string, string> }
21
+ | Response;
22
+
23
+ /**
24
+ * The WebSocket half of an HTTP route. An upgrade request on the route's path passes the Origin
25
+ * check, then `accept`, which authenticates it before any socket opens; other requests still
26
+ * reach the route's `handle`.
27
+ */
28
+ export interface WebSocketRoute<Data = unknown> {
29
+ /**
30
+ * The `Origin` values a browser may connect from, each as `scheme://host[:port]` exactly as the
31
+ * browser sends it, such as `https://chat.example.com` or `chrome-extension://<id>`; a request
32
+ * with another Origin, or none, is refused with 403 before `accept`. `"any"` skips the check,
33
+ * for clients that are not browsers.
34
+ */
35
+ origins: readonly string[] | "any";
36
+ /** Authenticates and authorizes the upgrade; a thrown error answers 500 like a failing `handle`. */
37
+ accept(
38
+ request: Request,
39
+ ): WebSocketAccept<Data> | Promise<WebSocketAccept<Data>>;
40
+ /**
41
+ * The largest message in bytes; a larger one closes the socket with 1009. Default 64 KiB. A
42
+ * message over twice the listener's largest limit drops the connection before it is read.
43
+ */
44
+ maxMessageBytes?: number;
45
+ /** At most `messages` per `perMs` from one socket; one more closes it with 1008. Default 120 a minute. */
46
+ rate?: { messages: number; perMs: number };
47
+ /**
48
+ * The most bytes one socket may have waiting for a client that reads slower than the route
49
+ * sends. It is checked before each `send`: a `send` that finds this many waiting returns
50
+ * `"dropped"` and cuts the socket without a close frame, so the client and the route's `close`
51
+ * both see 1006; one that finds fewer is sent whole, so the bytes waiting can reach this limit
52
+ * plus one message. Default 1 MiB.
53
+ */
54
+ maxBufferedBytes?: number;
55
+ /** The most sockets the route holds open at once; another upgrade gets 503 before `accept`. Default 256. */
56
+ maxConnections?: number;
57
+ /** What a handler returns is ignored; a promise is awaited. */
58
+ open?(socket: RouteSocket<Data>): unknown | Promise<unknown>;
59
+ /** Called per message as it arrives, without waiting for the previous call to settle. */
60
+ message(
61
+ socket: RouteSocket<Data>,
62
+ message: string | Uint8Array,
63
+ ): unknown | Promise<unknown>;
64
+ /** Called once the socket closed, also when the listener stops (code 1001); stop waits for it. */
65
+ close?(
66
+ socket: RouteSocket<Data>,
67
+ code: number,
68
+ reason: string,
69
+ ): unknown | Promise<unknown>;
70
+ }
71
+
72
+ export const DEFAULT_MAX_MESSAGE_BYTES = 64 * 1024;
73
+ export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
74
+ const DEFAULT_MAX_CONNECTIONS = 256;
75
+ const DEFAULT_RATE = { messages: 120, perMs: 60_000 };
76
+
77
+ /** The close codes RFC 6455 names for the reasons this module closes a socket. */
78
+ const GOING_AWAY = 1001;
79
+ const POLICY_VIOLATION = 1008;
80
+ const TOO_BIG = 1009;
81
+ const INTERNAL_ERROR = 1011;
82
+
83
+ /** What the server keeps on each socket: its route, the route's socket, and its rate window. */
84
+ export interface Connection {
85
+ route: string;
86
+ websocket: WebSocketRoute;
87
+ data: unknown;
88
+ socket?: RouteSocket;
89
+ windowStart: number;
90
+ count: number;
91
+ }
92
+
93
+ /** Whether the request asks to become a WebSocket: a GET with `Upgrade: websocket`. */
94
+ export const isUpgrade = (request: Request): boolean =>
95
+ request.method === "GET" &&
96
+ request.headers.get("upgrade")?.toLowerCase() === "websocket";
97
+
98
+ /** Whether an upgrade request carries the key and the version 13 that RFC 6455 requires. */
99
+ const completeHandshake = (request: Request): boolean =>
100
+ Boolean(request.headers.get("sec-websocket-key")) &&
101
+ request.headers.get("sec-websocket-version") === "13";
102
+
103
+ const originAllowed = (route: WebSocketRoute, origin: string | null) =>
104
+ route.origins === "any" ||
105
+ (origin !== null && route.origins.includes(origin));
106
+
107
+ const byteLength = (message: string | Uint8Array) =>
108
+ typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
109
+
110
+ /**
111
+ * Sends one message unless the route's `maxBufferedBytes` already wait; then, or when Bun drops
112
+ * the message, the socket is cut (1006) rather than left open with a gap in what it was sent.
113
+ * Bun answers 0 for an empty message it did send, and for a message it dropped.
114
+ */
115
+ function send(
116
+ ws: ServerWebSocket<Connection>,
117
+ message: string | Uint8Array,
118
+ ): WebSocketSendResult {
119
+ if (ws.readyState !== WebSocket.OPEN) return "dropped";
120
+ const limit =
121
+ ws.data.websocket.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
122
+ if (ws.getBufferedAmount() < limit) {
123
+ const status = ws.send(message);
124
+ if (ws.readyState !== WebSocket.OPEN) return "dropped";
125
+ if (status > 0 || (status === 0 && byteLength(message) === 0))
126
+ return "sent";
127
+ if (status === -1) return "queued";
128
+ }
129
+ ws.terminate();
130
+ return "dropped";
131
+ }
132
+
133
+ /** Resolves after `promise` settles or `ms` passes, whichever comes first. */
134
+ async function settledWithin(promise: Promise<unknown>, ms: number) {
135
+ let timer: ReturnType<typeof setTimeout> | undefined;
136
+ await Promise.race([
137
+ promise,
138
+ new Promise<void>((resolve) => {
139
+ timer = setTimeout(resolve, ms);
140
+ }),
141
+ ]);
142
+ clearTimeout(timer);
143
+ }
144
+
145
+ /**
146
+ * The open sockets of one host's listeners and the handler calls still running on them. A route
147
+ * handler that throws or rejects closes its socket with 1011 and logs the route and listener,
148
+ * never the URL.
149
+ */
150
+ export class SocketSet {
151
+ readonly #open = new Set<ServerWebSocket<Connection>>();
152
+ readonly #running = new Set<Promise<void>>();
153
+ /** Each route's open sockets plus the upgrades still in `accept`. */
154
+ readonly #held = new Map<WebSocketRoute, number>();
155
+ readonly #logger: Logger;
156
+
157
+ constructor(logger: Logger) {
158
+ this.#logger = logger;
159
+ }
160
+
161
+ /**
162
+ * Upgrades a request for a WebSocket route. A handshake without a key or version 13 is 400, a
163
+ * refused Origin 403, and a route at `maxConnections` 503, all before `accept`; the response
164
+ * `accept` returns is sent as it is, and a throwing `accept` is passed to the caller like a
165
+ * failing `handle`. Answers nothing once the socket is upgraded.
166
+ */
167
+ async upgrade(
168
+ name: string,
169
+ websocket: WebSocketRoute,
170
+ request: Request,
171
+ server: Server<Connection>,
172
+ ): Promise<Response | undefined> {
173
+ if (!completeHandshake(request))
174
+ return new Response("Bad Request", { status: 400 });
175
+ if (!originAllowed(websocket, request.headers.get("origin")))
176
+ return new Response("Forbidden", { status: 403 });
177
+ const held = this.#held.get(websocket) ?? 0;
178
+ if (held >= (websocket.maxConnections ?? DEFAULT_MAX_CONNECTIONS))
179
+ return new Response("Service Unavailable", { status: 503 });
180
+ // The place is taken while `accept` runs, so concurrent upgrades cannot overshoot it.
181
+ this.#held.set(websocket, held + 1);
182
+ let upgraded = false;
183
+ try {
184
+ const accepted = await websocket.accept(request);
185
+ if (accepted instanceof Response) return accepted;
186
+ const connection: Connection = {
187
+ route: name,
188
+ websocket,
189
+ data: accepted.data,
190
+ windowStart: Date.now(),
191
+ count: 0,
192
+ };
193
+ // Bun runs `open` inside `upgrade`, and `close` releases the place from then on.
194
+ upgraded = server.upgrade(request, {
195
+ data: connection,
196
+ ...(accepted.headers ? { headers: accepted.headers } : {}),
197
+ });
198
+ return upgraded
199
+ ? undefined
200
+ : new Response("Bad Request", { status: 400 });
201
+ } finally {
202
+ if (!upgraded) this.#release(websocket);
203
+ }
204
+ }
205
+
206
+ #release(websocket: WebSocketRoute): void {
207
+ const held = (this.#held.get(websocket) ?? 1) - 1;
208
+ if (held > 0) this.#held.set(websocket, held);
209
+ else this.#held.delete(websocket);
210
+ }
211
+
212
+ /**
213
+ * The Bun handler for one listener's sockets, given its largest route limits. Bun drops a frame
214
+ * over twice the message limit unread; up to there each route's own limit closes with 1009.
215
+ * Bun drops a message only when the largest buffered limit already waits, which each route's
216
+ * `send` checks first at its own limit, so a single message larger than any limit still goes out.
217
+ */
218
+ handler(
219
+ listener: string,
220
+ limits: { maxMessageBytes: number; maxBufferedBytes: number },
221
+ ): WebSocketHandler<Connection> {
222
+ return {
223
+ maxPayloadLength: limits.maxMessageBytes * 2,
224
+ backpressureLimit: limits.maxBufferedBytes,
225
+ open: (ws) => {
226
+ this.#open.add(ws);
227
+ const connection = ws.data;
228
+ connection.socket = {
229
+ data: connection.data,
230
+ send: (message) => send(ws, message),
231
+ close: (code, reason) => ws.close(code, reason),
232
+ };
233
+ const { open } = connection.websocket;
234
+ if (open) this.#run(ws, listener, (socket) => open(socket));
235
+ },
236
+ message: (ws, message) => {
237
+ const connection = ws.data;
238
+ const { websocket } = connection;
239
+ const limit = websocket.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
240
+ if (byteLength(message) > limit) {
241
+ ws.close(TOO_BIG, "message too big");
242
+ return;
243
+ }
244
+ const rate = websocket.rate ?? DEFAULT_RATE;
245
+ const now = Date.now();
246
+ if (now - connection.windowStart >= rate.perMs) {
247
+ connection.windowStart = now;
248
+ connection.count = 0;
249
+ }
250
+ connection.count += 1;
251
+ if (connection.count > rate.messages) {
252
+ ws.close(POLICY_VIOLATION, "too many messages");
253
+ return;
254
+ }
255
+ this.#run(ws, listener, (socket) => websocket.message(socket, message));
256
+ },
257
+ close: (ws, code, reason) => {
258
+ this.#open.delete(ws);
259
+ this.#release(ws.data.websocket);
260
+ const { close } = ws.data.websocket;
261
+ if (close)
262
+ this.#run(ws, listener, (socket) => close(socket, code, reason));
263
+ },
264
+ };
265
+ }
266
+
267
+ /** Runs one route handler, tracked until it settles; a failure closes the socket. */
268
+ #run(
269
+ ws: ServerWebSocket<Connection>,
270
+ listener: string,
271
+ call: (socket: RouteSocket) => unknown | Promise<unknown>,
272
+ ): Promise<void> {
273
+ const connection = ws.data;
274
+ const running = (async () => {
275
+ try {
276
+ if (connection.socket) await call(connection.socket);
277
+ } catch (err) {
278
+ this.#logger.error(
279
+ { err, route: connection.route, listener },
280
+ "websocket handler failed",
281
+ );
282
+ ws.close(INTERNAL_ERROR, "internal error");
283
+ }
284
+ })();
285
+ this.#running.add(running);
286
+ void running.finally(() => this.#running.delete(running));
287
+ return running;
288
+ }
289
+
290
+ /** Closes every open socket with 1001, so each route's `close` runs. */
291
+ closeAll(): void {
292
+ for (const ws of this.#open) ws.close(GOING_AWAY, "server stopping");
293
+ }
294
+
295
+ /**
296
+ * Waits, at most `timeoutMs`, until every socket has closed and every handler call has settled;
297
+ * a close the server reports later, or a call it starts meanwhile, is waited for too.
298
+ */
299
+ async drain(timeoutMs: number): Promise<void> {
300
+ const deadline = Date.now() + timeoutMs;
301
+ while (this.#open.size > 0 || this.#running.size > 0) {
302
+ const left = deadline - Date.now();
303
+ if (left <= 0) {
304
+ this.#logger.warn(
305
+ { open: this.#open.size, running: this.#running.size },
306
+ "websocket handlers still running after the close timeout",
307
+ );
308
+ return;
309
+ }
310
+ await settledWithin(
311
+ Promise.allSettled([...this.#running]),
312
+ Math.min(left, 10),
313
+ );
314
+ }
315
+ }
316
+ }
@@ -11,10 +11,9 @@ export function cardsEn(ctx: CatalogContext) {
11
11
  cardStopped: "⏹️ This round was stopped.",
12
12
  cardExpired: "⌛ Timed out with no answer.",
13
13
  cardOwnerOnly: `Only ${ctx.assistant}'s owner can answer this card.`,
14
- cardApproversNote: (minTier: string) =>
15
- `-# Who can approve: ${minTier} and above`,
16
- cardApproversRefusal: (minTier: string) =>
17
- `This card needs ${minTier} or above to answer.`,
14
+ cardApproversNote: (userId: string) => `-# Who can approve: <@${userId}>`,
15
+ cardApproversRefusal:
16
+ "This approval is for the speaker whose turn asked for it.",
18
17
  cardAskerNote: (userId: string) => `-# Who can answer: <@${userId}>`,
19
18
  cardAskerRefusal: "This question is for another speaker.",
20
19
  cardInactive: `This card is no longer active (it timed out, or ${ctx.assistant} restarted); reply in text if you need to.`,
@@ -42,9 +41,8 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
42
41
  cardStopped: "⏹️ 這一輪已停止。",
43
42
  cardExpired: "⌛ 已逾時,沒有回答。",
44
43
  cardOwnerOnly: `只有 ${ctx.assistant} 的擁有者能回答這張卡片。`,
45
- cardApproversNote: (minTier: string) => `-# 可以核准的人:${minTier} 以上`,
46
- cardApproversRefusal: (minTier: string) =>
47
- `這張卡片要 ${minTier} 以上才能回答。`,
44
+ cardApproversNote: (userId: string) => `-# 可以核准的人:<@${userId}>`,
45
+ cardApproversRefusal: "這張核准卡是給提出這一輪的發話者的。",
48
46
  cardAskerNote: (userId: string) => `-# 可以回答的人:<@${userId}>`,
49
47
  cardAskerRefusal: "這張問題是問另一位發話者的。",
50
48
  cardInactive: `這張卡片已經失效(逾時,或 ${ctx.assistant} 重新啟動過);需要的話直接打字回覆。`,