pilotswarm 0.5.30 → 0.5.32

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 (83) hide show
  1. package/package.json +2 -2
  2. package/tui/src/node-sdk-transport.js +4 -0
  3. package/ui/core/src/controller.js +102 -30
  4. package/ui/core/src/reducer.js +50 -1
  5. package/ui/core/src/selectors.js +68 -22
  6. package/ui/core/src/state.js +2 -0
  7. package/ui/core/src/themes/doom.js +106 -0
  8. package/ui/core/src/themes/helpers.js +7 -1
  9. package/ui/core/src/themes/index.js +4 -0
  10. package/ui/core/src/themes/winamp.js +104 -0
  11. package/ui/react/src/components.js +25 -0
  12. package/ui/react/src/web-app.js +320 -51
  13. package/web/api/ws.js +7 -0
  14. package/web/authz.js +14 -81
  15. package/web/dist/assets/{abnfDiagram-VRR7QNED-Cimc0f-u.js → abnfDiagram-VRR7QNED-mXFQ4a3c.js} +1 -1
  16. package/web/dist/assets/{arc-D8-_Dx5J.js → arc-BPuuvbge.js} +1 -1
  17. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-lzMAuQHJ.js → architectureDiagram-ZJ3FMSHR-Dt4uUYIe.js} +1 -1
  18. package/web/dist/assets/{blockDiagram-677ZJIJ3-Dp2CWjVS.js → blockDiagram-677ZJIJ3-Ax8Q22vV.js} +1 -1
  19. package/web/dist/assets/{c4Diagram-LMCZKHZV-DF93UkOy.js → c4Diagram-LMCZKHZV-CPiukyHt.js} +1 -1
  20. package/web/dist/assets/channel-BCzDctYg.js +1 -0
  21. package/web/dist/assets/{chunk-2Q5K7J3B-pbsQRM-F.js → chunk-2Q5K7J3B-VJLqCZJX.js} +1 -1
  22. package/web/dist/assets/{chunk-32BRIVSS-B_f-gG1O.js → chunk-32BRIVSS-DqOshxhf.js} +1 -1
  23. package/web/dist/assets/{chunk-5VM5RSS4-gqlpJDmt.js → chunk-5VM5RSS4-KPqV21jV.js} +1 -1
  24. package/web/dist/assets/{chunk-EX3LRPZG-Cms7hPmM.js → chunk-EX3LRPZG-DfhiCVl_.js} +1 -1
  25. package/web/dist/assets/{chunk-JWPE2WC7-Boq5PtPI.js → chunk-JWPE2WC7-H6khJPuW.js} +1 -1
  26. package/web/dist/assets/{chunk-MOJQB5TN-DVn9i8zt.js → chunk-MOJQB5TN-BKMAzQxj.js} +1 -1
  27. package/web/dist/assets/{chunk-RYQCIY6F-a1vKs3UX.js → chunk-RYQCIY6F-DNXRTxtj.js} +1 -1
  28. package/web/dist/assets/{chunk-V7JOEXUC-i60p5fHd.js → chunk-V7JOEXUC-B56PM6Gv.js} +1 -1
  29. package/web/dist/assets/{chunk-VR4S4FIN-a8p0o-8B.js → chunk-VR4S4FIN-Boi3hGkB.js} +1 -1
  30. package/web/dist/assets/{chunk-XXDRQBXY-BwP1yBKd.js → chunk-XXDRQBXY-DmJyn890.js} +1 -1
  31. package/web/dist/assets/classDiagram-OUVF2IWQ-ChqjVZil.js +1 -0
  32. package/web/dist/assets/classDiagram-v2-EOCWNBFH-ChqjVZil.js +1 -0
  33. package/web/dist/assets/{cose-bilkent-JH36ORCC-HW6kGgze.js → cose-bilkent-JH36ORCC-BRdFlFWt.js} +1 -1
  34. package/web/dist/assets/{cynefin-VYW2F7L2-YUFMn-vE.js → cynefin-VYW2F7L2-DqE-xib2.js} +1 -1
  35. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-Brdy17i4.js → cynefinDiagram-TSTJHNR4-D-QBZjIL.js} +1 -1
  36. package/web/dist/assets/{dagre-VKFMJZFB-CAiSJJj-.js → dagre-VKFMJZFB-DYu9oQV6.js} +1 -1
  37. package/web/dist/assets/{diagram-FQU43EPY-B63Cqe0J.js → diagram-FQU43EPY-DOwYccCg.js} +1 -1
  38. package/web/dist/assets/{diagram-G47NLZAW-ByktrDJ2.js → diagram-G47NLZAW-DTSEmrPr.js} +1 -1
  39. package/web/dist/assets/{diagram-NH7WQ7WH-BLSfRMoE.js → diagram-NH7WQ7WH-OqbzzquA.js} +1 -1
  40. package/web/dist/assets/{diagram-OA4YK3LP-DNVu-zpk.js → diagram-OA4YK3LP-BMVG6A8I.js} +1 -1
  41. package/web/dist/assets/{diagram-WEI45ONY-CNf4Sz-I.js → diagram-WEI45ONY-Rn2Q5xaz.js} +1 -1
  42. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-9CT3ergx.js → ebnfDiagram-CCIWWBDH-aL32_v5y.js} +1 -1
  43. package/web/dist/assets/{erDiagram-Q63AITRT-BPJHAMgl.js → erDiagram-Q63AITRT-DKCpyczx.js} +1 -1
  44. package/web/dist/assets/{flowDiagram-23GEKE2U-BVtpr6Iu.js → flowDiagram-23GEKE2U-CrZTPgR_.js} +1 -1
  45. package/web/dist/assets/{ganttDiagram-NO4QXBWP-BTHnv9sV.js → ganttDiagram-NO4QXBWP-DnSE1OtC.js} +1 -1
  46. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-BfmtqbL5.js → gitGraphDiagram-IHSO6WYX-C5SeYPUR.js} +1 -1
  47. package/web/dist/assets/{index-Bmb8qK39.js → index-B-TEKrRB.js} +6 -6
  48. package/web/dist/assets/index-D2B5bzSN.css +1 -0
  49. package/web/dist/assets/{infoDiagram-FWYZ7A6U-kU4i4HRu.js → infoDiagram-FWYZ7A6U-DDnJZp0X.js} +1 -1
  50. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-Ch7i-E8O.js → ishikawaDiagram-FXEZZL3T-ewhzfFkq.js} +1 -1
  51. package/web/dist/assets/{journeyDiagram-5HDEW3XC-CPQxp0ps.js → journeyDiagram-5HDEW3XC-T0iWWkLM.js} +1 -1
  52. package/web/dist/assets/{kanban-definition-HUTT4EX6-BBgMVqoJ.js → kanban-definition-HUTT4EX6-D1MFQZjb.js} +1 -1
  53. package/web/dist/assets/{linear-rwksvzXJ.js → linear-BKfPK9wE.js} +1 -1
  54. package/web/dist/assets/{mermaid.core-o7PszRUc.js → mermaid.core-CyzD7dTc.js} +4 -4
  55. package/web/dist/assets/{mindmap-definition-LN4V7U3C-wgOTdzlq.js → mindmap-definition-LN4V7U3C-CWXtcTlP.js} +1 -1
  56. package/web/dist/assets/{papaparse.min-BRXtjEI6.js → papaparse.min-DLUoV5k5.js} +1 -1
  57. package/web/dist/assets/{pegDiagram-2B236MQR-BkhQqqTm.js → pegDiagram-2B236MQR-BFjZun-r.js} +1 -1
  58. package/web/dist/assets/{pieDiagram-ENE6RG2P-CcMnvxUz.js → pieDiagram-ENE6RG2P-DMDtW4g7.js} +1 -1
  59. package/web/dist/assets/pilotswarm-DA0u7Cq_.js +99 -0
  60. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-_KD1QeEr.js → quadrantDiagram-ABIIQ3AL-CAwPVaYL.js} +1 -1
  61. package/web/dist/assets/{railroadDiagram-RFXS5EU6-CqI0olIe.js → railroadDiagram-RFXS5EU6-Co102PkQ.js} +1 -1
  62. package/web/dist/assets/{requirementDiagram-TGXJPOKE-1JxRwusF.js → requirementDiagram-TGXJPOKE-D19VlRSl.js} +1 -1
  63. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-9IpFoKzi.js → sankeyDiagram-HTMAVEWB-Sd_a3ukL.js} +1 -1
  64. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-teyObSQ9.js → sequenceDiagram-DBY2YBRQ-CojDRK1c.js} +1 -1
  65. package/web/dist/assets/{sizeCapture-X5ZJPWSS-DVhU2J_6.js → sizeCapture-X5ZJPWSS-XqXcxnDo.js} +1 -1
  66. package/web/dist/assets/{stateDiagram-2N3HPSRC-DHPoWpIQ.js → stateDiagram-2N3HPSRC-BcN_w699.js} +1 -1
  67. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BV_w1D3C.js +1 -0
  68. package/web/dist/assets/{swimlanes-5IMT3BWC-BxTtDQoB.js → swimlanes-5IMT3BWC-BcO6ai_I.js} +2 -2
  69. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BnYdP0qP.js +8 -0
  70. package/web/dist/assets/{timeline-definition-FHXFAJF6-D0wXZx-4.js → timeline-definition-FHXFAJF6-DOPfYxUq.js} +1 -1
  71. package/web/dist/assets/{vennDiagram-L72KCM5P-BSFyI3iV.js → vennDiagram-L72KCM5P-BSKLkEOC.js} +1 -1
  72. package/web/dist/assets/{wardleyDiagram-EHGQE667-BN-S7hh0.js → wardleyDiagram-EHGQE667-BdcYAVw2.js} +1 -1
  73. package/web/dist/assets/{xychartDiagram-FW5EYKEG-nDNuPs7o.js → xychartDiagram-FW5EYKEG-C6EJT4jB.js} +1 -1
  74. package/web/dist/index.html +3 -3
  75. package/web/runtime.js +55 -0
  76. package/web/server.js +7 -0
  77. package/web/dist/assets/channel-OwWeiCox.js +0 -1
  78. package/web/dist/assets/classDiagram-OUVF2IWQ-CDaP7nuC.js +0 -1
  79. package/web/dist/assets/classDiagram-v2-EOCWNBFH-CDaP7nuC.js +0 -1
  80. package/web/dist/assets/index-B37T1c2s.css +0 -1
  81. package/web/dist/assets/pilotswarm-DYXtzuUN.js +0 -99
  82. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DJjZWYLS.js +0 -1
  83. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CV12vDcK.js +0 -8
