@rubytech/create-realagent 1.0.647 → 1.0.649

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 (150) hide show
  1. package/dist/index.js +76 -3
  2. package/package.json +1 -1
  3. package/payload/platform/plugins/admin/skills/onboarding/SKILL.md +1 -11
  4. package/payload/platform/plugins/cloudflare/PLUGIN.md +3 -2
  5. package/payload/platform/plugins/cloudflare/scripts/_cdp-authorize.mjs +274 -0
  6. package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.sh +69 -0
  7. package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.ts +401 -0
  8. package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +73 -3
  9. package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +4 -2
  10. package/payload/platform/plugins/docs/references/cloudflare.md +1 -0
  11. package/payload/platform/plugins/docs/references/deployment.md +11 -0
  12. package/payload/platform/plugins/docs/references/getting-started.md +2 -0
  13. package/payload/platform/plugins/docs/references/memory-guide.md +2 -2
  14. package/payload/platform/plugins/docs/references/platform.md +6 -0
  15. package/payload/platform/plugins/docs/references/troubleshooting.md +16 -0
  16. package/payload/platform/plugins/memory/references/graph-primitives.md +5 -5
  17. package/payload/platform/templates/dotfiles/.tmux.conf +1 -0
  18. package/payload/platform/templates/systemd/maxy-ttyd.service +20 -0
  19. package/payload/server/public/assets/admin-BtjYSo0M.js +362 -0
  20. package/payload/server/public/assets/admin-kHJ-D0s7.css +1 -0
  21. package/payload/server/public/assets/{arc-DcrodP5U.js → arc-BMhgytDB.js} +1 -1
  22. package/payload/server/public/assets/architecture-YZFGNWBL-S9-oeq_x.js +1 -0
  23. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-Cvo_8X6C.js → architectureDiagram-Q4EWVU46-BePoi8XC.js} +1 -1
  24. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-CEK06TEn.js → blockDiagram-DXYQGD6D-BkiwLTtq.js} +1 -1
  25. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-QjYUSwTU.js → c4Diagram-AHTNJAMY-bpjPj2Ln.js} +1 -1
  26. package/payload/server/public/assets/channel-D3U0_a1j.js +1 -0
  27. package/payload/server/public/assets/{chunk-2KRD3SAO-BBifNfFc.js → chunk-2KRD3SAO-ZcHg_orY.js} +1 -1
  28. package/payload/server/public/assets/{chunk-336JU56O-B-N_zWuf.js → chunk-336JU56O-BpATJiGl.js} +2 -2
  29. package/payload/server/public/assets/chunk-426QAEUC-Wz6Bpsil.js +1 -0
  30. package/payload/server/public/assets/{chunk-4BX2VUAB-CtDQKj9B.js → chunk-4BX2VUAB-zJekz2NU.js} +1 -1
  31. package/payload/server/public/assets/{chunk-4TB4RGXK-Dnu9n3p1.js → chunk-4TB4RGXK-CLXL19Wd.js} +1 -1
  32. package/payload/server/public/assets/{chunk-55IACEB6-DSLoJJSj.js → chunk-55IACEB6-CzqB8aoU.js} +1 -1
  33. package/payload/server/public/assets/{chunk-5FUZZQ4R-rx-IvMNE.js → chunk-5FUZZQ4R-BoTfWHuW.js} +1 -1
  34. package/payload/server/public/assets/{chunk-5PVQY5BW-B9w9AKCS.js → chunk-5PVQY5BW-RhIfPCRB.js} +1 -1
  35. package/payload/server/public/assets/{chunk-67CJDMHE-C1yEjtiu.js → chunk-67CJDMHE-mM1sFmlz.js} +1 -1
  36. package/payload/server/public/assets/{chunk-7N4EOEYR-C2f3zeVH.js → chunk-7N4EOEYR-GUck0jv1.js} +1 -1
  37. package/payload/server/public/assets/{chunk-AA7GKIK3-B_U-NsDK.js → chunk-AA7GKIK3-BYhfUc1V.js} +1 -1
  38. package/payload/server/public/assets/{chunk-BSJP7CBP-DM6_wafW.js → chunk-BSJP7CBP-CTsYuARh.js} +1 -1
  39. package/payload/server/public/assets/{chunk-CIAEETIT-DG7WkfNj.js → chunk-CIAEETIT-CGsGmUze.js} +1 -1
  40. package/payload/server/public/assets/{chunk-EDXVE4YY-aS3_rdwQ.js → chunk-EDXVE4YY-utELKGQK.js} +1 -1
  41. package/payload/server/public/assets/{chunk-ENJZ2VHE-r1I0uoCf.js → chunk-ENJZ2VHE-CNHjq5xK.js} +1 -1
  42. package/payload/server/public/assets/{chunk-FMBD7UC4-CTm3YRE5.js → chunk-FMBD7UC4-DaRrfk3s.js} +1 -1
  43. package/payload/server/public/assets/{chunk-FOC6F5B3-CAIttx3K.js → chunk-FOC6F5B3-BaeLcJVt.js} +1 -1
  44. package/payload/server/public/assets/{chunk-ICPOFSXX-DQFV4c1l.js → chunk-ICPOFSXX-Di63NBur.js} +2 -2
  45. package/payload/server/public/assets/{chunk-K5T4RW27-B2WCPQBa.js → chunk-K5T4RW27-CTTOezMH.js} +1 -1
  46. package/payload/server/public/assets/{chunk-KGLVRYIC-C6w2sUOF.js → chunk-KGLVRYIC-DCkohKP2.js} +1 -1
  47. package/payload/server/public/assets/{chunk-LIHQZDEY-BT1hcDTK.js → chunk-LIHQZDEY-osQO30uB.js} +1 -1
  48. package/payload/server/public/assets/{chunk-ORNJ4GCN-Brl32BSe.js → chunk-ORNJ4GCN-DoLajOOe.js} +1 -1
  49. package/payload/server/public/assets/{chunk-OYMX7WX6-BCO6n1CX.js → chunk-OYMX7WX6-BSPzqyxs.js} +1 -1
  50. package/payload/server/public/assets/chunk-QZHKN3VN-BAQp1OEl.js +1 -0
  51. package/payload/server/public/assets/{chunk-U2HBQHQK-BvgC0fpb.js → chunk-U2HBQHQK-BZnA7c4T.js} +1 -1
  52. package/payload/server/public/assets/{chunk-X2U36JSP-3yLdcqYf.js → chunk-X2U36JSP-DpQ2OA_c.js} +1 -1
  53. package/payload/server/public/assets/{chunk-XPW4576I-D876RWxK.js → chunk-XPW4576I-BccP1mlQ.js} +1 -1
  54. package/payload/server/public/assets/{chunk-YZCP3GAM-CiuA4hOC.js → chunk-YZCP3GAM-BAkNXu0G.js} +1 -1
  55. package/payload/server/public/assets/{chunk-ZZ45TVLE-COjEBPzv.js → chunk-ZZ45TVLE-DBSm41oP.js} +1 -1
  56. package/payload/server/public/assets/classDiagram-6PBFFD2Q-6EGGLDD_.js +1 -0
  57. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DfAV4tgE.js +1 -0
  58. package/payload/server/public/assets/clone-BoV8noAi.js +1 -0
  59. package/payload/server/public/assets/{cose-bilkent-S5V4N54A-Dap0yL0o.js → cose-bilkent-S5V4N54A-Boeb8aWs.js} +1 -1
  60. package/payload/server/public/assets/{dagre-KV5264BT-DEia9UJj.js → dagre-KV5264BT-BkvWofSp.js} +1 -1
  61. package/payload/server/public/assets/{dagre-DBbjK-Cf.js → dagre-nvPNAunb.js} +1 -1
  62. package/payload/server/public/assets/data-gtcFdXVv.js +1 -0
  63. package/payload/server/public/assets/{diagram-5BDNPKRD-CWSP9MzJ.js → diagram-5BDNPKRD-CMEgyt4E.js} +1 -1
  64. package/payload/server/public/assets/{diagram-G4DWMVQ6-DWRsfitL.js → diagram-G4DWMVQ6-ChorrAF0.js} +1 -1
  65. package/payload/server/public/assets/{diagram-MMDJMWI5-n-jyzS4D.js → diagram-MMDJMWI5-D_iD27po.js} +1 -1
  66. package/payload/server/public/assets/{diagram-TYMM5635-CLPTbfLq.js → diagram-TYMM5635-8qXI1ioG.js} +1 -1
  67. package/payload/server/public/assets/{dist-Tkw8EOuG.js → dist-CrzV1W3-.js} +1 -1
  68. package/payload/server/public/assets/{erDiagram-SMLLAGMA-CmPC9Cnc.js → erDiagram-SMLLAGMA-BFjtKDSB.js} +1 -1
  69. package/payload/server/public/assets/file-DRa7iPfT.js +1 -0
  70. package/payload/server/public/assets/{flatten-Db2kUB5j.js → flatten-ya0TqRLc.js} +1 -1
  71. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-DKMNmUbX.js → flowDiagram-DWJPFMVM-Bpd7IL9l.js} +1 -1
  72. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-C5-y3w-l.js → ganttDiagram-T4ZO3ILL-CwOozU85.js} +1 -1
  73. package/payload/server/public/assets/gitGraph-7Q5UKJZL-BOC4CldZ.js +1 -0
  74. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-D9hG5kTg.js → gitGraphDiagram-UUTBAWPF-CcPILiC9.js} +1 -1
  75. package/payload/server/public/assets/graph-ydtwufZX.js +49 -0
  76. package/payload/server/public/assets/{graphlib-StP6GUhM.js → graphlib-B_mcXEVr.js} +1 -1
  77. package/payload/server/public/assets/house-DEu5yOIQ.js +1 -0
  78. package/payload/server/public/assets/info-OMHHGYJF-BSCPTUIx.js +1 -0
  79. package/payload/server/public/assets/infoDiagram-42DDH7IO-T2sn--WJ.js +2 -0
  80. package/payload/server/public/assets/{isEmpty-CXH_nKTs.js → isEmpty-h-wRi_o9.js} +1 -1
  81. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A--K4KOS61.js → ishikawaDiagram-UXIWVN3A-DOP9-Q8H.js} +1 -1
  82. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-DE-28YrW.js → journeyDiagram-VCZTEJTY-DGATg0WC.js} +1 -1
  83. package/payload/server/public/assets/{jsx-runtime-DwoXvzmf.js → jsx-runtime-BrB6Lw5Y.js} +1 -1
  84. package/payload/server/public/assets/{jsx-runtime-BQmd8XDE.css → jsx-runtime-C8kAju5f.css} +1 -1
  85. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-CxSHjau2.js → kanban-definition-6JOO6SKY-C5PigmKg.js} +1 -1
  86. package/payload/server/public/assets/{line-DbcqYIG0.js → line-DlKKhwkO.js} +1 -1
  87. package/payload/server/public/assets/{linear-DXHoZSN3.js → linear-DD4JiB1l.js} +1 -1
  88. package/payload/server/public/assets/{mermaid-parser.core-CNGUA13J.js → mermaid-parser.core-C8xGCa9p.js} +2 -2
  89. package/payload/server/public/assets/{mermaid.core-IQgx_upQ.js → mermaid.core-CCUSwZB_.js} +3 -3
  90. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-BT9Up6-C.js → mindmap-definition-QFDTVHPH-75k-IVhC.js} +1 -1
  91. package/payload/server/public/assets/{ordinal-CN3oz6oW.js → ordinal-Dwxksj1B.js} +1 -1
  92. package/payload/server/public/assets/packet-4T2RLAQJ-pBa_ZhNI.js +1 -0
  93. package/payload/server/public/assets/pie-ZZUOXDRM-BzYOyiMb.js +1 -0
  94. package/payload/server/public/assets/{pieDiagram-DEJITSTG-BuewQTi6.js → pieDiagram-DEJITSTG-DN5RsDwZ.js} +1 -1
  95. package/payload/server/public/assets/public-D0ymcICW.js +5 -0
  96. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-Cu8y2zQL.js → quadrantDiagram-34T5L4WZ-Sd9x6pNe.js} +1 -1
  97. package/payload/server/public/assets/radar-PYXPWWZC-CTVOaAq6.js +1 -0
  98. package/payload/server/public/assets/{reduce-SDh8_UdG.js → reduce-BUuWaDl2.js} +1 -1
  99. package/payload/server/public/assets/{requirementDiagram-MS252O5E-DiT9bo27.js → requirementDiagram-MS252O5E-BDgifYzj.js} +1 -1
  100. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-EkRnGTxM.js → sankeyDiagram-XADWPNL6-BX9VULNJ.js} +1 -1
  101. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-BUiQA3SI.js → sequenceDiagram-FGHM5R23-z3vMxhgE.js} +1 -1
  102. package/payload/server/public/assets/share-2-Cnd_9DYU.js +1 -0
  103. package/payload/server/public/assets/{src-DQQCRlaQ.js → src-Bo15iQ7w.js} +1 -1
  104. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-Fij35Tic.js → stateDiagram-FHFEXIEX-DlP0hBxF.js} +1 -1
  105. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-DSddQStC.js +1 -0
  106. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-BMsyr7wU.js → timeline-definition-GMOUNBTQ-DwQbhKCo.js} +1 -1
  107. package/payload/server/public/assets/treeView-SZITEDCU-OTnF4Qzw.js +1 -0
  108. package/payload/server/public/assets/treemap-W4RFUUIX-DlIRmHFb.js +1 -0
  109. package/payload/server/public/assets/{useVoiceRecorder-C0Fvv_Bt.js → useVoiceRecorder-CFIpzGaB.js} +3 -3
  110. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-6fegYFB3.js → vennDiagram-DHZGUBPP-WTqmZWWa.js} +1 -1
  111. package/payload/server/public/assets/wardley-RL74JXVD-DwMXAC4U.js +1 -0
  112. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-BtJ_B35h.js → wardleyDiagram-NUSXRM2D-BUY50x5T.js} +1 -1
  113. package/payload/server/public/assets/x-DitohdYO.js +1 -0
  114. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-DJ20B4NY.js → xychartDiagram-5P7HB3ND-Btdq-fDj.js} +1 -1
  115. package/payload/server/public/data.html +7 -5
  116. package/payload/server/public/graph.html +19 -0
  117. package/payload/server/public/index.html +10 -7
  118. package/payload/server/public/public.html +6 -6
  119. package/payload/server/server.js +965 -664
  120. package/payload/server/public/assets/admin-ITLuG4BN.js +0 -352
  121. package/payload/server/public/assets/architecture-YZFGNWBL-C38eyeNF.js +0 -1
  122. package/payload/server/public/assets/channel-D0dIwjlN.js +0 -1
  123. package/payload/server/public/assets/chunk-426QAEUC-C8oXXITm.js +0 -1
  124. package/payload/server/public/assets/chunk-QZHKN3VN-BE_lylks.js +0 -1
  125. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DH37CWIF.js +0 -1
  126. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DNJ7bv8r.js +0 -1
  127. package/payload/server/public/assets/clone-rrGuX3ZR.js +0 -1
  128. package/payload/server/public/assets/data-D8kol1ed.js +0 -1
  129. package/payload/server/public/assets/gitGraph-7Q5UKJZL-CCjgA3FG.js +0 -1
  130. package/payload/server/public/assets/info-OMHHGYJF-B65K6dQJ.js +0 -1
  131. package/payload/server/public/assets/infoDiagram-42DDH7IO-DUJfTICr.js +0 -2
  132. package/payload/server/public/assets/packet-4T2RLAQJ-fp5ishAK.js +0 -1
  133. package/payload/server/public/assets/pie-ZZUOXDRM-Bc3VMuuU.js +0 -1
  134. package/payload/server/public/assets/public-CWuf8cLU.js +0 -5
  135. package/payload/server/public/assets/radar-PYXPWWZC-D9jy5QAa.js +0 -1
  136. package/payload/server/public/assets/share-2-wGga_ldi.js +0 -1
  137. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-DQzhSd8K.js +0 -1
  138. package/payload/server/public/assets/treeView-SZITEDCU-CHyRL9e4.js +0 -1
  139. package/payload/server/public/assets/treemap-W4RFUUIX-DpQ_FOO6.js +0 -1
  140. package/payload/server/public/assets/wardley-RL74JXVD-CWBIAatW.js +0 -1
  141. /package/payload/server/public/assets/{_baseFor-D71p92tl.js → _baseFor-Dn4GSmI6.js} +0 -0
  142. /package/payload/server/public/assets/{array-Bs_owIvv.js → array-DJN9YAVf.js} +0 -0
  143. /package/payload/server/public/assets/{chunk-lgnzUk6H.js → chunk-DD-I1_y5.js} +0 -0
  144. /package/payload/server/public/assets/{cytoscape.esm-DLG5qhup.js → cytoscape.esm-BcJTl1re.js} +0 -0
  145. /package/payload/server/public/assets/{defaultLocale-Du_2bjyv.js → defaultLocale-B4F_XsBB.js} +0 -0
  146. /package/payload/server/public/assets/{init-BYLBkHX_.js → init-DX0Y1qU4.js} +0 -0
  147. /package/payload/server/public/assets/{katex-lkho_UhZ.js → katex-CjHJ1D7d.js} +0 -0
  148. /package/payload/server/public/assets/{path-BO54iFkf.js → path-7vUsG-o2.js} +0 -0
  149. /package/payload/server/public/assets/{preload-helper-DWTEM3RW.js → preload-helper-qlgyTAkD.js} +0 -0
  150. /package/payload/server/public/assets/{rough.esm-BCiZEpQC.js → rough.esm-NLRoWnq-.js} +0 -0
