@rubytech/create-sitedesk-code 0.1.517 → 0.1.519

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/dist/__tests__/edge-bind-template-wiring.test.js +37 -0
  2. package/dist/__tests__/samba-provision.test.js +103 -1
  3. package/dist/__tests__/tier-flag.test.js +10 -6
  4. package/dist/index.js +20 -5
  5. package/dist/samba-provision.js +72 -0
  6. package/dist/tier-flag.js +10 -5
  7. package/package.json +1 -1
  8. package/payload/platform/lib/entitlement/dist/index.d.ts +24 -0
  9. package/payload/platform/lib/entitlement/dist/index.d.ts.map +1 -1
  10. package/payload/platform/lib/entitlement/dist/index.js +40 -2
  11. package/payload/platform/lib/entitlement/dist/index.js.map +1 -1
  12. package/payload/platform/plugins/admin/mcp/dist/index.js +5 -9
  13. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  14. package/payload/platform/plugins/admin/skills/admin-user-management/SKILL.md +1 -1
  15. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +261 -8
  16. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +12 -0
  17. package/payload/platform/plugins/docs/references/admin-ui.md +44 -6
  18. package/payload/platform/plugins/docs/references/samba.md +2 -0
  19. package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +7 -3
  20. package/payload/platform/scripts/check-no-esm-require.mjs +5 -3
  21. package/payload/platform/scripts/generate-entitlement-fixture.mjs +1 -1
  22. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +16 -1
  24. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -1
  26. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +15 -0
  27. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -1
  28. package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.d.ts.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.js +5 -3
  30. package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.js.map +1 -1
  31. package/payload/platform/services/telegram-channel/dist/instructions.d.ts.map +1 -1
  32. package/payload/platform/services/telegram-channel/dist/instructions.js +13 -10
  33. package/payload/platform/services/telegram-channel/dist/instructions.js.map +1 -1
  34. package/payload/platform/services/webchat-channel/dist/instructions.d.ts +6 -2
  35. package/payload/platform/services/webchat-channel/dist/instructions.d.ts.map +1 -1
  36. package/payload/platform/services/webchat-channel/dist/instructions.js +66 -32
  37. package/payload/platform/services/webchat-channel/dist/instructions.js.map +1 -1
  38. package/payload/platform/services/webchat-channel/dist/notification.d.ts +2 -1
  39. package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
  40. package/payload/platform/services/webchat-channel/dist/notification.js +11 -4
  41. package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
  42. package/payload/platform/services/webchat-channel/dist/server.d.ts.map +1 -1
  43. package/payload/platform/services/webchat-channel/dist/server.js +30 -14
  44. package/payload/platform/services/webchat-channel/dist/server.js.map +1 -1
  45. package/payload/platform/services/webchat-channel/dist/turn-follow.d.ts.map +1 -1
  46. package/payload/platform/services/webchat-channel/dist/turn-follow.js +11 -5
  47. package/payload/platform/services/webchat-channel/dist/turn-follow.js.map +1 -1
  48. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +18 -4
  49. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  50. package/payload/platform/services/whatsapp-channel/dist/notification.js +19 -7
  51. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  52. package/payload/platform/services/whatsapp-channel/dist/server.d.ts.map +1 -1
  53. package/payload/platform/services/whatsapp-channel/dist/server.js +23 -3
  54. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  55. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
  56. package/payload/platform/services/whatsapp-channel/dist/targets.js +8 -2
  57. package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
  58. package/payload/platform/templates/systemd/edge.service.template +1 -1
  59. package/payload/server/chunk-PXNY2TAP.js +1980 -0
  60. package/payload/server/chunk-TIIWMF5G.js +4837 -0
  61. package/payload/server/{chunk-S6HYTIU3.js → chunk-UX2FRNO7.js} +72 -2031
  62. package/payload/server/manager-B6OFOXSW.js +61 -0
  63. package/payload/server/maxy-edge.js +5 -3
  64. package/payload/server/package.json +2 -1
  65. package/payload/server/public/activity.html +5 -5
  66. package/payload/server/public/agents.html +4 -4
  67. package/payload/server/public/assets/{AdminLoginScreens-B5qKb3YK.js → AdminLoginScreens-BZPbCk8g.js} +1 -1
  68. package/payload/server/public/assets/AdminLoginScreens-BZPbCk8g.js.br +0 -0
  69. package/payload/server/public/assets/AdminLoginScreens-BZPbCk8g.js.gz +0 -0
  70. package/payload/server/public/assets/AdminShell-Bpo_wgxm.js +2 -0
  71. package/payload/server/public/assets/AdminShell-Bpo_wgxm.js.br +0 -0
  72. package/payload/server/public/assets/AdminShell-Bpo_wgxm.js.gz +0 -0
  73. package/payload/server/public/assets/{activity-DmnVUDYO.js → activity-DOsfKzT3.js} +1 -1
  74. package/payload/server/public/assets/activity-DOsfKzT3.js.br +0 -0
  75. package/payload/server/public/assets/activity-DOsfKzT3.js.gz +0 -0
  76. package/payload/server/public/assets/admin-COgD1z5a.js +1 -0
  77. package/payload/server/public/assets/admin-COgD1z5a.js.br +0 -0
  78. package/payload/server/public/assets/admin-COgD1z5a.js.gz +0 -0
  79. package/payload/server/public/assets/{agents-Bmq9X7P3.js → agents-DqDnZTH_.js} +1 -1
  80. package/payload/server/public/assets/agents-DqDnZTH_.js.br +0 -0
  81. package/payload/server/public/assets/agents-DqDnZTH_.js.gz +0 -0
  82. package/payload/server/public/assets/{browser-B1G-W80B.js → browser-FL17Dfp0.js} +1 -1
  83. package/payload/server/public/assets/browser-FL17Dfp0.js.br +0 -0
  84. package/payload/server/public/assets/browser-FL17Dfp0.js.gz +0 -0
  85. package/payload/server/public/assets/calendar-CLfLvsnV.js +1 -0
  86. package/payload/server/public/assets/calendar-CLfLvsnV.js.br +0 -0
  87. package/payload/server/public/assets/calendar-CLfLvsnV.js.gz +0 -0
  88. package/payload/server/public/assets/chat-DYq4FxMG.js +1 -0
  89. package/payload/server/public/assets/chat-DYq4FxMG.js.br +0 -0
  90. package/payload/server/public/assets/chat-DYq4FxMG.js.gz +0 -0
  91. package/payload/server/public/assets/chevron-left-DWt5cdUo.js +1 -0
  92. package/payload/server/public/assets/chevron-right-B9suVrYy.js +1 -0
  93. package/payload/server/public/assets/chevron-right-B9suVrYy.js.br +0 -0
  94. package/payload/server/public/assets/clock-DW6qVXtv.js +1 -0
  95. package/payload/server/public/assets/clock-DW6qVXtv.js.br +0 -0
  96. package/payload/server/public/assets/clock-DW6qVXtv.js.gz +0 -0
  97. package/payload/server/public/assets/data-C8rbOgds.js +1 -0
  98. package/payload/server/public/assets/data-C8rbOgds.js.br +0 -0
  99. package/payload/server/public/assets/data-C8rbOgds.js.gz +0 -0
  100. package/payload/server/public/assets/{file-text-BVqqaDun.js → file-text-DNm0AzUz.js} +1 -1
  101. package/payload/server/public/assets/file-text-DNm0AzUz.js.br +0 -0
  102. package/payload/server/public/assets/file-text-DNm0AzUz.js.gz +0 -0
  103. package/payload/server/public/assets/{graph-CEcME0lp.js → graph-fAvmlVzh.js} +3 -3
  104. package/payload/server/public/assets/graph-fAvmlVzh.js.br +0 -0
  105. package/payload/server/public/assets/graph-fAvmlVzh.js.gz +0 -0
  106. package/payload/server/public/assets/{graph-labels-Zl32_AOH.js → graph-labels-CwdkCxfs.js} +1 -1
  107. package/payload/server/public/assets/graph-labels-CwdkCxfs.js.br +0 -0
  108. package/payload/server/public/assets/graph-labels-CwdkCxfs.js.gz +0 -0
  109. package/payload/server/public/assets/{maximize-2-rkkMyAhf.js → maximize-2-B6tAPhC2.js} +1 -1
  110. package/payload/server/public/assets/maximize-2-B6tAPhC2.js.br +0 -0
  111. package/payload/server/public/assets/maximize-2-B6tAPhC2.js.gz +0 -0
  112. package/payload/server/public/assets/operator-CTKvBVjk.js +1 -0
  113. package/payload/server/public/assets/operator-CTKvBVjk.js.br +0 -0
  114. package/payload/server/public/assets/operator-CTKvBVjk.js.gz +0 -0
  115. package/payload/server/public/assets/page-BWoNW3sa.js +32 -0
  116. package/payload/server/public/assets/page-BWoNW3sa.js.br +0 -0
  117. package/payload/server/public/assets/page-BWoNW3sa.js.gz +0 -0
  118. package/payload/server/public/assets/page-O5HkHQw4.js +1 -0
  119. package/payload/server/public/assets/page-O5HkHQw4.js.br +0 -0
  120. package/payload/server/public/assets/page-O5HkHQw4.js.gz +0 -0
  121. package/payload/server/public/assets/{public-Cqgfmo1D.js → public-BF1rnVPa.js} +1 -1
  122. package/payload/server/public/assets/public-BF1rnVPa.js.br +0 -0
  123. package/payload/server/public/assets/public-BF1rnVPa.js.gz +0 -0
  124. package/payload/server/public/assets/{rotate-ccw-O3RmqoTy.js → rotate-ccw-B8TqfCy4.js} +1 -1
  125. package/payload/server/public/assets/rotate-ccw-B8TqfCy4.js.br +0 -0
  126. package/payload/server/public/assets/rotate-ccw-B8TqfCy4.js.gz +0 -0
  127. package/payload/server/public/assets/{routines-DMYObMUy.js → routines-4R7BLZyH.js} +1 -1
  128. package/payload/server/public/assets/routines-4R7BLZyH.js.br +0 -0
  129. package/payload/server/public/assets/routines-4R7BLZyH.js.gz +0 -0
  130. package/payload/server/public/assets/{skills-DON1BKu5.js → skills-CQ9Qs2tg.js} +1 -1
  131. package/payload/server/public/assets/skills-CQ9Qs2tg.js.br +0 -0
  132. package/payload/server/public/assets/skills-CQ9Qs2tg.js.gz +0 -0
  133. package/payload/server/public/assets/tasks-DePyOrRk.js +1 -0
  134. package/payload/server/public/assets/tasks-DePyOrRk.js.br +0 -0
  135. package/payload/server/public/assets/tasks-DePyOrRk.js.gz +0 -0
  136. package/payload/server/public/assets/{time-entry-format-4zgmuQI0.js → time-entry-format-D9gtnG01.js} +1 -1
  137. package/payload/server/public/assets/time-entry-format-D9gtnG01.js.br +0 -0
  138. package/payload/server/public/assets/time-entry-format-D9gtnG01.js.gz +0 -0
  139. package/payload/server/public/assets/{triangle-alert-By5D7wuI.js → triangle-alert-DAtm2mLL.js} +1 -1
  140. package/payload/server/public/assets/triangle-alert-DAtm2mLL.js.br +0 -0
  141. package/payload/server/public/assets/triangle-alert-DAtm2mLL.js.gz +0 -0
  142. package/payload/server/public/assets/{useCopyFeedback-Dx0vtm9s.js → useCopyFeedback-uMJT-p6i.js} +1 -1
  143. package/payload/server/public/assets/useCopyFeedback-uMJT-p6i.js.br +0 -0
  144. package/payload/server/public/assets/useCopyFeedback-uMJT-p6i.js.gz +0 -0
  145. package/payload/server/public/assets/useSubAccountSwitcher-BufCPDDH.js +14 -0
  146. package/payload/server/public/assets/useSubAccountSwitcher-BufCPDDH.js.br +0 -0
  147. package/payload/server/public/assets/useSubAccountSwitcher-BufCPDDH.js.gz +0 -0
  148. package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css +1 -0
  149. package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css.br +0 -0
  150. package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css.gz +0 -0
  151. package/payload/server/public/assets/useVoiceRecorder-BFrPC-s7.js +2 -0
  152. package/payload/server/public/assets/useVoiceRecorder-BFrPC-s7.js.br +0 -0
  153. package/payload/server/public/assets/useVoiceRecorder-BFrPC-s7.js.gz +0 -0
  154. package/payload/server/public/assets/{wrench-CHfMwLza.js → wrench-CYgaN7K9.js} +1 -1
  155. package/payload/server/public/assets/wrench-CYgaN7K9.js.br +1 -0
  156. package/payload/server/public/assets/wrench-CYgaN7K9.js.gz +0 -0
  157. package/payload/server/public/browser.html +4 -4
  158. package/payload/server/public/calendar.html +7 -7
  159. package/payload/server/public/chat.html +13 -13
  160. package/payload/server/public/data.html +11 -11
  161. package/payload/server/public/graph.html +9 -9
  162. package/payload/server/public/index.html +14 -14
  163. package/payload/server/public/operator.html +14 -14
  164. package/payload/server/public/public.html +13 -13
  165. package/payload/server/public/routines.html +6 -6
  166. package/payload/server/public/skills.html +5 -5
  167. package/payload/server/public/tasks.html +6 -6
  168. package/payload/server/server.js +8156 -11969
  169. package/payload/server/public/assets/AdminLoginScreens-B5qKb3YK.js.br +0 -0
  170. package/payload/server/public/assets/AdminLoginScreens-B5qKb3YK.js.gz +0 -0
  171. package/payload/server/public/assets/AdminShell-BgJEq2zJ.js +0 -2
  172. package/payload/server/public/assets/AdminShell-BgJEq2zJ.js.br +0 -0
  173. package/payload/server/public/assets/AdminShell-BgJEq2zJ.js.gz +0 -0
  174. package/payload/server/public/assets/activity-DmnVUDYO.js.br +0 -0
  175. package/payload/server/public/assets/activity-DmnVUDYO.js.gz +0 -0
  176. package/payload/server/public/assets/admin-Dpge4OG9.js +0 -1
  177. package/payload/server/public/assets/admin-Dpge4OG9.js.br +0 -0
  178. package/payload/server/public/assets/admin-Dpge4OG9.js.gz +0 -0
  179. package/payload/server/public/assets/agents-Bmq9X7P3.js.br +0 -0
  180. package/payload/server/public/assets/agents-Bmq9X7P3.js.gz +0 -0
  181. package/payload/server/public/assets/browser-B1G-W80B.js.br +0 -0
  182. package/payload/server/public/assets/browser-B1G-W80B.js.gz +0 -0
  183. package/payload/server/public/assets/calendar-CHgrOFIN.js +0 -1
  184. package/payload/server/public/assets/calendar-CHgrOFIN.js.br +0 -0
  185. package/payload/server/public/assets/calendar-CHgrOFIN.js.gz +0 -0
  186. package/payload/server/public/assets/chat-CIwmmtwP.js +0 -1
  187. package/payload/server/public/assets/chat-CIwmmtwP.js.br +0 -0
  188. package/payload/server/public/assets/chat-CIwmmtwP.js.gz +0 -0
  189. package/payload/server/public/assets/chevron-left-EQjfaQ-n.js +0 -1
  190. package/payload/server/public/assets/chevron-left-EQjfaQ-n.js.br +0 -0
  191. package/payload/server/public/assets/chevron-right-Ct8jZUGP.js +0 -1
  192. package/payload/server/public/assets/chevron-right-Ct8jZUGP.js.br +0 -0
  193. package/payload/server/public/assets/clock-Cxz5ybck.js +0 -1
  194. package/payload/server/public/assets/clock-Cxz5ybck.js.br +0 -0
  195. package/payload/server/public/assets/clock-Cxz5ybck.js.gz +0 -0
  196. package/payload/server/public/assets/data-BQVXKVsJ.js +0 -1
  197. package/payload/server/public/assets/data-BQVXKVsJ.js.br +0 -1
  198. package/payload/server/public/assets/data-BQVXKVsJ.js.gz +0 -0
  199. package/payload/server/public/assets/file-text-BVqqaDun.js.br +0 -0
  200. package/payload/server/public/assets/file-text-BVqqaDun.js.gz +0 -0
  201. package/payload/server/public/assets/graph-CEcME0lp.js.br +0 -0
  202. package/payload/server/public/assets/graph-CEcME0lp.js.gz +0 -0
  203. package/payload/server/public/assets/graph-labels-Zl32_AOH.js.br +0 -0
  204. package/payload/server/public/assets/graph-labels-Zl32_AOH.js.gz +0 -0
  205. package/payload/server/public/assets/maximize-2-rkkMyAhf.js.br +0 -0
  206. package/payload/server/public/assets/maximize-2-rkkMyAhf.js.gz +0 -0
  207. package/payload/server/public/assets/operator-DlL023ch.js +0 -1
  208. package/payload/server/public/assets/operator-DlL023ch.js.br +0 -0
  209. package/payload/server/public/assets/operator-DlL023ch.js.gz +0 -0
  210. package/payload/server/public/assets/page-BAmYYqxp.js +0 -1
  211. package/payload/server/public/assets/page-BAmYYqxp.js.br +0 -0
  212. package/payload/server/public/assets/page-BAmYYqxp.js.gz +0 -0
  213. package/payload/server/public/assets/page-k0BhBkA2.js +0 -32
  214. package/payload/server/public/assets/page-k0BhBkA2.js.br +0 -0
  215. package/payload/server/public/assets/page-k0BhBkA2.js.gz +0 -0
  216. package/payload/server/public/assets/public-Cqgfmo1D.js.br +0 -0
  217. package/payload/server/public/assets/public-Cqgfmo1D.js.gz +0 -0
  218. package/payload/server/public/assets/rotate-ccw-O3RmqoTy.js.br +0 -0
  219. package/payload/server/public/assets/rotate-ccw-O3RmqoTy.js.gz +0 -0
  220. package/payload/server/public/assets/routines-DMYObMUy.js.br +0 -0
  221. package/payload/server/public/assets/routines-DMYObMUy.js.gz +0 -0
  222. package/payload/server/public/assets/skills-DON1BKu5.js.br +0 -0
  223. package/payload/server/public/assets/skills-DON1BKu5.js.gz +0 -0
  224. package/payload/server/public/assets/tasks-C2PhUSrf.js +0 -1
  225. package/payload/server/public/assets/tasks-C2PhUSrf.js.br +0 -0
  226. package/payload/server/public/assets/tasks-C2PhUSrf.js.gz +0 -0
  227. package/payload/server/public/assets/time-entry-format-4zgmuQI0.js.br +0 -0
  228. package/payload/server/public/assets/time-entry-format-4zgmuQI0.js.gz +0 -0
  229. package/payload/server/public/assets/triangle-alert-By5D7wuI.js.br +0 -0
  230. package/payload/server/public/assets/triangle-alert-By5D7wuI.js.gz +0 -0
  231. package/payload/server/public/assets/useCopyFeedback-Dx0vtm9s.js.br +0 -0
  232. package/payload/server/public/assets/useCopyFeedback-Dx0vtm9s.js.gz +0 -0
  233. package/payload/server/public/assets/useSubAccountSwitcher-CRWoQXuU.js +0 -9
  234. package/payload/server/public/assets/useSubAccountSwitcher-CRWoQXuU.js.br +0 -0
  235. package/payload/server/public/assets/useSubAccountSwitcher-CRWoQXuU.js.gz +0 -0
  236. package/payload/server/public/assets/useSubAccountSwitcher-UQQDcj0B.css +0 -1
  237. package/payload/server/public/assets/useSubAccountSwitcher-UQQDcj0B.css.br +0 -0
  238. package/payload/server/public/assets/useSubAccountSwitcher-UQQDcj0B.css.gz +0 -0
  239. package/payload/server/public/assets/useVoiceRecorder-BgwOCCwj.js +0 -2
  240. package/payload/server/public/assets/useVoiceRecorder-BgwOCCwj.js.br +0 -0
  241. package/payload/server/public/assets/useVoiceRecorder-BgwOCCwj.js.gz +0 -0
  242. package/payload/server/public/assets/wrench-CHfMwLza.js.br +0 -0
  243. package/payload/server/public/assets/wrench-CHfMwLza.js.gz +0 -0