@@ -95,21 +95,23 @@ const INSPECTOR_TAB_LABELS = {
95
95
  sequence: "Sequence",
96
96
  logs: "Logs",
97
97
  nodes: "Node Map",
98
- history: "History",
98
+ history: "Duroxide Event History",
99
99
  files: "Files",
100
100
  stats: "Stats",
101
101
  };
102
102
  // Glyphs for the icon-only Inspector tab row (labels move into hover/long-press
103
103
  // tooltips via IconButton, matching the session toolbar treatment).
104
- // Monochrome line-art codepoints only — emoji-default glyphs (📄 📊 🗑 ⏹) get
105
- // force-rendered as colored emoji on iOS and clash with the rest of the UI.
104
+ // COMPONENTS, not codepoints: the text glyphs this row used to carry rendered a
105
+ // stroke-weight lighter than the SVG session-toolbar icons, fell back per
106
+ // platform, and duplicated each other ("≣" was both Logs and the header Summary
107
+ // button). Function declarations hoist, so referencing them up here is fine.
106
108
  const INSPECTOR_TAB_ICONS = {
107
- sequence: "⇶",
108
- logs: "≣",
109
- nodes: "⬡",
110
- history: "⟲",
111
- files: "⧉",
112
- stats: "▁▄▇",
109
+ sequence: SequenceGlyph,
110
+ logs: LogsGlyph,
111
+ nodes: NodeMapGlyph,
112
+ history: DuroxideGlyph,
113
+ files: FilesGlyph,
114
+ stats: StatsGlyph,
113
115
  };
