@rubytech/create-sitedesk-code 0.1.525 → 0.1.527

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 (205) hide show
  1. package/dist/__tests__/samba-provision.test.js +46 -1
  2. package/dist/__tests__/websockify-bind-wiring.test.js +47 -0
  3. package/dist/index.js +39 -1
  4. package/dist/samba-provision.js +37 -0
  5. package/package.json +1 -1
  6. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +80 -13
  7. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +13 -0
  8. package/payload/platform/plugins/docs/references/admin-ui.md +70 -4
  9. package/payload/platform/plugins/docs/references/internals.md +1 -1
  10. package/payload/platform/plugins/docs/references/platform.md +1 -1
  11. package/payload/platform/plugins/linkedin-extension/extension/README.md +10 -2
  12. package/payload/platform/plugins/linkedin-extension/extension/background/sw.js +37 -4
  13. package/payload/platform/plugins/linkedin-extension/extension/content/profile.js +17 -2
  14. package/payload/platform/plugins/linkedin-extension/extension/content/thread.js +17 -2
  15. package/payload/platform/plugins/linkedin-extension/extension/manifest.json +1 -0
  16. package/payload/platform/plugins/linkedin-extension/extension/options/options.html +1 -0
  17. package/payload/platform/plugins/linkedin-extension/extension/options/options.js +78 -3
  18. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/routine-roster-audit.test.js +63 -1
  19. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/routine-roster-audit.test.js.map +1 -1
  20. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts +35 -0
  21. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts.map +1 -1
  22. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js +78 -0
  23. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js.map +1 -1
  24. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +13 -1
  25. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  26. package/payload/platform/scripts/vnc.sh +45 -1
  27. package/payload/server/{chunk-VUXKXOJU.js → chunk-7MGR5M6G.js} +4 -2
  28. package/payload/server/{chunk-WT2PFGY4.js → chunk-KY3BBT67.js} +216 -1
  29. package/payload/server/{manager-4TJK3YOY.js → manager-6XOWHREK.js} +1 -1
  30. package/payload/server/maxy-edge.js +226 -6
  31. package/payload/server/public/activity.html +5 -5
  32. package/payload/server/public/agents.html +4 -4
  33. package/payload/server/public/assets/{AdminLoginScreens-BgDjueVe.js → AdminLoginScreens-DSt9DYZZ.js} +1 -1
  34. package/payload/server/public/assets/AdminLoginScreens-DSt9DYZZ.js.br +0 -0
  35. package/payload/server/public/assets/AdminLoginScreens-DSt9DYZZ.js.gz +0 -0
  36. package/payload/server/public/assets/{AdminShell-YctTrR-D.js → AdminShell-MM3_yp3N.js} +1 -1
  37. package/payload/server/public/assets/AdminShell-MM3_yp3N.js.br +0 -0
  38. package/payload/server/public/assets/AdminShell-MM3_yp3N.js.gz +0 -0
  39. package/payload/server/public/assets/{activity-DGQt5ys_.js → activity-aVvbaVd4.js} +1 -1
  40. package/payload/server/public/assets/activity-aVvbaVd4.js.br +0 -0
  41. package/payload/server/public/assets/activity-aVvbaVd4.js.gz +0 -0
  42. package/payload/server/public/assets/{admin-DnUxbB3P.js → admin-B0lQnkH-.js} +1 -1
  43. package/payload/server/public/assets/admin-B0lQnkH-.js.br +0 -0
  44. package/payload/server/public/assets/admin-B0lQnkH-.js.gz +0 -0
  45. package/payload/server/public/assets/{agents-Bc2LZjwC.js → agents-CrpVTPRG.js} +1 -1
  46. package/payload/server/public/assets/agents-CrpVTPRG.js.br +0 -0
  47. package/payload/server/public/assets/agents-CrpVTPRG.js.gz +0 -0
  48. package/payload/server/public/assets/browser-CYHUzTop.js +1 -0
  49. package/payload/server/public/assets/browser-CYHUzTop.js.br +0 -0
  50. package/payload/server/public/assets/browser-CYHUzTop.js.gz +0 -0
  51. package/payload/server/public/assets/{calendar-D25EhXbD.js → calendar-4IWQpwYv.js} +1 -1
  52. package/payload/server/public/assets/calendar-4IWQpwYv.js.br +0 -0
  53. package/payload/server/public/assets/calendar-4IWQpwYv.js.gz +0 -0
  54. package/payload/server/public/assets/chat-DmjZTqnV.js +1 -0
  55. package/payload/server/public/assets/chat-DmjZTqnV.js.br +0 -0
  56. package/payload/server/public/assets/chat-DmjZTqnV.js.gz +0 -0
  57. package/payload/server/public/assets/chevron-left-CIl-s3LZ.js +1 -0
  58. package/payload/server/public/assets/chevron-left-CIl-s3LZ.js.br +0 -0
  59. package/payload/server/public/assets/chevron-right-DpAi2BsF.js +1 -0
  60. package/payload/server/public/assets/chevron-right-DpAi2BsF.js.br +0 -0
  61. package/payload/server/public/assets/clock-CGuM9VRx.js +1 -0
  62. package/payload/server/public/assets/clock-CGuM9VRx.js.br +0 -0
  63. package/payload/server/public/assets/data-BpdFiZKH.js +1 -0
  64. package/payload/server/public/assets/data-BpdFiZKH.js.br +2 -0
  65. package/payload/server/public/assets/data-BpdFiZKH.js.gz +0 -0
  66. package/payload/server/public/assets/{file-download-CiGklOfA.js → file-download-DayBHhKS.js} +1 -1
  67. package/payload/server/public/assets/file-download-DayBHhKS.js.br +0 -0
  68. package/payload/server/public/assets/file-download-DayBHhKS.js.gz +0 -0
  69. package/payload/server/public/assets/{file-text-XjJPULAZ.js → file-text-BSyhVH0m.js} +1 -1
  70. package/payload/server/public/assets/file-text-BSyhVH0m.js.br +0 -0
  71. package/payload/server/public/assets/file-text-BSyhVH0m.js.gz +0 -0
  72. package/payload/server/public/assets/{graph-BjHJ0z_6.js → graph-Bjv3Tc3B.js} +1 -1
  73. package/payload/server/public/assets/graph-Bjv3Tc3B.js.br +0 -0
  74. package/payload/server/public/assets/graph-Bjv3Tc3B.js.gz +0 -0
  75. package/payload/server/public/assets/{graph-labels-D-0u37KA.js → graph-labels-DegLKGdx.js} +1 -1
  76. package/payload/server/public/assets/graph-labels-DegLKGdx.js.br +0 -0
  77. package/payload/server/public/assets/graph-labels-DegLKGdx.js.gz +0 -0
  78. package/payload/server/public/assets/{maximize-2-BJ9S-nfS.js → maximize-2-DJFG2EXJ.js} +1 -1
  79. package/payload/server/public/assets/maximize-2-DJFG2EXJ.js.br +0 -0
  80. package/payload/server/public/assets/maximize-2-DJFG2EXJ.js.gz +0 -0
  81. package/payload/server/public/assets/{operator-Cay4rOOK.js → operator-Du9Ve_PR.js} +1 -1
  82. package/payload/server/public/assets/operator-Du9Ve_PR.js.br +0 -0
  83. package/payload/server/public/assets/operator-Du9Ve_PR.js.gz +0 -0
  84. package/payload/server/public/assets/{page-shuh4ES9.js → page-DgHYxG3M.js} +1 -1
  85. package/payload/server/public/assets/page-DgHYxG3M.js.br +0 -0
  86. package/payload/server/public/assets/page-DgHYxG3M.js.gz +0 -0
  87. package/payload/server/public/assets/{page-Cci_j_y7.js → page-qV1ypb6i.js} +5 -5
  88. package/payload/server/public/assets/page-qV1ypb6i.js.br +0 -0
  89. package/payload/server/public/assets/page-qV1ypb6i.js.gz +0 -0
  90. package/payload/server/public/assets/{public-BbJcQGsO.js → public-CNVEM-Bx.js} +1 -1
  91. package/payload/server/public/assets/public-CNVEM-Bx.js.br +0 -0
  92. package/payload/server/public/assets/public-CNVEM-Bx.js.gz +0 -0
  93. package/payload/server/public/assets/{rotate-ccw-jeSVwUAv.js → rotate-ccw-DQCaky8_.js} +1 -1
  94. package/payload/server/public/assets/rotate-ccw-DQCaky8_.js.br +0 -0
  95. package/payload/server/public/assets/rotate-ccw-DQCaky8_.js.gz +0 -0
  96. package/payload/server/public/assets/{routines-D3M1Wh3a.js → routines-DwK5FeLm.js} +1 -1
  97. package/payload/server/public/assets/routines-DwK5FeLm.js.br +0 -0
  98. package/payload/server/public/assets/routines-DwK5FeLm.js.gz +0 -0
  99. package/payload/server/public/assets/{skills-Bhej2ub1.js → skills-CosN3uA4.js} +1 -1
  100. package/payload/server/public/assets/skills-CosN3uA4.js.br +0 -0
  101. package/payload/server/public/assets/skills-CosN3uA4.js.gz +0 -0
  102. package/payload/server/public/assets/tasks-f0QikHmw.js +3 -0
  103. package/payload/server/public/assets/tasks-f0QikHmw.js.br +0 -0
  104. package/payload/server/public/assets/tasks-f0QikHmw.js.gz +0 -0
  105. package/payload/server/public/assets/{time-entry-format-r5UY9rjG.js → time-entry-format-DLTdS35_.js} +1 -1
  106. package/payload/server/public/assets/time-entry-format-DLTdS35_.js.br +0 -0
  107. package/payload/server/public/assets/time-entry-format-DLTdS35_.js.gz +0 -0
  108. package/payload/server/public/assets/{triangle-alert--PCKaewD.js → triangle-alert-CpwrbdYq.js} +1 -1
  109. package/payload/server/public/assets/triangle-alert-CpwrbdYq.js.br +3 -0
  110. package/payload/server/public/assets/triangle-alert-CpwrbdYq.js.gz +0 -0
  111. package/payload/server/public/assets/{useCopyFeedback-Dg5lNgqH.js → useCopyFeedback-D01gSp36.js} +1 -1
  112. package/payload/server/public/assets/useCopyFeedback-D01gSp36.js.br +0 -0
  113. package/payload/server/public/assets/useCopyFeedback-D01gSp36.js.gz +0 -0
  114. package/payload/server/public/assets/useSubAccountSwitcher-CJzJ9xV3.css +1 -0
  115. package/payload/server/public/assets/useSubAccountSwitcher-CJzJ9xV3.css.br +0 -0
  116. package/payload/server/public/assets/useSubAccountSwitcher-CJzJ9xV3.css.gz +0 -0
  117. package/payload/server/public/assets/{useVoiceRecorder-BY6ZsGMH.js → useVoiceRecorder-DiPgyDUy.js} +1 -1
  118. package/payload/server/public/assets/useVoiceRecorder-DiPgyDUy.js.br +0 -0
  119. package/payload/server/public/assets/useVoiceRecorder-DiPgyDUy.js.gz +0 -0
  120. package/payload/server/public/assets/{wrench-Bw1FMolk.js → wrench-C5SJreEu.js} +1 -1
  121. package/payload/server/public/assets/wrench-C5SJreEu.js.br +1 -0
  122. package/payload/server/public/assets/wrench-C5SJreEu.js.gz +0 -0
  123. package/payload/server/public/brand-defaults.css +1 -1
  124. package/payload/server/public/browser.html +4 -4
  125. package/payload/server/public/calendar.html +7 -7
  126. package/payload/server/public/chat.html +13 -13
  127. package/payload/server/public/data.html +12 -12
  128. package/payload/server/public/graph.html +9 -9
  129. package/payload/server/public/index.html +15 -15
  130. package/payload/server/public/operator.html +14 -14
  131. package/payload/server/public/public.html +13 -13
  132. package/payload/server/public/routines.html +7 -7
  133. package/payload/server/public/skills.html +5 -5
  134. package/payload/server/public/tasks.html +6 -6
  135. package/payload/server/server.js +53 -9
  136. package/payload/server/public/assets/AdminLoginScreens-BgDjueVe.js.br +0 -0
  137. package/payload/server/public/assets/AdminLoginScreens-BgDjueVe.js.gz +0 -0
  138. package/payload/server/public/assets/AdminShell-YctTrR-D.js.br +0 -0
  139. package/payload/server/public/assets/AdminShell-YctTrR-D.js.gz +0 -0
  140. package/payload/server/public/assets/activity-DGQt5ys_.js.br +0 -0
  141. package/payload/server/public/assets/activity-DGQt5ys_.js.gz +0 -0
  142. package/payload/server/public/assets/admin-DnUxbB3P.js.br +0 -0
  143. package/payload/server/public/assets/admin-DnUxbB3P.js.gz +0 -0
  144. package/payload/server/public/assets/agents-Bc2LZjwC.js.br +0 -0
  145. package/payload/server/public/assets/agents-Bc2LZjwC.js.gz +0 -0
  146. package/payload/server/public/assets/browser-BtF7SzvG.js +0 -1
  147. package/payload/server/public/assets/browser-BtF7SzvG.js.br +0 -0
  148. package/payload/server/public/assets/browser-BtF7SzvG.js.gz +0 -0
  149. package/payload/server/public/assets/calendar-D25EhXbD.js.br +0 -0
  150. package/payload/server/public/assets/calendar-D25EhXbD.js.gz +0 -0
  151. package/payload/server/public/assets/chat-z4jsHFXg.js +0 -1
  152. package/payload/server/public/assets/chat-z4jsHFXg.js.br +0 -0
  153. package/payload/server/public/assets/chat-z4jsHFXg.js.gz +0 -0
  154. package/payload/server/public/assets/chevron-left-BD09SN6R.js +0 -1
  155. package/payload/server/public/assets/chevron-right-ChmWeo7T.js +0 -1
  156. package/payload/server/public/assets/chevron-right-ChmWeo7T.js.br +0 -0
  157. package/payload/server/public/assets/clock-Yqf7Ke7I.js +0 -1
  158. package/payload/server/public/assets/clock-Yqf7Ke7I.js.br +0 -0
  159. package/payload/server/public/assets/clock-Yqf7Ke7I.js.gz +0 -0
  160. package/payload/server/public/assets/data-RmnKj5nC.js +0 -1
  161. package/payload/server/public/assets/data-RmnKj5nC.js.br +0 -2
  162. package/payload/server/public/assets/data-RmnKj5nC.js.gz +0 -0
  163. package/payload/server/public/assets/file-download-CiGklOfA.js.br +0 -0
  164. package/payload/server/public/assets/file-download-CiGklOfA.js.gz +0 -0
  165. package/payload/server/public/assets/file-text-XjJPULAZ.js.br +0 -0
  166. package/payload/server/public/assets/file-text-XjJPULAZ.js.gz +0 -0
  167. package/payload/server/public/assets/graph-BjHJ0z_6.js.br +0 -0
  168. package/payload/server/public/assets/graph-BjHJ0z_6.js.gz +0 -0
  169. package/payload/server/public/assets/graph-labels-D-0u37KA.js.br +0 -0
  170. package/payload/server/public/assets/graph-labels-D-0u37KA.js.gz +0 -0
  171. package/payload/server/public/assets/maximize-2-BJ9S-nfS.js.br +0 -0
  172. package/payload/server/public/assets/maximize-2-BJ9S-nfS.js.gz +0 -0
  173. package/payload/server/public/assets/operator-Cay4rOOK.js.br +0 -0
  174. package/payload/server/public/assets/operator-Cay4rOOK.js.gz +0 -0
  175. package/payload/server/public/assets/page-Cci_j_y7.js.br +0 -0
  176. package/payload/server/public/assets/page-Cci_j_y7.js.gz +0 -0
  177. package/payload/server/public/assets/page-shuh4ES9.js.br +0 -0
  178. package/payload/server/public/assets/page-shuh4ES9.js.gz +0 -0
  179. package/payload/server/public/assets/public-BbJcQGsO.js.br +0 -0
  180. package/payload/server/public/assets/public-BbJcQGsO.js.gz +0 -0
  181. package/payload/server/public/assets/rotate-ccw-jeSVwUAv.js.br +0 -0
  182. package/payload/server/public/assets/rotate-ccw-jeSVwUAv.js.gz +0 -0
  183. package/payload/server/public/assets/routines-D3M1Wh3a.js.br +0 -0
  184. package/payload/server/public/assets/routines-D3M1Wh3a.js.gz +0 -0
  185. package/payload/server/public/assets/skills-Bhej2ub1.js.br +0 -0
  186. package/payload/server/public/assets/skills-Bhej2ub1.js.gz +0 -0
  187. package/payload/server/public/assets/tasks-qY-dtOI2.js +0 -1
  188. package/payload/server/public/assets/tasks-qY-dtOI2.js.br +0 -0
  189. package/payload/server/public/assets/tasks-qY-dtOI2.js.gz +0 -0
  190. package/payload/server/public/assets/time-entry-format-r5UY9rjG.js.br +0 -0
  191. package/payload/server/public/assets/time-entry-format-r5UY9rjG.js.gz +0 -0
  192. package/payload/server/public/assets/triangle-alert--PCKaewD.js.br +0 -2
  193. package/payload/server/public/assets/triangle-alert--PCKaewD.js.gz +0 -0
  194. package/payload/server/public/assets/useCopyFeedback-Dg5lNgqH.js.br +0 -0
  195. package/payload/server/public/assets/useCopyFeedback-Dg5lNgqH.js.gz +0 -0
  196. package/payload/server/public/assets/useSubAccountSwitcher-BYfe9vj8.css +0 -1
  197. package/payload/server/public/assets/useSubAccountSwitcher-BYfe9vj8.css.br +0 -0
  198. package/payload/server/public/assets/useSubAccountSwitcher-BYfe9vj8.css.gz +0 -0
  199. package/payload/server/public/assets/useVoiceRecorder-BY6ZsGMH.js.br +0 -0
  200. package/payload/server/public/assets/useVoiceRecorder-BY6ZsGMH.js.gz +0 -0
  201. package/payload/server/public/assets/wrench-Bw1FMolk.js.br +0 -0
  202. package/payload/server/public/assets/wrench-Bw1FMolk.js.gz +0 -0
  203. /package/payload/server/public/assets/{useSubAccountSwitcher-CnpeWIYq.js → useSubAccountSwitcher-BRHYXwGO.js} +0 -0
  204. /package/payload/server/public/assets/{useSubAccountSwitcher-CnpeWIYq.js.br → useSubAccountSwitcher-BRHYXwGO.js.br} +0 -0
  205. /package/payload/server/public/assets/{useSubAccountSwitcher-CnpeWIYq.js.gz → useSubAccountSwitcher-BRHYXwGO.js.gz} +0 -0
