@rubytech/create-realagent 1.0.853 → 1.0.855

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 (42) hide show
  1. package/dist/index.js +9 -3
  2. package/package.json +1 -1
  3. package/payload/platform/lib/persistent-components/dist/index.d.ts +21 -0
  4. package/payload/platform/lib/persistent-components/dist/index.d.ts.map +1 -0
  5. package/payload/platform/lib/persistent-components/dist/index.js +32 -0
  6. package/payload/platform/lib/persistent-components/dist/index.js.map +1 -0
  7. package/payload/platform/lib/persistent-components/src/index.ts +28 -0
  8. package/payload/platform/lib/persistent-components/tsconfig.json +8 -0
  9. package/payload/platform/package.json +2 -2
  10. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  11. package/payload/platform/plugins/admin/hooks/__tests__/playwright-file-guard.test.sh +278 -0
  12. package/payload/platform/plugins/admin/hooks/playwright-file-guard.sh +204 -20
  13. package/payload/platform/plugins/admin/mcp/dist/index.js +40 -1
  14. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  15. package/payload/platform/plugins/cloudflare/references/manual-setup.md +2 -0
  16. package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.sh +39 -10
  17. package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.ts +112 -20
  18. package/payload/platform/plugins/docs/references/cloudflare.md +1 -0
  19. package/payload/platform/plugins/docs/references/deployment.md +2 -0
  20. package/payload/platform/plugins/docs/references/getting-started.md +2 -0
  21. package/payload/platform/plugins/docs/references/platform.md +1 -1
  22. package/payload/platform/plugins/docs/references/troubleshooting.md +10 -0
  23. package/payload/platform/scripts/admin-persist-audit.ts +191 -0
  24. package/payload/platform/scripts/component-knowledgedoc-backfill.ts +214 -0
  25. package/payload/platform/scripts/installer-device-verify.sh +17 -4
  26. package/payload/platform/templates/specialists/agents/content-producer.md +2 -2
  27. package/payload/server/chunk-CFNSKDGA.js +667 -0
  28. package/payload/server/chunk-DC6DWYZJ.js +1603 -0
  29. package/payload/server/chunk-LTB5SSQW.js +10889 -0
  30. package/payload/server/chunk-MN2LGNUB.js +2143 -0
  31. package/payload/server/client-pool-AMT2W3II.js +34 -0
  32. package/payload/server/cloudflare-task-tracker-LJ4SMK2D.js +20 -0
  33. package/payload/server/maxy-edge.js +3 -3
  34. package/payload/server/public/assets/admin-Cpk5cT4I.js +352 -0
  35. package/payload/server/public/assets/public-DApUXgoq.js +5 -0
  36. package/payload/server/public/assets/useVoiceRecorder-CI8GpxfU.js +36 -0
  37. package/payload/server/public/index.html +2 -2
  38. package/payload/server/public/public.html +2 -2
  39. package/payload/server/server.js +543 -354
  40. package/payload/server/public/assets/admin-Dyl8uNxX.js +0 -352
  41. package/payload/server/public/assets/public-B_PNZUph.js +0 -5
  42. package/payload/server/public/assets/useVoiceRecorder-fD0IWzJj.js +0 -36
@@ -5,11 +5,19 @@
5
5
  # token on stderr tagged `[list-cf-domains]`.
6
6
  #
7
7
  # Usage:
8
- # list-cf-domains.sh [<brand>]
8
+ # list-cf-domains.sh <brand>
9
9
  #
10
- # The brand arg (default: maxy) names the `${HOME}/.${BRAND}/logs/` directory
11
- # where selector-drift body dumps land. The script does not read brand.json
12
- # the directory is the only brand-derived path it needs.
10
+ # Task 954: brand arg is REQUIRED the prior silent default (string
11
+ # fallback to "maxy" when $1 was empty) made every non-Maxy brand's CF
12
+ # setup form fail on first use because the wrapper's child node helper
13
+ # read the Maxy CDP port from a hardcoded fallback. Missing arg now exits
14
+ # 1 with `phase=error reason=brand-arg-missing`.
15
+ #
16
+ # The wrapper also resolves and exports MAXY_PLATFORM_ROOT from its own
17
+ # script location so the .ts helper can read `<root>/config/brand.json` for
18
+ # the brand's `cdpPort` (Task 924's source of truth). Direct-SSH invocation
19
+ # works because the script lives at `<install>/platform/plugins/cloudflare/
20
+ # scripts/`, making platform root three dirs up from the resolved script.
13
21
  #
14
22
  # Runtime: Node 22's `--experimental-strip-types` runs the .ts helper
15
23
  # directly, so no tsx / playwright / ws dependency exists.
@@ -24,16 +32,29 @@ set -euo pipefail
24
32
  # Resolve symlinks before dirname — ~/list-cf-domains.sh is installed as a
25
33
  # symlink into $HOME, so the raw BASH_SOURCE[0] points at $HOME, not the
26
34
  # scripts directory where _stream-log.sh lives.
27
- source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/_stream-log.sh"
35
+ SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
36
+ source "${SCRIPT_DIR}/_stream-log.sh"
28
37
  require_stream_log_path list-cf-domains
