pilotswarm 0.5.44 → 0.5.46

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 (91) hide show
  1. package/mcp/README.md +11 -0
  2. package/mcp/dist/src/context.d.ts.map +1 -1
  3. package/mcp/dist/src/context.js +9 -1
  4. package/mcp/dist/src/context.js.map +1 -1
  5. package/mcp/dist/src/tools/agent-packages.d.ts.map +1 -1
  6. package/mcp/dist/src/tools/agent-packages.js +31 -0
  7. package/mcp/dist/src/tools/agent-packages.js.map +1 -1
  8. package/mcp/dist/src/util/respond.d.ts.map +1 -1
  9. package/mcp/dist/src/util/respond.js +6 -0
  10. package/mcp/dist/src/util/respond.js.map +1 -1
  11. package/package.json +2 -2
  12. package/tui/src/agents-cli.js +35 -0
  13. package/tui/src/node-sdk-transport.js +54 -0
  14. package/ui/core/src/controller.js +48 -6
  15. package/ui/core/src/history.js +28 -10
  16. package/ui/core/src/selectors.js +38 -5
  17. package/ui/react/src/web-app.js +327 -24
  18. package/web/api/canvas-plane.js +18 -6
  19. package/web/api/ws.js +22 -2
  20. package/web/auth/index.js +8 -1
  21. package/web/auth/normalize/entra.js +5 -1
  22. package/web/dist/assets/{abnfDiagram-VRR7QNED-67Q0WWge.js → abnfDiagram-VRR7QNED-GXM5pEfT.js} +1 -1
  23. package/web/dist/assets/{arc-BvcYFngS.js → arc-D52-xt6J.js} +1 -1
  24. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CPBN0rk8.js → architectureDiagram-ZJ3FMSHR-Bxh49GyJ.js} +1 -1
  25. package/web/dist/assets/{blockDiagram-677ZJIJ3-D_hp1Oah.js → blockDiagram-677ZJIJ3-D8LSE94K.js} +1 -1
  26. package/web/dist/assets/{c4Diagram-LMCZKHZV-DAOVGMIW.js → c4Diagram-LMCZKHZV-Dcs1NVIx.js} +1 -1
  27. package/web/dist/assets/channel-DE0-pG_Z.js +1 -0
  28. package/web/dist/assets/{chunk-2Q5K7J3B-DajZuaot.js → chunk-2Q5K7J3B-Dy3zu7gI.js} +1 -1
  29. package/web/dist/assets/{chunk-32BRIVSS-C6pXBsgt.js → chunk-32BRIVSS-5v78VX3T.js} +1 -1
  30. package/web/dist/assets/{chunk-5VM5RSS4-780edrmi.js → chunk-5VM5RSS4-DUJMCfdj.js} +1 -1
  31. package/web/dist/assets/{chunk-EX3LRPZG-C0e4NVuz.js → chunk-EX3LRPZG-DYyT1So9.js} +1 -1
  32. package/web/dist/assets/{chunk-JWPE2WC7-uRe83I6C.js → chunk-JWPE2WC7-Buzzx5R2.js} +1 -1
  33. package/web/dist/assets/{chunk-MOJQB5TN-B0WPoPjh.js → chunk-MOJQB5TN-B8fOWRD7.js} +1 -1
  34. package/web/dist/assets/{chunk-RYQCIY6F-eRFuHHyG.js → chunk-RYQCIY6F-Bp04YO26.js} +1 -1
  35. package/web/dist/assets/{chunk-V7JOEXUC-C8FDXprk.js → chunk-V7JOEXUC-Gw6h8gPE.js} +1 -1
  36. package/web/dist/assets/{chunk-VR4S4FIN-DPFpLQdB.js → chunk-VR4S4FIN-ASf4dPE-.js} +1 -1
  37. package/web/dist/assets/{chunk-XXDRQBXY-CoGQOPKW.js → chunk-XXDRQBXY-C4B-MNNk.js} +1 -1
  38. package/web/dist/assets/classDiagram-OUVF2IWQ-B0o6by6m.js +1 -0
  39. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B0o6by6m.js +1 -0
  40. package/web/dist/assets/{cose-bilkent-JH36ORCC-BTBSfO5v.js → cose-bilkent-JH36ORCC-C0S415ey.js} +1 -1
  41. package/web/dist/assets/{cynefin-VYW2F7L2-DgTkQEoK.js → cynefin-VYW2F7L2-LWUlL1XB.js} +1 -1
  42. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-DiBMlKoP.js → cynefinDiagram-TSTJHNR4-ClnNDuqy.js} +1 -1
  43. package/web/dist/assets/{dagre-VKFMJZFB-Qv-4dcbk.js → dagre-VKFMJZFB-CRMTDGPv.js} +1 -1
  44. package/web/dist/assets/{diagram-FQU43EPY-CgyAb7yx.js → diagram-FQU43EPY-Cjt1nFI8.js} +1 -1
  45. package/web/dist/assets/{diagram-G47NLZAW-BxN-KcGp.js → diagram-G47NLZAW-DynrWO-8.js} +1 -1
  46. package/web/dist/assets/{diagram-NH7WQ7WH-BiellpWz.js → diagram-NH7WQ7WH-CkEgxg4T.js} +1 -1
  47. package/web/dist/assets/{diagram-OA4YK3LP-C8Ktgrwv.js → diagram-OA4YK3LP-DzB43wzH.js} +1 -1
  48. package/web/dist/assets/{diagram-WEI45ONY-Bj5K1X91.js → diagram-WEI45ONY-DqxH3-sW.js} +1 -1
  49. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DNQI2XT9.js → ebnfDiagram-CCIWWBDH-5NxDrqIa.js} +1 -1
  50. package/web/dist/assets/{erDiagram-Q63AITRT-D5Xkllu6.js → erDiagram-Q63AITRT-OfiKzPj4.js} +1 -1
  51. package/web/dist/assets/{flowDiagram-23GEKE2U-DJpTtMuj.js → flowDiagram-23GEKE2U-DsqfPM3s.js} +1 -1
  52. package/web/dist/assets/{ganttDiagram-NO4QXBWP-C-EQlCJf.js → ganttDiagram-NO4QXBWP-N48fLign.js} +1 -1
  53. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-C8vpSbvW.js → gitGraphDiagram-IHSO6WYX-rn1cXRYh.js} +1 -1
  54. package/web/dist/assets/index-BHp6FirC.js +24 -0
  55. package/web/dist/assets/index-DEymVbi5.css +1 -0
  56. package/web/dist/assets/{infoDiagram-FWYZ7A6U-CKNzsR3r.js → infoDiagram-FWYZ7A6U-CjJ3TLmy.js} +1 -1
  57. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CjyQq3lf.js → ishikawaDiagram-FXEZZL3T-DvCylaIQ.js} +1 -1
  58. package/web/dist/assets/{journeyDiagram-5HDEW3XC-CK3EEkJt.js → journeyDiagram-5HDEW3XC-B_Ethynx.js} +1 -1
  59. package/web/dist/assets/{kanban-definition-HUTT4EX6-CHQ8YkPx.js → kanban-definition-HUTT4EX6-BlQuhDqn.js} +1 -1
  60. package/web/dist/assets/{linear-CUYirs1h.js → linear-DIwvxXrN.js} +1 -1
  61. package/web/dist/assets/{mermaid.core-Di8sQC4g.js → mermaid.core-CfAsl9Pc.js} +4 -4
  62. package/web/dist/assets/{mindmap-definition-LN4V7U3C-D38dmHP5.js → mindmap-definition-LN4V7U3C-BxJep4HE.js} +1 -1
  63. package/web/dist/assets/{papaparse.min-DWBU_pXS.js → papaparse.min-BjO79nbw.js} +1 -1
  64. package/web/dist/assets/{pegDiagram-2B236MQR-BU759QCo.js → pegDiagram-2B236MQR-yceXoFi0.js} +1 -1
  65. package/web/dist/assets/{pieDiagram-ENE6RG2P-BbpfHbsj.js → pieDiagram-ENE6RG2P-BNBzpT5B.js} +1 -1
  66. package/web/dist/assets/pilotswarm-DewDyJhu.js +97 -0
  67. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-BYZ3XHZn.js → quadrantDiagram-ABIIQ3AL-DCg1DTax.js} +1 -1
  68. package/web/dist/assets/{railroadDiagram-RFXS5EU6-D18gkZBJ.js → railroadDiagram-RFXS5EU6-DQdy8xST.js} +1 -1
  69. package/web/dist/assets/{requirementDiagram-TGXJPOKE-D6vo26Ib.js → requirementDiagram-TGXJPOKE-N8PYSqS8.js} +1 -1
  70. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-CxQwXnRE.js → sankeyDiagram-HTMAVEWB-DK9WhfTA.js} +1 -1
  71. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-CfGisw4A.js → sequenceDiagram-DBY2YBRQ-DEpALkWi.js} +1 -1
  72. package/web/dist/assets/{sizeCapture-X5ZJPWSS-BDK7-yA-.js → sizeCapture-X5ZJPWSS-CKOEPe9x.js} +1 -1
  73. package/web/dist/assets/{stateDiagram-2N3HPSRC-8IyidYSV.js → stateDiagram-2N3HPSRC-Dwwyy0hH.js} +1 -1
  74. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-CRKYt8KU.js +1 -0
  75. package/web/dist/assets/{swimlanes-5IMT3BWC-CgNqXCvM.js → swimlanes-5IMT3BWC-hDoJpKdQ.js} +2 -2
  76. package/web/dist/assets/swimlanesDiagram-G3AALYLV-DtnCdOQI.js +8 -0
  77. package/web/dist/assets/{timeline-definition-FHXFAJF6-DAJsGj6d.js → timeline-definition-FHXFAJF6-CPXnQ7LH.js} +1 -1
  78. package/web/dist/assets/{vennDiagram-L72KCM5P-DDVdvG5k.js → vennDiagram-L72KCM5P-CYsOwGTT.js} +1 -1
  79. package/web/dist/assets/{wardleyDiagram-EHGQE667-CiAs_m72.js → wardleyDiagram-EHGQE667-DKh__oEb.js} +1 -1
  80. package/web/dist/assets/{xychartDiagram-FW5EYKEG-D6OLJtsD.js → xychartDiagram-FW5EYKEG-Bws7l8k9.js} +1 -1
  81. package/web/dist/index.html +3 -3
  82. package/web/runtime.js +180 -6
  83. package/web/server.js +22 -0
  84. package/web/dist/assets/channel-DiW7lUT8.js +0 -1
  85. package/web/dist/assets/classDiagram-OUVF2IWQ-CpAhBd5x.js +0 -1
  86. package/web/dist/assets/classDiagram-v2-EOCWNBFH-CpAhBd5x.js +0 -1
  87. package/web/dist/assets/index-C2ioXNJJ.js +0 -24
  88. package/web/dist/assets/index-CVYm02UI.css +0 -1
  89. package/web/dist/assets/pilotswarm-DrM3pXOV.js +0 -97
  90. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-F7qYkvpr.js +0 -1
  91. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CUKrHLkY.js +0 -8
