pi-roundtable-web 0.25.0 → 0.26.1

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 (243) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +165 -12
  3. package/dist/account-zk4efb87.css +102 -0
  4. package/dist/chunk-account-ghq105t4.js +1 -0
  5. package/dist/chunk-action-menu-me0g9qsc.js +1 -0
  6. package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-rdv7ekrt.js} +1 -1
  7. package/dist/chunk-arranged-rooms-81rqhx9x.js +1 -0
  8. package/dist/chunk-character-creator-bd426yjw.js +1 -0
  9. package/dist/chunk-codeeditor-ajhgd33t.js +2 -0
  10. package/dist/chunk-confirm-p3mts8mx.js +1 -0
  11. package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-bkv3xn83.js} +1 -1
  12. package/dist/chunk-conversations-0e0vbxwr.js +1 -0
  13. package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-peyb8t0k.js} +1 -1
  14. package/dist/{chunk-entry-he1embgp.js → chunk-entry-mv7q8br0.js} +1 -1
  15. package/dist/chunk-folder-list-era3tksh.js +1 -0
  16. package/dist/chunk-folds-hvws0bd5.js +1 -0
  17. package/dist/chunk-gate-hc7dd0fx.js +1 -0
  18. package/dist/chunk-group-scene-dkbpmbh5.js +1 -0
  19. package/dist/chunk-group-table-fqmxgdfw.js +1 -0
  20. package/dist/chunk-host-icons-4xde9ntk.js +1 -0
  21. package/dist/chunk-host-picture-014yzk20.js +1 -0
  22. package/dist/chunk-host-state-t56e5sdy.js +1 -0
  23. package/dist/chunk-host-ui-p1zy9nmp.js +1 -0
  24. package/dist/chunk-index-0fmmvgee.js +2 -0
  25. package/dist/chunk-index-0q15ysrb.js +1 -0
  26. package/dist/chunk-index-0xh24y25.js +2 -0
  27. package/dist/chunk-index-2yebd9tb.js +426 -0
  28. package/dist/chunk-index-4t0r3t3b.js +2 -0
  29. package/dist/chunk-index-5yzfrf7b.js +138 -0
  30. package/dist/{chunk-index-d5abvq7v.js → chunk-index-6twb7ccd.js} +2 -2
  31. package/dist/chunk-index-73gh7kgr.js +920 -0
  32. package/dist/chunk-index-7xffskfa.js +8 -0
  33. package/dist/chunk-index-aqw7cy5j.js +1 -0
  34. package/dist/chunk-index-b6ca02e8.js +2 -0
  35. package/dist/chunk-index-b8nvhcx5.js +2 -0
  36. package/dist/{chunk-index-bvfqjh77.js → chunk-index-cvbdf2ca.js} +2 -2
  37. package/dist/chunk-index-czdketze.js +2 -0
  38. package/dist/{chunk-index-9jqcskb4.js → chunk-index-dt9775ka.js} +5 -5
  39. package/dist/chunk-index-eg55mqqe.js +1 -0
  40. package/dist/chunk-index-gr6e0hse.js +2 -0
  41. package/dist/chunk-index-gyary7np.js +13 -0
  42. package/dist/chunk-index-j214tczc.js +1 -0
  43. package/dist/chunk-index-jd6632ef.js +2 -0
  44. package/dist/chunk-index-jw7dtshr.js +2 -0
  45. package/dist/{chunk-index-krtk6nn2.js → chunk-index-k7t14rwa.js} +2 -2
  46. package/dist/chunk-index-kwcx62wb.js +4 -0
  47. package/dist/chunk-index-m7fvw92f.js +2 -0
  48. package/dist/chunk-index-mmg788a1.js +2 -0
  49. package/dist/chunk-index-mq0dacwr.js +2 -0
  50. package/dist/chunk-index-p53ak25v.js +2 -0
  51. package/dist/{chunk-index-27qca4mr.js → chunk-index-pk1yxkv4.js} +2 -2
  52. package/dist/{chunk-index-9b1kt7qw.css → chunk-index-rbcz9jd4.css} +1 -1
  53. package/dist/{chunk-index-s681nny9.js → chunk-index-rmxbdmn2.js} +2 -2
  54. package/dist/chunk-index-rnxj3z8k.js +2 -0
  55. package/dist/chunk-index-snzzfhk3.js +2 -0
  56. package/dist/chunk-index-v3bmr0nw.js +2 -0
  57. package/dist/chunk-index-vcyzwtk2.js +2 -0
  58. package/dist/{chunk-index-zfd00hwb.js → chunk-index-w2j3njb6.js} +2 -2
  59. package/dist/chunk-index-w31c4yjv.js +2 -0
  60. package/dist/chunk-index-wrm8w165.js +2 -0
  61. package/dist/chunk-index-xh0mg9cw.js +2 -0
  62. package/dist/chunk-index-ybtvvx85.js +2 -0
  63. package/dist/chunk-index-yzsgsvnp.js +2 -0
  64. package/dist/{chunk-index-18gxy79n.js → chunk-index-z9x4cnzf.js} +2 -2
  65. package/dist/chunk-index-zrvg23jd.js +2 -0
  66. package/dist/chunk-index-zynq0b0w.js +50 -0
  67. package/dist/chunk-lang-css-88v3hp3d.js +1 -0
  68. package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-k728ewsf.js} +1 -1
  69. package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-wjwgay4v.js} +3 -3
  70. package/dist/chunk-lang-html-07d47es4.js +1 -0
  71. package/dist/chunk-lang-javascript-fmhhgzwd.js +1 -0
  72. package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-7gd89mvs.js} +1 -1
  73. package/dist/chunk-lang-markdown-b6qbe8bt.js +6 -0
  74. package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-3v56y31b.js} +2 -2
  75. package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-kh0z74d1.js} +1 -1
  76. package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-9zv0zvbx.js} +1 -1
  77. package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-yw6bf3t7.js} +2 -2
  78. package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-tg1r96nq.js} +1 -1
  79. package/dist/chunk-lang-typescript-etjjvr2g.js +1 -0
  80. package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-ev1mndd4.js} +1 -1
  81. package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-fz5qpb3r.js} +1 -1
  82. package/dist/chunk-layout-actions-48ecgtkg.js +1 -0
  83. package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1epy3h0t.js} +2 -2
  84. package/dist/chunk-mode-toggle-fxmfxsa4.js +1 -0
  85. package/dist/chunk-note-row-kd0fy7ee.js +75 -0
  86. package/dist/chunk-notes-yb8f057c.js +1 -0
  87. package/dist/chunk-notice-bell-wr4f1rph.js +1 -0
  88. package/dist/chunk-notices-x07da5bb.js +1 -0
  89. package/dist/chunk-onboarding-csrkbh9z.js +1 -0
  90. package/dist/chunk-overview-avc2d30t.js +1 -0
  91. package/dist/chunk-palette-9nhp95bz.js +1 -0
  92. package/dist/chunk-placement-items-q694dpgk.js +1 -0
  93. package/dist/chunk-profile-6qemf3m7.js +1 -0
  94. package/dist/chunk-prompt-history-7enzp2p3.js +1 -0
  95. package/dist/chunk-prompts-nv6mrn0q.js +1 -0
  96. package/dist/chunk-room-confirms-zz7n61y7.js +1 -0
  97. package/dist/chunk-room-menu-rr6nzap1.js +1 -0
  98. package/dist/chunk-rooms-menu-button-zbtwj47d.js +1 -0
  99. package/dist/chunk-schedules-rw57nr0m.js +1 -0
  100. package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-ys5v9abz.js} +1 -1
  101. package/dist/chunk-sheet-hosts-jhvaacst.js +1 -0
  102. package/dist/chunk-skill-editor-sz8an8qr.js +8 -0
  103. package/dist/chunk-skills-tnr1h16h.js +1 -0
  104. package/dist/chunk-switcher-list-17enhrse.js +1 -0
  105. package/dist/chunk-tab-dot-r6brdbmw.js +1 -0
  106. package/dist/chunk-table-r12cja6p.js +1 -0
  107. package/dist/chunk-table-seats-g3t392zz.js +1 -0
  108. package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-p3ezsdcj.js} +1 -1
  109. package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-4x2n2b00.js} +23 -23
  110. package/dist/chunk-thread-actions-qc6ezkha.js +1 -0
  111. package/dist/chunk-transcript-n2s6dher.js +1 -0
  112. package/dist/chunk-where-f3scdydc.js +1 -0
  113. package/dist/chunk-workspace-xs023bxm.js +1 -0
  114. package/dist/chunk-zh-tw-efvmk3fd.js +1 -0
  115. package/dist/index.html +1 -1
  116. package/dist/sw.js +1 -1
  117. package/package.json +5 -4
  118. package/src/api-types.ts +356 -4
  119. package/src/app/app.ts +201 -32
  120. package/src/app/desk.ts +47 -22
  121. package/src/app/files.ts +73 -7
  122. package/src/app/presence.ts +2 -2
  123. package/src/app/protocol.ts +63 -1
  124. package/src/app/room-log.ts +153 -14
  125. package/src/app/rooms.ts +39 -0
  126. package/src/app/routes.ts +168 -28
  127. package/src/app/socket.ts +349 -81
  128. package/src/app/sockets.ts +108 -20
  129. package/src/app/surface.ts +72 -36
  130. package/src/app/threads.ts +31 -2
  131. package/src/app/uploads.ts +33 -7
  132. package/src/auth/authenticator.ts +77 -8
  133. package/src/auth/login-page.ts +4 -2
  134. package/src/auth/next.ts +52 -0
  135. package/src/auth/oidc-pages.ts +62 -0
  136. package/src/auth/oidc.ts +649 -0
  137. package/src/auth/sessions.ts +193 -0
  138. package/src/authz.ts +173 -0
  139. package/src/console-api.ts +122 -25
  140. package/src/console-server.ts +102 -12
  141. package/src/gate.ts +90 -0
  142. package/src/host/access.ts +161 -0
  143. package/src/host/cards.ts +112 -0
  144. package/src/host/context.ts +188 -0
  145. package/src/host/forms.ts +240 -0
  146. package/src/host/frames.ts +349 -0
  147. package/src/host/host-app.ts +178 -0
  148. package/src/host/host-context.ts +49 -0
  149. package/src/host/host-routes.ts +396 -0
  150. package/src/host/pages.ts +266 -0
  151. package/src/host/reads.ts +155 -0
  152. package/src/host/retention.ts +41 -0
  153. package/src/host/room-views.ts +137 -0
  154. package/src/host/rooms-service.ts +429 -0
  155. package/src/host/stored-json.ts +13 -0
  156. package/src/host/views.ts +106 -0
  157. package/src/i18n/zh-tw.ts +139 -0
  158. package/src/index.ts +15 -1
  159. package/src/notices/api.ts +43 -0
  160. package/src/notices/mount.ts +292 -0
  161. package/src/notices/service.ts +294 -0
  162. package/src/notices/store.ts +298 -0
  163. package/src/notices/urls.ts +38 -0
  164. package/src/onboarding/api.ts +32 -0
  165. package/src/onboarding/service.ts +149 -0
  166. package/src/onboarding/steps.ts +75 -0
  167. package/src/onboarding/waivers.ts +40 -0
  168. package/src/options.ts +534 -34
  169. package/src/page-presentation.ts +47 -2
  170. package/src/profile/api.ts +175 -0
  171. package/src/profile/links.ts +162 -0
  172. package/src/push/api.ts +189 -52
  173. package/src/push/dispatch.ts +156 -0
  174. package/src/push/drain.ts +139 -0
  175. package/src/push/outbox.ts +218 -0
  176. package/src/push/prefs.ts +118 -0
  177. package/src/push/sender.ts +7 -2
  178. package/src/push/triggers.ts +56 -5
  179. package/src/push/vapid.ts +19 -0
  180. package/src/push/web-push-transport.ts +106 -0
  181. package/src/push-sender/index.ts +14 -0
  182. package/src/push-sender/run.ts +118 -0
  183. package/src/push-sender/session.ts +54 -0
  184. package/src/pwa.ts +32 -4
  185. package/src/visitors.ts +94 -6
  186. package/src/web-app.ts +718 -0
  187. package/src/web-plugin.ts +279 -26
  188. package/dist/chunk-action-menu-qe839g4b.js +0 -1
  189. package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
  190. package/dist/chunk-character-creator-6yf29xwx.js +0 -1
  191. package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
  192. package/dist/chunk-confirm-xrw7d732.js +0 -1
  193. package/dist/chunk-conversations-ry228aes.js +0 -1
  194. package/dist/chunk-folder-list-5cp8stna.js +0 -1
  195. package/dist/chunk-folds-hff104tk.js +0 -1
  196. package/dist/chunk-group-scene-h228jdat.js +0 -1
  197. package/dist/chunk-index-0p5h2kdz.js +0 -2
  198. package/dist/chunk-index-0pt08yd9.js +0 -1
  199. package/dist/chunk-index-12bw9yj8.js +0 -2
  200. package/dist/chunk-index-2ad17eh4.js +0 -2
  201. package/dist/chunk-index-31wh5twj.js +0 -52
  202. package/dist/chunk-index-4dy94pbz.js +0 -2
  203. package/dist/chunk-index-4ecjtdy8.js +0 -13
  204. package/dist/chunk-index-4v1b7ks9.js +0 -2
  205. package/dist/chunk-index-5d8yjj4m.js +0 -2
  206. package/dist/chunk-index-89ajmxh7.js +0 -2
  207. package/dist/chunk-index-b9ny4623.js +0 -2
  208. package/dist/chunk-index-e1dr6g10.js +0 -2
  209. package/dist/chunk-index-gpkzhq1g.js +0 -2
  210. package/dist/chunk-index-hk3xyy20.js +0 -2
  211. package/dist/chunk-index-jm23vn03.js +0 -4
  212. package/dist/chunk-index-k7gh0jvz.js +0 -2
  213. package/dist/chunk-index-ky42ev0b.js +0 -2
  214. package/dist/chunk-index-pa2yyqh0.js +0 -2
  215. package/dist/chunk-index-pcvt668q.js +0 -2
  216. package/dist/chunk-index-pqw27eye.js +0 -1
  217. package/dist/chunk-index-wmbmmrd4.js +0 -302
  218. package/dist/chunk-index-ymbjrsp6.js +0 -2
  219. package/dist/chunk-lang-css-t85r8rz0.js +0 -1
  220. package/dist/chunk-lang-html-n6jparge.js +0 -1
  221. package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
  222. package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
  223. package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
  224. package/dist/chunk-layout-actions-bv0chj41.js +0 -1
  225. package/dist/chunk-notes-9b7pgqfe.js +0 -1
  226. package/dist/chunk-overview-8yw71esz.js +0 -1
  227. package/dist/chunk-palette-frms7axe.js +0 -1
  228. package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
  229. package/dist/chunk-prompt-history-yrqehads.js +0 -1
  230. package/dist/chunk-prompts-g6609tqx.js +0 -1
  231. package/dist/chunk-room-confirms-0a2y0925.js +0 -1
  232. package/dist/chunk-room-menu-g11q97br.js +0 -1
  233. package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
  234. package/dist/chunk-schedules-ezmacrbj.js +0 -1
  235. package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
  236. package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
  237. package/dist/chunk-skills-gqcfhq56.js +0 -1
  238. package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
  239. package/dist/chunk-table-s8cde1xz.js +0 -1
  240. package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
  241. package/dist/chunk-transcript-c59c6mpm.js +0 -1
  242. package/dist/chunk-workspace-0cyspkk4.js +0 -1
  243. package/dist/chunk-zh-tw-nc93jm10.js +0 -1
