pi-roundtable-web 0.26.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 (198) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +23 -7
  3. package/dist/chunk-account-ghq105t4.js +1 -0
  4. package/dist/{chunk-action-menu-rm5cyqkj.js → chunk-action-menu-me0g9qsc.js} +1 -1
  5. package/dist/{chunk-agent-b017vswv.js → chunk-agent-rdv7ekrt.js} +1 -1
  6. package/dist/chunk-arranged-rooms-81rqhx9x.js +1 -0
  7. package/dist/chunk-character-creator-bd426yjw.js +1 -0
  8. package/dist/chunk-codeeditor-ajhgd33t.js +2 -0
  9. package/dist/chunk-confirm-p3mts8mx.js +1 -0
  10. package/dist/{chunk-connectors-hrm06nx8.js → chunk-connectors-bkv3xn83.js} +1 -1
  11. package/dist/{chunk-conversations-e26rrf01.js → chunk-conversations-0e0vbxwr.js} +1 -1
  12. package/dist/{chunk-drag-v41gx2mc.js → chunk-drag-peyb8t0k.js} +1 -1
  13. package/dist/{chunk-entry-jxxb3ath.js → chunk-entry-mv7q8br0.js} +1 -1
  14. package/dist/chunk-folder-list-era3tksh.js +1 -0
  15. package/dist/chunk-folds-hvws0bd5.js +1 -0
  16. package/dist/{chunk-gate-8k61stsc.js → chunk-gate-hc7dd0fx.js} +1 -1
  17. package/dist/chunk-group-scene-dkbpmbh5.js +1 -0
  18. package/dist/chunk-group-table-fqmxgdfw.js +1 -0
  19. package/dist/chunk-host-icons-4xde9ntk.js +1 -0
  20. package/dist/chunk-host-picture-014yzk20.js +1 -0
  21. package/dist/chunk-host-state-t56e5sdy.js +1 -0
  22. package/dist/chunk-host-ui-p1zy9nmp.js +1 -0
  23. package/dist/chunk-index-0fmmvgee.js +2 -0
  24. package/dist/chunk-index-0q15ysrb.js +1 -0
  25. package/dist/chunk-index-0xh24y25.js +2 -0
  26. package/dist/chunk-index-2yebd9tb.js +426 -0
  27. package/dist/chunk-index-4t0r3t3b.js +2 -0
  28. package/dist/chunk-index-5yzfrf7b.js +138 -0
  29. package/dist/{chunk-index-2d0kg096.js → chunk-index-6twb7ccd.js} +2 -2
  30. package/dist/chunk-index-73gh7kgr.js +920 -0
  31. package/dist/chunk-index-7xffskfa.js +8 -0
  32. package/dist/chunk-index-aqw7cy5j.js +1 -0
  33. package/dist/{chunk-index-d1prgfbp.js → chunk-index-b6ca02e8.js} +2 -2
  34. package/dist/chunk-index-b8nvhcx5.js +2 -0
  35. package/dist/{chunk-index-014rjv9k.js → chunk-index-cvbdf2ca.js} +2 -2
  36. package/dist/chunk-index-czdketze.js +2 -0
  37. package/dist/{chunk-index-y8rnq8zf.js → chunk-index-dt9775ka.js} +3 -3
  38. package/dist/chunk-index-eg55mqqe.js +1 -0
  39. package/dist/{chunk-index-xgkd15n3.js → chunk-index-gr6e0hse.js} +1 -1
  40. package/dist/chunk-index-gyary7np.js +13 -0
  41. package/dist/chunk-index-j214tczc.js +1 -0
  42. package/dist/chunk-index-jd6632ef.js +2 -0
  43. package/dist/chunk-index-jw7dtshr.js +2 -0
  44. package/dist/{chunk-index-wnjp6wv0.js → chunk-index-k7t14rwa.js} +2 -2
  45. package/dist/{chunk-index-c07p8k0x.js → chunk-index-kwcx62wb.js} +3 -3
  46. package/dist/{chunk-index-hby0ap91.js → chunk-index-m7fvw92f.js} +2 -2
  47. package/dist/chunk-index-mmg788a1.js +2 -0
  48. package/dist/chunk-index-mq0dacwr.js +2 -0
  49. package/dist/chunk-index-p53ak25v.js +2 -0
  50. package/dist/{chunk-index-rhtvcbsq.js → chunk-index-pk1yxkv4.js} +2 -2
  51. package/dist/{chunk-index-2stynxw6.css → chunk-index-rbcz9jd4.css} +1 -1
  52. package/dist/{chunk-index-km40bhsz.js → chunk-index-rmxbdmn2.js} +2 -2
  53. package/dist/chunk-index-rnxj3z8k.js +2 -0
  54. package/dist/{chunk-index-hssjbr1k.js → chunk-index-snzzfhk3.js} +2 -2
  55. package/dist/chunk-index-v3bmr0nw.js +2 -0
  56. package/dist/{chunk-index-awx6nnta.js → chunk-index-vcyzwtk2.js} +1 -1
  57. package/dist/{chunk-index-nqfkwkfp.js → chunk-index-w2j3njb6.js} +2 -2
  58. package/dist/{chunk-index-e5sd30xy.js → chunk-index-w31c4yjv.js} +2 -2
  59. package/dist/chunk-index-wrm8w165.js +2 -0
  60. package/dist/chunk-index-xh0mg9cw.js +2 -0
  61. package/dist/chunk-index-ybtvvx85.js +2 -0
  62. package/dist/{chunk-index-dhpt8qr9.js → chunk-index-yzsgsvnp.js} +2 -2
  63. package/dist/{chunk-index-66nskrdj.js → chunk-index-z9x4cnzf.js} +2 -2
  64. package/dist/chunk-index-zrvg23jd.js +2 -0
  65. package/dist/chunk-index-zynq0b0w.js +50 -0
  66. package/dist/chunk-lang-css-88v3hp3d.js +1 -0
  67. package/dist/{chunk-lang-dockerfile-hrav55t7.js → chunk-lang-dockerfile-k728ewsf.js} +1 -1
  68. package/dist/{chunk-lang-go-x9ahv6e5.js → chunk-lang-go-wjwgay4v.js} +2 -2
  69. package/dist/chunk-lang-html-07d47es4.js +1 -0
  70. package/dist/chunk-lang-javascript-fmhhgzwd.js +1 -0
  71. package/dist/{chunk-lang-json-hsemyd0r.js → chunk-lang-json-7gd89mvs.js} +1 -1
  72. package/dist/{chunk-lang-markdown-zkwb4c9n.js → chunk-lang-markdown-b6qbe8bt.js} +5 -5
  73. package/dist/{chunk-lang-python-tewx7006.js → chunk-lang-python-3v56y31b.js} +2 -2
  74. package/dist/{chunk-lang-rust-0ydx84s1.js → chunk-lang-rust-kh0z74d1.js} +1 -1
  75. package/dist/{chunk-lang-shell-gq1tmdz4.js → chunk-lang-shell-9zv0zvbx.js} +1 -1
  76. package/dist/{chunk-lang-sql-xt1qyycg.js → chunk-lang-sql-yw6bf3t7.js} +2 -2
  77. package/dist/{chunk-lang-toml-0hamc5fc.js → chunk-lang-toml-tg1r96nq.js} +1 -1
  78. package/dist/chunk-lang-typescript-etjjvr2g.js +1 -0
  79. package/dist/{chunk-lang-xml-wf6h5pt2.js → chunk-lang-xml-ev1mndd4.js} +1 -1
  80. package/dist/{chunk-lang-yaml-7vk521h1.js → chunk-lang-yaml-fz5qpb3r.js} +1 -1
  81. package/dist/chunk-layout-actions-48ecgtkg.js +1 -0
  82. package/dist/{chunk-menu-items-1n9qargb.js → chunk-menu-items-1epy3h0t.js} +2 -2
  83. package/dist/chunk-mode-toggle-fxmfxsa4.js +1 -0
  84. package/dist/chunk-note-row-kd0fy7ee.js +75 -0
  85. package/dist/{chunk-notes-t15bh04m.js → chunk-notes-yb8f057c.js} +1 -1
  86. package/dist/chunk-notice-bell-wr4f1rph.js +1 -0
  87. package/dist/chunk-notices-x07da5bb.js +1 -0
  88. package/dist/chunk-onboarding-csrkbh9z.js +1 -0
  89. package/dist/{chunk-overview-km5vq375.js → chunk-overview-avc2d30t.js} +1 -1
  90. package/dist/{chunk-palette-a8txdgd0.js → chunk-palette-9nhp95bz.js} +1 -1
  91. package/dist/chunk-placement-items-q694dpgk.js +1 -0
  92. package/dist/chunk-profile-6qemf3m7.js +1 -0
  93. package/dist/{chunk-prompt-history-wm9dsy6s.js → chunk-prompt-history-7enzp2p3.js} +1 -1
  94. package/dist/chunk-prompts-nv6mrn0q.js +1 -0
  95. package/dist/chunk-room-confirms-zz7n61y7.js +1 -0
  96. package/dist/{chunk-room-menu-2tm2xtfx.js → chunk-room-menu-rr6nzap1.js} +1 -1
  97. package/dist/{chunk-rooms-menu-button-wxnsxjwc.js → chunk-rooms-menu-button-zbtwj47d.js} +1 -1
  98. package/dist/chunk-schedules-rw57nr0m.js +1 -0
  99. package/dist/{chunk-seat-menu-d3npdtx1.js → chunk-seat-menu-ys5v9abz.js} +1 -1
  100. package/dist/chunk-sheet-hosts-jhvaacst.js +1 -0
  101. package/dist/chunk-skill-editor-sz8an8qr.js +8 -0
  102. package/dist/{chunk-skills-shkdwes8.js → chunk-skills-tnr1h16h.js} +1 -1
  103. package/dist/chunk-switcher-list-17enhrse.js +1 -0
  104. package/dist/chunk-tab-dot-r6brdbmw.js +1 -0
  105. package/dist/chunk-table-r12cja6p.js +1 -0
  106. package/dist/chunk-table-seats-g3t392zz.js +1 -0
  107. package/dist/{chunk-table2d-n26b7gd0.js → chunk-table2d-p3ezsdcj.js} +1 -1
  108. package/dist/{chunk-table3d-8aysyf56.js → chunk-table3d-4x2n2b00.js} +20 -20
  109. package/dist/chunk-thread-actions-qc6ezkha.js +1 -0
  110. package/dist/chunk-transcript-n2s6dher.js +1 -0
  111. package/dist/chunk-where-f3scdydc.js +1 -0
  112. package/dist/chunk-workspace-xs023bxm.js +1 -0
  113. package/dist/chunk-zh-tw-efvmk3fd.js +1 -0
  114. package/dist/index.html +1 -1
  115. package/dist/sw.js +1 -1
  116. package/package.json +2 -2
  117. package/src/api-types.ts +58 -3
  118. package/src/app/app.ts +5 -0
  119. package/src/app/room-log.ts +16 -4
  120. package/src/app/rooms.ts +39 -0
  121. package/src/app/routes.ts +44 -2
  122. package/src/app/socket.ts +45 -9
  123. package/src/app/threads.ts +31 -2
  124. package/src/auth/authenticator.ts +10 -4
  125. package/src/auth/login-page.ts +4 -2
  126. package/src/auth/oidc-pages.ts +2 -0
  127. package/src/auth/oidc.ts +39 -1
  128. package/src/authz.ts +2 -1
  129. package/src/console-server.ts +32 -6
  130. package/src/host/cards.ts +18 -5
  131. package/src/host/context.ts +72 -25
  132. package/src/host/forms.ts +3 -0
  133. package/src/host/frames.ts +8 -1
  134. package/src/host/host-app.ts +6 -4
  135. package/src/host/host-routes.ts +36 -3
  136. package/src/host/room-views.ts +6 -6
  137. package/src/host/rooms-service.ts +16 -1
  138. package/src/host/views.ts +3 -0
  139. package/src/i18n/zh-tw.ts +9 -2
  140. package/src/index.ts +15 -1
  141. package/src/options.ts +36 -0
  142. package/src/page-presentation.ts +47 -2
  143. package/src/pwa.ts +32 -4
  144. package/src/web-app.ts +47 -3
  145. package/src/web-plugin.ts +20 -9
  146. package/dist/chunk-account-hw49gd0d.js +0 -1
  147. package/dist/chunk-arranged-rooms-q5v3dm1r.js +0 -1
  148. package/dist/chunk-character-creator-5dxcvd16.js +0 -1
  149. package/dist/chunk-codeeditor-pyt09fwg.js +0 -2
  150. package/dist/chunk-confirm-521yqmta.js +0 -1
  151. package/dist/chunk-folder-list-b97xk12b.js +0 -1
  152. package/dist/chunk-folds-b8q7eags.js +0 -1
  153. package/dist/chunk-group-scene-dx6wt19m.js +0 -1
  154. package/dist/chunk-group-table-e4jr8r3a.js +0 -1
  155. package/dist/chunk-host-state-g7e7c0k5.js +0 -1
  156. package/dist/chunk-host-ui-x7k6n07v.js +0 -939
  157. package/dist/chunk-index-2qd81wbx.js +0 -2
  158. package/dist/chunk-index-459drsft.js +0 -2
  159. package/dist/chunk-index-4mfj596y.js +0 -2
  160. package/dist/chunk-index-bbsytycb.js +0 -2
  161. package/dist/chunk-index-cq1e9xdz.js +0 -2
  162. package/dist/chunk-index-g351921r.js +0 -1
  163. package/dist/chunk-index-k2qq1gdj.js +0 -2
  164. package/dist/chunk-index-ke9ynm0n.js +0 -2
  165. package/dist/chunk-index-kpscvg85.js +0 -2
  166. package/dist/chunk-index-m0d100mg.js +0 -2
  167. package/dist/chunk-index-mb72snk9.js +0 -2
  168. package/dist/chunk-index-mjqqver5.js +0 -4
  169. package/dist/chunk-index-nwqk447w.js +0 -2
  170. package/dist/chunk-index-pcfhpm8v.js +0 -302
  171. package/dist/chunk-index-q8h8xzbm.js +0 -2
  172. package/dist/chunk-index-r5vszgfs.js +0 -2
  173. package/dist/chunk-index-rjv7vrwd.js +0 -13
  174. package/dist/chunk-index-v23m38p6.js +0 -50
  175. package/dist/chunk-index-va0g7d88.js +0 -1
  176. package/dist/chunk-index-z3k18atc.js +0 -2
  177. package/dist/chunk-lang-css-6bepqpnx.js +0 -1
  178. package/dist/chunk-lang-html-8jc72zt4.js +0 -1
  179. package/dist/chunk-lang-javascript-2p2qtbnf.js +0 -1
  180. package/dist/chunk-lang-typescript-82xf7z2e.js +0 -1
  181. package/dist/chunk-layout-actions-zh7bezk7.js +0 -1
  182. package/dist/chunk-notice-bell-w3y0h0sr.js +0 -1
  183. package/dist/chunk-notices-fa3g7qze.js +0 -1
  184. package/dist/chunk-onboarding-34v3nr17.js +0 -1
  185. package/dist/chunk-placement-items-wgaxsbvs.js +0 -1
  186. package/dist/chunk-profile-ypxdbsk1.js +0 -1
  187. package/dist/chunk-prompts-7mhhq7zs.js +0 -1
  188. package/dist/chunk-room-confirms-t11a1gsw.js +0 -1
  189. package/dist/chunk-schedules-2ft6xxyw.js +0 -1
  190. package/dist/chunk-sheet-hosts-vngvp7wv.js +0 -1
  191. package/dist/chunk-skill-editor-65n2hsq4.js +0 -8
  192. package/dist/chunk-switcher-list-g6hhmr1t.js +0 -1
  193. package/dist/chunk-table-1b6bsq2y.js +0 -1
  194. package/dist/chunk-table-seats-vy1qhdks.js +0 -1
  195. package/dist/chunk-transcript-355faceq.js +0 -1
  196. package/dist/chunk-where-j5qdt8a4.js +0 -1
  197. package/dist/chunk-workspace-vrbs6zx1.js +0 -1
  198. package/dist/chunk-zh-tw-zeg600bk.js +0 -1