@@ -0,0 +1,401 @@
1
+ // Raw CDP scrape of the operator's Cloudflare dashboard to discover the
2
+ // domains attached to the logged-in account. Output on stdout is a JSON
3
+ // `string[]`, sorted and deduped. Every failure mode exits non-zero with
4
+ // a `reason=<enum>` token on stderr tagged [list-cf-domains].
5
+ //
6
+ // Why raw CDP, not playwright:
7
+ // - `/json/new?about:blank` + WebSocket to the returned `webSocketDebuggerUrl`
8
+ // is ~120 LOC including retries and state machine.
9
+ // - Adds zero npm deps (Node 22 ships global `WebSocket`; no `ws`/`playwright`).
10
+ // - CDP is already bound to 127.0.0.1 by vnc.sh; the runtime surface is
11
+ // the same one setup-tunnel.sh uses for browser drive.
12
+ //
13
+ // Why URL-pattern extraction, not CSS selectors:
14
+ // - Cloudflare's SPA uses hashed, version-churning class names — a selector
15
+ // written today drifts within a dashboard redesign window.
16
+ // - The `/<accountId>/<domain>` URL shape is load-bearing for CF's own
17
+ // routing and cannot drift without breaking their entire dashboard; it
18
+ // is the most stable surface to key off.
19
+ //
20
+ // Contract with the caller (list-cf-domains.sh → route → form):
21
+ // stdout on exit 0: JSON `string[]` (empty array means signed-in-but-empty)
22
+ // stderr on any path: phase_line-formatted lines, `[list-cf-domains] …`
23
+ // exit 1: `reason=<enum>` on stderr; scrape-empty-but-no-error is exit 0
24
+ // with body dump (the enum exists so the caller can distinguish
25
+ // "selector drift" from "empty account" — both shapes arrive via
26
+ // stdout).
27
+
28
+ import { writeFile } from "node:fs/promises";
29
+ import { resolve } from "node:path";
30
+ import { homedir } from "node:os";
31
+
32
+ const CDP_HOST = "127.0.0.1";
33
+ const CDP_PORT = 9222;
34
+
35
+ // Phase budgets total 30s (enforced by the shell wrapper's `timeout`).
36
+ // Individual steps get sub-budgets so an early stall does not eat the
37
+ // whole envelope silently.
38
+ const CONNECT_TIMEOUT_MS = 2_000;
39
+ const NAVIGATE_TIMEOUT_MS = 15_000;
40
+ const SIGNED_IN_POLL_MS = 10_000;
41
+ const SCRAPE_POLL_MS = 10_000;
42
+ const POLL_INTERVAL_MS = 500;
43
+
44
+ type Reason =
45
+ | "cdp-unreachable"
46
+ | "target-create-failed"
47
+ | "ws-connect-failed"
48
+ | "not-signed-in"
49
+ | "navigate-failed"
50
+ | "table-wait-timeout"
51
+ | "runtime-error";
52
+
53
+ function logPhase(line: string): void {
54
+ // Written to stderr; shell wrapper tees to STREAM_LOG_PATH with timestamp.
55
+ // Keeping format parity with `_stream-log.sh phase_line` on the bash side
56
+ // means the tailer regex and grepping by `[list-cf-domains]` work uniformly.
57
+ process.stderr.write(`[list-cf-domains] ${line}\n`);
58
+ }
59
+
60
+ function die(reason: Reason, detail = ""): never {
61
+ logPhase(`phase=error reason=${reason}${detail ? ` detail="${detail.replace(/"/g, "'").slice(0, 300)}"` : ""}`);
62
+ process.exit(1);
63
+ }
64
+
65
+ async function cdpVersion(): Promise<void> {
66
+ const controller = new AbortController();
67
+ const timer = setTimeout(() => controller.abort(), CONNECT_TIMEOUT_MS);
68
+ try {
69
+ const res = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/version`, { signal: controller.signal });
70
+ if (!res.ok) die("cdp-unreachable", `HTTP ${res.status}`);
71
+ } catch (err) {
72
+ die("cdp-unreachable", err instanceof Error ? err.message : String(err));
73
+ } finally {
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+
78
+ interface CdpTarget {
79
+ id: string;
80
+ webSocketDebuggerUrl: string;
81
+ }
82
+
83
+ async function createTarget(): Promise<CdpTarget> {
84
+ // `PUT /json/new?<url>` returns JSON describing the target. The URL goes
85
+ // as a query-string argument (not a ?url= key), matching Chromium's CDP
86
+ // server. about:blank avoids a wasted navigation — the real navigate
87
+ // happens over WS after we attach.
88
+ const endpoint = `http://${CDP_HOST}:${CDP_PORT}/json/new?about:blank`;
89
+ let res: Response;
90
+ try {
91
+ res = await fetch(endpoint, { method: "PUT", signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) });
92
+ } catch (err) {
93
+ die("target-create-failed", err instanceof Error ? err.message : String(err));
94
+ }
95
+ if (!res.ok) die("target-create-failed", `HTTP ${res.status}`);
96
+ const target = (await res.json()) as Partial<CdpTarget>;
97
+ if (!target.id || !target.webSocketDebuggerUrl) {
98
+ die("target-create-failed", "response missing id or webSocketDebuggerUrl");
99
+ }
100
+ return target as CdpTarget;
101
+ }
102
+
103
+ async function closeTarget(id: string): Promise<void> {
104
+ try {
105
+ await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/close/${id}`, {
106
+ signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS),
107
+ });
108
+ } catch {
109
+ // Best-effort on cleanup — a leaked target is recovered by the next vnc.sh
110
+ // restart; surfacing it as a failure would mask the real scrape error.
111
+ }
112
+ }
113
+
114
+ // Minimal CDP WebSocket client. We only need three commands: Page.enable,
115
+ // Runtime.enable, Page.navigate, Runtime.evaluate. A full client would
116
+ // manage sessions / targets / events — we just need request/response with
117
+ // per-id correlation and an optional event stream for Page.frameNavigated
118
+ // (which we do not actually need because Runtime.evaluate on location.href
119
+ // is the authoritative signal).
120
+ class CdpClient {
121
+ private ws: WebSocket;
122
+ private nextId = 1;
123
+ private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
124
+
125
+ private constructor(ws: WebSocket) {
126
+ this.ws = ws;
127
+ this.ws.addEventListener("message", (ev) => this.onMessage(ev.data as string));
128
+ this.ws.addEventListener("error", () => {
129
+ for (const { reject } of this.pending.values()) reject(new Error("WebSocket error"));
130
+ this.pending.clear();
131
+ });
132
+ this.ws.addEventListener("close", () => {
133
+ for (const { reject } of this.pending.values()) reject(new Error("WebSocket closed"));
134
+ this.pending.clear();
135
+ });
136
+ }
137
+
138
+ static async connect(url: string): Promise<CdpClient> {
139
+ const ws = new WebSocket(url);
140
+ return await new Promise((resolvePromise, rejectPromise) => {
141
+ const timer = setTimeout(() => {
142
+ ws.close();
143
+ rejectPromise(new Error(`WebSocket connect timeout (${CONNECT_TIMEOUT_MS}ms)`));
144
+ }, CONNECT_TIMEOUT_MS);
145
+ ws.addEventListener("open", () => {
146
+ clearTimeout(timer);
147
+ resolvePromise(new CdpClient(ws));
148
+ }, { once: true });
149
+ ws.addEventListener("error", (ev) => {
150
+ clearTimeout(timer);
151
+ rejectPromise(new Error(`WebSocket error: ${(ev as ErrorEvent).message ?? "unknown"}`));
152
+ }, { once: true });
153
+ });
154
+ }
155
+
156
+ private onMessage(raw: string): void {
157
+ let msg: { id?: number; result?: unknown; error?: { message?: string } };
158
+ try {
159
+ msg = JSON.parse(raw);
160
+ } catch {
161
+ return;
162
+ }
163
+ if (typeof msg.id !== "number") return;
164
+ const waiter = this.pending.get(msg.id);
165
+ if (!waiter) return;
166
+ this.pending.delete(msg.id);
167
+ if (msg.error) {
168
+ waiter.reject(new Error(msg.error.message ?? "CDP error"));
169
+ } else {
170
+ waiter.resolve(msg.result);
171
+ }
172
+ }
173
+
174
+ send<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
175
+ const id = this.nextId++;
176
+ return new Promise<T>((resolvePromise, rejectPromise) => {
177
+ this.pending.set(id, {
178
+ resolve: resolvePromise as (v: unknown) => void,
179
+ reject: rejectPromise,
180
+ });
181
+ this.ws.send(JSON.stringify({ id, method, params }));
182
+ });
183
+ }
184
+
185
+ close(): void {
186
+ try { this.ws.close(); } catch { /* ws may already be closing */ }
187
+ }
188
+ }
189
+
190
+ interface EvaluateResult {
191
+ result: { type: string; value?: unknown; description?: string };
192
+ exceptionDetails?: { text?: string; exception?: { description?: string } };
193
+ }
194
+
195
+ async function evaluate<T = unknown>(cdp: CdpClient, expression: string): Promise<T> {
196
+ const res = await cdp.send<EvaluateResult>("Runtime.evaluate", {
197
+ expression,
198
+ returnByValue: true,
199
+ awaitPromise: false,
200
+ });
201
+ if (res.exceptionDetails) {
202
+ throw new Error(res.exceptionDetails.exception?.description ?? res.exceptionDetails.text ?? "evaluate threw");
203
+ }
204
+ return res.result.value as T;
205
+ }
206
+
207
+ // Detect whether the dashboard has redirected to a signed-in account path
208
+ // (URL like `/<32-hex-accountId>/…`) or the unsigned-in `/login` flow.
209
+ // Returns the accountId if signed in, null if still loading, false if
210
+ // definitively signed-out.
211
+ const ACCOUNT_ID_REGEX = /^\/([a-f0-9]{32})(?:\/|$)/;
212
+
213
+ async function readAccountId(cdp: CdpClient): Promise<string | null | false> {
214
+ const pathname = await evaluate<string>(cdp, "location.pathname");
215
+ if (typeof pathname !== "string") return null;
216
+ if (pathname.startsWith("/login") || pathname.startsWith("/sign-in")) return false;
217
+ const match = pathname.match(ACCOUNT_ID_REGEX);
218
+ return match ? match[1] : null;
219
+ }
220
+
221
+ async function waitForSignedIn(cdp: CdpClient): Promise<string> {
222
+ const deadline = Date.now() + SIGNED_IN_POLL_MS;
223
+ while (Date.now() < deadline) {
224
+ const state = await readAccountId(cdp);
225
+ if (state === false) die("not-signed-in", "dashboard redirected to login");
226
+ if (typeof state === "string") {
227
+ logPhase(`phase=dashboard-nav-complete accountId=${state.slice(0, 8)}…`);
228
+ return state;
229
+ }
230
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
231
+ }
232
+ // Timed out without seeing either pattern — treat as not-signed-in to
233
+ // prompt `tunnel-login`; a genuinely-slow dashboard that later resolves
234
+ // would be a false-negative we accept in exchange for deterministic UX.
235
+ die("not-signed-in", "no /<accountId>/… path reached within budget");
236
+ }
237
+
238
+ // Extract every domain mentioned on the Domains Overview page via URL-
239
+ // pattern match, then merge with any FQDN-shaped text found in table cells.
240
+ // Two sources: (a) `/<accountId>/<hostname>` URL hrefs on the page; (b) any
241
+ // text node on the page matching a valid FQDN pattern — the overview page
242
+ // renders the zone name as both a link and a table cell, so either source
243
+ // catches it. The merge is conservative: we require the string look like a
244
+ // domain (2+ labels, last label 2-63 alpha) AND not be a cloudflare-owned
245
+ // marketing host.
246
+ const SCRAPE_EXPRESSION = `(function() {
247
+ const accountIdMatch = location.pathname.match(/^\\/([a-f0-9]{32})/);
248
+ if (!accountIdMatch) return { reason: 'no-account-id', domains: [] };
249
+ const accountId = accountIdMatch[1];
250
+
251
+ const out = new Set();
252
+ const pushIfDomain = (s) => {
253
+ if (typeof s !== 'string') return;
254
+ const t = s.trim().toLowerCase();
255
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/.test(t)) return;
256
+ if (t.endsWith('.cloudflare.com') || t === 'cloudflare.com') return;
257
+ out.add(t);
258
+ };
259
+
260
+ // Source A: /<accountId>/<host> hrefs — the canonical CF routing pattern
261
+ // that the overview page uses to link each zone to its management screens.
262
+ const hrefPrefix = '/' + accountId + '/';
263
+ for (const a of document.querySelectorAll('a[href]')) {
264
+ const href = a.getAttribute('href') || '';
265
+ if (!href.startsWith(hrefPrefix)) continue;
266
+ const tail = href.slice(hrefPrefix.length).split('?')[0].split('#')[0];
267
+ const firstSegment = tail.split('/')[0];
268
+ pushIfDomain(firstSegment);
269
+ }
270
+
271
+ // Source B: explicit FQDN-shaped text in the main content region. Guards
272
+ // against a hypothetical CF redesign that drops the link tree — the zone
273
+ // name still appears as rendered text in the table row.
274
+ const main = document.querySelector('main') || document.body;
275
+ if (main) {
276
+ const treeWalker = document.createTreeWalker(main, NodeFilter.SHOW_TEXT);
277
+ let node;
278
+ while ((node = treeWalker.nextNode())) {
279
+ const text = (node.nodeValue || '').trim();
280
+ if (text.length < 4 || text.length > 253) continue;
281
+ pushIfDomain(text);
282
+ }
283
+ }
284
+
285
+ return { reason: 'ok', domains: Array.from(out).sort() };
286
+ })()`;
287
+
288
+ interface ScrapeOutcome {
289
+ reason: "ok" | "no-account-id";
290
+ domains: string[];
291
+ }
292
+
293
+ async function scrapeDomains(cdp: CdpClient): Promise<string[]> {
294
+ const deadline = Date.now() + SCRAPE_POLL_MS;
295
+ let lastOutcome: ScrapeOutcome | null = null;
296
+ while (Date.now() < deadline) {
297
+ try {
298
+ const outcome = await evaluate<ScrapeOutcome>(cdp, SCRAPE_EXPRESSION);
299
+ lastOutcome = outcome;
300
+ if (outcome.reason === "ok" && outcome.domains.length > 0) {
301
+ logPhase(`phase=dom-scrape-complete result=ok count=${outcome.domains.length}`);
302
+ return outcome.domains;
303
+ }
304
+ // `ok` with zero domains is ambiguous during SPA hydration — keep
305
+ // polling in case the table renders after a data fetch. The final
306
+ // iteration's zero result is the empty-account signal.
307
+ } catch (err) {
308
+ logPhase(`phase=scrape-retry err="${(err instanceof Error ? err.message : String(err)).slice(0, 120)}"`);
309
+ }
310
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
311
+ }
312
+ // Poll exhausted. Distinguish genuine empty account from selector drift by
313
+ // dumping the body HTML (bounded) for manual inspection, then reporting
314
+ // empty with the diagnostic enum. The script still exits 0 — the caller
315
+ // treats empty as a valid signal and shows the "add a domain" empty state.
316
+ try {
317
+ const html = await evaluate<string>(cdp, "document.documentElement.outerHTML.slice(0, 100000)");
318
+ // CONFIG_DIR is set by list-cf-domains.sh before the spawn. A silent
319
+ // fallback would dump logs into the wrong brand's directory on a Real
320
+ // Agent install — a silent-miswrite masking the wrapper-side break it
321
+ // came from. Per Task 473 doctrine: loud-fail on absent runtime-derived
322
+ // values.
323
+ const configDir = process.env.CONFIG_DIR;
324
+ if (!configDir) {
325
+ throw new Error("CONFIG_DIR env var not set by wrapper — refusing to guess brand log directory");
326
+ }
327
+ const logDir = resolve(homedir(), configDir, "logs");
328
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
329
+ const dumpPath = resolve(logDir, `list-cf-domains-${ts}.html`);
330
+ await writeFile(dumpPath, typeof html === "string" ? html : String(html), "utf-8");
331
+ logPhase(`phase=dom-scrape-complete result=empty-or-drift dump=${dumpPath} lastReason=${lastOutcome?.reason ?? "unknown"}`);
332
+ } catch (err) {
333
+ logPhase(`phase=dom-scrape-complete result=empty-or-drift dump=failed lastReason=${lastOutcome?.reason ?? "unknown"} err="${(err instanceof Error ? err.message : String(err)).slice(0, 120)}"`);
334
+ }
335
+ return [];
336
+ }
337
+
338
+ async function navigate(cdp: CdpClient, url: string): Promise<void> {
339
+ const result = await cdp.send<{ errorText?: string }>("Page.navigate", { url });
340
+ if (result?.errorText) die("navigate-failed", `Page.navigate errorText="${result.errorText}"`);
341
+ }
342
+
343
+ async function waitForDocumentReady(cdp: CdpClient): Promise<void> {
344
+ const deadline = Date.now() + NAVIGATE_TIMEOUT_MS;
345
+ while (Date.now() < deadline) {
346
+ const state = await evaluate<string>(cdp, "document.readyState");
347
+ if (state === "complete" || state === "interactive") return;
348
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
349
+ }
350
+ die("navigate-failed", "document.readyState did not reach complete/interactive");
351
+ }
352
+
353
+ async function main(): Promise<void> {
354
+ logPhase(`phase=script-start cdp=${CDP_HOST}:${CDP_PORT}`);
355
+
356
+ await cdpVersion();
357
+ logPhase(`phase=cdp-connect result=ok`);
358
+
359
+ const target = await createTarget();
360
+ logPhase(`phase=target-created targetId=${target.id.slice(0, 8)}…`);
361
+
362
+ let cdp: CdpClient;
363
+ try {
364
+ cdp = await CdpClient.connect(target.webSocketDebuggerUrl);
365
+ } catch (err) {
366
+ await closeTarget(target.id);
367
+ die("ws-connect-failed", err instanceof Error ? err.message : String(err));
368
+ }
369
+
370
+ try {
371
+ await cdp.send("Page.enable");
372
+ await cdp.send("Runtime.enable");
373
+
374
+ logPhase(`phase=dashboard-nav-start url=https://dash.cloudflare.com/`);
375
+ await navigate(cdp, "https://dash.cloudflare.com/");
376
+ await waitForDocumentReady(cdp);
377
+
378
+ const accountId = await waitForSignedIn(cdp);
379
+
380
+ const overviewUrl = `https://dash.cloudflare.com/${accountId}/domains/overview`;
381
+ logPhase(`phase=table-wait url=${overviewUrl}`);
382
+ await navigate(cdp, overviewUrl);
383
+ await waitForDocumentReady(cdp);
384
+
385
+ const domains = await scrapeDomains(cdp);
386
+
387
+ logPhase(`phase=target-closed`);
388
+ process.stdout.write(JSON.stringify(domains) + "\n");
389
+ logPhase(`phase=script-exit code=0 count=${domains.length}`);
390
+ } catch (err) {
391
+ // Only runtime errors that escaped the typed-reason paths land here.
392
+ die("runtime-error", err instanceof Error ? err.message : String(err));
393
+ } finally {
394
+ cdp.close();
395
+ await closeTarget(target.id);
396
+ }
397
+ }
398
+
399
+ main().catch((err: unknown) => {
400
+ die("runtime-error", err instanceof Error ? err.message : String(err));
401
+ });
@@ -168,11 +168,73 @@ if [ ! -f "${CFG_DIR}/cert.pem" ]; then
168
168
  echo "ERROR: CDP PUT /json/new?<url> returned empty — Chromium rejected the navigate." >&2