@@ -38,7 +38,7 @@ Admin identity lives in three places that must stay in lockstep:
38
38
 
39
39
  All account-level mutations (`tier`, `outputStyle`, `thinkingView`, `effort`, `enabledPlugins`, `admins[]`, agent settings) go through the dedicated MCP tools: `account-update`, `plugin-toggle-enabled`, `admin-add`, `admin-remove`. Direct `Edit` or `Write` on `account.json` is forbidden by IDENTITY.md doctrine — there is no server-side gate, so the doctrine line is the only thing standing between an agent slip and a silently corrupted account file. `tier` and `purchasedPlugins` derive from a Rubytech-signed entitlement payload on commercial installs, so a hand-edit is silently void.
40
40
 
41
- `tier` is the one account-level field with no admin-agent surface — `account-update` excludes it deliberately, because tier is privilege-bearing and the agent must not self-elevate. When refusing a tier-cap violation (`admin-add` against a full slot, etc.), point the owner at the founder-side path: `npx @rubytech/create-<brand>-code@latest --tier <solo|family|pro>` run on the device by the founder. On commercial installs the founder delivers a signed payload instead: `--entitlement-base64 <base64>`.
41
+ `tier` is the one account-level field with no admin-agent surface — `account-update` excludes it deliberately, because tier is privilege-bearing and the agent must not self-elevate. When refusing a tier-cap violation (`admin-add` against a full slot, etc.), point the owner at the founder-side path: `npx @rubytech/create-<brand>-code@latest --tier <solo|family|pro|max>` run on the device by the founder. On commercial installs the founder delivers a signed payload instead: `--entitlement-base64 <base64>`.
42
42
 