@@ -1,32 +1,79 @@
1
- import type { ActorFacts, Logger, RouteSocket } from "pi-roundtable";
1
+ import type { ActorFacts, Logger, RouteSocket, Tier } from "pi-roundtable";
2
2
  import type { ServerFrame } from "../api-types.ts";
3
3
 
4
- /** One open WebSocket of an owner: who, what the page shows, and when its credential lapses. */
4
+ /** One open WebSocket: who, what the page shows, and when its credential lapses. */
5
5
  export interface AppSocket {
6
6
  principal: { id: string; name: string };
7
7
  /** The actor the credential vouched for, which the person's messages are written as. */
8
8
  actor: ActorFacts | undefined;
9
9
  expiresAt: Date | undefined;
10
+ /** The tier they hold, as of the upgrade or the latest frame or recheck. */
11
+ tier: Tier;
12
+ /** The identity they spoke as; the principal's id when the credential names none. */
13
+ speakerId: string;
14
+ /** The roles their sign-in carried. */
15
+ roles: readonly string[];
16
+ /** A required onboarding step stands undone: only the frames a gated socket may have reach it. */
17
+ gated: boolean;
18
+ /** The session the socket came in on, for the recheck that asks whether it still stands. */
19
+ session?: string | undefined;
10
20
  /** The page is in view; a push goes out only when no socket is. */
11
21
  visible: boolean;
12
22
  /** The room the page shows, whose progress streams to it. */
13
23
  focus: string | undefined;
24
+ /** The host rooms the socket follows. */
25
+ subscriptions: Set<string>;
26
+ /** Set once `ready` and the replay went out: until then nothing but a frame addressed to the socket reaches it. */
27
+ started?: boolean;
14
28
  socket?: RouteSocket<AppSocket>;
15
29
  /** Closes the socket when its credential lapses or the cap on its life is reached. */
16
30
  expiry?: ReturnType<typeof setTimeout>;
17
- /** Looks again at whether the person is still an owner. */
31
+ /** Looks again at whether the person is still admitted, and at which tier. */
18
32
  recheck?: ReturnType<typeof setInterval>;
19
33
  }