29
38
 
30
- BRAND="${1:-maxy}"
39
+ if [ "$#" -lt 1 ] || [ -z "${1:-}" ]; then
40
+ phase_line list-cf-domains phase=error reason=brand-arg-missing
41
+ exit 1
42
+ fi
43
+
44
+ BRAND="${1}"
31
45
  CONFIG_DIR=".${BRAND}"
32
46
  mkdir -p "${HOME}/${CONFIG_DIR}/logs"
33
47
 
48
+ # MAXY_PLATFORM_ROOT is set by the systemd service when the admin server
49
+ # spawns this script via runFormSpawn. Direct-SSH invocation has no such env
50
+ # so derive it from the resolved script location: scripts/ → cloudflare/ →
51
+ # plugins/ → platform → install root (three dirs up).
52
+ if [ -z "${MAXY_PLATFORM_ROOT:-}" ]; then
53
+ MAXY_PLATFORM_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
54
+ fi
55
+
34
56
  phase_line list-cf-domains phase=script-start brand="${BRAND}" config_dir="${CONFIG_DIR}"
35
57
 
36
- SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
37
58
  TS_ENTRY="${SCRIPT_DIR}/list-cf-domains.ts"
38
59
 
39
60
  if [ ! -f "${TS_ENTRY}" ]; then
@@ -47,15 +68,23 @@ fi
47
68
  # (124) from a helper-reported failure (1).
48
69
  HARD_TIMEOUT_SECS=30
49
70
 
50
- # CONFIG_DIR is consumed by the node helper when writing selector-drift dumps
51
- # to ~/.${BRAND}/logs/list-cf-domains-<ts>.html.
71
+ # Env passed through:
72
+ # - CONFIG_DIR — consumed by the node helper when writing selector-drift
73
+ # dumps to ~/.${BRAND}/logs/list-cf-domains-<ts>.html.
74
+ # - BRAND — names the brand for log lines naming the brand on
75
+ # config-class failures (helper does not derive it from
76
+ # CONFIG_DIR to keep the two concerns independent).
77
+ # - MAXY_PLATFORM_ROOT — install root the helper reads `config/brand.json`
78
+ # from for `cdpPort`. Resolved above so direct-SSH
79
+ # invocation matches the systemd-spawned path.
52
80
  #
53
81
  # `node --experimental-strip-types` (stable in Node 22.22) runs the .ts file
54
82
  # natively: type annotations are stripped, no enums / decorators are used, so
55
83
  # no TS type-transform is needed. `--no-warnings` suppresses the experimental
56
84
  # banner which would otherwise leak into stderr and confuse the route parser.
57
85
  set +e
58
- CONFIG_DIR="${CONFIG_DIR}" timeout --preserve-status --signal=TERM "${HARD_TIMEOUT_SECS}" \
86
+ CONFIG_DIR="${CONFIG_DIR}" BRAND="${BRAND}" MAXY_PLATFORM_ROOT="${MAXY_PLATFORM_ROOT}" \
87
+ timeout --preserve-status --signal=TERM "${HARD_TIMEOUT_SECS}" \
59
88
  node --experimental-strip-types --no-warnings "${TS_ENTRY}"
60
89
  EXIT_CODE=$?
61
90
  set -e
@@ -42,19 +42,29 @@
42
42
  // "selector drift" from "empty account" — both shapes arrive via
43
43
  // stdout).
44
44
 
45
- import { appendFileSync } from "node:fs";
45
+ import { appendFileSync, readFileSync } from "node:fs";
46
46
  import { writeFile } from "node:fs/promises";
47
47
  import { resolve } from "node:path";
48
48
  import { homedir } from "node:os";
49
49
  import { fileURLToPath } from "node:url";
50
50
 
51
- // CDP host/port default to the VNC Chromium's localhost bind; env overrides
52
- // exist solely so the vitest regression under platform/ui/__tests__ can force
53
- // a deterministic ECONNREFUSED (point at 127.0.0.1:1 RFC 6335 reserved,
54
- // guaranteed-refused on every platform) without depending on whether a dev
55
- // machine happens to have VNC Chromium running on 9222.
56
- const CDP_HOST = process.env.LIST_CF_DOMAINS_CDP_HOST ?? "127.0.0.1";
57
- const CDP_PORT = Number(process.env.LIST_CF_DOMAINS_CDP_PORT ?? 9222);
51
+ // CDP endpoint is resolved lazily inside `main()` so importing this module
52
+ // for in-process tests (`scrapeCurrentPage` / `scrapeDomains` under JSDOM)
53
+ // does not require an installed brand on disk.
54
+ //
55
+ // Task 954 source-of-truth contract:
56
+ // Runtime: brand.json `cdpPort` at `${MAXY_PLATFORM_ROOT}/config/brand.json`
57
+ // is authoritative. Wrapper exports MAXY_PLATFORM_ROOT and BRAND. Missing
58
+ // env / file / field → loud-fail with one of three named reasons. The
59
+ // pre-Task-954 silent CDP-port default made every non-Maxy brand fail
60
+ // `cdp-unreachable` because the helper hit Maxy's port instead of the
61
+ // brand's; Task 787 / NEO4J_URI sets the precedent that runtime config
62
+ // never falls back silently.
63
+ //
64
+ // Test overrides: when BOTH `LIST_CF_DOMAINS_CDP_HOST` and
65
+ // `LIST_CF_DOMAINS_CDP_PORT` are set, they win over brand.json. The
66
+ // existing ECONNREFUSED vitest forces `127.0.0.1:1` (RFC 6335-reserved)
67
+ // this way without an installed brand. Production never sets these.
58
68
 