@@ -2950,16 +2950,28 @@ export function selectCanvasView(state) {
2950
2950
  }
2951
2951
 
2952
2952
  /**
2953
- * A validated canvas response on its way to the agent. Flagged so the PORTAL
2954
- * chat skips it (the canvas redraw is the visible half of the loop) while the
2955
- * TUI — which renders no canvas and would otherwise show a silent gap in the
2956
- * conversation — gets a compact dim line.
2953
+ * A validated canvas response on its way to the agent. Flagged `canvasAction`
2954
+ * so each host renders it its own way: the TUI prints the compact dim line
2955
+ * below, while the portal collapses it to a single row that opens to show the
2956
+ * payload. Both need the same facts, so the action name and the full payload
2957
+ * ride on the line rather than only inside the truncated runs.
2957
2958
  */
2958
2959
  function buildCanvasActionLine(message) {
2959
2960
  const dataKeys = message?.data && typeof message.data === "object" ? Object.keys(message.data) : [];
2960
2961
  const detail = dataKeys.length === 1 && typeof message.data[dataKeys[0]] === "string"
2961
2962
  ? message.data[dataKeys[0]].slice(0, 120)
2962
2963
  : (dataKeys.length ? JSON.stringify(message.data).slice(0, 120) : "");
2964
+ let payload = "";
2965
+ if (dataKeys.length) {
2966
+ // A page can post anything that survives structured clone; JSON.stringify
2967
+ // throws on a cycle or a BigInt, and this runs inside the transcript
2968
+ // render path where a throw blanks the whole conversation.
2969
+ try {
2970
+ payload = JSON.stringify(message.data, null, 2);
2971
+ } catch {
2972
+ payload = "";
2973
+ }
2974
+ }
2963
2975
  return {
2964
2976
  runs: [
2965
2977
  { text: "[canvas] ", color: "cyan", bold: true },
@@ -2967,6 +2979,10 @@ function buildCanvasActionLine(message) {
2967
2979
  ...(detail ? [{ text: ` — ${detail}`, color: "gray" }] : []),
2968
2980
  ],
2969
2981
  canvasAction: true,
2982
+ canvasActionName: message?.action || "action",
2983
+ canvasActionDetail: detail,
2984
+ canvasActionPayload: payload,
2985
+ canvasActionTime: message?.time || "",
2970
2986
  };
2971
2987
  }
2972
2988
 
@@ -4005,6 +4021,11 @@ export function selectAdminConsole(state) {
4005
4021
  && pkg.owner.provider === principal.provider
4006
4022
  && pkg.owner.subject === principal.subject),
4007
4023
  );