169
169
  exit 1
170
170
  fi
171
- phase_line setup-tunnel step=browser-drive result=accepted
171
+ # Extract the CDP target id so _cdp-authorize.mjs can open a debugger
172
+ # WebSocket against the correct tab. The PUT response is a JSON object
173
+ # describing the newly-created target; `.id` is the only stable field
174
+ # across Chromium versions that works for `/json/list` lookup.
175
+ CDP_TARGET_ID="$(printf '%s' "${CDP_RESP}" | jq -r '.id // empty' 2>/dev/null || true)"
176
+ if [ -z "${CDP_TARGET_ID}" ]; then
177
+ kill "${CF_PIPELINE_PID}" 2>/dev/null || true
178
+ phase_line setup-tunnel step=browser-drive result=error reason=cdp-target-id-missing \
179
+ resp_head="$(printf '%s' "${CDP_RESP}" | head -c 200)"
180
+ echo "ERROR: CDP PUT /json/new?<url> returned a body without an .id field — Chromium protocol drift?" >&2
181
+ exit 1
182
+ fi
183
+ phase_line setup-tunnel step=browser-drive result=accepted target_id="${CDP_TARGET_ID}"
184
+
185
+ # Task 588: drive the Authorize click via CDP WebSocket instead of waiting
186
+ # for a human. The helper shares a directory with this script; same
187
+ # readlink-resolve pattern as _stream-log.sh so the symlink install path
188
+ # (packages/create-maxy installs ~/setup-tunnel.sh as a symlink) doesn't
189
+ # point dirname at $HOME.
190
+ AUTH_HELPER="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/_cdp-authorize.mjs"
191
+ if [ ! -x "${AUTH_HELPER}" ]; then
192
+ kill "${CF_PIPELINE_PID}" 2>/dev/null || true
193
+ phase_line setup-tunnel step=browser-drive result=error reason=cdp-helper-missing \
194
+ path="${AUTH_HELPER}"
195
+ echo "ERROR: _cdp-authorize.mjs helper missing or not executable at ${AUTH_HELPER}" >&2
196
+ exit 1
197
+ fi
198
+ # tee_subprocess pipes the helper's cdp-authorize result= line into the
199
+ # stream log under the setup-tunnel:cdp-click tag so the chat UI renders
200
+ # the click outcome in real time. Exit code drives control flow.
201
+ if tee_subprocess setup-tunnel:cdp-click -- \
202
+ node "${AUTH_HELPER}" "${CDP_TARGET_ID}"; then
203
+ phase_line setup-tunnel step=oauth-login result=authorize-clicked \
204
+ target_id="${CDP_TARGET_ID}"
205
+ else
206
+ CLICK_RC=$?
207
+ kill "${CF_PIPELINE_PID}" 2>/dev/null || true
208
+ # Helper exit codes: 1=authorize-button-not-found (loud, expected failure
209
+ # mode when the VNC browser isn't signed into Cloudflare), 2=cdp-ws-unreachable,
210
+ # 3=target-not-found, 4=click-evaluate-threw, 5=protocol-error. All are
211
+ # final — no retry, no silent fallback. Map the exit code 1:1 to a reason
212
+ # string so the stream-log `reason=<…>` is greppable by exact cause; a
213
+ # single catch-all reason would defeat the observability contract.
214
+ case "${CLICK_RC}" in
215
+ 1) CLICK_REASON=authorize-button-not-found ;;
216
+ 2) CLICK_REASON=cdp-ws-unreachable ;;
217
+ 3) CLICK_REASON=target-not-found ;;
218
+ 4) CLICK_REASON=click-evaluate-threw ;;
219
+ 5) CLICK_REASON=protocol-error ;;
220
+ *) CLICK_REASON="unknown-exit-${CLICK_RC}" ;;
221
+ esac
222
+ phase_line setup-tunnel step=browser-drive result=error \
223
+ reason="${CLICK_REASON}" click_rc="${CLICK_RC}"
224
+ echo "ERROR: CDP-driven Authorize click failed (helper exit=${CLICK_RC}, reason=${CLICK_REASON})." >&2
225
+ echo " If the VNC browser is not signed into Cloudflare, sign in manually," >&2
226
+ echo " close the sign-in tab, and re-run setup — or run ~/reset-tunnel.sh first." >&2
227
+ exit 1
228
+ fi
172
229
 