114
116
 
115
117
  function cycleTabs(tabs, current, delta) {
@@ -3470,7 +3472,8 @@ function SessionRowContent({ row, theme, structured = false, showInlineDetail =
3470
3472
  return React.createElement(React.Fragment, null,
3471
3473
  plain.statusColor ? React.createElement(StatusDot, { color: plain.statusColor, node: row.depth > 0 }) : null,
3472
3474
  React.createElement(OwnerAvatar, { badge: row.ownerBadge }),
3473
- React.createElement(Runs, { runs: plain.rest, theme }));
3475
+ React.createElement("span", { className: "ps-session-row-title__text" },
3476
+ React.createElement(Runs, { runs: plain.rest, theme })));
3474
3477
  }
3475
3478
 
3476
3479
  // Dense row: the title takes one line (clamped by CSS) and the context %
@@ -3491,7 +3494,13 @@ function SessionRowContent({ row, theme, structured = false, showInlineDetail =
3491
3494
  React.createElement("div", { className: "ps-session-row-title" },
3492
3495
  title.statusColor ? React.createElement(StatusDot, { color: title.statusColor, node: row.depth > 0 }) : null,
3493
3496
  React.createElement(OwnerAvatar, { badge: row.ownerBadge }),
3494
- React.createElement(Runs, { runs: title.rest, theme })),
3497
+ // Runs wrapped in ONE element: the title is a flex row so the
3498
+ // avatar centres against the text, and the runs keep normal
3499
+ // inline flow inside the wrapper. Flexing the runs directly
3500
+ // collapses the whitespace BETWEEN them and silently narrows
3501
+ // the title (measured: 225.8px → 210.8px).
3502
+ React.createElement("span", { className: "ps-session-row-title__text" },
3503
+ React.createElement(Runs, { runs: title.rest, theme }))),
3495
3504
  hasCtx
3496
3505
  ? React.createElement("div", { className: "ps-session-row-ctx" },
3497
3506
  React.createElement(Runs, { runs: ctxRuns, theme }))
@@ -3532,10 +3541,86 @@ function useStableValue(value) {
3532
3541
  return ref.current;
3533
3542
  }
3534
3543
 
3544
+ /**
3545
+ * Vertical-primary touch scrolling with an explicit horizontal opt-in.
3546
+ *
3547
+ * The session list has `width: max-content` rows, so it overflows sideways
3548
+ * whenever a title is long. Declaring `touch-action: pan-x pan-y` told the
3549
+ * browser both axes were equally on offer, and on a phone a swipe is never
3550
+ * perfectly vertical — so scrolling down kept sliding the list sideways.
3551
+ *
3552
+ * The fix is to give the browser ONE axis (`touch-action: pan-y`, set in CSS,
3553
+ * which keeps native vertical momentum) and drive horizontal ourselves, only
3554
+ * for a gesture that is unambiguously sideways. Once a gesture commits to an
3555
+ * axis it keeps it until the finger lifts: to scroll across you stop and make
3556
+ * a deliberate left/right swipe, which is exactly the intent.
3557
+ */
3558
+ const PAN_COMMIT_PX = 10; // ignore the first few px — every swipe starts noisy
3559
+ const PAN_HORIZONTAL_RATIO = 1.6; // |dx| must beat |dy| by this much to go sideways
3560
+
3561
+ function useHorizontalPanOptIn(ref) {
3562
+ React.useEffect(() => {
3563
+ const el = ref.current;
3564
+ if (!el) return undefined;
3565
+
3566
+ let startX = 0;
3567
+ let startY = 0;
3568
+ let lastX = 0;
3569
+ let axis = null; // null = undecided, "x" | "y" = committed for this gesture
3570
+
3571
+ const onTouchStart = (event) => {
3572
+ if (event.touches.length !== 1) { axis = "y"; return; }
3573
+ const touch = event.touches[0];
3574
+ startX = lastX = touch.clientX;
3575
+ startY = touch.clientY;
3576
+ axis = null;
3577
+ };
3578
+
3579
+ const onTouchMove = (event) => {
3580
+ if (axis === "y" || event.touches.length !== 1) return;
3581
+ const touch = event.touches[0];
3582
+ const dx = touch.clientX - startX;
3583
+ const dy = touch.clientY - startY;
3584
+
3585
+ if (axis === null) {
3586
+ if (Math.abs(dx) < PAN_COMMIT_PX && Math.abs(dy) < PAN_COMMIT_PX) return;
3587
+ // Ties, and anything close to a tie, go to vertical. Vertical is
3588
+ // the primary axis; sideways has to be asked for.
3589
+ axis = Math.abs(dx) > Math.abs(dy) * PAN_HORIZONTAL_RATIO ? "x" : "y";
3590
+ lastX = touch.clientX;
3591
+ if (axis === "y") return;
3592
+ }
3593
+
3594
+ // Committed horizontal: the browser is not panning this axis, so we
3595
+ // do it. preventDefault stops the gesture also scrolling an
3596
+ // ancestor sideways.
3597
+ el.scrollLeft -= touch.clientX - lastX;
3598
+ lastX = touch.clientX;
3599
+ if (event.cancelable) event.preventDefault();
3600
+ };
3601
+
3602
+ const onTouchEnd = () => { axis = null; };
3603
+
3604
+ el.addEventListener("touchstart", onTouchStart, { passive: true });
3605
+ el.addEventListener("touchmove", onTouchMove, { passive: false });
3606
+ el.addEventListener("touchend", onTouchEnd, { passive: true });
3607
+ el.addEventListener("touchcancel", onTouchEnd, { passive: true });
3608
+ return () => {
3609
+ el.removeEventListener("touchstart", onTouchStart);
3610
+ el.removeEventListener("touchmove", onTouchMove);
3611
+ el.removeEventListener("touchend", onTouchEnd);
3612
+ el.removeEventListener("touchcancel", onTouchEnd);
3613
+ };
3614
+ }, [ref]);
3615
+ }
3616
+
3535
3617
  function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false }) {
3536
3618
  // Mobile keeps its inline detail line and gets no detail box — a reserved
3537
3619
  // footer would eat a meaningful slice of a phone screen.
3538
3620
  const isMobilePane = String(panelClassName).includes("ps-mobile-session-pane");
3621
+ // Vertical is the primary scroll axis; sideways takes a deliberate swipe.
3622
+ const sessionListRef = React.useRef(null);
3623
+ useHorizontalPanOptIn(sessionListRef);
3539
3624
  // Selection reverts to plain taps wherever the primary input is a finger:
3540
3625
  // the mobile pane, and the chat-focus overlay's list on a phone. Matches
3541
3626
  // the `(pointer: fine)` guard on touch-action in the stylesheet.
@@ -4127,7 +4212,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4127
4212
  }),