4024
+ // Editor-level: may change contents and rollout (publish, pin,
4025
+ // enable/disable). The server computes `canEdit` per viewer (admin, owner
4026
+ // or a granted editor); ownsPackage stays the owner-only gate for scope,
4027
+ // delete and the editor list.
4028
+ const canEditPackage = (pkg) => ownsPackage(pkg) || Boolean(pkg?.canEdit);
4008
4029
  // Built once per render, not per row.
4009
4030
  const ownerDirectory = ownerDirectoryFromSessions(state?.sessions?.byId);
4010
4031
  // Same rule as the session list: the owner chip only earns its place when
@@ -4046,7 +4067,7 @@ export function selectAdminConsole(state) {
4046
4067
  semver: pkg.active?.semver || null,
4047
4068
  sha7: pkg.active?.sha256 ? String(pkg.active.sha256).slice(0, 7) : null,
4048
4069
  agentCount: Array.isArray(pkg.active?.manifest?.agents) ? pkg.active.manifest.agents.length : 0,
4049
- canManage: ownsPackage(pkg),
4070
+ canManage: canEditPackage(pkg),
4050
4071
  // A user-scope package belongs to a PERSON, so it carries the same
4051
4072
  // owner-initials chip the session rows use. Shared packages belong to
4052
4073
  // the deployment and keep the scope badge instead.
@@ -4157,13 +4178,25 @@ export function selectAdminConsole(state) {
4157
4178
  && installedRowFor(worker)?.status === "ok").length
4158
4179
  : 0;
4159
4180
  const canManage = detail ? ownsPackage(detail) : (summary ? ownsPackage(summary) : false);
4181
+ const canEdit = detail ? canEditPackage(detail) : (summary ? canEditPackage(summary) : false);
4160
4182
  packageDetail = {
4161
4183
  name: pkgState.selectedName,
4162
4184
  loading: Boolean(pkgState.detailLoading),
4163
4185
  error: pkgState.detailError || null,
4164
4186
  scope: (detail?.scope || summary?.scope) === "shared" ? "shared" : "user",
4165
4187
  enabled: detail ? Boolean(detail.enabled) : Boolean(summary?.enabled),
4188
+ // canManage = owner/admin (scope, delete, editors);
4189
+ // canEdit = canManage OR granted editor (publish, pin, enable).
4166
4190
  canManage,
4191
+ canEdit,
4192
+ editors: Array.isArray(detail?.editors)
4193
+ ? detail.editors.map((e) => ({
4194
+ provider: e.provider,
4195
+ subject: e.subject,
4196
+ label: e.displayName || e.email || e.subject,
4197
+ grantedByDisplay: e.grantedByDisplay || null,
4198
+ }))
4199
+ : [],
4167
4200
  createdBy: detail?.createdBy || summary?.createdBy || null,
4168
4201
  createdAtText: adminPkgDate(detail?.createdAt || summary?.createdAt),
4169
4202
  description: typeof manifest.description === "string" ? manifest.description : "",
@@ -1257,6 +1257,33 @@ function SystemNoticeLine({ line, theme }) {
1257
1257
  : null);
1258
1258
  }
1259
1259
 
1260
+ /**
1261
+ * A canvas action is a button press inside the drawn page, not something the
1262
+ * viewer typed. Printing the raw `[canvas-action] {…}` JSON as a chat message
1263
+ * buries the conversation in plumbing, so it renders as one collapsed row —
1264
+ * the same affordance as a system notice — that opens to show the exact
1265
+ * payload the page sent.
1266
+ */
1267
+ function CanvasActionLine({ line, theme }) {
1268
+ const payload = String(line?.canvasActionPayload || "").trim();
1269
+ const name = line?.canvasActionName || "action";
1270
+ const detail = String(line?.canvasActionDetail || "").trim();
1271
+ const time = String(line?.canvasActionTime || "").trim();
1272
+ return React.createElement("details", { className: "ps-system-notice ps-canvas-action" },
1273
+ React.createElement("summary", {
1274
+ className: "ps-system-notice-summary ps-canvas-action-summary",
1275
+ style: { color: resolveColor(theme, "gray") || "var(--ps-muted)" },
1276
+ },
1277
+ React.createElement("span", { className: "ps-canvas-action-tag" }, "canvas"),
1278
+ React.createElement("span", { className: "ps-system-notice-summary-text" },
1279
+ `${name}${detail ? ` — ${detail}` : ""}`),
1280
+ time ? React.createElement("span", { className: "ps-canvas-action-time" }, time) : null),
1281
+ payload
1282
+ ? React.createElement("div", { className: "ps-system-notice-body" },
1283
+ React.createElement("pre", { className: "ps-canvas-action-payload" }, payload))
1284
+ : null);
1285
+ }
1286
+
1260
1287
  /**
1261
1288
  * Coalesce high-frequency dispatches to one per animation frame.
1262
1289
  *
@@ -1303,6 +1330,11 @@ const Line = React.memo(function Line({ line, theme, className = "" }) {
1303
1330
  if (line.kind === "systemNotice") {
1304
1331
  return React.createElement(SystemNoticeLine, { line, theme });
1305
1332
  }
1333
+ // Checked before `kind`: the canvas-action line carries runs for the TUI
1334
+ // but the portal renders it as its own collapsed row, not as those runs.
1335
+ if (line.canvasAction) {
1336
+ return React.createElement(CanvasActionLine, { line, theme });
1337
+ }
1306
1338
  if (line.kind === "runs") {
1307
1339
  return React.createElement("div", { className: lineClassName },
1308
1340
  React.createElement(Runs, { runs: line.runs, theme }));
@@ -3448,8 +3480,12 @@ function StructuredChatBlocks({ lines, theme, controller = null }) {
3448
3480
  // for a host that cannot render the canvas). In the browser the canvas
3449
3481
  // pane updating IS the signal, so the chat skips them outright — desktop
3450
3482
  // and mobile share this renderer, so one filter covers both.
3483
+ //
3484
+ // canvasAction lines are NOT skipped: pressing a button in the drawn page
3485
+ // is a real thing the viewer did, and dropping it leaves an unexplained
3486
+ // gap before the agent's reply. Line renders it as one collapsed row.
3451
3487
  const blocks = React.useMemo(
3452
- () => parseStructuredChatBlocks((lines || []).filter((line) => !line?.canvasUpdate && !line?.canvasAction)),
3488
+ () => parseStructuredChatBlocks((lines || []).filter((line) => !line?.canvasUpdate)),
3453
3489
  [lines],
3454
3490
  );
3455
3491
  return React.createElement(StructuredBlockList, { blocks, theme, controller });
@@ -6576,6 +6612,13 @@ function CanvasShareDialog({ controller, sessionId, slot, onClose }) {
6576
6612
  // making, and stacking them put a destructive Reset next to an innocuous
6577
6613
  // deep link. Mirrors the Manage-session dialog's General/Access tabs.
6578
6614
  const [tab, setTab] = React.useState("session");
6615
+ // The canvas KV write policy (interactive-canvas-apps Part D): who may
6616
+ // write the app's shared state. null = not loaded / not supported.
6617
+ const [kvPolicy, setKvPolicy] = React.useState(null);
6618
+ // Who the VIEWER is on this canvas. Only an owner or an admin may change
6619
+ // the policy, so the control is disabled for everyone else rather than
6620
+ // letting them pick a value and then showing a refusal.
6621
+ const [kvMe, setKvMe] = React.useState(null);
6579
6622
  const transport = controller.transport;
6580
6623
  const sessionLink = `${window.location.origin}/?session=${encodeURIComponent(sessionId)}&view=canvas&slot=${slot}&max=1`;
6581
6624
  // The token variants all resolve the same hashed row: one token, N doors,
@@ -6588,9 +6631,24 @@ function CanvasShareDialog({ controller, sessionId, slot, onClose }) {
6588
6631
  transport.getCanvasShareLink?.(sessionId, slot)
6589
6632
  .then((state) => setInfo(state || { exists: false }))
6590
6633
  .catch((err) => { setInfo({ exists: false }); setError(err?.message || String(err)); });
6634
+ if (typeof transport.readCanvasKv === "function") {
6635
+ transport.readCanvasKv(sessionId, slot, { limit: 1 })
6636
+ .then((read) => { setKvPolicy(read?.policy || "owner"); setKvMe(read?.me || null); })
6637
+ .catch(() => { setKvPolicy(null); setKvMe(null); });
6638
+ }
6591
6639
  }, [transport, sessionId, slot]);
6592
6640
  React.useEffect(() => { refresh(); }, [refresh]);
6593
6641
 
6642
+ const canSetKvPolicy = kvMe?.relation === "owner" || kvMe?.relation === "admin";
6643
+
6644
+ const setPolicy = (value) => {
6645
+ setBusy(true); setError("");
6646
+ transport.setCanvasKvAccess(sessionId, slot, value)
6647
+ .then(() => setKvPolicy(value))
6648
+ .catch((err) => setError(err?.message || String(err)))
6649
+ .finally(() => setBusy(false));
6650
+ };
6651
+
6594
6652
  const copy = (label, text) => {
6595
6653
  navigator.clipboard?.writeText(text)
6596
6654
  .then(() => { setCopied(label); window.setTimeout(() => setCopied(""), 1500); })
@@ -6648,7 +6706,34 @@ function CanvasShareDialog({ controller, sessionId, slot, onClose }) {
6648
6706
  role: "tabpanel", "aria-hidden": tab === "session" ? undefined : "true",
6649
6707
  },
6650
6708
  React.createElement("div", { className: "ps-canvas-share-sub" }, "Opens the portal signed in, canvas full screen. Session visibility rules apply."),
6651
- linkRows(sessionLink, "session")),
6709
+ linkRows(sessionLink, "session"),
6710
+ // Who may WRITE the app's shared state. Stated in words:
6711
+ // "readers" reaches everyone the session is read-shared
6712
+ // with, which a dialog that only says "link" hides.
6713
+ kvPolicy !== null
6714
+ ? React.createElement("div", { className: "ps-canvas-share-policy" },
6715
+ React.createElement("label", { className: "ps-canvas-share-sub", htmlFor: "ps-canvas-kv-policy" }, "Who can edit inside this app"),
6716
+ React.createElement("select", {
6717
+ id: "ps-canvas-kv-policy",
6718
+ className: "ps-canvas-slot-select",
6719
+ value: kvPolicy,
6720
+ disabled: busy || !canSetKvPolicy,
6721
+ onChange: (event) => setPolicy(event.target.value),
6722
+ },
6723
+ React.createElement("option", { value: "owner" }, "Only people who can edit the session"),
6724
+ React.createElement("option", { value: "readers" }, "Anyone who can open the session"),
6725
+ React.createElement("option", { value: "link" }, "Anyone who can open the session, plus link viewers")),
6726
+ React.createElement("div", { className: "ps-canvas-share-sub" },
6727
+ kvPolicy === "owner"
6728
+ ? "You, admins, and anyone you shared the session with as “write”. People with view-only access can watch the app but not change anything in it."
6729
+ : kvPolicy === "link"
6730
+ ? "Your view-only people can edit inside the app. Anyone holding the share link can watch it — link holders can never edit."
6731
+ : "Adds your view-only people: they can edit inside the app. It does not let them message the agent."),
6732
+ canSetKvPolicy
6733
+ ? null
6734
+ : React.createElement("div", { className: "ps-canvas-share-sub" },
6735
+ "Only the session owner or an admin can change this."))
6736
+ : null),
6652
6737
  React.createElement("div", {
6653
6738
  className: `ps-canvas-share-panel${tab === "public" ? " is-active" : ""}`,
6654
6739
  role: "tabpanel", "aria-hidden": tab === "public" ? undefined : "true",
@@ -6753,6 +6838,9 @@ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible
6753
6838
  const autoRetryRef = React.useRef({ rev: 0, used: false });
6754
6839
  const hostRef = React.useRef(null);
6755
6840
  const liveIframeElRef = React.useRef(null);
6841
+ // The staging frame's element, so the KV bridge can answer a page's
6842
+ // `ready` posted at script time (before promote). See the bridge below.
6843
+ const stagingIframeElRef = React.useRef(null);
6756
6844
  const hostBox = useElementBox(hostRef);
6757
6845
  // The frame geometry each revision LOADED at. A sandboxed page cannot be
6758
6846
  // poked after the fact (no reaching into an opaque origin), and pages
@@ -6779,18 +6867,99 @@ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible
6779
6867
  if (!controller || !sessionId) return undefined;
6780
6868
  const onMessage = (event) => {
6781
6869
  const frameEl = liveIframeElRef.current;
6782
- if (!frameEl || !event.source || event.source !== frameEl.contentWindow) return;
6783
6870
  const payload = event.data;
6784
- if (!payload || typeof payload !== "object" || payload.type !== "canvas-action") return;
6785
- controller.submitCanvasAction(sessionId, payload).then((result) => {
6786
- if (result && result.ok === false && typeof console !== "undefined") {
6787
- console.warn(`canvas action rejected: ${result.reason}`);
6871
+ if (!payload || typeof payload !== "object" || !event.source) return;
6872
+ const fromLive = Boolean(frameEl && event.source === frameEl.contentWindow);
6873
+ // A document runs its scripts in the STAGING frame first and is
6874
+ // promoted on load — so a page that calls CanvasKV() at script
6875
+ // time posts `ready` before this frame is live. KV READS are
6876
+ // safe to answer from the staging frame of THIS canvas (it holds
6877
+ // the revision about to go live; the reply goes back to the
6878
+ // window that asked). Writes and actions stay live-frame only.
6879
+ const fromStaging = Boolean(stagingIframeElRef.current && event.source === stagingIframeElRef.current.contentWindow);
6880
+ if (!fromLive && !fromStaging) return;
6881
+ if (payload.type === "canvas-action") {
6882
+ if (!fromLive) return;
6883
+ controller.submitCanvasAction(sessionId, payload).then((result) => {
6884
+ if (result && result.ok === false && typeof console !== "undefined") {
6885
+ console.warn(`canvas action rejected: ${result.reason}`);
6886
+ }
6887
+ }).catch(() => {});
6888
+ return;
6889
+ }
6890
+ // The KV bridge (door 1). The page never names the session or the
6891
+ // slot — both come from THIS frame — and never sends `by`: the
6892
+ // server stamps it from the signed-in principal.
6893
+ if (payload.type === "canvas-kv") {
6894
+ const id = payload.id;
6895
+ const transport = controller.transport;
6896
+ // Reply to the window that ASKED (live or staging), never to
6897
+ // whichever frame is live by the time the request lands.
6898
+ const source = event.source;
6899
+ const post = (message) => {
6900
+ try { source.postMessage(message, "*"); } catch { /* frame mid-teardown */ }
6901
+ };
6902
+ const fail = (error, code = "ERROR") => post({ type: "canvas-kv-result", id, ok: false, error: String(error?.message || error || "failed"), code: error?.code || code });
6903
+ if (typeof transport?.readCanvasKv !== "function") { fail("the canvas KV store is not available on this deployment", "UNAVAILABLE"); return; }
6904
+ const op = String(payload.op || "");
6905
+ if (!fromLive && (op === "put" || op === "delete")) {
6906
+ fail("the canvas is still loading; retry the write in a moment", "NOT_READY");
6907
+ return;
6788
6908
  }