173
230
  # Wait for cert.pem to land — cloudflared writes to ~/.cloudflared/cert.pem
174
- # regardless of --origincert, so watch the canonical location.
175
- LOGIN_TIMEOUT="${SETUP_TUNNEL_LOGIN_TIMEOUT:-180}"
231
+ # regardless of --origincert, so watch the canonical location. Task 588
232
+ # collapses the pre-click 180 s human-latency window to a 20 s deterministic
233
+ # round-trip window (Cloudflare → cloudflared webhook after consent). The
234
+ # 2-second heartbeat inside the loop is the observability contract for
235
+ # this bounded wait — no form-spawned script is allowed a silent poll of
236
+ # more than ~2 s per criterion 3 of Task 588.
237
+ LOGIN_TIMEOUT="${SETUP_TUNNEL_LOGIN_TIMEOUT:-20}"
176
238
  LOGIN_WAIT=0
177
239
  while [ ! -f "${HOME}/.cloudflared/cert.pem" ]; do
178
240
  if ! kill -0 "${CF_PIPELINE_PID}" 2>/dev/null; then
@@ -192,6 +254,14 @@ if [ ! -f "${CFG_DIR}/cert.pem" ]; then
192
254
  echo "ERROR: Timed out after ${LOGIN_WAIT}s waiting for cert.pem to land." >&2