@@ -6,7 +6,7 @@
6
6
  // style and runs under `node --test dist/__tests__/*.test.js` after build.
7
7
  import test from "node:test";
8
8
  import assert from "node:assert/strict";
9
- import { renderBrandStanza, renderGlobalSection, renderFullSmbConf, isPrivateIPv4, pickBindDecision, pickEdgeBindDecision, formatEdgeBindDecision, mergeSmbConf, removeBrandStanza, hasAnyBrandStanza, formatSambaMarker, SAMBA_STEPS, SAMBA_ENABLE_UNITS, } from "../samba-provision.js";
9
+ import { renderBrandStanza, renderGlobalSection, renderFullSmbConf, isPrivateIPv4, pickBindDecision, pickEdgeBindDecision, formatEdgeBindDecision, pickWebsockifyBind, formatWebsockifyBindFile, mergeSmbConf, removeBrandStanza, hasAnyBrandStanza, formatSambaMarker, SAMBA_STEPS, SAMBA_ENABLE_UNITS, } from "../samba-provision.js";
10
10
  // ---------------------------------------------------------------------------
11
11
  // Fixtures
12
12
  // ---------------------------------------------------------------------------
@@ -384,6 +384,51 @@ test("formatSambaMarker emits the `[install-invariant] samba-provision-<step> <s
384
384
  assert.equal(formatSambaMarker("units", "fail: Job for smbd.service failed"), "[install-invariant] samba-provision-units fail: Job for smbd.service failed");
385
385
  });
386
386
  // ---------------------------------------------------------------------------