43
43
  ## `admin-add` retries: re-pass any user-stated PIN
44
44
 
@@ -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:f164b44ebf79eb2e637d1157465fb53eb4d226989b350d3dbcf2df4fc8afd6b2
4
+ content-hash: sha256:69ee9681cbe3bd78fa8d38f916aa5979d4814681b59f3c1bd3570c613315ca7c
5
5
  brand: sitedesk-code
6
6
  product-name: SiteDesk
7
7
  ---
@@ -700,6 +700,7 @@ SiteDesk Code installs from one npm one-liner on every supported host. The host
700
700
  |---|---|---|---|---|
701
701
  | Raspberry Pi 5 (16GB) on Ubuntu Server 24.04 | [pi.md](pi.md) | systemd user-service | provisioned post-install via `cloudflared tunnel login` in the Pi's VNC browser | `--hostname` required |
702
702
  | Hetzner Cloud CAX31 (16GB arm64) on Ubuntu 24.04 | [hetzner.md](hetzner.md) | systemd user-service | provisioned post-install via `cloudflared tunnel login` in a noVNC browser reached over SSH port-forward | `--hostname` required |
703
+ | netcup RS 8000 G12 (64GB x86_64) on Ubuntu 24.04 | [netcup.md](netcup.md) | systemd user-service | provisioned post-install via `cloudflared tunnel login` in a noVNC browser reached over SSH port-forward | `--hostname` required |
703
704
  | macOS 14+ on Apple Silicon | [macos.md](macos.md) | launchd LaunchAgent | not provisioned; operator runs `cloudflared tunnel login` post-install if they want public reach | `--hostname` optional |
704
705
 
705
706
  The installer source is `maxy-code/packages/create-maxy-code/`. The same package is published as `@rubytech/create-maxy-code` and `@rubytech/create-realagent-code`; the publisher rewrites the package name at bundle time per brand.