193
255
  exit 1
194
256
  fi
257
+ # Heartbeat every 2 s after the click. t=0 is the authorize-clicked
258
+ # phase line above; the first heartbeat fires at t=2. Without this line
259
+ # the tailer sees silence for the full 1-20 s round-trip — the exact
260
+ # state Task 588 forbids.
261
+ if [ "${LOGIN_WAIT}" -gt 0 ] && [ $((LOGIN_WAIT % 2)) -eq 0 ]; then
262
+ phase_line setup-tunnel step=oauth-login result=awaiting-cert \
263
+ elapsed="${LOGIN_WAIT}s" timeout="${LOGIN_TIMEOUT}s"
264
+ fi
195
265
  sleep 1
196
266
  LOGIN_WAIT=$((LOGIN_WAIT + 1))
197
267
  done
@@ -84,7 +84,9 @@ Example:
84
84
 
85
85
  ## 4. Dashboard guidance — `references/dashboard-guide.md`
86
86
 
87
- Use this when the operator needs to do something only the Cloudflare dashboard can do: sign in, switch accounts, add a site, edit an apex CNAME, verify zone nameservers, delete a tunnel after stopping its replicas. The guide has one numbered click-path per operation. Quote the relevant click-path verbatim — the operator follows it in the browser; no agent browser automation is involved.
87
+ Use this when the operator needs to do something only the Cloudflare dashboard can do: sign in, switch accounts, add a site, edit an apex CNAME, verify zone nameservers, delete a tunnel after stopping its replicas. The guide has one numbered click-path per operation. Quote the relevant click-path verbatim — the operator follows it in the browser. The agent does not drive dashboard mutations via Playwright or Chrome DevTools.
88
+
89
+ The single exception is `list-cf-domains.sh` (Task 589), which reads the domains attached to the logged-in account to populate the `cloudflare-setup-form` dropdowns. That script is deterministic (bash + raw CDP, no LLM in the decision path), invoked only by the `/api/admin/cloudflare/domains` route, and produces only a JSON `string[]` on stdout; no dashboard state is changed. Any dashboard scrape that is not this exact script is forbidden — the agent does not extend this carve-out to new scripts it writes, hypothesises, or finds. Adding a new sanctioned scrape surface requires a code change reviewed as a doctrine change, not an inline agent decision.
88
90
 