59
69
  // Phase budgets total 30s (enforced by the shell wrapper's `timeout`).
60
70
  // Individual steps get sub-budgets so an early stall does not eat the
@@ -72,7 +82,14 @@ type Reason =
72
82
  | "not-signed-in"
73
83
  | "navigate-failed"
74
84
  | "table-wait-timeout"
75
- | "runtime-error";
85
+ | "runtime-error"
86
+ // Task 954 — config-class failures. The first three short-circuit before
87
+ // any CDP work begins; the route maps them to `field='config'` so the
88
+ // form renders a config-card naming the brand + path instead of a Retry
89
+ // button (retrying re-fires the same wrongly-resolved spawn — Task 787
90
+ // pattern).
91
+ | "brand-config-missing"
92
+ | "cdp-port-unresolved";
76
93
 
77
94
  // Sticky flag: once a stream-log write fails (EACCES, ENOENT, …), skip
78
95
  // subsequent file writes for the rest of this process. Repeated appends to an
@@ -120,11 +137,85 @@ function die(reason: Reason, detail = ""): never {
120
137
  process.exit(1);
121
138
  }
122
139
 
123
- async function cdpVersion(): Promise<void> {
140
+ interface CdpEndpoint {
141
+ host: string;
142
+ port: number;
143
+ source: "env-override" | "brand.json";
144
+ }
145
+
146
+ // Task 954 — single source of truth for the CDP endpoint. Called once from
147
+ // `main()` so the JSDOM scrape test (which imports `scrapeCurrentPage`
148
+ // directly without invoking `main`) does not need an installed brand.
149
+ function resolveCdpEndpoint(): CdpEndpoint {
150
+ const envHost = process.env.LIST_CF_DOMAINS_CDP_HOST;
151
+ const envPortRaw = process.env.LIST_CF_DOMAINS_CDP_PORT;
152
+ if (envHost !== undefined && envPortRaw !== undefined) {
153
+ const envPort = Number(envPortRaw);
154
+ if (!Number.isInteger(envPort) || envPort <= 0 || envPort > 65535) {
155
+ die(
156
+ "cdp-port-unresolved",
157
+ `LIST_CF_DOMAINS_CDP_PORT="${envPortRaw}" is not a valid integer port`,
158
+ );
159
+ }
160
+ return { host: envHost, port: envPort, source: "env-override" };
161
+ }
162
+
163
+ const brand = process.env.BRAND;
164
+ if (!brand) {
165
+ die(
166
+ "brand-config-missing",
167
+ "BRAND env var not set by wrapper — refusing to guess brand identity",
168
+ );
169
+ }
170
+ const platformRoot = process.env.MAXY_PLATFORM_ROOT;
171
+ if (!platformRoot) {
172
+ die(
173
+ "brand-config-missing",
174
+ "MAXY_PLATFORM_ROOT env var not set — wrapper must export it before spawn",
175
+ );
176
+ }
177
+ const brandPath = resolve(platformRoot, "config", "brand.json");
178
+ let raw: string;
179
+ try {
180
+ raw = readFileSync(brandPath, "utf-8");
181
+ } catch (err) {
182
+ // Distinct emission (not via `die`) so the path is named on the same line
183
+ // — config-card rendering on the route side keys off it.
184
+ logPhase(
185
+ `phase=error reason=brand-config-missing path=${brandPath} detail="${(err instanceof Error ? err.message : String(err)).replace(/"/g, "'").slice(0, 200)}"`,
186
+ );
187
+ process.exit(1);
188
+ }
189
+ let parsed: Record<string, unknown>;
190
+ try {
191
+ parsed = JSON.parse(raw) as Record<string, unknown>;
192
+ } catch (err) {
193
+ logPhase(
194
+ `phase=error reason=brand-config-missing path=${brandPath} detail="parse failed: ${(err instanceof Error ? err.message : String(err)).replace(/"/g, "'").slice(0, 160)}"`,
195
+ );
196
+ process.exit(1);
197
+ }
198
+ const cdpPort = parsed.cdpPort;
199
+ if (typeof cdpPort !== "number" || !Number.isInteger(cdpPort) || cdpPort <= 0 || cdpPort > 65535) {
200
+ // Names the keys actually present so a schema-drift incident surfaces
201
+ // immediately ("oh, brand.json renamed `cdpPort` to `cdp_port` in r123").
202
+ const keys = Object.keys(parsed).join(",");
203
+ logPhase(
204
+ `phase=error reason=cdp-port-unresolved brand=${brand} json_keys=${keys}`,
205
+ );
206
+ process.exit(1);
207
+ }
208
+ // Host stays 127.0.0.1 on the runtime path — vnc.sh binds Chromium's CDP
209
+ // server to localhost only, by construction. brand.json carries the port,
210
+ // not the host, because the host has never varied across brands.
211
+ return { host: "127.0.0.1", port: cdpPort, source: "brand.json" };
212
+ }
213
+
214
+ async function cdpVersion(host: string, port: number): Promise<void> {
124
215
  const controller = new AbortController();
125
216
  const timer = setTimeout(() => controller.abort(), CONNECT_TIMEOUT_MS);
126
217
  try {
127
- const res = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/version`, { signal: controller.signal });
218
+ const res = await fetch(`http://${host}:${port}/json/version`, { signal: controller.signal });
128
219
  if (!res.ok) die("cdp-unreachable", `HTTP ${res.status}`);
129
220
  } catch (err) {
130
221
  die("cdp-unreachable", err instanceof Error ? err.message : String(err));
@@ -138,12 +229,12 @@ interface CdpTarget {
138
229
  webSocketDebuggerUrl: string;
139
230
  }
140
231
 
141
- async function createTarget(): Promise<CdpTarget> {
232
+ async function createTarget(host: string, port: number): Promise<CdpTarget> {
142
233
  // `PUT /json/new?<url>` returns JSON describing the target. The URL goes
143
234
  // as a query-string argument (not a ?url= key), matching Chromium's CDP
144
235
  // server. about:blank avoids a wasted navigation — the real navigate
145
236
  // happens over WS after we attach.
146
- const endpoint = `http://${CDP_HOST}:${CDP_PORT}/json/new?about:blank`;
237
+ const endpoint = `http://${host}:${port}/json/new?about:blank`;
147
238
  let res: Response;
148
239
  try {
149
240
  res = await fetch(endpoint, { method: "PUT", signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) });
@@ -158,9 +249,9 @@ async function createTarget(): Promise<CdpTarget> {
158
249
  return target as CdpTarget;
159
250
  }
160
251
 
161
- async function closeTarget(id: string): Promise<void> {
252
+ async function closeTarget(host: string, port: number, id: string): Promise<void> {
162
253
  try {
163
- await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/close/${id}`, {
254
+ await fetch(`http://${host}:${port}/json/close/${id}`, {
164
255
  signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS),
165
256
  });
166
257
  } catch {
@@ -602,19 +693,20 @@ async function waitForDocumentReady(cdp: CdpClient): Promise<void> {
602
693
  }
603
694
 
604
695
  async function main(): Promise<void> {
605
- logPhase(`phase=script-start cdp=${CDP_HOST}:${CDP_PORT}`);
696
+ const endpoint = resolveCdpEndpoint();
697
+ logPhase(`phase=script-start cdp=${endpoint.host}:${endpoint.port} source=${endpoint.source}`);
606
698
 
607
- await cdpVersion();
699
+ await cdpVersion(endpoint.host, endpoint.port);
608
700
  logPhase(`phase=cdp-connect result=ok`);
609
701
 
610
- const target = await createTarget();
702
+ const target = await createTarget(endpoint.host, endpoint.port);
611
703
  logPhase(`phase=target-created targetId=${target.id.slice(0, 8)}…`);
612
704
 
613
705
  let cdp: CdpClient;
614
706
  try {
615
707
  cdp = await CdpClient.connect(target.webSocketDebuggerUrl);
616
708
  } catch (err) {
617
- await closeTarget(target.id);
709
+ await closeTarget(endpoint.host, endpoint.port, target.id);
618
710
  die("ws-connect-failed", err instanceof Error ? err.message : String(err));
619
711
  }
620
712
 
@@ -643,7 +735,7 @@ async function main(): Promise<void> {
643
735
  die("runtime-error", err instanceof Error ? err.message : String(err));
644
736
  } finally {
645
737
  cdp.close();
646
- await closeTarget(target.id);
738
+ await closeTarget(endpoint.host, endpoint.port, target.id);
647
739
  }
648
740
  }
649
741
 
@@ -9,6 +9,7 @@ Each installation has its own Cloudflare account. Sign-in is OAuth in the device
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
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. When the scrape returns an unexpected count (e.g. 1 on a two-zone account), the stream log's per-poll `phase=dom-scrape-poll n=<k> count=<n> domains=[…]` trajectory + the on-disk HTML dump at `~/{configDir}/logs/list-cf-domains-<ts>-count<n>-<mode>-pid<pid>.html` (earlier platform fixes — written on every scrape outcome, not just empty ones) give the operator everything they need to triage the cause without re-running. |
12
+ | **CDP port the scrape attaches to** | `brand.json` (`cdpPort`, stamped at install time) — the same brand-scoped port `vnc.sh` binds Chromium to. `list-cf-domains.sh <brand>` requires the brand arg; the helper reads `${MAXY_PLATFORM_ROOT}/config/brand.json` (no silent default). Missing brand arg, missing brand.json, or missing `cdpPort` field each exit 1 with one of three named reasons (`brand-arg-missing`, `brand-config-missing`, `cdp-port-unresolved`); the route maps all three to `field=config` so the form renders a config card naming the brand instead of a Retry button — retrying would re-fire the same wrongly-resolved spawn. |
12
13
  | **Local tunnel state** | `~/{configDir}/cloudflared/` — `cert.pem`, `<UUID>.json`, `config.yml`, `tunnel.state`, `alias-domains.json`. |
13
14
 
14
15
  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.
@@ -115,6 +115,8 @@ The installer detects this case during system-dependency setup and replaces the
115
115
 
116
116
  The post-install acceptance gate at `platform/scripts/test-laptop-vnc-boot.sh` runs four checks: the configured Chromium realpath is non-snap, the path is absolute and executable, the per-brand CDP port returns Chromium version JSON, and the VNC boot log ends with `VNC + browser stack running` with no preceding `Chromium failed to start`. The gate runs automatically at the end of every install on Linux; manual invocation is `MAXY_PLATFORM_ROOT=<install-dir>/platform <install-dir>/platform/scripts/test-laptop-vnc-boot.sh`.
117
117
 
118
+ A separate operator-side harness at `platform/scripts/installer-device-verify.sh <published-version>` runs after every npm publish to confirm the installer reaches a terminal-success marker on each device in the operator's manifest. Two markers are accepted because the installer's CDP probe behaves differently per `DISPLAY_MODE`: `Browser automation ready (CDP connected)` on Pi (virtual display, persistent Chromium) and `[cdp-check] skipped reason=native-display` on laptop (native display, on-demand Chromium). Either is a pass. The harness is operator-only — end users do not run it.
119
+
118
120
  ## Running multiple brands on one device
119
121
 
120
122
  A single Pi or laptop can host more than one brand (for example Maxy and Real Agent) side by side. Each brand runs as its own service on its own port, with its own install directory and its own data. Installing one brand does not touch the other.
@@ -60,6 +60,8 @@ When the text field is empty, a microphone button appears in place of the send b
60
60
 
61
61
  Voice recording requires a secure connection (HTTPS). When accessing {{productName}} over the local network via HTTP, use the tunnel URL for voice notes.
62
62
 
63
+ You can also drop, paste, or pick an audio file (`.opus`, `.ogg`, `.m4a`, `.mp3`, `.wav`, `.webm`) into the chat composer — for example a voice note forwarded from WhatsApp. The file is transcribed the same way the in-browser recording is, and only the transcript reaches {{productName}}; the audio itself is discarded after transcription.
64
+
63
65
  ## What {{productName}} Remembers
64
66
 
65
67
  {{productName}} maintains a memory graph of everything important: contacts, conversations, preferences, relationships, and context. When you tell {{productName}} something, it stores it. When you ask about something later, it retrieves it.
@@ -65,7 +65,7 @@ There is no dashboard, no settings panel, no menus. Everything is done through c
65
65
 
66
66
  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.
67
67
 
68
- The admin interface is a three-pane layout: a sidebar on the left with your brand mark, navigation (Chat, People, Agents, Projects, Tasks, Artefacts), and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu — holding the surface side-by-side with the conversation so the chat stays live while you work in it. The sidebar's nav rows swap the list view in place — Chat shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list — the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable — type in the document and changes save automatically; specialist agent templates are read-only because they ship with Maxy and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood — clicking a second project swaps the focus rather than stacking on top. The chat / artefact divider is drag-resizable — drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat / artefact min-width floors. Your chosen width is remembered across reloads. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On phones (<720px) the sidebar slides in as a drawer from the left when you tap the menu icon in the chat header.
68
+ The admin interface is a three-pane layout: a sidebar on the left with your brand mark, navigation (Chat, People, Agents, Projects, Tasks, Artefacts), and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu — holding the surface side-by-side with the conversation so the chat stays live while you work in it. The sidebar's nav rows swap the list view in place — Chat shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list — the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable — type in the document and changes save automatically; specialist agent templates are read-only because they ship with Maxy and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood — clicking a second project swaps the focus rather than stacking on top. The chat / artefact divider is drag-resizable — drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat / artefact min-width floors. Your chosen width is remembered across reloads. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On phones (<720px) the sidebar slides in as a drawer from the left when you tap the menu icon in the chat header. When the sidebar is collapsed to the 56px icon rail, clicking the Artefacts icon expands the rail back open so the artefact list is visible — the row was previously a silent no-op in collapsed state.
69
69
 
70
70
  Page titles are brand-aware: the browser tab shows your product name (e.g. `Real Agent` instead of `Maxy`) on every shell — chat, graph, and data — so a non-default brand never leaks the default name in tab strips or browser history.
71
71
 
@@ -1,5 +1,13 @@
1
1
  # Troubleshooting
2
2
 
3
+ ## Browser navigation to a local file (`file://`) used to time out for two minutes
4
+
5
+ **Symptom:** Older versions of the platform's admin agent would attempt `browser_navigate file:///path/to.html`, hit Playwright's silent two-minute timeout, then guess fixed ports (8080 / 3000 / 8000 / 9000) and report `ERR_CONNECTION_REFUSED` for each before someone manually started a local HTTP server.
6
+
7
+ **Resolution shipped:** The `playwright-file-guard` PreToolUse hook (admin plugin) intercepts `file://` URLs, picks a free loopback port, backgrounds `python3 -m http.server` rooted at the file's parent directory, connect-verifies the server within one second, and rewrites the tool call's URL to `http://127.0.0.1:<port>/<basename>` before Playwright sees it. The agent never sees the rewrite. Stale server processes are reaped opportunistically on every hook invocation (1 h threshold, gated by a `ps` cmdline check that won't kill a reused PID).
8
+
9
+ **Diagnose if it ever recurs:** grep the per-conversation stream log for `[playwright-file-guard] action=`. One `action=rewrite original=file://… port=<n> pid=<m>` line per file:// navigate is the healthy signal. `action=fail reason=<r>` indicates the hook tried to rewrite but failed open (Playwright handled the original URL); the reason field names the cause (`python3-missing`, `port-pick-failed`, `server-not-ready`, `file-not-found`, `spawn-failed`). The `cleanup` argv on the hook script can be invoked manually to sweep `/tmp/playwright-file-guard.*.pid`; the suite at `platform/plugins/admin/hooks/__tests__/playwright-file-guard.test.sh` exercises every path.
10
+
3
11
  ## First user-domain write rejected by `[graph-write-gate] reject reason=no-admin-user`
4
12
 
5
13
  **Symptom:** Admin chat reports "couldn't save that — set up your business profile first" or `[graph-write-gate] reject reason=no-admin-user` appears in `server.log` on the operator's first non-bootstrap write (a website, service, opening hours, etc.). Reproduces on Minimal-onboarded installs from before the seed-stamping fix shipped.
@@ -64,6 +72,8 @@ tail -200 ~/.maxy/logs/maxy-ui.log | rg '\[remote-auth\].*resolvedKind='
64
72
 
65
73
  **Agent searches the filesystem after uploading a zip.** If you uploaded a zip and the agent burns several turns running `find` / `Glob` instead of unzipping, that is the symptom of the recovery-retry attachment-context regression (now closed by the recovery context preservation contract in `.docs/agents.md`). Greppable confirmation is the `[context-overflow-recovery] retry … attachmentsCarried=<n>` line in the conversation stream log. If you see `[context-overflow-recovery] WARN attachment-context-lost`, the regression has returned — surface to support.
66
74
 
75
+
76
+ **A turn rendered in chat is missing on next page-refresh.** Pre-the 2026-05-07 mandate this was a class of silent failure — Neo4j persists were wrapped in a no-op error catch and a write that threw left the artefact "rendered then disappeared on resume". The 2026-05-07 mandate makes JSONL canonical: the resume route reads the SDK transcript file at `~/.claude/projects/<project-key>/<sessionId>.jsonl` first, supplements from Neo4j, and triggers async heal-on-resume writes for any turn the JSONL has but Neo4j does not. So a refreshed conversation always renders what the SDK saw, regardless of write outcome. If a heal write itself fails, the chat shows a top-of-conversation banner naming the count; if every heal succeeds the resume is silent and the missing rows are quietly restored to Neo4j. Greppable post-deploy invariants in the per-conversation stream log (`logs/claude-agent-stream-<conversationId>.log`): `[admin-resume] reason=<…> source=<jsonl|jsonl-missing|neo4j-only>` (one per resume), `[admin-persist] convId=<8> writer=<…> outcome=<ok|fail|skip>` (per persist site), `[admin-persist-heal] convId=<8> turnIndex=<n> outcome=<ok|fail>` (per heal write). To force-audit a specific conversation against its Neo4j projection without re-executing it, run `tsx platform/scripts/admin-persist-audit.ts --conversation-id=<uuid> --account-id=<uuid> --session-id=<uuid>` — non-zero exit + per-divergence `[admin-persist-audit] expected=<message|component> missing reason=neo4j-row-absent` lines name what would have been silently lost pre-mandate.
67
77
  **Wrong Claude account answering on a multi-brand device.** On a host running both Maxy and Real Agent, each brand's admin agent reads its own `~/${brand.configDir}/.claude/.credentials.json`; there is no longer a shared `~/.claude/` thrashing them against one another. If a brand reports auth failures or appears to be operating against the wrong subscription, check three things:
68
78
  1. `grep "\[claude-auth\] init" ~/.${brand}/logs/server.log | tail -1` — the resolved path must end with `~/.${brand}/.claude/.credentials.json`. If a `[claude-auth] WARN cross-brand-path-detected` line is present, the runtime is still pointing at `~/.claude/`; the brand main service did not pick up the `Environment=CLAUDE_CONFIG_DIR=` setting (re-run the brand installer to refresh the unit file).
69
79
  2. `diff <(jq .claudeAiOauth.accessToken ~/.maxy/.claude/.credentials.json) <(jq .claudeAiOauth.accessToken ~/.realagent/.claude/.credentials.json)` — must be non-empty after each brand's operator has run `claude /login` against distinct Anthropic accounts; if it's empty, both brands are still logged in to the same account (operator action, not a code bug).
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env -S node --loader tsx
2
+ /**
3
+ * Task 940 — admin persist audit harness.
4
+ *
5
+ * Compares JSONL canonical state against Neo4j projection for a given
6
+ * conversationId. Prints one [admin-persist-audit] divergence line per
7
+ * (sdkTurnUuid, expected) gap; non-zero exit on any mismatch. Designed to
8
+ * run against an operator-supplied stream log fixture WITHOUT re-executing
9
+ * the live session — JSONL is the only ground truth this script consults.
10
+ *
11
+ * Usage:
12
+ * tsx platform/scripts/admin-persist-audit.ts \
13
+ * --conversation-id=<uuid> \
14
+ * --account-id=<uuid> \
15
+ * --jsonl=<path> # optional override; otherwise resolved from accountId+sessionId
16
+ * --session-id=<uuid> # required if --jsonl not provided
17
+ *
18
+ * Exit codes:
19
+ * 0 = no divergences
20
+ * 1 = at least one Message or Component absent from Neo4j
21
+ * 2 = invocation error (missing args, file unreadable, Neo4j unreachable)
22
+ *
23
+ * Why audit-only and not also auto-heal: the heal-on-resume writer at
24
+ * server/routes/admin/sessions.ts handles the live path. This harness exists
25
+ * for forensic investigation against operator-supplied JSONLs (e.g. the
26
+ * 2026-05-07 stream log that motivated this task) where the live session
27
+ * has long since terminated.
28
+ */
29
+
30
+ import { existsSync, readFileSync } from "node:fs";
31
+ import { homedir } from "node:os";
32
+ import { resolve } from "node:path";
33
+ import process from "node:process";
34
+
35
+ import { replayJsonl, resolveJsonlPath } from "../ui/app/lib/claude-agent/jsonl-replay";
36
+ import { ACCOUNTS_DIR } from "../ui/app/lib/claude-agent/account";
37
+ import { getRecentMessages, getSession } from "../ui/app/lib/neo4j-store";
38
+ import { PERSISTENT_COMPONENTS } from "../lib/persistent-components/src/index";
39
+
40
+ interface Args {
41
+ conversationId: string;
42
+ accountId: string;
43
+ jsonlPath: string;
44
+ }
45
+
46
+ function parseArgs(argv: string[]): Args | { error: string } {
47
+ const out: Partial<Args> = {};
48
+ let sessionId: string | undefined;
49
+ let jsonlOverride: string | undefined;
50
+ for (const a of argv) {
51
+ const m = a.match(/^--([a-z-]+)=(.+)$/);
52
+ if (!m) continue;
53
+ const [, key, val] = m;
54
+ if (key === "conversation-id") out.conversationId = val;
55
+ else if (key === "account-id") out.accountId = val;
56
+ else if (key === "session-id") sessionId = val;
57
+ else if (key === "jsonl") jsonlOverride = val;
58
+ }
59
+ if (!out.conversationId) return { error: "--conversation-id required" };
60
+ if (!out.accountId) return { error: "--account-id required" };
61
+ if (jsonlOverride) {
62
+ out.jsonlPath = resolve(jsonlOverride);
63
+ } else if (sessionId) {
64
+ const accountDir = resolve(ACCOUNTS_DIR, out.accountId);
65
+ out.jsonlPath = resolveJsonlPath(homedir(), accountDir, sessionId);
66
+ } else {
67
+ return { error: "either --jsonl or --session-id required" };
68
+ }
69
+ return out as Args;
70
+ }
71
+
72
+ async function main(): Promise<number> {
73
+ const parsed = parseArgs(process.argv.slice(2));
74
+ if ("error" in parsed) {
75
+ console.error(`[admin-persist-audit] usage error: ${parsed.error}`);
76
+ return 2;
77
+ }
78
+ const { conversationId, jsonlPath } = parsed;
79
+
80
+ if (!existsSync(jsonlPath)) {
81
+ console.error(`[admin-persist-audit] jsonl absent path=${jsonlPath} convId=${conversationId.slice(0, 8)}`);
82
+ return 2;
83
+ }
84
+
85
+ // JSONL replay — derives the canonical message stream and the expected
86
+ // component side-effects.
87
+ const replay = replayJsonl(jsonlPath);
88
+ if (replay.malformedLines > 0) {
89
+ console.error(`[admin-persist-audit] jsonl-malformed-lines convId=${conversationId.slice(0, 8)} count=${replay.malformedLines}`);
90
+ }
91
+
92
+ // Neo4j projection — what the resume route would have rendered pre-940.
93
+ let neo4j: Awaited<ReturnType<typeof getRecentMessages>>;
94
+ try {
95
+ neo4j = await getRecentMessages(conversationId, 1000);
96
+ } catch (err) {
97
+ console.error(`[admin-persist-audit] neo4j-read-failed convId=${conversationId.slice(0, 8)} reason=${err instanceof Error ? err.message : String(err)}`);
98
+ return 2;
99
+ }
100
+
101
+ // Build a set of (role, content) keys present in Neo4j for fast lookup.
102
+ // Same matching key the resume route uses, for parity.
103
+ const neo4jByKey = new Map<string, typeof neo4j[number]>();
104
+ for (const n of neo4j) neo4jByKey.set(`${n.role}\x1f${n.content}`, n);
105
+
106
+ let divergences = 0;
107
+ for (const j of replay.messages) {
108
+ const key = `${j.role}\x1f${j.content}`;
109
+ const match = neo4jByKey.get(key);
110
+ if (!match) {
111
+ // Whole message absent from Neo4j.
112
+ console.log(`[admin-persist-audit] convId=${conversationId.slice(0, 8)} sdkTurnUuid=${j.messageId.slice(0, 8)} expected=message missing reason=neo4j-row-absent`);
113
+ divergences += 1;
114
+ // Each missing message implies its components are also missing — emit
115
+ // one divergence line per absent component for forensic completeness.
116
+ for (const c of j.components) {
117
+ console.log(`[admin-persist-audit] convId=${conversationId.slice(0, 8)} sdkTurnUuid=${j.messageId.slice(0, 8)} expected=component component_name=${c.name} ordinal=${c.ordinal} missing reason=neo4j-row-absent`);
118
+ divergences += 1;
119
+ }
120
+ continue;
121
+ }
122
+ // Message exists; cross-check component count (Neo4j carries components
123
+ // as siblings of :Message via :HAS_COMPONENT).
124
+ const neoComps = match.components ?? [];
125
+ if (neoComps.length < j.components.length) {
126
+ for (let i = neoComps.length; i < j.components.length; i++) {
127
+ const c = j.components[i];
128
+ console.log(`[admin-persist-audit] convId=${conversationId.slice(0, 8)} sdkTurnUuid=${j.messageId.slice(0, 8)} expected=component component_name=${c.name} ordinal=${c.ordinal} missing reason=neo4j-row-absent`);
129
+ divergences += 1;
130
+ }
131
+ }
132
+ }
133
+
134
+ // Task 942 — every PERSISTENT_COMPONENTS :Component row must have a
135
+ // sibling :KnowledgeDocument with a matching attachmentId. Two failure
136
+ // modes: (a) live writer succeeded the :Component CREATE but the
137
+ // sibling :KnowledgeDocument MERGE didn't fire (theoretical — they're
138
+ // in the same tx, so this is only possible if the row is pre-942 or if
139
+ // the FOREACH ran on a null attachmentId); (b) the disk-write failed
140
+ // mid-render and `c.attachmentId` is null but the artefact bytes might
141
+ // be recoverable from `c.data`. Both cases are surfaced as `kd-row-absent`
142
+ // — the operator runs component-knowledgedoc-backfill.ts to materialise
143
+ // the projection.
144
+ const projectionSession = getSession();
145
+ try {
146
+ const componentRowsResult = await projectionSession.run(
147
+ `MATCH (m:Message {conversationId: $conversationId})-[:HAS_COMPONENT]->(c:Component)
148
+ WHERE c.name IN $names
149
+ OPTIONAL MATCH (k:KnowledgeDocument {accountId: c.accountId, attachmentId: c.attachmentId})
150
+ WHERE c.attachmentId IS NOT NULL
151
+ RETURN c.componentId AS componentId,
152
+ c.name AS componentName,
153
+ c.accountId AS accountId,
154
+ c.attachmentId AS attachmentId,
155
+ k IS NOT NULL AS hasProjection`,
156
+ { conversationId, names: Array.from(PERSISTENT_COMPONENTS) },
157
+ );
158
+
159
+ for (const record of componentRowsResult.records) {
160
+ const componentId = record.get("componentId") as string;
161
+ const componentName = record.get("componentName") as string;
162
+ const attachmentId = record.get("attachmentId") as string | null;
163
+ const hasProjection = record.get("hasProjection") === true;
164
+ if (!attachmentId) {
165
+ console.log(`[admin-persist-audit] convId=${conversationId.slice(0, 8)} componentId=${componentId.slice(0, 8)} expected=knowledgedoc component_name=${componentName} missing reason=empty-attachment-id`);
166
+ divergences += 1;
167
+ continue;
168
+ }
169
+ if (!hasProjection) {
170
+ console.log(`[admin-persist-audit] convId=${conversationId.slice(0, 8)} componentId=${componentId.slice(0, 8)} expected=knowledgedoc component_name=${componentName} missing reason=kd-row-absent attachmentId=${attachmentId.slice(0, 8)}`);
171
+ divergences += 1;
172
+ }
173
+ }
174
+ } finally {
175
+ await projectionSession.close();
176
+ }
177
+
178
+ if (divergences === 0) {
179
+ console.log(`[admin-persist-audit] convId=${conversationId.slice(0, 8)} jsonlMessages=${replay.messages.length} neo4jMessages=${neo4j.length} divergences=0 status=ok`);
180
+ return 0;
181
+ }
182
+ console.log(`[admin-persist-audit] convId=${conversationId.slice(0, 8)} jsonlMessages=${replay.messages.length} neo4jMessages=${neo4j.length} divergences=${divergences} status=mismatch`);
183
+ return 1;
184
+ }
185
+
186
+ main()
187
+ .then((code) => process.exit(code))
188
+ .catch((err) => {
189
+ console.error(`[admin-persist-audit] crashed: ${err instanceof Error ? err.stack : String(err)}`);
190
+ process.exit(2);
191
+ });