6789
- }).catch(() => {});
6909
+ if (op === "ready" || op === "list") {
6910
+ transport.readCanvasKv(sessionId, slot, { prefix: payload.prefix ?? null, limit: payload.limit ?? null, after: payload.after ?? null })
6911
+ .then((read) => post({
6912
+ type: op === "ready" ? "canvas-kv-ready" : "canvas-kv-result",
6913
+ id, ok: true,
6914
+ entries: read?.entries ?? [], nextAfter: read?.nextAfter ?? null,
6915
+ me: read?.me ?? null, canWrite: Boolean(read?.me?.canWrite), policy: read?.policy ?? "owner",
6916
+ }))
6917
+ .catch(fail);
6918
+ return;
6919
+ }
6920
+ if (op === "get") {
6921
+ transport.readCanvasKv(sessionId, slot, { key: payload.key })
6922
+ .then((read) => post({ type: "canvas-kv-result", id, ok: true, entry: read?.entries?.[0] ?? null }))
6923
+ .catch(fail);
6924
+ return;
6925
+ }
6926
+ if (op === "put" || op === "delete") {
6927
+ const one = op === "put"
6928
+ ? { op: "put", key: payload.key, value: payload.value, ...(payload.ifMatch != null ? { ifMatch: payload.ifMatch } : {}) }
6929
+ : { op: "delete", key: payload.key, ...(payload.ifMatch != null ? { ifMatch: payload.ifMatch } : {}) };
6930
+ transport.writeCanvasKv(sessionId, slot, [one])
6931
+ .then((written) => {
6932
+ const r = written?.results?.[0] ?? { ok: false, error: "no result" };
6933
+ post({ type: "canvas-kv-result", id, ok: Boolean(r.ok), key: r.key, rev: r.rev, capped: r.capped, error: r.error, code: r.code });
6934
+ })
6935
+ .catch(fail);
6936
+ return;
6937
+ }
6938
+ fail(`unknown canvas-kv op ${op}`, "INVALID_REQUEST");
6939
+ }
6790
6940
  };