89
91
  ---
90
92
 
@@ -96,6 +98,6 @@ When the operator's request touches Cloudflare, the agent's permitted actions ar
96
98
  - Quote `references/manual-setup.md`, `references/reset-guide.md`, or `references/dashboard-guide.md`.
97
99
  - Verify reachability via plain HTTP (`curl -I https://<hostname>`).
98
100
 
99
- The agent does not drive Cloudflare's dashboard via Playwright or Chrome DevTools. The agent does not synthesise `cloudflared` flag combinations from web search or prior training. The agent does not call Cloudflare API or SDK from any language. The agent does not write or mutate `cert.pem`, `tunnel.state`, `config.yml`, or `alias-domains.json` directly — `setup-tunnel.sh` and the operator manage those files.
101
+ The agent does not drive Cloudflare dashboard mutations via Playwright or Chrome DevTools. The single sanctioned read-only scrape is `list-cf-domains.sh`, invoked only by the `/api/admin/cloudflare/domains` route — the LLM is not in its decision path. No other dashboard-automation surface is permitted; the agent does not generalise this exception to new scripts. The agent does not synthesise `cloudflared` flag combinations from web search or prior training. The agent does not call Cloudflare API or SDK from any language. The agent does not write or mutate `cert.pem`, `tunnel.state`, `config.yml`, or `alias-domains.json` directly — `setup-tunnel.sh` and the operator manage those files.
100
102
 