package/src/app/routes.ts CHANGED
@@ -19,11 +19,13 @@ import type {
19
19
  CharacterRefusal,
20
20
  MessagesView,
21
21
  ModelsView,
22
+ RoomOneView,
22
23
  RoomsView,
23
24
  SchedulesView,
24
25
  ScheduleView,
25
26
  SessionView,
26
27
  SkillView,
28
+ ThreadsDismissed,
27
29
  WorkspaceConflict,
28
30
  WorkspaceNew,
29
31
  WorkspaceRefusalBody,
@@ -48,7 +50,7 @@ import type { LayoutStore } from "./layout.ts";
48
50
  import { LayoutRoutes } from "./layout-routes.ts";
49
51
  import { PromptApi, type PromptApiOptions } from "./prompt-routes.ts";
50
52
  import { PAGE_LIMIT, type RoomLog } from "./room-log.ts";
51
- import type { Rooms } from "./rooms.ts";
53
+ import type { ResolvedRoom, Rooms } from "./rooms.ts";
52
54
  import { SkillDocApi } from "./skill-doc-routes.ts";
53
55
  import type { AppSurface } from "./surface.ts";
54
56
  import { TeamApi } from "./team-routes.ts";
@@ -75,7 +77,10 @@ export interface AppApiOptions {
75
77
  /** The rooms whose push the owners muted. */
76
78
  mutes: Pick<PushMutes, "set" | "setMany">;
77
79
  /** The dispatch threads, which go with the room they hang under. */
78
- threads: Pick<WebThreads, "under" | "removeUnder">;
80
+ threads: Pick<
81
+ WebThreads,
82
+ "under" | "removeUnder" | "dismiss" | "dismissFinished"
83
+ >;
79
84
  /** The owners' shared arrangement of the room list: pins and folders. */
80
85
  layout: LayoutStore;
81
86
  conversations: Pick<ConversationPort, "startFresh">;
@@ -496,6 +501,16 @@ export class AppApi {
496
501
  }
497
502
  const room = await rooms.resolve(key);
498
503
  if (!room) throw notFound();
504
+ if (!action && method === "GET") {
505
+ // A thread dismissed from the list still opens by its address; the page asks for its view.
506
+ const view = await rooms.threadView(room.key);
507
+ if (!view) throw notFound();
508
+ return json({ room: view } satisfies RoomOneView);
509
+ }
510
+ if (action === "dismiss" && method === "POST" && parts.length === 4)
511
+ return this.#dismiss(room, admission);
512
+ if (action === "dismiss-threads" && method === "POST" && parts.length === 4)
513
+ return this.#dismissFinished(room, admission);
499
514
  if (action === "messages" && method === "GET" && parts.length === 4) {
500
515
  const params = new URL(request.url).searchParams;
501
516
  const before = params.get("before");
@@ -573,6 +588,33 @@ export class AppApi {
573
588
  throw new HttpError(404, "There is no such API.");
574
589
  }
575
590
 
591
+ /** Takes one finished thread off the room list for every owner; an open one is refused. */
592
+ async #dismiss(
593
+ room: ResolvedRoom,
594
+ admission: Authenticated,
595
+ ): Promise<Response> {
596
+ if (room.kind !== "thread") throw notFound();
597
+ await this.#owner(admission);
598
+ const outcome = await this.#options.threads.dismiss(room.name);
599
+ if (outcome === "missing") throw notFound();
600
+ if (outcome === "open")
601
+ throw new HttpError(409, "The thread is still open.");
602
+ this.#options.changed();
603
+ return json({ dismissed: 1 } satisfies ThreadsDismissed);
604
+ }
605
+
606
+ /** Takes every finished thread of a room off the room list; the open ones stay. */
607
+ async #dismissFinished(
608
+ room: ResolvedRoom,
609
+ admission: Authenticated,
610
+ ): Promise<Response> {
611
+ if (room.kind === "thread") throw notFound();
612
+ await this.#owner(admission);
613
+ const dismissed = await this.#options.threads.dismissFinished(room.key);
614
+ if (dismissed) this.#options.changed();
615
+ return json({ dismissed } satisfies ThreadsDismissed);
616
+ }
617
+
576
618
  async #file(id: string, admission: Authenticated): Promise<Response> {
577
619
  if (!isFileId(id)) throw notFound();
578
620
  const { files, host } = this.#options;
package/src/app/socket.ts CHANGED
@@ -559,7 +559,9 @@ export class SocketRoute {
559
559
  this.#refuse(connection, code, frame.id);
560
560
  // The agent rooms are the owners', and they take only turns.
561
561
  if (who.tier !== "owner") return refuse("forbidden");
562
- if (frame.mode === "note") return refuse("bad_frame");
562
+ // A note is for the other people who read the rooms; where nobody else does, it is no frame.
563
+ const note = frame.mode === "note";
564
+ if (note && !(await rooms.notesOn())) return refuse("bad_frame");
563
565
  const room = await rooms.resolve(frame.room);
564
566
  if (!room) return refuse("unknown_room");
565
567
  if (room.readOnly) return refuse("read_only");
@@ -567,18 +569,22 @@ export class SocketRoute {
567
569
  if (text.length > MAX_MESSAGE_CHARS) return refuse("too_long");
568
570
  const ids = frame.uploads ?? [];
569
571
  if (text === "" && ids.length === 0) return refuse("empty");
570
- // Without the actor the credential vouched for, the router cannot tell whose message it is.
571
- if (!who.actor) return refuse("forbidden");
572
+ // Without the actor the credential vouched for, the router cannot tell whose message it is;
573
+ // a note goes to no router.
574
+ if (!note && !who.actor) return refuse("forbidden");
572
575
  const files = uploads.claim(ids, who.principalId);
573
576
  if (!files) return refuse("unknown_upload");
574
577
  if (!surface.started) return refuse("not_accepted");
575
578
  try {
576
- await this.say(room.key, who, text, files, connection, (message) =>
577
- sockets.deliver(
578
- { type: "accepted", id: frame.id, room: room.key, message },
579
- { connection },
580
- ),
581
- );
579
+ if (note)
580
+ await this.#note(connection, who, room.key, frame.id, text, files);
581
+ else
582
+ await this.say(room.key, who, text, files, connection, (message) =>
583
+ sockets.deliver(
584
+ { type: "accepted", id: frame.id, room: room.key, message },
585
+ { connection },
586
+ ),
587
+ );
582
588
  } catch (error) {
583
589
  logger.warn(
584
590
  { room: room.key, err: error },
@@ -588,6 +594,36 @@ export class SocketRoute {
588
594
  }
589
595
  }
590
596
 
597
+ /**
598
+ * Keeps an owner's internal note in a room: stored and shown to the other readers, and given to
599
+ * no one else. It does not reach the host, so the agent runs no turn, the note is not in its
600
+ * conversation, and nothing about the room reads as asked.
601
+ */
602
+ async #note(
603
+ connection: AppSocket,
604
+ who: Answerer,
605
+ room: ChannelKey,
606
+ id: string,
607
+ text: string,
608
+ files: readonly RoomFile[],
609
+ ): Promise<void> {
610
+ const { surface, uploads, sockets } = this.#options;
611
+ const message = await surface.publish(
612
+ {
613
+ room,
614
+ kind: "owner",
615
+ principalId: who.principalId,
616
+ author: who.name,
617
+ text,
618
+ files: [...files],
619
+ note: true,
620
+ },
621
+ connection,
622
+ );
623
+ uploads.use(files.map((file) => file.id));
624
+ sockets.deliver({ type: "accepted", id, room, message }, { connection });
625
+ }
626
+
591
627
  /**
592
628
  * Takes a message of the owner into a room: stores it, shows it to the other sockets, and
593
629
  * hands it to the host as the owner's. `files` are uploads the sender claimed, which move to
@@ -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);
@@ -78,7 +78,7 @@ export interface AuthenticatorOptions {
78
78
  listener: { id: string; address: Readonly<ListenerAddress> | undefined };
79
79
  /** The sign-in flow and the session store, for `oidc`. */
80
80
  oidc?: {
81
- login: Pick<OidcLogin, "open" | "credential">;
81
+ login: Pick<OidcLogin, "open" | "credential" | "verified">;
82
82
  sessions: Pick<WebSessions, "alive">;
83
83
  };
84
84
  /** Where the password is read from; default `process.env`. */
@@ -205,6 +205,8 @@ export function createAuthenticator(
205
205
  )
206
206
  return denied("foreign origin");
207
207
  const { session } = checked as Admitted;
208
+ if (session)
209
+ options.oidc?.login.verified(session, identified.visitor.principal.id);
208
210
  return {
209
211
  visitor: identified.visitor,
210
212
  ...(checked.actor ? { actor: checked.actor } : {}),
@@ -222,9 +224,13 @@ export function createAuthenticator(
222
224
  }
223
225
  }
224
226
  const identified = await identify(held.actor);
225
- return "refusal" in identified
226
- ? denied(String(identified.refusal))
227
- : { visitor: identified.visitor };
227
+ if ("refusal" in identified) return denied(String(identified.refusal));
228
+ if (held.session)
229
+ options.oidc?.login.verified(
230
+ held.session,
231
+ identified.visitor.principal.id,
232
+ );
233
+ return { visitor: identified.visitor };
228
234
  },
229
235
  };
230
236
  }
