@rubytech/create-maxy-code 0.1.379 → 0.1.383
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__/plugin-install.test.js +18 -1
- package/dist/index.js +22 -8
- package/dist/lib/plugin-install.js +22 -0
- package/package.json +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +23 -8
- package/payload/platform/plugins/docs/references/admin-ui.md +11 -6
- package/payload/platform/plugins/docs/references/attachments.md +5 -1
- package/payload/platform/plugins/docs/references/internals.md +4 -0
- package/payload/platform/plugins/docs/references/plugins-guide.md +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/d1-command.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/d1-command.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/d1-command.test.js +23 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/d1-command.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.d.ts +12 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.js +14 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/d1-command.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +3 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
- package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +1 -1
- package/payload/platform/services/claude-session-manager/dist/account-title-stores.d.ts +21 -0
- package/payload/platform/services/claude-session-manager/dist/account-title-stores.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/account-title-stores.js +71 -0
- package/payload/platform/services/claude-session-manager/dist/account-title-stores.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +2 -2
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +46 -103
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +45 -8
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/skill-registration-drift.d.ts +51 -0
- package/payload/platform/services/claude-session-manager/dist/skill-registration-drift.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/skill-registration-drift.js +163 -0
- package/payload/platform/services/claude-session-manager/dist/skill-registration-drift.js.map +1 -0
- package/payload/server/package.json +2 -1
- package/payload/server/public/assets/{AdminLoginScreens-BHo8w_9J.js → AdminLoginScreens-C32Kg8HN.js} +1 -1
- package/payload/server/public/assets/AdminShell-DizHgGBB.js +1 -0
- package/payload/server/public/assets/{Checkbox-LmjNFccS.js → Checkbox-C-dvkjKb.js} +1 -1
- package/payload/server/public/assets/{admin-BBWILfl8.js → admin-CSBsndZ4.js} +1 -1
- package/payload/server/public/assets/admin-types-hioowVct.js +1 -0
- package/payload/server/public/assets/{browser-DtLvD_ES.js → browser-BUuA2PPl.js} +1 -1
- package/payload/server/public/assets/{calendar-D3i4IqOV.js → calendar-CGlyx54B.js} +1 -1
- package/payload/server/public/assets/chat-DT1PbRkD.js +1 -0
- package/payload/server/public/assets/data-BC4KTIX4.js +1 -0
- package/payload/server/public/assets/{graph-ClQeGEH4.js → graph-CvQZVxwZ.js} +1 -1
- package/payload/server/public/assets/{graph-labels-OaIKHgMg.js → graph-labels-C7bJMcXs.js} +1 -1
- package/payload/server/public/assets/{operator-Dxqfta5U.js → operator-4skjNtEa.js} +1 -1
- package/payload/server/public/assets/{page-Dv_YEOUW.js → page-B0zvkWaG.js} +1 -1
- package/payload/server/public/assets/page-BYlBTwqJ.js +32 -0
- package/payload/server/public/assets/{public-C_mM8sSZ.js → public-ClYyeEA1.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-VF0b6NTt.js → rotate-ccw-DPCP-Lmz.js} +1 -1
- package/payload/server/public/assets/useSubAccountSwitcher-B8FEcQNM.js +9 -0
- package/payload/server/public/assets/{useSubAccountSwitcher-VzvzZC7h.css → useSubAccountSwitcher-DqxG4I5C.css} +1 -1
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/calendar.html +4 -4
- package/payload/server/public/chat.html +7 -7
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +7 -7
- package/payload/server/public/index.html +9 -9
- package/payload/server/public/operator.html +9 -9
- package/payload/server/public/public.html +7 -7
- package/payload/server/server.js +301 -82
- package/payload/server/public/assets/AdminShell-DbTijFVA.js +0 -1
- package/payload/server/public/assets/admin-types-DJoj6VJv.js +0 -1
- package/payload/server/public/assets/chat-DU-T6blN.js +0 -1
- package/payload/server/public/assets/data-B_vDxQrO.js +0 -1
- package/payload/server/public/assets/page-BkIb7CHh.js +0 -32
- package/payload/server/public/assets/useSubAccountSwitcher-CQbgG-FH.js +0 -9
|
@@ -4,7 +4,24 @@
|
|
|
4
4
|
// permanent verification artifact.
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
|
-
import {
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { parsePluginList, computeInstallActions, parseExternalPlugins, findUnregisteredResyncs, discoverMarketplaceCandidateDirs, } from "../lib/plugin-install.js";
|
|
9
|
+
test("discoverMarketplaceCandidateDirs lists platform, each bundle's nested plugins dir, then premium root", () => {
|
|
10
|
+
const out = discoverMarketplaceCandidateDirs("/opt/app", ["sitedesk", "real-agent", ".claude-plugin"]);
|
|
11
|
+
assert.deepEqual(out, [
|
|
12
|
+
join("/opt/app", "platform", "plugins"),
|
|
13
|
+
join("/opt/app", "premium-plugins", "real-agent", "plugins"),
|
|
14
|
+
join("/opt/app", "premium-plugins", "sitedesk", "plugins"),
|
|
15
|
+
join("/opt/app", "premium-plugins"),
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
test("discoverMarketplaceCandidateDirs with no premium bundles returns platform + premium root", () => {
|
|
19
|
+
const out = discoverMarketplaceCandidateDirs("/opt/app", []);
|
|
20
|
+
assert.deepEqual(out, [
|
|
21
|
+
join("/opt/app", "platform", "plugins"),
|
|
22
|
+
join("/opt/app", "premium-plugins"),
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
8
25
|
test("parsePluginList extracts name@marketplace tuples", () => {
|
|
9
26
|
const stdout = `
|
|
10
27
|
❯ work@maxy-platform
|
package/dist/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import { parseSwVers, isSupportedMacosVersion } from "./macos-version.js";
|
|
|
20
20
|
import { decideChromiumAction, isSnapConfinedPath } from "./snap-chromium.js";
|
|
21
21
|
import { classifyPortHolder } from "./preflight-port-classifier.js";
|
|
22
22
|
import { memoryControllerMissing, cmdlineAlreadyPatched, patchCmdline } from "./cgroup-memory-controller.js";
|
|
23
|
-
import { parsePluginList, computeInstallActions, parseExternalPlugins, findUnregisteredResyncs, } from "./lib/plugin-install.js";
|
|
23
|
+
import { parsePluginList, computeInstallActions, parseExternalPlugins, findUnregisteredResyncs, discoverMarketplaceCandidateDirs, } from "./lib/plugin-install.js";
|
|
24
24
|
import { findPremiumMcpDirs } from "./lib/premium-mcp-discover.js";
|
|
25
25
|
import { pickBindDecision, mergeSmbConf, formatSambaMarker, SAMBA_ENABLE_UNITS, } from "./samba-provision.js";
|
|
26
26
|
import { networkInterfaces, userInfo, cpus } from "node:os";
|
|
@@ -2194,13 +2194,27 @@ function deployPayload() {
|
|
|
2194
2194
|
function registerLocalAndExternalPlugins() {
|
|
2195
2195
|
console.log(" Registering local + external Claude Code plugins...");
|
|
2196
2196
|
const localTrees = [];
|
|
2197
|
-
//
|
|
2198
|
-
//
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2197
|
+
// Marketplace.json probe list: platform plugins, every nested premium bundle's
|
|
2198
|
+
// `plugins/` dir (each a `maxy-premium-<bundle>` marketplace), and the premium
|
|
2199
|
+
// root (`maxy-premium`). Discovered from the brand-filtered install tree, so
|
|
2200
|
+
// only shipped bundles appear; candidates whose marketplace.json is absent are
|
|
2201
|
+
// skipped by the existSync gate below. Replaces the prior hardcode that named
|
|
2202
|
+
// only real-agent's nested dir and silently skipped every other nested bundle
|
|
2203
|
+
// (e.g. sitedesk), leaving its sub-plugins' skills unregistered (Task 1340).
|
|
2204
|
+
let premiumBundleDirs = [];
|
|
2205
|
+
const premiumRootDir = join(INSTALL_DIR, "premium-plugins");
|
|
2206
|
+
try {
|
|
2207
|
+
premiumBundleDirs = readdirSync(premiumRootDir).filter((name) => {
|
|
2208
|
+
try {
|
|
2209
|
+
return statSync(join(premiumRootDir, name)).isDirectory();
|
|
2210
|
+
}
|
|
2211
|
+
catch {
|
|
2212
|
+
return false;
|
|
2213
|
+
}
|
|
2214
|
+
});
|
|
2215
|
+
}
|
|
2216
|
+
catch { /* no premium tree on this brand — leave empty */ }
|
|
2217
|
+
const candidates = discoverMarketplaceCandidateDirs(INSTALL_DIR, premiumBundleDirs);
|
|
2204
2218
|
for (const dir of candidates) {
|
|
2205
2219
|
const mkPath = join(dir, ".claude-plugin", "marketplace.json");
|
|
2206
2220
|
if (!existsSync(mkPath))
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// callers in src/index.ts wrap these with the actual `spawnSync` / fs reads;
|
|
3
3
|
// keeping the logic side-effect-free here makes the install / configure
|
|
4
4
|
// branches unit-testable without stubbing every shell invocation.
|
|
5
|
+
import { join } from "node:path";
|
|
5
6
|
/**
|
|
6
7
|
* `claude plugin list` prints rows like:
|
|
7
8
|
* ❯ work@maxy-platform
|
|
@@ -124,3 +125,24 @@ export function parseExternalPlugins(raw) {
|
|
|
124
125
|
}
|
|
125
126
|
return out;
|
|
126
127
|
}
|
|
128
|
+
/** Ordered list of directories the installer probes for a local
|
|
129
|
+
* `.claude-plugin/marketplace.json`. The bundler stamps one at
|
|
130
|
+
* `platform/plugins/`, at each nested premium bundle's `plugins/` dir
|
|
131
|
+
* (`maxy-premium-<bundle>`), and at the premium root (`maxy-premium`).
|
|
132
|
+
* `premiumBundleDirs` is the raw `readdirSync` of `<installDir>/premium-plugins`;
|
|
133
|
+
* dotfile entries (`.claude-plugin`) are excluded and the remainder is sorted
|
|
134
|
+
* so the probe order is deterministic. Pure — the caller's existence check
|
|
135
|
+
* skips any returned dir whose marketplace.json is absent, so non-nested
|
|
136
|
+
* bundles cost nothing. Replaces the prior hardcode that named only
|
|
137
|
+
* real-agent's nested dir and silently skipped every other nested bundle. */
|
|
138
|
+
export function discoverMarketplaceCandidateDirs(installDir, premiumBundleDirs) {
|
|
139
|
+
const bundles = premiumBundleDirs
|
|
140
|
+
.filter((name) => !name.startsWith("."))
|
|
141
|
+
.sort();
|
|
142
|
+
const out = [join(installDir, "platform", "plugins")];
|
|
143
|
+
for (const bundle of bundles) {
|
|
144
|
+
out.push(join(installDir, "premium-plugins", bundle, "plugins"));
|
|
145
|
+
}
|
|
146
|
+
out.push(join(installDir, "premium-plugins"));
|
|
147
|
+
return out;
|
|
148
|
+
}
|
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:86a7d5decfa1f569dcb4a23a7506fe36f23947c44dc68d4c43277be01c6418e2
|
|
5
5
|
brand: maxy-code
|
|
6
6
|
product-name: Maxy
|
|
7
7
|
---
|
|
@@ -420,6 +420,8 @@ There is no per-account purchase record; the brand decides the shipping set.
|
|
|
420
420
|
|
|
421
421
|
Some premium plugins are **bundles** — multiple sub-plugins shipped under one directory in `premium-plugins/`, each independently activatable. For example, Real Agent ships 10 sub-plugins covering different aspects of estate agency work. They are all enabled by default. Sub-plugins you don't want active can be turned off individually with "disable <name>"; enabling or disabling individual sub-plugins does not affect the others.
|
|
422
422
|
|
|
423
|
+
A bundle's sub-plugins carry the skills, so each must be registered as its own Claude Code plugin for its skills to load — the installer does this at install time for every bundle the brand ships. When a sub-plugin's skills are missing (a specialist dispatches but its skill answers *Unknown skill*), the manager names the gap at boot: grep `server.log` for `[skill-registration] op=drift` — each line gives the account, the sub-plugin, and which registration surface is missing. The fix is to re-run the installer for this brand; a clean boot instead logs `startup-self-test skill-registration=ok`.
|
|
424
|
+
|
|
423
425
|
If you ask Maxy about a tool from a plugin your brand does not ship (for example, a Maxy install asking about a Real Agent Loop CRM tool), Maxy responds with a structured `<tool-surface-error>` envelope naming the missing plugin and the remedy, rather than improvising with a generic alternative.
|
|
424
426
|
|
|
425
427
|
**Public agent embedding:** Premium plugins marked as public-eligible have their full content (skills and reference knowledge) embedded in public agent prompts. This means a public agent for a Real Agent member can handle buyer enquiries, book viewings, deliver coaching content, and onboard new applicants — all powered by the premium plugin's domain knowledge. Plugins marked admin-only (listings, vendors, leads, business) are only available to the account owner's admin agent.
|
|
@@ -2149,7 +2151,11 @@ Source: https://docs.getmaxy.com/attachments.md
|
|
|
2149
2151
|
|
|
2150
2152
|
# Admin Chat Attachments
|
|
2151
2153
|
|
|
2152
|
-
What you can drag-and-drop into the admin chat window, what happens to each file, and the size caps.
|
|
2154
|
+
What you can drag-and-drop or paste into the admin chat window, what happens to each file, and the size caps.
|
|
2155
|
+
|
|
2156
|
+
## How files get in
|
|
2157
|
+
|
|
2158
|
+
You can drag-and-drop a file, pick one with the paperclip, or paste. All three routes stage the file the same way and enforce the same caps. Pasting a file you copied from your file manager (or another app) keeps its real name and type, so `report.pdf` stays `report.pdf`. Pasting a screenshot or a raw clipboard image has no filename to keep, so it is staged under a generated unique name like `pasted-image-1700000000000.png`. Pasting plain text still types the text into the message box as before.
|
|
2153
2159
|
|
|
2154
2160
|
## Accepted file types
|
|
2155
2161
|
|
|
@@ -2531,7 +2537,7 @@ either is a regression.
|
|
|
2531
2537
|
| `/session` | Admin cookie session: PIN-gated mint, validate, rotate. | `GET /`, `POST /` |
|
|
2532
2538
|
| `/sessions` | Legacy admin-server conversation routes. No UI consumer remains after the ConversationsModal was retired; the surviving handlers are deletion candidates and not described here. | (legacy, no live caller) |
|
|
2533
2539
|
| `/sidebar-sessions` | Sole data path for the sidebar Sessions list. One JSONL on disk equals one row. The row's delete button is the only way a row disappears. Each row carries `sessionId`, `title`, `startedAt`, `live`, `isSubagent`, `pid: number \| null` (basename of the matched `sessions/<pid>.json`), and `projectDir` (the directory holding the JSONL — consumed by the delete route). The payload also carries top-level `accountId` so the pane renders the full UUID label whose first ~8 chars prefix-match the truncated Remote Control daemon entry in claude.ai/code. The legacy `rcUrl` field is gone — the row's external-link affordance now POSTs `/session-rc-spawn` to start a fresh local `claude --remote-control <name> --session-id <sid>` PTY on every click. | `GET /` |
|
|
2534
|
-
| `/session-delete` | POST `{ sessionId }`. Thin proxy over the manager: POST `/:id/stop` (idempotent) then DELETE `/:id`.
|
|
2540
|
+
| `/session-delete` | POST `{ sessionId }`. Thin proxy over the manager: POST `/:id/stop` (idempotent) then DELETE `/:id`. Sends **no** accountId. The manager resolves the session by scanning every slug under `projects/` for its JSONL (`slugForExistingJsonl`, covering `archive/`), exactly as the sidebar list enumerates rows, and gates on file existence alone — so any session the operator can see is deletable. This replaces the earlier accountId-derived slug and the `!row` term, which narrowed resolution below the list and left a session under a non-house slug with no watcher row (e.g. a legacy `-home-admin` session on a single-account install) listed yet un-deletable. Account scope is a view filter, not a delete gate — admin access is install-wide, so there is nothing to scope against. The sibling `/session-stop` (the standalone End control) resolves identically and also forwards no accountId. | `POST /` |
|
|
2535
2541
|
| `/session-rc-spawn` | POST `{ sessionId?, name? }`. Fire-and-forget `claude --remote-control [name] [--session-id <sid>]`. Present `sessionId` resumes; absent starts a fresh session (also used by the sidebar's "New session" button — it no longer opens claude.ai/code directly). Proxies to the manager's `/rc-spawn`, which waits up to **60 s** (raised from 12 s) for the spawned PTY to bind and returns `{ spawnedPid, sessionId, bridgeSessionId, slug, outcome, reason }`. For a webchat-bound spawn (every admin-gated host's "New session", returning a same-origin `/chat?session=<id>` target — `resolveRcSpawnOutcome` → `sameOrigin:true`) the Sidebar navigates the **current** tab via `window.location.assign`, replacing the dashboard in place (back returns to it); only a claude.ai/code slug (`sameOrigin:false`, the bare-admin resume bridge, never a new-session outcome) navigates a separately-opened tab. On `timeout` or `spawn-failed` it shows an error modal (reason + sessionId) and **never** opens a bare claude.ai/code tab. The new process registers itself as its own Remote Control entry in claude.ai/code. | `POST /` |
|
|
2536
2542
|
| `/claude-sessions` | **Spawn surface only**. `POST /` is the Sidebar new-session-with-prompt path, cookie-auth only (the recorder loopback caller was removed; LinkedIn ingest moved to `/rc-spawn`). The former UI-facing handlers (SSE row feed, list, resume, stop, rename, archive, delete, `/:id/meta`, `/:id/input`, `/:id/log`) were removed — the maxy dashboard no longer manages or displays sessions. | `POST /` |
|
|
2537
2543
|
|
|
@@ -2541,6 +2547,8 @@ either is a regression.
|
|
|
2541
2547
|
|
|
2542
2548
|
**`/chat` empty-state starter chips.** While the admin webchat has no conversation JSONL yet (`GET /api/webchat/session` → `projectDir:null`), `app/chat/page.tsx` renders a "What's up next, {givenName}?" greeting (given name = first whitespace token of the admin session's `userName`; degrades to "What's up next?" without one) above three one-tap starter chips (replacing the earlier read-only lists): **Catch me up** (`Inbox`), **My day** (`CalendarClock`), **To-do** (`ListTodo`). `GreetingPanel` no longer fetches `GET /api/webchat/greeting` — the headline reads from the `userName` prop alone. A tap dispatches the chip's verbatim prompt through the existing `send()` with zero typing, via the same arm/fire path as the `?q=` handoff seed: `dispatchChip` logs `[admin-ui] op=chip-tap id=<catch-up|my-day|to-do> chars=<n>`, sets the composer text, and arms a one-shot effect that fires `send()` once the text matches. Chips gate to `variant` admin/operator; the public surface shows the headline alone. The first journaled turn replaces the panel with the transcript. The `/api/webchat/greeting` route (`server/routes/webchat-greeting.ts`) and `specialist-roster.ts` are now unconsumed by this surface — slimming them is a separate follow-up. The full webchat architecture lives in `.docs/admin-webchat-native-channel.md`.
|
|
2543
2549
|
|
|
2550
|
+
**`/chat` composer transport button.** The button at the end of the input shows a **microphone** while the box is empty — tap it to record a voice note. Type a character, or attach a file, and the same button becomes the **send** arrow; while the agent is replying it becomes a **stop** square. So an empty composer offers voice, a composer with something to send offers send, and a running turn offers stop — one button, three states. The microphone-device chooser (which input to record from) stays in the row of icons below the box. This is the same on the admin `/chat`, the public chat, and the maxy-lite webchat.
|
|
2551
|
+
|
|
2544
2552
|
**`/chat` Conversations flyout + zero-sessions splash.** The admin `/chat` `HeaderMenu` carries a **Conversations** item (rendered only when `onOpenConversations` is wired, which scopes it to `/chat`) that opens an in-chat session-management pane (`app/chat/SessionList.tsx`) hosted by `chat/page.tsx` — distinct from, and sharing endpoints with, the `Sidebar` Sessions list (which stays hidden on narrow viewports). It enumerates the admin's own webchat sessions via `GET /api/admin/sidebar-sessions` (carries the per-row `live` marker and `archived` flag, install-wide), lists them newest-first with the live one marked and archived rows folded under a collapsible subsection, and offers per-row resume (`/chat?session=<id>`), rename, archive/unarchive, two-tap delete, an **End** control on the live row (`session-stop`), plus a New-session control (`session-rc-spawn`) and a copyable full id. When the canonical pointer is `known:false` (`canonical-empty`) and enumeration returns zero rows, the surface renders a splash (brand logo + New session) instead of a dead bootstrap thread; a freshly-spawned New session is `known:true`, so its greeting is preserved. Client breadcrumbs: `[chat-conversations] op=enumerate owned=<n>` and `op=action name=<open|rename|archive|delete|stop> sessionId=<id8>` (`op=action-failed … status=…` on a non-2xx). Detail lives in `.docs/admin-webchat-native-channel.md` ("Conversations flyout + zero-sessions splash").
|
|
2545
2553
|
|
|
2546
2554
|
**`/chat` header always shows the account name.** The admin/operator `HeaderMenu` centre always renders the active account's name alongside the brand logo, on every chat surface, regardless of the active conversation — `headerTitle = businessName || brand.productName`. There is no per-conversation header title: the active conversation's label stays in the sidebar and the Conversations flyout (each resolves its own per-session title independently), never in the header. The account name is `LocalBusiness.name`, resolved by `fetchAccountName(accountId)` in `neo4j-store.ts` with **no theme gate** — unlike `fetchBranding` (which returns null unless a colour/logo property is also set, and stays the source for actual theming). The house account is seeded with `LocalBusiness.name = <brand productName>` (`seed-neo4j.sh`, coalesced ON MATCH so a re-seed backfills a name-less node and never clobbers a name the business-profile recorder set later), so the sub-account picker's house row shows the product name (e.g. "SiteDesk"), not the raw accountId UUID. When a name is absent the resolver emits `[branding] account-name-fallback accountId=<id8>… reason=no-name`, and the picker falls back to the accountId; the seed + backfill is the name source. The lite variant header is unchanged.
|
|
@@ -2819,8 +2827,11 @@ authoritative. This section names the surfaces and what backs each.
|
|
|
2819
2827
|
> (`.admin-shell-root:has(> .platform-operator) > .admin-header`), the chat-column
|
|
2820
2828
|
> clamp (`.platform-operator > .webchat-page`), and the `/data` clamp
|
|
2821
2829
|
> (`.data-main`) all read it, so logo and burger align to the panel edges on both
|
|
2822
|
-
> surfaces.
|
|
2823
|
-
>
|
|
2830
|
+
> surfaces. In the full admin shell the one `--chat-measure` consumer is the
|
|
2831
|
+
> main-column WhatsApp/Telegram viewer (`.platform > .wa-reader`), clamped and
|
|
2832
|
+
> centred so its wallpaper reads as a bounded column, not an edge-to-edge
|
|
2833
|
+
> surface; the rest of the admin shell stays edge-to-edge. The public webchat
|
|
2834
|
+
> (`.public-surface`) inherits the same value.
|
|
2824
2835
|
>
|
|
2825
2836
|
> **Conversations item.** The dynamic Conversations burger item appears on
|
|
2826
2837
|
> **both** the operator chat surface and `/data` whenever ≥1 reviewable
|
|
@@ -2837,10 +2848,10 @@ authoritative. This section names the surfaces and what backs each.
|
|
|
2837
2848
|
| `+ New session` button | Opens `NewSessionModal`, which POSTs `{channel, permissionMode, model, initialMessage}` to `/api/admin/claude-sessions`. | `claude-sessions.ts` |
|
|
2838
2849
|
| Mode trigger | Per-`(accountId, userId)` `SpawnPreference` for `permissionMode` and `model`; persists across reload, tab, device. | `session-defaults.ts` |
|
|
2839
2850
|
| Nav rows (Chat / People / Agents / Projects / Tasks / Artefacts) | People, Agents, Tasks open the artefact-pane Graph filtered to the matching label. Chat selects the active conversation. Artefacts swaps the list to editable documents. **The "Projects" row is brand-aware:** its visible term and the graph label it filters to both come from the injected `window.__BRAND__.primaryContainer` (`brand.json` → `server/index.ts` inject → `brand.ts` `brandPrimaryContainer()`), so Maxy reads "Projects" → `:Project`, SiteDesk "Jobs" → `:Job`, Real Agent "Properties" → `:Property`. A brand JSON missing `primaryContainer` falls back to Project/Projects (server logs `[brand] op=primaryContainer-default reason=absent brand=<hostname>`; the populated case logs `[brand] op=inject primaryContainer=<label>/<term>`). | `graph-search.ts`, `graph-subgraph.ts`, `sidebar-artefacts.ts` |
|
|
2840
|
-
| Sessions list (Active / Archived / All) | Live row store driven by SSE; manual reconcile button on the segmented control re-fetches the full id set. Every listed row carries two launch actions: the claude.ai/code resume (`ExternalLink`) and a chat-launch (`MessageSquare`) that opens `/chat?session=<full sessionId>` — the admin webchat pointed at that exact session; sending there resumes a stopped session with the webchat channel bound to that id (see `admin-webchat-native-channel.md`, "Session-targeted mode"). At the ≤720 px drawer breakpoint each row stacks into two lines — dot + title + id/stamp, then the action icons — with a divider between rows and actions at full opacity (the desktop hover-gated 0.5 opacity never resolves on touch). Below a 400 px list width each row's action icons collapse to a single `MoreHorizontal` ellipsis trigger; its popover menu lists the same actions as the inline icons but labels them with concise verbs (`Resume in claude.ai/code`, `Open in webchat`, `Stop`, `Archive` / `Unarchive`, `Rename`, `Delete`, `Re-seat`) rather than the inline icon buttons' verbose per-session aria-labels — the inline buttons keep those verbose labels because an icon-only control needs the full descriptive accessible name. **Re-seat:** Re-seat is a member action of the one `SessionRowActions` cluster, not a separate sibling control — wide it is the sliders icon inline with the other icons (sharing the cluster geometry and alignment); collapsed it is the `Re-seat` menu item, which expands the model/mode/effort form inline inside the overflow menu. The form (`SessionReseatControl`) forks the session via `/api/admin/session-reseat` and navigates to the fork (the same fork mechanism `/chat`'s pickers drive); its model picker defaults to the row's current model (a `model` field on each row, read from the JSONL tail), and mode and effort each carry a "Keep" option that omits the field. The overflow menu and the Re-seat form render through a `document.body` portal, fixed-positioned from the trigger's rect (right-aligned, flipped above the trigger when there is no room below), so neither is clipped by `.side-list`'s `overflow-y:auto`; Escape, a pointerdown outside the trigger/popover, and any scroll or resize dismiss them. | `/claude-sessions/events`, `/claude-sessions`, `/session-reseat` |
|
|
2851
|
+
| Sessions list (Active / Archived / All) | Live row store driven by SSE; manual reconcile button on the segmented control re-fetches the full id set. Every listed row carries two launch actions: the claude.ai/code resume (`ExternalLink`) and a chat-launch (`MessageSquare`) that opens `/chat?session=<full sessionId>` — the admin webchat pointed at that exact session; sending there resumes a stopped session with the webchat channel bound to that id (see `admin-webchat-native-channel.md`, "Session-targeted mode"). **Clicking the row's body** (live-dot, title, id, or timestamp) opens that same `/chat?session=<id>` as the chat-launch action. The main area stays a `<div>` (it wraps the inline rename input, which a `<button>` may not contain) carrying `role="button"`, `tabIndex`, an "Open <title> in webchat" aria-label, and Enter/Space activation, styled with `conv-main-clickable` for pointer + hover parity with the channel-row button. The click and keyboard handlers no-op while the row is being renamed, so an in-progress edit is preserved; the action icons and the `⋯` overflow menu are a sibling cluster running their own handlers, so activating them never triggers the row-body open. At the ≤720 px drawer breakpoint each row stacks into two lines — dot + title + id/stamp, then the action icons — with a divider between rows and actions at full opacity (the desktop hover-gated 0.5 opacity never resolves on touch). Below a 400 px list width each row's action icons collapse to a single `MoreHorizontal` ellipsis trigger; its popover menu lists the same actions as the inline icons but labels them with concise verbs (`Resume in claude.ai/code`, `Open in webchat`, `Stop`, `Archive` / `Unarchive`, `Rename`, `Delete`, `Re-seat`) rather than the inline icon buttons' verbose per-session aria-labels — the inline buttons keep those verbose labels because an icon-only control needs the full descriptive accessible name. **Re-seat:** Re-seat is a member action of the one `SessionRowActions` cluster, not a separate sibling control — wide it is the sliders icon inline with the other icons (sharing the cluster geometry and alignment); collapsed it is the `Re-seat` menu item, which expands the model/mode/effort form inline inside the overflow menu. The form (`SessionReseatControl`) forks the session via `/api/admin/session-reseat` and navigates to the fork (the same fork mechanism `/chat`'s pickers drive); its model picker defaults to the row's current model (a `model` field on each row, read from the JSONL tail), and mode and effort each carry a "Keep" option that omits the field. The overflow menu and the Re-seat form render through a `document.body` portal, fixed-positioned from the trigger's rect (right-aligned, flipped above the trigger when there is no room below), so neither is clipped by `.side-list`'s `overflow-y:auto`; Escape, a pointerdown outside the trigger/popover, and any scroll or resize dismiss them. | `/claude-sessions/events`, `/claude-sessions`, `/session-reseat` |
|
|
2841
2852
|
| Data pane | A "Data" nav row beside Artefacts/Sessions. Uses the same `activeNav` toggle, but its content — the headless `DataFileBrowser` (graph search + `{installDir}/data/` file browser) — renders in the shell's **main column** via `DataPaneSurface`, like the WhatsApp Transcript, not in the side-list; there is no side-list block under `activeNav==='data'`. `AdminShell` owns a `dataOpen` main-column state (mutually exclusive with the WhatsApp selection); the row lifts the choice through `onSelectData`. On `/` it opens in place; from any other route (`/graph`, `/chat`, `/browser`) it navigates to `/?data=1`, which the root shell hydrates on mount (`hydrateDataFromUrl`, then strips the query). The admin burger no longer carries a Data item (the operator surface, which has no Sidebar, keeps the burger → `/data` route). Client breadcrumbs: `[admin-ui] sidebar-nav surface=data …`, `[admin-ui] data-open route=… via=<in-place\|navigate>`, `[admin-ui] data-hydrate route=/`. | `sidebar-artefacts.ts`, `files.ts`, `graph-search.ts` |
|
|
2842
|
-
| Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row per channel that has ≥1 conversation. Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no conversations shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount**, so a failed load shows one muted, non-clickable line instead of channel rows. **WhatsApp rows are sourced from the on-disk message store** (`readAllConversations`), one row per stored conversation keyed by `remoteJid`, independent of whether any session JSONL exists — a blocked non-admin DM or a group-observed thread that never produced a session is listed here. **Telegram rows are session-sourced** (one per admin Telegram session), unchanged. Each conversation row shows a **display name**: for WhatsApp, the store record's `senderName` (else the `senderTelephone`, else the `remoteJid`); for Telegram, the precedence `operatorName ?? senderId ?? title` where `operatorName` is the bound `Person givenName familyName`. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`) from `lastMessageAt`, never "Invalid Date". **Scope colour (WhatsApp):** each WhatsApp row is coloured by its conversation's scope — a thin left accent bar, green for `admin`, orange for `public`, taken from the conversation's most-recent stored record (this replaces the former "Public" text pill). **Re-seat:** only session-sourced rows (Telegram, and webchat in the Sessions panel) carry a `SessionRowActions` Re-seat cluster; a WhatsApp store row has no session, so it carries no Re-seat. A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route it navigates to `/?wa=<remoteJid>&acct=<accountId>`, which the root shell hydrates on mount (then strips the query). Server logs `[wa-reader] op=list
|
|
2843
|
-
| Channel transcript (`<Transcript>`) | **WhatsApp** reads its conversation verbatim from the message store file (`<remoteJid>.jsonl`) over SSE (`/whatsapp-reader/store-stream`): both directions, `dateSent`-ordered, backlog on connect then a live tail so a newly stored message appears without reload — no session JSONL is read. **Telegram** still reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). On the Telegram session stream, `tool-call`/`tool-result` turns render as **collapsed cards** so the human↔agent text is not buried in JSON. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom**; while scrolled up a **jump-to-bottom control** (`.wa-jump`) appears. Both streams send a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tag each frame with a byte-offset `id:`, and resume from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. The Telegram session stream also interleaves a collapsed `⚙ directive injected (N B)` row per turn (listed by `GET /whatsapp-reader/directives?sessionId=`, bytes served by `GET /whatsapp-reader/directive?sessionId=&name=`); a WhatsApp store conversation has no session, so it has no directives. A centered day-divider row (`.day-divider`) marks each local calendar-day crossover. The store stream open logs `[wa-reader] op=open remoteJid=<jid> scope=<admin\|public> messages=<n> source=store`. | `/whatsapp-reader/store-stream`, `/whatsapp-reader/stream`, `/whatsapp-reader/directives`, `/whatsapp-reader/directive` |
|
|
2853
|
+
| Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row per channel that has ≥1 conversation. Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no conversations shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount**, so a failed load shows one muted, non-clickable line instead of channel rows. **WhatsApp rows are sourced from the on-disk message store** (`readAllConversations`), one row per stored conversation keyed by `remoteJid`, independent of whether any session JSONL exists — a blocked non-admin DM or a group-observed thread that never produced a session is listed here. **Telegram rows are session-sourced** (one per admin Telegram session), unchanged. Each conversation row shows a **display name**: for WhatsApp, the store record's `senderName` (else the `senderTelephone`, else the `remoteJid`); for Telegram, the precedence `operatorName ?? senderId ?? title` where `operatorName` is the bound `Person givenName familyName`. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`) from `lastMessageAt`, never "Invalid Date". **Scope colour (WhatsApp):** each WhatsApp row is coloured by its conversation's scope — a thin left accent bar, green for `admin`, orange for `public`, taken from the conversation's most-recent stored record (this replaces the former "Public" text pill). **Re-seat:** only session-sourced rows (Telegram, and webchat in the Sessions panel) carry a `SessionRowActions` Re-seat cluster; a WhatsApp store row has no session, so it carries no Re-seat. A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route it navigates to `/?wa=<remoteJid>&acct=<accountId>`, which the root shell hydrates on mount (then strips the query). **Account-scoped** exactly as the sidebar Sessions list: both row sources (WhatsApp store rows and Telegram/public-webchat session rows) are scoped to the caller's pinned account (`getAccountIdForSession(cacheKey)`), multi-account-gated (`listValidAccounts().length > 1`). On a multi-account install a subaccount view shows only its own conversations; the house account's WhatsApp appears only under the house view; an untagged session sidecar is excluded and counted; an unresolvable scope fails closed to an empty list (logs `op=list-scope-unresolved`). A single-account install filters nothing, byte-identical to before. Server logs `[wa-reader] op=list pinnedAccount=<id8> resolvedAccount=<id8> multiAccount=<bool> conversations=<n> sessionRows=<m> storeRows=<k> adminScope=<n> publicScope=<n> sessionRowsExcludedUntagged=<n>` per list build (`pinnedAccount === resolvedAccount` is the post-fix invariant when `multiAccount=true`; `conversations=0` on an account with a non-empty store dir is the over-filtering drift signal); the client logs `[admin-ui] wa-open route=… via=<in-place\|navigate> …` and `[admin-ui] wa-hydrate route=/ remoteJid=…`. | `/whatsapp-reader/conversations` |
|
|
2854
|
+
| Channel transcript (`<Transcript>`) | **WhatsApp** reads its conversation verbatim from the message store file (`<remoteJid>.jsonl`) over SSE (`/whatsapp-reader/store-stream`): both directions, `dateSent`-ordered, backlog on connect then a live tail so a newly stored message appears without reload — no session JSONL is read. **Telegram** still reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). On the Telegram session stream, `tool-call`/`tool-result` turns render as **collapsed cards** so the human↔agent text is not buried in JSON. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom**; while scrolled up a **jump-to-bottom control** (`.wa-jump`) appears. Both streams send a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tag each frame with a byte-offset `id:`, and resume from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. The Telegram session stream also interleaves a collapsed `⚙ directive injected (N B)` row per turn (listed by `GET /whatsapp-reader/directives?sessionId=`, bytes served by `GET /whatsapp-reader/directive?sessionId=&name=`); a WhatsApp store conversation has no session, so it has no directives. A centered day-divider row (`.day-divider`) marks each local calendar-day crossover. This viewer renders **delivered messages only** (both WhatsApp store rows and Telegram session rows, via `forceDeliveredOnly`) with **no per-viewer toggle** — the former "Messages only" pill is retired; the admin `/chat` composer's own messages-only control is unaffected. The panel — wallpaper included — is **bounded to `--chat-measure` and centred** (`.platform > .wa-reader`), so it reads as a column the same width as `/data`, not an edge-to-edge beige surface. The store stream open logs `[wa-reader] op=open remoteJid=<jid> scope=<admin\|public> messages=<n> source=store`. | `/whatsapp-reader/store-stream`, `/whatsapp-reader/stream`, `/whatsapp-reader/directives`, `/whatsapp-reader/directive` |
|
|
2844
2855
|
| Conversations row hover actions | Inline rename, archive, delete, JSONL view / download per row. The historical `.conversations-modal` CSS block exists in `globals.css` but is no longer mounted from any TSX — Sidebar.tsx now owns every per-row affordance directly. | `claude-sessions.ts` |
|
|
2845
2856
|
| Artefacts list | Lists every `:FileArtifact` under this account's tree (`relativePath STARTS WITH 'accounts/'`, all file types, excluding the `uploads/<id>/` subtree) plus this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates. Click downloads the row's backing file (`downloadPath` → `GET /api/admin/files/download`) so the operator opens it in their local app; rows whose file is outside `DATA_ROOT` (bundled-fallback templates) show a "can't be downloaded" pill. The in-app artefact pane is dead pending removal. | `sidebar-artefacts.ts`, `files.ts` |
|
|
2846
2857
|
| System-stats widget | CPU / RAM widget at the foot of the sidebar. | `system-stats.ts` (see above) |
|
|
@@ -3678,6 +3689,10 @@ The `brand-excluded` branch closes the recurring crash-restart loop on brands th
|
|
|
3678
3689
|
|
|
3679
3690
|
**Brand-foreign premium bundles.** This closes the loop one layer up: the installer bundler at [`packages/create-maxy-code/scripts/bundle.js`](../../../../packages/create-maxy-code/scripts/bundle.js) now applies the same `brand.json#shipsPremiumBundles` gate at *payload assembly time*, so foreign bundles never reach disk on the device. The gate is shared with the test suite via [`scripts/premium-bundle-gate.mjs`](../../../../packages/create-maxy-code/scripts/premium-bundle-gate.mjs) and accepts only two shapes — `undefined` / missing → ships nothing; `string[]` → ships only the named bundles. The legacy boolean `true` form is **rejected**: bundle.js hard-fails with `FATAL: brand.shipsPremiumBundles must be a string[] (boolean 'true' no longer accepted; enumerate bundles in <brand.json>)`. An allowlist entry naming a bundle directory that is absent on disk is also FATAL — silent over-shipping is the failure mode this gate exists to prevent. Each build emits one `[bundler] premium-bundle-gate brand=<n> mode=<m> shipped=[…] skipped=[…]` line. The runtime gate `walkPremiumBundles` at [`plugin-manifest.ts`](../../../ui/app/lib/claude-agent/plugin-manifest.ts) keeps the same shape and stays as defence-in-depth — on a correctly bundled payload, it walks only allowlisted bundles because foreign ones are not present. The drift-gate's `agents-dir-skipped reason=brand-foreign-bundle` line therefore fires only when something has staged a foreign bundle out-of-band.
|
|
3680
3691
|
|
|
3692
|
+
**Premium sub-plugin Claude Code registration.** A premium bundle takes one of two shapes on disk. A **standalone** bundle (`premium-plugins/<bundle>/` with a `PLUGIN.md` and no `plugins/` subdir — `teaching`, `venture-studio`, `writer-craft`) is a single plugin under the `maxy-premium` marketplace. A **nested-marketplace** bundle (`premium-plugins/<bundle>/plugins/<sub>/` — `real-agent`, `sitedesk`) carries only bundle-level agents at its root; its skills live in sub-plugins, each a full plugin under a per-bundle marketplace named `maxy-premium-<bundle>`. The bundler ([`scripts/generate-plugin-manifests.mjs`](../../../../scripts/generate-plugin-manifests.mjs)) stamps a `.claude-plugin/marketplace.json` at `platform/plugins/` (`maxy-platform`), at each nested bundle's `plugins/` dir (`maxy-premium-<bundle>`), and at the premium root (`maxy-premium`). The installer ([`registerLocalAndExternalPlugins` in `packages/create-maxy-code/src/index.ts`](../../../../packages/create-maxy-code/src/index.ts)) discovers **every** `premium-plugins/<bundle>/plugins/` on the brand-filtered install tree — not a hardcoded subset — adds each marketplace, and installs every listed plugin by qualified `name@marketplace` at `--scope user`, which enables it in `settings.json enabledPlugins`. Three Claude Code registration surfaces then gate skill loading: `known_marketplaces.json` (marketplace registered), `installed_plugins.json` (a `scope:"user"` row for `name@marketplace`), and `settings.json enabledPlugins` (`name@marketplace` enabled). A nested sub-plugin absent from any of the three loads none of its skills — `Skill(<name>)` returns *Unknown skill* at runtime even though Maxy's own `account.json enabledPlugins` (stamped by `reconcileEnabledPlugins`, which keys on `plugins/<sub>/PLUGIN.md` presence) lists it.
|
|
3693
|
+
|
|
3694
|
+
**Boot-time skill-availability gate.** A manager boot gate ([`skill-registration-drift.ts`](../../../services/claude-session-manager/src/skill-registration-drift.ts)) closes the silent divergence between that Maxy intent and the Claude Code registration surfaces. It resolves each premium sub named in every account's `account.json enabledPlugins` to its expected `name@marketplace` (nested sub → `maxy-premium-<bundle>`, standalone → `maxy-premium`) by descending the on-disk premium tree, then checks all three surfaces. For each enabled-but-unregistered sub it emits one `[skill-registration] op=drift FATAL accountId=<id> plugin=<sub> expected=<name@marketplace> missing=<surfaces>` line (best-effort mirrored to journald via `systemd-cat -t maxy-csm`), naming exactly which surface lacks the entry. Unlike the specialist-tool-drift gate this one does **not** `process.exit` — a mis-registered sub-plugin must not brick the whole install, so boot continues and the operator keeps a working admin for repair (re-running the corrected installer for the brand). A clean pass emits `startup-self-test skill-registration=ok inspected=<N>`.
|
|
3695
|
+
|
|
3681
3696
|
**Structured journald mirror for boot-failed.** Every `boot-failed reason=specialist-tool-drift …` line is mirrored to journald via `systemd-cat -t maxy-csm -p err` with the fields `specialist=`, `tool=`, `drift_reason=`, `agent_path=` so `journalctl --user -u <brand>-claude-session-manager.service -t maxy-csm` can filter by any of them without grep on `server.log`. The stdout line stays unchanged so the existing diagnostic one-liners keep working. `systemd-cat` absence (e.g. macOS dev box) is swallowed — the stdout line is the primary surface; the structured emit is auxiliary.
|
|
3682
3697
|
|
|
3683
3698
|
**Per-spawn signals (server.log).** Every spawn emits `pty-spawn-mcp-config servers=<N> tools=<M> bytes=<B> path=<…>` once, plus one `pty-spawn-agents-dir role=<admin|public> path=<…>` per added directory. Specialist spawns additionally emit `pty-spawn-allowlist specialist=<name> count=<N> stripped=<S> sourced-from=agent-frontmatter` where `stripped` is the count of brand-excluded tool names removed before argv emission. The diagnostic one-liner is `grep -E 'pty-spawn-mcp-config|pty-spawn-agents-dir|pty-spawn-allowlist|mcp-config-allowlist-coverage|specialist-tool-strip|boot-failed reason=' ~/.<brand>/logs/server.log | tail -50`.
|
|
@@ -57,7 +57,7 @@ either is a regression.
|
|
|
57
57
|
| `/session` | Admin cookie session: PIN-gated mint, validate, rotate. | `GET /`, `POST /` |
|
|
58
58
|
| `/sessions` | Legacy admin-server conversation routes. No UI consumer remains after the ConversationsModal was retired; the surviving handlers are deletion candidates and not described here. | (legacy, no live caller) |
|
|
59
59
|
| `/sidebar-sessions` | Sole data path for the sidebar Sessions list. One JSONL on disk equals one row. The row's delete button is the only way a row disappears. Each row carries `sessionId`, `title`, `startedAt`, `live`, `isSubagent`, `pid: number \| null` (basename of the matched `sessions/<pid>.json`), and `projectDir` (the directory holding the JSONL — consumed by the delete route). The payload also carries top-level `accountId` so the pane renders the full UUID label whose first ~8 chars prefix-match the truncated Remote Control daemon entry in claude.ai/code. The legacy `rcUrl` field is gone — the row's external-link affordance now POSTs `/session-rc-spawn` to start a fresh local `claude --remote-control <name> --session-id <sid>` PTY on every click. | `GET /` |
|
|
60
|
-
| `/session-delete` | POST `{ sessionId }`. Thin proxy over the manager: POST `/:id/stop` (idempotent) then DELETE `/:id`.
|
|
60
|
+
| `/session-delete` | POST `{ sessionId }`. Thin proxy over the manager: POST `/:id/stop` (idempotent) then DELETE `/:id`. Sends **no** accountId. The manager resolves the session by scanning every slug under `projects/` for its JSONL (`slugForExistingJsonl`, covering `archive/`), exactly as the sidebar list enumerates rows, and gates on file existence alone — so any session the operator can see is deletable. This replaces the earlier accountId-derived slug and the `!row` term, which narrowed resolution below the list and left a session under a non-house slug with no watcher row (e.g. a legacy `-home-admin` session on a single-account install) listed yet un-deletable. Account scope is a view filter, not a delete gate — admin access is install-wide, so there is nothing to scope against. The sibling `/session-stop` (the standalone End control) resolves identically and also forwards no accountId. | `POST /` |
|
|
61
61
|
| `/session-rc-spawn` | POST `{ sessionId?, name? }`. Fire-and-forget `claude --remote-control [name] [--session-id <sid>]`. Present `sessionId` resumes; absent starts a fresh session (also used by the sidebar's "New session" button — it no longer opens claude.ai/code directly). Proxies to the manager's `/rc-spawn`, which waits up to **60 s** (raised from 12 s) for the spawned PTY to bind and returns `{ spawnedPid, sessionId, bridgeSessionId, slug, outcome, reason }`. For a webchat-bound spawn (every admin-gated host's "New session", returning a same-origin `/chat?session=<id>` target — `resolveRcSpawnOutcome` → `sameOrigin:true`) the Sidebar navigates the **current** tab via `window.location.assign`, replacing the dashboard in place (back returns to it); only a claude.ai/code slug (`sameOrigin:false`, the bare-admin resume bridge, never a new-session outcome) navigates a separately-opened tab. On `timeout` or `spawn-failed` it shows an error modal (reason + sessionId) and **never** opens a bare claude.ai/code tab. The new process registers itself as its own Remote Control entry in claude.ai/code. | `POST /` |
|
|
62
62
|
| `/claude-sessions` | **Spawn surface only**. `POST /` is the Sidebar new-session-with-prompt path, cookie-auth only (the recorder loopback caller was removed; LinkedIn ingest moved to `/rc-spawn`). The former UI-facing handlers (SSE row feed, list, resume, stop, rename, archive, delete, `/:id/meta`, `/:id/input`, `/:id/log`) were removed — the maxy dashboard no longer manages or displays sessions. | `POST /` |
|
|
63
63
|
|
|
@@ -67,6 +67,8 @@ either is a regression.
|
|
|
67
67
|
|
|
68
68
|
**`/chat` empty-state starter chips.** While the admin webchat has no conversation JSONL yet (`GET /api/webchat/session` → `projectDir:null`), `app/chat/page.tsx` renders a "What's up next, {givenName}?" greeting (given name = first whitespace token of the admin session's `userName`; degrades to "What's up next?" without one) above three one-tap starter chips (replacing the earlier read-only lists): **Catch me up** (`Inbox`), **My day** (`CalendarClock`), **To-do** (`ListTodo`). `GreetingPanel` no longer fetches `GET /api/webchat/greeting` — the headline reads from the `userName` prop alone. A tap dispatches the chip's verbatim prompt through the existing `send()` with zero typing, via the same arm/fire path as the `?q=` handoff seed: `dispatchChip` logs `[admin-ui] op=chip-tap id=<catch-up|my-day|to-do> chars=<n>`, sets the composer text, and arms a one-shot effect that fires `send()` once the text matches. Chips gate to `variant` admin/operator; the public surface shows the headline alone. The first journaled turn replaces the panel with the transcript. The `/api/webchat/greeting` route (`server/routes/webchat-greeting.ts`) and `specialist-roster.ts` are now unconsumed by this surface — slimming them is a separate follow-up. The full webchat architecture lives in `.docs/admin-webchat-native-channel.md`.
|
|
69
69
|
|
|
70
|
+
**`/chat` composer transport button.** The button at the end of the input shows a **microphone** while the box is empty — tap it to record a voice note. Type a character, or attach a file, and the same button becomes the **send** arrow; while the agent is replying it becomes a **stop** square. So an empty composer offers voice, a composer with something to send offers send, and a running turn offers stop — one button, three states. The microphone-device chooser (which input to record from) stays in the row of icons below the box. This is the same on the admin `/chat`, the public chat, and the maxy-lite webchat.
|
|
71
|
+
|
|
70
72
|
**`/chat` Conversations flyout + zero-sessions splash.** The admin `/chat` `HeaderMenu` carries a **Conversations** item (rendered only when `onOpenConversations` is wired, which scopes it to `/chat`) that opens an in-chat session-management pane (`app/chat/SessionList.tsx`) hosted by `chat/page.tsx` — distinct from, and sharing endpoints with, the `Sidebar` Sessions list (which stays hidden on narrow viewports). It enumerates the admin's own webchat sessions via `GET /api/admin/sidebar-sessions` (carries the per-row `live` marker and `archived` flag, install-wide), lists them newest-first with the live one marked and archived rows folded under a collapsible subsection, and offers per-row resume (`/chat?session=<id>`), rename, archive/unarchive, two-tap delete, an **End** control on the live row (`session-stop`), plus a New-session control (`session-rc-spawn`) and a copyable full id. When the canonical pointer is `known:false` (`canonical-empty`) and enumeration returns zero rows, the surface renders a splash (brand logo + New session) instead of a dead bootstrap thread; a freshly-spawned New session is `known:true`, so its greeting is preserved. Client breadcrumbs: `[chat-conversations] op=enumerate owned=<n>` and `op=action name=<open|rename|archive|delete|stop> sessionId=<id8>` (`op=action-failed … status=…` on a non-2xx). Detail lives in `.docs/admin-webchat-native-channel.md` ("Conversations flyout + zero-sessions splash").
|
|
71
73
|
|
|
72
74
|
**`/chat` header always shows the account name.** The admin/operator `HeaderMenu` centre always renders the active account's name alongside the brand logo, on every chat surface, regardless of the active conversation — `headerTitle = businessName || brand.productName`. There is no per-conversation header title: the active conversation's label stays in the sidebar and the Conversations flyout (each resolves its own per-session title independently), never in the header. The account name is `LocalBusiness.name`, resolved by `fetchAccountName(accountId)` in `neo4j-store.ts` with **no theme gate** — unlike `fetchBranding` (which returns null unless a colour/logo property is also set, and stays the source for actual theming). The house account is seeded with `LocalBusiness.name = <brand productName>` (`seed-neo4j.sh`, coalesced ON MATCH so a re-seed backfills a name-less node and never clobbers a name the business-profile recorder set later), so the sub-account picker's house row shows the product name (e.g. "SiteDesk"), not the raw accountId UUID. When a name is absent the resolver emits `[branding] account-name-fallback accountId=<id8>… reason=no-name`, and the picker falls back to the accountId; the seed + backfill is the name source. The lite variant header is unchanged.
|
|
@@ -345,8 +347,11 @@ authoritative. This section names the surfaces and what backs each.
|
|
|
345
347
|
> (`.admin-shell-root:has(> .platform-operator) > .admin-header`), the chat-column
|
|
346
348
|
> clamp (`.platform-operator > .webchat-page`), and the `/data` clamp
|
|
347
349
|
> (`.data-main`) all read it, so logo and burger align to the panel edges on both
|
|
348
|
-
> surfaces.
|
|
349
|
-
>
|
|
350
|
+
> surfaces. In the full admin shell the one `--chat-measure` consumer is the
|
|
351
|
+
> main-column WhatsApp/Telegram viewer (`.platform > .wa-reader`), clamped and
|
|
352
|
+
> centred so its wallpaper reads as a bounded column, not an edge-to-edge
|
|
353
|
+
> surface; the rest of the admin shell stays edge-to-edge. The public webchat
|
|
354
|
+
> (`.public-surface`) inherits the same value.
|
|
350
355
|
>
|
|
351
356
|
> **Conversations item.** The dynamic Conversations burger item appears on
|
|
352
357
|
> **both** the operator chat surface and `/data` whenever ≥1 reviewable
|
|
@@ -363,10 +368,10 @@ authoritative. This section names the surfaces and what backs each.
|
|
|
363
368
|
| `+ New session` button | Opens `NewSessionModal`, which POSTs `{channel, permissionMode, model, initialMessage}` to `/api/admin/claude-sessions`. | `claude-sessions.ts` |
|
|
364
369
|
| Mode trigger | Per-`(accountId, userId)` `SpawnPreference` for `permissionMode` and `model`; persists across reload, tab, device. | `session-defaults.ts` |
|
|
365
370
|
| Nav rows (Chat / People / Agents / Projects / Tasks / Artefacts) | People, Agents, Tasks open the artefact-pane Graph filtered to the matching label. Chat selects the active conversation. Artefacts swaps the list to editable documents. **The "Projects" row is brand-aware:** its visible term and the graph label it filters to both come from the injected `window.__BRAND__.primaryContainer` (`brand.json` → `server/index.ts` inject → `brand.ts` `brandPrimaryContainer()`), so Maxy reads "Projects" → `:Project`, SiteDesk "Jobs" → `:Job`, Real Agent "Properties" → `:Property`. A brand JSON missing `primaryContainer` falls back to Project/Projects (server logs `[brand] op=primaryContainer-default reason=absent brand=<hostname>`; the populated case logs `[brand] op=inject primaryContainer=<label>/<term>`). | `graph-search.ts`, `graph-subgraph.ts`, `sidebar-artefacts.ts` |
|
|
366
|
-
| Sessions list (Active / Archived / All) | Live row store driven by SSE; manual reconcile button on the segmented control re-fetches the full id set. Every listed row carries two launch actions: the claude.ai/code resume (`ExternalLink`) and a chat-launch (`MessageSquare`) that opens `/chat?session=<full sessionId>` — the admin webchat pointed at that exact session; sending there resumes a stopped session with the webchat channel bound to that id (see `admin-webchat-native-channel.md`, "Session-targeted mode"). At the ≤720 px drawer breakpoint each row stacks into two lines — dot + title + id/stamp, then the action icons — with a divider between rows and actions at full opacity (the desktop hover-gated 0.5 opacity never resolves on touch). Below a 400 px list width each row's action icons collapse to a single `MoreHorizontal` ellipsis trigger; its popover menu lists the same actions as the inline icons but labels them with concise verbs (`Resume in claude.ai/code`, `Open in webchat`, `Stop`, `Archive` / `Unarchive`, `Rename`, `Delete`, `Re-seat`) rather than the inline icon buttons' verbose per-session aria-labels — the inline buttons keep those verbose labels because an icon-only control needs the full descriptive accessible name. **Re-seat:** Re-seat is a member action of the one `SessionRowActions` cluster, not a separate sibling control — wide it is the sliders icon inline with the other icons (sharing the cluster geometry and alignment); collapsed it is the `Re-seat` menu item, which expands the model/mode/effort form inline inside the overflow menu. The form (`SessionReseatControl`) forks the session via `/api/admin/session-reseat` and navigates to the fork (the same fork mechanism `/chat`'s pickers drive); its model picker defaults to the row's current model (a `model` field on each row, read from the JSONL tail), and mode and effort each carry a "Keep" option that omits the field. The overflow menu and the Re-seat form render through a `document.body` portal, fixed-positioned from the trigger's rect (right-aligned, flipped above the trigger when there is no room below), so neither is clipped by `.side-list`'s `overflow-y:auto`; Escape, a pointerdown outside the trigger/popover, and any scroll or resize dismiss them. | `/claude-sessions/events`, `/claude-sessions`, `/session-reseat` |
|
|
371
|
+
| Sessions list (Active / Archived / All) | Live row store driven by SSE; manual reconcile button on the segmented control re-fetches the full id set. Every listed row carries two launch actions: the claude.ai/code resume (`ExternalLink`) and a chat-launch (`MessageSquare`) that opens `/chat?session=<full sessionId>` — the admin webchat pointed at that exact session; sending there resumes a stopped session with the webchat channel bound to that id (see `admin-webchat-native-channel.md`, "Session-targeted mode"). **Clicking the row's body** (live-dot, title, id, or timestamp) opens that same `/chat?session=<id>` as the chat-launch action. The main area stays a `<div>` (it wraps the inline rename input, which a `<button>` may not contain) carrying `role="button"`, `tabIndex`, an "Open <title> in webchat" aria-label, and Enter/Space activation, styled with `conv-main-clickable` for pointer + hover parity with the channel-row button. The click and keyboard handlers no-op while the row is being renamed, so an in-progress edit is preserved; the action icons and the `⋯` overflow menu are a sibling cluster running their own handlers, so activating them never triggers the row-body open. At the ≤720 px drawer breakpoint each row stacks into two lines — dot + title + id/stamp, then the action icons — with a divider between rows and actions at full opacity (the desktop hover-gated 0.5 opacity never resolves on touch). Below a 400 px list width each row's action icons collapse to a single `MoreHorizontal` ellipsis trigger; its popover menu lists the same actions as the inline icons but labels them with concise verbs (`Resume in claude.ai/code`, `Open in webchat`, `Stop`, `Archive` / `Unarchive`, `Rename`, `Delete`, `Re-seat`) rather than the inline icon buttons' verbose per-session aria-labels — the inline buttons keep those verbose labels because an icon-only control needs the full descriptive accessible name. **Re-seat:** Re-seat is a member action of the one `SessionRowActions` cluster, not a separate sibling control — wide it is the sliders icon inline with the other icons (sharing the cluster geometry and alignment); collapsed it is the `Re-seat` menu item, which expands the model/mode/effort form inline inside the overflow menu. The form (`SessionReseatControl`) forks the session via `/api/admin/session-reseat` and navigates to the fork (the same fork mechanism `/chat`'s pickers drive); its model picker defaults to the row's current model (a `model` field on each row, read from the JSONL tail), and mode and effort each carry a "Keep" option that omits the field. The overflow menu and the Re-seat form render through a `document.body` portal, fixed-positioned from the trigger's rect (right-aligned, flipped above the trigger when there is no room below), so neither is clipped by `.side-list`'s `overflow-y:auto`; Escape, a pointerdown outside the trigger/popover, and any scroll or resize dismiss them. | `/claude-sessions/events`, `/claude-sessions`, `/session-reseat` |
|
|
367
372
|
| Data pane | A "Data" nav row beside Artefacts/Sessions. Uses the same `activeNav` toggle, but its content — the headless `DataFileBrowser` (graph search + `{installDir}/data/` file browser) — renders in the shell's **main column** via `DataPaneSurface`, like the WhatsApp Transcript, not in the side-list; there is no side-list block under `activeNav==='data'`. `AdminShell` owns a `dataOpen` main-column state (mutually exclusive with the WhatsApp selection); the row lifts the choice through `onSelectData`. On `/` it opens in place; from any other route (`/graph`, `/chat`, `/browser`) it navigates to `/?data=1`, which the root shell hydrates on mount (`hydrateDataFromUrl`, then strips the query). The admin burger no longer carries a Data item (the operator surface, which has no Sidebar, keeps the burger → `/data` route). Client breadcrumbs: `[admin-ui] sidebar-nav surface=data …`, `[admin-ui] data-open route=… via=<in-place\|navigate>`, `[admin-ui] data-hydrate route=/`. | `sidebar-artefacts.ts`, `files.ts`, `graph-search.ts` |
|
|
368
|
-
| Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row per channel that has ≥1 conversation. Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no conversations shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount**, so a failed load shows one muted, non-clickable line instead of channel rows. **WhatsApp rows are sourced from the on-disk message store** (`readAllConversations`), one row per stored conversation keyed by `remoteJid`, independent of whether any session JSONL exists — a blocked non-admin DM or a group-observed thread that never produced a session is listed here. **Telegram rows are session-sourced** (one per admin Telegram session), unchanged. Each conversation row shows a **display name**: for WhatsApp, the store record's `senderName` (else the `senderTelephone`, else the `remoteJid`); for Telegram, the precedence `operatorName ?? senderId ?? title` where `operatorName` is the bound `Person givenName familyName`. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`) from `lastMessageAt`, never "Invalid Date". **Scope colour (WhatsApp):** each WhatsApp row is coloured by its conversation's scope — a thin left accent bar, green for `admin`, orange for `public`, taken from the conversation's most-recent stored record (this replaces the former "Public" text pill). **Re-seat:** only session-sourced rows (Telegram, and webchat in the Sessions panel) carry a `SessionRowActions` Re-seat cluster; a WhatsApp store row has no session, so it carries no Re-seat. A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route it navigates to `/?wa=<remoteJid>&acct=<accountId>`, which the root shell hydrates on mount (then strips the query). Server logs `[wa-reader] op=list
|
|
369
|
-
| Channel transcript (`<Transcript>`) | **WhatsApp** reads its conversation verbatim from the message store file (`<remoteJid>.jsonl`) over SSE (`/whatsapp-reader/store-stream`): both directions, `dateSent`-ordered, backlog on connect then a live tail so a newly stored message appears without reload — no session JSONL is read. **Telegram** still reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). On the Telegram session stream, `tool-call`/`tool-result` turns render as **collapsed cards** so the human↔agent text is not buried in JSON. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom**; while scrolled up a **jump-to-bottom control** (`.wa-jump`) appears. Both streams send a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tag each frame with a byte-offset `id:`, and resume from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. The Telegram session stream also interleaves a collapsed `⚙ directive injected (N B)` row per turn (listed by `GET /whatsapp-reader/directives?sessionId=`, bytes served by `GET /whatsapp-reader/directive?sessionId=&name=`); a WhatsApp store conversation has no session, so it has no directives. A centered day-divider row (`.day-divider`) marks each local calendar-day crossover. The store stream open logs `[wa-reader] op=open remoteJid=<jid> scope=<admin\|public> messages=<n> source=store`. | `/whatsapp-reader/store-stream`, `/whatsapp-reader/stream`, `/whatsapp-reader/directives`, `/whatsapp-reader/directive` |
|
|
373
|
+
| Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row per channel that has ≥1 conversation. Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no conversations shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount**, so a failed load shows one muted, non-clickable line instead of channel rows. **WhatsApp rows are sourced from the on-disk message store** (`readAllConversations`), one row per stored conversation keyed by `remoteJid`, independent of whether any session JSONL exists — a blocked non-admin DM or a group-observed thread that never produced a session is listed here. **Telegram rows are session-sourced** (one per admin Telegram session), unchanged. Each conversation row shows a **display name**: for WhatsApp, the store record's `senderName` (else the `senderTelephone`, else the `remoteJid`); for Telegram, the precedence `operatorName ?? senderId ?? title` where `operatorName` is the bound `Person givenName familyName`. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`) from `lastMessageAt`, never "Invalid Date". **Scope colour (WhatsApp):** each WhatsApp row is coloured by its conversation's scope — a thin left accent bar, green for `admin`, orange for `public`, taken from the conversation's most-recent stored record (this replaces the former "Public" text pill). **Re-seat:** only session-sourced rows (Telegram, and webchat in the Sessions panel) carry a `SessionRowActions` Re-seat cluster; a WhatsApp store row has no session, so it carries no Re-seat. A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route it navigates to `/?wa=<remoteJid>&acct=<accountId>`, which the root shell hydrates on mount (then strips the query). **Account-scoped** exactly as the sidebar Sessions list: both row sources (WhatsApp store rows and Telegram/public-webchat session rows) are scoped to the caller's pinned account (`getAccountIdForSession(cacheKey)`), multi-account-gated (`listValidAccounts().length > 1`). On a multi-account install a subaccount view shows only its own conversations; the house account's WhatsApp appears only under the house view; an untagged session sidecar is excluded and counted; an unresolvable scope fails closed to an empty list (logs `op=list-scope-unresolved`). A single-account install filters nothing, byte-identical to before. Server logs `[wa-reader] op=list pinnedAccount=<id8> resolvedAccount=<id8> multiAccount=<bool> conversations=<n> sessionRows=<m> storeRows=<k> adminScope=<n> publicScope=<n> sessionRowsExcludedUntagged=<n>` per list build (`pinnedAccount === resolvedAccount` is the post-fix invariant when `multiAccount=true`; `conversations=0` on an account with a non-empty store dir is the over-filtering drift signal); the client logs `[admin-ui] wa-open route=… via=<in-place\|navigate> …` and `[admin-ui] wa-hydrate route=/ remoteJid=…`. | `/whatsapp-reader/conversations` |
|
|
374
|
+
| Channel transcript (`<Transcript>`) | **WhatsApp** reads its conversation verbatim from the message store file (`<remoteJid>.jsonl`) over SSE (`/whatsapp-reader/store-stream`): both directions, `dateSent`-ordered, backlog on connect then a live tail so a newly stored message appears without reload — no session JSONL is read. **Telegram** still reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). On the Telegram session stream, `tool-call`/`tool-result` turns render as **collapsed cards** so the human↔agent text is not buried in JSON. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom**; while scrolled up a **jump-to-bottom control** (`.wa-jump`) appears. Both streams send a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tag each frame with a byte-offset `id:`, and resume from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. The Telegram session stream also interleaves a collapsed `⚙ directive injected (N B)` row per turn (listed by `GET /whatsapp-reader/directives?sessionId=`, bytes served by `GET /whatsapp-reader/directive?sessionId=&name=`); a WhatsApp store conversation has no session, so it has no directives. A centered day-divider row (`.day-divider`) marks each local calendar-day crossover. This viewer renders **delivered messages only** (both WhatsApp store rows and Telegram session rows, via `forceDeliveredOnly`) with **no per-viewer toggle** — the former "Messages only" pill is retired; the admin `/chat` composer's own messages-only control is unaffected. The panel — wallpaper included — is **bounded to `--chat-measure` and centred** (`.platform > .wa-reader`), so it reads as a column the same width as `/data`, not an edge-to-edge beige surface. The store stream open logs `[wa-reader] op=open remoteJid=<jid> scope=<admin\|public> messages=<n> source=store`. | `/whatsapp-reader/store-stream`, `/whatsapp-reader/stream`, `/whatsapp-reader/directives`, `/whatsapp-reader/directive` |
|
|
370
375
|
| Conversations row hover actions | Inline rename, archive, delete, JSONL view / download per row. The historical `.conversations-modal` CSS block exists in `globals.css` but is no longer mounted from any TSX — Sidebar.tsx now owns every per-row affordance directly. | `claude-sessions.ts` |
|
|
371
376
|
| Artefacts list | Lists every `:FileArtifact` under this account's tree (`relativePath STARTS WITH 'accounts/'`, all file types, excluding the `uploads/<id>/` subtree) plus this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates. Click downloads the row's backing file (`downloadPath` → `GET /api/admin/files/download`) so the operator opens it in their local app; rows whose file is outside `DATA_ROOT` (bundled-fallback templates) show a "can't be downloaded" pill. The in-app artefact pane is dead pending removal. | `sidebar-artefacts.ts`, `files.ts` |
|
|
372
377
|
| System-stats widget | CPU / RAM widget at the foot of the sidebar. | `system-stats.ts` (see above) |
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# Admin Chat Attachments
|
|
2
2
|
|
|
3
|
-
What you can drag-and-drop into the admin chat window, what happens to each file, and the size caps.
|
|
3
|
+
What you can drag-and-drop or paste into the admin chat window, what happens to each file, and the size caps.
|
|
4
|
+
|
|
5
|
+
## How files get in
|
|
6
|
+
|
|
7
|
+
You can drag-and-drop a file, pick one with the paperclip, or paste. All three routes stage the file the same way and enforce the same caps. Pasting a file you copied from your file manager (or another app) keeps its real name and type, so `report.pdf` stays `report.pdf`. Pasting a screenshot or a raw clipboard image has no filename to keep, so it is staged under a generated unique name like `pasted-image-1700000000000.png`. Pasting plain text still types the text into the message box as before.
|
|
4
8
|
|
|
5
9
|
## Accepted file types
|
|
6
10
|
|
|
@@ -483,6 +483,10 @@ The `brand-excluded` branch closes the recurring crash-restart loop on brands th
|
|
|
483
483
|
|
|
484
484
|
**Brand-foreign premium bundles.** This closes the loop one layer up: the installer bundler at [`packages/create-maxy-code/scripts/bundle.js`](../../../../packages/create-maxy-code/scripts/bundle.js) now applies the same `brand.json#shipsPremiumBundles` gate at *payload assembly time*, so foreign bundles never reach disk on the device. The gate is shared with the test suite via [`scripts/premium-bundle-gate.mjs`](../../../../packages/create-maxy-code/scripts/premium-bundle-gate.mjs) and accepts only two shapes — `undefined` / missing → ships nothing; `string[]` → ships only the named bundles. The legacy boolean `true` form is **rejected**: bundle.js hard-fails with `FATAL: brand.shipsPremiumBundles must be a string[] (boolean 'true' no longer accepted; enumerate bundles in <brand.json>)`. An allowlist entry naming a bundle directory that is absent on disk is also FATAL — silent over-shipping is the failure mode this gate exists to prevent. Each build emits one `[bundler] premium-bundle-gate brand=<n> mode=<m> shipped=[…] skipped=[…]` line. The runtime gate `walkPremiumBundles` at [`plugin-manifest.ts`](../../../ui/app/lib/claude-agent/plugin-manifest.ts) keeps the same shape and stays as defence-in-depth — on a correctly bundled payload, it walks only allowlisted bundles because foreign ones are not present. The drift-gate's `agents-dir-skipped reason=brand-foreign-bundle` line therefore fires only when something has staged a foreign bundle out-of-band.
|
|
485
485
|
|
|
486
|
+
**Premium sub-plugin Claude Code registration.** A premium bundle takes one of two shapes on disk. A **standalone** bundle (`premium-plugins/<bundle>/` with a `PLUGIN.md` and no `plugins/` subdir — `teaching`, `venture-studio`, `writer-craft`) is a single plugin under the `maxy-premium` marketplace. A **nested-marketplace** bundle (`premium-plugins/<bundle>/plugins/<sub>/` — `real-agent`, `sitedesk`) carries only bundle-level agents at its root; its skills live in sub-plugins, each a full plugin under a per-bundle marketplace named `maxy-premium-<bundle>`. The bundler ([`scripts/generate-plugin-manifests.mjs`](../../../../scripts/generate-plugin-manifests.mjs)) stamps a `.claude-plugin/marketplace.json` at `platform/plugins/` (`maxy-platform`), at each nested bundle's `plugins/` dir (`maxy-premium-<bundle>`), and at the premium root (`maxy-premium`). The installer ([`registerLocalAndExternalPlugins` in `packages/create-maxy-code/src/index.ts`](../../../../packages/create-maxy-code/src/index.ts)) discovers **every** `premium-plugins/<bundle>/plugins/` on the brand-filtered install tree — not a hardcoded subset — adds each marketplace, and installs every listed plugin by qualified `name@marketplace` at `--scope user`, which enables it in `settings.json enabledPlugins`. Three Claude Code registration surfaces then gate skill loading: `known_marketplaces.json` (marketplace registered), `installed_plugins.json` (a `scope:"user"` row for `name@marketplace`), and `settings.json enabledPlugins` (`name@marketplace` enabled). A nested sub-plugin absent from any of the three loads none of its skills — `Skill(<name>)` returns *Unknown skill* at runtime even though Maxy's own `account.json enabledPlugins` (stamped by `reconcileEnabledPlugins`, which keys on `plugins/<sub>/PLUGIN.md` presence) lists it.
|
|
487
|
+
|
|
488
|
+
**Boot-time skill-availability gate.** A manager boot gate ([`skill-registration-drift.ts`](../../../services/claude-session-manager/src/skill-registration-drift.ts)) closes the silent divergence between that Maxy intent and the Claude Code registration surfaces. It resolves each premium sub named in every account's `account.json enabledPlugins` to its expected `name@marketplace` (nested sub → `maxy-premium-<bundle>`, standalone → `maxy-premium`) by descending the on-disk premium tree, then checks all three surfaces. For each enabled-but-unregistered sub it emits one `[skill-registration] op=drift FATAL accountId=<id> plugin=<sub> expected=<name@marketplace> missing=<surfaces>` line (best-effort mirrored to journald via `systemd-cat -t maxy-csm`), naming exactly which surface lacks the entry. Unlike the specialist-tool-drift gate this one does **not** `process.exit` — a mis-registered sub-plugin must not brick the whole install, so boot continues and the operator keeps a working admin for repair (re-running the corrected installer for the brand). A clean pass emits `startup-self-test skill-registration=ok inspected=<N>`.
|
|
489
|
+
|
|
486
490
|
**Structured journald mirror for boot-failed.** Every `boot-failed reason=specialist-tool-drift …` line is mirrored to journald via `systemd-cat -t maxy-csm -p err` with the fields `specialist=`, `tool=`, `drift_reason=`, `agent_path=` so `journalctl --user -u <brand>-claude-session-manager.service -t maxy-csm` can filter by any of them without grep on `server.log`. The stdout line stays unchanged so the existing diagnostic one-liners keep working. `systemd-cat` absence (e.g. macOS dev box) is swallowed — the stdout line is the primary surface; the structured emit is auxiliary.
|
|
487
491
|
|
|
488
492
|
**Per-spawn signals (server.log).** Every spawn emits `pty-spawn-mcp-config servers=<N> tools=<M> bytes=<B> path=<…>` once, plus one `pty-spawn-agents-dir role=<admin|public> path=<…>` per added directory. Specialist spawns additionally emit `pty-spawn-allowlist specialist=<name> count=<N> stripped=<S> sourced-from=agent-frontmatter` where `stripped` is the count of brand-excluded tool names removed before argv emission. The diagnostic one-liner is `grep -E 'pty-spawn-mcp-config|pty-spawn-agents-dir|pty-spawn-allowlist|mcp-config-allowlist-coverage|specialist-tool-strip|boot-failed reason=' ~/.<brand>/logs/server.log | tail -50`.
|
|
@@ -96,6 +96,8 @@ There is no per-account purchase record; the brand decides the shipping set.
|
|
|
96
96
|
|
|
97
97
|
Some premium plugins are **bundles** — multiple sub-plugins shipped under one directory in `premium-plugins/`, each independently activatable. For example, Real Agent ships 10 sub-plugins covering different aspects of estate agency work. They are all enabled by default. Sub-plugins you don't want active can be turned off individually with "disable <name>"; enabling or disabling individual sub-plugins does not affect the others.
|
|
98
98
|
|
|
99
|
+
A bundle's sub-plugins carry the skills, so each must be registered as its own Claude Code plugin for its skills to load — the installer does this at install time for every bundle the brand ships. When a sub-plugin's skills are missing (a specialist dispatches but its skill answers *Unknown skill*), the manager names the gap at boot: grep `server.log` for `[skill-registration] op=drift` — each line gives the account, the sub-plugin, and which registration surface is missing. The fix is to re-run the installer for this brand; a clean boot instead logs `startup-self-test skill-registration=ok`.
|
|
100
|
+
|
|
99
101
|
If you ask {{productName}} about a tool from a plugin your brand does not ship (for example, a Maxy install asking about a Real Agent Loop CRM tool), {{productName}} responds with a structured `<tool-surface-error>` envelope naming the missing plugin and the remedy, rather than improvising with a generic alternative.
|
|
100
102
|
|
|
101
103
|
**Public agent embedding:** Premium plugins marked as public-eligible have their full content (skills and reference knowledge) embedded in public agent prompts. This means a public agent for a Real Agent member can handle buyer enquiries, book viewings, deliver coaching content, and onboard new applicants — all powered by the premium plugin's domain knowledge. Plugins marked admin-only (listings, vendors, leads, business) are only available to the account owner's admin agent.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"d1-command.test.d.ts","sourceRoot":"","sources":["../../../src/lib/__tests__/d1-command.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Regression guard for Task 1350. The brand server PATH carries no global
|
|
2
|
+
// `wrangler`; only `npx wrangler` resolves, the same form the proven
|
|
3
|
+
// forward-path availability publisher uses. A bare-`wrangler` spawn head
|
|
4
|
+
// returns ENOENT on every reconcile tick, so booking->calendar reverse-sync
|
|
5
|
+
// silently fails. This pins the resolved command to the npx form.
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import { buildD1Command } from "../d1-command.js";
|
|
8
|
+
describe("buildD1Command", () => {
|
|
9
|
+
it("invokes wrangler via npx, not a bare wrangler binary", () => {
|
|
10
|
+
const { command, args } = buildD1Command("mydb", "SELECT 1");
|
|
11
|
+
expect(command).toBe("npx");
|
|
12
|
+
expect(args[0]).toBe("wrangler");
|
|
13
|
+
expect(command).not.toBe("wrangler");
|
|
14
|
+
});
|
|
15
|
+
it("preserves the d1 execute argv shape and the SQL/db it was given", () => {
|
|
16
|
+
const { args } = buildD1Command("mydb", "SELECT 1");
|
|
17
|
+
expect(args).toEqual([
|
|
18
|
+
"wrangler", "d1", "execute", "mydb",
|
|
19
|
+
"--remote", "--json", "--command", "SELECT 1",
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
//# sourceMappingURL=d1-command.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"d1-command.test.js","sourceRoot":"","sources":["../../../src/lib/__tests__/d1-command.test.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,qEAAqE;AACrE,yEAAyE;AACzE,4EAA4E;AAC5E,kEAAkE;AAClE,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAElD,QAAQ,CAAC,gBAAgB,EAAE,GAAG,EAAE;IAC9B,EAAE,CAAC,sDAAsD,EAAE,GAAG,EAAE;QAC9D,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,cAAc,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;QAC7D,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC5B,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACjC,MAAM,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IACvC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,iEAAiE,EAAE,GAAG,EAAE;QACzE,MAAM,EAAE,IAAI,EAAE,GAAG,cAAc,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;QACpD,MAAM,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC;YACnB,UAAU,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM;YACnC,UAAU,EAAE,QAAQ,EAAE,WAAW,EAAE,UAAU;SAC9C,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the argv for a D1 SQL read. Resolves wrangler via `npx` — the brand
|
|
3
|
+
* server PATH carries no global `wrangler`, only `npx wrangler` resolves, the
|
|
4
|
+
* same form the proven forward-path availability publisher uses. Keeping this
|
|
5
|
+
* pure lets the argv be asserted without importing the side-effectful
|
|
6
|
+
* dispatcher script (Task 1350).
|
|
7
|
+
*/
|
|
8
|
+
export declare function buildD1Command(dbName: string, sql: string): {
|
|
9
|
+
command: string;
|
|
10
|
+
args: string[];
|
|
11
|
+
};
|
|
12
|
+
//# sourceMappingURL=d1-command.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"d1-command.d.ts","sourceRoot":"","sources":["../../src/lib/d1-command.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,GACV;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAKrC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the argv for a D1 SQL read. Resolves wrangler via `npx` — the brand
|
|
3
|
+
* server PATH carries no global `wrangler`, only `npx wrangler` resolves, the
|
|
4
|
+
* same form the proven forward-path availability publisher uses. Keeping this
|
|
5
|
+
* pure lets the argv be asserted without importing the side-effectful
|
|
6
|
+
* dispatcher script (Task 1350).
|
|
7
|
+
*/
|
|
8
|
+
export function buildD1Command(dbName, sql) {
|
|
9
|
+
return {
|
|
10
|
+
command: "npx",
|
|
11
|
+
args: ["wrangler", "d1", "execute", dbName, "--remote", "--json", "--command", sql],
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=d1-command.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"d1-command.js","sourceRoot":"","sources":["../../src/lib/d1-command.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAC5B,MAAc,EACd,GAAW;IAEX,OAAO;QACL,OAAO,EAAE,KAAK;QACd,IAAI,EAAE,CAAC,UAAU,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,CAAC;KACpF,CAAC;AACJ,CAAC"}
|
|
@@ -24,6 +24,7 @@ import { resolve, join } from "node:path";
|
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
25
|
import { reconcileOne, auditCounts } from "../lib/booking-reconcile.js";
|
|
26
26
|
import { notifyBooking } from "../lib/booking-notify.js";
|
|
27
|
+
import { buildD1Command } from "../lib/d1-command.js";
|
|
27
28
|
function resolveConfigDir() {
|
|
28
29
|
const platformRoot = process.env.PLATFORM_ROOT;
|
|
29
30
|
if (!platformRoot)
|
|
@@ -99,7 +100,8 @@ function readEnvFile(path) {
|
|
|
99
100
|
}
|
|
100
101
|
/** Run a D1 SQL statement via wrangler with the per-scope token. Returns parsed rows. */
|
|
101
102
|
function d1Query(dbName, sql, env) {
|
|
102
|
-
const
|
|
103
|
+
const { command, args } = buildD1Command(dbName, sql);
|
|
104
|
+
const res = spawnSync(command, args, {
|
|
103
105
|
env: { ...process.env, ...env },
|
|
104
106
|
encoding: "utf-8",
|
|
105
107
|
maxBuffer: 16 * 1024 * 1024,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reconcile-bookings.js","sourceRoot":"","sources":["../../src/scripts/reconcile-bookings.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAoC,MAAM,cAAc,CAAC;AAChE,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AACtG,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,YAAY,EAAE,WAAW,EAAmB,MAAM,6BAA6B,CAAC;AACzF,OAAO,EAAE,aAAa,EAAmB,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"reconcile-bookings.js","sourceRoot":"","sources":["../../src/scripts/reconcile-bookings.ts"],"names":[],"mappings":"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"}
|
|
@@ -252,7 +252,7 @@ When a pairing attempt fails — the phone accepts the code and shows "Continue
|
|
|
252
252
|
grep -aE '\[whatsapp:(login|session|reconcile)\]' <server log> | grep 'cid=<id>'
|
|
253
253
|
```
|
|
254
254
|
|
|
255
|
-
The single decision point is whether an `op=pair-success` line appears between `op=code-issued` and `op=close`. Absent + `op=close … kind=qr-refs-ended-408` → the completion signal never returned (beyond the Pi). A dirty `op=creds-pre registered=true hasAccount=false
|
|
255
|
+
The single decision point is whether an `op=pair-success` line appears between `op=code-issued` and `op=close`. Absent + `op=close … kind=qr-refs-ended-408` → the completion signal never returned (beyond the Pi). When no account signature is captured, the login now terminates on `op=pairing-incomplete … reason=no-account-signature` rather than reconnecting a half-registered credential into a `401`; the operator sees an actionable message ("WhatsApp accepted the code but did not finish linking … open WhatsApp, Linked Devices, remove existing devices, then ask for a new code") that points at the phone's linked-device limit or a stale companion — the account-side cause a reconnect cannot fix. A dirty `op=creds-pre registered=true hasAccount=false` → Pi-side. A half-registered credential folder (pairing requested, never completed) is reconciled on each `/status` pass: an in-flight or freshly-written one is preserved and logged `[whatsapp:reconcile] op=half-registered … inFlight=<bool>`, while an aged, abandoned one (no live login, creds untouched past the 5-minute pairing window) is deleted once, logged `op=discard-half-registered`, so the next pairing starts from an empty folder.
|
|
256
256
|
|
|
257
257
|
Full grammar, success/failure signatures, and the `kind` table: [`.docs/whatsapp-pairing-lifeline.md`](../../../../.docs/whatsapp-pairing-lifeline.md).
|
|
258
258
|
|