@@ -1041,6 +1042,8 @@ The doc is brand-aware. Examples use the default brand `maxy-code`; substitute `
1041
1042
 
1042
1043
  > Pi install: see [pi.md](pi.md). macOS install: see [macos.md](macos.md). Architecture notes for engineers: see [../deployment.md](../deployment.md).
1043
1044
 
1045
+ > **Need more than 16 GB?** CAX31 is sized for a handful of concurrent sessions, because each live session costs roughly 1.1–1.3 GB. When the install has to carry more, or when Hetzner ordering is unavailable, see [netcup.md](netcup.md) — a 64 GB x86_64 alternative on the same apt + systemd path, with copy-on-write snapshots and ECC memory. Note the architecture difference: CAX is ARM64 like the Pi, netcup RS is x86_64, so a graph moved between them must go through the logical Cypher export rather than a binary Neo4j dump.
1046
+
1044
1047
  > **Data sovereignty note.** Installing on Hetzner moves the operator's graph and conversations from a device they own onto a rented server. For internal use or for operators who explicitly prefer cloud hosting, fine. As the default for customers, this cuts against the inverted-SaaS positioning — surface the trade-off before recommending it.
1045
1048
 
1046
1049
  ## Server spec
@@ -1210,7 +1213,7 @@ npx -y @rubytech/create-realagent-code@latest --uninstall
1210
1213
  - **No SCP / rsync.** Updates are `npx -y @rubytech/create-maxy-code@latest …` again, never a file push from the operator's laptop.
1211
1214
  - **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).
1212
1215
  - **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.
1213
- - **No public IPv4 exposure.** The Hetzner firewall opens port 22 only; every operator-facing surface is fronted by the Cloudflare tunnel.
1216
+ - **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.
1214
1217
 
1215
1218
  ## Smoke checklist
1216
1219
 
@@ -1226,6 +1229,216 @@ Fresh-Hetzner smoke pass criteria:
1226
1229
 
1227
1230
  If any step fails, attach `$HOME/.<brand>/logs/install-<timestamp>.log` to the report.
1228
1231
 
1232
+ ---
1233
+ # netcup Root Server Install
1234
+ Source: https://docs.getmaxy.com/install/netcup.md
1235
+
1236
+ # Installing SiteDesk Code on a netcup root server
1237
+
1238
+ End-to-end install for a fresh netcup **RS 8000 G12** (AMD EPYC 9645, 16 dedicated cores, 64 GB DDR5 ECC, 2 TB NVMe). This is the alternative to [hetzner.md](hetzner.md) when the install has to carry more concurrent sessions than a 16 GB host allows, or when Hetzner ordering is unavailable.
1239
+
1240
+ The doc is brand-aware. Examples use the default brand `maxy-code`; substitute `realagent-code` (or any other brand under `maxy-code/brands/`) wherever you want a parallel install. Each brand is fully isolated — its own persist directory, its own systemd user-service, its own Neo4j port, its own VNC display, its own Cloudflare tunnel, its own `CLAUDE_CONFIG_DIR`.
1241
+
1242
+ > Pi install: see [pi.md](pi.md). Hetzner install: see [hetzner.md](hetzner.md). macOS install: see [macos.md](macos.md). Architecture notes for engineers: see [../deployment.md](../deployment.md).
1243
+
1244
+ > **Data sovereignty note.** The same caveat as Hetzner applies: this moves the operator's graph and conversations from a device they own onto a rented server. For internal use or for operators who explicitly prefer cloud hosting, fine. As the default for customers, this cuts against the inverted-SaaS positioning — surface the trade-off before recommending it.
1245
+
1246
+ ## Why netcup rather than Hetzner
1247
+
1248
+ Both are rented Linux hosts running the same apt + systemd path. The differences that decide between them:
1249
+
1250
+ | | Hetzner CAX31 | netcup RS 8000 G12 |
1251
+ |---|---|---|
1252
+ | RAM | 16 GB | **64 GB** |
1253
+ | CPU | 8 vCPU Ampere Altra | 16 dedicated cores, EPYC 9645 |
1254
+ | Architecture | ARM64 | **x86_64** |
1255
+ | Disk | 160 GB NVMe | 2 TB NVMe |
1256
+ | ECC | not stated | yes, DDR5 ECC |
1257
+ | Snapshots | via cloud console | copy-on-write, included |
1258
+ | Remote OS console | cloud console | included |
1259
+ | Setup fee | none | none |
1260
+ | Billing | monthly | **12 months, paid up front** |
1261
+
1262
+ Take netcup when the constraint is RAM. Each live session is roughly 1.1–1.3 GB across ~23 node children, so a 16 GB host is sized for a handful of concurrent sessions and a 64 GB host is not.
1263
+
1264
+ Take Hetzner CAX31 when the install is small and you want ARM64 parity with the Pi, or monthly billing.
1265
+
1266
+ **Architecture is the one migration-relevant difference.** CAX31 is ARM64, the same chip family as the Pi 5, so binaries compile identically. netcup RS is x86_64. This is a supported path — the multi-brand laptop reference install and the canonical Hetzner CX22 VM in [../deployment.md](../deployment.md) are both x86_64 Ubuntu — but a graph or binary artefact moved between an ARM64 and an x86_64 host must go through the logical Cypher export, never a binary Neo4j dump.
1267
+
1268
+ ### Capacity steps with RAM, and this box clears the top step
1269
+
1270
+ `resolveRcDaemonCapacity` (`platform/services/claude-session-manager/src/rc-daemon.ts`) resolves **10** concurrent sessions on a host at or above 15 GiB and **40** on one at or above 56 GiB. A 64 GB netcup box clears the higher step, so it admits 40 where a 16 GB host admits 10. The 56 GiB threshold comes from 40 sessions at the measured ~1.2 GB each. Confirm what a host actually resolved with:
1271
+
1272
+ ```bash
1273
+ ~/<installDir>/platform/scripts/logs-read.sh --tail server 200 | grep 'op=capacity-resolved'
1274
+ ```
1275
+
1276
+ ## Server spec
1277
+
1278
+ | Field | Value | Why |
1279
+ |---|---|---|
1280
+ | Tier | **RS 8000 G12** | 16 dedicated cores, 64 GB DDR5 ECC, 2 TB NVMe. Sized for concurrent-session RAM, which is the binding constraint. |
1281
+ | Image | Ubuntu 24.04 LTS (x86_64) | Same image family as the Pi and Hetzner installs. Earlier Ubuntu / non-LTS images are not part of the supported matrix. |
1282
+ | Location | Nuremberg or Vienna (EU), Manassas (USA), Singapore, or "No preference Europe" | Both European locations are EU/EEA, so GDPR applies either way. A named location is worth the surcharge only when your own processing records or client agreements have to state where data lives. |
1283
+ | Network | IPv4 + IPv6 | The Cloudflare tunnel terminates all public traffic; the server's own IPv4 is not exposed to operators after step 5. |
1284
+ | Contract | 12 months minimum, billed 12 months up front | A 1-month term is available at a surcharge. Note this is a full-year commitment, not a monthly charge. |
1285
+ | Traffic | Flatrate, throttled to 300 Mbit/s if it exceeds 3 TB in any 24 hours | Not a constraint for this workload; a full account directory is single-digit GB. |
1286
+
1287
+ **Upgrades are one-way.** Moving to a larger plan in the same generation (RS 8000 G12 → RS 12000 G12) is done in netcup's Customer Control Panel, but it cannot be reversed — reversing means cancel and re-order, which is a full manual migration. An upgrade also starts a **new** minimum contract term, with unused time on the old one refunded. Cross-generation moves are not possible at all. Start at the smallest plan that meets the measured requirement.
1288
+
1289
+ ## 1. Provision the server
1290
+
1291
+ In netcup's Customer Control Panel:
1292
+
1293
+ 1. Order the RS 8000 G12, choosing contract period and location per the spec table above.
1294
+ 2. When the server is provisioned, install **Ubuntu 24.04 LTS** from the CCP's image list.
1295
+ 3. Add your SSH public key during image setup, or paste it into `/root/.ssh/authorized_keys` from the remote console afterwards.
1296
+
1297
+ netcup does not provide a network firewall product in front of the server, so at this point the host firewall is the only inbound control. Close everything except SSH before going further:
1298
+
1299
+ ```bash
1300
+ ufw default deny incoming
1301
+ ufw default allow outgoing
1302
+ ufw allow 22/tcp
1303
+ ufw --force enable
1304
+ ```
1305
+
1306
+ Every operator-facing surface is fronted by the Cloudflare tunnel, which dials *out*, so nothing else needs an inbound rule.
1307
+
1308
+ SSH in as `root`:
1309
+
1310
+ ```bash
1311
+ ssh root@<ipv4>
1312
+ ```
1313
+
1314
+ > The CCP's remote management console is for the operating system — recovering a host that will not boot or has locked you out over SSH. It is **not** the platform's noVNC browser in step 5; those are separate surfaces with separate purposes.
1315
+
1316
+ ## 2. Prepare the OS
1317
+
1318
+ Identical to the Hetzner path. Update the package index and install Node 22 from NodeSource; the Ubuntu archive Node is too old and a 20.x binary trips the engines check.
1319
+
1320
+ ```bash
1321
+ apt-get update
1322
+ curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
1323
+ apt-get install -y nodejs
1324
+
1325
+ # Verify (must be 22.6 or newer)
1326
+ node --version
1327
+ ```
1328
+
1329
+ Create a non-root user to own the install and the systemd user-service:
1330
+
1331
+ ```bash
1332
+ adduser --disabled-password --gecos "" admin
1333
+ usermod -aG sudo admin
1334
+ echo "admin ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/admin
1335
+ chmod 440 /etc/sudoers.d/admin
1336
+ mkdir -p /home/admin/.ssh
1337
+ cp ~/.ssh/authorized_keys /home/admin/.ssh/authorized_keys
1338
+ chown -R admin:admin /home/admin/.ssh
1339
+ chmod 700 /home/admin/.ssh
1340
+ chmod 600 /home/admin/.ssh/authorized_keys
1341
+
1342
+ # From now on, SSH as admin, not root
1343
+ ssh admin@<ipv4>
1344
+ ```
1345
+
1346
+ Everything else the installer needs (apt deps for the VNC stack, `cloudflared`, Neo4j, Ollama, Chromium) is installed by `@rubytech/create-maxy-code` in step 3.
1347
+
1348
+ ## 3. Take a snapshot before installing
1349
+
1350
+ This is the step netcup gives you that a bare-metal host does not. Take a copy-on-write snapshot from the CCP now, with the OS prepared and nothing installed. A failed install or a bad upgrade rolls back to a known-good base in minutes rather than a rebuild.
1351
+
1352
+ Take another snapshot immediately before any subsequent platform upgrade.
1353
+
1354
+ ## 4. Run the installer
1355
+
1356
+ Run as the `admin` user (do not use `sudo`; the installer escalates internally where it needs to). The `--hostname` flag is required on a cloud VM — it becomes the Cloudflare-fronted hostname and the systemd unit name.
1357
+
1358
+ ```bash
1359
+ npx -y @rubytech/create-maxy-code@latest --hostname <hostname>
1360
+ ```
1361
+
1362
+ Pick a `<hostname>` that is short, lowercase, and unique across your Cloudflare account. The installer does exactly what it does on Hetzner — see [hetzner.md](hetzner.md#3-run-the-installer) for the step-by-step of what it creates. The full install log lands at `$HOME/.maxy-code/logs/install-<timestamp>.log`.
1363
+
1364
+ ### Installing a second brand
1365
+
1366
+ ```bash
1367
+ npx -y @rubytech/create-realagent-code@latest --hostname <realagent-hostname>
1368
+ ```
1369
+
1370
+ The persist directory becomes `$HOME/.realagent-code`, the systemd user-service becomes `<realagent-hostname>.service`, Neo4j is provisioned on its own port, and the VNC display + websockify + ttyd ports shift to the brand's reserved range.
1371
+
1372
+ Co-residency has an aggregate cost the per-brand capacity figure does not express, and clearing the 56 GiB step makes it sharper, not safer. Each brand's daemon reads whole-box RAM independently and reaches its own conclusion, so on a 64 GB host **four co-resident brands each resolve 40**, an aggregate of 160 concurrent sessions at roughly 1.2 GB each against a box that holds 64 GB. No brand reaches its own cap and is wrong; the box runs out first. A count-based per-daemon cap derived from box-wide RAM cannot express a constraint shared by co-resident daemons — the box-wide `MemAvailable` reclaim in `slice-memory-policy.ts` is what actually bounds this. Treat a multi-brand 64 GB install as needing that reclaim path verified under load, not assumed.
1373
+
1374
+ ## 5. Reach the dashboard and VNC browser over SSH port-forwarding
1375
+
1376
+ Identical to the Hetzner path — there is no LAN between operator and server, so both surfaces are forwarded over SSH until the Cloudflare tunnel exists. See [hetzner.md](hetzner.md#4-reach-the-dashboard-and-vnc-browser-over-ssh-port-forwarding) for the `ssh -L` invocations and the multi-server port convention.
1377
+
1378
+ ## 6. Bootstrap the Cloudflare tunnel
1379
+
1380
+ Identical to the Hetzner path, and the same rule applies: the tunnel's only auth path is `cloudflared tunnel login` driven interactively in the noVNC browser over the SSH forward. See [hetzner.md](hetzner.md#5-bootstrap-the-cloudflare-tunnel).
1381
+
1382
+ Setup is done when, and only when, `curl -I https://<hostname>.<your-zone>` issued from the operator's laptop returns `HTTP/2 200`. No state file, no `tunnel run` exit code, and no "service is active" claim substitutes for the live HTTPS response.
1383
+
1384
+ ## 7. Verify reboot persistence
1385
+
1386
+ Reboot the server (`sudo reboot`). After it comes back up, SSH back in and confirm:
1387
+
1388
+ ```bash
1389
+ systemctl --user status <hostname>.service
1390
+ systemctl --user status <hostname>-cloudflared.service
1391
+ ```
1392
+
1393
+ Both should be `Active: active (running)` within ten or twenty seconds of boot. `loginctl show-user admin | grep Linger` must report `Linger=yes` — without it the user-service does not start until you SSH in. The installer sets linger; if it is missing, run `sudo loginctl enable-linger admin`.
1394
+
1395
+ Confirm the resolved session capacity matches the host's RAM:
1396
+
1397
+ ```bash
1398
+ ~/maxy-code/platform/scripts/logs-read.sh --tail server 200 | grep 'op=capacity-resolved'
1399
+ ```
1400
+
1401
+ ## Uninstall
1402
+
1403
+ ```bash
1404
+ npx -y @rubytech/create-maxy-code@latest --uninstall
1405
+ ```
1406
+
1407
+ This stops and disables the systemd user-service, removes the unit file, removes the brand's `sysctl.d` QUIC-tuning file, and removes the persist directory `$HOME/.maxy-code/`. Shared apt packages stay on the system. To wipe the box completely, restore the pre-install snapshot from step 3, or cancel the server in the CCP.
1408
+
1409
+ ## What this install does not do
1410
+
1411
+ - **No SCP / rsync.** Updates are `npx -y @rubytech/create-maxy-code@latest …` again, never a file push from the operator's laptop.
1412
+ - **Tunnel auth is OAuth; the API is permitted for the rest.** As on Hetzner.
1413
+ - **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.
1414
+ - **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.
1415
+
1416
+ ## Smoke checklist
1417
+
1418
+ Fresh-netcup smoke pass criteria:
1419
+
1420
+ 1. Provision an RS 8000 G12 with Ubuntu 24.04 x86_64 and an SSH key; SSH in as `root`, create `admin`, switch.
1421
+ 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:
1422
+
1423
+ ```bash
1424
+ nc -z -w 5 <ipv4> 22 && echo "22 open (expected)"
1425
+ nc -z -w 5 <ipv4> 19200 && echo "EDGE EXPOSED — check the bind, then ufw"
1426
+ ```
1427
+
1428
+ 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.
1429
+
1430
+ Re-run this after every reboot, after any brand install, and after any CCP image reinstall.
1431
+ 3. Snapshot taken with the OS prepared and nothing installed.
1432
+ 4. Install completes on the clean image, prints a loopback URL, and the systemd user-service is `active (running)`.
1433
+ 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.
1434
+ 6. The noVNC page reached over `ssh -L` displays the admin UI.
1435
+ 7. Cloudflare setup ends with `curl -I https://<hostname>.<your-zone>` returning `HTTP/2 200` from the operator's laptop.
1436
+ 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.
1437
+ 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.
1438
+ 10. Uninstall removes the systemd unit and the persist directory.
1439
+
1440
+ If any step fails, attach `$HOME/.<brand>/logs/install-<timestamp>.log` to the report.
1441
+
1229
1442
  ---