6791
6941
  window.addEventListener("message", onMessage);
6792
6942
  return () => window.removeEventListener("message", onMessage);
6793
- }, [controller, sessionId]);
6943
+ }, [controller, sessionId, slot]);
6944
+
6945
+ // Live KV changes for this slot → the page, as `canvas-kv-change`. A
6946
+ // pointer-only ping (value too big for the notify) fetches that one key.
6947
+ React.useEffect(() => {
6948
+ if (!controller || !sessionId || typeof controller.subscribeCanvasKv !== "function") return undefined;
6949
+ return controller.subscribeCanvasKv(sessionId, (change) => {
6950
+ if (!change || Number(change.slot) !== Number(slot)) return;
6951
+ const frameWindow = liveIframeElRef.current?.contentWindow;
6952
+ if (!frameWindow) return;
6953
+ const deliver = (entry) => {
6954
+ try { frameWindow.postMessage({ type: "canvas-kv-change", key: change.key, rev: change.rev, op: change.op, ...(entry ? { v: entry.v, by: entry.by, at: entry.at } : {}) }, "*"); } catch { /* frame mid-teardown */ }
6955
+ };
6956
+ if (change.op === "delete") { deliver(null); return; }
6957
+ if (change.value && typeof change.value === "object") { deliver({ v: change.value.v, by: change.value.by, at: change.value.at }); return; }
6958
+ controller.transport?.readCanvasKv?.(sessionId, slot, { key: change.key })
6959
+ .then((read) => deliver(read?.entries?.[0] ?? null))
6960
+ .catch(() => { /* the page's next list catches up */ });
6961
+ });
6962
+ }, [controller, sessionId, slot]);
6794
6963
 
