@rubytech/create-maxy-code 0.1.478 → 0.1.480

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 (252) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1789-reseat-channel-row-fork.md +1244 -0
  3. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1818-loop-gate-app-routes.md +462 -0
  4. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1819-top-level-label-allowlist.md +321 -0
  5. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1829-platform-ui-typecheck.md +830 -0
  6. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1831-data-portal-class-derived-allowlist.md +2177 -0
  7. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1789-reseat-channel-row-fork-design.md +201 -0
  8. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1818-loop-gate-app-routes-design.md +217 -0
  9. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1819-top-level-label-allowlist-design.md +80 -0
  10. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1829-platform-ui-typecheck-design.md +163 -0
  11. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md +163 -0
  12. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  13. package/payload/platform/lib/graph-style/dist/index.js +22 -0
  14. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  15. package/payload/platform/lib/graph-style/src/__tests__/parity.test.ts +6 -0
  16. package/payload/platform/lib/graph-style/src/index.ts +20 -0
  17. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +155 -12
  18. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  19. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +3 -0
  20. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  21. package/payload/platform/lib/storage-broker/dist/cf-exec.js +68 -28
  22. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  23. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +194 -16
  24. package/payload/platform/lib/storage-broker/src/cf-exec.ts +74 -29
  25. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +13 -1
  26. package/payload/platform/plugins/cloudflare/PLUGIN.md +26 -1
  27. package/payload/platform/plugins/cloudflare/bin/d1-http.mjs +80 -0
  28. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +17 -2
  29. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +357 -0
  30. package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +103 -0
  31. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
  32. package/payload/platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts +96 -0
  33. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +120 -0
  34. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +59 -4
  35. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +176 -0
  36. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-state-contract.test.ts +75 -0
  37. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +131 -0
  38. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +68 -0
  39. package/payload/platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts +88 -0
  40. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +39 -0
  41. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +68 -1
  42. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +30 -1
  43. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +87 -0
  44. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +8 -4
  45. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +10 -0
  46. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +107 -1
  47. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +82 -2
  48. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +9 -0
  49. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +56 -0
  50. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +98 -2
  51. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +12 -0
  52. package/payload/platform/plugins/docs/references/cloudflare.md +12 -0
  53. package/payload/platform/plugins/outlook/mcp/dist/__tests__/device-flow.test.js +41 -1
  54. package/payload/platform/plugins/outlook/mcp/dist/__tests__/device-flow.test.js.map +1 -1
  55. package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js +249 -2
  56. package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js.map +1 -1
  57. package/payload/platform/plugins/outlook/mcp/dist/__tests__/token-store.test.js +127 -3
  58. package/payload/platform/plugins/outlook/mcp/dist/__tests__/token-store.test.js.map +1 -1
  59. package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.d.ts +17 -0
  60. package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.d.ts.map +1 -1
  61. package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.js +26 -1
  62. package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.js.map +1 -1
  63. package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.d.ts +37 -1
  64. package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.d.ts.map +1 -1
  65. package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.js +126 -5
  66. package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.js.map +1 -1
  67. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts +18 -0
  68. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts.map +1 -1
  69. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js +123 -18
  70. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js.map +1 -1
  71. package/payload/platform/plugins/outlook/references/auth.md +45 -2
  72. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts +2 -0
  73. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts.map +1 -0
  74. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js +66 -0
  75. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js.map +1 -0
  76. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts +41 -0
  77. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts.map +1 -0
  78. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js +75 -0
  79. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js.map +1 -0
  80. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +122 -0
  81. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  82. package/payload/server/{chunk-3XLLTG6R.js → chunk-REWXLAPC.js} +30 -10
  83. package/payload/server/package.json +1 -1
  84. package/payload/server/public/activity.html +7 -6
  85. package/payload/server/public/assets/{AdminLoginScreens-DR5eLF-a.js → AdminLoginScreens-DXYJWXaX.js} +1 -1
  86. package/payload/server/public/assets/AdminShell-UDgNN4CA.js +2 -0
  87. package/payload/server/public/assets/Checkbox-C08Ulc_a.js +1 -0
  88. package/payload/server/public/assets/activity-06gsRDVv.js +1 -0
  89. package/payload/server/public/assets/admin-BhiHyORH.js +1 -0
  90. package/payload/server/public/assets/{arc-BrHWN2Kr.js → arc-BHXuzTwg.js} +1 -1
  91. package/payload/server/public/assets/architecture-YZFGNWBL-Ct5q6ks1.js +1 -0
  92. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-Clt9NOr0.js → architectureDiagram-Q4EWVU46-DsN_vxau.js} +1 -1
  93. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-BOe_jmXi.js → blockDiagram-DXYQGD6D-CvNat1uB.js} +1 -1
  94. package/payload/server/public/assets/browser-Cz4IKS47.js +1 -0
  95. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-B64FaKMV.js → c4Diagram-AHTNJAMY-E9yqTPTW.js} +1 -1
  96. package/payload/server/public/assets/calendar-D0xtNqH8.js +1 -0
  97. package/payload/server/public/assets/channel-PE9vBKEQ.js +1 -0
  98. package/payload/server/public/assets/chat-DwLJ830n.js +1 -0
  99. package/payload/server/public/assets/chevron-left-Cv-TFuEf.js +1 -0
  100. package/payload/server/public/assets/{chunk-2KRD3SAO-BGAUfWvM.js → chunk-2KRD3SAO-DpzI_0EY.js} +1 -1
  101. package/payload/server/public/assets/{chunk-336JU56O-PFi6uQZ0.js → chunk-336JU56O-eR_I9Eu4.js} +2 -2
  102. package/payload/server/public/assets/chunk-426QAEUC-BDNZtJsz.js +1 -0
  103. package/payload/server/public/assets/{chunk-4BX2VUAB-CkxXXCiF.js → chunk-4BX2VUAB-D-6xk1t8.js} +1 -1
  104. package/payload/server/public/assets/{chunk-4TB4RGXK-B-7tN4IA.js → chunk-4TB4RGXK-BkqUcFpL.js} +1 -1
  105. package/payload/server/public/assets/{chunk-55IACEB6-CBkNmqrt.js → chunk-55IACEB6-ChCl2pSP.js} +1 -1
  106. package/payload/server/public/assets/{chunk-5FUZZQ4R-wa9ujn7i.js → chunk-5FUZZQ4R-DasGsg54.js} +1 -1
  107. package/payload/server/public/assets/{chunk-5PVQY5BW-DIk-xBaE.js → chunk-5PVQY5BW-DXlHUX1h.js} +1 -1
  108. package/payload/server/public/assets/{chunk-67CJDMHE-BbMV1lgW.js → chunk-67CJDMHE-BZfVpTsE.js} +1 -1
  109. package/payload/server/public/assets/{chunk-7N4EOEYR-C49Mv-nQ.js → chunk-7N4EOEYR-C6U5TIgT.js} +1 -1
  110. package/payload/server/public/assets/{chunk-AA7GKIK3-Dy_pJOp_.js → chunk-AA7GKIK3-CA7cVDmw.js} +1 -1
  111. package/payload/server/public/assets/{chunk-BSJP7CBP-Ce-OmPqn.js → chunk-BSJP7CBP-DxN2GC3p.js} +1 -1
  112. package/payload/server/public/assets/{chunk-CIAEETIT-tnjq-APC.js → chunk-CIAEETIT-C0-4wA8y.js} +1 -1
  113. package/payload/server/public/assets/{chunk-EDXVE4YY-Bvv_deZ2.js → chunk-EDXVE4YY--kyb1rLc.js} +1 -1
  114. package/payload/server/public/assets/{chunk-ENJZ2VHE-CmxCPKyU.js → chunk-ENJZ2VHE-FBN6hR7V.js} +1 -1
  115. package/payload/server/public/assets/{chunk-FMBD7UC4-Top6y08S.js → chunk-FMBD7UC4-Lpge4M5g.js} +1 -1
  116. package/payload/server/public/assets/{chunk-FOC6F5B3-B9LPNCUS.js → chunk-FOC6F5B3-CM1rwOou.js} +1 -1
  117. package/payload/server/public/assets/{chunk-ICPOFSXX-Bi-_c351.js → chunk-ICPOFSXX-JdYLY3yu.js} +2 -2
  118. package/payload/server/public/assets/{chunk-K5T4RW27-DAnRM04h.js → chunk-K5T4RW27-DCUblGBr.js} +1 -1
  119. package/payload/server/public/assets/{chunk-KGLVRYIC-C0QYcbLe.js → chunk-KGLVRYIC-BP_cide2.js} +1 -1
  120. package/payload/server/public/assets/{chunk-LIHQZDEY-BjvA5ykt.js → chunk-LIHQZDEY-DjMa1lOl.js} +1 -1
  121. package/payload/server/public/assets/{chunk-ORNJ4GCN-Cs46Gj7z.js → chunk-ORNJ4GCN-D51eJAoV.js} +1 -1
  122. package/payload/server/public/assets/{chunk-OYMX7WX6-BBpimab5.js → chunk-OYMX7WX6-4hWIO544.js} +1 -1
  123. package/payload/server/public/assets/chunk-QZHKN3VN-B_Hris8x.js +1 -0
  124. package/payload/server/public/assets/{chunk-U2HBQHQK-CQUSBHoK.js → chunk-U2HBQHQK-BwdQf9cS.js} +1 -1
  125. package/payload/server/public/assets/{chunk-X2U36JSP-Dm8TevG0.js → chunk-X2U36JSP-DmDxwLOg.js} +1 -1
  126. package/payload/server/public/assets/{chunk-XPW4576I-B10QuxYb.js → chunk-XPW4576I-JQyAB7ND.js} +1 -1
  127. package/payload/server/public/assets/{chunk-YZCP3GAM-vaGsu4Wk.js → chunk-YZCP3GAM-BonLdCzI.js} +1 -1
  128. package/payload/server/public/assets/{chunk-ZZ45TVLE-D1Qi22bL.js → chunk-ZZ45TVLE-BXKB2wSk.js} +1 -1
  129. package/payload/server/public/assets/classDiagram-6PBFFD2Q-D8Q05iMI.js +1 -0
  130. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-Dm3hAxfm.js +1 -0
  131. package/payload/server/public/assets/clone-C-wOwvpY.js +1 -0
  132. package/payload/server/public/assets/{cose-bilkent-S5V4N54A-CRDvLRXs.js → cose-bilkent-S5V4N54A-BDnd4Blw.js} +1 -1
  133. package/payload/server/public/assets/{dagre-CEmqJTt3.js → dagre-BmexzuER.js} +1 -1
  134. package/payload/server/public/assets/{dagre-KV5264BT-LXMTo7Fp.js → dagre-KV5264BT-D4WVuwPj.js} +1 -1
  135. package/payload/server/public/assets/data-DUDJfnjI.js +1 -0
  136. package/payload/server/public/assets/{diagram-5BDNPKRD-zyis6nPt.js → diagram-5BDNPKRD-Bbf4mT8a.js} +1 -1
  137. package/payload/server/public/assets/{diagram-G4DWMVQ6-BiU0XAAD.js → diagram-G4DWMVQ6-BpqVTUmx.js} +1 -1
  138. package/payload/server/public/assets/{diagram-MMDJMWI5-rGFiz1SX.js → diagram-MMDJMWI5-DY-k5k6r.js} +1 -1
  139. package/payload/server/public/assets/{diagram-TYMM5635-B0wak1ZJ.js → diagram-TYMM5635-CXHxwacS.js} +1 -1
  140. package/payload/server/public/assets/{erDiagram-SMLLAGMA-D8AUxLyX.js → erDiagram-SMLLAGMA-BNKEc3l8.js} +1 -1
  141. package/payload/server/public/assets/{flatten-naJ5PJ4f.js → flatten-jn8bZgcI.js} +1 -1
  142. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-BtdUf68b.js → flowDiagram-DWJPFMVM-CbAeuJsi.js} +1 -1
  143. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-C-lkzDmf.js → ganttDiagram-T4ZO3ILL-CeeNnL4l.js} +1 -1
  144. package/payload/server/public/assets/gitGraph-7Q5UKJZL-BBHsY16R.js +1 -0
  145. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-DRUKumq2.js → gitGraphDiagram-UUTBAWPF-DG8RCtfg.js} +1 -1
  146. package/payload/server/public/assets/{graph-DRloNic4.js → graph-BAoktBcx.js} +19 -19
  147. package/payload/server/public/assets/graph-labels-LeIhl7ZG.js +1 -0
  148. package/payload/server/public/assets/{graphlib-zRsaoFEC.js → graphlib-BkDExPQi.js} +1 -1
  149. package/payload/server/public/assets/info-OMHHGYJF-C1j5I_zx.js +1 -0
  150. package/payload/server/public/assets/infoDiagram-42DDH7IO-ZWkwHWEu.js +2 -0
  151. package/payload/server/public/assets/{isEmpty-Bnpg7b8f.js → isEmpty-Dkk6_L-B.js} +1 -1
  152. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-BguMgoR8.js → ishikawaDiagram-UXIWVN3A-D_iMHV64.js} +1 -1
  153. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-CXhyK1n-.js → journeyDiagram-VCZTEJTY-BvPRpulx.js} +1 -1
  154. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-C0UCWVx-.js → kanban-definition-6JOO6SKY-CkWaXWo2.js} +1 -1
  155. package/payload/server/public/assets/{line--uhS9YDE.js → line-BGQT9unx.js} +1 -1
  156. package/payload/server/public/assets/{linear-CAOWPodQ.js → linear-DzrYI8-8.js} +1 -1
  157. package/payload/server/public/assets/{maximize-2-CzDaBugs.js → maximize-2-2MgPSwlx.js} +1 -1
  158. package/payload/server/public/assets/{mermaid-parser.core-D6kPx7vc.js → mermaid-parser.core-DrMY7FMe.js} +2 -2
  159. package/payload/server/public/assets/{mermaid.core-CTd4DS6O.js → mermaid.core-BWdf6fZ7.js} +3 -3
  160. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-_AyvyG8C.js → mindmap-definition-QFDTVHPH-CgqM9h97.js} +1 -1
  161. package/payload/server/public/assets/operator-CZifmNhp.js +1 -0
  162. package/payload/server/public/assets/{ordinal-Wx4m9zHk.js → ordinal-DuJYM7p1.js} +1 -1
  163. package/payload/server/public/assets/packet-4T2RLAQJ-CGiTbe0N.js +1 -0
  164. package/payload/server/public/assets/{page-ef-6rRPL.js → page-B4kllGd_.js} +6 -6
  165. package/payload/server/public/assets/page-DbfE7QwJ.js +1 -0
  166. package/payload/server/public/assets/{pdf-render-CeX6d2v7.js → pdf-render-riHufgrn.js} +1 -1
  167. package/payload/server/public/assets/pie-ZZUOXDRM-BrK023dE.js +1 -0
  168. package/payload/server/public/assets/{pieDiagram-DEJITSTG-6TsUDkkQ.js → pieDiagram-DEJITSTG-BeUGoYS7.js} +1 -1
  169. package/payload/server/public/assets/public-VtmyYz8E.js +1 -0
  170. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-CWw9gOI4.js → quadrantDiagram-34T5L4WZ-CX82WgKy.js} +1 -1
  171. package/payload/server/public/assets/radar-PYXPWWZC-p7NRamXX.js +1 -0
  172. package/payload/server/public/assets/{reduce-BUmc8RDR.js → reduce-COyotL2V.js} +1 -1
  173. package/payload/server/public/assets/{requirementDiagram-MS252O5E-DCOlxNvx.js → requirementDiagram-MS252O5E-CzqebQXj.js} +1 -1
  174. package/payload/server/public/assets/{rotate-ccw-DXEKUhXP.js → rotate-ccw-DaZdB2cz.js} +1 -1
  175. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-CW4Xbir3.js → sankeyDiagram-XADWPNL6-CaHi1yzU.js} +1 -1
  176. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-vhb_pBLG.js → sequenceDiagram-FGHM5R23-BF2AeiKW.js} +1 -1
  177. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-Cb1WExEX.js → stateDiagram-FHFEXIEX-DtUCA8Ih.js} +1 -1
  178. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-a4FvB3L8.js +1 -0
  179. package/payload/server/public/assets/tasks-CvqVDbfO.js +1 -0
  180. package/payload/server/public/assets/{time-entry-format-CsYAjplS.js → time-entry-format-TD-PPEIu.js} +1 -1
  181. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-BAHTf_AF.js → timeline-definition-GMOUNBTQ-D3Q1YrKY.js} +1 -1
  182. package/payload/server/public/assets/treeView-SZITEDCU-CoWk8VoG.js +1 -0
  183. package/payload/server/public/assets/treemap-W4RFUUIX-BhWeGj8Y.js +1 -0
  184. package/payload/server/public/assets/{triangle-alert-0lSBIUg-.js → triangle-alert-BaLhVNnn.js} +1 -1
  185. package/payload/server/public/assets/useCopyFeedback-BhjPzRa3.js +1 -0
  186. package/payload/server/public/assets/useSubAccountSwitcher-CWRpVt1-.css +1 -0
  187. package/payload/server/public/assets/{useSubAccountSwitcher-BZnzc5C3.js → useSubAccountSwitcher-HEVLb6Z9.js} +2 -2
  188. package/payload/server/public/assets/useVoiceRecorder-CfGgj3vm.js +2 -0
  189. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-DtEttvW4.js → vennDiagram-DHZGUBPP-DnjzQDGf.js} +1 -1
  190. package/payload/server/public/assets/wardley-RL74JXVD-C0q3Hqng.js +1 -0
  191. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-K06y6CVJ.js → wardleyDiagram-NUSXRM2D-T3WHrUvi.js} +1 -1
  192. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-D66c40lp.js → xychartDiagram-5P7HB3ND-2Efyv4h2.js} +1 -1
  193. package/payload/server/public/brand-defaults.css +1 -0
  194. package/payload/server/public/browser.html +4 -5
  195. package/payload/server/public/calendar.html +5 -6
  196. package/payload/server/public/chat.html +12 -13
  197. package/payload/server/public/data.html +11 -12
  198. package/payload/server/public/graph.html +10 -11
  199. package/payload/server/public/index.html +14 -15
  200. package/payload/server/public/operator.html +14 -15
  201. package/payload/server/public/privacy.html +1 -0
  202. package/payload/server/public/public.html +12 -13
  203. package/payload/server/public/tasks.html +7 -5
  204. package/payload/server/public/vnc-popout.html +1 -0
  205. package/payload/server/server.js +833 -389
  206. package/payload/server/{src-XQ63FPRE.js → src-JJ6PHO5I.js} +1 -1
  207. package/payload/server/public/assets/AdminShell-D9zaUxPd.js +0 -2
  208. package/payload/server/public/assets/Checkbox-DcbbAIe5.js +0 -1
  209. package/payload/server/public/assets/activity-C9P5NjEZ.js +0 -1
  210. package/payload/server/public/assets/admin-D2KLZnQW.js +0 -1
  211. package/payload/server/public/assets/architecture-YZFGNWBL-CZQzGRSM.js +0 -1
  212. package/payload/server/public/assets/browser-CDWqEiCD.js +0 -1
  213. package/payload/server/public/assets/calendar-BG7fOpbB.js +0 -1
  214. package/payload/server/public/assets/channel-zDUAN9Ks.js +0 -1
  215. package/payload/server/public/assets/chat-RyH-WYQC.js +0 -1
  216. package/payload/server/public/assets/chevron-left-DmXCr6nB.js +0 -1
  217. package/payload/server/public/assets/chunk-426QAEUC-iJnqNRug.js +0 -1
  218. package/payload/server/public/assets/chunk-QZHKN3VN-Qlc2PFAj.js +0 -1
  219. package/payload/server/public/assets/classDiagram-6PBFFD2Q-CFsKL52p.js +0 -1
  220. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-BWBUUCiO.js +0 -1
  221. package/payload/server/public/assets/clone-h5XhkDUL.js +0 -1
  222. package/payload/server/public/assets/data-BaY_LkLR.js +0 -1
  223. package/payload/server/public/assets/gitGraph-7Q5UKJZL-CBZPQJLj.js +0 -1
  224. package/payload/server/public/assets/graph-labels-DD-GqS4v.js +0 -1
  225. package/payload/server/public/assets/info-OMHHGYJF-1vIoX-kP.js +0 -1
  226. package/payload/server/public/assets/infoDiagram-42DDH7IO-KHcXRaan.js +0 -2
  227. package/payload/server/public/assets/operator-BZeeyait.js +0 -1
  228. package/payload/server/public/assets/packet-4T2RLAQJ-CTU0gCGb.js +0 -1
  229. package/payload/server/public/assets/page-B0obcg3b.js +0 -1
  230. package/payload/server/public/assets/pie-ZZUOXDRM-D-EEHxhC.js +0 -1
  231. package/payload/server/public/assets/public-9D3MMOkC.js +0 -1
  232. package/payload/server/public/assets/radar-PYXPWWZC-DHXATZGB.js +0 -1
  233. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CxOIKvXh.js +0 -1
  234. package/payload/server/public/assets/tasks-BB0L7e5a.js +0 -1
  235. package/payload/server/public/assets/treeView-SZITEDCU-CC7ouMFd.js +0 -1
  236. package/payload/server/public/assets/treemap-W4RFUUIX-CaCg27im.js +0 -1
  237. package/payload/server/public/assets/useCopyFeedback-jTBQ2h0V.js +0 -1
  238. package/payload/server/public/assets/useSelectionMode-BooArySZ.js +0 -1
  239. package/payload/server/public/assets/useSubAccountSwitcher-D9knHOKq.css +0 -1
  240. package/payload/server/public/assets/useVoiceRecorder-PUT8q_sm.js +0 -2
  241. package/payload/server/public/assets/wardley-RL74JXVD-bMPsqQZ7.js +0 -1
  242. /package/payload/server/public/assets/{_baseFor-k8hYPe-U.js → _baseFor-Bjj9It3Q.js} +0 -0
  243. /package/payload/server/public/assets/{array-BFDiaBgf.js → array-BrrxEo12.js} +0 -0
  244. /package/payload/server/public/assets/{cytoscape.esm-Ch0xptA4.js → cytoscape.esm-CTWJxUxo.js} +0 -0
  245. /package/payload/server/public/assets/{defaultLocale-ZeknFqNe.js → defaultLocale-CFDbRV4k.js} +0 -0
  246. /package/payload/server/public/assets/{dist-B2N0v8hZ.js → dist-BATfy7Sn.js} +0 -0
  247. /package/payload/server/public/assets/{init-0VJVrkRJ.js → init-dXCzGgLP.js} +0 -0
  248. /package/payload/server/public/assets/{katex-Br7bH10N.js → katex-CHA1JFYJ.js} +0 -0
  249. /package/payload/server/public/assets/{path-INs8XTPH.js → path-CCRpdXhK.js} +0 -0
  250. /package/payload/server/public/assets/{preload-helper-D_sPnjeL.js → preload-helper-CH6UZRzu.js} +0 -0
  251. /package/payload/server/public/assets/{rough.esm-DU9bZLvZ.js → rough.esm-Bud4g4fh.js} +0 -0
  252. /package/payload/server/public/assets/{src-DHSQDYx-.js → src-DNdBRXXx.js} +0 -0
