@rubytech/create-maxy-code 0.1.340 → 0.1.342
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.
- package/dist/__tests__/known-brand-hostnames.test.js +40 -0
- package/dist/__tests__/neo4j-teardown.test.js +55 -0
- package/dist/known-brands.js +24 -0
- package/dist/neo4j-teardown.js +45 -0
- package/dist/uninstall.js +89 -40
- package/package.json +1 -1
- package/payload/platform/config/brand.json +2 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +3 -1
- package/payload/platform/plugins/cloudflare/PLUGIN.md +1 -0
- package/payload/platform/plugins/cloudflare/skills/cloudflare/SKILL.md +1 -0
- package/payload/platform/plugins/cloudflare/skills/site-deploy/SKILL.md +84 -0
- package/payload/platform/plugins/docs/references/admin-ui.md +2 -0
- package/payload/platform/templates/specialists/agents/content-producer.md +2 -0
- package/payload/server/{chunk-NE7G5GT7.js → chunk-5BG6CHGH.js} +17 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/assets/{AdminLoginScreens-CHTeh_Vu.js → AdminLoginScreens-Brx8CmXN.js} +1 -1
- package/payload/server/public/assets/{AdminShell-DjoP7YoA.js → AdminShell-CHZMDX2u.js} +1 -1
- package/payload/server/public/assets/{Checkbox-D58GsKoQ.js → Checkbox-aePjWzRH.js} +1 -1
- package/payload/server/public/assets/{OperatorConversations-CDdp2nVn.css → OperatorConversations-BMIZQR9t.css} +1 -1
- package/payload/server/public/assets/{OperatorConversations-RmqANYz8.js → OperatorConversations-DpjPPIOp.js} +1 -1
- package/payload/server/public/assets/{admin-LGICBqil.js → admin-DIDvfti6.js} +1 -1
- package/payload/server/public/assets/{browser-CRgweVtw.js → browser-Bp5kGgyr.js} +1 -1
- package/payload/server/public/assets/chat-C0IWx7FL.js +1 -0
- package/payload/server/public/assets/{data-CttrzhfL.js → data-RsMye_06.js} +1 -1
- package/payload/server/public/assets/{graph-CIBba84R.js → graph-Bnsvbnkf.js} +1 -1
- package/payload/server/public/assets/{graph-labels-Bi0fu8Ns.js → graph-labels-jduMtwXb.js} +1 -1
- package/payload/server/public/assets/{operator-mwkYv8g5.js → operator-9K-TElDd.js} +1 -1
- package/payload/server/public/assets/page-BT9hkXHm.js +30 -0
- package/payload/server/public/assets/{public-CiUboUwu.js → public-DvL1Zov1.js} +1 -1
- package/payload/server/public/brand/maxy-og-image.png +0 -0
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/chat.html +5 -5
- package/payload/server/public/data.html +4 -4
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +6 -6
- package/payload/server/public/operator.html +7 -7
- package/payload/server/public/public.html +5 -5
- package/payload/server/server.js +183 -26
- package/payload/server/public/assets/chat-CXPRTVW7.js +0 -1
- package/payload/server/public/assets/page-BpxHz1N-.js +0 -30
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Drift guard: every shipped brand's hostname must appear in
|
|
2
|
+
// KNOWN_BRAND_HOSTNAMES, the allowlist that peerBrandPresent() matches against
|
|
3
|
+
// to decide whether a co-resident brand is still installed. A shipped brand
|
|
4
|
+
// missing from the list makes peerBrandPresent() report "no peer" while that
|
|
5
|
+
// brand survives, so the uninstall wipes device-wide singletons (~/.claude
|
|
6
|
+
// OAuth, ~/.ollama cache, shared Neo4j data, apt packages) it still depends on.
|
|
7
|
+
//
|
|
8
|
+
// The list is intentionally a SUPERSET of brands/ (it carries legacy hostnames
|
|
9
|
+
// and reserved slots that have no brand.json), so this is a subset check, not
|
|
10
|
+
// an equality check. The reverse direction is not constrained.
|
|
11
|
+
//
|
|
12
|
+
// Runs via Node's built-in test runner (the codebase convention — see
|
|
13
|
+
// peer-brand-detect.test.ts). Path resolution mirrors base-toolchain-deps.test.ts.
|
|
14
|
+
import test from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { dirname, resolve, join } from "node:path";
|
|
19
|
+
import { KNOWN_BRAND_HOSTNAMES } from "../known-brands.js";
|
|
20
|
+
// dist/__tests__/known-brand-hostnames.test.js → maxy-code/brands
|
|
21
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const BRANDS_DIR = resolve(here, "../../../../brands");
|
|
23
|
+
function shippedBrandHostnames() {
|
|
24
|
+
return readdirSync(BRANDS_DIR, { withFileTypes: true })
|
|
25
|
+
.filter((e) => e.isDirectory())
|
|
26
|
+
.map((e) => join(BRANDS_DIR, e.name, "brand.json"))
|
|
27
|
+
.filter((p) => existsSync(p))
|
|
28
|
+
.map((p) => JSON.parse(readFileSync(p, "utf-8")).hostname);
|
|
29
|
+
}
|
|
30
|
+
test("brands/ directory is discoverable from the test location", () => {
|
|
31
|
+
assert.ok(existsSync(BRANDS_DIR), `brands dir not found at ${BRANDS_DIR}`);
|
|
32
|
+
assert.ok(shippedBrandHostnames().length > 0, "no shipped brand.json hostnames found");
|
|
33
|
+
});
|
|
34
|
+
test("every shipped brand hostname is in KNOWN_BRAND_HOSTNAMES", () => {
|
|
35
|
+
const allow = new Set(KNOWN_BRAND_HOSTNAMES);
|
|
36
|
+
for (const hostname of shippedBrandHostnames()) {
|
|
37
|
+
assert.ok(allow.has(hostname), `brands/ ships hostname "${hostname}" but it is absent from KNOWN_BRAND_HOSTNAMES; ` +
|
|
38
|
+
`peerBrandPresent() would not detect a co-resident "${hostname}" install`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Contract grid for resolveDedicatedNeo4jTeardown — the pure decision behind
|
|
2
|
+
// the uninstall's dedicated-Neo4j decommission. The wrapper in uninstall.ts
|
|
3
|
+
// owns fs/systemctl; this suite is decision-only, no I/O.
|
|
4
|
+
//
|
|
5
|
+
// The load-bearing guarantee: the dedicated-removal path NEVER targets the
|
|
6
|
+
// bare `neo4j` service / port 7687, including when the brand port is
|
|
7
|
+
// unreadable (undefined). Runs via Node's built-in test runner (the codebase
|
|
8
|
+
// convention — see peer-brand-detect.test.ts).
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { resolveDedicatedNeo4jTeardown, SHARED_NEO4J_PORT } from "../neo4j-teardown.js";
|
|
12
|
+
const HOST = "sitedesk-code";
|
|
13
|
+
test("dedicated port → full teardown plan with install-parity names", () => {
|
|
14
|
+
const plan = resolveDedicatedNeo4jTeardown({ brandHostname: HOST, neo4jPort: 7689 });
|
|
15
|
+
assert.notEqual(plan, null);
|
|
16
|
+
// Names derive from BRAND.hostname exactly as setupDedicatedNeo4j creates them.
|
|
17
|
+
assert.deepEqual(plan, {
|
|
18
|
+
service: "neo4j-sitedesk-code",
|
|
19
|
+
unitFile: "/etc/systemd/system/neo4j-sitedesk-code.service",
|
|
20
|
+
confDir: "/etc/neo4j-sitedesk-code",
|
|
21
|
+
dataDir: "/var/lib/neo4j-sitedesk-code",
|
|
22
|
+
logDir: "/var/log/neo4j-sitedesk-code",
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
test("dedicated plan never names the shared `neo4j` service", () => {
|
|
26
|
+
const plan = resolveDedicatedNeo4jTeardown({ brandHostname: HOST, neo4jPort: 7689 });
|
|
27
|
+
assert.notEqual(plan, null);
|
|
28
|
+
assert.notEqual(plan.service, "neo4j");
|
|
29
|
+
for (const p of [plan.unitFile, plan.confDir, plan.dataDir, plan.logDir]) {
|
|
30
|
+
// No teardown path is the shared apt-managed location.
|
|
31
|
+
assert.notEqual(p, "/var/lib/neo4j");
|
|
32
|
+
assert.notEqual(p, "/etc/neo4j");
|
|
33
|
+
assert.notEqual(p, "/var/log/neo4j");
|
|
34
|
+
assert.notEqual(p, "/etc/systemd/system/neo4j.service");
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
test("shared port 7687 → null (shared path, never the dedicated removal)", () => {
|
|
38
|
+
assert.equal(resolveDedicatedNeo4jTeardown({ brandHostname: HOST, neo4jPort: SHARED_NEO4J_PORT }), null);
|
|
39
|
+
});
|
|
40
|
+
test("unreadable brand port (undefined) → null, never selects shared", () => {
|
|
41
|
+
// The defect this guards: a port-read failure must NOT fall through to the
|
|
42
|
+
// shared `neo4j`/7687. null tells the caller to take the shared path only
|
|
43
|
+
// when the brand is genuinely shared, never on read failure of a dedicated brand.
|
|
44
|
+
assert.equal(resolveDedicatedNeo4jTeardown({ brandHostname: HOST, neo4jPort: undefined }), null);
|
|
45
|
+
});
|
|
46
|
+
test("empty hostname → null (cannot form a dedicated unit)", () => {
|
|
47
|
+
assert.equal(resolveDedicatedNeo4jTeardown({ brandHostname: "", neo4jPort: 7689 }), null);
|
|
48
|
+
});
|
|
49
|
+
test("a different dedicated port still yields a hostname-derived plan", () => {
|
|
50
|
+
// Unit/dir names depend on hostname, not the port value — a forced
|
|
51
|
+
// --neo4j-port that differs from brand.json still tears down the same unit.
|
|
52
|
+
const plan = resolveDedicatedNeo4jTeardown({ brandHostname: "realagent-code", neo4jPort: 7700 });
|
|
53
|
+
assert.equal(plan.service, "neo4j-realagent-code");
|
|
54
|
+
assert.equal(plan.dataDir, "/var/lib/neo4j-realagent-code");
|
|
55
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Allowlist of brand hostnames in the Maxy ecosystem. peerBrandPresent() in
|
|
2
|
+
// uninstall.ts matches systemd unit filenames (`<hostname>.service` and
|
|
3
|
+
// `<hostname>-edge.service`) against this list to decide whether a co-resident
|
|
4
|
+
// brand is still installed — the gate on every device-wide teardown step
|
|
5
|
+
// (shared ~/.claude / ~/.ollama wipe, shared Neo4j data wipe, apt purge,
|
|
6
|
+
// Ollama/Samba teardown). Matching only these filenames, not any stray
|
|
7
|
+
// `.service` file, keeps stale units and unrelated user services from counting
|
|
8
|
+
// as peer evidence.
|
|
9
|
+
//
|
|
10
|
+
// This list is a SUPERSET of the brands shipped under `brands/`: `maxy` and
|
|
11
|
+
// `realagent` are legacy hostnames, `maxy-2/3/4` are reserved slots. Every
|
|
12
|
+
// hostname under `brands/*/brand.json` MUST appear here, or peerBrandPresent()
|
|
13
|
+
// fails to detect that brand as a peer. known-brand-hostnames.test.ts enforces
|
|
14
|
+
// that subset relationship and fails when a new shipped brand is missing.
|
|
15
|
+
export const KNOWN_BRAND_HOSTNAMES = [
|
|
16
|
+
"maxy",
|
|
17
|
+
"maxy-code",
|
|
18
|
+
"realagent",
|
|
19
|
+
"realagent-code",
|
|
20
|
+
"sitedesk-code",
|
|
21
|
+
"maxy-2",
|
|
22
|
+
"maxy-3",
|
|
23
|
+
"maxy-4",
|
|
24
|
+
];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Pure dedicated-Neo4j teardown resolution. Extracted from uninstall.ts so the
|
|
2
|
+
// "does this brand run a dedicated Neo4j, and what does its full decommission
|
|
3
|
+
// target?" decision can be unit-tested with concrete inputs, no fs/systemctl.
|
|
4
|
+
// Mirrors the peer-brand-detect.ts pattern: inputs in, plan out, no I/O.
|
|
5
|
+
//
|
|
6
|
+
// The uninstaller wraps this with the privileged stop/disable/rm and the
|
|
7
|
+
// daemon-reload; this module owns only the naming + the shared-instance guard.
|
|
8
|
+
/** The apt-installed shared Neo4j binds this bolt port. A brand on this port
|
|
9
|
+
* is NOT dedicated — it shares the device-wide instance. */
|
|
10
|
+
export const SHARED_NEO4J_PORT = 7687;
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the dedicated-Neo4j teardown plan for a brand, or `null` when the
|
|
13
|
+
* brand does not run a dedicated instance.
|
|
14
|
+
*
|
|
15
|
+
* Returns `null` — i.e. the dedicated-removal path must NOT act, and the caller
|
|
16
|
+
* falls back to its shared handling — in every case where targeting a dedicated
|
|
17
|
+
* unit would be wrong or unsafe:
|
|
18
|
+
* - `neo4jPort === undefined`: the port is unreadable. A read failure must
|
|
19
|
+
* never select the shared `neo4j`/7687 service; returning null keeps the
|
|
20
|
+
* dedicated path inert rather than guessing.
|
|
21
|
+
* - `neo4jPort === SHARED_NEO4J_PORT`: the brand genuinely shares 7687.
|
|
22
|
+
* - empty `brandHostname`: no hostname means no `neo4j-<hostname>` unit can
|
|
23
|
+
* exist; refuse rather than form a degenerate `neo4j-` name.
|
|
24
|
+
*
|
|
25
|
+
* When a plan IS returned, `service` is always `neo4j-<hostname>` and can never
|
|
26
|
+
* equal the bare `neo4j` — the hard guard against ever stopping or deleting the
|
|
27
|
+
* shared instance.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveDedicatedNeo4jTeardown(args) {
|
|
30
|
+
const { brandHostname, neo4jPort } = args;
|
|
31
|
+
if (neo4jPort === undefined)
|
|
32
|
+
return null;
|
|
33
|
+
if (neo4jPort === SHARED_NEO4J_PORT)
|
|
34
|
+
return null;
|
|
35
|
+
if (!brandHostname)
|
|
36
|
+
return null;
|
|
37
|
+
const service = `neo4j-${brandHostname}`;
|
|
38
|
+
return {
|
|
39
|
+
service,
|
|
40
|
+
unitFile: `/etc/systemd/system/${service}.service`,
|
|
41
|
+
confDir: `/etc/neo4j-${brandHostname}`,
|
|
42
|
+
dataDir: `/var/lib/neo4j-${brandHostname}`,
|
|
43
|
+
logDir: `/var/log/neo4j-${brandHostname}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
package/dist/uninstall.js
CHANGED
|
@@ -4,6 +4,8 @@ import { resolve, join, dirname } from "node:path";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
6
|
import { removeBrandStanza, hasAnyBrandStanza } from "./samba-provision.js";
|
|
7
|
+
import { resolveDedicatedNeo4jTeardown } from "./neo4j-teardown.js";
|
|
8
|
+
import { KNOWN_BRAND_HOSTNAMES } from "./known-brands.js";
|
|
7
9
|
const HOME = homedir();
|
|
8
10
|
const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
|
|
9
11
|
// Brand manifest — read from payload to derive brand-specific installation paths.
|
|
@@ -97,13 +99,6 @@ function commandExists(cmd) {
|
|
|
97
99
|
export function isMaxyInstalled() {
|
|
98
100
|
return existsSync(INSTALL_DIR);
|
|
99
101
|
}
|
|
100
|
-
/** Known brand hostnames in the Maxy ecosystem. Each brand ships a main unit
|
|
101
|
-
* (`<hostname>.service`) and a per-brand edge unit
|
|
102
|
-
* (`<hostname>-edge.service`). Peer detection matches only these filenames, not any stray
|
|
103
|
-
* `.service` file — stale units, gnome-keyring disable markers, and unrelated
|
|
104
|
-
* user services are not peer evidence. When a third brand is
|
|
105
|
-
* added under `brands/`, append its hostname here. */
|
|
106
|
-
const KNOWN_BRAND_HOSTNAMES = ["maxy", "maxy-code", "realagent", "realagent-code", "maxy-2", "maxy-3", "maxy-4"];
|
|
107
102
|
/** Detect whether another brand is installed on this device.
|
|
108
103
|
* device-wide steps (apt package purge, Ollama binary removal, apt
|
|
109
104
|
* repo cleanup, ~/.claude / ~/.ollama wipes) must skip when a peer brand is
|
|
@@ -166,20 +161,26 @@ function stopServices() {
|
|
|
166
161
|
catch {
|
|
167
162
|
console.log(` ${edgeUnitShort} not running`);
|
|
168
163
|
}
|
|
169
|
-
// Stop Neo4j
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
164
|
+
// Stop Neo4j. A dedicated brand's instance is stopped AND disabled here so it
|
|
165
|
+
// does not return at the next boot; the unit file and data/config/log dirs are
|
|
166
|
+
// removed in step 5 (removeNeo4jData). Hard guard: when the brand is dedicated
|
|
167
|
+
// the target is always `neo4j-<hostname>` — the shared `neo4j`/7687 instance is
|
|
168
|
+
// never stopped, even if `.env` is unreadable (dedicatedNeo4jTeardown falls
|
|
169
|
+
// back to brand.json, never to the shared service). A genuinely shared brand
|
|
170
|
+
// (port 7687 / no dedicated port) stops the shared `neo4j` as before.
|
|
171
|
+
const neo4jTeardown = dedicatedNeo4jTeardown();
|
|
172
|
+
if (neo4jTeardown) {
|
|
173
|
+
if (privilegedSystemctl("stop", neo4jTeardown.service)) {
|
|
174
|
+
console.log(` Stopped ${neo4jTeardown.service}`);
|
|
175
|
+
}
|
|
176
|
+
if (privilegedSystemctl("disable", neo4jTeardown.service)) {
|
|
177
|
+
console.log(` Disabled ${neo4jTeardown.service}`);
|
|
178
|
+
}
|
|
180
179
|
}
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
else {
|
|
181
|
+
if (privilegedSystemctl("stop", "neo4j")) {
|
|
182
|
+
console.log(" Stopped neo4j (shared instance)");
|
|
183
|
+
}
|
|
183
184
|
}
|
|
184
185
|
// Stop the cloudflared service for this brand (Task 757 — the connector now
|
|
185
186
|
// runs as a supervised cloudflared-<brand>.service, not a transient scope).
|
|
@@ -438,22 +439,36 @@ function removeNeo4jData() {
|
|
|
438
439
|
// 7687 data is skipped entirely when a peer brand is present. Dedicated
|
|
439
440
|
// branded instances live at /var/lib/neo4j-<hostname>/ and are always
|
|
440
441
|
// this-brand-owned by construction.
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
442
|
+
const teardown = dedicatedNeo4jTeardown();
|
|
443
|
+
if (teardown) {
|
|
444
|
+
// Full decommission of the dedicated instance. Stop + disable already ran
|
|
445
|
+
// in step 1 (stopServices); here we remove every artifact the installer's
|
|
446
|
+
// setupDedicatedNeo4j() created — unit file, config dir, data dir, log dir —
|
|
447
|
+
// then daemon-reload so systemd forgets the unit. Removing the whole data
|
|
448
|
+
// dir (not just data/) also clears the install's plugins/ and import/ trees;
|
|
449
|
+
// the log dir is /var/log/neo4j-<hostname> (server.directories.logs), not a
|
|
450
|
+
// subdir of the data dir.
|
|
451
|
+
const paths = [teardown.unitFile, teardown.confDir, teardown.dataDir, teardown.logDir];
|
|
452
|
+
for (const p of paths) {
|
|
453
|
+
if (existsSync(p)) {
|
|
454
|
+
try {
|
|
455
|
+
shell("rm", ["-rf", p], { sudo: true });
|
|
456
|
+
console.log(` Removed ${p}`);
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
console.log(` Failed to remove ${p}: ${err instanceof Error ? err.message : String(err)}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
privilegedSystemctl("daemon-reload");
|
|
446
464
|
return;
|
|
447
465
|
}
|
|
448
|
-
|
|
449
|
-
if (
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
// neo4j-<hostname>.service. Shared instance owns neither.
|
|
453
|
-
paths.push(`/etc/neo4j-${BRAND.hostname}`);
|
|
454
|
-
paths.push(`/etc/systemd/system/neo4j-${BRAND.hostname}.service`);
|
|
466
|
+
// Shared instance on 7687 — skip entirely when a peer brand still depends on it.
|
|
467
|
+
if (peerBrandPresent()) {
|
|
468
|
+
console.log(` Shared Neo4j instance on 7687 — peer brand present, skipping data wipe.`);
|
|
469
|
+
return;
|
|
455
470
|
}
|
|
456
|
-
for (const p of
|
|
471
|
+
for (const p of ["/var/lib/neo4j/data", "/var/lib/neo4j/logs"]) {
|
|
457
472
|
if (existsSync(p)) {
|
|
458
473
|
try {
|
|
459
474
|
shell("rm", ["-rf", p], { sudo: true });
|
|
@@ -464,15 +479,9 @@ function removeNeo4jData() {
|
|
|
464
479
|
}
|
|
465
480
|
}
|
|
466
481
|
}
|
|
467
|
-
if (isDedicated) {
|
|
468
|
-
try {
|
|
469
|
-
spawnSync("sudo", ["systemctl", "daemon-reload"], { stdio: "pipe" });
|
|
470
|
-
}
|
|
471
|
-
catch { /* ignore */ }
|
|
472
|
-
}
|
|
473
482
|
}
|
|
474
483
|
/** Read NEO4J_URI port from this brand's .env. Returns undefined when the
|
|
475
|
-
* file is missing or malformed — caller
|
|
484
|
+
* file is missing or malformed — caller folds in the brand.json fallback. */
|
|
476
485
|
function readNeo4jPortFromEnv() {
|
|
477
486
|
const envPath = join(CONFIG_DIR, ".env");
|
|
478
487
|
if (!existsSync(envPath))
|
|
@@ -488,6 +497,46 @@ function readNeo4jPortFromEnv() {
|
|
|
488
497
|
return undefined;
|
|
489
498
|
}
|
|
490
499
|
}
|
|
500
|
+
/** Resolve this brand's dedicated-Neo4j teardown plan, or null when the brand
|
|
501
|
+
* runs the shared instance. The port is `.env` (the actual running config,
|
|
502
|
+
* preserving the installer's own port priority — this is where a `--neo4j-port`
|
|
503
|
+
* override lands) with brand.json as the robust fallback. The result is
|
|
504
|
+
* memoised on first call: stopServices (step 1) is the first caller and runs
|
|
505
|
+
* while `~/<configDir>/.env` still exists; removeAppDirs (step 4) deletes that
|
|
506
|
+
* `.env` before removeNeo4jData (step 5) calls again, so without the cache the
|
|
507
|
+
* two call sites could classify dedication differently (step 1 sees the
|
|
508
|
+
* override port, step 5 sees only brand.json) and orphan the dedicated dirs.
|
|
509
|
+
* Caching the first (env-informed) resolution keeps stop/disable and dir
|
|
510
|
+
* removal targeting the same instance. A read failure can never fall through
|
|
511
|
+
* to the shared `neo4j`/7687 service. Dedicated names are `neo4j-<hostname>`,
|
|
512
|
+
* matching `setupDedicatedNeo4j()`. */
|
|
513
|
+
let _dedicatedNeo4jTeardownResolved = false;
|
|
514
|
+
let _dedicatedNeo4jTeardownPlan = null;
|
|
515
|
+
function dedicatedNeo4jTeardown() {
|
|
516
|
+
if (_dedicatedNeo4jTeardownResolved)
|
|
517
|
+
return _dedicatedNeo4jTeardownPlan;
|
|
518
|
+
const neo4jPort = readNeo4jPortFromEnv() ?? BRAND.neo4jPort;
|
|
519
|
+
_dedicatedNeo4jTeardownPlan = resolveDedicatedNeo4jTeardown({ brandHostname: BRAND.hostname, neo4jPort });
|
|
520
|
+
_dedicatedNeo4jTeardownResolved = true;
|
|
521
|
+
return _dedicatedNeo4jTeardownPlan;
|
|
522
|
+
}
|
|
523
|
+
/** Run a privileged `systemctl <args...>` interactively so a password prompt is
|
|
524
|
+
* visible (the silent `stdio:"pipe"` no-op is the defect being fixed). Exit 5
|
|
525
|
+
* (no such unit) is an acceptable end-state — the unit is already absent, which
|
|
526
|
+
* keeps re-runs idempotent. Any other non-zero is a genuine failure (e.g. sudo
|
|
527
|
+
* auth declined) and is surfaced loudly rather than swallowed. Returns true on
|
|
528
|
+
* success or "already absent". Cannot delegate to shell(): shell() throws on
|
|
529
|
+
* any non-zero, which would abort the uninstall on an already-absent unit. */
|
|
530
|
+
function privilegedSystemctl(...args) {
|
|
531
|
+
const result = spawnSync("sudo", ["systemctl", ...args], { stdio: "inherit", timeout: 30_000 });
|
|
532
|
+
if (result.status === 0)
|
|
533
|
+
return true;
|
|
534
|
+
if (result.status === 5)
|
|
535
|
+
return true; // no such unit — already gone
|
|
536
|
+
const reason = result.signal ? `signal ${result.signal}` : `exit ${result.status}`;
|
|
537
|
+
console.error(` FAILED: sudo systemctl ${args.join(" ")} (${reason}) — privileged step did not complete`);
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
491
540
|
// ---------------------------------------------------------------------------
|
|
492
541
|
// Step 6: Purge system packages
|
|
493
542
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: platform-architecture
|
|
3
3
|
description: Use when grounding any documented-surface claim about what Maxy ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
|
|
4
|
-
content-hash: sha256:
|
|
4
|
+
content-hash: sha256:7276f9a571c11e5350ab9a29cdc60f6416f4f0b685f58679217efff1b53413e9
|
|
5
5
|
brand: maxy-code
|
|
6
6
|
product-name: Maxy
|
|
7
7
|
---
|
|
@@ -2334,6 +2334,8 @@ either is a regression.
|
|
|
2334
2334
|
|
|
2335
2335
|
**`/chat` Claude-desktop transcript presentation.** The admin webchat keeps the shared `Transcript` shell (stream, follow-tail) but injects a /chat-only item renderer via the component's optional `renderItems` prop: `renderChatTimeline` in `app/chat/transcript-render.tsx`. Presentation: operator turns render as a right-aligned grey bubble showing the message text and, beneath it, a subtle always-visible time-of-day stamp (no label); delivered agent replies render as plain prose with the same time-of-day stamp beneath (the reply-document filename line stays, as prose); the stamp is HH:MM from the turn's `ts` (locale-formatted, right-aligned inside the operator bubble, left-aligned under agent prose), and a turn whose `ts` is null/unparseable shows no stamp, never an empty line — so only delivered operator and agent-reply turns are stamped, while tool runs, the collapsed "Thinking" block, and the agent-error banner stay time-free; a maximal consecutive run of `tool-call`/`tool-result` turns renders as one collapsed grey one-liner ("Used N tools ›") whose expansion shows every call and result payload — a lone call still gets the one-liner, and prose or a directive row ends a run. The WhatsApp reader (`/whatsapp`) omits the prop and keeps the default `renderTimeline`, so its rendering is unchanged; both presentations are test-pinned (`app/whatsapp/__tests__/Transcript-*.test.tsx`, `app/chat/__tests__/transcript-render.test.tsx`). **Day-divider:** both renderers insert a centered `.day-divider` row between two consecutive timeline items on different local calendar days (label `Today`/`Yesterday`, else `Sat 14 Jun 2026`), so a thread spanning midnight is never an ambiguous run of HH:MM; the first dated item gets a leading divider, a null/unparseable `ts` marks no boundary, dividers are computed over the filtered `visibleItems`, and in `/chat` a day crossover flushes any open tool/think run first so a collapsed run never spans it. Per-bubble HH:MM stamps are unchanged. Shared helpers `dayKey`/`dayLabel`/`itemTs`/`DayDivider` live in `app/whatsapp/Transcript.tsx`.
|
|
2336
2336
|
|
|
2337
|
+
**`/chat` live activity line.** While a turn is in flight the transcript tail shows one ephemeral `ChatActivity` line (mounted only while `busy`, admin/operator only). It tracks real activity, not a timer: while a `Task` subagent runs it shows that subagent's headline (`agentType · description`, prefixed `N agents ·` when ≥2 are concurrent), sourced from `agent-<hex>.meta.json` via named `activity` SSE events the admin reader pushes from the session's `subagents/` dir; with no subagent active it shows a neutral word that advances only on a real turn arrival. The line carries a turn-elapsed clock and flips to a `stalled` state once nothing has been written for 5 minutes (`now − lastEmitAt`), so the operator can tell a wedge from progress without SSH. It is never added to the persisted timeline. Detail and the `[webchat-activity]` observability live in [`admin-webchat-native-channel.md`](../../../.docs/admin-webchat-native-channel.md).
|
|
2338
|
+
|
|
2337
2339
|
**maxy title for public sessions.** A `role=public` webchat spawn never produces a useful Claude Code `ai-title` (an anonymous one-line visitor turn), so every public row would otherwise read identically. The webchat route (`chat.ts`) composes a deterministic title — `Web · <senderId[:8]>[ · <personId>] · <UTC YYYY-MM-DD HH:mm>` (personId present only for gated visitors) — and threads it through the native webchat gateway's public spawn (`handleInbound` → `buildPublicWebchatSpawnRequest` → `managerSpawn`) to the manager `POST /public-spawn` body as `name`. `/spawn` validates it with `validateUserTitle` and, for `role=public` only, writes it into `UserTitleStore` so it occupies the operator-rename tier and wins over `ai-title`. Admin (`/rc-spawn`) and WhatsApp titling are unchanged. Observability: every public spawn logs `[spawn] role=public … title="…"` (or `title=missing`); the manager's row builder emits `[public-title] sessionId=… unexpected titleSource=<ai|null>` on the next list read for any public row that did not resolve from the user tier.
|
|
2338
2340
|
|
|
2339
2341
|
**Public visitor surface.** The public-host root (`GET /`), the `/:slug` agent routes, and the admin-host `/public` / `/public-chat` previews all serve one shell, `public.html` → [`app/public-entry.tsx`](../../../ui/app/public-entry.tsx) → `PublicChat`. `PublicChat` reuses `useSession` purely as the magic-link gatekeeper — it drives `AccessGate`, resolves the agent slug from the path, and handles `?token=` verification; once the grant is satisfied it mounts `ChatSurface variant="public"`, whose transcript reads the visitor-scoped, **delivered-only** stream `GET /api/public-reader/stream` ([`server/routes/public-reader.ts`](../../../ui/server/routes/public-reader.ts)) — the visitor sees the agent's delivered prose, never the tool/tool-result/directive bytes the legacy `/api/chat` SSE render exposed. The human visitor's branding is resolved client-side by `useSession`. For the head only, the server injects per-agent link-preview meta — `<title>`, `og:title`/`og:description`/`og:image`, `theme-color`, and a per-agent favicon, resolved from the same branding cache `useSession` reads — into the served shell, so a link-preview crawler (which never runs the client bundle) gets a branded card per agent instead of one generic shell; an agent with no branding cache gets the clean brand-default shell with no empty meta tags. There is exactly one public client surface and it is 1:1 visitor↔agent: the earlier `?surface=next` A/B handle is retired, group messaging is retired (not a supported product surface), and a former `/g/<slug>` group URL now serves the same 1:1 shell — a stale bookmark gets the 1:1 chat, never a broken render.
|
|
@@ -32,6 +32,7 @@ The plugin registers no agent-facing MCP tools. Every Cloudflare operation is th
|
|
|
32
32
|
| Skill | Purpose |
|
|
33
33
|
|---|---|
|
|
34
34
|
| [cloudflare/SKILL.md](skills/cloudflare/SKILL.md) | The entry point for every Cloudflare operation. Routes each operation class to its reference, names the outcome contracts (tunnel external HTTP 200; hosting deployed URL; D1 round-trip), and states the tool-discipline + secret-redaction rules that bind the agent. |
|
|
35
|
+
| [site-deploy/SKILL.md](skills/site-deploy/SKILL.md) | Assemble one canonical content tree into a live Pages deployment on its custom domain that survives the install device being offline. Names the single-source-tree convention, the reused per-scope token, the form-to-D1 trigger, Open Graph correctness, and the cache-busted live-`200` done-gate; defers the command blocks to `hosting-sites.md` and `d1-data-capture.md`. Admin-run; a content specialist hands the deploy off to it. |
|
|
35
36
|
|
|
36
37
|
### References
|
|
37
38
|
|
|
@@ -16,6 +16,7 @@ This is the entry point for every Cloudflare task on the install. Pick the opera
|
|
|
16
16
|
| Tear down corrupt tunnel state and start clean | `references/reset-guide.md` | a fresh `manual-setup.md` run reaches `200` |
|
|
17
17
|
| Call the Cloudflare API (DNS, zones, tunnels, Access, token list/reuse/revoke) | `references/api.md` | the API call returns success and the change is observable |
|
|
18
18
|
| Deploy a static / Next.js site to Cloudflare Pages | `references/hosting-sites.md` | the deployed URL serves the new build |
|
|
19
|
+
| Take an approved content tree to a live site on its custom domain (canonical source, OG, optional D1 form, live verify) | `Skill site-deploy` | cache-busted `200` on the custom domain with correct content + `og:*`; a form site records a test POST to D1 |
|
|
19
20
|
| Capture form/waitlist submissions into a database | `references/d1-data-capture.md` | a test POST appears in `SELECT … WHERE swept = 0` |
|
|
20
21
|
| Diagnose / enable Web Analytics on a site (0 visitors, no data) | `references/web-analytics.md` | beacon present in live HTML (`curl -s https://<host>/ \| grep cloudflareinsights`) |
|
|
21
22
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: site-deploy
|
|
3
|
+
description: Assemble an approved content tree into a live Cloudflare Pages deployment on its custom domain that survives the install device being offline. This is the skill behind "deploy this site", "put this landing page / investor site / microsite live on its own domain", "publish the waitlist site", and "the live site is stale / shows the wrong brand". It takes one canonical source tree, reuses the per-scope Pages-and-D1 token from the account secrets file, runs `wrangler pages deploy` to the production alias, attaches the custom domain, wires a contact or waitlist form to D1 when the tree carries one, and gates "done" on a cache-busted live `200` with correct content and Open Graph metadata on the production domain. Admin-run; it holds Bash and the account secrets surface. Distinct from `admin:publish-site` (device-tunnel hosting that dies when the device goes offline) and from `sitedesk:microsite` (single-page builder that stops before deploy).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Deploy a site to Cloudflare Pages
|
|
7
|
+
|
|
8
|
+
Run by the admin/main session, the operator-infra surface that holds the `Bash` tool and reads the account secrets file. A content specialist authors and approves the site tree; the deploy itself is admin work because it touches the Cloudflare account credentials. When a `content-producer` finishes a site, it hands the deploy off to admin, which loads this skill.
|
|
9
|
+
|
|
10
|
+
This skill is an outcome contract, not a second copy of the Pages mechanics. The mechanics live in the cloudflare references; this skill names the canonical source convention, the auth discipline, the form-to-D1 wiring trigger, the Open Graph correctness check, and the live done-gate, then defers each command block to the matching reference. Read `references/hosting-sites.md` (build, `wrangler pages deploy`, custom domain) and `references/d1-data-capture.md` (form to Pages Function to D1) via `plugin-read` before issuing those commands; read `references/api.md` for the token discipline.
|
|
11
|
+
|
|
12
|
+
## When this skill, not another
|
|
13
|
+
|
|
14
|
+
| Situation | Skill |
|
|
15
|
+
|---|---|
|
|
16
|
+
| A standalone site (landing, investor, waitlist, microsite output) goes live on its own custom domain, hosted on Cloudflare's edge, surviving the install device being offline | **this skill** |
|
|
17
|
+
| Serve content that the install device itself renders, at a custom domain over the brand tunnel | `admin:publish-site` (device-tunnel `/sites/` path) |
|
|
18
|
+
| Build a single-page site from a brief | `sitedesk:microsite` (it stops before deploy; its output tree is then deployed by this skill) |
|
|
19
|
+
|
|
20
|
+
`admin:publish-site` serves from the install device through the brand tunnel: when the device is offline the site is unreachable. This skill puts the site on Cloudflare Pages, so it stays up independent of the device. Name the distinction when the operator's intent is ambiguous, and pick by whether the site must survive the device going offline.
|
|
21
|
+
|
|
22
|
+
## One canonical source tree
|
|
23
|
+
|
|
24
|
+
Each site is driven by exactly one source tree at:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
<accountDir>/pages/<project>/
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`<project>` is the slug of the site's graph node (the `:Organization` or site entity the site represents), so there is one deterministic path per site and no second copy to reconcile. The tree holds the framework source, `wrangler.toml`, the built output directory, and `functions/api/contact.ts` when the site captures form data.
|
|
31
|
+
|
|
32
|
+
There is never a `sites/<brand>/` versus `output/<brand>-site/` pair, a `.bak` clone, or a stale sibling. If more than one candidate tree exists when you start, reconcile to the single canonical path first: confirm which tree is current, move it to `<accountDir>/pages/<project>/`, and remove the others before deploying. Deploying from an unreconciled tree is the root cause of the dev/live drift this skill exists to prevent. `<accountDir>/pages/` is distinct from `<accountDir>/sites/`, which belongs to the device-tunnel `admin:publish-site` path; the two never share a directory.
|
|
33
|
+
|
|
34
|
+
## Auth: reuse the per-scope token, never mint ad hoc
|
|
35
|
+
|
|
36
|
+
A Pages deploy needs **Account · Cloudflare Pages · Edit**. A site with a D1-backed form also needs **Account · D1 · Edit** on the same token. Load the stable `<brand>-pages-d1` token from the account secrets file, minting it once only if absent and persisting it, exactly as `references/api.md` § Provisioning and reusing a stable per-scope token and `references/d1-data-capture.md` § 0 prescribe. The token is never written into the project tree, never committed, never echoed into chat. The master token stays in the secrets file. Surface every command as verb plus target ("deploying Pages project `<project>`"); `wrangler` prints the deployment URL, which is what gets relayed.
|
|
37
|
+
|
|
38
|
+
## Deploy and attach the custom domain
|
|
39
|
+
|
|
40
|
+
Follow `references/hosting-sites.md`: build the framework output, write `wrangler.toml` (the project name is `<project>`, the output dir is the framework default), and `wrangler pages deploy <output-dir> --project-name <project> --branch=main` to the production alias. Attach the custom domain to the Pages project via the Cloudflare API with the reused `<brand>-pages-d1` token (the operator clicks **Workers & Pages → project → Custom domains** in the dashboard only when they prefer to do it by hand), so the site answers on the operator's domain, not only on `<project>.pages.dev`.
|
|
41
|
+
|
|
42
|
+
Attaching the custom domain to the project is not the same as the domain resolving. The attach leaves validation **pending** until a DNS record in the zone points the hostname at the Pages site; until that record exists the domain returns `NXDOMAIN` and the page is unreachable. Confirm the record exists and create it when it does not:
|
|
43
|
+
|
|
44
|
+
- A **subdomain** custom domain (`investors.example.com`) needs a `CNAME` to `<project>.pages.dev`.
|
|
45
|
+
- An **apex** custom domain (`example.com`) cannot hold a literal CNAME, so it needs a **proxied (flattened) CNAME** at the apex to `<project>.pages.dev`, plus a `www` CNAME if `www` is also served. This is the case the attach most often leaves pending.
|
|
46
|
+
|
|
47
|
+
Creating that DNS record is a **DNS-scope** operation, not a Pages-scope one: the `<brand>-pages-d1` token returns a `10000 Authentication error` on the DNS endpoint. Use the DNS-scoped `<brand>-dns` token, exactly as `cloudflare/SKILL.md` § Apex hostnames and `references/api.md` § DNS records prescribe, minting it once if absent. The hostname's zone must be on the same Cloudflare account; a hostname whose zone lives in a different account is reached with that account's own `<brand>-dns` token, never by probing a drawer of ad-hoc `CF_DNS_TOKEN_*` strays (that token sprawl is what the `references/api.md` reconcile pass exists to clear). The custom domain is the surface the done-gate checks, so a missing record fails the gate rather than passing silently.
|
|
48
|
+
|
|
49
|
+
## Wire the form to D1 when one exists
|
|
50
|
+
|
|
51
|
+
When the canonical tree carries a contact or waitlist form, the deploy is not complete until that form records to D1. Follow `references/d1-data-capture.md`: create the database, add the `[[d1_databases]]` binding to `wrangler.toml`, create the table, and ship `functions/api/contact.ts`. The token from the auth step already carries D1 Edit, so the form does not silently 500 at the insert. A site with no form skips this section.
|
|
52
|
+
|
|
53
|
+
## Open Graph correctness
|
|
54
|
+
|
|
55
|
+
The live page must carry brand-correct Open Graph metadata: `<meta property="og:title">`, `og:description`, `og:image`, and `og:url` reflecting this site and this brand, not a leftover from whatever the tree was cloned from. Stale OG (a live card still naming a previous brand) is a recorded failure mode of hand deployment. Confirm the `og:*` tags in the deployed HTML are correct before claiming done; if they are stale, fix them in the source tree, redeploy, and re-check.
|
|
56
|
+
|
|
57
|
+
## Done-gate: a live cache-busted 200 on the custom domain
|
|
58
|
+
|
|
59
|
+
"Done" is never "I ran deploy". It is a live behavioural signal on the **production custom domain**, surfaced verbatim in chat:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
curl -s "https://<custom-domain>/?cb=$(date +%s)" -D - -o /tmp/site.html | head -1
|
|
63
|
+
grep -o '<meta property="og:[^>]*>' /tmp/site.html
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The done-gate passes only when all hold:
|
|
67
|
+
|
|
68
|
+
- the domain resolves at all: a `NXDOMAIN` or "could not resolve host" means the custom-domain DNS record was never created, so go back to the custom-domain step and create the CNAME with the `<brand>-dns` token; a `522` is the certificate-provisioning window, so wait and re-curl rather than treating it as done,
|
|
69
|
+
- the status line is `HTTP/2 200` (or `HTTP/1.1 200 OK`),
|
|
70
|
+
- the body carries an expected content marker for this site (a brand string or headline you can name in advance), not a stale marker from a previous brand,
|
|
71
|
+
- the `og:*` tags are present and brand-correct,
|
|
72
|
+
- for a form-bearing site, a test POST appears in D1 unswept, per `references/d1-data-capture.md` § Outcome contract. This sub-check reuses the same `${PAGES_D1}` token and `<db-name>` already loaded when the form was wired, not a fresh token:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
curl -sS -X POST -d "email=test@example.com&name=verify" "https://<custom-domain>/api/contact" -i | head -1
|
|
76
|
+
CLOUDFLARE_API_TOKEN="${PAGES_D1}" wrangler d1 execute <db-name> --remote --command \
|
|
77
|
+
"SELECT email FROM leads WHERE swept = 0;"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Any other status, a missing or stale content marker, an OG mismatch, or a form POST that does not land in D1 blocks "done" and is reported with the exact failing URL. Record the deployment alias and the cache-busted verification command in the result so a later operator re-runs the same check against the custom domain to confirm live state without reading source.
|
|
81
|
+
|
|
82
|
+
## Tool discipline
|
|
83
|
+
|
|
84
|
+
The permitted surface is `wrangler`, `curl`, and the Cloudflare API via Bash following the cloudflare references, using the reused per-scope token. The agent does not mint a fresh token per deploy, does not drive the dashboard with Playwright or Chrome DevTools, does not synthesise `wrangler` flags from web search, and never writes or echoes a token. When a step fails, report the exact output with secrets redacted, name the failing URL or command, and stop.
|
|
@@ -69,6 +69,8 @@ either is a regression.
|
|
|
69
69
|
|
|
70
70
|
**`/chat` Claude-desktop transcript presentation.** The admin webchat keeps the shared `Transcript` shell (stream, follow-tail) but injects a /chat-only item renderer via the component's optional `renderItems` prop: `renderChatTimeline` in `app/chat/transcript-render.tsx`. Presentation: operator turns render as a right-aligned grey bubble showing the message text and, beneath it, a subtle always-visible time-of-day stamp (no label); delivered agent replies render as plain prose with the same time-of-day stamp beneath (the reply-document filename line stays, as prose); the stamp is HH:MM from the turn's `ts` (locale-formatted, right-aligned inside the operator bubble, left-aligned under agent prose), and a turn whose `ts` is null/unparseable shows no stamp, never an empty line — so only delivered operator and agent-reply turns are stamped, while tool runs, the collapsed "Thinking" block, and the agent-error banner stay time-free; a maximal consecutive run of `tool-call`/`tool-result` turns renders as one collapsed grey one-liner ("Used N tools ›") whose expansion shows every call and result payload — a lone call still gets the one-liner, and prose or a directive row ends a run. The WhatsApp reader (`/whatsapp`) omits the prop and keeps the default `renderTimeline`, so its rendering is unchanged; both presentations are test-pinned (`app/whatsapp/__tests__/Transcript-*.test.tsx`, `app/chat/__tests__/transcript-render.test.tsx`). **Day-divider:** both renderers insert a centered `.day-divider` row between two consecutive timeline items on different local calendar days (label `Today`/`Yesterday`, else `Sat 14 Jun 2026`), so a thread spanning midnight is never an ambiguous run of HH:MM; the first dated item gets a leading divider, a null/unparseable `ts` marks no boundary, dividers are computed over the filtered `visibleItems`, and in `/chat` a day crossover flushes any open tool/think run first so a collapsed run never spans it. Per-bubble HH:MM stamps are unchanged. Shared helpers `dayKey`/`dayLabel`/`itemTs`/`DayDivider` live in `app/whatsapp/Transcript.tsx`.
|
|
71
71
|
|
|
72
|
+
**`/chat` live activity line.** While a turn is in flight the transcript tail shows one ephemeral `ChatActivity` line (mounted only while `busy`, admin/operator only). It tracks real activity, not a timer: while a `Task` subagent runs it shows that subagent's headline (`agentType · description`, prefixed `N agents ·` when ≥2 are concurrent), sourced from `agent-<hex>.meta.json` via named `activity` SSE events the admin reader pushes from the session's `subagents/` dir; with no subagent active it shows a neutral word that advances only on a real turn arrival. The line carries a turn-elapsed clock and flips to a `stalled` state once nothing has been written for 5 minutes (`now − lastEmitAt`), so the operator can tell a wedge from progress without SSH. It is never added to the persisted timeline. Detail and the `[webchat-activity]` observability live in [`admin-webchat-native-channel.md`](../../../.docs/admin-webchat-native-channel.md).
|
|
73
|
+
|
|
72
74
|
**maxy title for public sessions.** A `role=public` webchat spawn never produces a useful Claude Code `ai-title` (an anonymous one-line visitor turn), so every public row would otherwise read identically. The webchat route (`chat.ts`) composes a deterministic title — `Web · <senderId[:8]>[ · <personId>] · <UTC YYYY-MM-DD HH:mm>` (personId present only for gated visitors) — and threads it through the native webchat gateway's public spawn (`handleInbound` → `buildPublicWebchatSpawnRequest` → `managerSpawn`) to the manager `POST /public-spawn` body as `name`. `/spawn` validates it with `validateUserTitle` and, for `role=public` only, writes it into `UserTitleStore` so it occupies the operator-rename tier and wins over `ai-title`. Admin (`/rc-spawn`) and WhatsApp titling are unchanged. Observability: every public spawn logs `[spawn] role=public … title="…"` (or `title=missing`); the manager's row builder emits `[public-title] sessionId=… unexpected titleSource=<ai|null>` on the next list read for any public row that did not resolve from the user tier.
|
|
73
75
|
|
|
74
76
|
**Public visitor surface.** The public-host root (`GET /`), the `/:slug` agent routes, and the admin-host `/public` / `/public-chat` previews all serve one shell, `public.html` → [`app/public-entry.tsx`](../../../ui/app/public-entry.tsx) → `PublicChat`. `PublicChat` reuses `useSession` purely as the magic-link gatekeeper — it drives `AccessGate`, resolves the agent slug from the path, and handles `?token=` verification; once the grant is satisfied it mounts `ChatSurface variant="public"`, whose transcript reads the visitor-scoped, **delivered-only** stream `GET /api/public-reader/stream` ([`server/routes/public-reader.ts`](../../../ui/server/routes/public-reader.ts)) — the visitor sees the agent's delivered prose, never the tool/tool-result/directive bytes the legacy `/api/chat` SSE render exposed. The human visitor's branding is resolved client-side by `useSession`. For the head only, the server injects per-agent link-preview meta — `<title>`, `og:title`/`og:description`/`og:image`, `theme-color`, and a per-agent favicon, resolved from the same branding cache `useSession` reads — into the served shell, so a link-preview crawler (which never runs the client bundle) gets a branded card per agent instead of one generic shell; an agent with no branding cache gets the clean brand-default shell with no empty meta tags. There is exactly one public client surface and it is 1:1 visitor↔agent: the earlier `?surface=next` A/B handle is retired, group messaging is retired (not a supported product surface), and a former `/g/<slug>` group URL now serves the same 1:1 shell — a stale bookmark gets the 1:1 chat, never a broken render.
|
|
@@ -44,6 +44,8 @@ When a brief carries a "host this website" / "publish this site" / "put this onl
|
|
|
44
44
|
|
|
45
45
|
Confirm the slug with the operator before publishing. The slug is one or more `/`-separated segments under `<accountDir>/sites/`. Refusals are loud-fail; relay the tool's `Operator action:` line verbatim and stop. On success, take the `pathSlug` from the tool response, call `mcp__plugin_admin_admin__public-hostname`, and surface `https://<hostname><pathSlug>` to the operator as one line.
|
|
46
46
|
|
|
47
|
+
`publish-site` serves from the install device through the brand tunnel: when the device is offline the site is unreachable. When the brief instead wants a site live on its **own custom domain that survives the device being offline** (a Cloudflare Pages deployment), that is a different hosting model and a different seat. You do not hold Cloudflare account authority, so you do not deploy it yourself. Assemble and approve the one canonical source tree at `<accountDir>/pages/<project>/` (`<project>` = the slug of the site's graph node; no `sites/`-vs-`output/` duplicate), then hand the deploy back to the admin session to run `Skill site-deploy`, which owns the Cloudflare credentials, the form-to-D1 wiring, the Open Graph check, and the live cache-busted done-gate on the custom domain. Return to admin naming the canonical tree path and the expected custom domain.
|
|
48
|
+
|
|
47
49
|
## File delivery
|
|
48
50
|
|
|
49
51
|
To deliver a generated file, write it under `accounts/<accountId>/output/` (any depth) and report its full `output/` path in your result. As a subagent you do not send the file yourself; the main session delivers it, and the path you report is what it delivers. On a turn that arrived over a message channel (web chat, WhatsApp, Telegram) the main session calls `SendUserFile` with the path and the platform forwards the file to that conversation as an attachment. On the admin dashboard the same `output/` file appears in the Artefacts panel and is reachable on the file share. See the `file-presentation` skill for the wording.
|
|
@@ -3113,6 +3113,22 @@ function renderLoginPage(opts) {
|
|
|
3113
3113
|
const displayFont = opts?.displayFont ?? "'Newsreader', Georgia, serif";
|
|
3114
3114
|
const bodyFont = opts?.bodyFont ?? "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
|
|
3115
3115
|
const logoContainsName = opts?.logoContainsName ?? false;
|
|
3116
|
+
const tagline = opts?.tagline ?? "";
|
|
3117
|
+
const origin = opts?.origin ?? "";
|
|
3118
|
+
const ogCardPath = opts?.ogCardPath ?? "";
|
|
3119
|
+
const ogBlock = origin && ogCardPath ? [
|
|
3120
|
+
`<meta property="og:title" content="Sign in \u2014 ${escapeHtml(productName)}">`,
|
|
3121
|
+
`<meta property="og:description" content="${escapeHtml(tagline)}">`,
|
|
3122
|
+
`<meta property="og:type" content="website">`,
|
|
3123
|
+
`<meta property="og:url" content="${escapeHtml(origin)}">`,
|
|
3124
|
+
`<meta property="og:image" content="${escapeHtml(origin + ogCardPath)}">`,
|
|
3125
|
+
`<meta property="og:image:width" content="1200">`,
|
|
3126
|
+
`<meta property="og:image:height" content="630">`,
|
|
3127
|
+
`<meta name="twitter:card" content="summary_large_image">`,
|
|
3128
|
+
`<meta name="twitter:title" content="Sign in \u2014 ${escapeHtml(productName)}">`,
|
|
3129
|
+
`<meta name="twitter:description" content="${escapeHtml(tagline)}">`,
|
|
3130
|
+
`<meta name="twitter:image" content="${escapeHtml(origin + ogCardPath)}">`
|
|
3131
|
+
].join("\n ") : "";
|
|
3116
3132
|
const errorHtml = error ? `<p class="msg msg--error">${escapeHtml(error)}</p>` : "";
|
|
3117
3133
|
const changeErrorHtml = changeError ? `<p class="msg msg--error">${escapeHtml(changeError)}</p>` : "";
|
|
3118
3134
|
const successHtml = success ? `<p class="msg msg--success">${escapeHtml(success)}</p>` : "";
|
|
@@ -3148,6 +3164,7 @@ function renderLoginPage(opts) {
|
|
|
3148
3164
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3149
3165
|
<title>Sign in \u2014 ${escapeHtml(productName)}</title>
|
|
3150
3166
|
<link rel="icon" href="${escapeHtml(faviconPath)}">
|
|
3167
|
+
${ogBlock}
|
|
3151
3168
|
${googleFontsLink}
|
|
3152
3169
|
<style>
|
|
3153
3170
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|