6795
6964
  React.useEffect(() => {
6796
6965
  if (!controller || !sessionId || !latestRev) return undefined;
@@ -6971,7 +7140,9 @@ function CanvasFrame({ controller, sessionId, slot = 1, latestRev, zoom, visible
6971
7140
  return React.createElement("div", { ref: hostRef, className: "ps-html-preview-host is-fitting" },
6972
7141
  [live, staging].filter(Boolean).map((frame) => React.createElement("iframe", {
6973
7142
  key: `canvas:${frame.rev}:${frame.url}`,
6974
- ref: frame === live ? ((el) => { liveIframeElRef.current = el; }) : undefined,
7143
+ ref: frame === live
7144
+ ? ((el) => { liveIframeElRef.current = el; })
7145
+ : ((el) => { stagingIframeElRef.current = el; }),
6975
7146
  className: `ps-html-preview-frame${frame === live ? "" : " is-staging"}`,
6976
7147
  style: zoomStyle,
6977
7148
  src: frame.url,
@@ -9938,14 +10109,42 @@ function ProviderSystemSpend({ view }) {
9938
10109
  ? React.createElement("div", { className: "ps-budget-empty is-error", role: "alert" }, spend.error)
9939
10110
  : null,
9940
10111
  spend.loaded ? React.createElement(React.Fragment, null,
9941
- React.createElement("div", { className: "ps-budget-system-total" },
9942
- React.createElement("strong", null, `${spend.tokensLabel} tokens`),
9943
- React.createElement("span", { className: "ps-budget-sub" }, ` · ${budgetPlural(spend.turns, "turn", "turns")}`)),
10112
+ // The headline: total tokens and turns as two stat tiles, then a
10113
+ // per-model table with a share bar so the mix reads at a glance
10114
+ // (77.9M of 94.7M on one model is the fact that matters).
10115
+ React.createElement("div", { className: "ps-budget-system-stats" },
10116
+ React.createElement("div", { className: "ps-budget-system-stat" },
10117
+ React.createElement("strong", null, spend.tokensLabel),
10118
+ React.createElement("span", null, "tokens")),
10119
+ React.createElement("div", { className: "ps-budget-system-stat" },
10120
+ React.createElement("strong", null, formatCompactNumber(spend.turns)),
10121
+ React.createElement("span", null, spend.turns === 1 ? "turn" : "turns")),
10122
+ spend.turns > 0
10123
+ ? React.createElement("div", { className: "ps-budget-system-stat" },
10124
+ React.createElement("strong", null, formatCompactNumber(Math.round(spend.tokens / spend.turns))),
10125
+ React.createElement("span", null, "tokens / turn"))
10126
+ : null),
9944
10127
  spend.models.length
9945
- ? React.createElement("div", { className: "ps-budget-system-models" },
9946
- spend.models.map((model) => React.createElement("div", { key: model.key, className: "ps-budget-system-model" },
9947
- React.createElement("span", null, modelLabel(model.label)),
9948
- React.createElement("strong", null, `${model.tokensLabel} · ${budgetPlural(model.turns, "turn", "turns")}`))))
10128
+ ? React.createElement("table", { className: "ps-budget-system-models" },
10129
+ React.createElement("thead", null,
10130
+ React.createElement("tr", null,
10131
+ React.createElement("th", { scope: "col" }, "Model"),
10132
+ React.createElement("th", { scope: "col", className: "is-num" }, "Tokens"),
10133
+ React.createElement("th", { scope: "col", className: "is-share" }, "Share"),
10134
+ React.createElement("th", { scope: "col", className: "is-num" }, "Turns"))),
10135
+ React.createElement("tbody", null,
10136
+ spend.models.map((model) => {
10137
+ const share = spend.tokens > 0 ? model.tokens / spend.tokens : 0;
10138
+ const pct = Math.round(share * 100);
10139
+ return React.createElement("tr", { key: model.key },
10140
+ React.createElement("td", { className: "ps-budget-system-model-name" }, modelLabel(model.label)),
10141
+ React.createElement("td", { className: "is-num" }, model.tokensLabel),
10142
+ React.createElement("td", { className: "is-share" },
10143
+ React.createElement("span", { className: "ps-budget-system-bar", role: "img", "aria-label": `${pct}% of system spend` },
10144
+ React.createElement("span", { className: "ps-budget-system-bar-fill", style: { width: `${Math.max(share > 0 ? 1 : 0, share * 100)}%` } })),
10145
+ React.createElement("span", { className: "ps-budget-system-pct" }, `${pct}%`)),
10146
+ React.createElement("td", { className: "is-num" }, formatCompactNumber(model.turns)));
10147
+ })))
9949
10148
  : React.createElement("div", { className: "ps-budget-sub" }, "No system spend in this range.")) : null);
9950
10149
  }
9951
10150
 
@@ -12003,13 +12202,18 @@ function AdminPackageDetailPane({ controller, view }) {
12003
12202
  React.createElement("span", null, version.dateText),
12004
12203
  version.active
12005
12204
  ? React.createElement("span", { className: "is-ok" }, "● active")
12006
- : (detail.canManage
12205
+ : (detail.canEdit
12007
12206
  ? React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act("pin", version.semver), disabled: Boolean(pending) }, "Pin")
12008
12207
  : null)))))
12009
12208
  : null,
12010
- detail.canManage
12209
+ // Editors live on the shared copy only. Everyone sees the list;
12210
+ // only the owner/admin changes it.
12211
+ detail.scope === "shared"
12212
+ ? React.createElement(AdminPackageEditorsSection, { controller, detail, pending })
12213
+ : null,
12214
+ detail.canEdit
12011
12215
  ? React.createElement("div", { className: "ps-admin-detail__actions" },
12012
- detail.source
12216
+ detail.source && detail.canManage
12013
12217
  ? React.createElement("button", { type: "button", className: "ps-primary-button", onClick: act("sync"), disabled: Boolean(pending) },
12014
12218
  pending === "sync" ? "Syncing…" : "Sync now")
12015
12219
  : null,
@@ -12034,8 +12238,11 @@ function AdminPackageDetailPane({ controller, view }) {
12034
12238
  disabled: Boolean(pending) || !detail.activeSemver,
12035
12239
  title: `Publish ${detail.name}@${detail.activeSemver ?? "?"} into the existing shared package`,
12036
12240
  }, pending === "republish" ? "Publishing…" : `Publish ${detail.activeSemver ?? ""} to shared`)
12037
- : React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act(detail.scope === "shared" ? "demote" : "promote"), disabled: Boolean(pending) },
12038
- detail.scope === "shared" ? "Demote to user" : "Promote to shared"),
12241
+ : (detail.canManage
12242
+ ? React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act(detail.scope === "shared" ? "demote" : "promote"), disabled: Boolean(pending),
12243
+ title: detail.scope === "shared" && detail.editors.length ? "Demoting revokes every editor grant" : undefined },
12244
+ detail.scope === "shared" ? "Demote to user" : "Promote to shared")
12245
+ : null),
12039
12246
  detail.scope === "shared" && !detail.hasOwnUserCounterpart