@@ -0,0 +1,1244 @@
1
+ # Task 1789 — Channel-row re-seat Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Re-seating a WhatsApp or Telegram admin row forks it as a channel session and repoints the sender at the fork, instead of forking it to webchat and destroying the channel seat.
6
+
7
+ **Architecture:** Two changes, both required. The re-seat route reads the source row's `.meta.json` sidecar and builds a channel-shaped fork payload when the source is an admin channel row. A new per-sender forward-pointer file in the account directory, written at that fork, is consulted at inbound resolution so the sender's deterministic id yields the fork instead. The pure spawn-request builders gain an optional override input; the filesystem read stays in `server/index.ts`.
8
+
9
+ **Tech Stack:** TypeScript, Hono, Vitest, Node `fs`.
10
+
11
+ ## Global Constraints
12
+
13
+ - Working directory for every command: `maxy-code/platform/ui`.
14
+ - Test runner: `npx vitest run <path>`. Full suite: `npm test`.
15
+ - Scope is **admin** channel rows only. A `role:'public'` sidecar takes today's webchat path unchanged.
16
+ - Channels in scope: `whatsapp` and `telegram`. The standing audit (Task 8) is WhatsApp-only.
17
+ - The webchat re-seat payload must stay **byte-for-byte identical** to today. Every task that touches `resolveReseatPlan` re-runs the existing webchat assertions.
18
+ - Override reads must never throw. A malformed or absent file reads as `null` and routing falls back to `adminSessionIdFor`.
19
+ - Every commit ends with the two trailers:
20
+ ```
21
+ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
22
+ Session: <id from `ls -t /Users/neo/.claude/projects/-Users-neo-getmaxy-maxy-code/*.jsonl | head -1`>
23
+ ```
24
+
25
+ ## File Structure
26
+
27
+ | File | Responsibility |
28
+ |---|---|
29
+ | `server/channel-session-override.ts` (new) | Read/write the per-sender forward pointer. Pure filesystem unit, no callers' knowledge. |
30
+ | `server/routes/webchat.ts` | `locateSidecar` widened to full `SidecarMeta`; new exported `readSourceRowMeta`. |
31
+ | `server/routes/admin/session-reseat.ts` | Sidecar read, channel-shaped payload, override write, lifeline fields. |
32
+ | `app/lib/whatsapp/gateway/spawn-request.ts` | Optional `overrideSessionId`. |
33
+ | `app/lib/telegram/gateway/spawn-request.ts` | Optional `overrideSessionId`. |
34
+ | `server/index.ts` | Resolve account dir, read override at both spawn sites, inject gateway dep, wire unseated audit. |
35
+ | `app/lib/whatsapp/gateway/wa-gateway.ts` | Detach-time map; `resolveSessionOverride` dep for the `op=inbound` line. |
36
+ | `app/lib/whatsapp/gateway/routes.ts` | `onAttached`/`onDetached` hooks. |
37
+ | `app/lib/telegram/gateway/telegram-gateway.ts` | `resolveSessionOverride` dep for the `op=inbound` line. |
38
+ | `app/lib/whatsapp/gateway/duplicate-sender-audit.ts` | `op=sender-unseated` emitter. |
39
+ | `app/components/SessionReseatControl.tsx` | Comment correction. |
40
+ | `../../.docs/admin-webchat-native-channel.md` | Document the override and the unbound window. |
41
+
42
+ ---
43
+
44
+ ### Task 1: The override file
45
+
46
+ **Files:**
47
+ - Create: `server/channel-session-override.ts`
48
+ - Test: `server/__tests__/channel-session-override.test.ts`
49
+
50
+ **Interfaces:**
51
+ - Consumes: nothing.
52
+ - Produces:
53
+ - `channelOverridePath(accountDir: string): string`
54
+ - `readChannelOverrideId(accountDir: string, channel: string, senderId: string): string | null`
55
+ - `writeChannelOverrideId(accountDir: string, channel: string, senderId: string, sessionId: string): void`
56
+
57
+ - [ ] **Step 1: Write the failing test**
58
+
59
+ Create `server/__tests__/channel-session-override.test.ts`:
60
+
61
+ ```ts
62
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
63
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
64
+ import { tmpdir } from 'node:os'
65
+ import { join } from 'node:path'
66
+ import { readChannelOverrideId, writeChannelOverrideId, channelOverridePath } from '../channel-session-override'
67
+
68
+ const FORK = 'fc397054-1111-4111-8111-111111111111'
69
+ const OTHER = 'aa11bb22-2222-4222-8222-222222222222'
70
+
71
+ let dir: string
72
+ beforeEach(() => {
73
+ dir = mkdtempSync(join(tmpdir(), 'chan-override-'))
74
+ })
75
+ afterEach(() => {
76
+ rmSync(dir, { recursive: true, force: true })
77
+ })
78
+
79
+ describe('channel-session-override', () => {
80
+ it('round-trips a fork id for one sender', () => {
81
+ writeChannelOverrideId(dir, 'whatsapp', '447504472444', FORK)
82
+ expect(readChannelOverrideId(dir, 'whatsapp', '447504472444')).toBe(FORK)
83
+ })
84
+
85
+ it('returns null for an unwritten sender', () => {
86
+ writeChannelOverrideId(dir, 'whatsapp', '447504472444', FORK)
87
+ expect(readChannelOverrideId(dir, 'whatsapp', '447958391734')).toBeNull()
88
+ })
89
+
90
+ it('returns null when no file exists at all', () => {
91
+ expect(readChannelOverrideId(dir, 'whatsapp', '447504472444')).toBeNull()
92
+ })
93
+
94
+ it('partitions by channel', () => {
95
+ writeChannelOverrideId(dir, 'whatsapp', '123', FORK)
96
+ expect(readChannelOverrideId(dir, 'telegram', '123')).toBeNull()
97
+ })
98
+
99
+ it('preserves other senders on a second write', () => {
100
+ writeChannelOverrideId(dir, 'whatsapp', '111', FORK)
101
+ writeChannelOverrideId(dir, 'telegram', '222', OTHER)
102
+ expect(readChannelOverrideId(dir, 'whatsapp', '111')).toBe(FORK)
103
+ expect(readChannelOverrideId(dir, 'telegram', '222')).toBe(OTHER)
104
+ })
105
+
106
+ it('overwrites the same sender on re-seat of a fork', () => {
107
+ writeChannelOverrideId(dir, 'whatsapp', '111', FORK)
108
+ writeChannelOverrideId(dir, 'whatsapp', '111', OTHER)
109
+ expect(readChannelOverrideId(dir, 'whatsapp', '111')).toBe(OTHER)
110
+ })
111
+
112
+ it('reads null rather than throwing on a malformed file', () => {
113
+ writeFileSync(channelOverridePath(dir), '{ not json', 'utf8')
114
+ expect(() => readChannelOverrideId(dir, 'whatsapp', '111')).not.toThrow()
115
+ expect(readChannelOverrideId(dir, 'whatsapp', '111')).toBeNull()
116
+ })
117
+
118
+ it('reads null when the stored value is not a UUID', () => {
119
+ writeFileSync(channelOverridePath(dir), JSON.stringify({ bySender: { 'whatsapp:111': 'nope' } }), 'utf8')
120
+ expect(readChannelOverrideId(dir, 'whatsapp', '111')).toBeNull()
121
+ })
122
+ })
123
+ ```
124
+
125
+ - [ ] **Step 2: Run test to verify it fails**
126
+
127
+ Run: `npx vitest run server/__tests__/channel-session-override.test.ts`
128
+ Expected: FAIL — cannot resolve `../channel-session-override`.
129
+
130
+ - [ ] **Step 3: Write minimal implementation**
131
+
132
+ Create `server/channel-session-override.ts`:
133
+
134
+ ```ts
135
+ // Task 1789 — per-sender channel forward pointer.
136
+ //
137
+ // A channel session's id is adminSessionIdFor(accountId, senderId): sha256
138
+ // shaped into a v4 UUID, a pure function. When an operator re-seats a channel
139
+ // row, the fork gets a fresh id that hash will never return, so every future
140
+ // inbound from that sender would resolve back to the stranded original. This
141
+ // per-account file points the sender forward at the fork, keyed by
142
+ // `<channel>:<senderId>`.
143
+ //
144
+ // This is NOT Task 1526's per-firing seat, which Task 1746 removed. That seat
145
+ // was salted per scheduled dispatch and minted a NEW session every firing,
146
+ // which then attached under the sender's own hub key and swallowed their real
147
+ // messages. This pointer is stable: one session, repointed once by an operator
148
+ // action, resolving identically for every subsequent inbound. It can never
149
+ // yield a second id for a sender, so it cannot split a conversation.
150
+
151
+ import { readFileSync, writeFileSync, renameSync } from 'node:fs'
152
+ import { join } from 'node:path'
153
+
154
+ const FILE = 'channel-session-override.json'
155
+ const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
156
+
157
+ export function channelOverridePath(accountDir: string): string {
158
+ return join(accountDir, FILE)
159
+ }
160
+
161
+ function senderKey(channel: string, senderId: string): string {
162
+ return `${channel}:${senderId}`
163
+ }
164
+
165
+ type OverrideFile = { bySender?: Record<string, unknown> }
166
+
167
+ function readRaw(accountDir: string): OverrideFile | null {
168
+ try {
169
+ return JSON.parse(readFileSync(channelOverridePath(accountDir), 'utf8')) as OverrideFile
170
+ } catch {
171
+ return null
172
+ }
173
+ }
174
+
175
+ /** The re-seated session id for this sender, or null when unset, malformed, or
176
+ * not UUID-shaped. Never throws: a corrupt file must degrade to the
177
+ * deterministic id, never wedge inbound routing. */
178
+ export function readChannelOverrideId(accountDir: string, channel: string, senderId: string): string | null {
179
+ const raw = readRaw(accountDir)
180
+ if (!raw || !raw.bySender || typeof raw.bySender !== 'object') return null
181
+ const v = raw.bySender[senderKey(channel, senderId)]
182
+ return typeof v === 'string' && UUID.test(v) ? v : null
183
+ }
184
+
185
+ /** Point this sender forward at a re-seated session. Merges into `bySender`,
186
+ * preserving every other sender's entry. Atomic tmp+rename so a concurrent
187
+ * read never sees a half-written file. */
188
+ export function writeChannelOverrideId(accountDir: string, channel: string, senderId: string, sessionId: string): void {
189
+ const raw = readRaw(accountDir)
190
+ const bySender: Record<string, string> = {}
191
+ if (raw?.bySender && typeof raw.bySender === 'object') {
192
+ for (const [k, v] of Object.entries(raw.bySender)) {
193
+ if (typeof v === 'string') bySender[k] = v
194
+ }
195
+ }
196
+ bySender[senderKey(channel, senderId)] = sessionId
197
+ const path = channelOverridePath(accountDir)
198
+ const tmp = `${path}.${process.pid}.tmp`
199
+ writeFileSync(tmp, JSON.stringify({ bySender }, null, 2), 'utf8')
200
+ renameSync(tmp, path)
201
+ }
202
+ ```
203
+
204
+ - [ ] **Step 4: Run test to verify it passes**
205
+
206
+ Run: `npx vitest run server/__tests__/channel-session-override.test.ts`
207
+ Expected: PASS, 8 tests.
208
+
209
+ - [ ] **Step 5: Commit**
210
+
211
+ ```bash
212
+ git add platform/ui/server/channel-session-override.ts platform/ui/server/__tests__/channel-session-override.test.ts
213
+ git commit -m "feat(1789): per-sender channel session forward pointer"
214
+ ```
215
+
216
+ ---
217
+
218
+ ### Task 2: Expose the source row's full sidecar
219
+
220
+ `locateSidecar` in `server/routes/webchat.ts` scans project slugs for a session's `.meta.json` but returns only three fields. The re-seat route needs `role` and `senderId` too. Widen it to return the full `SidecarMeta` and add an exported resolver that mirrors `readTargetOwner`'s two-path lookup (sidecar scan, then the meta adjacent to a located JSONL).
221
+
222
+ **Files:**
223
+ - Modify: `server/routes/webchat.ts:78-115`
224
+
225
+ **Interfaces:**
226
+ - Consumes: `readSidecarMeta`, `SidecarMeta` from `server/routes/admin/sidebar-sessions.ts` (already imported by this file).
227
+ - Produces: `readSourceRowMeta(sessionId: string): SidecarMeta | null` — the row's sidecar, or null when the session is unknown.
228
+
229
+ - [ ] **Step 1: Widen `locateSidecar` to the full sidecar**
230
+
231
+ Replace the signature and return at `server/routes/webchat.ts:78` and its two return sites:
232
+
233
+ ```ts
234
+ function locateSidecar(sessionId: string): SidecarMeta | null {
235
+ const cfg = claudeConfigDir()
236
+ if (!cfg) return null
237
+ const projectsRoot = join(cfg, 'projects')
238
+ let slugs: string[]
239
+ try {
240
+ slugs = readdirSync(projectsRoot, { withFileTypes: true })
241
+ .filter((d) => d.isDirectory())
242
+ .map((d) => d.name)
243
+ } catch {
244
+ return null
245
+ }
246
+ for (const slug of slugs) {
247
+ const metaPath = join(projectsRoot, slug, `${sessionId}.meta.json`)
248
+ // Task 1789 — the whole sidecar, not a hand-picked triple: the re-seat
249
+ // route needs role/senderId to decide whether the fork is channel-shaped.
250
+ if (existsSync(metaPath)) return readSidecarMeta(metaPath)
251
+ }
252
+ return null
253
+ }
254
+ ```
255
+
256
+ Ensure `SidecarMeta` is in the existing type import from `./admin/sidebar-sessions`. The two existing call sites (`:108` in `readTargetOwner`, `:657`) already read `.adminUserId` / `.channel` / `.accountId`, which the wider type still carries — no changes needed there.
257
+
258
+ - [ ] **Step 2: Add the exported resolver**
259
+
260
+ Insert immediately after `readTargetOwner` (around `server/routes/webchat.ts:115`):
261
+
262
+ ```ts
263
+ /** Task 1789 — the source row's sidecar for the re-seat route, resolved by the
264
+ * same two paths as {@link readTargetOwner}: the slug-top-level scan for a
265
+ * JSONL-less fresh target, then the meta adjacent to a located JSONL (incl.
266
+ * archived). null when the session is unknown. */
267
+ export function readSourceRowMeta(sessionId: string): SidecarMeta | null {
268
+ const sidecar = locateSidecar(sessionId)
269
+ if (sidecar !== null) return sidecar
270
+ const { projectDir } = locateSession(sessionId)
271
+ if (projectDir !== null) return readSidecarMeta(join(projectDir, `${sessionId}.meta.json`))
272
+ return null
273
+ }
274
+ ```
275
+
276
+ - [ ] **Step 3: Run the existing webchat suite to prove no regression**
277
+
278
+ Run: `npx vitest run server/routes/__tests__/webchat.test.ts`
279
+ Expected: PASS, no new failures versus the pre-change baseline.
280
+
281
+ - [ ] **Step 4: Typecheck**
282
+
283
+ Run: `npx tsc --noEmit -p tsconfig.json`
284
+ Expected: no errors.
285
+
286
+ - [ ] **Step 5: Commit**
287
+
288
+ ```bash
289
+ git add platform/ui/server/routes/webchat.ts
290
+ git commit -m "refactor(1789): widen locateSidecar to full SidecarMeta, export readSourceRowMeta"
291
+ ```
292
+
293
+ ---
294
+
295
+ ### Task 3: `resolveReseatPlan` builds a channel-shaped payload
296
+
297
+ **Files:**
298
+ - Modify: `server/routes/admin/session-reseat.ts:45-81`
299
+ - Test: `server/routes/admin/__tests__/session-reseat.test.ts`
300
+
301
+ **Interfaces:**
302
+ - Consumes: `readSourceRowMeta` (Task 2) — used by the route in Task 4, not here.
303
+ - Produces: `resolveReseatPlan(body, mintId?, adminUserId?, authenticatedName?, source?)` where the new fifth positional parameter is:
304
+ ```ts
305
+ source?: { channel: string | null; senderId: string | null; role: string | null; accountId: string | null } | null
306
+ ```
307
+
308
+ - [ ] **Step 1: Write the failing tests**
309
+
310
+ Append to `server/routes/admin/__tests__/session-reseat.test.ts`:
311
+
312
+ ```ts
313
+ const WA_SOURCE = { channel: 'whatsapp', senderId: '447504472444', role: 'admin', accountId: '5e99bba5-1111-4111-8111-111111111111' }
314
+
315
+ describe('resolveReseatPlan — channel rows (Task 1789)', () => {
316
+ it('inherits a whatsapp admin source channel and sender', () => {
317
+ const plan = resolveReseatPlan({ fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined, WA_SOURCE)
318
+ expect(plan.payload).toMatchObject({
319
+ sessionId: 'NEW',
320
+ forkFromSessionId: SRC,
321
+ channel: 'whatsapp',
322
+ senderId: '447504472444',
323
+ role: 'admin',
324
+ targetAccountId: WA_SOURCE.accountId,
325
+ })
326
+ expect(plan.payload.webchatChannel).toBeUndefined()
327
+ })
328
+
329
+ it('inherits a telegram admin source channel and sender', () => {
330
+ const plan = resolveReseatPlan(
331
+ { fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined,
332
+ { ...WA_SOURCE, channel: 'telegram', senderId: '99887766' },
333
+ )
334
+ expect(plan.payload).toMatchObject({ channel: 'telegram', senderId: '99887766', role: 'admin' })
335
+ expect(plan.payload.webchatChannel).toBeUndefined()
336
+ })
337
+
338
+ it('omits targetAccountId when the sidecar predates accountId', () => {
339
+ const plan = resolveReseatPlan(
340
+ { fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined,
341
+ { ...WA_SOURCE, accountId: null },
342
+ )
343
+ expect(plan.payload.channel).toBe('whatsapp')
344
+ expect('targetAccountId' in plan.payload).toBe(false)
345
+ })
346
+
347
+ it('takes the webchat path for a public whatsapp source', () => {
348
+ const plan = resolveReseatPlan(
349
+ { fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined,
350
+ { ...WA_SOURCE, role: 'public' },
351
+ )
352
+ expect(plan.payload.channel).toBe('webchat')
353
+ expect(plan.payload.senderId).toBe('session:NEW')
354
+ })
355
+
356
+ it('takes the webchat path when the source sidecar has no senderId', () => {
357
+ const plan = resolveReseatPlan(
358
+ { fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined,
359
+ { ...WA_SOURCE, senderId: null },
360
+ )
361
+ expect(plan.payload.channel).toBe('webchat')
362
+ })
363
+
364
+ it('takes the webchat path for a webchat source and for no source at all', () => {
365
+ const fromWebchat = resolveReseatPlan(
366
+ { fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined,
367
+ { channel: 'webchat', senderId: 'session:old', role: 'admin', accountId: null },
368
+ )
369
+ const fromNothing = resolveReseatPlan({ fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1')
370
+ expect(fromWebchat.payload).toEqual(fromNothing.payload)
371
+ })
372
+ })
373
+ ```
374
+
375
+ - [ ] **Step 2: Run tests to verify they fail**
376
+
377
+ Run: `npx vitest run server/routes/admin/__tests__/session-reseat.test.ts`
378
+ Expected: FAIL — the channel assertions see `channel: 'webchat'`.
379
+
380
+ - [ ] **Step 3: Rewrite the header comment and the function**
381
+
382
+ Replace `server/routes/admin/session-reseat.ts:45-81` with:
383
+
384
+ ```ts
385
+ /** The channel/sender shape a fork inherits from its source row. */
386
+ export interface ReseatSource {
387
+ channel: string | null
388
+ senderId: string | null
389
+ role: string | null
390
+ accountId: string | null
391
+ }
392
+
393
+ /** Task 1789 — is this source an admin channel row? All three must hold: the
394
+ * sidecar says role admin, its channel is a native channel, and it names a
395
+ * sender. Public rows are excluded deliberately: their session id keys on
396
+ * personId as well as senderId, so the per-sender forward pointer would be
397
+ * ambiguous for them, and public re-seat is not evidenced as broken. */
398
+ function isAdminChannelSource(source: ReseatSource | null | undefined): source is ReseatSource & { channel: 'whatsapp' | 'telegram'; senderId: string } {
399
+ return (
400
+ !!source &&
401
+ source.role === 'admin' &&
402
+ (source.channel === 'whatsapp' || source.channel === 'telegram') &&
403
+ typeof source.senderId === 'string' &&
404
+ source.senderId.length > 0
405
+ )
406
+ }
407
+
408
+ /**
409
+ * Pure: build the manager /rc-spawn fork payload. `mintId` is injected for
410
+ * deterministic tests; production passes randomUUID. `forkFromSessionId` tells
411
+ * the manager to seed history from the source; `model` pins the fork.
412
+ *
413
+ * Task 1789 — the fork inherits the SOURCE ROW's channel. An admin whatsapp or
414
+ * telegram row forks as that channel, carrying the source's own senderId, so
415
+ * the fork is a channel session rather than a webchat one. Before this, every
416
+ * fork was webchat-shaped with senderId `session:<newId>`, which replaced the
417
+ * per-sender channel server and detached the sender's seat. Every other source
418
+ * — webchat, public, or a row with no sidecar — keeps the webchat shape, keyed
419
+ * on `session:<newId>`, lining up with /send and the reply tool.
420
+ *
421
+ * The channel fork carries NO waChannel/telegramChannel descriptor: this route
422
+ * has no gateway url or server path. The fork therefore has no reply tool until
423
+ * the next inbound rebinds it through ensureChannelSession. That window is
424
+ * logged by the caller (`op=channel-fork-unbound`), because the manager's own
425
+ * `op=channel-binding-lost` invariant is gated on its channel store and cannot
426
+ * fire for a freshly minted fork id.
427
+ */
428
+ export function resolveReseatPlan(
429
+ body: { fromSessionId: string; model: string; name?: string; permissionMode?: string; effort?: string },
430
+ mintId: () => string = randomUUID,
431
+ adminUserId?: string,
432
+ authenticatedName?: string,
433
+ source?: ReseatSource | null,
434
+ ): ReseatPlan {
435
+ const sessionId = mintId()
436
+ const key = `session:${sessionId}`
437
+ const payload: Record<string, unknown> = {
438
+ sessionId,
439
+ forkFromSessionId: body.fromSessionId,
440
+ model: body.model,
441
+ role: 'admin',
442
+ }
443
+ if (isAdminChannelSource(source)) {
444
+ payload.channel = source.channel
445
+ payload.senderId = source.senderId
446
+ // Task 1789 — the fork must run under the SOURCE's account. Without this
447
+ // the manager defaults it to the boot account, so a channel row belonging
448
+ // to a client sub-account would fork into the house account and every
449
+ // Neo4j write the fork makes would land under the wrong scope. Pre-983
450
+ // sidecars carry no accountId; those keep the manager default.
451
+ if (source.accountId) payload.targetAccountId = source.accountId
452
+ } else {
453
+ payload.channel = 'webchat'
454
+ payload.senderId = key
455
+ payload.webchatChannel = webchatChannelDescriptor(key)
456
+ }
457
+ if (body.name) payload.name = body.name
458
+ // Task 928 — carry the picked mode/effort only when supplied; an absent lever
459
+ // leaves the fork on the settings-resolved mode / harness-default effort.
460
+ if (body.permissionMode) payload.permissionMode = body.permissionMode
461
+ if (body.effort) payload.effort = body.effort
462
+ if (adminUserId) payload.adminUserId = adminUserId
463
+ // Task 889 — carry the resolved authenticated-admin name so the manager
464
+ // injects the identity line on the fork's first turn.
465
+ if (authenticatedName) payload.authenticatedName = authenticatedName
466
+ return { sessionId, payload }
467
+ }
468
+ ```
469
+
470
+ Also update the file's top-of-file header (`session-reseat.ts:1-17`): replace the sentence describing the route as webchat-only with:
471
+
472
+ ```ts
473
+ // Operator-initiated model re-seat for an existing admin session — webchat rows
474
+ // in the Sessions panel and whatsapp/telegram channel rows alike. Claude reads
475
+ ```
476
+
477
+ - [ ] **Step 4: Run tests to verify they pass**
478
+
479
+ Run: `npx vitest run server/routes/admin/__tests__/session-reseat.test.ts`
480
+ Expected: PASS — the new channel tests and every pre-existing webchat test.
481
+
482
+ - [ ] **Step 5: Commit**
483
+
484
+ ```bash
485
+ git add platform/ui/server/routes/admin/session-reseat.ts platform/ui/server/routes/admin/__tests__/session-reseat.test.ts
486
+ git commit -m "feat(1789): fork inherits the source row's channel"
487
+ ```
488
+
489
+ ---
490
+
491
+ ### Task 4: Route wiring — sidecar read, override write, lifeline
492
+
493
+ **Files:**
494
+ - Modify: `server/routes/admin/session-reseat.ts` (route handler, ~`:91-212`)
495
+
496
+ **Interfaces:**
497
+ - Consumes: `readSourceRowMeta` (Task 2), `readChannelOverrideId` / `writeChannelOverrideId` (Task 1), `listValidAccounts` from `app/lib/claude-agent/account`.
498
+ - Produces: nothing new for later tasks.
499
+
500
+ - [ ] **Step 1: Add the imports**
501
+
502
+ At the top of `server/routes/admin/session-reseat.ts`:
503
+
504
+ ```ts
505
+ import { WEBCHAT_ADMIN_KEY, resolvePrimaryAdminUserId, readTargetOwner, ownedByRequester, readSourceRowMeta } from '../webchat'
506
+ import { readCanonicalOverrideId, writeCanonicalOverrideId } from '../../canonical-webchat-override'
507
+ import { writeChannelOverrideId } from '../../channel-session-override'
508
+ import { listValidAccounts } from '../../../app/lib/claude-agent/account'
509
+ ```
510
+
511
+ - [ ] **Step 2: Read the source sidecar and pass it to the plan**
512
+
513
+ Replace the `const plan = resolveReseatPlan(...)` call (currently `:141-146`) with:
514
+
515
+ ```ts
516
+ // Task 1789 — the fork inherits the source row's channel, so read the source
517
+ // sidecar before building the payload. A null sidecar (unknown or husk row)
518
+ // yields today's webchat shape.
519
+ const sourceMeta = readSourceRowMeta(fromSessionId)
520
+ const source = sourceMeta
521
+ ? { channel: sourceMeta.channel, senderId: sourceMeta.senderId, role: sourceMeta.role, accountId: sourceMeta.accountId }
522
+ : null
523
+ const plan = resolveReseatPlan(
524
+ { fromSessionId, model, ...(permissionMode ? { permissionMode } : {}), ...(effort ? { effort } : {}) },
525
+ randomUUID,
526
+ adminUserId,
527
+ authenticatedName,
528
+ source,
529
+ )
530
+ const isChannelFork = plan.payload.channel === 'whatsapp' || plan.payload.channel === 'telegram'
531
+ const reseatReqId = randomUUID()
532
+ ```
533
+
534
+ - [ ] **Step 3: Name the source row on the lifeline**
535
+
536
+ Replace the `op=request` line (currently `:152`) with:
537
+
538
+ ```ts
539
+ // Task 1789 — the lifeline names WHAT KIND of row was re-seated. `outcome=bound`
540
+ // reported that the manager returned, not that the seat survived, so a re-seat
541
+ // that destroyed a channel seat logged as a success.
542
+ console.log(`[chat-reseat] op=request reseatReqId=${reseatReqId} from=${fromSessionId} from-channel=${source?.channel ?? 'none'} from-sender=${source?.senderId ?? 'none'} to-model=${model} to-mode=${permissionMode ?? 'none'} to-effort=${effort ?? 'none'} new=${plan.sessionId}`)
543
+ ```
544
+
545
+ - [ ] **Step 4: Write the forward pointer after a successful channel fork**
546
+
547
+ Insert immediately after the existing canonical-forward `try/catch` block (currently ends `:201`):
548
+
549
+ ```ts
550
+ // Task 1789 — point this sender forward at the fork, so the next inbound
551
+ // resolves to it instead of recomputing the stranded deterministic id.
552
+ // Written under the SOURCE's account dir, the same account the gateway reads
553
+ // under (its effectiveAccountId), so an account-manager-routed sub-account
554
+ // sender resolves its own pointer and never the house's. Best-effort: a
555
+ // failure here must not fail the re-seat, but it does leave the sender on the
556
+ // original session, so it is logged loudly.
557
+ if (isChannelFork) {
558
+ const channel = plan.payload.channel as 'whatsapp' | 'telegram'
559
+ const senderId = plan.payload.senderId as string
560
+ try {
561
+ const dir = source?.accountId
562
+ ? (listValidAccounts().find((a) => a.accountId === source.accountId)?.accountDir ?? null)
563
+ : (resolveAccount()?.accountDir ?? null)
564
+ if (dir) {
565
+ writeChannelOverrideId(dir, channel, senderId, plan.sessionId)
566
+ console.log(`[chat-reseat] op=channel-forward reseatReqId=${reseatReqId} senderId=${senderId} from=${fromSessionId} to=${plan.sessionId}`)
567
+ } else {
568
+ console.error(`[session-reseat] channel-forward-failed reason=no-account-dir accountId=${source?.accountId ?? 'none'} senderId=${senderId}`)
569
+ }
570
+ } catch (err) {
571
+ console.error(`[session-reseat] channel-forward-failed err=${err instanceof Error ? err.message : String(err)} senderId=${senderId}`)
572
+ }
573
+ // Task 1789 — the fork carries no channel descriptor (this route has no
574
+ // gateway url or server path), so it has no reply tool until the next
575
+ // inbound rebinds it via ensureChannelSession. The manager's own
576
+ // op=channel-binding-lost invariant is gated on its channel store, which is
577
+ // empty for a freshly minted fork id, so this window is otherwise silent.
578
+ console.log(`[chat-reseat] op=channel-fork-unbound reseatReqId=${reseatReqId} senderId=${senderId} session=${plan.sessionId} note=binds-on-next-inbound`)
579
+ }
580
+ ```
581
+
582
+ **On the chained re-seat.** The write above is unconditional for a channel fork, and that alone handles the chained case: re-seating a fork writes the pointer again at the same `<channel>:<senderId>` key, moving it forward. No `fromSessionId` comparison is needed here, unlike the canonical pointer above it — the canonical pointer is keyed by *admin*, so it must check whether the re-seated row is the one that admin's bare `/chat` resolves to. The channel key is derived from the source row's own sender, so a channel fork is by construction the row the pointer belongs to.
583
+
584
+ - [ ] **Step 5: Typecheck and run the suite**
585
+
586
+ Run: `npx tsc --noEmit -p tsconfig.json && npx vitest run server/routes/admin/__tests__/session-reseat.test.ts`
587
+ Expected: no type errors; tests PASS.
588
+
589
+ - [ ] **Step 6: Commit**
590
+
591
+ ```bash
592
+ git add platform/ui/server/routes/admin/session-reseat.ts
593
+ git commit -m "feat(1789): re-seat writes the channel forward pointer and names the source row"
594
+ ```
595
+
596
+ ---
597
+
598
+ ### Task 5: Spawn builders accept an override
599
+
600
+ **Files:**
601
+ - Modify: `app/lib/whatsapp/gateway/spawn-request.ts`
602
+ - Modify: `app/lib/telegram/gateway/spawn-request.ts`
603
+ - Test: `app/lib/whatsapp/gateway/__tests__/spawn-request.test.ts`
604
+ - Test: `app/lib/telegram/gateway/__tests__/spawn-request.test.ts`
605
+
606
+ **Interfaces:**
607
+ - Produces: both builders accept an optional `overrideSessionId?: string | null` on their existing input object. When a non-empty string, it becomes `sessionId`; otherwise `adminSessionIdFor(...)` as today.
608
+
609
+ - [ ] **Step 1: Write the failing tests**
610
+
611
+ Append to `app/lib/whatsapp/gateway/__tests__/spawn-request.test.ts`:
612
+
613
+ ```ts
614
+ const FORK = 'fc397054-1111-4111-8111-111111111111'
615
+
616
+ describe('buildWaSpawnRequest — session override (Task 1789)', () => {
617
+ it('uses the override when one is set for the sender', () => {
618
+ const req = buildWaSpawnRequest({
619
+ accountId: 'acct', senderId: '+447700900123', role: 'admin', personId: null,
620
+ gatewayUrl: 'http://g', serverPath: '/s', overrideSessionId: FORK,
621
+ })
622
+ expect(req.sessionId).toBe(FORK)
623
+ })
624
+
625
+ it('falls back to the deterministic id when the override is null or absent', () => {
626
+ const base = { accountId: 'acct', senderId: '+447700900123', role: 'admin' as const, personId: null, gatewayUrl: 'http://g', serverPath: '/s' }
627
+ expect(buildWaSpawnRequest({ ...base, overrideSessionId: null }).sessionId).toBe(adminSessionIdFor('acct', '+447700900123'))
628
+ expect(buildWaSpawnRequest(base).sessionId).toBe(adminSessionIdFor('acct', '+447700900123'))
629
+ })
630
+ })
631
+ ```
632
+
633
+ Append the mirror to `app/lib/telegram/gateway/__tests__/spawn-request.test.ts`:
634
+
635
+ ```ts
636
+ const FORK = 'fc397054-1111-4111-8111-111111111111'
637
+
638
+ describe('buildTelegramSpawnRequest — session override (Task 1789)', () => {
639
+ it('uses the override when one is set for the sender', () => {
640
+ const req = buildTelegramSpawnRequest({
641
+ accountId: 'acct', senderId: '123', role: 'admin', personId: null,
642
+ gatewayUrl: 'http://g', serverPath: '/s', overrideSessionId: FORK,
643
+ })
644
+ expect(req.sessionId).toBe(FORK)
645
+ })
646
+
647
+ it('falls back to the deterministic id when the override is null or absent', () => {
648
+ const base = { accountId: 'acct', senderId: '123', role: 'admin' as const, personId: null, gatewayUrl: 'http://g', serverPath: '/s' }
649
+ expect(buildTelegramSpawnRequest({ ...base, overrideSessionId: null }).sessionId).toBe(adminSessionIdFor('acct', '123', undefined))
650
+ expect(buildTelegramSpawnRequest(base).sessionId).toBe(adminSessionIdFor('acct', '123', undefined))
651
+ })
652
+ })
653
+ ```
654
+
655
+ - [ ] **Step 2: Run tests to verify they fail**
656
+
657
+ Run: `npx vitest run app/lib/whatsapp/gateway/__tests__/spawn-request.test.ts app/lib/telegram/gateway/__tests__/spawn-request.test.ts`
658
+ Expected: FAIL — TypeScript rejects `overrideSessionId`, or `sessionId` is the deterministic id.
659
+
660
+ - [ ] **Step 3: Add the parameter to the WhatsApp builder**
661
+
662
+ In `app/lib/whatsapp/gateway/spawn-request.ts`, add to the input type and replace the `sessionId` line and its comment:
663
+
664
+ ```ts
665
+ export function buildWaSpawnRequest(input: {
666
+ accountId: string
667
+ senderId: string
668
+ role: 'admin' | 'public'
669
+ personId: string | null
670
+ gatewayUrl: string
671
+ serverPath: string
672
+ /** Task 1789 — the sender's channel forward pointer, when the operator has
673
+ * re-seated this row. Read by the caller (server/index.ts) so this builder
674
+ * stays pure. */
675
+ overrideSessionId?: string | null
676
+ }): {
677
+ ```
678
+
679
+ ```ts
680
+ // Task 1746 — the deterministic per-sender id is the DEFAULT id a sender's
681
+ // session has. Task 1526's per-firing seat override used to replace it on a
682
+ // scheduled cold start, which spawned a second session per firing; that
683
+ // session then attached under the sender's own hub key and swallowed their
684
+ // real messages until it detached, splitting the conversation in two.
685
+ //
686
+ // Task 1789's override is not that. It is a stable per-sender forward
687
+ // pointer written once by an operator re-seat and resolving identically for
688
+ // every subsequent inbound, so it can never yield a second id for a sender.
689
+ // Do not remove it without reading server/channel-session-override.ts.
690
+ sessionId: input.overrideSessionId || adminSessionIdFor(input.accountId, input.senderId, personId),
691
+ ```
692
+
693
+ - [ ] **Step 4: Add the parameter to the Telegram builder**
694
+
695
+ In `app/lib/telegram/gateway/spawn-request.ts`, add the identical `overrideSessionId?: string | null` field with the same doc comment, and replace the `sessionId` line:
696
+
697
+ ```ts
698
+ // Task 1746 / 1789 — the deterministic per-sender id is the DEFAULT; an
699
+ // operator re-seat sets a stable per-sender forward pointer that wins here.
700
+ // Mirror of the WhatsApp builder; see its comment for the full distinction
701
+ // between this pointer and Task 1526's removed per-firing seat.
702
+ sessionId: input.overrideSessionId || adminSessionIdFor(input.accountId, input.senderId, personId),
703
+ ```
704
+
705
+ - [ ] **Step 5: Run tests to verify they pass**
706
+
707
+ Run: `npx vitest run app/lib/whatsapp/gateway/__tests__/spawn-request.test.ts app/lib/telegram/gateway/__tests__/spawn-request.test.ts`
708
+ Expected: PASS, including every pre-existing assertion.
709
+
710
+ - [ ] **Step 6: Commit**
711
+
712
+ ```bash
713
+ git add platform/ui/app/lib/whatsapp/gateway/spawn-request.ts platform/ui/app/lib/telegram/gateway/spawn-request.ts platform/ui/app/lib/whatsapp/gateway/__tests__/spawn-request.test.ts platform/ui/app/lib/telegram/gateway/__tests__/spawn-request.test.ts
714
+ git commit -m "feat(1789): spawn builders accept an optional session override"
715
+ ```
716
+
717
+ ---
718
+
719
+ ### Task 6: `server/index.ts` reads the override at both spawn sites
720
+
721
+ **Files:**
722
+ - Modify: `server/index.ts:418` (WhatsApp spawn), `:700` (Telegram spawn), plus imports near `:2843-2858`
723
+
724
+ **Interfaces:**
725
+ - Consumes: `readChannelOverrideId` (Task 1), builders' `overrideSessionId` (Task 5), `listValidAccounts`.
726
+ - Produces: `resolveChannelOverride(accountId: string, channel: 'whatsapp' | 'telegram', senderId: string): string | null` — a module-local helper, also passed to both gateways in Task 7.
727
+
728
+ - [ ] **Step 1: Add the imports**
729
+
730
+ Alongside the existing imports at the bottom import block of `server/index.ts`:
731
+
732
+ ```ts
733
+ import { readChannelOverrideId } from './channel-session-override.js'
734
+ import { listValidAccounts } from '../app/lib/claude-agent/account.js'
735
+ ```
736
+
737
+ (If `listValidAccounts` is already imported in this file, do not duplicate it — check with `grep -n "listValidAccounts" server/index.ts` first.)
738
+
739
+ - [ ] **Step 2: Add the resolver helper**
740
+
741
+ Place it above the WhatsApp gateway construction block (before `:418`):
742
+
743
+ ```ts
744
+ // Task 1789 — resolve a sender's channel forward pointer. Keyed on the EFFECTIVE
745
+ // account (the account the session runs under), matching the write side in
746
+ // session-reseat.ts, which keys on the source sidecar's accountId. An
747
+ // account-manager-routed sub-account sender therefore resolves its own pointer
748
+ // and never the house's. Best-effort: any failure yields null and the caller
749
+ // falls back to the deterministic id.
750
+ function resolveChannelOverride(accountId: string, channel: 'whatsapp' | 'telegram', senderId: string): string | null {
751
+ try {
752
+ const dir = listValidAccounts().find((a) => a.accountId === accountId)?.accountDir
753
+ return dir ? readChannelOverrideId(dir, channel, senderId) : null
754
+ } catch {
755
+ return null
756
+ }
757
+ }
758
+ ```
759
+
760
+ - [ ] **Step 3: Thread it into the WhatsApp spawn**
761
+
762
+ Replace `server/index.ts:418`:
763
+
764
+ ```ts
765
+ const req = buildWaSpawnRequest({
766
+ accountId: effectiveAccountId, senderId, role, personId, gatewayUrl, serverPath,
767
+ // Task 1789 — admin rows only: a public session's id keys on personId as
768
+ // well as senderId, so the per-sender pointer is not applicable to it.
769
+ overrideSessionId: role === 'admin' ? resolveChannelOverride(effectiveAccountId, 'whatsapp', senderId) : null,
770
+ })
771
+ ```
772
+
773
+ - [ ] **Step 4: Thread it into the Telegram spawn**
774
+
775
+ Replace `server/index.ts:700`:
776
+
777
+ ```ts
778
+ const req = buildTelegramSpawnRequest({
779
+ accountId, senderId, role, personId, gatewayUrl, serverPath,
780
+ // Task 1789 — admin rows only; see the WhatsApp twin.
781
+ overrideSessionId: role === 'admin' ? resolveChannelOverride(accountId, 'telegram', senderId) : null,
782
+ })
783
+ ```
784
+
785
+ - [ ] **Step 5: Typecheck**
786
+
787
+ Run: `npx tsc --noEmit -p tsconfig.json`
788
+ Expected: no errors.
789
+
790
+ - [ ] **Step 6: Commit**
791
+
792
+ ```bash
793
+ git add platform/ui/server/index.ts
794
+ git commit -m "feat(1789): resolve the channel forward pointer at both spawn sites"
795
+ ```
796
+
797
+ ---
798
+
799
+ ### Task 7: Gateway `op=inbound` lines name the real session
800
+
801
+ **Files:**
802
+ - Modify: `app/lib/whatsapp/gateway/wa-gateway.ts` (deps interface `:68`, `op=inbound` `:312-318`)
803
+ - Modify: `app/lib/telegram/gateway/telegram-gateway.ts` (deps interface, `op=inbound` `:181-188`)
804
+ - Modify: `server/index.ts` (both gateway constructions)
805
+ - Test: `app/lib/whatsapp/gateway/__tests__/` — add to the existing gateway test file
806
+
807
+ **Interfaces:**
808
+ - Produces: both gateway deps interfaces gain `resolveSessionOverride?: (accountId: string, senderId: string) => string | null`.
809
+
810
+ - [ ] **Step 1: Write the failing test**
811
+
812
+ Create `app/lib/whatsapp/gateway/__tests__/inbound-session-log.test.ts`:
813
+
814
+ ```ts
815
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
816
+ import { WaGateway } from '../wa-gateway'
817
+ import { adminSessionIdFor } from '../../../channel-pty-bridge/admin-session-id'
818
+
819
+ const FORK = 'fc397054-1111-4111-8111-111111111111'
820
+ const SENDER = '447504472444'
821
+
822
+ function makeGateway(resolveSessionOverride?: (accountId: string, senderId: string) => string | null) {
823
+ return new WaGateway({
824
+ gatewayUrl: 'http://x',
825
+ serverPath: '/x',
826
+ ensureChannelSession: vi.fn(async () => {}),
827
+ sendDocument: vi.fn(async () => ({ ok: true as const })),
828
+ ...(resolveSessionOverride ? { resolveSessionOverride } : {}),
829
+ })
830
+ }
831
+
832
+ async function deliver(gw: WaGateway) {
833
+ gw.hub.attach(SENDER, () => {})
834
+ gw.hub.markReady(SENDER, Date.now())
835
+ await gw.handleInbound({
836
+ accountId: 'house', effectiveAccountId: 'acct', senderId: SENDER, role: 'admin',
837
+ text: 'hi', reply: vi.fn(async () => {}), media: [],
838
+ })
839
+ }
840
+
841
+ // The gateway's op=inbound line must name the session the message actually
842
+ // reaches. Without the override consult it named the stranded deterministic id.
843
+ describe('wa-gateway op=inbound sessionId (Task 1789)', () => {
844
+ let errs: string[]
845
+ beforeEach(() => {
846
+ errs = []
847
+ vi.spyOn(console, 'error').mockImplementation((m: unknown) => { errs.push(String(m)) })
848
+ })
849
+ afterEach(() => { vi.restoreAllMocks() })
850
+
851
+ it('names the override when one is set', async () => {
852
+ await deliver(makeGateway(() => FORK))
853
+ expect(errs.find((l) => l.includes('op=inbound'))).toContain(`sessionId=${FORK}`)
854
+ })
855
+
856
+ it('names the deterministic id when the override resolves null', async () => {
857
+ await deliver(makeGateway(() => null))
858
+ expect(errs.find((l) => l.includes('op=inbound'))).toContain(`sessionId=${adminSessionIdFor('acct', SENDER)}`)
859
+ })
860
+
861
+ it('names the deterministic id when no resolver is injected', async () => {
862
+ await deliver(makeGateway())
863
+ expect(errs.find((l) => l.includes('op=inbound'))).toContain(`sessionId=${adminSessionIdFor('acct', SENDER)}`)
864
+ })
865
+
866
+ it('passes the EFFECTIVE account to the resolver, not the socket account', async () => {
867
+ const spy = vi.fn(() => null)
868
+ await deliver(makeGateway(spy))
869
+ expect(spy).toHaveBeenCalledWith('acct', SENDER)
870
+ })
871
+ })
872
+ ```
873
+
874
+ - [ ] **Step 2: Run test to verify it fails**
875
+
876
+ Run: `npx vitest run app/lib/whatsapp/gateway/__tests__/inbound-session-log.test.ts`
877
+ Expected: FAIL — `resolveSessionOverride` is not a known dep, and the line names the deterministic id in all three cases.
878
+
879
+ - [ ] **Step 3: Add the dep to both gateway interfaces**
880
+
881
+ In `WaGatewayDeps` (`app/lib/whatsapp/gateway/wa-gateway.ts:68`) and the Telegram equivalent, add:
882
+
883
+ ```ts
884
+ /** Task 1789 — the sender's channel forward pointer, when the operator has
885
+ * re-seated this row. Injected by server/index.ts so the gateway keeps no
886
+ * filesystem knowledge and stays testable with a stub. Absent => the
887
+ * deterministic id, which is what every pre-1789 caller got. */
888
+ resolveSessionOverride?: (accountId: string, senderId: string) => string | null
889
+ ```
890
+
891
+ - [ ] **Step 4: Consult it on the log line**
892
+
893
+ In `app/lib/whatsapp/gateway/wa-gateway.ts`, replace the `resolvedSessionId` assignment (`:312`):
894
+
895
+ ```ts
896
+ // Task 1746 — name the session this inbound resolves to. Two distinct
897
+ // sessionId values for one sender IS the signature of the split defect.
898
+ // Task 1789 — consult the sender's forward pointer first, so this names the
899
+ // session the message actually REACHES rather than the stranded
900
+ // deterministic id an operator re-seat left behind.
901
+ const resolvedSessionId =
902
+ (input.role === 'admin' ? this.deps.resolveSessionOverride?.(input.effectiveAccountId, senderId) : null) ||
903
+ adminSessionIdFor(
904
+ input.effectiveAccountId,
905
+ senderId,
906
+ input.role === 'public' && input.personId ? input.personId : undefined,
907
+ )
908
+ ```
909
+
910
+ In `app/lib/telegram/gateway/telegram-gateway.ts`, apply the mirror at `:181`, using `input.accountId` (Telegram's own account variable at that site) and `input.senderId`:
911
+
912
+ ```ts
913
+ // Task 1746 / 1789 — name the session this inbound reaches; the forward
914
+ // pointer wins over the deterministic id. See the WhatsApp twin.
915
+ const resolvedSessionId =
916
+ (input.role === 'admin' ? this.deps.resolveSessionOverride?.(input.accountId, input.senderId) : null) ||
917
+ adminSessionIdFor(
918
+ input.accountId,
919
+ input.senderId,
920
+ input.role === 'public' && input.personId ? input.personId : undefined,
921
+ )
922
+ ```
923
+
924
+ - [ ] **Step 5: Inject the resolver at both gateway constructions**
925
+
926
+ In `server/index.ts`, add to the `WaGateway` deps object:
927
+
928
+ ```ts
929
+ resolveSessionOverride: (accountId, senderId) => resolveChannelOverride(accountId, 'whatsapp', senderId),
930
+ ```
931
+
932
+ and to the Telegram gateway deps object:
933
+
934
+ ```ts
935
+ resolveSessionOverride: (accountId, senderId) => resolveChannelOverride(accountId, 'telegram', senderId),
936
+ ```
937
+
938
+ - [ ] **Step 6: Run tests and typecheck**
939
+
940
+ Run: `npx vitest run app/lib/whatsapp/gateway/ app/lib/telegram/gateway/ && npx tsc --noEmit -p tsconfig.json`
941
+ Expected: PASS, no type errors.
942
+
943
+ - [ ] **Step 7: Commit**
944
+
945
+ ```bash
946
+ git add platform/ui/app/lib/whatsapp/gateway/wa-gateway.ts platform/ui/app/lib/telegram/gateway/telegram-gateway.ts platform/ui/server/index.ts platform/ui/app/lib/whatsapp/gateway/__tests__/inbound-session-log.test.ts
947
+ git commit -m "feat(1789): op=inbound names the session the message reaches"
948
+ ```
949
+
950
+ ---
951
+
952
+ ### Task 8: Standing check — `op=sender-unseated`
953
+
954
+ A sender whose channel server detached and never came back is invisible to action logging, because nothing happens. The sweep cannot see this today: `runDuplicateSenderAudit` walks sidecars on disk and has no event history. So the gateway records detach times and the sweep queries them.
955
+
956
+ **Files:**
957
+ - Modify: `app/lib/whatsapp/gateway/routes.ts` (`WaChannelRouteDeps`, the attach/detach sites `:48-56`)
958
+ - Modify: `app/lib/whatsapp/gateway/wa-gateway.ts` (`routes()` `:151-170`, new state + accessor)
959
+ - Modify: `app/lib/whatsapp/gateway/duplicate-sender-audit.ts`
960
+ - Modify: `server/index.ts` (`runDuplicateSenderAudit`)
961
+ - Test: `app/lib/whatsapp/gateway/__tests__/duplicate-sender-audit.test.ts`
962
+
963
+ **Interfaces:**
964
+ - Produces:
965
+ - `WaChannelRouteDeps.onAttached?: (senderId: string) => void`, `onDetached?: (senderId: string) => void`
966
+ - `WaGateway.unseatedSenders(nowMs: number, olderThanMs: number): Array<{ senderId: string; sinceMs: number }>`
967
+ - `logUnseatedSenders(entries: Array<{ senderId: string; sinceMs: number }>, nowMs: number): void`
968
+
969
+ - [ ] **Step 1: Write the failing test**
970
+
971
+ Append to `app/lib/whatsapp/gateway/__tests__/duplicate-sender-audit.test.ts`:
972
+
973
+ First extend the file's existing vitest import to include `vi`, `beforeEach`, and `afterEach`, and its `duplicate-sender-audit` import to include `logUnseatedSenders`. Then append:
974
+
975
+ ```ts
976
+ describe('logUnseatedSenders (Task 1789)', () => {
977
+ let errs: string[]
978
+ beforeEach(() => {
979
+ errs = []
980
+ vi.spyOn(console, 'error').mockImplementation((m: unknown) => { errs.push(String(m)) })
981
+ })
982
+ afterEach(() => { vi.restoreAllMocks() })
983
+
984
+ it('emits one line per unseated sender', () => {
985
+ logUnseatedSenders([{ senderId: '447504472444', sinceMs: 1_000 }], 901_000)
986
+ expect(errs).toHaveLength(1)
987
+ expect(errs[0]).toContain('op=sender-unseated')
988
+ expect(errs[0]).toContain('senderId=447504472444')
989
+ })
990
+
991
+ it('is silent when nothing is unseated', () => {
992
+ logUnseatedSenders([], 901_000)
993
+ expect(errs).toHaveLength(0)
994
+ })
995
+ })
996
+ ```
997
+
998
+ Also add a unit test for the gateway's own accounting, in `app/lib/whatsapp/gateway/__tests__/wa-gateway-unseated.test.ts`:
999
+
1000
+ ```ts
1001
+ import { describe, it, expect, vi } from 'vitest'
1002
+ import { WaGateway } from '../wa-gateway'
1003
+
1004
+ function makeGateway() {
1005
+ return new WaGateway({
1006
+ gatewayUrl: 'http://x',
1007
+ serverPath: '/x',
1008
+ ensureChannelSession: vi.fn(async () => {}),
1009
+ sendDocument: vi.fn(async () => ({ ok: true as const })),
1010
+ })
1011
+ }
1012
+
1013
+ describe('WaGateway.unseatedSenders (Task 1789)', () => {
1014
+ it('reports nothing when no sender has ever detached', () => {
1015
+ expect(makeGateway().unseatedSenders(1_000_000, 900_000)).toEqual([])
1016
+ })
1017
+
1018
+ it('reports a sender detached longer than the interval', () => {
1019
+ const gw = makeGateway()
1020
+ gw.recordDetached('A', 1_000)
1021
+ expect(gw.unseatedSenders(1_000_000, 900_000)).toEqual([{ senderId: 'A', sinceMs: 1_000 }])
1022
+ })
1023
+
1024
+ it('does not report a sender detached within the interval', () => {
1025
+ const gw = makeGateway()
1026
+ gw.recordDetached('A', 900_000)
1027
+ expect(gw.unseatedSenders(1_000_000, 900_000)).toEqual([])
1028
+ })
1029
+
1030
+ it('does not report a sender that re-attached', () => {
1031
+ const gw = makeGateway()
1032
+ gw.recordDetached('A', 1_000)
1033
+ gw.recordAttached('A')
1034
+ expect(gw.unseatedSenders(1_000_000, 900_000)).toEqual([])
1035
+ })
1036
+ })
1037
+ ```
1038
+
1039
+ - [ ] **Step 2: Run test to verify it fails**
1040
+
1041
+ Run: `npx vitest run app/lib/whatsapp/gateway/__tests__/duplicate-sender-audit.test.ts`
1042
+ Expected: FAIL — `logUnseatedSenders` is not exported.
1043
+
1044
+ - [ ] **Step 3: Add the emitter**
1045
+
1046
+ Append to `app/lib/whatsapp/gateway/duplicate-sender-audit.ts`:
1047
+
1048
+ ```ts
1049
+ /** Task 1789 — a sender whose channel server detached and never re-attached has
1050
+ * no seat, and nothing happens, so no transition line ever reports it. This is
1051
+ * the standing reconciliation: every known sender has a seat, or is named here
1052
+ * with how long it has been without one. 0 entries = healthy = silent. */
1053
+ export function logUnseatedSenders(entries: Array<{ senderId: string; sinceMs: number }>, nowMs: number): void {
1054
+ for (const e of entries) {
1055
+ const secs = Math.round((nowMs - e.sinceMs) / 1000)
1056
+ console.error(`[whatsapp-native] op=sender-unseated senderId=${e.senderId} since=${new Date(e.sinceMs).toISOString()} forSeconds=${secs}`)
1057
+ }
1058
+ }
1059
+ ```
1060
+
1061
+ - [ ] **Step 4: Record detach times in the gateway**
1062
+
1063
+ Add the hooks to `WaChannelRouteDeps` in `app/lib/whatsapp/gateway/routes.ts`:
1064
+
1065
+ ```ts
1066
+ /** Task 1789 — seat transitions, so the gateway can answer "which senders
1067
+ * have had no channel server since when" for the standing audit. */
1068
+ onAttached?: (senderId: string) => void
1069
+ onDetached?: (senderId: string) => void
1070
+ ```
1071
+
1072
+ Call them at the existing sites in `createWaChannelRoutes`:
1073
+
1074
+ ```ts
1075
+ console.error(`[whatsapp-native] op=channel-attached senderId=${senderId}`)
1076
+ deps.onAttached?.(senderId)
1077
+ stream.onAbort(() => {
1078
+ deps.hub.detach(senderId)
1079
+ console.error(`[whatsapp-native] op=channel-detached senderId=${senderId}`)
1080
+ deps.onDetached?.(senderId)
1081
+ })
1082
+ ```
1083
+
1084
+ Add the state and accessor to `WaGateway` in `app/lib/whatsapp/gateway/wa-gateway.ts`, and wire the hooks in `routes()`:
1085
+
1086
+ ```ts
1087
+ /** Task 1789 — senderId -> the ms at which its channel server last detached,
1088
+ * cleared on attach. The standing audit reads this; nothing else does. */
1089
+ private readonly detachedAt = new Map<string, number>()
1090
+
1091
+ /** Seat lost. `atMs` is injected so the audit's accounting is testable. */
1092
+ recordDetached(senderId: string, atMs: number): void {
1093
+ this.detachedAt.set(senderId, atMs)
1094
+ }
1095
+
1096
+ /** Seat regained — the sender is no longer unseated. */
1097
+ recordAttached(senderId: string): void {
1098
+ this.detachedAt.delete(senderId)
1099
+ }
1100
+
1101
+ /** Senders with no channel server for longer than `olderThanMs`. */
1102
+ unseatedSenders(nowMs: number, olderThanMs: number): Array<{ senderId: string; sinceMs: number }> {
1103
+ const out: Array<{ senderId: string; sinceMs: number }> = []
1104
+ for (const [senderId, sinceMs] of this.detachedAt) {
1105
+ if (nowMs - sinceMs > olderThanMs) out.push({ senderId, sinceMs })
1106
+ }
1107
+ return out.sort((a, b) => a.senderId.localeCompare(b.senderId))
1108
+ }
1109
+ ```
1110
+
1111
+ ```ts
1112
+ onAttached: (senderId) => this.recordAttached(senderId),
1113
+ onDetached: (senderId) => this.recordDetached(senderId, Date.now()),
1114
+ ```
1115
+
1116
+ - [ ] **Step 5: Query it from the sweep**
1117
+
1118
+ In `server/index.ts`'s `runDuplicateSenderAudit`, after the existing `logDuplicateSenderGroups(...)` call:
1119
+
1120
+ ```ts
1121
+ // Task 1789 — same sweep, second standing check: senders detached longer
1122
+ // than one interval with no re-attach.
1123
+ const now = Date.now()
1124
+ logUnseatedSenders(waGateway.unseatedSenders(now, 15 * 60_000), now)
1125
+ ```
1126
+
1127
+ and extend the existing import:
1128
+
1129
+ ```ts
1130
+ import { findDuplicateSenderGroups, logDuplicateSenderGroups, logUnseatedSenders, type SenderSessionRecord } from '../app/lib/whatsapp/gateway/duplicate-sender-audit.js'
1131
+ ```
1132
+
1133
+ - [ ] **Step 6: Run tests and typecheck**
1134
+
1135
+ Run: `npx vitest run app/lib/whatsapp/gateway/ && npx tsc --noEmit -p tsconfig.json`
1136
+ Expected: PASS, no type errors.
1137
+
1138
+ - [ ] **Step 7: Commit**
1139
+
1140
+ ```bash
1141
+ git add platform/ui/app/lib/whatsapp/gateway/routes.ts platform/ui/app/lib/whatsapp/gateway/wa-gateway.ts platform/ui/app/lib/whatsapp/gateway/duplicate-sender-audit.ts platform/ui/app/lib/whatsapp/gateway/__tests__/duplicate-sender-audit.test.ts platform/ui/server/index.ts
1142
+ git commit -m "feat(1789): standing op=sender-unseated check in the duplicate-sender sweep"
1143
+ ```
1144
+
1145
+ ---
1146
+
1147
+ ### Task 9: Correct the contradictory comments and the docs
1148
+
1149
+ `session-reseat.ts`'s header said the fork is always webchat-bound; `SessionReseatControl.tsx:10-13` said the same form serves both row kinds. The route's assumption won, which is the defect. Both must now describe the delivered behaviour. The `session-reseat.ts` header and both `spawn-request.ts` comments were already corrected in Tasks 3 and 5; this task closes the remaining two surfaces.
1150
+
1151
+ **Files:**
1152
+ - Modify: `app/components/SessionReseatControl.tsx:7-23`
1153
+ - Modify: `../../.docs/admin-webchat-native-channel.md`
1154
+
1155
+ - [ ] **Step 1: Correct the component comment**
1156
+
1157
+ Replace the second sentence of the `SessionReseatControl` doc comment:
1158
+
1159
+ ```tsx
1160
+ * Task 928 — per-row Re-seat form for the operator dashboard. Re-seat is the
1161
+ * fork mechanism (Task 876): it forks the session onto a fresh id born on the
1162
+ * chosen model/mode/effort, history preserved, then navigates to the fork. The
1163
+ * same form serves webchat rows (the Sessions panel) and channel rows
1164
+ * (WhatsApp/Telegram), since both drive the one /api/admin/session-reseat fork.
1165
+ *
1166
+ * Task 1789 — the route now honours that. It reads the source row's sidecar and
1167
+ * forks an admin whatsapp/telegram row as THAT channel, carrying the source's
1168
+ * senderId, and writes a per-sender forward pointer so the sender's next inbound
1169
+ * resolves to the fork. Before this the route hardcoded a webchat payload for
1170
+ * every row, which replaced the sender's channel server and detached its seat.
1171
+ * A channel fork has no reply tool until that next inbound rebinds it; the
1172
+ * lifeline names that window as op=channel-fork-unbound.
1173
+ ```
1174
+
1175
+ - [ ] **Step 2: Document the override**
1176
+
1177
+ Append a section to `maxy-code/.docs/admin-webchat-native-channel.md`:
1178
+
1179
+ ```markdown
1180
+ ## Channel-row re-seat and the per-sender forward pointer (Task 1789)
1181
+
1182
+ A channel session's id is `adminSessionIdFor(accountId, senderId)` — a pure hash,
1183
+ so every inbound from a sender recomputes the same id. An operator re-seat forks
1184
+ the row onto a freshly minted id that hash will never return.
1185
+
1186
+ Two mechanisms make a channel re-seat work, and neither is sufficient alone.
1187
+ `session-reseat.ts` reads the source row's `.meta.json` and, when it is an
1188
+ **admin** `whatsapp` or `telegram` row, builds the fork payload with that channel
1189
+ and the source's own `senderId` instead of the webchat shape. `server/channel-session-override.ts`
1190
+ then holds a per-account `<channel>:<senderId>` → fork-id pointer, written at that
1191
+ fork and consulted at both spawn sites in `server/index.ts`, so the sender's next
1192
+ inbound resolves to the fork.
1193
+
1194
+ Public rows are out of scope: their session id keys on `personId` as well as
1195
+ `senderId`, so a per-sender pointer is ambiguous for them. They re-seat as
1196
+ webchat, unchanged.
1197
+
1198
+ The pointer is not Task 1526's per-firing seat that Task 1746 removed. That seat
1199
+ was salted per scheduled dispatch and minted a new session every firing, which
1200
+ attached under the sender's own hub key and swallowed their real messages. This
1201
+ pointer is stable — one session, repointed once — and can never yield a second id
1202
+ for a sender.
1203
+
1204
+ A channel fork carries no `waChannel` / `telegramChannel` descriptor, because the
1205
+ re-seat route holds no gateway url or server path. So the fork has no reply tool
1206
+ until the next inbound rebinds it through `ensureChannelSession`. That window is
1207
+ logged as `op=channel-fork-unbound`; the manager's own `op=channel-binding-lost`
1208
+ invariant is gated on its channel store and cannot fire for a fresh fork id.
1209
+ ```
1210
+
1211
+ - [ ] **Step 3: Verify the docs hook is satisfied**
1212
+
1213
+ Run: `git status --short` and confirm both a code file and a docs file are staged.
1214
+
1215
+ - [ ] **Step 4: Commit**
1216
+
1217
+ ```bash
1218
+ git add platform/ui/app/components/SessionReseatControl.tsx ../../.docs/admin-webchat-native-channel.md
1219
+ git commit -m "docs(1789): correct the re-seat comments and document the forward pointer"
1220
+ ```
1221
+
1222
+ ---
1223
+
1224
+ ### Task 10: Full verification
1225
+
1226
+ - [ ] **Step 1: Full test suite**
1227
+
1228
+ Run: `npm test`
1229
+ Expected: PASS. Compare failures against the pre-sprint baseline; any pre-existing failure is recorded, not fixed here.
1230
+
1231
+ - [ ] **Step 2: Typecheck and build**
1232
+
1233
+ Run: `npx tsc --noEmit -p tsconfig.json && npm run build`
1234
+ Expected: no type errors, build succeeds.
1235
+
1236
+ - [ ] **Step 3: Lint changed files**
1237
+
1238
+ Run: `npx eslint $(git diff --name-only main...HEAD | grep -E '\.tsx?$' | sed 's|^platform/ui/||')`
1239
+ Expected: no new errors in changed files.
1240
+
1241
+ - [ ] **Step 4: Confirm the webchat payload is unchanged**
1242
+
1243
+ Run: `npx vitest run server/routes/admin/__tests__/session-reseat.test.ts -t 'mints a new id'`
1244
+ Expected: PASS — the original Task 876 assertion still holds byte-for-byte.