@@ -1,9 +1,10 @@
1
1
  import { type ConsolePresentation, message } from "../features.ts";
2
2
  import { escapeHtml } from "../http.ts";
3
+ import { iconLinks } from "../page-presentation.ts";
3
4
 
4
- /** The login page's CSP: no script at all, one inline style, and the form posts only to this origin. */
5
+ /** The login page's CSP: no script at all, one inline style, its own icon, and the form posts only to this origin. */
5
6
  export const LOGIN_CSP =
6
- "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
7
+ "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
7
8
 
8
9
  /** The login page, as one server-rendered document with no script; `note` is a line above the field. */
9
10
  export function loginPage(
@@ -19,6 +20,7 @@ export function loginPage(
19
20
  <meta charset="utf-8">
20
21
  <meta name="viewport" content="width=device-width, initial-scale=1">
21
22
  <meta name="robots" content="noindex">
23
+ ${iconLinks(mount, false)}
22
24
  <title>${escapeHtml(title)}</title>
23
25
  <style>
24
26
  body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#f6f6f4;color:#1c1c1a}
@@ -1,5 +1,6 @@
1
1
  import { type ConsolePresentation, message } from "../features.ts";
2
2
  import { escapeHtml } from "../http.ts";
3
+ import { iconLinks } from "../page-presentation.ts";
3
4
  import { LOGIN_CSP } from "./login-page.ts";
4
5
 
5
6
  /** What a page of the sign-in flow tells, and the link it offers. */
@@ -30,6 +31,7 @@ export function oidcPage(
30
31
  <meta charset="utf-8">
31
32
  <meta name="viewport" content="width=device-width, initial-scale=1">
32
33
  <meta name="robots" content="noindex">
34
+ ${iconLinks(mount, false)}
33
35
  <title>${escapeHtml(title)}</title>
34
36
  <style>
35
37
  body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#f6f6f4;color:#1c1c1a}
package/src/auth/oidc.ts CHANGED
@@ -27,6 +27,10 @@ const MAX_NAME_CHARS = 100;
27
27
  const MAX_SUBJECT_CHARS = 256;
28
28
  /** The most characters of one stored claim, as JSON. */
29
29
  const MAX_CLAIM_CHARS = 2000;
30
+ /** How seldom a live session is reported again to `onSessionVerified`. */
31
+ const VERIFIED_EVERY_MS = 300_000;
32
+ /** The most sessions whose last report is remembered before the lapsed ones are dropped. */
33
+ const VERIFIED_PRUNE_AT = 1024;
30
34
  /** Answers to a silent request that mean "the person must be asked", not "something is wrong". */
31
35
  const INTERACTION_NEEDED = new Set([
32
36
  "login_required",
@@ -133,6 +137,8 @@ export interface OidcLoginOptions {
133
137
  onSignIn?(info: SignedIn): void | Promise<void>;
134
138
  /** A session ended (logout), so the sockets on it can close. */
135
139
  onEnded?(sessionId: string): void;
140
+ /** A request or an open socket proved a live session of this person: the host's `onSessionVerified`. */
141
+ onSessionVerified?(principalId: string): void | Promise<void>;
136
142
  logger: Logger;
137
143
  /** The provider's signing keys; the published ones by default. */
138
144
  keys?: JWTVerifyGetKey;
@@ -210,6 +216,8 @@ export class OidcLogin {
210
216
  readonly #outCookie = "__Secure-rt_out";
211
217
  /** States already used, until they would have lapsed. */
212
218
  readonly #spent = new Map<string, number>();
219
+ /** When each session was last reported to `onSessionVerified`, by session id. */
220
+ readonly #verified = new Map<string, number>();
213
221
 
214
222
  constructor(options: OidcLoginOptions) {
215
223
  this.#options = options;
@@ -576,6 +584,33 @@ export class OidcLogin {
576
584
  return { admitted: true, actor: facts, session: session.id };
577
585
  };
578
586
 
587
+ /**
588
+ * Reports that a request or an open socket proved this session live, so the host may count the
589
+ * person as present. At most once per session every few minutes; never waits for the hook and
590
+ * never throws: a hook that fails is logged.
591
+ */
592
+ verified(session: string, principalId: string): void {
593
+ const hook = this.#options.onSessionVerified;
594
+ if (!hook) return;
595
+ const now = this.#now();
596
+ const last = this.#verified.get(session);
597
+ if (last !== undefined && now - last < VERIFIED_EVERY_MS) return;
598
+ if (this.#verified.size >= VERIFIED_PRUNE_AT)
599
+ for (const [id, at] of this.#verified)
600
+ if (now - at >= VERIFIED_EVERY_MS) this.#verified.delete(id);
601
+ this.#verified.set(session, now);
602
+ const failed = (cause: unknown) =>
603
+ this.#options.logger.warn(
604
+ { err: cause },
605
+ "web-console: the session-verified hook failed",
606
+ );
607
+ try {
608
+ Promise.resolve(hook(principalId)).catch(failed);
609
+ } catch (cause) {
610
+ failed(cause);
611
+ }
612
+ }
613
+
579
614
  async #logout(request: Request): Promise<Response> {
580
615
  const { mount, origin, settings, metadata } = this.#options;
581
616
  if (request.headers.get("origin") !== origin)
@@ -589,7 +624,10 @@ export class OidcLogin {
589
624
  );
590
625
  if (token) {
591
626
  const id = await this.#options.sessions.end(token).catch(() => undefined);
592
- if (id) this.#options.onEnded?.(id);
627
+ if (id) {
628
+ this.#verified.delete(id);
629
+ this.#options.onEnded?.(id);
630
+ }
593
631
  }
594
632
  const base = `${mount}/`;
595
633
  let to = `${mount}/auth/signed-out`;
package/src/authz.ts CHANGED
@@ -50,7 +50,8 @@ export const ROUTES: readonly Rule[] = [
50
50
  row("*", "api/notes", "V"),
51
51
  row("*", "api/notes/:id", "V"),
52
52
  row("*", "api/principals", "V"),
53
- // The agent rooms are the owners'. The route says 404, not 403, to anyone else.
53
+ // The agent rooms are the owners'. The route says 404, not 403, to anyone else. Their threads are under
54
+ // it too: a thread's view and `…/dismiss`, and `…/dismiss-threads` of its parent.
54
55
  row("*", "api/rooms", "O", true),
55
56
  row("*", "api/rooms/**", "O", true),
56
57
  row("*", "api/folders/**", "O"),
@@ -12,8 +12,12 @@ import type { ConsoleApi } from "./console-api.ts";
12
12
  import { type ConsolePresentation, message } from "./features.ts";
13
13
  import type { Gate } from "./gate.ts";
14
14
  import { json } from "./http.ts";
15
- import { presentPage } from "./page-presentation.ts";
16
- import { servePwa } from "./pwa.ts";
15
+ import {
16
+ defaultDescription,
17
+ type PageMeta,
18
+ presentPage,
19
+ } from "./page-presentation.ts";
20
+ import { hasMarkSvg, isPublicStatic, servePwa } from "./pwa.ts";
17
21
  import type { Visitor } from "./visitors.ts";
18
22
  import { viewerOf } from "./web-app.ts";
19
23
 
@@ -76,6 +80,10 @@ export interface ConsoleServerOptions {
76
80
  /** Where the install icons come from; default the ones this package ships. */
77
81
  iconsDir?: string;
78
82
  look?: "modern" | "camelot";
83
+ /** The console's own origin, for the absolute `og:image` of a link preview. */
84
+ origin?: string;
85
+ /** What the page says about itself in a search result and a link preview; default derived from the title. */
86
+ description?: string;
79
87
  }
80
88
 
81
89
  /**
@@ -89,6 +97,7 @@ export interface ConsoleServerOptions {
89
97
  export class ConsoleServer {
90
98
  readonly #options: ConsoleServerOptions;
91
99
  readonly #base: string;
100
+ readonly #meta: PageMeta | undefined;
92
101
  readonly #streams = new Set<ReadableStreamDefaultController<Uint8Array>>();
93
102
  #pending: ReturnType<typeof setTimeout> | undefined;
94
103
  #ping: ReturnType<typeof setInterval> | undefined;
@@ -96,6 +105,16 @@ export class ConsoleServer {
96
105
  constructor(options: ConsoleServerOptions) {
97
106
  this.#options = options;
98
107
  this.#base = `${options.mount}/`;
108
+ const title = options.title ?? "Roundtable";
109
+ this.#meta = options.origin
110
+ ? {
111
+ title,
112
+ description: options.description ?? defaultDescription(title),
113
+ origin: options.origin,
114
+ mount: options.mount,
115
+ svg: hasMarkSvg(options.iconsDir),
116
+ }
117
+ : undefined;
99
118
  options.subscribe(() => this.#changed());
100
119
  }
101
120
 
@@ -157,6 +176,11 @@ export class ConsoleServer {
157
176
  if (path !== this.#options.mount && !path.startsWith(this.#base))
158
177
  return new Response("Not found", { status: 404 });
159
178
  const rest = path.slice(this.#base.length);
179
+ // Static and the same for everyone: a sign-in page and a link preview show the icon without a session.
180
+ if (READ_METHODS.has(request.method) && isPublicStatic(rest)) {
181
+ const shown = servePwa(rest, this.#pwa());
182
+ if (shown) return shown;
183
+ }
160
184
  const open = await this.#options.authenticator.open?.(request, rest);
161
185
  if (open) return open;
162
186
  const admission = await this.#options.authenticator.authenticate(request);
@@ -183,10 +207,7 @@ export class ConsoleServer {
183
207
  );
184
208
  if (!READ_METHODS.has(request.method))
185
209
  return new Response("Method not allowed", { status: 405 });
186
- const installable = servePwa(rest, {
187
- ...this.#options,
188
- title: this.#options.title ?? "Roundtable",
189
- });
210
+ const installable = servePwa(rest, this.#pwa());
190
211
  if (installable) return installable;
191
212
  const asset = this.#options.assets.get(rest);
192
213
  if (asset && rest !== "index.html" && rest !== "sw.js")
@@ -205,6 +226,7 @@ export class ConsoleServer {
205
226
  this.#options.pathRouting ? this.#options.mount : undefined,
206
227
  this.#options.presentation,
207
228
  this.#options.look,
229
+ this.#meta,
208
230
  ),
209
231
  {
210
232
  headers: {
@@ -223,6 +245,10 @@ export class ConsoleServer {
223
245
  return new Response("Not found", { status: 404 });
224
246
  }
225
247
 
248
+ #pwa(): Parameters<typeof servePwa>[1] {
249
+ return { ...this.#options, title: this.#options.title ?? "Roundtable" };
250
+ }
251
+
226
252
  async #gated(visitor: Visitor): Promise<boolean> {
227
253
  const { gate } = this.#options;
228
254
  if (!gate) return false;
package/src/host/cards.ts CHANGED
@@ -49,11 +49,20 @@ export class HostCardLog {
49
49
  },
50
50
  };
51
51
 
52
+ /** The mark of a card the host keeps past its kind's retention (`HostCard.keep`). */
53
+ static readonly keepMigration: Migration = {
54
+ name: "web-host-cards-keep",
55
+ up: async (sql) => {
56
+ await sql`
57
+ ALTER TABLE web_host_cards ADD COLUMN IF NOT EXISTS keep boolean NOT NULL DEFAULT false`;
58
+ },
59
+ };
60
+
52
61
  async create(room: string, card: HostCard): Promise<StoredHostCard> {
53
62
  const id = crypto.randomUUID();
54
63
  const [row]: Row[] = await this.#sql`
55
- INSERT INTO web_host_cards (id, room, card, state)
56
- VALUES (${id}, ${room}, ${JSON.stringify(card)}::jsonb, ${card.state ?? null})
64
+ INSERT INTO web_host_cards (id, room, card, state, keep)
65
+ VALUES (${id}, ${room}, ${JSON.stringify(card)}::jsonb, ${card.state ?? null}, ${card.keep === true})
57
66
  RETURNING *`;
58
67
  if (!row) throw new Error("the card log stored nothing");
59
68
  return cardOf(row);
@@ -67,7 +76,7 @@ export class HostCardLog {
67
76
  ): Promise<StoredHostCard | undefined> {
68
77
  const [row]: Row[] = await this.#sql`
69
78
  UPDATE web_host_cards
70
- SET card = ${JSON.stringify(card)}::jsonb, state = ${card.state ?? null}, updated_at = now()
79
+ SET card = ${JSON.stringify(card)}::jsonb, state = ${card.state ?? null}, keep = ${card.keep === true}, updated_at = now()
71
80
  WHERE id = ${id} AND room = ${room} RETURNING *`;
72
81
  return row && cardOf(row);
73
82
  }
@@ -85,11 +94,15 @@ export class HostCardLog {
85
94
  return rows.map(cardOf);
86
95
  }
87
96
 
88
- /** Deletes the cards of the rooms whose key starts with `prefix` that are older than `before`; how many. */
97
+ /**
98
+ * Deletes the cards of the rooms whose key starts with `prefix` that are older than `before`,
99
+ * except those the host marked `keep`; how many.
100
+ */
89
101
  async sweepKind(prefix: string, before: Date): Promise<number> {
90
102
  const rows: unknown[] = await this.#sql`
91
103
  DELETE FROM web_host_cards
92
- WHERE starts_with(room, ${prefix}) AND created_at < ${before} RETURNING 1`;
104
+ WHERE starts_with(room, ${prefix}) AND created_at < ${before}
105
+ AND NOT keep RETURNING 1`;
93
106
  return rows.length;
94
107
  }
95
108
 
@@ -54,22 +54,46 @@ function sourceOf(message: RoomMessage): Source {
54
54
  };
55
55
  }
56
56
 
57
+ /** What `WebApp.rooms.context` takes. */
58
+ export interface HostRoomContextOptions {
59
+ /** The most messages to return, newest kept; default 15, at most 100. */
60
+ limit?: number;
61
+ /** The most characters of one message before it is cut with `…`; default 500. */
62
+ messageChars?: number;
63
+ }
64
+
65
+ /** The two ways a host room's recent messages are read. */
66
+ export interface HostRoomContext {
67
+ /** For a turn the room's message addressed: marks what it returns as handed over. */
68
+ read(
69
+ message: InboundMessage,
70
+ options?: ChannelContextOptions,
71
+ ): Promise<ChannelContext | undefined>;
72
+ /** For a host with no addressing message: the same block, and nothing is marked handed over. */
73
+ peek(
74
+ room: string,
75
+ options?: HostRoomContextOptions,
76
+ ): Promise<ChannelContext | undefined>;
77
+ }
78
+
57
79
  /**
58
- * The app surface's `channelContext` for a host room: what was said in it since the assistant
80
+ * The app surface's channel context of a host room: what was said in it since the assistant
59
81
  * last posted, before the message that addressed it, as the core's block of channel messages. An
60
82
  * outsider's words read as `external`, a notice line is not in the channel as far as the context
61
83
  * goes, and a message already handed to the conversation is not handed again. Anything but a host
62
- * room has none: an agent room is one person's.
84
+ * room has none: an agent room is one person's. `peek` reads the same block for the end of the
85
+ * room and leaves the handed-over marks alone, so a retry gets the same.
63
86
  */
64
- export function hostChannelContext(
87
+ export function hostRoomContext(
65
88
  ctx: Pick<HostContext, "registry" | "log">,
66
- ): (
67
- message: InboundMessage,
68
- options?: ChannelContextOptions,
69
- ) => Promise<ChannelContext | undefined> {
89
+ ): HostRoomContext {
70
90
  const seen = new Map<string, Set<string>>();
71
- return async (message, options = {}) => {
72
- const room = message.channel;
91
+ const build = async (
92
+ room: string,
93
+ addressed: number | undefined,
94
+ options: ChannelContextOptions,
95
+ consume: boolean,
96
+ ): Promise<ChannelContext | undefined> => {
73
97
  if (!ctx.registry.isHostRoom(room)) return undefined;
74
98
  const fetch = positive("fetch", options.fetch ?? DEFAULT_FETCH, PAGE_LIMIT);
75
99
  const keep = positive("keep", options.keep ?? DEFAULT_KEEP, 100);
@@ -78,12 +102,7 @@ export function hostChannelContext(
78
102
  options.messageChars ?? DEFAULT_CHARS,
79
103
  20_000,
80
104
  );
81
- const addressed = /^app-(\d+)$/.exec(message.messageId)?.[1];
82
- const page = await ctx.log.page(
83
- room,
84
- addressed === undefined ? undefined : Number(addressed),
85
- fetch,
86
- );
105
+ const page = await ctx.log.page(room, addressed, fetch);
87
106
  const sources = page.messages.map((one) => ({
88
107
  source: sourceOf(one),
89
108
  notice: authorKindOf(one) === "notice",
@@ -99,24 +118,26 @@ export function hostChannelContext(
99
118
  const lastEnd = considered.findLastIndex(
100
119
  (entry) => verdict(entry) === "end",
101
120
  );
102
- const known = seen.get(room) ?? new Set<string>();
121
+ const known = consume ? (seen.get(room) ?? new Set<string>()) : undefined;
103
122
  const fresh = considered
104
123
  .slice(lastEnd + 1)
105
124
  .filter(
106
125
  (entry) =>
107
126
  verdict(entry) === "keep" &&
108
- !known.has(entry.source.id) &&
127
+ !known?.has(entry.source.id) &&
109
128
  entry.source.text !== "",
110
129
  )
111
130
  .slice(-keep);
112
- // What was in the window is not handed over again, kept or not.
113
- for (const entry of considered.slice(lastEnd + 1))
114
- known.add(entry.source.id);
115
- seen.delete(room);
116
- seen.set(room, new Set([...known].slice(-SEEN_PER_ROOM)));
117
- if (seen.size > SEEN_ROOMS) {
118
- const oldest = seen.keys().next().value;
119
- if (oldest !== undefined) seen.delete(oldest);
131
+ if (known) {
132
+ // What was in the window is not handed over again, kept or not.
133
+ for (const entry of considered.slice(lastEnd + 1))
134
+ known.add(entry.source.id);
135
+ seen.delete(room);
136
+ seen.set(room, new Set([...known].slice(-SEEN_PER_ROOM)));
137
+ if (seen.size > SEEN_ROOMS) {
138
+ const oldest = seen.keys().next().value;
139
+ if (oldest !== undefined) seen.delete(oldest);
140
+ }
120
141
  }
121
142
  if (fresh.length === 0) return undefined;
122
143
  const messages: ChannelContextMessage[] = fresh.map(({ source }) => ({
@@ -138,4 +159,30 @@ export function hostChannelContext(
138
159
  }));
139
160
  return { messages, text: formatChannelContext(messages) };
140
161
  };
162
+ return {
163
+ read(message, options = {}) {
164
+ const addressed = /^app-(\d+)$/.exec(message.messageId)?.[1];
165
+ return build(
166
+ message.channel,
167
+ addressed === undefined ? undefined : Number(addressed),
168
+ options,
169
+ true,
170
+ );
171
+ },
172
+ peek(room, options = {}) {
173
+ const limit = options.limit ?? DEFAULT_KEEP;
174
+ return build(
175
+ room,
176
+ undefined,
177
+ {
178
+ keep: limit,
179
+ fetch: Math.min(PAGE_LIMIT, Math.max(DEFAULT_FETCH, limit * 3)),
180
+ ...(options.messageChars === undefined
181
+ ? {}
182
+ : { messageChars: options.messageChars }),
183
+ },
184
+ false,
185
+ );
186
+ },
187
+ };
141
188
  }