12040
12247
  ? React.createElement("button", {
12041
12248
  type: "button",
@@ -12047,14 +12254,110 @@ function AdminPackageDetailPane({ controller, view }) {
12047
12254
  : null,
12048
12255
  React.createElement("button", { type: "button", className: "ps-mini-button", onClick: act(detail.enabled ? "disable" : "enable"), disabled: Boolean(pending) },
12049
12256
  detail.enabled ? "Disable" : "Enable"),
12050
- React.createElement("button", { type: "button", className: "ps-mini-button is-danger", onClick: confirmDelete, disabled: Boolean(pending) }, "Delete"))
12257
+ detail.canManage
12258
+ ? React.createElement("button", { type: "button", className: "ps-mini-button is-danger", onClick: confirmDelete, disabled: Boolean(pending) }, "Delete")
12259
+ : null)
12051
12260
  : React.createElement("p", { className: "ps-admin-console__hint" },
12052
- "Read-only: only the package creator or an admin can modify it."),
12261
+ "Read-only: only the package creator, a granted editor, or an admin can modify it."),
12053
12262
  detail.actionError
12054
12263
  ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" }, detail.actionError)
12055
12264
  : null);
12056
12265
  }
12057
12266
 
12267
+ /**
12268
+ * Editors of a SHARED package: named users who may publish, pin and
12269
+ * enable/disable it (never scope, delete, or this list). Everyone who can
12270
+ * see the package sees the list; only the owner/admin changes it. Grantees
12271
+ * are picked from the same member directory the session-share dialog uses.
12272
+ */
12273
+ function AdminPackageEditorsSection({ controller, detail, pending }) {
12274
+ const [query, setQuery] = React.useState("");
12275
+ const [directory, setDirectory] = React.useState([]);
12276
+ React.useEffect(() => {
12277
+ if (!detail.canManage || typeof controller.transport.listKnownUsers !== "function") return undefined;
12278
+ let cancelled = false;
12279
+ controller.transport.listKnownUsers({ limit: 500 })
12280
+ .then((users) => { if (!cancelled) setDirectory(Array.isArray(users) ? users : []); })
12281
+ .catch(() => {});
12282
+ return () => { cancelled = true; };
12283
+ }, [controller, detail.canManage]);
12284
+
12285
+ const editorKeys = new Set(detail.editors.map((e) => `${e.provider}${e.subject}`));
12286
+ const q = query.trim().toLowerCase();
12287
+ const suggestions = q
12288
+ ? directory.filter((u) => !editorKeys.has(`${u.provider}${u.subject}`) && (
12289
+ (u.displayName && u.displayName.toLowerCase().includes(q))
12290
+ || (u.email && u.email.toLowerCase().includes(q))
12291
+ || (u.subject && u.subject.toLowerCase().includes(q)))).slice(0, 8)
12292
+ : [];
12293
+ const grant = (user) => {
12294
+ if (!user?.provider || !user?.subject) return;
12295
+ setQuery("");
12296
+ controller.runAdminPackageAction("grantEditor", { provider: user.provider, subject: user.subject });
12297
+ };
12298
+ // Enter/Add grants only an EXACT match, or the single remaining
12299
+ // suggestion. A typo must never hand write access to whoever happens
12300
+ // to sort first in the directory — the person picks from the list.
12301
+ const grantTyped = () => {
12302
+ const text = query.trim();
12303
+ if (!text) return;
12304
+ const exact = directory.find((u) =>
12305
+ (u.displayName && u.displayName.toLowerCase() === text.toLowerCase())
12306
+ || (u.email && u.email.toLowerCase() === text.toLowerCase())
12307
+ || (u.subject && u.subject.toLowerCase() === text.toLowerCase()));
12308
+ if (exact) return grant(exact);
12309
+ if (suggestions.length === 1) return grant(suggestions[0]);
12310
+ };
12311
+
12312
+ return React.createElement("div", { className: "ps-admin-editors" },
12313
+ React.createElement("div", { className: "ps-admin-detail__label" }, "Editors"),
12314
+ detail.editors.length
12315
+ ? React.createElement("div", { className: "ps-admin-detail__chips" },
12316
+ detail.editors.map((e) => React.createElement("span", {
12317
+ key: `${e.provider}${e.subject}`,
12318
+ className: "ps-agent-chip",
12319
+ title: `${e.provider}:${e.subject}${e.grantedByDisplay ? ` · granted by ${e.grantedByDisplay}` : ""}`,
12320
+ },
12321
+ e.label,
12322
+ detail.canManage
12323
+ ? React.createElement("button", {
12324
+ type: "button",
12325
+ className: "ps-chip-remove",
12326
+ "aria-label": `Remove editor ${e.label}`,
12327
+ disabled: Boolean(pending),
12328
+ onClick: () => controller.runAdminPackageAction("revokeEditor", { provider: e.provider, subject: e.subject }),
12329
+ }, "×")
12330
+ : null)))
12331
+ : React.createElement("p", { className: "ps-admin-console__hint" },
12332
+ detail.canManage
12333
+ ? "No editors yet. Add a person to let them publish, pin and enable this package. Grants are revoked if the package is demoted."
12334
+ : "Only the owner and admins can change this package."),
12335
+ detail.canManage
12336
+ ? React.createElement("div", { className: "ps-admin-editors__add" },
12337
+ React.createElement("input", {
12338
+ type: "text",
12339
+ className: "ps-input",
12340
+ placeholder: "Add editor by name, email, or id…",
12341
+ value: query,
12342
+ disabled: Boolean(pending),
12343
+ onChange: (event) => setQuery(event.target.value),
12344
+ onKeyDown: (event) => { if (event.key === "Enter") { event.preventDefault(); grantTyped(); } },
12345
+ "aria-label": "Add editor",
12346
+ }),
12347
+ React.createElement("button", { type: "button", className: "ps-mini-button", onClick: grantTyped, disabled: Boolean(pending) || !query.trim() }, "Add"),
12348
+ suggestions.length
12349
+ ? React.createElement("div", { className: "ps-admin-editors__suggestions", role: "listbox" },
12350
+ suggestions.map((u) => React.createElement("button", {
12351
+ key: `${u.provider}${u.subject}`,
12352
+ type: "button",
12353
+ role: "option",
12354
+ className: "ps-admin-editors__suggestion",
12355
+ onClick: () => grant(u),
12356
+ }, u.displayName || u.email || u.subject, u.email && u.displayName ? React.createElement("i", null, ` ${u.email}`) : null)))
12357
+ : null)
12358
+ : null);
12359
+ }
12360
+
12058
12361
  function AdminPackageWorkspacePane({ controller, view, layout = null, onLayout = null }) {
12059
12362
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
12060
12363
  const theme = getTheme(themeId);
@@ -54,12 +54,24 @@ export function createCanvasPlane({
54
54
  if (payload?.schema && payload.schema !== schema) return;
55
55
  const handlers = subscribers.get(payload?.sessionId);
56
56
  if (!handlers) return;
57
- const update = {
58
- slot: Number(payload.slot),
59
- seq: Number(payload.seq),
60
- kind: payload.kind === "doc" ? "doc" : "data",
61
- ...(payload.patch && typeof payload.patch === "object" ? { patch: payload.patch } : {}),
62
- };
57
+ // The KV branch is a separate dispatch path from doc/data: one
58
+ // `rev` per KEY (max-wins), never the slot's seq chain. Feeding
59
+ // per-key revs into seq would read as a gap and resync-storm.
60
+ const update = payload.kind === "kv"
61
+ ? {
62
+ slot: Number(payload.slot),
63
+ kind: "kv",
64
+ key: String(payload.key ?? ""),
65
+ rev: Number(payload.rev),
66
+ op: payload.op === "delete" ? "delete" : "put",
67
+ ...(payload.value !== undefined ? { value: payload.value } : {}),
68
+ }
69
+ : {
70
+ slot: Number(payload.slot),
71
+ seq: Number(payload.seq),
72
+ kind: payload.kind === "doc" ? "doc" : "data",
73
+ ...(payload.patch && typeof payload.patch === "object" ? { patch: payload.patch } : {}),
74
+ };
63
75
  for (const cb of handlers) {
64
76
  try { cb(update); } catch { /* one bad socket must not stop the fan-out */ }
65
77
  }
package/web/api/ws.js CHANGED
@@ -33,6 +33,15 @@ export function createConnectionHandler(runtime, { allowThemeMessages = false }
33
33
  let shareScope = null;
34
34
  let shareRevalidateTimer = null;
35
35
  let auth = null;
36
+ // Messages that arrive while auth is still being resolved (a client
37
+ // that sends `subscribeCanvas` on `open`) must not be lost: the real
38
+ // handler is attached only after the awaits below. Buffer them and
39
+ // replay once it is. Measured before this: the share view lost its
40
+ // subscribe about half the time.
41
+ const early = [];
42
+ let buffering = true;
43
+ const earlyListener = (raw) => { if (buffering) early.push(raw); };
44
+ ws.on("message", earlyListener);
36
45
  if (shareToken) {
37
46
  if (typeof runtime.resolveCanvasShareToken === "function") {
38
47
  shareScope = await runtime.resolveCanvasShareToken(shareToken).catch(() => null);
@@ -84,6 +93,15 @@ export function createConnectionHandler(runtime, { allowThemeMessages = false }
84
93
 
85
94
  send({ type: "ready" });
86
95
 
96
+ // The real handler is registered synchronously right below. Stop
97
+ // buffering NOW (so nothing is delivered twice) and replay what was
98
+ // buffered after this tick, when the handler exists.
99
+ buffering = false;
100
+ setImmediate(() => {
101
+ ws.off("message", earlyListener);
102
+ for (const raw of early.splice(0)) ws.emit("message", raw, false);
103
+ });
104
+
87
105
  ws.on("message", async (raw) => {
88
106
  let message;
89
107
  try {
@@ -109,12 +127,14 @@ export function createConnectionHandler(runtime, { allowThemeMessages = false }
109
127
  send({ type: "error", scope: "canvas", sessionId, error: "canvas plane unavailable" });
110
128
  return;
111
129
  }
130
+ // A bearer is never told the session id: the token IS the
131
+ // address, and the share view does not need it.
112
132
  const unsubscribe = plane.subscribe(sessionId, (update) => {
113
133
  if (Number(update?.slot) !== shareScope.slot) return;
114
- send({ type: "canvasLive", sessionId, ...update });
134
+ send({ type: "canvasLive", ...update });
115
135
  });
116
136
  canvasSubscriptions.set(sessionId, unsubscribe);
117
- send({ type: "subscribedCanvas", sessionId });
137
+ send({ type: "subscribedCanvas" });
118
138
  } else if (type === "unsubscribeCanvas") {
119
139
  const unsubscribe = canvasSubscriptions.get(shareScope.sessionId);
120
140
  if (unsubscribe) {