1230
1443
  # Cloudflare Tunnel
1231
1444
  Source: https://docs.getmaxy.com/cloudflare.md
@@ -3031,7 +3244,7 @@ either is a regression.
3031
3244
  |---|---|---|
3032
3245
  | `/session` | Admin cookie session: PIN-gated mint, validate, rotate. | `GET /`, `POST /` |
3033
3246
  | `/sessions` | Legacy admin-server conversation routes. No UI consumer remains after the ConversationsModal was retired; the surviving handlers are deletion candidates and not described here. | (legacy, no live caller) |
3034
- | `/sidebar-sessions` | Sole data path for the sidebar Sessions list. One JSONL on disk equals one row. The row's delete button is the only way a row disappears. Each row carries `sessionId`, `title`, `startedAt`, `live`, `isSubagent`, `pid: number \| null` (basename of the matched `sessions/<pid>.json`), and `projectDir` (the directory holding the JSONL — consumed by the delete route). The payload also carries top-level `accountId` so the pane renders the full UUID label whose first ~8 chars prefix-match the truncated Remote Control daemon entry in claude.ai/code. The legacy `rcUrl` field is gone — the row's external-link affordance now POSTs `/session-rc-spawn` to start a fresh local `claude --remote-control <name> --session-id <sid>` PTY on every click. **WhatsApp and Telegram sessions (admin + public) appear in this list**, colour-coded by channel (WhatsApp green `#25D366`, Telegram blue `#229ED9`, via a `conv-channel-*` left accent bar) and carrying the standard row kebab (Usage & cost, Reset/re-seat, Rename, Archive, Delete) so the operator can inspect and re-seat a channel session's params. Each row's `channel` is the authoritative `.meta.json` sidecar value (`whatsapp`/`telegram`/`webchat`/`browser`/null), not the JSONL-envelope channel. Each row also carries its sidecar `senderId`. For a WhatsApp/Telegram row with a `senderId` and no operator rename or ai-title (`titleSource:'prefix'`), the route resolves a contact `personName` and the sidebar renders the reader panel's `"<name> <number>"` label via the shared `operatorConvName`, degrading to the number alone then the stripped title on a miss, so a Sessions row and that conversation's reader-panel row read identically. A channel row name has two sources, in precedence order. First the shared `resolveOperatorNamesBySender` helper the WhatsApp reader also calls, which matches the sender phone against the account's admins — that names an admin WhatsApp session and every Telegram session. Second, for a `whatsapp` row only, the WhatsApp pushName from the account's on-disk message store (`storePushNamesBySender` over `readConversationSummaries`, keyed by `normalizeE164` of the counterparty phone so a `+44 7…` sender matches a `447…` store key). The store is consulted only when the resolver reports **no binding** for that sender. That is typically a public WhatsApp customer, the case this exists for — such a sender has no admin record and so can only be named by the store, and without it those rows degraded to the bare number while the reader panel named them — though an admin missing from `users.json` qualifies too. A resolver miss caused by Neo4j being unreachable deliberately does NOT qualify: that is an outage rather than an absent binding, so the row degrades to the number and still logs `reason=neo4j-down`, keeping the outage signal intact. Telegram has no message store and is never looked up. The store read is guarded: a corrupt or absent store degrades to no pushName and logs `op=store-unreadable`, never 500s the list. Both sources are the ones the reader itself uses, so the two surfaces cannot drift; a renamed or ai-titled channel row keeps its own title. The Sessions list reads the viewed account's own store only. An account-manager's thread persists under the house account rather than the sub-account it manages, so on a multi-account install viewing a sub-account that row is not named here even though the reader panel names it — the reader additionally consults the house store, filtered to manager phones bound to that sub. The only role/channel excluded from the list is `public`+`webchat` (its dedicated reader is the sole surface); the exclusion predicate is `isSessionListExcluded`, decoupled from the reader's inclusion predicate `isReaderChannelSession` (unchanged), so the WhatsApp/Telegram reader panels are unaffected — a store-only WhatsApp conversation with no session JSONL stays reader-only. Observability rides `[admin-sessions-list] … channelInList=whatsapp:<n>,telegram:<n>,webchat:<n> channelNameCandidates=<n> channelNamesResolved=<n> (operator=<n> store=<n>) unknownChannel=<n> excludedPublicWebchat=<n>`, and each unresolved channel name logs one `[admin-sessions-list] op=channel-name-unresolved sessionId=<id8> channel=<whatsapp\|telegram> senderId=<id> reason=<no-senderId\|classify-miss\|neo4j-down>` line — emitted only when **both** sources miss, so a residual shortfall means a genuinely nameless sender rather than the expected public-customer floor, and the `operator=`/`store=` split says which source is carrying the names. | `GET /` |
3247
+ | `/sidebar-sessions` | Sole data path for the sidebar Sessions list. One JSONL on disk equals one row. The row's delete button is the only way a row disappears. Each row carries `sessionId`, `title`, `startedAt`, `live`, `isSubagent`, `pid: number \| null` (basename of the matched `sessions/<pid>.json`), and `projectDir` (the directory holding the JSONL — consumed by the delete route). The payload also carries top-level `accountId` so the pane renders the full UUID label whose first ~8 chars prefix-match the truncated Remote Control daemon entry in claude.ai/code. The legacy `rcUrl` field is gone — the row's external-link affordance now POSTs `/session-rc-spawn` to start a fresh local `claude --remote-control <name> --session-id <sid>` PTY on every click. **WhatsApp and Telegram sessions (admin + public) appear in this list**, colour-coded by channel (WhatsApp green `#25D366`, Telegram blue `#229ED9`, via a `conv-channel-*` left accent bar) and carrying the standard row kebab (Usage & cost, Reset/re-seat, Rename, Archive, Delete) so the operator can inspect and re-seat a channel session's params. Each row's `channel` is the authoritative `.meta.json` sidecar value (`whatsapp`/`telegram`/`webchat`/`browser`/null), not the JSONL-envelope channel. Each row also carries its sidecar `senderId`. For a WhatsApp/Telegram row with a `senderId` and no operator rename or ai-title (`titleSource:'prefix'`), the route resolves a contact `personName` and the sidebar renders the reader panel's `"<name> <number>"` label via the shared `operatorConvName`, degrading to the number alone then the stripped title on a miss. On a `whatsapp` row the number is printed in international form (`+44 7504 472444`, `+33 7 68 24 30 67`) by `formatSenderPhone` — `normalizeE164` then libphonenumber-js `formatInternational`, gated on `isPossible` so an unresolved LID prints raw; a `telegram` senderId is a Telegram user id and a `webchat` one is `session:<id>`, so neither is regrouped. The formatting is display-only: row identity, matching, and store keys stay on the normalized digits. Because the label function is shared, a Sessions row and that conversation's reader-panel row read identically. A channel row name has two sources, in precedence order. First the shared `resolveOperatorNamesBySender` helper the WhatsApp reader also calls, which matches the sender phone against the account's admins — that names an admin WhatsApp session and every Telegram session. Second, for a `whatsapp` row only, the WhatsApp pushName from the account's on-disk message store (`storePushNamesBySender` over `readConversationSummaries`, keyed by `normalizeE164` of the counterparty phone so a `+44 7…` sender matches a `447…` store key). The store is consulted only when the resolver reports **no binding** for that sender. That is typically a public WhatsApp customer, the case this exists for — such a sender has no admin record and so can only be named by the store, and without it those rows degraded to the bare number while the reader panel named them — though an admin missing from `users.json` qualifies too. A resolver miss caused by Neo4j being unreachable deliberately does NOT qualify: that is an outage rather than an absent binding, so the row degrades to the number and still logs `reason=neo4j-down`, keeping the outage signal intact. Telegram has no message store and is never looked up. The store read is guarded: a corrupt or absent store degrades to no pushName and logs `op=store-unreadable`, never 500s the list. Both sources are the ones the reader itself uses, so the two surfaces cannot drift; a renamed or ai-titled channel row keeps its own title. The Sessions list reads the viewed account's own store only. An account-manager's thread persists under the house account rather than the sub-account it manages, so on a multi-account install viewing a sub-account that row is not named here even though the reader panel names it — the reader additionally consults the house store, filtered to manager phones bound to that sub. The only role/channel excluded from the list is `public`+`webchat` (its dedicated reader is the sole surface); the exclusion predicate is `isSessionListExcluded`, decoupled from the reader's inclusion predicate `isReaderChannelSession` (unchanged), so the WhatsApp/Telegram reader panels are unaffected — a store-only WhatsApp conversation with no session JSONL stays reader-only. Observability rides `[admin-sessions-list] … channelInList=whatsapp:<n>,telegram:<n>,webchat:<n> channelNameCandidates=<n> channelNamesResolved=<n> (operator=<n> store=<n>) unknownChannel=<n> excludedPublicWebchat=<n>`, and each unresolved channel name logs one `[admin-sessions-list] op=channel-name-unresolved sessionId=<id8> channel=<whatsapp\|telegram> senderId=<id> reason=<no-senderId\|classify-miss\|neo4j-down>` line — emitted only when **both** sources miss, so a residual shortfall means a genuinely nameless sender rather than the expected public-customer floor, and the `operator=`/`store=` split says which source is carrying the names. | `GET /` |
3035
3248
  | `/session-delete` | POST `{ sessionId }`. Thin proxy over the manager: POST `/:id/stop` (idempotent) then DELETE `/:id`. Sends **no** accountId. The manager resolves the session by scanning every slug under `projects/` for its JSONL (`slugForExistingJsonl`, covering `archive/`), exactly as the sidebar list enumerates rows, and gates on file existence alone — so any session the operator can see is deletable. This replaces the earlier accountId-derived slug and the `!row` term, which narrowed resolution below the list and left a session under a non-house slug with no watcher row (e.g. a legacy `-home-admin` session on a single-account install) listed yet un-deletable. Account scope is a view filter, not a delete gate — admin access is install-wide, so there is nothing to scope against. The sibling `/session-stop` (the standalone End control) resolves identically and also forwards no accountId. | `POST /` |