101
103
  When a sanctioned surface fails, the agent reports the failure with the exact output, cites the recovery step from `references/reset-guide.md`, and stops. Improvisation — "let me try a different flag" or "let me check the dashboard myself" — is the behaviour this rule exists to prevent. See IDENTITY.md § Cloudflare operations for the unconditional form.
@@ -8,6 +8,7 @@ Each installation has its own Cloudflare account. Sign-in is OAuth in the device
8
8
  |------|--------|
9
9
  | **Product identity** (Maxy vs Real Agent) | `brand.json` (`productName`, `configDir`) — known at install. |
10
10
  | **Cloudflare account identity** | `cert.pem` from OAuth. One account per brand per device. |
11
+ | **Domain scope** (which zones the operator can route) | Live Cloudflare dashboard at form-render time via `list-cf-domains.sh`, not `brand.json`. Brand identity has no authority over which domains the operator's CF account holds. |
11
12
  | **Local tunnel state** | `~/{configDir}/cloudflared/` — `cert.pem`, `<UUID>.json`, `config.yml`, `tunnel.state`, `alias-domains.json`. |
12
13
 
13
14
  There is no token-based auth for the operator-owned path (Mode A). To switch Cloudflare accounts, run `reset-tunnel.sh` (which deletes the cert and every tunnel on the current account), then run `setup-tunnel.sh` again — `cloudflared tunnel login` inside the setup script will pick a fresh account when you sign in.
@@ -66,6 +66,15 @@ The logs will show which service failed to start and why. Common causes:
66
66
  - **Port 19200 already in use** — check for another process: `lsof -i :19200`
67
67
  - **Claude OAuth expired** — the next admin session will prompt you to re-authenticate
68
68
 
69
+ ## Systemd units on each device
70
+
71
+ Each Maxy device runs two independent `--user` systemd units:
72
+
73
+ - `maxy-ui.service` — the admin + public HTTP server (default port 19200). Restarted by the upgrade flow; short downtime is expected during steps 8→12 of an upgrade.
74
+ - `maxy-ttyd.service` — a persistent PTY over WebSocket on `127.0.0.1:7681` attached to a tmux session named `maxy-pty`. Independent of `maxy-ui` — restarting the UI does not interrupt shell work, which is exactly the property the upgrade flow relies on to survive its own admin-server restart.
75
+
76
+ If the Software Update window's terminal goes blank and does not reconnect, `sudo systemctl --user status maxy-ttyd` is the first thing to check. Restarting the unit (`sudo systemctl --user restart maxy-ttyd`) is safe: the tmux session survives because `tmux new-session -A -s maxy-pty` is idempotent.
77
+
69
78
  ## Upgrading
70
79
 
71
80
  To upgrade Maxy to the latest version, ask Maxy: "Upgrade Maxy." The platform checks the current device identity (hostname and port via `system-status`), then re-runs the installer with explicit `--hostname` and `--port` flags to preserve them across the upgrade.
@@ -76,4 +85,6 @@ The docs plugin (this plugin) is upgraded in the same step — you always have t
76
85
 
77
86
  Maxy checks for new releases on every admin session start — whenever you log in, reload the page, or return to the admin chat. When a newer version is available, the Software Update window opens automatically showing your current and the latest version, with a one-click Upgrade button. Dismissing the window (click outside or the close button) defers the alert until your next login or reload; no alert is shown when you are already on the latest version.
78
87
 
88
+ The upgrade runs inside a live terminal embedded in the Software Update window — you see each installation step stream as it happens, and any password prompts from `sudo` appear directly in the terminal for you to answer. Closing the window does not cancel the upgrade; re-opening it reattaches to the same shell so you can see what happened while disconnected.
89
+
79
90
  The header menu's version indicator still reflects real-time status: a green dot means you are up to date, and an accent-coloured dot means an upgrade is available. Opening the menu refreshes the version check, so a long-lived session can still surface an upgrade that became available after login without reloading the page.