20
34
 
21
- /** The open sockets. Every one belongs to an owner, so a frame for the owners goes to them all. */
35
+ /**
36
+ * Who a frame is for. There is no unaddressed send: every frame names its audience, and the
37
+ * open-time replay goes through the same filter as a live frame.
38
+ */
39
+ export type Audience =
40
+ /** Sockets whose tier is owner now; with `focus`, those showing that room. */
41
+ | { owners: true; focus?: string }
42
+ /** Every socket of one person. */
43
+ | { principal: string }
44
+ /** The sockets that follow a host room and may read it now; with `focus`, those also showing it. */
45
+ | { room: string; focus?: true }
46
+ /** One socket; with `audience`, only if it is also in that audience (the replay on open). */
47
+ | { connection: AppSocket; audience?: Audience };
48
+
49
+ /** The frames a gated socket still gets: the gate itself, the person's notices and badge, and refusals. */
50
+ const GATED_FRAMES: ReadonlySet<ServerFrame["type"]> = new Set([
51
+ "ready",
52
+ "refused",
53
+ "gate",
54
+ "notice",
55
+ "notice_resolved",
56
+ "badge",
57
+ ]);
58
+
59
+ /** Whether a socket may read a host room now; answered from a memo, so it costs no lookup. */
60
+ export type RoomReader = (connection: AppSocket, room: string) => boolean;
61
+
62
+ /** The open sockets, and the one way to put a frame on them. */
22
63
  export class Sockets {
23
64
  readonly #open = new Set<AppSocket>();
24
65
  readonly #logger: Logger;
66
+ #reader: RoomReader = () => false;
25
67
 
26
68
  constructor(logger: Logger) {
27
69
  this.#logger = logger;
28
70
  }
29
71
 
72
+ /** Sets who may read which host room; until it is set, no host room frame reaches anyone. */
73
+ setRoomReader(reader: RoomReader): void {
74
+ this.#reader = reader;
75
+ }
76
+
30
77
  add(connection: AppSocket, socket: RouteSocket<AppSocket>): void {
31
78
  connection.socket = socket;
32
79
  this.#open.add(connection);
@@ -40,8 +87,42 @@ export class Sockets {
40
87
  this.#open.delete(connection);
41
88
  }
42
89
 
43
- /** Sends a frame on one socket; a dropped send is logged, never thrown. */
44
- send(connection: AppSocket, frame: ServerFrame): void {
90
+ /** Whether the connection is in the audience now. */
91
+ reaches(connection: AppSocket, audience: Audience): boolean {
92
+ if ("connection" in audience)
93
+ return (
94
+ audience.connection === connection &&
95
+ (!audience.audience || this.reaches(connection, audience.audience))
96
+ );
97
+ if (!connection.started) return false;
98
+ if ("owners" in audience)
99
+ return (
100
+ connection.tier === "owner" &&
101
+ (audience.focus === undefined || connection.focus === audience.focus)
102
+ );
103
+ if ("principal" in audience)
104
+ return connection.principal.id === audience.principal;
105
+ return (
106
+ connection.subscriptions.has(audience.room) &&
107
+ this.#reader(connection, audience.room) &&
108
+ (!audience.focus || connection.focus === audience.room)
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Puts a frame on the sockets in `audience`, except `except`. A gated socket gets only what the
114
+ * gate lets through. A dropped send is logged, never thrown.
115
+ */
116
+ deliver(frame: ServerFrame, audience: Audience, except?: AppSocket): void {
117
+ for (const connection of this.#open) {
118
+ if (connection === except || !this.reaches(connection, audience))
119
+ continue;
120
+ if (connection.gated && !GATED_FRAMES.has(frame.type)) continue;
121
+ this.#send(connection, frame);
122
+ }
123
+ }
124
+
125
+ #send(connection: AppSocket, frame: ServerFrame): void {
45
126
  const result = connection.socket?.send(JSON.stringify(frame));
46
127
  if (result === "dropped")
47
128
  this.#logger.warn(
@@ -50,18 +131,7 @@ export class Sockets {
50
131
  );
51
132
  }
52
133
 
53
- /** Sends a frame to every open socket, or those `where` accepts, except `except`. */
54
- broadcast(
55
- frame: ServerFrame,
56
- where?: (connection: AppSocket) => boolean,
57
- except?: AppSocket,
58
- ): void {
59
- for (const connection of this.#open)
60
- if (connection !== except && (!where || where(connection)))
61
- this.send(connection, frame);
62
- }
63
-
64
- /** Whether a page of this person is in view now; of any owner when no one is named. */
134
+ /** Whether a page of this person is in view now; of any person when none is named. */
65
135
  anyVisible(principalId?: string): boolean {
66
136
  for (const connection of this.#open)
67
137
  if (
@@ -72,10 +142,28 @@ export class Sockets {
72
142
  return false;
73
143
  }
74
144
 
145
+ /** Calls `visit` on each open socket `where` accepts. */
146
+ each(
147
+ where: (connection: AppSocket) => boolean,
148
+ visit: (connection: AppSocket) => void,
149
+ ): void {
150
+ for (const connection of [...this.#open])
151
+ if (where(connection)) visit(connection);
152
+ }
153
+
154
+ /** Closes the sockets `where` accepts, such as those of a session that ended. */
155
+ closeWhere(
156
+ where: (connection: AppSocket) => boolean,
157
+ code: number,
158
+ reason: string,
159
+ ): void {
160
+ for (const connection of [...this.#open])
161
+ if (where(connection)) connection.socket?.close(code, reason);
162
+ }
163
+
75
164
  /** Closes every socket, when the app stops. */
76
165
  closeAll(code: number, reason: string): void {
77
- for (const connection of [...this.#open])
78
- connection.socket?.close(code, reason);
166
+ this.closeWhere(() => true, code, reason);
79
167
  }
80
168
 
81
169
  get size(): number {
@@ -16,9 +16,9 @@ import {
16
16
  } from "pi-roundtable";
17
17
  import type { RoomFile, RoomMessage } from "../api-types.ts";
18
18
  import type { OwnerDesk } from "./desk.ts";
19
- import { type FileDir, typeByName } from "./files.ts";
19
+ import { type FileBinding, type FileDir, typeByName } from "./files.ts";
20
20
  import type { NewRoomMessage, RoomLog } from "./room-log.ts";
21
- import type { AppSocket, Sockets } from "./sockets.ts";
21
+ import type { AppSocket, Audience, Sockets } from "./sockets.ts";
22
22
  import { AppSurfaceThreads } from "./surface-threads.ts";
23
23
  import type { WebThreads } from "./threads.ts";
24
24
  import type { Uploads } from "./uploads.ts";
@@ -45,6 +45,12 @@ export interface AppSurfaceOptions {
45
45
  threads: Pick<WebThreads, "open" | "close">;
46
46
  /** Whether a room can host a thread: an agent's or group's room that exists. */
47
47
  canHost(parent: ChannelKey): Promise<boolean>;
48
+ /** Who has a room's frames: the owners' sockets for an agent room, a host room's readers for a host room. */
49
+ audienceOf(room: string): Audience;
50
+ /** Whether the room is a host's (its frames are the `host_*` ones). */
51
+ isHostRoom(room: string): boolean;
52
+ /** What binds a file an agent attaches in a host room, so its readers are served it. */
53
+ fileBinding?(room: string): FileBinding | undefined;
48
54
  /** Tells the sockets the room list differs, once changes settle. */
49
55
  changed(): void;
50
56
  /**
@@ -54,6 +60,8 @@ export interface AppSurfaceOptions {
54
60
  unprompted?(room: ChannelKey, author: string, text: string): void;
55
61
  /** Called when a room's activity changes, so the presence table can follow. */
56
62
  activity?(): void;
63
+ /** What was said in a host room since the assistant last posted; the host rooms' reader. */
64
+ channelContext?: ChatSurface["channelContext"];
57
65
  /** The clock, in milliseconds; default `Date.now`. */
58
66
  now?: () => number;
59
67
  logger: Logger;
@@ -88,8 +96,8 @@ export interface RoomActivity {
88
96
  }
89
97
 
90
98
  /**
91
- * The owner app's rooms as a chat surface: what the host shows in a room reaches every socket of
92
- * every owner as a frame, and what a room says is stored in the room log. The agent team runs
99
+ * The owner app's rooms as a chat surface: what the host shows in a room reaches the sockets that
100
+ * may have it (the owners' for an agent room) as a frame, and what a room says is stored in the room log. The agent team runs
93
101
  * in the rooms (`agentRooms`) as it runs in its Discord channels, but each room is a
94
102
  * conversation of its own. A reply arrives whole, in Markdown, with its files stored, and an
95
103
  * agent's interim text is shown live until the final reply replaces it.
@@ -101,6 +109,8 @@ export class AppSurface implements ChatSurface {
101
109
  readonly formatting = APP_FORMATTING;
102
110
  /** The turn takes an upload up to the most the app lets a person upload. */
103
111
  readonly maxAttachmentBytes: number;
112
+ /** Present on a surface with host rooms: what was said in one since the assistant last posted. */
113
+ channelContext?: ChatSurface["channelContext"];
104
114
  readonly threads: AppSurfaceThreads;
105
115
  readonly #options: AppSurfaceOptions;
106
116
  readonly #activity = new Map<ChannelKey, Activity>();
@@ -114,6 +124,7 @@ export class AppSurface implements ChatSurface {
114
124
  this.surface = options.surface;
115
125
  this.maxAttachmentBytes = options.uploads.maxBytes;
116
126
  this.#options = options;
127
+ if (options.channelContext) this.channelContext = options.channelContext;
117
128
  this.threads = new AppSurfaceThreads({
118
129
  surface: options.surface,
119
130
  threads: options.threads,
@@ -147,26 +158,29 @@ export class AppSurface implements ChatSurface {
147
158
  return this.#deliver !== undefined;
148
159
  }
149
160
 
150
- /** Stores a message of a room and shows it to every open socket, except `except`. */
161
+ /** Stores a message of a room and shows it to the sockets that may have the room, except `except`. */
151
162
  async publish(row: NewRoomMessage, except?: AppSocket): Promise<RoomMessage> {
152
163
  const message = await this.#options.log.append(row);
153
- this.#options.sockets.broadcast(
154
- { type: "message", room: row.room, message },
155
- undefined,
164
+ this.#options.sockets.deliver(
165
+ this.#options.isHostRoom(row.room)
166
+ ? { type: "host_message", room: row.room, message }
167
+ : { type: "message", room: row.room, message },
168
+ this.#options.audienceOf(row.room),
156
169
  except,
157
170
  );
158
171
  return message;
159
172
  }
160
173
 
161
- /** Changes the text of a stored message and shows the change to every open socket. */
174
+ /** Changes the text of a stored message and shows the change to the sockets that may have the room. */
162
175
  async edit(room: ChannelKey, id: number, text: string): Promise<void> {
163
176
  const message = await this.#options.log.edit(room, id, text);
164
177
  if (!message) return;
165
- this.#options.sockets.broadcast({
166
- type: "edited",
167
- room: message.room,
168
- message,
169
- });
178
+ this.#options.sockets.deliver(
179
+ this.#options.isHostRoom(room)
180
+ ? { type: "host_edited", room: message.room, message }
181
+ : { type: "edited", room: message.room, message },
182
+ this.#options.audienceOf(room),
183
+ );
170
184
  }
171
185
 
172
186
  /** Whether a turn works in the room and whether Stop applies, as `activity` frames say. */
@@ -225,12 +239,15 @@ export class AppSurface implements ChatSurface {
225
239
  }
226
240
 
227
241
  #announce(room: ChannelKey, outcome?: "done" | "failed"): void {
228
- this.#options.sockets.broadcast({
229
- type: "activity",
230
- room,
231
- ...this.activityOf(room),
232
- ...(outcome ? { outcome } : {}),
233
- });
242
+ this.#options.sockets.deliver(
243
+ {
244
+ type: "activity",
245
+ room,
246
+ ...this.activityOf(room),
247
+ ...(outcome ? { outcome } : {}),
248
+ },
249
+ this.#options.audienceOf(room),
250
+ );
234
251
  this.#options.activity?.();
235
252
  }
236
253
 
@@ -283,7 +300,15 @@ export class AppSurface implements ChatSurface {
283
300
  const entry = this.#activity.get(channel);
284
301
  if (entry) entry.outcome = reply.failed ? "failed" : "done";
285
302
  const files: RoomFile[] = (reply.files ?? []).map((file) =>
286
- this.#options.files.put(file.name, typeByName(file.name), file.data),
303
+ this.#options.files.put(
304
+ file.name,
305
+ typeByName(file.name),
306
+ file.data,
307
+ undefined,
308
+ this.#options.isHostRoom(channel)
309
+ ? this.#options.fileBinding?.(channel)
310
+ : undefined,
311
+ ),
287
312
  );
288
313
  await this.publish({
289
314
  room: channel,
@@ -328,21 +353,26 @@ export class AppSurface implements ChatSurface {
328
353
  this.#speaks(channel, as?.agent);
329
354
  const live = crypto.randomUUID();
330
355
  const show = (current: string) =>
331
- this.#options.sockets.broadcast({
332
- type: "message",
333
- room: channel,
334
- message: {
335
- id: 0,
356
+ this.#options.sockets.deliver(
357
+ {
358
+ type: this.#options.isHostRoom(channel)
359
+ ? "host_message"
360
+ : "message",
336
361
  room: channel,
337
- kind: "agent",
338
- ...(as?.agent ? { agent: as.agent } : {}),
339
- author: as?.name ?? ASSISTANT,
340
- text: current,
341
- files: [],
342
- at: new Date().toISOString(),
343
- live,
362
+ message: {
363
+ id: 0,
364
+ room: channel,
365
+ kind: "agent",
366
+ ...(as?.agent ? { agent: as.agent } : {}),
367
+ author: as?.name ?? ASSISTANT,
368
+ text: current,
369
+ files: [],
370
+ at: new Date().toISOString(),
371
+ live,
372
+ },
344
373
  },
345
- });
374
+ this.#options.audienceOf(channel),
375
+ );
346
376
  show(text);
347
377
  return {
348
378
  edit: async (edited) => {
@@ -362,9 +392,15 @@ export class AppSurface implements ChatSurface {
362
392
 
363
393
  progress(channel: ChannelKey, event: TurnProgress): void {
364
394
  this.#toolEvent(channel, event);
365
- this.#options.sockets.broadcast(
395
+ // Only the sockets showing the room get its progress, and only those that may have the room.
396
+ const audience = this.#options.audienceOf(channel);
397
+ this.#options.sockets.deliver(
366
398
  { type: "progress", room: channel, event },
367
- (connection) => connection.focus === channel,
399
+ "owners" in audience
400
+ ? { owners: true, focus: channel }
401
+ : "room" in audience
402
+ ? { room: audience.room, focus: true }
403
+ : audience,
368
404
  );
369
405
  }
370
406
 
@@ -60,6 +60,15 @@ export class WebThreads {
60
60
  },
61
61
  };
62
62
 
63
+ /** The mark of a finished thread the owners took off the room list; the thread itself stays. */
64
+ static readonly dismissedMigration: Migration = {
65
+ name: "web-threads-dismissed",
66
+ up: async (sql) => {
67
+ await sql`
68
+ ALTER TABLE web_threads ADD COLUMN IF NOT EXISTS dismissed_at timestamptz`;
69
+ },
70
+ };
71
+
63
72
  /** Starts a thread under `parent`. */
64
73
  async open(parent: string, title: string): Promise<WebThread> {
65
74
  const id = `thread.${crypto.randomUUID()}`;
@@ -76,6 +85,26 @@ export class WebThreads {
76
85
  await this.#sql`UPDATE web_threads SET open = false WHERE id = ${id}`;
77
86
  }
78
87
 
88
+ /**
89
+ * Takes a finished thread off the room list. It stays readable by its address, and asking again
90
+ * changes nothing. An open thread is refused: `open` says so, `missing` that there is none.
91
+ */
92
+ async dismiss(id: string): Promise<"dismissed" | "open" | "missing"> {
93
+ const rows: unknown[] = await this.#sql`
94
+ UPDATE web_threads SET dismissed_at = COALESCE(dismissed_at, now())
95
+ WHERE id = ${id} AND NOT open RETURNING 1`;
96
+ if (rows.length) return "dismissed";
97
+ return (await this.get(id)) ? "open" : "missing";
98
+ }
99
+
100
+ /** Dismisses every finished thread under `parent` that is still listed; returns how many. */
101
+ async dismissFinished(parent: string): Promise<number> {
102
+ const rows: unknown[] = await this.#sql`
103
+ UPDATE web_threads SET dismissed_at = now()
104
+ WHERE parent = ${parent} AND NOT open AND dismissed_at IS NULL RETURNING 1`;
105
+ return rows.length;
106
+ }
107
+
79
108
  async get(id: string): Promise<WebThread | undefined> {
80
109
  const [row]: Row[] = await this
81
110
  .#sql`SELECT * FROM web_threads WHERE id = ${id}`;
@@ -98,14 +127,14 @@ export class WebThreads {
98
127
 
99
128
  /**
100
129
  * The threads the room list shows, newest first: every open one, and the `closedLimit` newest
101
- * closed ones, which stay readable but are not kept in view for ever.
130
+ * closed ones that were not dismissed. The rest stay readable but are not kept in view.
102
131
  */
103
132
  async list(closedLimit = CLOSED_LISTED): Promise<WebThread[]> {
104
133
  const rows: Row[] = await this.#sql`
105
134
  SELECT * FROM (
106
135
  SELECT * FROM web_threads WHERE open
107
136
  UNION ALL
108
- (SELECT * FROM web_threads WHERE NOT open
137
+ (SELECT * FROM web_threads WHERE NOT open AND dismissed_at IS NULL
109
138
  ORDER BY created_at DESC, id LIMIT ${closedLimit})
110
139
  ) shown ORDER BY created_at DESC, id`;
111
140
  return rows.map(threadOf);
@@ -1,7 +1,12 @@
1
1
  import { extname } from "node:path";
2
2
  import type { AttachmentFile, AttachmentRef } from "pi-roundtable";
3
3
  import type { RoomFile, UploadView } from "../api-types.ts";
4
- import { FILE_RETENTION_MS, type FileDir, safeName } from "./files.ts";
4
+ import {
5
+ FILE_RETENTION_MS,
6
+ type FileBinding,
7
+ type FileDir,
8
+ safeName,
9
+ } from "./files.ts";
5
10
  import {
6
11
  boundaryOf,
7
12
  MultipartError,
@@ -32,7 +37,7 @@ const startsWith = (bytes: Uint8Array, signature: readonly number[], at = 0) =>
32
37
  const ascii = (text: string) => [...text].map((char) => char.charCodeAt(0));
33
38
 
34
39
  /** The image type the first bytes say, if any; these four are the ones the page shows itself. */
35
- function imageTypeOf(head: Uint8Array): string | undefined {
40
+ export function imageTypeOf(head: Uint8Array): string | undefined {
36
41
  if (startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
37
42
  return "image/png";
38
43
  if (startsWith(head, [0xff, 0xd8, 0xff])) return "image/jpeg";
@@ -153,6 +158,8 @@ export class Uploads {
153
158
  readonly #now: () => number;
154
159
  readonly maxBytes: number;
155
160
  readonly #pendingBytes: number;
161
+ /** How long a stored file of an agent room is kept, in milliseconds. */
162
+ readonly #keepMs: number;
156
163
 
157
164
  constructor(
158
165
  waiting: FileDir,
@@ -161,6 +168,8 @@ export class Uploads {
161
168
  maxBytes?: number;
162
169
  /** The most bytes one person may have waiting; default 400 MiB. */
163
170
  pendingBytes?: number;
171
+ /** How many days a stored file of an agent room is kept; default 30. */
172
+ keepDays?: number;
164
173
  now?: () => number;
165
174
  } = {},
166
175
  ) {
@@ -169,6 +178,10 @@ export class Uploads {
169
178
  this.#now = options.now ?? Date.now;
170
179
  this.maxBytes = options.maxBytes ?? DEFAULT_UPLOAD_MAX_BYTES;
171
180
  this.#pendingBytes = options.pendingBytes ?? PENDING_BYTES_PER_PRINCIPAL;
181
+ this.#keepMs =
182
+ options.keepDays === undefined
183
+ ? FILE_RETENTION_MS
184
+ : options.keepDays * 24 * 3_600_000;
172
185
  }
173
186
 
174
187
  #waitingOf(principalId: string): { files: number; bytes: number } {
@@ -303,11 +316,15 @@ export class Uploads {
303
316
  return files;
304
317
  }
305
318
 
306
- /** Moves claimed uploads into the stored files, once their message is accepted. */
307
- use(ids: readonly string[]): void {
319
+ /**
320
+ * Moves claimed uploads into the stored files, once their message is accepted. `binding` ties
321
+ * them to the host room the message is in, so only that room's readers are served them.
322
+ */
323
+ use(ids: readonly string[], binding?: FileBinding): void {
308
324
  for (const id of ids) {
309
325
  this.#pending.delete(id);
310
- this.#waiting.moveTo(id, this.#stored);
326
+ if (this.#waiting.moveTo(id, this.#stored) && binding)
327
+ this.#stored.bind(id, binding);
311
328
  }
312
329
  }
313
330
 
@@ -334,7 +351,11 @@ export class Uploads {
334
351
  return { path };
335
352
  }
336
353
 
337
- /** Deletes the waiting uploads older than a day and the stored files older than 30 days; returns how many. */
354
+ /**
355
+ * Deletes the waiting uploads older than a day and the stored files that are due; returns how
356
+ * many. A file of an agent room is due after `keepDays` (default 30); a file bound to a host
357
+ * room is due when its own expiry passes, and kept when it has none.
358
+ */
338
359
  sweep(): number {
339
360
  const now = this.#now();
340
361
  let dropped = 0;
@@ -343,7 +364,12 @@ export class Uploads {
343
364
  this.#pending.delete(id);
344
365
  dropped += 1;
345
366
  }
346
- for (const id of this.#stored.older(FILE_RETENTION_MS, now)) {
367
+ for (const id of this.#stored.ids()) {
368
+ const binding = this.#stored.binding(id);
369
+ const due = binding
370
+ ? binding.expiresAt !== undefined && binding.expiresAt <= now
371
+ : (this.#stored.ageOf(id, now) ?? 0) > this.#keepMs;
372
+ if (!due) continue;
347
373
  this.#stored.remove(id);
348
374
  dropped += 1;
349
375
  }