3036
3249
  | `/session-rc-spawn` | POST `{ sessionId?, name? }`. Fire-and-forget `claude --remote-control [name] [--session-id <sid>]`. Present `sessionId` resumes; absent starts a fresh session (also used by the sidebar's "New session" button — it no longer opens claude.ai/code directly). Proxies to the manager's `/rc-spawn`, which waits up to **60 s** (raised from 12 s) for the spawned PTY to bind and returns `{ spawnedPid, sessionId, bridgeSessionId, slug, outcome, reason }`. For a webchat-bound spawn (every admin-gated host's "New session", returning a same-origin `/chat?session=<id>` target — `resolveRcSpawnOutcome` → `sameOrigin:true`) the Sidebar navigates the **current** tab via `window.location.assign`, replacing the dashboard in place (back returns to it); only a claude.ai/code slug (`sameOrigin:false`, the bare-admin resume bridge, never a new-session outcome) navigates a separately-opened tab. On `timeout` or `spawn-failed` it shows an error modal (reason + sessionId) and **never** opens a bare claude.ai/code tab. The new process registers itself as its own Remote Control entry in claude.ai/code. | `POST /` |
3037
3250
  | `/claude-sessions` | **Spawn surface only**. `POST /` is the Sidebar new-session-with-prompt path, cookie-auth only (the recorder loopback caller was removed; LinkedIn ingest moved to `/rc-spawn`). The former UI-facing handlers (SSE row feed, list, resume, stop, rename, archive, delete, `/:id/meta`, `/:id/input`, `/:id/log`) were removed — the maxy dashboard no longer manages or displays sessions. | `POST /` |
@@ -3054,7 +3267,7 @@ either is a regression.
3054
3267
 
3055
3268
  **`/chat` Claude-desktop transcript presentation.** The admin webchat keeps the shared `Transcript` shell (stream, follow-tail) but injects a /chat-only item renderer via the component's optional `renderItems` prop: `renderChatTimeline` in `app/chat/transcript-render.tsx`. Presentation: operator turns render as a right-aligned grey bubble showing the message text and, beneath it, a subtle always-visible time-of-day stamp (no label); delivered agent replies render as plain prose with the same time-of-day stamp beneath (the reply-document filename line stays, as prose); the stamp is HH:MM from the turn's `ts` (locale-formatted, right-aligned inside the operator bubble, left-aligned under agent prose), and a turn whose `ts` is null/unparseable shows no stamp, never an empty line — so only delivered operator and agent-reply turns are stamped, while tool runs, the collapsed "Thinking" block, and the agent-error banner stay time-free; a maximal consecutive run of `tool-call`/`tool-result` turns renders as one collapsed grey one-liner ("Used N tools ›") whose expansion shows every call and result payload — a lone call still gets the one-liner, and prose or a directive row ends a run. The WhatsApp/operator reader (`/whatsapp`, `OperatorConversations`) omits the prop and keeps the default `renderTimeline`, so its bubble/DM chrome is unchanged — except that since the attachment-render extraction the default renderer draws inbound `operator-inbound` uploads and `agent-file` deliveries inline (image thumbnail / pdf.js preview / voice-note player / download chip) via the attachment renderer shared with `/chat`, extracted to `app/chat/attachment-render.tsx` to break the cycle with `transcript-render.tsx`; `renderChatTimeline` is untouched. Both presentations are test-pinned (`app/whatsapp/__tests__/Transcript-*.test.tsx`, `app/chat/__tests__/transcript-render.test.tsx`, `app/chat/__tests__/pdf-preview.test.tsx`). **Day-divider:** both renderers insert a centered `.day-divider` row between two consecutive timeline items on different local calendar days (label `Today`/`Yesterday`, else `Sat 14 Jun 2026`), so a thread spanning midnight is never an ambiguous run of HH:MM; the first dated item gets a leading divider, a null/unparseable `ts` marks no boundary, dividers are computed over the filtered `visibleItems`, and in `/chat` a day crossover flushes any open tool/think run first so a collapsed run never spans it. Per-bubble HH:MM stamps are unchanged. Shared helpers `dayKey`/`dayLabel`/`itemTs`/`DayDivider` live in `app/whatsapp/Transcript.tsx`.
3056
3269
 
3057
- **One delivered bubble per turn when the agent replied.** On the admin `/chat`, a turn that used the reply tool shows exactly one delivered bubble — the reply. A trailing sign-off or recap the agent writes after the reply is treated as narration and is not shown as a second bubble (it collapses like any mid-turn prose). Only a turn that ends **without** using the reply tool has its final text promoted to a delivered bubble, so a genuine no-reply answer is never lost. The tradeoff, accepted by the operator: a turn that acknowledges on the reply tool and then writes the real answer as trailing text will not show that trailing answer the agent is steered to put the answer on the reply tool so this stays rare.
3270
+ **One delivered bubble per turn on the admin `/chat`.** The admin webchat has no outbound transport, so the agent has no reply tool there: the text its turn ends with **is** the delivered answer, and it renders as the one prominent prose bubble. Mid-turn prose collapses into the folded run as before, so a turn shows exactly one delivered bubble. A turn that reaches its end with no trailing text delivers nothing and is logged as `op=turn-answer-absent`. Public webchat, WhatsApp and Telegram keep their reply tool, and every historical admin transcript still renders its recorded reply-tool bubbles unchanged.
3058
3271
 
3059
3272
  **WhatsApp reader is read/write (WhatsApp-Web surface).** A WhatsApp store conversation in the operator Conversations reader (`OperatorConversations`) and the admin root channel viewer (`app/page.tsx`) no longer opens the plain read-only DM chrome — it opens `app/whatsapp/WhatsappWebConversation.tsx`, a WhatsApp-Web-styled surface: a conversation header, the delivered-only store transcript reskinned green/white under a `.wa-web` root class, and a reply composer (`app/whatsapp/ReplyComposer.tsx`). The operator can type text, insert emoji (a codepoint-data picker — no OS input, so the `check-no-emoji-in-jsx` gate stays green), record a voice note (MediaRecorder → the existing audio send path), attach a file **from device storage** (a modal browsing the DATA_ROOT tree via `GET /api/admin/files`, sent by reference — the OS file dialog is no longer the reader's picker), reply to a specific message (a per-bubble affordance that quotes the original in the composer and threads natively on WhatsApp), and search the loaded transcript (a header control that filters to matching turns and highlights the match). The header carries only that Search control — the former Video / Phone / menu icons are gone, because no call or presence data exists behind them. Sent files honour the webchat allowlist (`SUPPORTED_MIME_SET` / 50 MB / 5 files); a device-file reference is authorised server-side (`authorizeReplyFileRef`) against the send's own account (the conversation owner) **or** the session's pinned account (`getAccountIdForSession` — the exact scope the DeviceFilePicker used to offer it), so a house/managing-admin session can attach a managed-client file the picker listed. Because a reply sends bytes OUT to an external WhatsApp recipient, any other account, a `public` partition, and any install-wide / non-account path are refused fail-closed; the cross-account admission's authority is the session's pinned account, never the caller-supplied body `accountId` (the own-account admission matches the body `accountId`, which the route already trusts via `isValidAccountId` and the existing-conversation guard). A scope refusal returns a distinct HTTP 403 reason (`file-ref-cross-account` / `file-ref-public` / `file-ref-foreign`) and copy, never "device file not found" (reserved for a genuine resolve/stat miss). Per-ref `[wa-manual-send] op=reply-file-ref … via=own|session|deny reason=<…> ok=<bool>` logs the decision before any send; `via=deny ok=true` is the exfiltration signature that must never appear. **Send is reply-only and DM-only:** `POST /api/whatsapp-reader/reply` (`requireAdminSession`) refuses any target with no existing stored conversation (`reason=no-existing-conversation`, HTTP 403) — a cold first-contact send bans the account, so the composer is never a cold-contact path, enforced server-side — and refuses `@g.us` groups (`reason=group-send-blocked`), matching the agent's observe-only group invariant; a group thread shows a read-only note in place of the composer. The endpoint is the sole store writer for a manual send: right after each send resolves it appends the record itself, stamped `origin:'operator-manual'` (attributed "You", distinct from an assistant reply), because the Baileys echo that normally persists the store is gated on message text (`manager.ts:906`) and would leave an uncaptioned media send delivered-but-invisible. The record's `messageId` matches the echo's derivation so the async text echo's own persist dedups to a no-op; the send is confirmed by re-read (`op=recorded present=<bool>`). A sent send does not spawn an assistant turn (echo dispatch stays suppressed). Attachments deliver as WhatsApp media/documents and render **inline** in the store reader (image thumbnail / pdf preview / audio player / download chip), because the manual-send path records the message with the servable `attachmentId`. The `.wa-web` skin is scoped to WhatsApp store threads only, so `/chat`, the admin agent session view, and the public visitor webchat are unchanged; the visitor webchat gains no send capability. The in-bubble delivered-tick is cosmetic (no read-receipt data is stored); inbound bubbles keep their sender name so group threads stay attributable, and both directions carry a WhatsApp callout tail (`.wa-web`-scoped only). Non-WhatsApp reader rows (Telegram/webchat session JSONLs) keep the plain read-only `Transcript`. Observability rides `[wa-manual-send]` lines correlated by a per-request `sendId`. **Composer sizing matches the webchat composer:** the reply textarea auto-grows to fit multi-line content (measured from `scrollHeight`, clamped to a one-row minimum and a `viewport/3` maximum, scrollable past the cap with the scrollbar hidden), and a top-border grab handle drags (pointer) or nudges (ArrowUp/ArrowDown, keyboard-focusable) to an explicit height in the same clamp; a manual drag pins the height and overrides auto-grow until the field is cleared, which releases it back to auto-grow. A completed manual resize logs `[operator-ui] op=wa-composer-resize mode=drag|key h=<px>`; a bare click on the handle (no drag) logs nothing.
3060
3273
 
@@ -3732,7 +3945,7 @@ authoritative. This section names the surfaces and what backs each.
3732
3945
  > a read-only reader. The item gates on the flyout handler being wired, exactly
3733
3946
  > like the admin and lite items. The read-only inbound-channel **Conversations**
3734
3947
  > reader (WhatsApp, Telegram, public webchat, via the admin-gated
3735
- > `/api/whatsapp-reader/conversations` route) remains on `/data`'s Conversations
3948
+ > `/api/operator-conversations/conversations` route) remains on `/data`'s Conversations
3736
3949
  > item and in the admin Sidebar; it is no longer reachable from the operator chat
3737
3950
  > surface. The operator surface is documented in full in the internal doc
3738
3951
  > `maxy-code/.docs/operator-dashboard.md`.
@@ -3774,7 +3987,7 @@ authoritative. This section names the surfaces and what backs each.
3774
3987
  > count. On the operator **chat** surface the handler is always wired, so the
3775
3988
  > item is always present and opens the live session switcher (above). On `/data`
3776
3989
  > the handler is wired only when ≥1 reviewable inbound conversation exists
3777
- > (fetched from `/whatsapp-reader/conversations` with the operator's session
3990
+ > (fetched from `/operator-conversations/conversations` with the operator's session
3778
3991
  > key), and selecting it renders the read-only reader in place (the file browser
3779
3992
  > is swapped for it; Back returns) — so `/data` keeps its hide-when-empty
3780
3993
  > behaviour. The flyout-open signal `[operator-ui] op=conversations-menu
@@ -3788,8 +4001,8 @@ authoritative. This section names the surfaces and what backs each.
3788
4001
  | Nav rows (Chat / People / Agents / Projects / Tasks / Artefacts) | People, Agents, Tasks open the artefact-pane Graph filtered to the matching label. Chat selects the active conversation. Artefacts swaps the list to editable documents. **The "Projects" row is brand-aware:** its visible term and the graph label it filters to both come from the injected `window.__BRAND__.primaryContainer` (`brand.json` → `server/index.ts` inject → `brand.ts` `brandPrimaryContainer()`), so SiteDesk reads "Projects" → `:Project`, SiteDesk "Jobs" → `:Job`, Real Agent "Properties" → `:Property`. A brand JSON missing `primaryContainer` falls back to Project/Projects (server logs `[brand] op=primaryContainer-default reason=absent brand=<hostname>`; the populated case logs `[brand] op=inject primaryContainer=<label>/<term>`). | `graph-search.ts`, `graph-subgraph.ts`, `sidebar-artefacts.ts` |
3789
4002
  | Sessions list (Active / Archived / All) | Live row store driven by SSE; manual reconcile button on the segmented control re-fetches the full id set. Every listed row carries two launch actions: the claude.ai/code resume (`ExternalLink`) and a chat-launch (`MessageSquare`) that opens `/chat?session=<full sessionId>` — the admin webchat pointed at that exact session; sending there resumes a stopped session with the webchat channel bound to that id (see `admin-webchat-native-channel.md`, "Session-targeted mode"). **Clicking the row's body** (live-dot, title, id, or timestamp) opens that same `/chat?session=<id>` as the chat-launch action. The main area stays a `<div>` (it wraps the inline rename input, which a `<button>` may not contain) carrying `role="button"`, `tabIndex`, an "Open <title> in webchat" aria-label, and Enter/Space activation, styled with `conv-main-clickable` for pointer + hover parity with the channel-row button. The click and keyboard handlers no-op while the row is being renamed, so an in-progress edit is preserved; the action icons and the `⋯` overflow menu are a sibling cluster running their own handlers, so activating them never triggers the row-body open. At the ≤720 px drawer breakpoint each row stacks into two lines — dot + title + id/stamp, then the action icons — with a divider between rows and actions at full opacity (the desktop hover-gated 0.5 opacity never resolves on touch). Below a 400 px list width each row's action icons collapse to a single `MoreHorizontal` ellipsis trigger; its popover menu lists the same actions as the inline icons but labels them with concise verbs (`Resume in claude.ai/code`, `Open in webchat`, `Stop`, `Archive` / `Unarchive`, `Rename`, `Delete`, `Re-seat`) rather than the inline icon buttons' verbose per-session aria-labels — the inline buttons keep those verbose labels because an icon-only control needs the full descriptive accessible name. **Re-seat:** Re-seat is a member action of the one `SessionRowActions` cluster, not a separate sibling control — wide it is the sliders icon inline with the other icons (sharing the cluster geometry and alignment); collapsed it is the `Re-seat` menu item, which expands the model/mode/effort form inline inside the overflow menu. The form (`SessionReseatControl`) forks the session via `/api/admin/session-reseat` and navigates to the fork (the same fork mechanism `/chat`'s pickers drive); its model picker defaults to the row's current model (a `model` field on each row, read from the JSONL tail), and mode and effort each carry a "Keep" option that omits the field. The overflow menu and the Re-seat form render through a `document.body` portal, fixed-positioned from the trigger's rect (right-aligned, flipped above the trigger when there is no room below), so neither is clipped by `.side-list`'s `overflow-y:auto`; Escape, a pointerdown outside the trigger/popover, and any scroll or resize dismiss them. | `/claude-sessions/events`, `/claude-sessions`, `/session-reseat` |
3790
4003
  | Data pane | A "Data" nav row beside Artefacts/Sessions. Uses the same `activeNav` toggle, but its content — the headless `DataFileBrowser` (graph search + `{installDir}/data/` file browser) — renders in the shell's **main column** via `DataPaneSurface`, like the WhatsApp Transcript, not in the side-list; there is no side-list block under `activeNav==='data'`. `AdminShell` owns a `dataOpen` main-column state (mutually exclusive with the WhatsApp selection); the row lifts the choice through `onSelectData`. On `/` it opens in place; from any other route (`/graph`, `/chat`, `/browser`) it navigates to `/?data=1`, which the root shell hydrates on mount (`hydrateDataFromUrl`, then strips the query). The admin burger no longer carries a Data item (the operator surface, which has no Sidebar, keeps the burger → `/data` route). Client breadcrumbs: `[admin-ui] sidebar-nav surface=data …`, `[admin-ui] data-open route=… via=<in-place\|navigate>`, `[admin-ui] data-hydrate route=/`. | `sidebar-artefacts.ts`, `files.ts`, `graph-search.ts` |
3791
- | Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row per channel that has ≥1 conversation. Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no conversations shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount**, so a failed load shows one muted, non-clickable line instead of channel rows. **WhatsApp rows are sourced from the on-disk message store** (`readAllConversations`), one row per stored conversation keyed by `remoteJid`, independent of whether any session JSONL exists — a blocked non-admin DM or a group-observed thread that never produced a session is listed here. **Telegram rows are session-sourced** (one per admin Telegram session), unchanged. Each conversation row shows a **display name**: for WhatsApp, the store record's `senderName` (else the `senderTelephone`, else the `remoteJid`); for Telegram, the precedence `operatorName ?? senderId ?? title` where `operatorName` is the bound `Person givenName familyName`. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`) from `lastMessageAt`, never "Invalid Date". **Scope colour (WhatsApp):** each WhatsApp row is coloured by its conversation's scope — a thin left accent bar, green for `admin`, orange for `public`, taken from the conversation's most-recent stored record (this replaces the former "Public" text pill). **Re-seat:** only session-sourced rows (Telegram, and webchat in the Sessions panel) carry a `SessionRowActions` Re-seat cluster; a WhatsApp store row has no session, so it carries no Re-seat. A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route it navigates to `/?wa=<remoteJid>&acct=<accountId>`, which the root shell hydrates on mount (then strips the query). **Account-scoped** exactly as the sidebar Sessions list: both row sources (WhatsApp store rows and Telegram/public-webchat session rows) are scoped to the caller's pinned account (`getAccountIdForSession(cacheKey)`), multi-account-gated (`listValidAccounts().length > 1`). On a multi-account install a subaccount view shows only its own conversations; the house account's WhatsApp appears only under the house view; an untagged session sidecar is excluded and counted; an unresolvable scope fails closed to an empty list (logs `op=list-scope-unresolved`). A single-account install filters nothing, byte-identical to before. Server logs `[wa-reader] op=list pinnedAccount=<id8> resolvedAccount=<id8> multiAccount=<bool> conversations=<n> sessionRows=<m> storeRows=<k> adminScope=<n> publicScope=<n> sessionRowsExcludedUntagged=<n>` per list build (`pinnedAccount === resolvedAccount` is the post-fix invariant when `multiAccount=true`; `conversations=0` on an account with a non-empty store dir is the over-filtering drift signal); the client logs `[admin-ui] wa-open route=… via=<in-place\|navigate> …` and `[admin-ui] wa-hydrate route=/ remoteJid=…`. | `/whatsapp-reader/conversations` |
3792
- | Channel transcript (`<Transcript>`) | **WhatsApp** reads its conversation verbatim from the message store file (`<remoteJid>.jsonl`) over SSE (`/whatsapp-reader/store-stream`): both directions, `dateSent`-ordered, backlog on connect then a live tail so a newly stored message appears without reload — no session JSONL is read. **Telegram** still reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). On the Telegram session stream, `tool-call`/`tool-result` turns render as **collapsed cards** so the human↔agent text is not buried in JSON. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom**; while scrolled up a **jump-to-bottom control** (`.wa-jump`) appears. Both streams send a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tag each frame with a byte-offset `id:`, and resume from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. The Telegram session stream also interleaves a collapsed `⚙ directive injected (N B)` row per turn (listed by `GET /whatsapp-reader/directives?sessionId=`, bytes served by `GET /whatsapp-reader/directive?sessionId=&name=`); a WhatsApp store conversation has no session, so it has no directives. A centered day-divider row (`.day-divider`) marks each local calendar-day crossover. This viewer renders **delivered messages only** (both WhatsApp store rows and Telegram session rows, via `forceDeliveredOnly`) with **no per-viewer toggle** — the former "Messages only" pill is retired; the admin `/chat` composer's own messages-only control is unaffected. The panel — wallpaper included — is **bounded to `--chat-measure` and centred** (`.platform > .wa-reader`), so it reads as a column the same width as `/data`, not an edge-to-edge beige surface. The store stream open logs `[wa-reader] op=open remoteJid=<jid> scope=<admin\|public> messages=<n> source=store`. | `/whatsapp-reader/store-stream`, `/whatsapp-reader/stream`, `/whatsapp-reader/directives`, `/whatsapp-reader/directive` |
4004
+ | Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row per channel that has ≥1 conversation. Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no conversations shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount**, so a failed load shows one muted, non-clickable line instead of channel rows. **WhatsApp rows are sourced from the on-disk message store** (`readAllConversations`), one row per stored conversation keyed by `remoteJid`, independent of whether any session JSONL exists — a blocked non-admin DM or a group-observed thread that never produced a session is listed here. **Telegram rows are session-sourced** (one per admin Telegram session), unchanged. Each conversation row shows a **display name**: for WhatsApp, the store record's `senderName` (else the `senderTelephone`, else the `remoteJid`); for Telegram, the precedence `operatorName ?? senderId ?? title` where `operatorName` is the bound `Person givenName familyName`. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`) from `lastMessageAt`, never "Invalid Date". **Scope colour (WhatsApp):** each WhatsApp row is coloured by its conversation's scope — a thin left accent bar, green for `admin`, orange for `public`, taken from the conversation's most-recent stored record (this replaces the former "Public" text pill). **Re-seat:** only session-sourced rows (Telegram, and webchat in the Sessions panel) carry a `SessionRowActions` Re-seat cluster; a WhatsApp store row has no session, so it carries no Re-seat. A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route it navigates to `/?wa=<remoteJid>&acct=<accountId>`, which the root shell hydrates on mount (then strips the query). **Account-scoped** exactly as the sidebar Sessions list: both row sources (WhatsApp store rows and Telegram/public-webchat session rows) are scoped to the caller's pinned account (`getAccountIdForSession(cacheKey)`), multi-account-gated (`listValidAccounts().length > 1`). On a multi-account install a subaccount view shows only its own conversations; the house account's WhatsApp appears only under the house view; an untagged session sidecar is excluded and counted; an unresolvable scope fails closed to an empty list (logs `op=list-scope-unresolved`). A single-account install filters nothing, byte-identical to before. Server logs `[wa-reader] op=list pinnedAccount=<id8> resolvedAccount=<id8> multiAccount=<bool> conversations=<n> sessionRows=<m> storeRows=<k> adminScope=<n> publicScope=<n> sessionRowsExcludedUntagged=<n>` per list build (`pinnedAccount === resolvedAccount` is the post-fix invariant when `multiAccount=true`; `conversations=0` on an account with a non-empty store dir is the over-filtering drift signal); the client logs `[admin-ui] wa-open route=… via=<in-place\|navigate> …` and `[admin-ui] wa-hydrate route=/ remoteJid=…`. | `/operator-conversations/conversations` |
4005
+ | Channel transcript (`<Transcript>`) | **WhatsApp** reads its conversation verbatim from the message store file (`<remoteJid>.jsonl`) over SSE (`/whatsapp-reader/store-stream`): both directions, `dateSent`-ordered, backlog on connect then a live tail so a newly stored message appears without reload — no session JSONL is read. **Telegram** still reads one session's JSONL over SSE (`/session-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). On the Telegram session stream, `tool-call`/`tool-result` turns render as **collapsed cards** so the human↔agent text is not buried in JSON. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom**; while scrolled up a **jump-to-bottom control** (`.wa-jump`) appears. Both streams send a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tag each frame with a byte-offset `id:`, and resume from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. The Telegram session stream also interleaves a collapsed `⚙ directive injected (N B)` row per turn (listed by `GET /session-reader/directives?sessionId=`, bytes served by `GET /session-reader/directive?sessionId=&name=`); a WhatsApp store conversation has no session, so it has no directives. A centered day-divider row (`.day-divider`) marks each local calendar-day crossover. This viewer renders **delivered messages only** (both WhatsApp store rows and Telegram session rows, via `forceDeliveredOnly`) with **no per-viewer toggle** — the former "Messages only" pill is retired; the admin `/chat` composer's own messages-only control is unaffected. The panel — wallpaper included — is **bounded to `--chat-measure` and centred** (`.platform > .wa-reader`), so it reads as a column the same width as `/data`, not an edge-to-edge beige surface. The store stream open logs `[wa-reader] op=open remoteJid=<jid> scope=<admin\|public> messages=<n> source=store`. | `/whatsapp-reader/store-stream`, `/session-reader/stream`, `/session-reader/directives`, `/session-reader/directive` |
3793
4006
  | Conversations row hover actions | Inline rename, archive, delete, JSONL view / download per row. The historical `.conversations-modal` CSS block exists in `globals.css` but is no longer mounted from any TSX — Sidebar.tsx now owns every per-row affordance directly. | `claude-sessions.ts` |
3794
4007
  | Artefacts list | Lists every `:FileArtifact` under this account's tree (`relativePath STARTS WITH 'accounts/'`, all file types, excluding the `uploads/<id>/` subtree) plus this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates. Click downloads the row's backing file (`downloadPath` → `GET /api/admin/files/download`) so the operator opens it in their local app; rows whose file is outside `DATA_ROOT` (bundled-fallback templates) show a "can't be downloaded" pill. The in-app artefact pane is dead pending removal. | `sidebar-artefacts.ts`, `files.ts` |
3795
4008
  | System-stats widget | CPU / RAM widget at the foot of the sidebar. | `system-stats.ts` (see above) |
@@ -3891,6 +4104,44 @@ equivalent wording: the reader reply path and the `/send-admin` route.
3891
4104
  Consolidating them is tracked separately; do not copy their strings into
3892
4105
  new callers.
3893
4106
 
4107
+ ## WhatsApp inbound notifications
4108
+
4109
+ An inbound WhatsApp message raises a WhatsApp-green card at the **top right** of
4110
+ every admin surface within about a second of arrival. The card shows the
4111
+ sub-account it arrived on, the sender (their WhatsApp profile name, or their
4112
+ number when they have none), and the first two lines of the message, with an
4113
+ ellipsis when there was more. A media message with no caption shows its media
4114
+ type instead.
4115
+
4116
+ The card disappears within five seconds and is **stored nowhere** — there is no
4117
+ history, no unread count, and nothing to click. It exists to tell the operator a
4118
+ message landed; the reader is where the message itself is read.
4119
+
4120
+ **Bursts drain rather than pile up.** One card alone lasts five seconds. As more
4121
+ arrive, every card on screen speeds up: two share 2.5 seconds each, three 1.7,
4122
+ four 1.25, and five or more sit on a 1.2 second floor. A card's dwell only ever
4123
+ shortens, so it does not linger once the stack empties. There is no cap on how
4124
+ many cards can appear at once.
4125
+
4126
+ **Four sender types, one icon each:**
4127
+
4128
+ | Icon | Sender |
4129
+ |---|---|
4130
+ | Shield | **Admin** — the operator's own number, or a bound admin |
4131
+ | Two-way arrows | **Account manager (active)** — a manager whose messages drive an agent turn |
4132
+ | Eye | **Account manager (passive)** — a passive bind, which files work but never replies |
4133
+ | Person | **Public** — a member of the public |
4134
+
4135
+ Cards from **every** sub-account appear regardless of which account the tab is
4136
+ currently pinned to, and each is labelled with that sub-account's name. That is
4137
+ deliberate: seeing another account's message is what tells the operator to
4138
+ switch to it.
4139
+
4140
+ Messages that raise no card, by design: anything the operator sent, group
4141
+ messages (groups are observation-only), history catch-up during a re-sync, and
4142
+ any sender the access rules decline. A blocked or unrecognised contact is
4143
+ silent.
4144
+
3894
4145
  ## Related references
3895
4146
 
3896
4147
  - [`platform.md`](platform.md) — UI layout, session reconcile model,
@@ -5199,6 +5450,8 @@ So:
5199
5450
 
5200
5451
  The share is reachable solely from the box itself. Operators reach it by forwarding loopback over SSH — `ssh -L 4445:localhost:445 admin@<tunnel-host>` and then mounting `smb://localhost:4445` — or by joining the box to a private network and re-installing, which makes the private interface the chosen LAN bind.
5201
5452
 
5453
+ That second remedy applies to SMB only. The admin edge uses a different rule: it binds loopback whenever the host carries a public IPv4, whether or not a private interface exists alongside it, so joining a private network does not make the edge LAN-reachable on a cloud box.
5454
+
5202
5455
  If the device has no non-loopback IPv4 interface at all, the installer refuses to provision and exits — there is nothing safe to bind to. This is distinct from the public-only case, which still provisions a working (host-local) share.
5203
5456
 
5204
5457
  This classification is the structural guarantee that SMB never leaves the box (loopback-only) or the LAN (LAN-only), even if upstream firewall rules are misconfigured. The Cloudflare tunnel that fronts the admin UI carries HTTPS only; it does not route SMB.
@@ -9,6 +9,18 @@ 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-28 (0.1.519)
13
+
14
+ - The admin webchat now delivers the assistant's answer as the turn's final text, and the separate reply tool has been retired for admin sessions (public visitors still get it).
15
+ - The Edge tunnel now binds to the correct interface on hosts without a LAN, and the port is documented as private by construction with ufw as the backstop.
16
+ - WhatsApp group messages in the reader show sender colours and avatars.
17
+
18
+ ## 2026-07-28 (0.1.518)
19
+
20
+ - WhatsApp sender numbers are now shown in full international form instead of a bare string of digits.
21
+ - Replying from the WhatsApp reader now goes out on the connection that conversation actually arrived on, rather than the account's default one.
22
+ - There is a new max plan tier of 20 admins, and machines with 56 GiB of memory now run up to 40 conversations at once.
23
+
12
24
  ## 2026-07-27 (0.1.517)
13
25
 
14
26
  - Three stray zip files that had been packaged into the install by mistake are gone, including one that was served on your own site address. They held duplicate logo images and an out-of-date copy of some internal code, and nothing used them. The packaging step now refuses to include a zip at all, so this cannot happen again.