387
+ // pickWebsockifyBind — the noVNC transport's host bind posture (Task 2089)
388
+ //
389
+ // The mapper reads the edge decision and nothing else, so a host can never end
390
+ // up with a public noVNC page behind a loopback edge. The `kind` passthrough is
391
+ // what lets the runtime log line distinguish `loopback-only` from `none`, which
392
+ // map to the same address.
393
+ // ---------------------------------------------------------------------------
394
+ test("pickWebsockifyBind: LAN host keeps the wildcard bind", () => {
395
+ // Byte-identical to the pre-2089 behaviour. The Pi's `.local` noVNC page is
396
+ // reached over whichever family avahi resolved, so the wildcard stays `[::]`
397
+ // rather than narrowing to `0.0.0.0`.
398
+ assert.deepEqual(pickWebsockifyBind(pickEdgeBindDecision(PI_WLAN0_ONLY)), {
399
+ bind: "[::]",
400
+ kind: "lan",
401
+ });
402
+ });
403
+ test("pickWebsockifyBind: public-only host binds loopback", () => {
404
+ assert.deepEqual(pickWebsockifyBind(pickEdgeBindDecision(HETZNER_PUBLIC_ETH0_ONLY)), {
405
+ bind: "127.0.0.1",
406
+ kind: "loopback-only",
407
+ });
408
+ });
409
+ test("pickWebsockifyBind: dual-homed cloud host binds loopback despite a private iface", () => {
410
+ // The fail-open Task 2082 closed for the edge, closed here too: a provider
411
+ // private network alongside a public address must not keep a public bind.
412
+ // `kind` stays `lan` because pickBindDecision keys on the private interface;
413
+ // the bind address is what the edge decision, not `kind`, dictates.
414
+ assert.deepEqual(pickWebsockifyBind(pickEdgeBindDecision(HETZNER_CLOUD_PUBLIC_PLUS_PRIVNET)), {
415
+ bind: "127.0.0.1",
416
+ kind: "lan",
417
+ });
418
+ });
419
+ test("pickWebsockifyBind: no non-loopback IPv4 binds loopback", () => {
420
+ assert.deepEqual(pickWebsockifyBind(pickEdgeBindDecision(PI_LO_ONLY)), {
421
+ bind: "127.0.0.1",
422
+ kind: "none",
423
+ });
424
+ });
425
+ test("formatWebsockifyBindFile: one parseable line, trailing newline", () => {
426
+ // vnc.sh parses this with `head -n1` + two sed captures, so the shape is
427
+ // load-bearing: two `key=value` pairs, space-separated, one line.
428
+ assert.equal(formatWebsockifyBindFile({ bind: "127.0.0.1", kind: "loopback-only" }), "bind=127.0.0.1 kind=loopback-only\n");
429
+ assert.equal(formatWebsockifyBindFile({ bind: "[::]", kind: "lan" }), "bind=[::] kind=lan\n");
430
+ });
431
+ // ---------------------------------------------------------------------------
387
432
  // SAMBA_ENABLE_UNITS — the units step must never enable nmbd (Task 755)
388
433
  // ---------------------------------------------------------------------------