@@ -40,6 +40,8 @@ When you first open the admin interface:
40
40
 
41
41
  This setup is resumable — if you close the browser mid-setup, Maxy picks up where you left off next time.
42
42
 
43
+ After install, a live admin terminal is available inside the Software Update window — your Pi's shell, accessible through the admin UI, for upgrades and any other shell work without needing to SSH.
44
+
43
45
  ## How to Use Maxy
44
46
 
45
47
  Conversation is the only interface. Type or speak what you need:
@@ -84,9 +84,9 @@ Ask naturally:
84
84
 
85
85
  Maxy answers relational questions — "list all my people", "how many tasks do I have", "find the person with email X", "show me the 20 most recently created nodes" — via direct read-only Cypher against your Neo4j. This is faster and more precise than semantic search when the question is "the exact set where", not "things similar to".
86
86
 
87
- You can also open the Neo4j Browser at any time from the burger menu → **Graph**. Sign in with your Neo4j username (`neo4j`) and password (stored in `config/.neo4j-password` on the device). Run `MATCH (n) RETURN n LIMIT 25` for a visual overview of your graph, or write your own Cypher for ad-hoc exploration.
87
+ You can also open a visual view of your graph at any time from the burger menu → **Graph**. It renders up to 200 of your most recently updated nodes as a force-directed map, coloured by label (Person, Service, KnowledgeDocument, Task, …). Click a node to see its properties; type in the search box to highlight matches.
88
88
 
89
- The browser reaches only your own brand's Neo4j — a Maxy device and a Real Agent device share no graph state even when on the same laptop.
89
+ The page reads only your own brand's Neo4j — a Maxy device and a Real Agent device share no graph state even when on the same laptop. No credentials are required; the view inherits your admin session.
90
90
 
91
91
  ## Privacy
92
92
 
@@ -62,6 +62,12 @@ There is no dashboard, no settings panel, no menus. Everything is done through c
62
62
 
63
63
  The chat input auto-grows as you type — it expands to fit your message and shrinks back when you delete text. You can also drag the resize handle above the input to set a custom height.
64
64
 
65
+ ## Admin Terminal
66
+
67
+ The admin UI includes a live terminal surface that opens a real shell on your Pi in the browser, reached via the Software Update modal. Under the hood it's a WebSocket (`/admin/terminal/ws`) attached through `@xterm/xterm` to `ttyd` on `127.0.0.1:7681`, backed by a persistent tmux session named `maxy-pty`.
68
+
69
+ The tmux session outlives admin-server restarts — running an upgrade inside this terminal means you see the live shell output continuously, even through the admin server's own restart mid-upgrade. Closing the browser tab does not kill the running work; re-opening the Software Update window reattaches to the same session and scrollback shows everything that happened in the meantime. Password-protected `sudo` prompts appear natively inside the terminal, and the password you type never leaves the Pi — the admin-server proxy is a raw byte pipe that never inspects frame payloads.
70
+
65
71
  ## AI Content Provenance
66
72
 
67
73
  When your public agent sends a message to someone — via email, WhatsApp, Telegram, or SMS — the platform automatically includes a brief disclosure that the content was generated by AI. This is transparent and cannot be turned off.
@@ -90,3 +90,19 @@ If the tunnel won't reconnect, re-run the Cloudflare setup: ask Maxy "Reconnect
90
90
  If the initial Cloudflare login fails during setup, Maxy will fall back to asking you for a connection key. You can create one in the Cloudflare dashboard (Maxy will guide you through this in the browser).
91
91
 
92
92
  **If you switched Cloudflare accounts or are stuck on the wrong one:** ask Maxy "Reset my Cloudflare login and start over." This is a clean reset — Maxy clears every stored credential, then opens a fresh browser sign-in. The next sign-in binds to whichever Cloudflare account you choose, with no risk of the previous account's stored credentials silently coming back.
93
+
94
+ ---
95
+
96
+ ## Admin Terminal Stuck Disconnected After Upgrade
97
+
98
+ **Symptom:** The Software Update window's terminal goes blank, says "server restart detected — reconnecting…" and never comes back, or the window sits empty after you open it.
99
+
100
+ **Check:** `sudo systemctl --user status maxy-ttyd` — the unit should be `active (running)`. If it is, the admin-server proxy should reattach automatically within a few seconds.
101
+
102
+ **Fix:** Restart the unit:
103
+
104
+ ```bash
105
+ sudo systemctl --user restart maxy-ttyd
106
+ ```
107
+
108
+ This is safe — the tmux session survives the ttyd restart because `tmux new-session -A -s maxy-pty` is idempotent. Any upgrade or other shell command still running inside the session continues uninterrupted; ttyd simply re-attaches to it. If the session itself is wedged, you can kill it explicitly with `tmux kill-session -t maxy-pty` and the next attach will create a fresh one.
@@ -17,11 +17,11 @@ filter in the query.
17
17
 
18
18
  For visual exploration ("can I see this graphically?", "show me the graph"),
19
19
  tell the user to open the **Graph** menu item in the burger menu — it opens
20
- Neo4j Browser pointed at their own brand's graph. Do not build ad-hoc HTML
21
- visualizations, do not start a static file server, do not `Write` an
22
- `.html` file and navigate Playwright to it. The one-sentence reply
23
- ("Open the Graph menu item in the top-right — it opens a visual browser
24
- for your graph") is the correct answer.
20
+ a Maxy-native force-directed view of their own brand's subgraph (Task 587).
21
+ Do not build ad-hoc HTML visualizations, do not start a static file server,
22
+ do not `Write` an `.html` file and navigate Playwright to it. The one-sentence
23
+ reply ("Open the Graph menu item in the top-right — it opens a visual view
24
+ of your graph") is the correct answer.
25
25
 
26
26
  ## When the graph tools are absent
27
27
 
@@ -0,0 +1 @@
1
+ set -g history-limit 50000
@@ -0,0 +1,20 @@
1
+ [Unit]
2
+ Description=Maxy admin terminal (ttyd + tmux) — persistent PTY for admin UI
3
+ After=default.target
4
+
5
+ [Service]
6
+ Type=simple
7
+ # -p 7681 listen on 127.0.0.1:7681 (same-origin proxy in maxy-ui binds to it)
8
+ # -i 127.0.0.1 reject non-loopback connections at the ttyd layer as well
9
+ # -W writable (allow client → server input bytes)
10
+ # tmux new-session -A -s maxy-pty attach if session exists, create otherwise.
11
+ # Lifetime = user session / device lifetime. Outlives maxy-ui restarts because
12
+ # this unit has no After=maxy-ui.service and no Requires= — independent.
13
+ # -x 200 -y 50 initial geometry; xterm.js fit-addon drives runtime resizes.
14
+ ExecStart=/usr/bin/ttyd -p 7681 -i 127.0.0.1 -W tmux new-session -A -s maxy-pty -x 200 -y 50
15
+ Environment=TERM=xterm-256color
16
+ Restart=always
17
+ RestartSec=2
18
+
19
+ [Install]
20
+ WantedBy=default.target