4128
4213
  React.createElement(IconButton, {
4129
4214
  className: "ps-mini-button",
4130
- icon: activeSession?.isSystem ? "↻" : React.createElement(TerminateGlyph),
4215
+ icon: React.createElement(activeSession?.isSystem ? RestartGlyph : TerminateGlyph),
4131
4216
  onClick: () => controller.handleCommand(activeSession?.isGroup ? UI_COMMANDS.DELETE_SESSION : UI_COMMANDS.OPEN_TERMINATE_PICKER).catch(() => {}),
4132
4217
  disabled: !canTerminate,
4133
4218
  label: isBulkSelection
@@ -4170,6 +4255,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4170
4255
  className: combinedPanelClassName,
4171
4256
  },
4172
4257
  React.createElement("div", {
4258
+ ref: sessionListRef,
4173
4259
  className: `ps-action-list ps-session-list${dragState.dragging ? " is-dragging" : ""}`,
4174
4260
  // Clicking empty space below the rows clears the list selection while
4175
4261
  // the other panes keep showing the session. With nothing selected the
@@ -4264,20 +4350,189 @@ const VISIBILITY_META = {
4264
4350
  shared_write: { glyph: "✎", label: "Shared · write" },
4265
4351
  };
4266
4352
 
4267
- // The "manage" glyph (sliders) for the Manage session button — reads as
4268
- // settings/controls rather than the narrower share-nodes glyph.
4269
- function ManageGlyph() {
4353
+ // Shared wrapper for the line-art icon family. Same props PinGlyph shipped
4354
+ // inline, so every glyph inherits .ps-share-glyph sizing and currentColor.
4355
+ function Glyph({ children }) {
4270
4356
  return React.createElement("svg", {
4271
4357
  className: "ps-share-glyph", viewBox: "0 0 24 24", fill: "none",
4272
4358
  stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round",
4273
4359
  "aria-hidden": "true",
4360
+ }, children);
4361
+ }
4362
+
4363
+ // The "manage" glyph for the Manage session button. A wrench, not sliders:
4364
+ // sliders are the second most common FILTER icon in the wild, so they collided
4365
+ // with the funnel on the header toolbar. A wrench reads as "configure this one
4366
+ // session" and stays distinct from the cog, which opens the admin console.
4367
+ function ManageGlyph() {
4368
+ return React.createElement(Glyph, null,
4369
+ React.createElement("path", { d: "M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" }));
4370
+ }
4371
+
4372
+ // Filter — the funnel. Plain, with no "a filter is applied" dot or active
4373
+ // state: there is no cheap way to tell a narrowed list from the default one.
4374
+ // The owner filter starts at `all: false` for every signed-in user (see
4375
+ // defaultOwnerFilterForPrincipal), so any "is it filtered" test built on it
4376
+ // reads true from the moment the app loads and the button looks stuck on.
4377
+ function FunnelGlyph() {
4378
+ return React.createElement(Glyph, null,
4379
+ React.createElement("path", { d: "M21 4H3l7.2 8.5V19l3.6 2v-8.5L21 4z" }));
4380
+ }
4381
+
4382
+ // Summary — a written summary. Was "≣", the same codepoint the Logs tab used.
4383
+ function SummaryGlyph() {
4384
+ return React.createElement(Glyph, null,
4385
+ React.createElement("path", { d: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" }),
4386
+ React.createElement("path", { d: "M14 3v5h5" }),
4387
+ React.createElement("line", { x1: "8.5", y1: "13", x2: "15.5", y2: "13" }),
4388
+ React.createElement("line", { x1: "8.5", y1: "17", x2: "13", y2: "17" }));
4389
+ }
4390
+
4391
+ // Back to the transcript. Replaces the 💬 emoji, which iOS force-renders in
4392
+ // colour against an otherwise monochrome row (see the note on the tab table).
4393
+ function TranscriptGlyph() {
4394
+ return React.createElement(Glyph, null,
4395
+ React.createElement("path", { d: "M21 14.5a2.5 2.5 0 0 1-2.5 2.5H8l-5 4V5.5A2.5 2.5 0 0 1 5.5 3h13A2.5 2.5 0 0 1 21 5.5z" }));
4396
+ }
4397
+
4398
+ function PlusGlyph() {
4399
+ return React.createElement(Glyph, null,
4400
+ React.createElement("line", { x1: "12", y1: "5", x2: "12", y2: "19" }),
4401
+ React.createElement("line", { x1: "5", y1: "12", x2: "19", y2: "12" }));
4402
+ }
4403
+
4404
+ // Theme picker. Deliberately a contrast disc rather than a sun/moon: the picker
4405
+ // offers DOOM, WinAMP, win95 and friends, not a light/dark binary.
4406
+ function ContrastGlyph() {
4407
+ return React.createElement(Glyph, null,
4408
+ React.createElement("circle", { cx: "12", cy: "12", r: "9" }),
4409
+ React.createElement("path", { d: "M12 3a9 9 0 0 1 0 18z", fill: "currentColor" }));
4410
+ }
4411
+
4412
+ function ExpandGlyph() {
4413
+ return React.createElement(Glyph, null,
4414
+ React.createElement("path", { d: "M8 3H5a2 2 0 0 0-2 2v3" }),
4415
+ React.createElement("path", { d: "M16 3h3a2 2 0 0 1 2 2v3" }),
4416
+ React.createElement("path", { d: "M21 16v3a2 2 0 0 1-2 2h-3" }),
4417
+ React.createElement("path", { d: "M3 16v3a2 2 0 0 0 2 2h3" }));
4418
+ }
4419
+
4420
+ function CollapseGlyph() {
4421
+ return React.createElement(Glyph, null,
4422
+ React.createElement("path", { d: "M8 3v3a2 2 0 0 1-2 2H3" }),
4423
+ React.createElement("path", { d: "M21 8h-3a2 2 0 0 1-2-2V3" }),
4424
+ React.createElement("path", { d: "M16 21v-3a2 2 0 0 1 2-2h3" }),
4425
+ React.createElement("path", { d: "M3 16h3a2 2 0 0 1 2 2v3" }));
4426
+ }
4427
+
4428
+ function CogGlyph() {
4429
+ return React.createElement(Glyph, null,
4430
+ React.createElement("circle", { cx: "12", cy: "12", r: "3" }),
4431
+ React.createElement("path", { d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" }));
4432
+ }
4433
+
4434
+ // Restart a system session. Was "↻" — the one text codepoint in an otherwise
4435
+ // all-SVG row, so it rendered a weight lighter and sat a pixel high.
4436
+ function RestartGlyph() {
4437
+ return React.createElement(Glyph, null,
4438
+ React.createElement("path", { d: "M21 12a9 9 0 1 1-2.64-6.36" }),
4439
+ React.createElement("path", { d: "M21 3v5h-5" }));
4440
+ }
4441
+
4442
+ function UploadGlyph() {
4443
+ return React.createElement(Glyph, null,
4444
+ React.createElement("path", { d: "M12 16V4" }),
4445
+ React.createElement("path", { d: "M7.5 8.5L12 4l4.5 4.5" }),
4446
+ React.createElement("path", { d: "M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" }));
4447
+ }
4448
+
4449
+ function DownloadGlyph() {
4450
+ return React.createElement(Glyph, null,
4451
+ React.createElement("path", { d: "M12 4v12" }),
4452
+ React.createElement("path", { d: "M7.5 11.5L12 16l4.5-4.5" }),
4453
+ React.createElement("path", { d: "M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" }));
4454
+ }
4455
+
4456
+ // ── Inspector tab glyphs ────────────────────────────────────────────────────
4457
+
4458
+ // Two lifelines with a call and a return — an actual sequence diagram, rather
4459
+ // than the "⇶" stack of arrows, which only said "forward". The lifelines sit at
4460
+ // x4.5/x19.5 (a 15-unit gap): at the original 10 the arrowheads all but touched
4461
+ // them and the glyph read as one dense block at 18px.
4462
+ function SequenceGlyph() {
4463
+ return React.createElement(Glyph, null,
4464
+ React.createElement("line", { x1: "4.5", y1: "3", x2: "4.5", y2: "21" }),
4465
+ React.createElement("line", { x1: "19.5", y1: "3", x2: "19.5", y2: "21" }),
4466
+ React.createElement("path", { d: "M4.5 8.5h13" }),
4467
+ React.createElement("path", { d: "M15 6l2.5 2.5-2.5 2.5" }),
4468
+ React.createElement("path", { d: "M19.5 15.5h-13" }),
4469
+ React.createElement("path", { d: "M9 13l-2.5 2.5 2.5 2.5" }));
4470
+ }
4471
+
4472
+ // A side panel with its list rail — fronts the mobile "Sessions" toggle, which
4473
+ // opens exactly that. Paired with CollapseGlyph for "Exit focus" so the two
4474
+ // rail buttons match the icon vocabulary of the main toolbar.
4475
+ function SidebarGlyph() {
4476
+ return React.createElement(Glyph, null,
4477
+ React.createElement("path", { d: "M3 5.5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" }),
4478
+ React.createElement("path", { d: "M9.5 3.5v17" }));
4479
+ }
4480
+
4481
+ // Logs are console output; a prompt chevron says that and nothing else.
4482
+ function LogsGlyph() {
4483
+ return React.createElement(Glyph, null,
4484
+ React.createElement("path", { d: "M4 17l6-6-6-6" }),
4485
+ React.createElement("line", { x1: "12", y1: "19", x2: "20", y2: "19" }));
4486
+ }
4487
+
4488
+ // Three connected nodes. A bare "⬡" was a shape, not a map.
4489
+ function NodeMapGlyph() {
4490
+ return React.createElement(Glyph, null,
4491
+ React.createElement("circle", { cx: "6", cy: "6", r: "2.6" }),
4492
+ React.createElement("circle", { cx: "18", cy: "6", r: "2.6" }),
4493
+ React.createElement("circle", { cx: "12", cy: "18", r: "2.6" }),
4494
+ React.createElement("line", { x1: "8.6", y1: "6", x2: "15.4", y2: "6" }),
4495
+ React.createElement("line", { x1: "7.4", y1: "8.3", x2: "10.6", y2: "15.7" }),
4496
+ React.createElement("line", { x1: "16.6", y1: "8.3", x2: "13.4", y2: "15.7" }));
4497
+ }
4498
+
4499
+ // The Duroxide brand mark reduced to the 24 grid: hexagon ring, gear, and the
4500
+ // sweep arrow breaking out at the lower right. FILLS, not strokes — a 2px
4501
+ // stroked gear at r=3 is unreadable by the time the row renders it at 18px.
4502
+ // Monochrome currentColor, so it inherits every theme including win95 and DOOM.
4503
+ const DUROXIDE_HEX = "M12 2.3 20.4 7.15v9.7L12 21.7 3.6 16.85V7.15Z";
4504
+ const DUROXIDE_GEAR = "M15.2 10.1A4.2 4.2 0 0 1 15.2 11.9L14.23 11.69A3.2 3.2 0 0 1 13.8 12.73L14.64 13.26A4.2 4.2 0 0 1 13.36 14.54L12.83 13.7A3.2 3.2 0 0 1 11.79 14.13L12 15.1A4.2 4.2 0 0 1 10.2 15.1L10.41 14.13A3.2 3.2 0 0 1 9.37 13.7L8.84 14.54A4.2 4.2 0 0 1 7.56 13.26L8.4 12.73A3.2 3.2 0 0 1 7.97 11.69L7 11.9A4.2 4.2 0 0 1 7 10.1L7.97 10.31A3.2 3.2 0 0 1 8.4 9.27L7.56 8.74A4.2 4.2 0 0 1 8.84 7.46L9.37 8.3A3.2 3.2 0 0 1 10.41 7.87L10.2 6.9A4.2 4.2 0 0 1 12 6.9L11.79 7.87A3.2 3.2 0 0 1 12.83 8.3L13.36 7.46A4.2 4.2 0 0 1 14.64 8.74L13.8 9.27A3.2 3.2 0 0 1 14.23 10.31ZM12.8 11A1.7 1.7 0 0 0 9.4 11A1.7 1.7 0 0 0 12.8 11Z";
4505
+ const DUROXIDE_ARROW = "M7.23 15.01A5.7 5.7 0 0 1 17.76 14.55L19.11 15.04L15.1 16.57L14.71 13.44L16.06 13.93A3.9 3.9 0 0 0 8.87 14.25Z";
4506
+ function DuroxideGlyph() {
4507
+ return React.createElement("svg", {
4508
+ className: "ps-share-glyph", viewBox: "0 0 24 24", fill: "none",
4509
+ stroke: "currentColor", "aria-hidden": "true",
4274
4510
  },
4275
- React.createElement("line", { x1: "4", y1: "6", x2: "20", y2: "6" }),
4276
- React.createElement("line", { x1: "4", y1: "12", x2: "20", y2: "12" }),
4277
- React.createElement("line", { x1: "4", y1: "18", x2: "20", y2: "18" }),
4278
- React.createElement("circle", { cx: "9", cy: "6", r: "2", fill: "currentColor" }),
4279
- React.createElement("circle", { cx: "15", cy: "12", r: "2", fill: "currentColor" }),
4280
- React.createElement("circle", { cx: "8", cy: "18", r: "2", fill: "currentColor" }));
4511
+ React.createElement("path", { d: DUROXIDE_HEX, strokeWidth: "1.9", strokeLinejoin: "round" }),
4512
+ React.createElement("path", { d: DUROXIDE_GEAR, fill: "currentColor", stroke: "none", fillRule: "evenodd" }),
4513
+ React.createElement("path", { d: DUROXIDE_ARROW, fill: "currentColor", stroke: "none" }));
4514
+ }
4515
+
4516
+ // Two overlapping PAGES — the front one folded — so the tab reads as "several
4517
+ // artifacts". The old "⧉" is the stacked-squares glyph every other app means
4518
+ // "duplicate" by, which made a destination look like an action.
4519
+ function FilesGlyph() {
4520
+ return React.createElement(Glyph, null,
4521
+ React.createElement("path", { d: "M9 2.8h4.2L17 6.6v8.6a1.8 1.8 0 0 1-1.8 1.8H15" }),
4522
+ React.createElement("path", { d: "M4.5 9.4a1.8 1.8 0 0 1 1.8-1.8h4.2l3.8 3.8v8a1.8 1.8 0 0 1-1.8 1.8H6.3a1.8 1.8 0 0 1-1.8-1.8z" }),
4523
+ React.createElement("path", { d: "M10.5 7.6v3.8h3.8" }));
4524
+ }
4525
+
4526
+ // Solid bars on a baseline. Three things made the first attempt at this read as
4527
+ // a cell-signal meter instead: monotonically ascending heights, round caps, and
4528
+ // nothing anchoring the bars. So these are solid square-shouldered rects with
4529
+ // the middle bar tallest, sitting on a rule.
4530
+ function StatsGlyph() {
4531
+ return React.createElement(Glyph, null,
4532
+ React.createElement("rect", { x: "4.2", y: "12", width: "3.6", height: "8", rx: "0.6", fill: "currentColor", stroke: "none" }),
4533
+ React.createElement("rect", { x: "10.2", y: "6.5", width: "3.6", height: "13.5", rx: "0.6", fill: "currentColor", stroke: "none" }),
4534
+ React.createElement("rect", { x: "16.2", y: "9.5", width: "3.6", height: "10.5", rx: "0.6", fill: "currentColor", stroke: "none" }),
4535
+ React.createElement("path", { d: "M3 21h18" }));
4281
4536
  }
4282
4537
 
4283
4538
  // The standard "link" glyph (two chain segments). Used for the Copy link button.
@@ -4954,7 +5209,7 @@ function InspectorTabs({ activeTab, controller }) {
4954
5209
  return React.createElement("div", { className: "ps-tab-row ps-tab-row-icons" },
4955
5210
  visibleTabs.map((tab) => React.createElement(IconButton, {
4956
5211
  key: tab,
4957
- icon: INSPECTOR_TAB_ICONS[tab] || "•",
5212
+ icon: INSPECTOR_TAB_ICONS[tab] ? React.createElement(INSPECTOR_TAB_ICONS[tab]) : "•",
4958
5213
  label: INSPECTOR_TAB_LABELS[tab] || tab,
4959
5214
  active: activeTab === tab,
4960
5215
  onClick: () => {
@@ -5066,7 +5321,7 @@ function FilesPane({ controller, focused, mobile = false, previewOnly = false, n
5066
5321
  }, `${markedSet.size} marked`),
5067
5322
  viewState.canDeleteArtifacts
5068
5323
  ? React.createElement(IconButton, {
5069
- icon: "🗑",
5324
+ icon: React.createElement(TerminateGlyph),
5070
5325
  label: `Delete ${markedSet.size} marked`,
5071
5326
  onClick: () => controller.deleteMarkedArtifacts().catch(() => {}),
5072
5327
  })
@@ -5090,19 +5345,21 @@ function FilesPane({ controller, focused, mobile = false, previewOnly = false, n
5090
5345
  },
5091
5346
  }),
5092
5347
  React.createElement(IconButton, {
5093
- icon: "↥",
5348
+ icon: React.createElement(UploadGlyph),
5094
5349
  label: "Upload",
5095
5350
  onClick: openUploadPicker,
5096
5351
  disabled: !viewState.canBrowserUpload && !viewState.canPathUpload,
5097
5352
  }),
5098
5353
  React.createElement(IconButton, {
5099
- icon: "↧",
5354
+ icon: React.createElement(DownloadGlyph),
5100
5355
  label: "Download",
5101
5356
  onClick: () => controller.handleCommand(UI_COMMANDS.DOWNLOAD_SELECTED_FILE).catch(() => {}),
5102
5357
  disabled: !hasSelection,
5103
5358
  }),
5359
+ // Trash, not "✕" — the bulk-delete two buttons away was already a trash
5360
+ // can, and "✕" means "clear marks" in this same row.
5104
5361
  viewState.canDeleteArtifacts ? React.createElement(IconButton, {
5105
- icon: "✕",
5362
+ icon: React.createElement(TerminateGlyph),
5106
5363
  label: "Delete",
5107
5364
  onClick: () => controller.handleCommand(UI_COMMANDS.DELETE_SELECTED_FILE).catch(() => {}),
5108
5365
  disabled: !hasSelection,
@@ -5114,7 +5371,7 @@ function FilesPane({ controller, focused, mobile = false, previewOnly = false, n
5114
5371
  disabled: !hasSelection,
5115
5372
  }) : null,
5116
5373
  React.createElement(IconButton, {
5117
- icon: "▾",
5374
+ icon: React.createElement(FunnelGlyph),
5118
5375
  label: "Filter",
5119
5376
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_FILES_FILTER).catch(() => {}),
5120
5377
  }),
@@ -5122,7 +5379,7 @@ function FilesPane({ controller, focused, mobile = false, previewOnly = false, n
5122
5379
  // back affordance belongs to the mobile overlay, which renders its own
5123
5380
  // top bar — putting it here too would change desktop behavior.
5124
5381
  React.createElement(IconButton, {
5125
- icon: viewState.fullscreen ? "⇱" : "⛶",
5382
+ icon: React.createElement(viewState.fullscreen ? CollapseGlyph : ExpandGlyph),
5126
5383
  label: viewState.fullscreen ? "Exit fullscreen" : "Fullscreen",
5127
5384
  onClick: () => controller.handleCommand(UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN).catch(() => {}),
5128
5385
  }));
@@ -5459,7 +5716,9 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
5459
5716
  }));
5460
5717
  actions.push(React.createElement(IconButton, {
5461
5718
  key: "filter",
5462
- icon: "▾",
5719
+ // Same funnel as the session and artifact filters — filtering should
5720
+ // look identical wherever it appears.
5721
+ icon: React.createElement(FunnelGlyph),
5463
5722
  label: "Filter",
5464
5723
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_LOG_FILTER).catch(() => {}),
5465
5724
  }));
@@ -5608,17 +5867,12 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
5608
5867
 
5609
5868
  let content = null;
5610
5869
  if (pane === "sessions") {
5870
+ // No close button: the rail's own Sessions toggle already closes this,
5871
+ // and a second ✕ crowded a header that already carries five actions.
5611
5872
  content = React.createElement(SessionPane, {
5612
5873
  controller,
5613
5874
  panelClassName: "ps-chat-focus-pane",
5614
5875
  structuredRows: mobile,
5615
- actions: React.createElement("button", {
5616
- type: "button",
5617
- className: "ps-mini-button ps-overlay-close",
5618
- onClick: onClose,
5619
- "aria-label": "Close",
5620
- title: "Close",
5621
- }, "✕"),
5622
5876
  });
5623
5877
  } else if (pane === "inspector") {
5624
5878
  content = React.createElement(InspectorPane, {
@@ -5656,18 +5910,23 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
5656
5910
  function ChatFocusWorkspace({ controller, openPane, onTogglePane, onExitFocus, mobile = false }) {
5657
5911
  const focusRegion = useControllerSelector(controller, (state) => state.ui.focusRegion);
5658
5912
 
5913
+ // Icons, not words: two text buttons ate a whole row of phone height, and
5914
+ // IconButton carries the label as a hover tooltip / long-press on touch.
5659
5915
  const rail = mobile
5660
5916
  ? React.createElement("div", { className: "ps-chat-focus-rail" },
5661
- React.createElement("button", {
5662
- type: "button",
5917
+ React.createElement(IconButton, {
5663
5918
  className: "ps-mini-button ps-chat-focus-button",
5919
+ icon: React.createElement(CollapseGlyph),
5920
+ label: "Exit focus mode",
5664
5921
  onClick: onExitFocus,
5665
- }, "Exit Focus"),
5666
- React.createElement("button", {
5667
- type: "button",
5668
- className: `ps-mini-button ps-chat-focus-button${openPane === "sessions" ? " is-active" : ""}`,
5922
+ }),
5923
+ React.createElement(IconButton, {
5924
+ className: "ps-mini-button ps-chat-focus-button",
5925
+ icon: React.createElement(SidebarGlyph),
5926
+ label: openPane === "sessions" ? "Hide sessions" : "Show sessions",
5927
+ active: openPane === "sessions",
5669
5928
  onClick: () => onTogglePane("sessions"),
5670
- }, "Sessions"))
5929
+ }))
5671
5930
  : React.createElement("div", { className: "ps-chat-focus-rail" },
5672
5931
  CHAT_FOCUS_PANES.map((pane) => React.createElement("button", {
5673
5932
  key: pane.id,
@@ -6195,25 +6454,25 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
6195
6454
  // toolbar could not spare.
6196
6455
  {
6197
6456
  key: "new",
6198
- icon: "+",
6457
+ icon: React.createElement(PlusGlyph),
6199
6458
  label: "New session — choose model and agent",
6200
6459
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_MODEL_PICKER).catch(() => {}),
6201
6460
  },
6202
6461
  {
6203
6462
  key: "filter",
6204
- icon: "▾",
6463
+ icon: React.createElement(FunnelGlyph),
6205
6464
  label: "Filter sessions",
6206
6465
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_SESSION_FILTER).catch(() => {}),
6207
6466
  },
6208
6467
  {
6209
6468
  key: "theme",
6210
- icon: "◑",
6469
+ icon: React.createElement(ContrastGlyph),
6211
6470
  label: "Theme",
6212
6471
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_THEME_PICKER).catch(() => {}),
6213
6472
  },
6214
6473
  {
6215
6474
  key: "summary",
6216
- icon: chatView.mode === "summary" ? "💬" : "≣",
6475
+ icon: React.createElement(chatView.mode === "summary" ? TranscriptGlyph : SummaryGlyph),
6217
6476
  label: chatView.activeSessionIsGroup
6218
6477
  ? "Groups show group details"
6219
6478
  : (chatView.mode === "summary" ? "Show chat transcript" : "Show summary"),
@@ -6223,7 +6482,7 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
6223
6482
  },
6224
6483
  ...(onToggleChatFocus ? [{
6225
6484
  key: "focus",
6226
- icon: chatFocusMode ? "⇱" : "⛶",
6485
+ icon: React.createElement(chatFocusMode ? CollapseGlyph : ExpandGlyph),
6227
6486
  label: chatFocusMode ? "Exit focus mode" : "Focus the chat pane",
6228
6487
  onClick: onToggleChatFocus,
6229
6488
  disabled: chatFocusDisabled,
@@ -6234,7 +6493,7 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
6234
6493
  // the button is omitted rather than shipped half-working.
6235
6494
  ...(mobile ? [] : [{
6236
6495
  key: "admin",
6237
- icon: "⚙",
6496
+ icon: React.createElement(CogGlyph),
6238
6497
  label: adminVisible ? "Close admin console" : "Admin console",
6239
6498
  onClick: () => controller.handleCommand(adminVisible ? UI_COMMANDS.CLOSE_ADMIN_CONSOLE : UI_COMMANDS.OPEN_ADMIN_CONSOLE).catch(() => {}),
6240
6499
  active: adminVisible,
@@ -8624,8 +8883,18 @@ export function PilotSwarmWebApp({ controller }) {
8624
8883
  // workspace keeps rendering underneath so backing out restores it intact.
8625
8884
  if (filesFullscreenActive && !mobile) mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
8626
8885
  React.createElement(InspectorPane, { controller, mobile: true }));
8627
- else if (mobilePane === "inspector") mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
8628
- React.createElement(InspectorPane, { controller, mobile: true }));
8886
+ // Node Map is the one inspector tab whose detail lives in ANOTHER pane: the
8887
+ // Activity pane doubles as worker details, and on desktop it sits directly
8888
+ // below the inspector, which is what the "details fill the pane below" hint
8889
+ // means. On a phone Activity is a separate bottom tab, so tapping a node
8890
+ // filled a pane you could not see. Stack them here instead — the node list
8891
+ // scrolls, the details sit under it.
8892
+ else if (mobilePane === "inspector") mobileContent = (state.inspectorTab === "nodes")
8893
+ ? React.createElement("div", { className: "ps-mobile-pane-fill ps-mobile-node-split" },
8894
+ React.createElement(InspectorPane, { controller, mobile: true }),
8895
+ React.createElement(ActivityPane, { controller, panelClassName: "ps-mobile-node-detail" }))
8896
+ : React.createElement("div", { className: "ps-mobile-pane-fill" },
8897
+ React.createElement(InspectorPane, { controller, mobile: true }));
8629
8898
  else if (mobilePane === "activity") mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
8630
8899
  React.createElement(ActivityPane, { controller }));
8631
8900
  else mobileContent = React.createElement(MobileWorkspace, { controller });
package/web/api/ws.js CHANGED
@@ -26,6 +26,13 @@ export function createConnectionHandler(runtime, { allowThemeMessages = false }
26
26
  ws.close(auth.status === 403 ? 4403 : 4401, auth.error || (auth.status === 403 ? "Forbidden" : "Unauthorized"));
27
27
  return;
28
28
  }
29
+ // Same sign-in role capture as the HTTP `requireAuth` path. This is
30
+ // the OTHER authenticated entry point, and it must not be forgotten:
31
+ // a client that connects once and then lives on the socket would
32
+ // otherwise never re-confirm its role, and the worker — which expires
33
+ // stale observations — would quietly drop that user's agent sessions
34
+ // to non-admin while the portal still showed them as admin.
35
+ runtime.noteSignInRole?.(auth);
29
36
 
30
37
  const sessionSubscriptions = new Map();
31
38
  let logUnsubscribe = null;