389
434
  test("SAMBA_ENABLE_UNITS enables smbd only — nmbd is never started", () => {
@@ -0,0 +1,47 @@
1
+ // Nothing else in the repo ties vnc.sh's websockify invocation to the installer
2
+ // that writes the file it reads, so either half could ship alone with the whole
3
+ // suite green. Two silent failures this closes:
4
+ //
5
+ // - installer writes the file, vnc.sh still hardcodes `[::]` → the Task 2089
6
+ // fix ships inert on every host, with every pickWebsockifyBind test passing;
7
+ // - vnc.sh reads the file, installer never writes it → resolve_websockify_bind
8
+ // exits 1 and the whole VNC stack is down on every host, not just cloud ones.
9
+ //
10
+ // Both files are read from disk at runtime, the same pattern as
11
+ // edge-bind-template-wiring.test.ts, which closes the equivalent gap for Task
12
+ // 2082's edge unit.
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { readFileSync } from "node:fs";
16
+ import { fileURLToPath } from "node:url";
17
+ import { dirname, resolve } from "node:path";
18
+ // dist/__tests__/websockify-bind-wiring.test.js → ../../src/index.ts
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const INDEX_TS = resolve(here, "../../src/index.ts");
21
+ // dist/__tests__ → up four is the maxy-code root that holds platform/.
22
+ const VNC_SH = resolve(here, "../../../../platform/scripts/vnc.sh");
23
+ const SRC = readFileSync(INDEX_TS, "utf-8");
24
+ const VNC = readFileSync(VNC_SH, "utf-8");
25
+ test("vnc.sh no longer hardcodes the wildcard websockify bind", () => {
26
+ assert.doesNotMatch(VNC, /websockify --web \S+ "\[::\]:\$\{WEBSOCKIFY_PORT\}"/, "vnc.sh must take the websockify bind from platform/config/websockify-bind, not a literal [::]");
27
+ });
28
+ test("vnc.sh binds the resolved address and logs the decision", () => {
29
+ assert.match(VNC, /websockify --web \S+ "\$\{WEBSOCKIFY_BIND\}:\$\{WEBSOCKIFY_PORT\}"/, "the websockify invocation must interpolate the resolved bind address");
30
+ assert.match(VNC, /log "websockify-bind address=\$\{WEBSOCKIFY_BIND\} kind=\$\{WEBSOCKIFY_BIND_KIND\}/, "the websockify-bind token is the task's diagnostic grep — it must survive any edit");
31
+ });
32
+ test("vnc.sh resolves the bind before starting websockify", () => {
33
+ const resolveAt = VNC.indexOf("resolve_websockify_bind\n");
34
+ const startAt = VNC.indexOf("websockify --web");
35
+ assert.ok(resolveAt > 0, "vnc.sh must call resolve_websockify_bind");
36
+ assert.ok(resolveAt < startAt, "resolve_websockify_bind must run before the websockify invocation, or WEBSOCKIFY_BIND is unset");
37
+ });
38
+ test("vnc.sh refuses a bind address outside the two the mapper can produce", () => {
39
+ // The allow-list is what stops a hand-edited or half-written file handing
40
+ // websockify a wildcard on a cloud host.
41
+ assert.match(VNC, /"\[::\]"\|"127\.0\.0\.1"\) ;;/, "resolve_websockify_bind must allow-list the two addresses pickWebsockifyBind can emit");
42
+ });
43
+ test("the installer writes the file vnc.sh reads", () => {
44
+ assert.match(VNC, /config\/websockify-bind/, "vnc.sh must read platform/config/websockify-bind");
45
+ assert.match(SRC, /join\(configDir, "websockify-bind"\)/, "index.ts must write that same filename");
46
+ assert.match(SRC, /^\s*writeWebsockifyBindFile\(\);$/m, "index.ts defines the writer but never calls it — the file would never be written");
47
+ });
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ import { classifyPortHolder } from "./preflight-port-classifier.js";
29
29
  import { memoryControllerMissing, cmdlineAlreadyPatched, patchCmdline } from "./cgroup-memory-controller.js";
30
30
  import { parsePluginList, computeInstallActions, parseExternalPlugins, findUnregisteredResyncs, discoverMarketplaceCandidateDirs, } from "./lib/plugin-install.js";
31
31
  import { findPremiumMcpDirs } from "./lib/premium-mcp-discover.js";
32
- import { pickBindDecision, pickEdgeBindDecision, formatEdgeBindDecision, mergeSmbConf, formatSambaMarker, SAMBA_ENABLE_UNITS, } from "./samba-provision.js";
32
+ import { pickBindDecision, pickEdgeBindDecision, formatEdgeBindDecision, pickWebsockifyBind, formatWebsockifyBindFile, mergeSmbConf, formatSambaMarker, SAMBA_ENABLE_UNITS, } from "./samba-provision.js";
33
33
  import { networkInterfaces, userInfo, cpus } from "node:os";
34
34
  const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
35
35
  // Brand manifest — read from payload to derive all brand-specific installation values.
@@ -641,6 +641,41 @@ function writeChromiumBinaryPathFile() {
641
641
  console.log(` Wrote ${target} → ${RESOLVED_CHROMIUM_BIN}`);
642
642
  logFile(` [snap-chromium] wrote ${target} contents=${RESOLVED_CHROMIUM_BIN}`);
643
643
  }
644
+ /**
645
+ * Write the websockify bind address to
646
+ * `<INSTALL_DIR>/platform/config/websockify-bind` so vnc.sh gives the noVNC
647
+ * transport the same host posture the edge got in Task 2082. Task 2089.
648
+ *
649
+ * The carrier is a file rather than a unit environment variable because
650
+ * `vnc.sh start` has two callers and only one of them is the edge. The other is
651
+ * `ensureVnc()` (platform/ui/app/lib/vnc.ts), which shells the script from the
652
+ * maxy-ui process; the maxy-ui unit carries no EDGE_HOSTNAME, so an env-var
653
+ * carrier would silently restore a wildcard bind on every X-down recovery. A
654
+ * file is also correct for a hand-run `bash vnc.sh start`, which no unit
655
+ * environment reaches at all.
656
+ *
657
+ * Called immediately after writeChromiumBinaryPathFile for the same reason it
658
+ * is: platform/config is a payload subdirectory, so this must run after
659
+ * deployPayload. Linux-only — darwin runs no VNC stack (installServiceDarwin
660
+ * excludes every VNC/X11 variable for that reason).
661
+ *
662
+ * Idempotent: re-running the installer on an unchanged host rewrites the same
663
+ * bytes. A host that gains or loses an interface gets the new decision on the
664
+ * next install, which the console and install-log lines below make visible.
665
+ */
666
+ function writeWebsockifyBindFile() {
667
+ if (process.platform !== "linux") {
668
+ logFile(` writeWebsockifyBindFile skipped: platform=${process.platform}`);
669
+ return;
670
+ }
671
+ const decision = pickWebsockifyBind(pickEdgeBindDecision(networkInterfaces()));
672
+ const configDir = resolve(INSTALL_DIR, "platform/config");
673
+ mkdirSync(configDir, { recursive: true });
674
+ const target = join(configDir, "websockify-bind");
675
+ writeFileSync(target, formatWebsockifyBindFile(decision), { mode: 0o644 });
676
+ console.log(` Wrote ${target} → bind=${decision.bind} kind=${decision.kind}`);
677
+ logFile(` [websockify-bind] wrote ${target} bind=${decision.bind} kind=${decision.kind}`);
678
+ }
644
679
  // Scrub any hardware-watchdog drop-in this installer (or an earlier version)
645
680
  // left on the box, then daemon-reexec so the live RuntimeWatchdogUSec returns
646
681
  // to systemd's default (0 = off). The hardware-watchdog feature is retired:
@@ -5333,6 +5368,9 @@ try {
5333
5368
  // value. Must run after deployPayload (config dir is
5334
5369
  // a payload subdirectory). Linux-only; no-op on darwin/non-linux.
5335
5370
  writeChromiumBinaryPathFile();
5371
+ // Same install-time-file pattern and the same post-deployPayload
5372
+ // requirement: vnc.sh reads this to decide the websockify bind. Task 2089.
5373
+ writeWebsockifyBindFile();
5336
5374
  // scrub plaintext neo4j passwords from any pre-fix install-*.log.
5337
5375
  // Idempotent — re-running on already-redacted logs is a no-op. Runs after
5338
5376
  // payload deploy so the bundled redact-install-logs.sh is on disk.
@@ -224,6 +224,43 @@ export function formatEdgeBindDecision(d) {
224
224
  return `[edge-bind-decision] kind=${d.kind} iface=${d.iface ?? "none"} ` +
225
225
  `private=${list(d.privateAddresses)} public=${list(d.publicAddresses)} EDGE_HOSTNAME=${d.hostname}`;
226
226
  }
227
+ /**
228
+ * Map the edge's bind decision onto websockify. Task 2089.
229
+ *
230
+ * This deliberately takes `EdgeBindDecision` rather than the interface table:
231
+ * the noVNC page and the admin edge must never disagree about whether the box
232
+ * is publicly reachable, and reading the same decision is the only way to
233
+ * guarantee that. It is not a third classifier — `pickBindDecision` (Samba) and
234
+ * `pickEdgeBindDecision` (edge) remain the only two.
235
+ *
236
+ * The LAN outcome is the literal `[::]`, which is what shipped before this
237
+ * task, not `0.0.0.0`. avahi publishes an AAAA for `.local` names, so a client
238
+ * may reach the page over IPv6; narrowing the family would be a behaviour
239
+ * change this task did not ask for. `[::]` accepts IPv4 as well under the
240
+ * default `net.ipv6.bindv6only=0`, which is exactly why the loopback outcome
241
+ * has to be an explicit `127.0.0.1`.
242
+ *
243
+ * The loopback outcome binds `127.0.0.1` only, not `[::1]` and not both.
244
+ * websockify takes one bind address per process, so "both" would mean two
245
+ * processes. Measured on the netcup box 2026-07-29: `localhost` there resolves
246
+ * `::1` first (its image ships no `localhost` line in `/etc/hosts`), and a
247
+ * listener bound to `127.0.0.1` alone still served a request forwarded by
248
+ * `ssh -L <port>:localhost:<port>` — OpenSSH falls back from the refused `::1`.
249
+ * So the SSH-forward recovery path the installer prints keeps working.
250
+ */
251
+ export function pickWebsockifyBind(d) {
252
+ return { bind: d.hostname === "0.0.0.0" ? "[::]" : "127.0.0.1", kind: d.kind };
253
+ }
254
+ /**
255
+ * Render the install-time carrier file read by `platform/scripts/vnc.sh`.
256
+ *
257
+ * The shape is load-bearing: vnc.sh takes `head -n1` and captures `bind=` and
258
+ * `kind=` with two seds, so this must stay one line of space-separated
259
+ * `key=value` pairs with a trailing newline.
260
+ */
261
+ export function formatWebsockifyBindFile(d) {
262
+ return `bind=${d.bind} kind=${d.kind}\n`;
263
+ }
227
264
  // ---------------------------------------------------------------------------
228
265
  // smb.conf merge / remove
229
266
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-sitedesk-code",
3
- "version": "0.1.525",
3
+ "version": "0.1.527",
4
4
  "description": "Install SiteDesk — automated back office for independent building contractors",
5
5
  "bin": {
6
6
  "create-sitedesk-code": "./dist/index.js"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what SiteDesk ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:c1dc0d24b2faa821b80ec583a83b5828393886fbda1963cfb874401d4137cd03
4
+ content-hash: sha256:10d4f123ccb98ccdd68e27e8d22854fb1d9d5dfd985358ff1afa7f5361b25dfa
5
5
  brand: sitedesk-code
6
6
  product-name: SiteDesk
7
7
  ---
@@ -212,7 +212,7 @@ There is no dashboard, no settings panel, no menus. Everything is done through c
212
212
 
213
213
  The chat input auto-grows as you type — it expands to fit your message and shrinks back when you delete text. You can also drag the resize handle above the input to set a custom height.
214
214
 
215
- The admin interface is a three-pane layout: a sidebar on the left with navigation (Sessions, People, Agents, Projects, Tasks, Artefacts) and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu, holding the surface side-by-side with the conversation so the chat stays live while you work in it. At the very top of the sidebar — above the nav rows — a borderless row holds two controls: a "+ New session" button on the left that spawns a fresh Claude Code session, and a Mode trigger on the right showing the current permission mode (Ask, Accept edits, Plan, or Auto). The sidebar's vertical order is: new-session strip first, then the nav (Sessions, People, Agents, Projects, Tasks, Artefacts), then the sessions list, then the footer. Both controls render as plain text-plus-icon affordances with no surrounding rectangle. The "+ New session" button is a text-width hit target — its clickable area is exactly the icon plus label, not the whole row — and shows no hover fill; the only hover feedback is the pointer cursor. The Mode trigger is pushed flush to the right edge of the row. Clicking the Mode trigger opens a popover downward from the row whose header reads "Mode" and lists the four permission modes with the current selection check-marked. The sidebar's nav rows swap the list view in place: Sessions shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). Each recent session row carries a three-state indicator: three pulsing dots when the session is busy (currently processing a turn), a solid sage dot when it is idle (live PTY waiting for input), and a hollow ring when it is archived (PTY exited, JSONL on disk for audit). The list itself splits into three views via a segmented control above the rows: **Active** shows every live session, **Archived** shows every JSONL on disk whose PTY has exited, and **All** shows both. The view choice persists across reloads. An "Include subagents" toggle inside the Active view surfaces specialist spawns (database-operator, premium-plugin agents, anything spawned with a `--agent` flag) which are hidden by default so the list reflects what you started directly. Each row also carries a small uppercase badge — `admin` for operator-driven sessions, the specialist name (for example `db-op`) for background work — so the source of any row is unambiguous at a glance. The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list, because the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable: type in the document and changes save automatically; specialist agent templates are read-only because they ship with SiteDesk and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood; clicking a second project swaps the focus rather than stacking on top. The sidebar's right edge is drag-resizable on every shell-hosted admin page (Sessions root, Chat, Graph, Browser, and Data): drag the handle to widen or narrow the sidebar, and your chosen width is remembered across reloads and shared across all of them. AdminShell mounts the drag handle itself, so every route that wraps its content in `<AdminShell>` gets sidebar resize for free; the handle does not render in the mobile drawer. The chat and artefact divider is also drag-resizable: drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat and artefact min-width floors. Your chosen width is remembered across reloads. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On every viewport the chat header reads left to right as a triptych: a dedicated sidebar toggle (the panel-right icon, which swaps to panel-right-open when the sidebar is showing), the brand mark next to the title in the centre, and the menu burger on the right. This header toggle is the sole sidebar-toggle button; the sidebar itself no longer carries a duplicate. The burger opens the navigation flyout: Dashboard, Data, Graph, Browser, and Chat at the top — Dashboard routes back to `/`, so every satellite page keeps a one-click way home, and the Chat item carries the Claude mark rather than a generic icon — then Public (the agents list) between the two dividers, and below the second divider the version row, Disconnect Claude, and Log out. Tap the sidebar toggle to show or hide the sidebar: on phones (<720px) it slides the drawer in or out, on wider screens it collapses or expands the sidebar column. The brand mark in the centre is decorative; clicks go through the dedicated toggle so the affordance is unambiguous. The drawer animation only fires on tap (220ms slide in or out); resizing your window across the 720px boundary snaps the layout without animation, so you never see a half-open flash. At ≤640px the session metadata pane stacks each label above its value instead of the desktop two-column grid, and the row of action buttons (Open in new tab / Download JSONL / View JSONL / Rename / Pin / Archive / End or Purge) collapses behind a single Actions trigger that opens a popover upward from the foot of the pane. Breakpoint summary: >1280px = full sidebar + chat + artefact pane (drag-resizable divider); 1280px→1080px = sidebar narrows; 1080px→820px = artefact pane hides (Browser/Data/Graph open as full-window pages instead); 820px→720px = sidebar collapses to 56px icon rail; ≤720px = sidebar becomes off-canvas drawer (vertical stack of nav, recents list, foot, the same shape as the desktop sidebar, just on top of the chat instead of beside it).
215
+ The admin interface is a three-pane layout: a sidebar on the left with navigation (Sessions, People, Agents, Projects, Tasks, Artefacts) and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu, holding the surface side-by-side with the conversation so the chat stays live while you work in it. At the very top of the sidebar — above the nav rows — a borderless row holds two controls: a "+ New session" button on the left that spawns a fresh Claude Code session, and a Mode trigger on the right showing the current permission mode (Ask, Accept edits, Plan, or Auto). The sidebar's vertical order is: new-session strip first, then the nav (Sessions, People, Agents, Projects, Tasks, Artefacts), then the sessions list, then the footer. Both controls render as plain text-plus-icon affordances with no surrounding rectangle. The "+ New session" button is a text-width hit target — its clickable area is exactly the icon plus label, not the whole row — and shows no hover fill; the only hover feedback is the pointer cursor. The Mode trigger is pushed flush to the right edge of the row. Clicking the Mode trigger opens a popover downward from the row whose header reads "Mode" and lists the four permission modes with the current selection check-marked. The sidebar's nav rows swap the list view in place: Sessions shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). Each recent session row carries a three-state indicator: three pulsing dots when the session is busy (currently processing a turn), a solid sage dot when it is idle (live PTY waiting for input), and a hollow ring when it is archived (PTY exited, JSONL on disk for audit). The list itself splits into three views via a segmented control above the rows: **Active** shows every live session, **Archived** shows every JSONL on disk whose PTY has exited, and **All** shows both. The view choice persists across reloads. An "Include subagents" toggle inside the Active view surfaces specialist spawns (database-operator, premium-plugin agents, anything spawned with a `--agent` flag) which are hidden by default so the list reflects what you started directly. Each row also carries a small uppercase badge — `admin` for operator-driven sessions, the specialist name (for example `db-op`) for background work — so the source of any row is unambiguous at a glance. The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list, because the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable: type in the document and changes save automatically; specialist agent templates are read-only because they ship with SiteDesk and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood; clicking a second project swaps the focus rather than stacking on top. The sidebar's right edge is drag-resizable on every shell-hosted admin page (Sessions root, Chat, Graph, Browser, and Data): drag the handle to widen or narrow the sidebar, and your chosen width is remembered across reloads and shared across all of them. AdminShell mounts the drag handle itself, so every route that wraps its content in `<AdminShell>` gets sidebar resize for free; the handle does not render in the mobile drawer. The chat and artefact divider is also drag-resizable: drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat and artefact min-width floors. Your chosen width is remembered across reloads. The conversation column on the Chat page has its own width control: a small handle sits half way up the page just inside each side of the column, and dragging either one widens or narrows the reading measure. The column stays centred, so both handles do the same thing — dragging the left one out and the right one out by the same distance give the same width. It will not go below its default 760px and will not go wider than the chat panel itself; the messages, the input box and the jump-to-latest button all move together. Double-click either handle to go back to 760px. Your chosen width is remembered across reloads. The handles do not appear on a phone (below 640px), where the conversation already fills the width. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On every viewport the chat header reads left to right as a triptych: a dedicated sidebar toggle (the panel-right icon, which swaps to panel-right-open when the sidebar is showing), the brand mark next to the title in the centre, and the menu burger on the right. This header toggle is the sole sidebar-toggle button; the sidebar itself no longer carries a duplicate. The burger opens the navigation flyout: Dashboard, Data, Graph, Browser, and Chat at the top — Dashboard routes back to `/`, so every satellite page keeps a one-click way home, and the Chat item carries the Claude mark rather than a generic icon — then Public (the agents list) between the two dividers, and below the second divider the version row, Disconnect Claude, and Log out. Tap the sidebar toggle to show or hide the sidebar: on phones (<720px) it slides the drawer in or out, on wider screens it collapses or expands the sidebar column. The brand mark in the centre is decorative; clicks go through the dedicated toggle so the affordance is unambiguous. The drawer animation only fires on tap (220ms slide in or out); resizing your window across the 720px boundary snaps the layout without animation, so you never see a half-open flash. At ≤640px the session metadata pane stacks each label above its value instead of the desktop two-column grid, and the row of action buttons (Open in new tab / Download JSONL / View JSONL / Rename / Pin / Archive / End or Purge) collapses behind a single Actions trigger that opens a popover upward from the foot of the pane. Breakpoint summary: >1280px = full sidebar + chat + artefact pane (drag-resizable divider); 1280px→1080px = sidebar narrows; 1080px→820px = artefact pane hides (Browser/Data/Graph open as full-window pages instead); 820px→720px = sidebar collapses to 56px icon rail; ≤720px = sidebar becomes off-canvas drawer (vertical stack of nav, recents list, foot, the same shape as the desktop sidebar, just on top of the chat instead of beside it).
216
216
 
217
217
  Page titles are brand-aware: the browser tab shows your product name (e.g. `Real Agent` instead of `SiteDesk`) on every shell — chat, graph, and data — so a non-default brand never leaks the default name in tab strips or browser history.
218
218
 
@@ -1215,7 +1215,7 @@ npx -y @rubytech/create-realagent-code@latest --uninstall
1215
1215
  - **No SCP / rsync.** Updates are `npx -y @rubytech/create-maxy-code@latest …` again, never a file push from the operator's laptop.
1216
1216
  - **Tunnel auth is OAuth; the API is permitted for the rest.** The tunnel's only auth path is `cloudflared tunnel login` in the noVNC browser over SSH forward. DNS, Pages, and D1 use the Cloudflare API with a short-lived narrow token the agent mints from an operator-provisioned master token (see the `cloudflare` plugin).
1217
1217
  - **No shared state across brands.** Two brands on one server each have their own Neo4j port, systemd unit, VNC display, websockify port, tunnel, and persist directory.
1218
- - **No public IPv4 exposure.** The Hetzner firewall opens port 22 only; every operator-facing surface is fronted by the Cloudflare tunnel. The provider firewall is no longer the only thing making that true for the admin edge: this host carries a public IPv4, so the installer writes `EDGE_HOSTNAME=127.0.0.1` into the edge unit and the edge port is loopback-only by construction. That holds whether or not the box also has a Cloud Network attached. It covers the edge port only; see [netcup.md](netcup.md) for what a host without a provider firewall still depends on `ufw` for.
1218
+ - **No public IPv4 exposure.** The Hetzner firewall opens port 22 only; every operator-facing surface is fronted by the Cloudflare tunnel. The provider firewall is no longer the only thing making that true for the operator surfaces: this host carries a public IPv4, so the installer writes `EDGE_HOSTNAME=127.0.0.1` into the edge unit and records the same decision in `platform/config/websockify-bind`, which `vnc.sh` reads to bind the noVNC websockify transport. Both are loopback-only by construction, and that holds whether or not the box also has a Cloud Network attached. Confirm with `grep -E 'bind-decision|EDGE_HOSTNAME' $HOME/.<brand>/logs/install-*.log` and `grep websockify-bind $HOME/.<brand>/logs/vnc-boot.log`. Other listeners on the box are not covered; see [netcup.md](netcup.md) for what a host without a provider firewall still depends on `ufw` for.
1219
1219
 
1220
1220
  ## Smoke checklist
1221
1221
 
@@ -1415,21 +1415,22 @@ This stops and disables the systemd user-service, removes the unit file, removes
1415
1415
  - **No SCP / rsync.** Updates are `npx -y @rubytech/create-maxy-code@latest …` again, never a file push from the operator's laptop.
1416
1416
  - **Tunnel auth is OAuth; the API is permitted for the rest.** As on Hetzner.
1417
1417
  - **No shared state across brands.** Two brands on one server each have their own Neo4j port, systemd unit, VNC display, websockify port, tunnel, and persist directory.
1418
- - **No provider firewall, so `ufw` is the only network-level layer.** On Hetzner Cloud the provider firewall opens 22 only; netcup has nothing in front of the server. What keeps the admin edge private here is the bind itself: the installer classifies the host from its interface table and, because this box carries a public IPv4, writes `EDGE_HOSTNAME=127.0.0.1` into the edge unit, so the edge port is loopback-only by construction and `ufw` is the backstop rather than the single control. Confirm which was written with `grep -E 'bind-decision|EDGE_HOSTNAME' $HOME/.<brand>/logs/install-*.log`. This covers the **edge port only**, not the whole box: the noVNC websockify transport still binds all interfaces, so `ufw` remains load-bearing for that port, and anything that clears the ruleset a flushed nftables set after a kernel change, `ufw disable` during debugging, a CCP image reinstall exposes it. Verify from **off** the box, not with `ufw status` on it.
1418
+ - **No provider firewall, so `ufw` is the only network-level layer.** On Hetzner Cloud the provider firewall opens 22 only; netcup has nothing in front of the server. What keeps the operator surfaces private here is the bind itself. The installer classifies the host from its interface table and, because this box carries a public IPv4, binds **both** operator-facing listeners to loopback: it writes `EDGE_HOSTNAME=127.0.0.1` into the edge unit, and it records the same decision in `platform/config/websockify-bind`, which `vnc.sh` reads to bind the noVNC websockify transport. Neither surface is reachable from off the box by construction, so `ufw` is the backstop rather than the single control. Confirm both with `grep -E 'bind-decision|EDGE_HOSTNAME' $HOME/.<brand>/logs/install-*.log` and `grep websockify-bind $HOME/.<brand>/logs/vnc-boot.log`; on this host class they read `EDGE_HOSTNAME=127.0.0.1` and `address=127.0.0.1`. Other services on the box still bind all interfaces and still depend on `ufw` Samba and CUPS among them — so anything that clears the ruleset (a flushed nftables set after a kernel change, `ufw disable` during debugging, a CCP image reinstall) exposes those. Verify from **off** the box, not with `ufw status` on it.
1419
1419
 
1420
1420
  ## Smoke checklist
1421
1421
 
1422
1422
  Fresh-netcup smoke pass criteria:
1423
1423
 
1424
1424
  1. Provision an RS 8000 G12 with Ubuntu 24.04 x86_64 and an SSH key; SSH in as `root`, create `admin`, switch.
1425
- 2. **From a machine off the server**, port 22 answers and the edge port does not. Two independent things make that true here: the edge binds loopback on this host class, and `ufw` denies inbound. `ufw status` on the box states the intention; only an external probe confirms the outcome:
1425
+ 2. **From a machine off the server**, port 22 answers and neither the edge port nor the noVNC websockify port does. Two independent things make that true here: both listeners bind loopback on this host class, and `ufw` denies inbound. `ufw status` on the box states the intention; only an external probe confirms the outcome:
1426
1426
 
1427
1427
  ```bash
1428
1428
  nc -z -w 5 <ipv4> 22 && echo "22 open (expected)"
1429
- nc -z -w 5 <ipv4> 19200 && echo "EDGE EXPOSED — check the bind, then ufw"
1429
+ nc -z -w 5 <ipv4> <edge port> && echo "EDGE EXPOSED — check the bind, then ufw"
1430
+ nc -z -w 5 <ipv4> <websockify port> && echo "noVNC EXPOSED — check the bind, then ufw"
1430
1431
  ```
1431
1432
 
1432
- If the edge port answers, read the bind first: `grep -E 'bind-decision|EDGE_HOSTNAME' $HOME/.<brand>/logs/install-*.log` should show `EDGE_HOSTNAME=127.0.0.1` on this host, and `ss -lntp` should show the edge on `127.0.0.1:19200`. A public bind there is a classification fault, not a firewall fault.
1433
+ If either answers, read the bind before the firewall. `grep -E 'bind-decision|EDGE_HOSTNAME' $HOME/.<brand>/logs/install-*.log` should show `EDGE_HOSTNAME=127.0.0.1`, `grep websockify-bind $HOME/.<brand>/logs/vnc-boot.log` should show `address=127.0.0.1`, and `ss -lntp` should show both on `127.0.0.1`. A wildcard or public address on either line is a classification fault, not a firewall fault.
1433
1434
 
1434
1435
  Re-run this after every reboot, after any brand install, and after any CCP image reinstall.
1435
1436
  3. Snapshot taken with the OS prepared and nothing installed.
@@ -1437,7 +1438,7 @@ Fresh-netcup smoke pass criteria:
1437
1438
  5. `logs-read.sh --tail server 200 | grep 'op=capacity-resolved'` returns one line reporting `capacity=40`, with `totalMemGiB` matching the provisioned RAM. A `capacity=10` on a 64 GB box means the host resolved below the 56 GiB step and needs investigating before load.
1438
1439
  6. The noVNC page reached over `ssh -L` displays the admin UI.
1439
1440
  7. Cloudflare setup ends with `curl -I https://<hostname>.<your-zone>` returning `HTTP/2 200` from the operator's laptop.
1440
- 8. Reboot; both units come back up; the public URL is reachable again without any manual action; the step-2 external probe still shows the edge port closed.
1441
+ 8. Reboot; both units come back up; the public URL is reachable again without any manual action; the step-2 external probe still shows the edge and noVNC ports closed.
1441
1442
  9. Install a second brand with a different `--hostname`; both admin UIs are reachable on their own public hostnames and neither has touched the other's state.
1442
1443
  10. Uninstall removes the systemd unit and the persist directory.
1443
1444
 
@@ -4008,15 +4009,58 @@ button** while it holds text — clicking empties the input, clears results
4008
4009
  | Mount | Purpose |
4009
4010
  |---|---|
4010
4011
  | `/browser` | `POST /launch` — launches Chromium on the resolved transport (native display or VNC) on demand. Backs the standalone `/browser` operator page. Lifeline tag `[admin/browser/launch]`. |
4012
+ | `/browser` | `GET /screencast-targets` — the page-target list behind the screencast viewer. Server-side hop to the CDP HTTP surface, which is loopback-only; filtered to `type:'page'`. `501` off the Linux display stack, `502` when Chromium is unreachable. |
4013
+ | `/browser` | `POST /screencast-event` — relay for the client half of the `[browser/screencast]` lifeline. Accepts a fixed op allowlist (`request`, `screencast-started`, `frame`, `input`, `teardown`); the server-side ops are refused so a client cannot emit a line that reads as a server decision. |
4011
4014
 
4012
4015
  The in-chat / artefact-embedded brand-VNC iframe components and the
4013
4016
  `/browser-iframe` event-beacon route were retired and stay
4014
4017
  deleted; the `/device-browser` (navigate-the-device-tab) route was also
4015
4018
  removed and is not restored. The standalone `/browser` page is a
4016
- sanctioned operator surface peer to `/graph` and `/data`: it embeds the live
4017
- VNC viewer (`/vnc-viewer.html`, popout `/vnc-popout.html`) and calls
4018
- `/api/admin/browser/launch`. On darwin (no noVNC asset) it shows a "VNC surface
4019
- not available" state.
4019
+ sanctioned operator surface peer to `/graph` and `/data`.
4020
+
4021
+ **The human view is a CDP screencast, not a framebuffer.** `/browser`
4022
+ renders `CdpScreencastSurface`: frames arrive as CDP `Page.screencastFrame` and
4023
+ paint to a canvas, and the operator's clicks, scrolling and typing leave as
4024
+ `Input.dispatch*`. Interaction therefore lands on page coordinates rather than
4025
+ screen pixels, and the view is one tab chosen from a strip rather than the whole
4026
+ X display. `VncSurface` and `/vnc-viewer.html` still exist and still work, but
4027
+ nothing on `/browser` reaches them, and retiring that stack is separate work.
4028
+
4029
+ The transport is `/cdp-screencast` on the dashboard's own host and port, claimed
4030
+ by the edge listener before its `forwardUpgrade` catch-all. It is gated by
4031
+ `canAccessAdmin` and rejected on operator and public hosts (`reason` values
4032
+ `origin-operator-host` and `origin-public-host`, mirroring the websockify
4033
+ rejection), and it validates the target id against a fixed 32-hex charset because
4034
+ that id is interpolated into the upstream request line. Before replaying the
4035
+ upgrade to Chromium it drops the admin session cookie and the browser `Origin`:
4036
+ Chromium is launched with no `--remote-allow-origins`, so its DevTools endpoint
4037
+ has no allowlisted origin, and the proxy has already checked same-origin itself.
4038
+
4039
+ Chromium's debugging port is bound to `127.0.0.1` on `${CDP_PORT}` and is never
4040
+ tunnelled or bound off-localhost. That port carries no authentication and total
4041
+ control of the browser, which is why the proxy is the only path to it. Per-brand
4042
+ isolation is the `CDP_PORT` import and nothing else, so two co-resident brands
4043
+ each view their own Chromium.
4044
+
4045
+ Off the Linux display stack, and whenever Chromium is unreachable, the surface
4046
+ renders an explicit unavailable state. That is deliberate rather than cosmetic: a
4047
+ canvas that never receives a frame is indistinguishable from a black desktop,
4048
+ where the iframe it replaced failed visibly.
4049
+
4050
+ Observability is `[browser/screencast]` correlated by `sid` across
4051
+ `request → gate → cdp-connected → screencast-started → frame → input → teardown
4052
+ → socket-closed`, plus `[cdp-ws-census] liveSockets=<N> sessions=<M>` on every
4053
+ open and close. Diagnostic path:
4054
+
4055
+ ```
4056
+ logs-read.sh | grep -E '\[browser/screencast\]|\[cdp-ws-census\]'
4057
+ ```
4058
+
4059
+ then filter to one `sid` for a whole session. Frame lines are sampled, so a
4060
+ frozen `seq` against a live socket is the frozen-viewer signature. `liveSockets`
4061
+ climbing while `sessions` stays flat is the leak signature: an operator tab that
4062
+ closes without a clean teardown emits no action log of its own, so the standing
4063
+ reconcile pass closes client-dead sockets and reports `op=orphan-reap closed=<N>`.
4020
4064
 
4021
4065
  ### Diagnostics
4022
4066
 
@@ -4317,6 +4361,29 @@ on every admin page; double-click resets to half the available width
4317
4361
  (viewport minus sidebar), clamped to the per-pane min-width floors. The
4318
4362
  chosen width is remembered across reloads.
4319
4363
 
4364
+ **The /chat reading measure is operator-set.** `--webchat-measure` was a fixed
4365
+ 760px; it is now the FLOOR of a range the operator drags, written inline on
4366
+ `.webchat-page` by `WebchatSplitter` so the message column, the composer and the
4367
+ jump-to-latest button — all three read the same property — stay aligned. Two
4368
+ handles, one just inside each column edge, vertically centred. The column is
4369
+ centre-aligned, so one symmetric rule drives both: `measure = 2 × |pointerX −
4370
+ panelCentreX|`, which is why the two handles are interchangeable. Envelope:
4371
+ 760px ≤ measure ≤ the chat panel's own width; double-click either handle to
4372
+ revert to 760px and clear the stored value; the measure persists in
4373
+ `localStorage` under `maxy-webchat-measure-px` and re-clamps on window resize and
4374
+ on any panel-width change (sidebar drag, artefact open/close). No handle renders
4375
+ below a 640px viewport, where the media query already gives the column the full
4376
+ width. A measure wider than its panel does not overflow — the column just fills
4377
+ the panel — so that state emits nothing on its own; the standing check is
4378
+ `[admin-ui] op=webchat-measure-audit measurePx= panelPx= availPx= columnPx=
4379
+ handles= governed= fit=`, emitted on mount and on every re-clamp with identical
4380
+ consecutive readings suppressed. `columnPx` is the measured `.wa-thread-scroll`
4381
+ rect, not the property read back, so a property written to the wrong element
4382
+ shows as `measurePx=1200 columnPx=760`; `columnPx=-1` means no transcript is
4383
+ mounted (the greeting panel). `fit=false` or `handles=` disagreeing with
4384
+ `governed=` is the failure. A completed drag logs `[admin-ui] webchat-resize
4385
+ px=<final>`; a bare click logs nothing.
4386
+
4320
4387
  ## Health vs version
4321
4388
 
4322
4389
  Two endpoints, two surfaces, two restart-survival roles:
@@ -5314,7 +5381,7 @@ For estate-agent client work surfaced on the `/tasks` operator page, `work-creat
5314
5381
 
5315
5382
  Two surfaces feed the gate. (1) **Workflow path:** `memory-write` accepts an optional `producedByTaskId` parameter. When set, an inbound `:PRODUCED` edge from that Task is composed into the write's relationships before the gate runs — the typical agent-side pattern is to call `work-create` at the start of an autonomous flow, capture `taskId`, and pass it as `producedByTaskId` on every subsequent `memory-write` for a gated label. The gate verifies Task and write share the same `accountId`; mismatch is rejected loud. (2) **Direct-ask path:** the admin server resolves the active `:AdminConversation`'s `sessionId` UUID and stamps it as `SESSION_NODE_ID` in the spawn env at PTY-spawn time. The same stamp propagates onto specialist subagent spawns the admin dispatches so listing-curator, content-producer, database-operator etc. inherit the same conversation anchor. The `contact-create` and `memory-write` wrappers call `injectConversationProvenance` (exported from [`@maxy/graph-write`](../../../lib/graph-write/src/conversation-provenance.ts)) which MATCHes `(c:Conversation {sessionId, accountId})` — account isolation is part of the natural key, not a separate gate — and prepends the synthetic `:PRODUCED` edge (composed by Neo4j elementId, which the helper reads off the MATCH). No agent-visible schema field changes. `memory-write` uses the env-stamp only as a fallback when `producedByTaskId` is unset; `contact-create` has no `producedByTaskId` parameter today and relies on the env-stamp alone. Autonomous (cron-driven) specialists with no parent conversation legitimately have no env-stamp; those must thread `producedByTaskId`.
5316
5383
 
5317
- A task also carries a **workspace** edge, distinct from the two provenance ones above: `(t:Task)-[:WORKED_ON_IN]->(c:Conversation)` is the session an operator works the task in, written only by the `/tasks` page Session control. `RAISED_DURING` and `PART_OF` say where a task was *raised*, so neither can answer "which chat do I continue this task in" — a task raised in an unrelated conversation would resume that one. At most one such edge exists per task: the control's link route deletes any edge to a different Conversation before merging the new one, which is how a resume the session manager redirected to a live fork re-points itself rather than staying on the dead source. The `:Task` also carries `sessionSpawnRequestedAt`, stamped before the spawn and removed when the link lands, so a session that was created but never linked is countable rather than invisible.
5384
+ A task also carries a **workspace** edge, distinct from the two provenance ones above: `(t:Task)-[:WORKED_ON_IN]->(c:Conversation)` is the session an operator works the task in, written only by the `/tasks` page Session control. `RAISED_DURING` and `PART_OF` say where a task was *raised*, so neither can answer "which chat do I continue this task in" — a task raised in an unrelated conversation would resume that one. At most one such edge exists per task: the control's link route deletes any edge to a different Conversation before merging the new one. The link route runs only on a first click, when the task has no session yet; a click on an already-linked task navigates straight to `/chat?session=<id>` and writes nothing, so an edge is never re-pointed after it is first written. The `:Task` also carries `sessionSpawnRequestedAt`, stamped before the spawn and removed when the link lands, so a session that was created but never linked is countable rather than invisible.
5318
5385
 
5319
5386
  Operator audit cyphers:
5320
5387
  - "What entities did this conversation's actions produce?" — `MATCH (c:AdminConversation {sessionId:$id})<-[:RAISED_DURING]-(t:Task)-[:PRODUCED]->(e) RETURN labels(e), e.name, t.kind, t.status`
@@ -9,6 +9,19 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-07-30 (0.1.527)
13
+
14
+ - The remote browser page now shows a live picture of the browser you can click and type into directly, replacing the old screen-sharing view.
15
+ - The Session button on an open task now opens the chat inside the dashboard instead of sending you out to a different site, and the new session starts already knowing the task's description and notes.
16
+ - Posting to LinkedIn from the browser extension works again. It was being stopped before the post ever left the browser, and when it failed it blamed the wrong thing; a missing permission and an unreachable device are now reported as the different problems they are.
17
+ - The page title is smaller and the chat column can be dragged wider or narrower.
18
+
19
+ ## 2026-07-30 (0.1.526)
20
+
21
+ - Asking WhatsApp to reconnect now answers as soon as the reconnect starts, instead of waiting on the old connection to die and leaving you with no reply at all.
22
+ - The remote screen viewer is no longer reachable from every network the device sits on. It is now bound to the same posture chosen when the device was set up, and that choice is recorded so it stays put.
23
+ - A repeating routine that has lost its next run time is now reported by a standing check, so a routine that has quietly stopped being scheduled shows up instead of staying invisible.
24
+
12
25
  ## 2026-07-29 (0.1.525)
13
26
 
14
27
  - A repeating routine whose start date had already gone by now runs on its next due occurrence instead of sitting silent forever. There is also a standing check that tells you when a routine has a start date that never fired.
@@ -783,15 +783,58 @@ button** while it holds text — clicking empties the input, clears results
783
783
  | Mount | Purpose |
784
784
  |---|---|
785
785
  | `/browser` | `POST /launch` — launches Chromium on the resolved transport (native display or VNC) on demand. Backs the standalone `/browser` operator page. Lifeline tag `[admin/browser/launch]`. |
786
+ | `/browser` | `GET /screencast-targets` — the page-target list behind the screencast viewer. Server-side hop to the CDP HTTP surface, which is loopback-only; filtered to `type:'page'`. `501` off the Linux display stack, `502` when Chromium is unreachable. |
787
+ | `/browser` | `POST /screencast-event` — relay for the client half of the `[browser/screencast]` lifeline. Accepts a fixed op allowlist (`request`, `screencast-started`, `frame`, `input`, `teardown`); the server-side ops are refused so a client cannot emit a line that reads as a server decision. |
786
788
 
787
789
  The in-chat / artefact-embedded brand-VNC iframe components and the
788
790
  `/browser-iframe` event-beacon route were retired and stay
789
791
  deleted; the `/device-browser` (navigate-the-device-tab) route was also
790
792
  removed and is not restored. The standalone `/browser` page is a
791
- sanctioned operator surface peer to `/graph` and `/data`: it embeds the live
792
- VNC viewer (`/vnc-viewer.html`, popout `/vnc-popout.html`) and calls
793
- `/api/admin/browser/launch`. On darwin (no noVNC asset) it shows a "VNC surface
794
- not available" state.
793
+ sanctioned operator surface peer to `/graph` and `/data`.
794
+
795
+ **The human view is a CDP screencast, not a framebuffer.** `/browser`
796
+ renders `CdpScreencastSurface`: frames arrive as CDP `Page.screencastFrame` and
797
+ paint to a canvas, and the operator's clicks, scrolling and typing leave as
798
+ `Input.dispatch*`. Interaction therefore lands on page coordinates rather than
799
+ screen pixels, and the view is one tab chosen from a strip rather than the whole
800
+ X display. `VncSurface` and `/vnc-viewer.html` still exist and still work, but
801
+ nothing on `/browser` reaches them, and retiring that stack is separate work.
802
+
803
+ The transport is `/cdp-screencast` on the dashboard's own host and port, claimed
804
+ by the edge listener before its `forwardUpgrade` catch-all. It is gated by
805
+ `canAccessAdmin` and rejected on operator and public hosts (`reason` values
806
+ `origin-operator-host` and `origin-public-host`, mirroring the websockify
807
+ rejection), and it validates the target id against a fixed 32-hex charset because
808
+ that id is interpolated into the upstream request line. Before replaying the
809
+ upgrade to Chromium it drops the admin session cookie and the browser `Origin`:
810
+ Chromium is launched with no `--remote-allow-origins`, so its DevTools endpoint
811
+ has no allowlisted origin, and the proxy has already checked same-origin itself.
812
+
813
+ Chromium's debugging port is bound to `127.0.0.1` on `${CDP_PORT}` and is never
814
+ tunnelled or bound off-localhost. That port carries no authentication and total
815
+ control of the browser, which is why the proxy is the only path to it. Per-brand
816
+ isolation is the `CDP_PORT` import and nothing else, so two co-resident brands
817
+ each view their own Chromium.
818
+
819
+ Off the Linux display stack, and whenever Chromium is unreachable, the surface
820
+ renders an explicit unavailable state. That is deliberate rather than cosmetic: a
821
+ canvas that never receives a frame is indistinguishable from a black desktop,
822
+ where the iframe it replaced failed visibly.
823
+
824
+ Observability is `[browser/screencast]` correlated by `sid` across
825
+ `request → gate → cdp-connected → screencast-started → frame → input → teardown
826
+ → socket-closed`, plus `[cdp-ws-census] liveSockets=<N> sessions=<M>` on every
827
+ open and close. Diagnostic path:
828
+
829
+ ```
830
+ logs-read.sh | grep -E '\[browser/screencast\]|\[cdp-ws-census\]'
831
+ ```
832
+
833
+ then filter to one `sid` for a whole session. Frame lines are sampled, so a
834
+ frozen `seq` against a live socket is the frozen-viewer signature. `liveSockets`
835
+ climbing while `sessions` stays flat is the leak signature: an operator tab that
836
+ closes without a clean teardown emits no action log of its own, so the standing
837
+ reconcile pass closes client-dead sockets and reports `op=orphan-reap closed=<N>`.
795
838
 
796
839
  ### Diagnostics
797
840
 
@@ -1092,6 +1135,29 @@ on every admin page; double-click resets to half the available width
1092
1135
  (viewport minus sidebar), clamped to the per-pane min-width floors. The
1093
1136
  chosen width is remembered across reloads.
1094
1137
 
1138
+ **The /chat reading measure is operator-set.** `--webchat-measure` was a fixed
1139
+ 760px; it is now the FLOOR of a range the operator drags, written inline on
1140
+ `.webchat-page` by `WebchatSplitter` so the message column, the composer and the
1141
+ jump-to-latest button — all three read the same property — stay aligned. Two
1142
+ handles, one just inside each column edge, vertically centred. The column is
1143
+ centre-aligned, so one symmetric rule drives both: `measure = 2 × |pointerX −
1144
+ panelCentreX|`, which is why the two handles are interchangeable. Envelope:
1145
+ 760px ≤ measure ≤ the chat panel's own width; double-click either handle to
1146
+ revert to 760px and clear the stored value; the measure persists in
1147
+ `localStorage` under `maxy-webchat-measure-px` and re-clamps on window resize and
1148
+ on any panel-width change (sidebar drag, artefact open/close). No handle renders
1149
+ below a 640px viewport, where the media query already gives the column the full
1150
+ width. A measure wider than its panel does not overflow — the column just fills
1151
+ the panel — so that state emits nothing on its own; the standing check is
1152
+ `[admin-ui] op=webchat-measure-audit measurePx= panelPx= availPx= columnPx=
1153
+ handles= governed= fit=`, emitted on mount and on every re-clamp with identical
1154
+ consecutive readings suppressed. `columnPx` is the measured `.wa-thread-scroll`
1155
+ rect, not the property read back, so a property written to the wrong element
1156
+ shows as `measurePx=1200 columnPx=760`; `columnPx=-1` means no transcript is
1157
+ mounted (the greeting panel). `fit=false` or `handles=` disagreeing with
1158
+ `governed=` is the failure. A completed drag logs `[admin-ui] webchat-resize
1159
+ px=<final>`; a bare click logs nothing.
1160
+
1095
1161
  ## Health vs version
1096
1162
 
1097
1163
  Two endpoints, two surfaces, two restart-survival roles:
@@ -560,7 +560,7 @@ For estate-agent client work surfaced on the `/tasks` operator page, `work-creat
560
560
 
561
561
  Two surfaces feed the gate. (1) **Workflow path:** `memory-write` accepts an optional `producedByTaskId` parameter. When set, an inbound `:PRODUCED` edge from that Task is composed into the write's relationships before the gate runs — the typical agent-side pattern is to call `work-create` at the start of an autonomous flow, capture `taskId`, and pass it as `producedByTaskId` on every subsequent `memory-write` for a gated label. The gate verifies Task and write share the same `accountId`; mismatch is rejected loud. (2) **Direct-ask path:** the admin server resolves the active `:AdminConversation`'s `sessionId` UUID and stamps it as `SESSION_NODE_ID` in the spawn env at PTY-spawn time. The same stamp propagates onto specialist subagent spawns the admin dispatches so listing-curator, content-producer, database-operator etc. inherit the same conversation anchor. The `contact-create` and `memory-write` wrappers call `injectConversationProvenance` (exported from [`@maxy/graph-write`](../../../lib/graph-write/src/conversation-provenance.ts)) which MATCHes `(c:Conversation {sessionId, accountId})` — account isolation is part of the natural key, not a separate gate — and prepends the synthetic `:PRODUCED` edge (composed by Neo4j elementId, which the helper reads off the MATCH). No agent-visible schema field changes. `memory-write` uses the env-stamp only as a fallback when `producedByTaskId` is unset; `contact-create` has no `producedByTaskId` parameter today and relies on the env-stamp alone. Autonomous (cron-driven) specialists with no parent conversation legitimately have no env-stamp; those must thread `producedByTaskId`.
562
562
 
563
- A task also carries a **workspace** edge, distinct from the two provenance ones above: `(t:Task)-[:WORKED_ON_IN]->(c:Conversation)` is the session an operator works the task in, written only by the `/tasks` page Session control. `RAISED_DURING` and `PART_OF` say where a task was *raised*, so neither can answer "which chat do I continue this task in" — a task raised in an unrelated conversation would resume that one. At most one such edge exists per task: the control's link route deletes any edge to a different Conversation before merging the new one, which is how a resume the session manager redirected to a live fork re-points itself rather than staying on the dead source. The `:Task` also carries `sessionSpawnRequestedAt`, stamped before the spawn and removed when the link lands, so a session that was created but never linked is countable rather than invisible.
563
+ A task also carries a **workspace** edge, distinct from the two provenance ones above: `(t:Task)-[:WORKED_ON_IN]->(c:Conversation)` is the session an operator works the task in, written only by the `/tasks` page Session control. `RAISED_DURING` and `PART_OF` say where a task was *raised*, so neither can answer "which chat do I continue this task in" — a task raised in an unrelated conversation would resume that one. At most one such edge exists per task: the control's link route deletes any edge to a different Conversation before merging the new one. The link route runs only on a first click, when the task has no session yet; a click on an already-linked task navigates straight to `/chat?session=<id>` and writes nothing, so an edge is never re-pointed after it is first written. The `:Task` also carries `sessionSpawnRequestedAt`, stamped before the spawn and removed when the link lands, so a session that was created but never linked is countable rather than invisible.
564
564
 
565
565
  Operator audit cyphers:
566
566
  - "What entities did this conversation's actions produce?" — `MATCH (c:AdminConversation {sessionId:$id})<-[:RAISED_DURING]-(t:Task)-[:PRODUCED]->(e) RETURN labels(e), e.name, t.kind, t.status`
@@ -85,7 +85,7 @@ There is no dashboard, no settings panel, no menus. Everything is done through c
85
85
 
86
86
  The chat input auto-grows as you type — it expands to fit your message and shrinks back when you delete text. You can also drag the resize handle above the input to set a custom height.
87
87
 
88
- The admin interface is a three-pane layout: a sidebar on the left with navigation (Sessions, People, Agents, Projects, Tasks, Artefacts) and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu, holding the surface side-by-side with the conversation so the chat stays live while you work in it. At the very top of the sidebar — above the nav rows — a borderless row holds two controls: a "+ New session" button on the left that spawns a fresh Claude Code session, and a Mode trigger on the right showing the current permission mode (Ask, Accept edits, Plan, or Auto). The sidebar's vertical order is: new-session strip first, then the nav (Sessions, People, Agents, Projects, Tasks, Artefacts), then the sessions list, then the footer. Both controls render as plain text-plus-icon affordances with no surrounding rectangle. The "+ New session" button is a text-width hit target — its clickable area is exactly the icon plus label, not the whole row — and shows no hover fill; the only hover feedback is the pointer cursor. The Mode trigger is pushed flush to the right edge of the row. Clicking the Mode trigger opens a popover downward from the row whose header reads "Mode" and lists the four permission modes with the current selection check-marked. The sidebar's nav rows swap the list view in place: Sessions shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). Each recent session row carries a three-state indicator: three pulsing dots when the session is busy (currently processing a turn), a solid sage dot when it is idle (live PTY waiting for input), and a hollow ring when it is archived (PTY exited, JSONL on disk for audit). The list itself splits into three views via a segmented control above the rows: **Active** shows every live session, **Archived** shows every JSONL on disk whose PTY has exited, and **All** shows both. The view choice persists across reloads. An "Include subagents" toggle inside the Active view surfaces specialist spawns (database-operator, premium-plugin agents, anything spawned with a `--agent` flag) which are hidden by default so the list reflects what you started directly. Each row also carries a small uppercase badge — `admin` for operator-driven sessions, the specialist name (for example `db-op`) for background work — so the source of any row is unambiguous at a glance. The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list, because the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable: type in the document and changes save automatically; specialist agent templates are read-only because they ship with SiteDesk and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood; clicking a second project swaps the focus rather than stacking on top. The sidebar's right edge is drag-resizable on every shell-hosted admin page (Sessions root, Chat, Graph, Browser, and Data): drag the handle to widen or narrow the sidebar, and your chosen width is remembered across reloads and shared across all of them. AdminShell mounts the drag handle itself, so every route that wraps its content in `<AdminShell>` gets sidebar resize for free; the handle does not render in the mobile drawer. The chat and artefact divider is also drag-resizable: drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat and artefact min-width floors. Your chosen width is remembered across reloads. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On every viewport the chat header reads left to right as a triptych: a dedicated sidebar toggle (the panel-right icon, which swaps to panel-right-open when the sidebar is showing), the brand mark next to the title in the centre, and the menu burger on the right. This header toggle is the sole sidebar-toggle button; the sidebar itself no longer carries a duplicate. The burger opens the navigation flyout: Dashboard, Data, Graph, Browser, and Chat at the top — Dashboard routes back to `/`, so every satellite page keeps a one-click way home, and the Chat item carries the Claude mark rather than a generic icon — then Public (the agents list) between the two dividers, and below the second divider the version row, Disconnect Claude, and Log out. Tap the sidebar toggle to show or hide the sidebar: on phones (<720px) it slides the drawer in or out, on wider screens it collapses or expands the sidebar column. The brand mark in the centre is decorative; clicks go through the dedicated toggle so the affordance is unambiguous. The drawer animation only fires on tap (220ms slide in or out); resizing your window across the 720px boundary snaps the layout without animation, so you never see a half-open flash. At ≤640px the session metadata pane stacks each label above its value instead of the desktop two-column grid, and the row of action buttons (Open in new tab / Download JSONL / View JSONL / Rename / Pin / Archive / End or Purge) collapses behind a single Actions trigger that opens a popover upward from the foot of the pane. Breakpoint summary: >1280px = full sidebar + chat + artefact pane (drag-resizable divider); 1280px→1080px = sidebar narrows; 1080px→820px = artefact pane hides (Browser/Data/Graph open as full-window pages instead); 820px→720px = sidebar collapses to 56px icon rail; ≤720px = sidebar becomes off-canvas drawer (vertical stack of nav, recents list, foot, the same shape as the desktop sidebar, just on top of the chat instead of beside it).
88
+ The admin interface is a three-pane layout: a sidebar on the left with navigation (Sessions, People, Agents, Projects, Tasks, Artefacts) and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu, holding the surface side-by-side with the conversation so the chat stays live while you work in it. At the very top of the sidebar — above the nav rows — a borderless row holds two controls: a "+ New session" button on the left that spawns a fresh Claude Code session, and a Mode trigger on the right showing the current permission mode (Ask, Accept edits, Plan, or Auto). The sidebar's vertical order is: new-session strip first, then the nav (Sessions, People, Agents, Projects, Tasks, Artefacts), then the sessions list, then the footer. Both controls render as plain text-plus-icon affordances with no surrounding rectangle. The "+ New session" button is a text-width hit target — its clickable area is exactly the icon plus label, not the whole row — and shows no hover fill; the only hover feedback is the pointer cursor. The Mode trigger is pushed flush to the right edge of the row. Clicking the Mode trigger opens a popover downward from the row whose header reads "Mode" and lists the four permission modes with the current selection check-marked. The sidebar's nav rows swap the list view in place: Sessions shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). Each recent session row carries a three-state indicator: three pulsing dots when the session is busy (currently processing a turn), a solid sage dot when it is idle (live PTY waiting for input), and a hollow ring when it is archived (PTY exited, JSONL on disk for audit). The list itself splits into three views via a segmented control above the rows: **Active** shows every live session, **Archived** shows every JSONL on disk whose PTY has exited, and **All** shows both. The view choice persists across reloads. An "Include subagents" toggle inside the Active view surfaces specialist spawns (database-operator, premium-plugin agents, anything spawned with a `--agent` flag) which are hidden by default so the list reflects what you started directly. Each row also carries a small uppercase badge — `admin` for operator-driven sessions, the specialist name (for example `db-op`) for background work — so the source of any row is unambiguous at a glance. The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list, because the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable: type in the document and changes save automatically; specialist agent templates are read-only because they ship with SiteDesk and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood; clicking a second project swaps the focus rather than stacking on top. The sidebar's right edge is drag-resizable on every shell-hosted admin page (Sessions root, Chat, Graph, Browser, and Data): drag the handle to widen or narrow the sidebar, and your chosen width is remembered across reloads and shared across all of them. AdminShell mounts the drag handle itself, so every route that wraps its content in `<AdminShell>` gets sidebar resize for free; the handle does not render in the mobile drawer. The chat and artefact divider is also drag-resizable: drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat and artefact min-width floors. Your chosen width is remembered across reloads. The conversation column on the Chat page has its own width control: a small handle sits half way up the page just inside each side of the column, and dragging either one widens or narrows the reading measure. The column stays centred, so both handles do the same thing — dragging the left one out and the right one out by the same distance give the same width. It will not go below its default 760px and will not go wider than the chat panel itself; the messages, the input box and the jump-to-latest button all move together. Double-click either handle to go back to 760px. Your chosen width is remembered across reloads. The handles do not appear on a phone (below 640px), where the conversation already fills the width. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On every viewport the chat header reads left to right as a triptych: a dedicated sidebar toggle (the panel-right icon, which swaps to panel-right-open when the sidebar is showing), the brand mark next to the title in the centre, and the menu burger on the right. This header toggle is the sole sidebar-toggle button; the sidebar itself no longer carries a duplicate. The burger opens the navigation flyout: Dashboard, Data, Graph, Browser, and Chat at the top — Dashboard routes back to `/`, so every satellite page keeps a one-click way home, and the Chat item carries the Claude mark rather than a generic icon — then Public (the agents list) between the two dividers, and below the second divider the version row, Disconnect Claude, and Log out. Tap the sidebar toggle to show or hide the sidebar: on phones (<720px) it slides the drawer in or out, on wider screens it collapses or expands the sidebar column. The brand mark in the centre is decorative; clicks go through the dedicated toggle so the affordance is unambiguous. The drawer animation only fires on tap (220ms slide in or out); resizing your window across the 720px boundary snaps the layout without animation, so you never see a half-open flash. At ≤640px the session metadata pane stacks each label above its value instead of the desktop two-column grid, and the row of action buttons (Open in new tab / Download JSONL / View JSONL / Rename / Pin / Archive / End or Purge) collapses behind a single Actions trigger that opens a popover upward from the foot of the pane. Breakpoint summary: >1280px = full sidebar + chat + artefact pane (drag-resizable divider); 1280px→1080px = sidebar narrows; 1080px→820px = artefact pane hides (Browser/Data/Graph open as full-window pages instead); 820px→720px = sidebar collapses to 56px icon rail; ≤720px = sidebar becomes off-canvas drawer (vertical stack of nav, recents list, foot, the same shape as the desktop sidebar, just on top of the chat instead of beside it).
89
89
 
90
90
  Page titles are brand-aware: the browser tab shows your product name (e.g. `Real Agent` instead of `SiteDesk`) on every shell — chat, graph, and data — so a non-default brand never leaks the default name in tab strips or browser history.
91
91