@rubytech/create-maxy-code 0.1.439 → 0.1.441
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/package.json +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +41 -12
- package/payload/platform/plugins/cloudflare/skills/calendar-site/SKILL.md +15 -5
- package/payload/platform/plugins/cloudflare/skills/calendar-site/template/functions/api/calendar/free-busy.ts +152 -0
- package/payload/platform/plugins/cloudflare/skills/calendar-site/template/public/index.html +6 -3
- package/payload/platform/plugins/cloudflare/skills/calendar-site/template/schema.sql +11 -0
- package/payload/platform/plugins/docs/references/admin-identity-gate.md +32 -17
- package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
- package/payload/platform/plugins/docs/references/calendar-booking.md +1 -1
- package/payload/platform/plugins/docs/references/graph.md +19 -3
- package/payload/platform/plugins/docs/references/internals.md +2 -2
- package/payload/platform/plugins/docs/references/outlook-guide.md +9 -4
- package/payload/platform/plugins/docs/references/troubleshooting.md +8 -0
- package/payload/platform/plugins/graph/.claude-plugin/plugin.json +19 -0
- package/payload/platform/plugins/graph/PLUGIN.md +10 -4
- package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/outlook/PLUGIN.md +7 -3
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/account-register.test.js +100 -10
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/account-register.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-list.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-list.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-list.test.js +55 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-list.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-list.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-list.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-list.test.js +64 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-list.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-registry.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-registry.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-registry.test.js +193 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-registry.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/token-store.test.js +58 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/token-store.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/mailbox-registry.d.ts +81 -0
- package/payload/platform/plugins/outlook/mcp/dist/auth/mailbox-registry.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/auth/mailbox-registry.js +171 -0
- package/payload/platform/plugins/outlook/mcp/dist/auth/mailbox-registry.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.d.ts +16 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.js +25 -3
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/index.js +119 -50
- package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.d.ts +8 -4
- package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.js +22 -3
- package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.d.ts +17 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.js +84 -21
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mailbox-list.d.ts +26 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/mailbox-list.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/mailbox-list.js +28 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/mailbox-list.js.map +1 -0
- package/payload/platform/plugins/outlook/references/auth.md +36 -8
- package/payload/platform/plugins/outlook/references/graph-surfaces.md +15 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.d.ts +27 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js +241 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js.map +1 -0
- package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +100 -0
- package/payload/platform/scripts/lib/account-schema-owned-dirs.py +207 -0
- package/payload/platform/scripts/lib/account-schema-owned-dirs.sh +33 -0
- package/payload/platform/scripts/lib/provision-account-dir.sh +7 -0
- package/payload/platform/scripts/setup-account.sh +10 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
- 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 +6 -4
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/tool-registration-drift.d.ts +7 -6
- package/payload/platform/services/claude-session-manager/dist/tool-registration-drift.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/tool-registration-drift.js +56 -7
- package/payload/platform/services/claude-session-manager/dist/tool-registration-drift.js.map +1 -1
- package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
- package/payload/server/{chunk-UGPQCVIW.js → chunk-WC4WJR6X.js} +0 -6
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/assets/{AdminLoginScreens-DeR71H29.js → AdminLoginScreens-CukwQufW.js} +1 -1
- package/payload/server/public/assets/AdminShell-BwEBH6tT.js +2 -0
- package/payload/server/public/assets/{admin-C15h-NL0.js → admin-CdFRCUUZ.js} +1 -1
- package/payload/server/public/assets/{browser-B7xQLIRK.js → browser-BWR59IkB.js} +1 -1
- package/payload/server/public/assets/{calendar-A-vGm6pL.js → calendar-kuTcxX1t.js} +1 -1
- package/payload/server/public/assets/chat-ejnq2rck.js +1 -0
- package/payload/server/public/assets/data-dZbOyLdT.js +1 -0
- package/payload/server/public/assets/{graph-D0orIRAh.js → graph-CboWM2_-.js} +2 -2
- package/payload/server/public/assets/{operator-DZzuotDB.js → operator-oSaB1mya.js} +1 -1
- package/payload/server/public/assets/{page-Ck_pLhAk.js → page-DEkI-Gl1.js} +1 -1
- package/payload/server/public/assets/{page-3g0A19Rv.js → page-DMeJHVfA.js} +5 -5
- package/payload/server/public/assets/{public-WFUQw9AD.js → public-C54tl6V4.js} +1 -1
- package/payload/server/public/assets/{useCopyFeedback-CSk9KjhY.js → useCopyFeedback-DHoYHiK9.js} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-DCyJQkFK.js → useVoiceRecorder-b7HzTFs0.js} +1 -1
- package/payload/server/public/browser.html +2 -2
- package/payload/server/public/calendar.html +2 -2
- package/payload/server/public/chat.html +5 -5
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +5 -5
- package/payload/server/public/operator.html +6 -6
- package/payload/server/public/public.html +5 -5
- package/payload/server/server.js +255 -112
- package/payload/server/public/assets/AdminShell-DlAVQUda.js +0 -2
- package/payload/server/public/assets/chat-DAae6Z51.js +0 -1
- package/payload/server/public/assets/data-BNWpKx1d.js +0 -1
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:9f7fc73400d2f9cf942e2df6f544bc69b95abe820ac1ff9b529807c3c7fd6407
|
|
5
5
|
brand: maxy-code
|
|
6
6
|
product-name: Maxy
|
|
7
7
|
---
|
|
@@ -2031,6 +2031,7 @@ The `outlook` plugin gives the admin agent access to Microsoft 365 / Outlook.com
|
|
|
2031
2031
|
2. **Per account: register the Outlook account** — in admin chat, ask the agent to "register my Outlook account". The agent runs `outlook-account-register`, which returns a short user code and the URL `microsoft.com/devicelogin` and relays both to you.
|
|
2032
2032
|
3. **Consent on any device** — open `microsoft.com/devicelogin` on your phone or laptop, enter the code, sign in to your Microsoft account, and consent to the requested scopes (`offline_access`, `User.Read`, `Mail.ReadWrite`, `Mail.Send`, `Calendars.ReadWrite`, `Contacts.Read`). `Mail.Send` enables sending and `Calendars.ReadWrite` enables calendar control; a mailbox last consented on an older read-only scope set must re-register before send or any calendar write succeeds. The agent finishes by calling `outlook-account-register-poll`.
|
|
2033
2033
|
4. **Done.** Subsequent tool calls (mail, calendar, contacts) use the persisted refresh token transparently.
|
|
2034
|
+
5. **Several mailboxes on one account.** Register again to add a second mailbox; it is stored alongside the first, not in place of it. Every operational tool then takes an optional `mailbox` argument — an email address or graphUserId. With one mailbox, omit it. With two or more, name it; omitting it returns a refusal listing the connected mailboxes. `outlook-mailbox-list` shows every connected mailbox. Only one registration runs at a time per account; starting another while one is live returns an in-progress signal.
|
|
2034
2035
|
|
|
2035
2036
|
## Tools
|
|
2036
2037
|
|
|
@@ -2038,7 +2039,7 @@ The `outlook` plugin gives the admin agent access to Microsoft 365 / Outlook.com
|
|
|
2038
2039
|
|------|---------|
|
|
2039
2040
|
| `outlook-account-register` | Start the device-code flow for this account. Returns a user code + `microsoft.com/devicelogin` URL immediately; does not block. |
|
|
2040
2041
|
| `outlook-account-register-poll` | Finish the device-code flow. One check per call: returns pending, registered, expired, or denied. |
|
|
2041
|
-
| `outlook-mail-list` | Recent mail. Default top=25, folder=Inbox. |
|
|
2042
|
+
| `outlook-mail-list` | Recent mail. Default top=25, folder=Inbox. `folder` takes a well-known name or alias (case/space-insensitive): Inbox, Sent Items/sent, Drafts, Deleted Items/trash, Junk Email/junk/spam, Archive, Outbox — or a raw folder id. Custom folder names are not resolved. |
|
|
2042
2043
|
| `outlook-mail-search` | Microsoft Graph `$search` over the mailbox. |
|
|
2043
2044
|
| `outlook-calendar-list` | Calendar events in next rangeDays days (default 7, max 365). |
|
|
2044
2045
|
| `outlook-calendar-event` | Full detail of a single event by id. |
|
|
@@ -2048,7 +2049,8 @@ The `outlook` plugin gives the admin agent access to Microsoft 365 / Outlook.com
|
|
|
2048
2049
|
| `outlook-calendar-respond` | Accept, decline, or tentatively accept an invite. |
|
|
2049
2050
|
| `outlook-calendar-freebusy` | Availability for one or more addresses over a window. |
|
|
2050
2051
|
| `outlook-contacts-list` | Top contacts. Default top=50. |
|
|
2051
|
-
| `outlook-mailbox-info` | Health probe — auth state, refresh-window, folder count. |
|
|
2052
|
+
| `outlook-mailbox-info` | Health probe for one mailbox — auth state, refresh-window, folder count. Name the mailbox with `mailbox` when several are attached. |
|
|
2053
|
+
| `outlook-mailbox-list` | Every connected mailbox with its email, scopes, and token health. Local only — no network — so it also flags a mailbox whose stored credentials cannot be read. |
|
|
2052
2054
|
| `outlook-mail-send` | Send a message (`POST /me/sendMail`). to/cc/bcc arrays, subject, body, isHtml. Success asserted on Graph 202. Needs `Mail.Send`. |
|
|
2053
2055
|
| `outlook-draft` | Create a draft in Drafts (`POST /me/messages`). Same body model. Returns the draft id. Needs `Mail.ReadWrite`. |
|
|
2054
2056
|
| `outlook-draft-send` | Send an existing draft by id (`POST /me/messages/{id}/send`). Graph consumes the draft, returns 202. Needs `Mail.Send`. |
|
|
@@ -2066,10 +2068,12 @@ All log lines start with `[outlook-mcp]` and write to `server.log`. They are key
|
|
|
2066
2068
|
| Pending-store census | `pending-store-census account=<id> live=<N>` |
|
|
2067
2069
|
| Pending cleared | `pending-cleared account=<id> verified=<bool>` |
|
|
2068
2070
|
| /me failed post-token | `me-failed account=<id> status=<N>` |
|
|
2069
|
-
| Auth ok | `auth-ok account=<id> graphUserId=<id> scopes=<csv> tokenExpSec=<N>` |
|
|
2071
|
+
| Auth ok | `auth-ok account=<id> graphUserId=<id> mail=<addr> scopes=<csv> tokenExpSec=<N>` |
|
|
2072
|
+
| Registration already in flight | `register-in-progress account=<id>` (start refused; one device-code flow at a time) |
|
|
2073
|
+
| Credential resolve | `cred-resolve account=<id> mailbox=<id-or-empty> source=arg|sole|none|refused` — names the resolved mailbox and how it was chosen; `source=refused` is the ambiguity/not-found refusal |
|
|
2070
2074
|
| Token refreshed | `token-refreshed account=<id> oldExpSec=<N> newExpSec=<N>` |
|
|
2071
2075
|
| Refresh failed | `token-refresh-failed account=<id> reason=<err>` (terminal) |
|
|
2072
|
-
| Mail list | `mail-list account=<id>
|
|
2076
|
+
| Mail list | `mail-list op=resolve account=<id> folderIn=<name> folderResolved=<token> asRawId=<bool> count=<N> elapsedMs=<N>` (on error, `error=<msg>` replaces `count`) |
|
|
2073
2077
|
| Mail search | `mail-search account=<id> query=<trunc-32> count=<N> elapsedMs=<N>` |
|
|
2074
2078
|
| Calendar list | `calendar-list account=<id> rangeDays=<N> count=<N> elapsedMs=<N>` |
|
|
2075
2079
|
| Calendar event | `calendar-event account=<id> eventId=<trunc-12> elapsedMs=<N>` |
|
|
@@ -2081,6 +2085,7 @@ All log lines start with `[outlook-mcp]` and write to `server.log`. They are key
|
|
|
2081
2085
|
| Scope insufficient | `scope-insufficient tool=<t> account=<id> missing=Calendars.ReadWrite` (stale read-only consent) |
|
|
2082
2086
|
| Contacts list | `contacts-list account=<id> count=<N> elapsedMs=<N>` |
|
|
2083
2087
|
| Mailbox info | `mailbox-info account=<id> tokenWithinRefreshWindow=<bool> folderCount=<N>` |
|
|
2088
|
+
| Mailbox list | `mailbox-list account=<id> count=<N> unreadable=<N>` (unreadable>0 flags an orphaned or corrupt mailbox store) |
|
|
2084
2089
|
| Graph error | `graph-error account=<id> status=<N> code=<graphErrorCode> retryAfterMs=<N-or-null>` — writes flow through the same `callGraph` loop, so a failed write logs `graph-error` too |
|
|
2085
2090
|
| On-prem rejected | `on-prem-rejected account=<id> mailServer=<host>` (terminal) |
|
|
2086
2091
|
| Scope insufficient | `scope-insufficient account=<id> tool=<t> missing=Mail.Send` (terminal — re-register to grant send) |
|
|
@@ -2292,7 +2297,7 @@ A `pendingUnreconciled` number that stays above zero across several runs means t
|
|
|
2292
2297
|
|
|
2293
2298
|
## What this does not do
|
|
2294
2299
|
|
|
2295
|
-
One meeting type only (one length, one weekly window, one buffer). No syncing to Google or iCloud calendars: your meetings in Maxy are the source of truth. No rescheduling or cancellation links, reminders, or visitor time-zone selection. The booking page shows
|
|
2300
|
+
One meeting type only (one length, one weekly window, one buffer). No syncing to Google or iCloud calendars: your meetings in Maxy are the source of truth. No rescheduling or cancellation links, reminders, or visitor time-zone selection. The booking page shows open times from a snapshot your device publishes to the edge on a short timer, so the page keeps showing availability and taking bookings even while your device is offline — a slot that gets booked drops off at once, and times that have passed are never shown. If your device stays offline a long time the open times can go stale, but they never vanish; they refresh the moment it is back online.
|
|
2296
2301
|
|
|
2297
2302
|
---
|
|
2298
2303
|
# LinkedIn Extension
|
|
@@ -2739,7 +2744,7 @@ either is a regression.
|
|
|
2739
2744
|
|---|---|---|
|
|
2740
2745
|
| `/session` | Admin cookie session: PIN-gated mint, validate, rotate. | `GET /`, `POST /` |
|
|
2741
2746
|
| `/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) |
|
|
2742
|
-
| `/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. **WhatsApp and Telegram sessions (admin + public) appear in this list**, colour-coded by channel (WhatsApp green `#25D366`, Telegram blue `#229ED9`, via a `conv-channel-*` left accent bar) and carrying the standard row kebab (Usage & cost, Reset/re-seat, Rename, Archive, Delete) so the operator can inspect and re-seat a channel session's params. Each row's `channel` is the authoritative `.meta.json` sidecar value (`whatsapp`/`telegram`/`webchat`/`browser`/null), not the JSONL-envelope channel. The only role/channel excluded from the list is `public`+`webchat` (its dedicated reader is the sole surface); the exclusion predicate is `isSessionListExcluded`, decoupled from the reader's inclusion predicate `isReaderChannelSession` (unchanged), so the WhatsApp/Telegram reader panels are unaffected — a store-only WhatsApp conversation with no session JSONL stays reader-only. Observability rides `[admin-sessions-list] … channelInList=whatsapp:<n>,telegram:<n>,webchat:<n> unknownChannel=<n> excludedPublicWebchat=<n
|
|
2747
|
+
| `/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. **WhatsApp and Telegram sessions (admin + public) appear in this list**, colour-coded by channel (WhatsApp green `#25D366`, Telegram blue `#229ED9`, via a `conv-channel-*` left accent bar) and carrying the standard row kebab (Usage & cost, Reset/re-seat, Rename, Archive, Delete) so the operator can inspect and re-seat a channel session's params. Each row's `channel` is the authoritative `.meta.json` sidecar value (`whatsapp`/`telegram`/`webchat`/`browser`/null), not the JSONL-envelope channel. Each row also carries its sidecar `senderId`. For a WhatsApp/Telegram row with a `senderId` and no operator rename or ai-title (`titleSource:'prefix'`), the route resolves a contact `personName` and the sidebar renders the reader panel's `"<name> <number>"` label via the shared `operatorConvName`, degrading to the number alone then the stripped title on a miss, so a Sessions row and that conversation's reader-panel row read identically. The name comes from the shared `resolveOperatorNamesBySender` helper the WhatsApp reader also calls, so the two surfaces cannot drift; a renamed or ai-titled channel row keeps its own title. The only role/channel excluded from the list is `public`+`webchat` (its dedicated reader is the sole surface); the exclusion predicate is `isSessionListExcluded`, decoupled from the reader's inclusion predicate `isReaderChannelSession` (unchanged), so the WhatsApp/Telegram reader panels are unaffected — a store-only WhatsApp conversation with no session JSONL stays reader-only. Observability rides `[admin-sessions-list] … channelInList=whatsapp:<n>,telegram:<n>,webchat:<n> channelNameCandidates=<n> channelNamesResolved=<n> unknownChannel=<n> excludedPublicWebchat=<n>`, and each unresolved channel name logs one `[admin-sessions-list] op=channel-name-unresolved sessionId=<id8> channel=<whatsapp\|telegram> senderId=<id> reason=<no-senderId\|classify-miss\|neo4j-down>` line so a persistent `channelNamesResolved` shortfall is never silent. | `GET /` |
|
|
2743
2748
|
| `/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 /` |
|
|
2744
2749
|
| `/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 /` |
|
|
2745
2750
|
| `/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 /` |
|
|
@@ -3182,8 +3187,10 @@ Source: https://docs.getmaxy.com/graph.md
|
|
|
3182
3187
|
# Graph View
|
|
3183
3188
|
|
|
3184
3189
|
The **Graph** admin page (`/graph`) renders a force-directed view of your
|
|
3185
|
-
account's Neo4j subgraph.
|
|
3186
|
-
|
|
3190
|
+
account's Neo4j subgraph. Node captions are hidden on first load so the
|
|
3191
|
+
whole-graph overview stays uncluttered, and appear the moment you interact.
|
|
3192
|
+
Once shown, captions follow the zoom level so you see the most useful identity
|
|
3193
|
+
at every scale.
|
|
3187
3194
|
|
|
3188
3195
|
## Default view
|
|
3189
3196
|
|
|
@@ -3239,11 +3246,25 @@ This is an in-page overlay, so the browser tab and address bar stay put. It is
|
|
|
3239
3246
|
not the operating system's full-screen mode. The maximize button shows only on
|
|
3240
3247
|
the `/graph` page, not where the graph is embedded in another pane.
|
|
3241
3248
|
|
|
3249
|
+
## Captions hidden on first load
|
|
3250
|
+
|
|
3251
|
+
When you open `/graph` (or refresh the page) the whole graph auto-fits into the
|
|
3252
|
+
viewport, which on a busy graph would render every caption at once and overlap
|
|
3253
|
+
them into unreadable clutter. So captions start hidden. They appear on your
|
|
3254
|
+
first interaction of any kind — zoom, drag, a filter chip, a search, clicking a
|
|
3255
|
+
node — and stay visible for the rest of that visit. To get the clean
|
|
3256
|
+
caption-free overview back, reload the page.
|
|
3257
|
+
|
|
3258
|
+
This is a one-off marker tied to page load, not a zoom threshold: after the
|
|
3259
|
+
first interaction, captions follow the zoom tiers below and do not hide again
|
|
3260
|
+
until the next reload.
|
|
3261
|
+
|
|
3242
3262
|
## Conversation label tiers
|
|
3243
3263
|
|
|
3244
3264
|
Conversation nodes carry the most operator-meaningful identity in the
|
|
3245
3265
|
subgraph (the conversation name or summary, the date it started, the message
|
|
3246
|
-
count).
|
|
3266
|
+
count). Once captions are showing they render in one of three tiers, switched
|
|
3267
|
+
by canvas scale:
|
|
3247
3268
|
|
|
3248
3269
|
| Zoom | Label shape | Example |
|
|
3249
3270
|
|------|-------------|---------|
|
|
@@ -3962,7 +3983,7 @@ For subagents, the same spawn pushes `--add-dir` for every bundled plugin agents
|
|
|
3962
3983
|
|
|
3963
3984
|
A boot gate refuses to start the manager when any admin-allowlisted tool `mcp__<plugin>__*` lacks a registered server. The signal is `boot-failed reason=mcp-allowlist-without-server plugin=<p> tool=<t>` followed by `process.exit(1)`. The remediation is a one-line edit to the named `PLUGIN.md`: add the `mcp:` block. The complementary observability emit `mcp-config-allowlist-coverage admin-tools=A admin-registered=R` (where `A === R`) confirms the invariant per boot.
|
|
3964
3985
|
|
|
3965
|
-
**Whether a plugin's server mounts is decided by the generated manifest, not the `mcp:` block.** A plugin ships its MCP as `mcp/`; the manifest generator ([`scripts/generate-plugin-manifests.mjs`](../../../../scripts/generate-plugin-manifests.mjs), run at bundle time over the payload) reads the plugin's `mcp-manifest:` frontmatter. `auto` emits an `mcpServers` block into the generated `.claude-plugin/plugin.json` — routed through `lib/mcp-spawn-tee`, carrying no per-session env (`LOG_DIR`/`SESSION_ID`/`ACCOUNT_ID`/`NEO4J_*`/`PLATFORM_ROOT` ride the PTY env block described above) — and copies the tee into the plugin's `lib/`; `skip` emits nothing, so Claude Code never spawns the server. The generator
|
|
3986
|
+
**Whether a plugin's server mounts is decided by the generated manifest, not the `mcp:` block.** A plugin ships its MCP as `mcp/`; the manifest generator ([`scripts/generate-plugin-manifests.mjs`](../../../../scripts/generate-plugin-manifests.mjs), run at bundle time over the payload) reads the plugin's `mcp-manifest:` frontmatter. `auto` emits an `mcpServers` block into the generated `.claude-plugin/plugin.json` — routed through `lib/mcp-spawn-tee`, carrying no per-session env (`LOG_DIR`/`SESSION_ID`/`ACCOUNT_ID`/`NEO4J_*`/`PLATFORM_ROOT` ride the PTY env block described above) — and copies the tee into the plugin's `lib/`; `skip` emits nothing, so Claude Code never spawns the server. The generator recognises two server shapes. A plugin-local `mcp/` (its `hasMcp` check: `mcp/dist/index.js` or `mcp/package.json`) uses `auto` or `skip`. A tool-plugin whose `mcp:` server is a shared `platform/lib/<name>` binary rather than a plugin-local `mcp/` (e.g. `graph`, served by `platform/lib/graph-mcp`) is annotated `mcp-manifest: shared-lib`: the generator emits that plugin's `mcp:` block verbatim into `mcpServers` — command, args (anchored on `${PLATFORM_ROOT}`, routed through the shared `lib/mcp-spawn-tee`), and env — which Claude Code expands at spawn from the PTY env block. No tee copy is made into the plugin, because both the tee and the server resolve from `${PLATFORM_ROOT}` outside the per-plugin cache. `graph` is the one shared-lib tool-plugin; every other tool-plugin ships a plugin-local `mcp/` and uses `auto`. A plugin with an `mcp:` block, no plugin-local `mcp/`, and no `mcp-manifest: shared-lib` annotation is a FATAL at generation time. Premium plugins import platform libs (`mcp-lifeline`, etc.) via a source-tree relative path; the bundler's premium-lib shipper (`packages/create-maxy-code/scripts/bundle.js`) rewrites that path and vendors each referenced `platform/lib/<name>` into `premium-plugins/<bundle>/lib/<name>/`, so the auto-mounted dist resolves its imports. The `mcp-allowlist-without-server` gate above proves only that a plugin's PLUGIN.md `mcp:` block matches its allowlist — it reads source, not the generated manifest — so a plugin left on `skip` passes that gate while its server silently never mounts (its tools are listed but not live). The `tool-registration` gate below closes that blind spot.
|
|
3966
3987
|
|
|
3967
3988
|
A second boot gate walks every specialist `.md` under `platform/templates/specialists/agents/`, every bundled `<plugin>/agents/` directory, and the per-account `<accountDir>/specialists/agents/` directory, parses each file's `tools:` frontmatter line (canonical long-prefix names), and classifies every tool name as one of: CC-native (Read, Bash, …), a tool the loaded `PLUGIN.md` set actually serves (matched as the long canonical name in `toolSurface.all`), a third-party MCP bridge (a `mcp__plugin_*` name whose plugin segment is NOT a maxy platform plugin — Playwright etc., upstream-owned, passes unconditionally), `unknown-tool-in-plugin` (maxy plugin namespace served but tool name absent), `unknown-plugin-namespace` (namespace served by nothing), `brand-excluded-plugin` (namespace served by nothing on this brand, **but** the brand's `brand.json#plugins.excluded` list names it), or `malformed-name` (not CC-native and not `mcp__`-shaped). The first three pass. The next two refuse boot with one `boot-failed reason=specialist-tool-drift specialist=<name> tool=<t> drift=<class> path=<…>` line per defect, then `process.exit(1)`. A maxy-plugin `mcp__plugin_*` name is validated against `toolSurface.all`, so a typo or stale long-prefix tool name still refuses boot rather than passing as a bridge; the build-time `check-canonical-tool-names.mjs` gate catches the same drift in instruction files before publish. `brand-excluded-plugin` is a structural pass: it lands in a per-specialist strip-list, the manager continues to boot, and at spawn time `pty-spawner` removes those tool names from the `--agent <name>` spawn's `--allowed-tools` argv. The complementary observability emit `specialist-tool-strip specialist=<name> plugin=<p> tools=<csv> reason=brand-excluded` fires one line per stripped (specialist, plugin) pair so an operator who reads `server.log` sees the brand filter doing work without cross-referencing `brand.json` against the template. The startup-self-test line `startup-self-test specialist-tool-drift=ok inspected=<N> stripped-specialists=<M>` confirms the gate ran and how many specialists carry strip-lists.
|
|
3968
3989
|
|
|
@@ -3974,7 +3995,7 @@ The `brand-excluded` branch closes the recurring crash-restart loop on brands th
|
|
|
3974
3995
|
|
|
3975
3996
|
**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>`.
|
|
3976
3997
|
|
|
3977
|
-
**Boot-time tool-availability gate.** The twin of the skill gate for the MCP surface ([`tool-registration-drift.ts`](../../../services/claude-session-manager/src/tool-registration-drift.ts)). Where the skill gate reconciles a premium sub's *skills*, this one reconciles a tool-plugin's *server mount*. Its tool-plugin set is `toolSurface.mcpServers`
|
|
3998
|
+
**Boot-time tool-availability gate.** The twin of the skill gate for the MCP surface ([`tool-registration-drift.ts`](../../../services/claude-session-manager/src/tool-registration-drift.ts)). Where the skill gate reconciles a premium sub's *skills*, this one reconciles a tool-plugin's *server mount*. Its tool-plugin set is `toolSurface.mcpServers` — every plugin that declares tools and an `mcp:` block, which includes specialist-only tool-plugins like `loop` whose tools are all `adminAllowlist: false` (the admin allowlist never lists them, so an admin-allowlist-derived check would miss them), and shared-lib-server plugins like `graph` (now that the generator emits an `mcpServers` block for them). For each account's `enabledPlugins` that names such a tool-plugin, it inspects the **generated** `.claude-plugin/plugin.json` rather than the PLUGIN.md source: an absent manifest is `reason=not-installed`, a manifest with no `mcpServers` is `reason=no-mcpServers-manifest` (the `skip` signature), and an `mcpServers` manifest whose server dist is missing is `reason=dist-missing` — for a plugin-local `mcp/` that dist is `mcp/dist/index.js`; for a shared-lib plugin the gate resolves the manifest's shared server arg via `${PLATFORM_ROOT}` and checks that binary. Each defect emits one `[tool-registration] op=drift FATAL accountId=<id> plugin=<name> expected-server=<name> reason=<…>` line (best-effort mirrored to journald via `systemd-cat -t maxy-csm`); like the skill gate it does **not** `process.exit`, so a single unmounted tool-plugin does not brick the install. A clean pass emits `startup-self-test tool-registration=ok inspected=<N>`. This is the standing check the no-event failure needs: an unmounted server emits no `[mcp-helper] op=spawn` line, so only a gate that converts its absence into `op=drift` makes it loud.
|
|
3978
3999
|
|
|
3979
4000
|
**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.
|
|
3980
4001
|
|
|
@@ -4781,3 +4802,11 @@ sudo systemctl --user start maxy-ui
|
|
|
4781
4802
|
- `grep -c '\[admin-ui\] sidebar-meta-pane-reconcile' ~/.maxy/logs/admin-ui-console.log` should equal the count of End / Resume / Purge clicks while the metadata pane was open. A `to=gone` line without a paired Close click means the pane's auto-close logic regressed.
|
|
4782
4803
|
|
|
4783
4804
|
**Why this matters.** The render-body log was misleading: it read as "the admin agent is checking onboarding state continuously", when in fact `onboardingComplete` had not changed at all. The fix moved the log into `useEffect(…, [])` then dropped the per-tick poll entirely, so a quiet console is now the steady state. With both fixes in place, console output is a faithful record of what the page actually did each operator click.
|
|
4805
|
+
|
|
4806
|
+
## Typing in the `/chat` composer is slow to render
|
|
4807
|
+
|
|
4808
|
+
**Symptom.** In the admin/operator `/chat` webchat, typing into the message box lags. Characters appear behind the keystrokes, worse on a long conversation. CPU, RAM, and network show no pressure.
|
|
4809
|
+
|
|
4810
|
+
**Cause.** A controlled-input re-render storm. The composer text lives in the chat host, and `<Transcript>` is a sibling under that host, so each keystroke re-renders the host and reconciles the whole transcript tree. Cost scales with transcript length. The "no CPU/RAM spike" reading is consistent, not contradictory: React reconciliation is short main-thread bursts, invisible in Activity Monitor averages; the felt symptom is per-keystroke input latency. See the webchat composer/transcript render contract in `.docs/platform.md`.
|
|
4811
|
+
|
|
4812
|
+
**Diagnostic.** Open the surface with `?diag=composer` appended to the URL and watch the browser console while typing on a long conversation. Each `[diag:composer]` line is a transcript reconcile attributed to a keystroke, with its `actualDuration` (reconcile cost) and `latency` (keystroke to commit). Correct behaviour logs zero lines per keystroke and the transcript still streams and follows the tail. One or more lines per keystroke, or a latency that grows with conversation length, means the decoupling regressed: a keystroke state re-hoisted above the `<Transcript>` sibling, or a `<Transcript>` prop identity that now churns on a keystroke-only render.
|
|
@@ -24,7 +24,11 @@ A booking visitor and the admin look at the booking from opposite sides — the
|
|
|
24
24
|
| Admin calendar (4 views) | the install, at `/calendar` on the admin host (resolve via `admin-hostname`) | admin sign-in | every time-bound item for the account — booked and imported meetings, agent-scheduled appointments, and tasks with a due date |
|
|
25
25
|
| Public booking page | Cloudflare Pages, on the booking domain | open | free/busy only, never any meeting detail |
|
|
26
26
|
|
|
27
|
-
Only the public page is deployed here, because it must stay reachable and keep capturing bookings even when the install device is
|
|
27
|
+
Only the public page is deployed here, because it must stay reachable and keep capturing bookings even when the install device is offline. The admin calendar is dynamic, account-scoped, and authenticated, so it is served by the install over the tunnel and is same-origin with its data. Do not try to put the admin calendar on Pages.
|
|
28
|
+
|
|
29
|
+
## Availability survives the device going offline
|
|
30
|
+
|
|
31
|
+
The booking page does not fetch open times from the device. It reads them same-origin from its own edge Function, `functions/api/calendar/free-busy.ts`, which serves a snapshot stored in the D1 `availability_snapshot` table minus any slot already booked (so a taken slot disappears at once, and two visitors never see the same slot) and with past slots filtered out (so a stale snapshot never offers an expired time). The snapshot is kept fresh by a publish job the platform server arms on a short timer: while the device is online it pushes the account's computed free/busy into `availability_snapshot`; while the device is off it simply does not run and the edge keeps serving the last snapshot until the device returns. There is no per-account cron or setup; the server arms it for the account it resolves, which is the one account whose booking site this install serves.
|
|
28
32
|
|
|
29
33
|
## Set the availability window first
|
|
30
34
|
|
|
@@ -34,7 +38,7 @@ The public page shows open slots computed as the weekly window minus busy `:Meet
|
|
|
34
38
|
<accountDir>/calendar-availability.json
|
|
35
39
|
```
|
|
36
40
|
|
|
37
|
-
Collect from the operator, then write the file: the account `timezone` (IANA), the meeting `durationMins`, the `bufferMins` between meetings, and the `weekly` window as `{ mon: [["09:00","17:00"]], ... }` with an empty array for days that are closed. Also collect `daysAhead` (the booking horizon — a positive integer count of days the page lets visitors book into, e.g. `14`) and `allowMultiSlot` (a boolean: may a visitor combine consecutive open slots into one longer back-to-back booking? default `false`). Add the `
|
|
41
|
+
Collect from the operator, then write the file: the account `timezone` (IANA), the meeting `durationMins`, the `bufferMins` between meetings, and the `weekly` window as `{ mon: [["09:00","17:00"]], ... }` with an empty array for days that are closed. Also collect `daysAhead` (the booking horizon — a positive integer count of days the page lets visitors book into, e.g. `14`) and `allowMultiSlot` (a boolean: may a visitor combine consecutive open slots into one longer back-to-back booking? default `false`). Add the `bookingDomain` (the custom domain the page will answer on) and `bookingDbName` (the D1 database name chosen below). Re-running this skill with new numbers is how availability — including the horizon and the multi-slot choice — is changed later; there is no separate editor. The device-side job that keeps the edge snapshot fresh is armed by the always-running platform server, so there is nothing per-account to schedule.
|
|
38
42
|
|
|
39
43
|
## Assemble the canonical tree
|
|
40
44
|
|
|
@@ -44,11 +48,11 @@ Copy the skill template into the one canonical Pages source path `site-deploy` e
|
|
|
44
48
|
<accountDir>/pages/<project>/
|
|
45
49
|
```
|
|
46
50
|
|
|
47
|
-
The template ships `public/index.html`, `public/booking.js`, `public/booking.css`, `functions/api/book.ts`, `wrangler.toml`, and `schema.sql`. The substitutions that make it concrete are: in `public/index.html`, set
|
|
51
|
+
The template ships `public/index.html`, `public/booking.js`, `public/booking.css`, `functions/api/book.ts`, `functions/api/calendar/free-busy.ts`, `wrangler.toml`, and `schema.sql`. The substitutions that make it concrete are: in `public/index.html`, set `<meta name="days-ahead">` content to `daysAhead` and set `<meta name="allow-multi-slot">` content to `allowMultiSlot` as the literal `true` or `false`; and fill `wrangler.toml`'s `__PROJECT_NAME__`, `__D1_DATABASE_NAME__`, and `__D1_DATABASE_ID__` from the project name and the database created below. Leave `<meta name="api-base">` empty as it ships; the page reads availability same-origin from the site's own `free-busy` edge Function (see the snapshot section below), never the device tunnel. Leave no `__...__` placeholder in the assembled tree.
|
|
48
52
|
|
|
49
53
|
## Provision the D1 bookings table
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
The booking-specific schema ships with the template: `schema.sql` creates both the `bookings` table (written by the booking page) and the `availability_snapshot` table (the single-row free/busy cache the publish job upserts and the `free-busy` edge Function reads). Applying `schema.sql` provisions both. Load the stable `<brand>-pages-d1` token from the account secrets file, minting it once only if absent and persisting it, exactly as `references/api.md` and `references/d1-data-capture.md` prescribe; the token needs both Pages Edit and D1 Edit. Create the database, record its id into `wrangler.toml`, and apply `schema.sql` to the remote database. The token is never written into the tree, never committed, never echoed.
|
|
52
56
|
|
|
53
57
|
## Deploy through site-deploy
|
|
54
58
|
|
|
@@ -59,7 +63,13 @@ Hand the assembled tree to `cloudflare:site-deploy`: it builds nothing for a sta
|
|
|
59
63
|
Done is a live behavioural signal on the booking domain, surfaced verbatim:
|
|
60
64
|
|
|
61
65
|
- The booking domain returns `HTTP/2 200` on a cache-busted request.
|
|
62
|
-
- The page's free/busy call succeeds: `curl` the
|
|
66
|
+
- The page's free/busy call succeeds same-origin: `curl` the booking domain's own `free-busy` edge Function and confirm it returns a `slots` array (with `updatedAt`) and carries no meeting title, description, or location field. Immediately after deploy it may return `{"slots":[],"updatedAt":null,"stale":true}` until the platform server's publish job runs once (within ~5 minutes); re-check until `updatedAt` is fresh and `slots` is populated. A response missing the `slots` field means the edge Function did not deploy; empty-with-`stale` after several minutes means the publish job is not reaching D1 (check the brand journal for `[calendar-availability]`).
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
curl -s "https://<booking-domain>/api/calendar/free-busy?from=$(date -u +%Y-%m-%dT00:00:00Z)&to=$(date -u -v+14d +%Y-%m-%dT00:00:00Z)" | head -c 400
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The device's own free/busy (the publish job's source) should also return a `slots` array; a `404` there means the availability window was not written:
|
|
63
73
|
|
|
64
74
|
```bash
|
|
65
75
|
curl -s "https://<brand-tunnel-domain>/api/calendar/free-busy?from=$(date -u +%Y-%m-%dT00:00:00Z)&to=$(date -u -v+14d +%Y-%m-%dT00:00:00Z)" | head -c 400
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Edge free/busy Function (calendar-site skill) — offline-resilient availability.
|
|
2
|
+
//
|
|
3
|
+
// Served at GET /api/calendar/free-busy on the booking domain, same-origin with
|
|
4
|
+
// the page. It serves open times from a snapshot the device publishes into D1
|
|
5
|
+
// on a short timer (platform/ui server → publish-availability), MINUS any slot
|
|
6
|
+
// already booked in the D1 `bookings` table:
|
|
7
|
+
// - Serving the snapshot means the public page keeps showing availability
|
|
8
|
+
// while the install device is offline (the device's own tunnel free/busy is
|
|
9
|
+
// then unreachable, but the snapshot survives on the edge).
|
|
10
|
+
// - Subtracting accepted bookings here — at the edge — means a booked slot
|
|
11
|
+
// disappears the instant it is taken, with no wait for the device to
|
|
12
|
+
// reconcile and republish, so two visitors are never shown the same slot.
|
|
13
|
+
// - Past slots are always filtered out, so a stale snapshot never offers a
|
|
14
|
+
// time that has already passed.
|
|
15
|
+
//
|
|
16
|
+
// Split like book.ts so the logic is verifiable without the Pages runtime:
|
|
17
|
+
// computeFreeBusy takes an injected DB + logger and returns the HTTP shape.
|
|
18
|
+
|
|
19
|
+
interface D1PreparedStatement {
|
|
20
|
+
bind: (...values: unknown[]) => D1PreparedStatement
|
|
21
|
+
first: <T = Record<string, unknown>>() => Promise<T | null>
|
|
22
|
+
all: <T = Record<string, unknown>>() => Promise<{ results?: T[] }>
|
|
23
|
+
}
|
|
24
|
+
interface D1Database {
|
|
25
|
+
prepare: (query: string) => D1PreparedStatement
|
|
26
|
+
}
|
|
27
|
+
interface FreeBusyEnv {
|
|
28
|
+
DB: D1Database
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Slot {
|
|
32
|
+
start: string
|
|
33
|
+
end: string
|
|
34
|
+
}
|
|
35
|
+
interface Snapshot {
|
|
36
|
+
durationMins?: number
|
|
37
|
+
timezone?: string
|
|
38
|
+
slots?: Slot[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Logger = (line: string) => void
|
|
42
|
+
|
|
43
|
+
interface FreeBusyQuery {
|
|
44
|
+
from?: string | null
|
|
45
|
+
to?: string | null
|
|
46
|
+
nowMs: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Default horizon when `to` is absent — a generous ceiling so a snapshot always
|
|
50
|
+
// covers the page's own (operator-set) horizon.
|
|
51
|
+
const DEFAULT_HORIZON_MS = 31 * 24 * 3600 * 1000
|
|
52
|
+
|
|
53
|
+
export interface FreeBusyResult {
|
|
54
|
+
durationMins: number | null
|
|
55
|
+
timezone: string | null
|
|
56
|
+
slots: Slot[]
|
|
57
|
+
updatedAt: string | null
|
|
58
|
+
stale?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function computeFreeBusy(
|
|
62
|
+
env: FreeBusyEnv,
|
|
63
|
+
query: FreeBusyQuery,
|
|
64
|
+
log: Logger,
|
|
65
|
+
): Promise<FreeBusyResult> {
|
|
66
|
+
const { nowMs } = query
|
|
67
|
+
const from = query.from ? Date.parse(query.from) : nowMs
|
|
68
|
+
const to = query.to ? Date.parse(query.to) : nowMs + DEFAULT_HORIZON_MS
|
|
69
|
+
|
|
70
|
+
let row: { payload?: string; updatedAt?: string } | null = null
|
|
71
|
+
try {
|
|
72
|
+
row = await env.DB
|
|
73
|
+
.prepare('SELECT payload, updatedAt FROM availability_snapshot WHERE id = 1')
|
|
74
|
+
.first<{ payload?: string; updatedAt?: string }>()
|
|
75
|
+
} catch {
|
|
76
|
+
row = null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// No snapshot published yet (first deploy) is distinct from a corrupt payload
|
|
80
|
+
// below — both otherwise collapse to empty slots, so name them apart in the log.
|
|
81
|
+
if (!row || !row.payload) {
|
|
82
|
+
log('[calendar-availability] op=serve result=no-snapshot slots=0')
|
|
83
|
+
return { durationMins: null, timezone: null, slots: [], updatedAt: null, stale: true }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let snap: Snapshot
|
|
87
|
+
try {
|
|
88
|
+
snap = JSON.parse(row.payload) as Snapshot
|
|
89
|
+
} catch {
|
|
90
|
+
log('[calendar-availability] op=serve result=corrupt slots=0')
|
|
91
|
+
return { durationMins: null, timezone: null, slots: [], updatedAt: row.updatedAt ?? null, stale: true }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const lower = Math.max(Number.isNaN(from) ? nowMs : from, nowMs)
|
|
95
|
+
const upper = Number.isNaN(to) ? nowMs + DEFAULT_HORIZON_MS : to
|
|
96
|
+
|
|
97
|
+
// Slots already taken (optimistic subtraction). Bound the scan to bookings at
|
|
98
|
+
// or after `lower`: reconcile only marks rows swept, never de-accepts them, so
|
|
99
|
+
// without this the accepted set grows without bound over the calendar's life —
|
|
100
|
+
// and a past booking can never match a snapshot slot that survives the
|
|
101
|
+
// start >= lower filter below, so the bound drops nothing. A failed read
|
|
102
|
+
// serves the snapshot as-is rather than 500 — the booking page still works.
|
|
103
|
+
const booked = new Set<string>()
|
|
104
|
+
try {
|
|
105
|
+
const res = await env.DB
|
|
106
|
+
.prepare("SELECT slotStart FROM bookings WHERE status = 'accepted' AND slotStart >= ?1")
|
|
107
|
+
.bind(new Date(lower).toISOString())
|
|
108
|
+
.all<{ slotStart?: string }>()
|
|
109
|
+
for (const b of res.results || []) {
|
|
110
|
+
if (b && typeof b.slotStart === 'string') booked.add(b.slotStart)
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
/* keep the snapshot as-is */
|
|
114
|
+
}
|
|
115
|
+
const slots = (snap.slots || []).filter((s) => {
|
|
116
|
+
const t = Date.parse(s.start)
|
|
117
|
+
return !Number.isNaN(t) && t >= lower && t < upper && !booked.has(s.start)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const ageMs = row.updatedAt ? nowMs - Date.parse(row.updatedAt) : null
|
|
121
|
+
log(`[calendar-availability] op=serve result=ok slots=${slots.length} ageMs=${ageMs ?? 'na'}`)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
durationMins: snap.durationMins ?? null,
|
|
125
|
+
timezone: snap.timezone ?? null,
|
|
126
|
+
slots,
|
|
127
|
+
updatedAt: row.updatedAt ?? null,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface PagesContext {
|
|
132
|
+
request: Request
|
|
133
|
+
env: FreeBusyEnv
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function onRequestGet(context: PagesContext): Promise<Response> {
|
|
137
|
+
const url = new URL(context.request.url)
|
|
138
|
+
const result = await computeFreeBusy(
|
|
139
|
+
context.env,
|
|
140
|
+
{
|
|
141
|
+
from: url.searchParams.get('from'),
|
|
142
|
+
to: url.searchParams.get('to'),
|
|
143
|
+
nowMs: Date.now(),
|
|
144
|
+
},
|
|
145
|
+
(line) => console.log(line),
|
|
146
|
+
)
|
|
147
|
+
// Free/busy carries no credentials; a wildcard origin keeps it readable if a
|
|
148
|
+
// page ever fetches it cross-origin, matching the device endpoint's CORS.
|
|
149
|
+
return Response.json(result, {
|
|
150
|
+
headers: { 'Access-Control-Allow-Origin': '*' },
|
|
151
|
+
})
|
|
152
|
+
}
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<!--
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
<!-- Availability is read same-origin from this site's own edge Function
|
|
7
|
+
(functions/api/calendar/free-busy.ts), which serves the snapshot the
|
|
8
|
+
device publishes to D1. Empty = same-origin, so the page keeps working
|
|
9
|
+
when the install device is offline. Left blank deliberately; do not point
|
|
10
|
+
it at the device tunnel. -->
|
|
11
|
+
<meta name="api-base" content="" />
|
|
9
12
|
<!-- Operator-set booking horizon (positive integer days). The skill rewrites
|
|
10
13
|
the content; booking.js falls back to 14 when it is left unsubstituted. -->
|
|
11
14
|
<meta name="days-ahead" content="__DAYS_AHEAD__" />
|
|
@@ -16,3 +16,14 @@ CREATE TABLE IF NOT EXISTS bookings (
|
|
|
16
16
|
);
|
|
17
17
|
|
|
18
18
|
CREATE INDEX IF NOT EXISTS bookings_unswept ON bookings (status, swept);
|
|
19
|
+
|
|
20
|
+
-- Single-row cache of the account's computed free/busy, published by the device
|
|
21
|
+
-- on a short timer (platform/ui server → publish-availability). The booking
|
|
22
|
+
-- page's edge free/busy Function serves open times from this snapshot minus any
|
|
23
|
+
-- accepted booking, so the public page keeps showing availability while the
|
|
24
|
+
-- device is offline. id is pinned to 1: one account per booking site, one row.
|
|
25
|
+
CREATE TABLE IF NOT EXISTS availability_snapshot (
|
|
26
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
27
|
+
payload TEXT,
|
|
28
|
+
updatedAt TEXT
|
|
29
|
+
);
|
|
@@ -34,16 +34,24 @@ sessions skip it.
|
|
|
34
34
|
On a channel (WhatsApp today), the inbound sender phone is bound to a person's
|
|
35
35
|
`userId` via `users.json` (`UserEntry.phone`), confirmed against the account's
|
|
36
36
|
`admins[]`. The channel admin session carries that `userId` — graph writes and
|
|
37
|
-
the `${USER_ID}` MCP env attribute to the actual sender. An unbound
|
|
38
|
-
|
|
39
|
-
phone)
|
|
37
|
+
the `${USER_ID}` MCP env attribute to the actual sender. An unbound sender falls
|
|
38
|
+
into two cases. The owner's self-phone (a self-chat command, or a DM the gate
|
|
39
|
+
admits as `reason:'self-phone'`) resolves to the owner `userId` (`admins[]` row
|
|
40
|
+
with `role:'owner'`), which the UI passes as the per-session `userId` override —
|
|
41
|
+
so the owner's own-phone session keeps owner attribution even when the owner
|
|
42
|
+
phone is not bound in `users.json`. Every other unbound sender (an `adminPhones`
|
|
43
|
+
entry with no `users.json` phone) gets an empty `USER_ID`; the boot-time owner is
|
|
44
|
+
not substituted.
|
|
40
45
|
|
|
41
46
|
A WhatsApp admin whose inbound arrives as an unresolved LID (a cold mapping
|
|
42
47
|
cache) resolves to their own `userId` via a runtime LID cache
|
|
43
48
|
(`whatsapp-lid-map.json`), populated the first time their LID resolves to their
|
|
44
|
-
phone. A LID that has never resolved has no cache entry and
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
phone. A LID that has never resolved has no cache entry and gets an empty
|
|
50
|
+
`USER_ID` — a LID-form inbound normalizes to no phone, so it cannot match the
|
|
51
|
+
owner's E.164 self-phone and never takes the owner-self path. The
|
|
52
|
+
`op=admin-identity … userId=<value>` log line reports `unbound-empty` for a
|
|
53
|
+
non-owner unbound sender and `owner-self-unresolved` for an owner self-phone on
|
|
54
|
+
an account with no `role:'owner'` row.
|
|
47
55
|
|
|
48
56
|
On loopback (claude.ai/code), multiple admins resolve to the right *profile*
|
|
49
57
|
conversationally but share the owner's `userId` at the env/MCP layer.
|
|
@@ -69,12 +77,17 @@ it read as an owner self-change.
|
|
|
69
77
|
The binding now holds:
|
|
70
78
|
|
|
71
79
|
- `USER_ID` at `/rc-spawn` is `resolvedAdminUserId (authenticated admin) ??
|
|
72
|
-
overrideUserId ?? ''`. The owner fallback is removed. An authenticated
|
|
73
|
-
always wins; an unbound sender with no admin identity gets empty
|
|
74
|
-
documented-legal value. This changes the earlier accepted
|
|
75
|
-
channel sender's rc-spawn no longer inherits the owner
|
|
76
|
-
layer.
|
|
77
|
-
|
|
80
|
+
overrideUserId ?? ''`. The owner env fallback is removed. An authenticated
|
|
81
|
+
admin always wins; an unbound sender with no admin identity gets empty
|
|
82
|
+
`USER_ID`, a documented-legal value. This changes the earlier accepted
|
|
83
|
+
boundary: an unbound channel sender's rc-spawn no longer inherits the owner
|
|
84
|
+
`userId` at the env layer. Owner attribution is restored for one case without
|
|
85
|
+
reopening the leak: the WhatsApp channel resolves the owner self-phone
|
|
86
|
+
to the owner `userId` client-side and sends it as `overrideUserId`, so it
|
|
87
|
+
arrives as an explicit per-session identity, not a blanket env default; a
|
|
88
|
+
non-owner unbound sender still gets empty `USER_ID`. The `claude rc` daemon env
|
|
89
|
+
(not a per-session conversation) is unchanged and still carries the boot owner
|
|
90
|
+
`userId`.
|
|
78
91
|
- A credential tool with no explicit `userId` resolves to the verified caller
|
|
79
92
|
(`USER_ID`, empty treated as no identity). With no verified identity it errors
|
|
80
93
|
and writes nothing; it never substitutes an ambient env value for a missing
|
|
@@ -96,13 +109,15 @@ The binding now holds:
|
|
|
96
109
|
The channel-admin binding is reassembled at read time from three independently
|
|
97
110
|
operator-maintained sources: `adminPhones` (role allowlist), `admins[]`
|
|
98
111
|
(membership), and `users.json` `UserEntry.phone` (the phone→userId binding). If
|
|
99
|
-
they disagree, `resolveAdminUserId` returns null and the session
|
|
100
|
-
|
|
101
|
-
|
|
112
|
+
they disagree, `resolveAdminUserId` returns null and the session runs with an
|
|
113
|
+
empty `USER_ID` — here as a *misconfiguration*, not an intentional unbound
|
|
114
|
+
sender. These are `adminPhones` entries, never the owner self-phone (the owner
|
|
115
|
+
self-phone is attributed by the gate's `reason:'self-phone'` verdict), so no
|
|
116
|
+
drift entry silently inherits the owner `userId`.
|
|
102
117
|
|
|
103
118
|
On every server start the platform replays the resolution chain for each
|
|
104
|
-
account's `adminPhones` entry and logs one line per entry that would
|
|
105
|
-
|
|
119
|
+
account's `adminPhones` entry and logs one line per entry that would run with an
|
|
120
|
+
empty `USER_ID`:
|
|
106
121
|
|
|
107
122
|
[admin-identity] binding-drift accountId=<id> phone=<p> reason=no-users-binding
|
|
108
123
|
[admin-identity] binding-drift accountId=<id> phone=<p> reason=userid-not-admin userId=<u>
|
|
@@ -56,7 +56,7 @@ either is a regression.
|
|
|
56
56
|
|---|---|---|
|
|
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
|
-
| `/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. **WhatsApp and Telegram sessions (admin + public) appear in this list**, colour-coded by channel (WhatsApp green `#25D366`, Telegram blue `#229ED9`, via a `conv-channel-*` left accent bar) and carrying the standard row kebab (Usage & cost, Reset/re-seat, Rename, Archive, Delete) so the operator can inspect and re-seat a channel session's params. Each row's `channel` is the authoritative `.meta.json` sidecar value (`whatsapp`/`telegram`/`webchat`/`browser`/null), not the JSONL-envelope channel. The only role/channel excluded from the list is `public`+`webchat` (its dedicated reader is the sole surface); the exclusion predicate is `isSessionListExcluded`, decoupled from the reader's inclusion predicate `isReaderChannelSession` (unchanged), so the WhatsApp/Telegram reader panels are unaffected — a store-only WhatsApp conversation with no session JSONL stays reader-only. Observability rides `[admin-sessions-list] … channelInList=whatsapp:<n>,telegram:<n>,webchat:<n> unknownChannel=<n> excludedPublicWebchat=<n
|
|
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. **WhatsApp and Telegram sessions (admin + public) appear in this list**, colour-coded by channel (WhatsApp green `#25D366`, Telegram blue `#229ED9`, via a `conv-channel-*` left accent bar) and carrying the standard row kebab (Usage & cost, Reset/re-seat, Rename, Archive, Delete) so the operator can inspect and re-seat a channel session's params. Each row's `channel` is the authoritative `.meta.json` sidecar value (`whatsapp`/`telegram`/`webchat`/`browser`/null), not the JSONL-envelope channel. Each row also carries its sidecar `senderId`. For a WhatsApp/Telegram row with a `senderId` and no operator rename or ai-title (`titleSource:'prefix'`), the route resolves a contact `personName` and the sidebar renders the reader panel's `"<name> <number>"` label via the shared `operatorConvName`, degrading to the number alone then the stripped title on a miss, so a Sessions row and that conversation's reader-panel row read identically. The name comes from the shared `resolveOperatorNamesBySender` helper the WhatsApp reader also calls, so the two surfaces cannot drift; a renamed or ai-titled channel row keeps its own title. The only role/channel excluded from the list is `public`+`webchat` (its dedicated reader is the sole surface); the exclusion predicate is `isSessionListExcluded`, decoupled from the reader's inclusion predicate `isReaderChannelSession` (unchanged), so the WhatsApp/Telegram reader panels are unaffected — a store-only WhatsApp conversation with no session JSONL stays reader-only. Observability rides `[admin-sessions-list] … channelInList=whatsapp:<n>,telegram:<n>,webchat:<n> channelNameCandidates=<n> channelNamesResolved=<n> unknownChannel=<n> excludedPublicWebchat=<n>`, and each unresolved channel name logs one `[admin-sessions-list] op=channel-name-unresolved sessionId=<id8> channel=<whatsapp\|telegram> senderId=<id> reason=<no-senderId\|classify-miss\|neo4j-down>` line so a persistent `channelNamesResolved` shortfall is never silent. | `GET /` |
|
|
60
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 /` |
|
|
@@ -55,4 +55,4 @@ A `pendingUnreconciled` number that stays above zero across several runs means t
|
|
|
55
55
|
|
|
56
56
|
## What this does not do
|
|
57
57
|
|
|
58
|
-
One meeting type only (one length, one weekly window, one buffer). No syncing to Google or iCloud calendars: your meetings in Maxy are the source of truth. No rescheduling or cancellation links, reminders, or visitor time-zone selection. The booking page shows
|
|
58
|
+
One meeting type only (one length, one weekly window, one buffer). No syncing to Google or iCloud calendars: your meetings in Maxy are the source of truth. No rescheduling or cancellation links, reminders, or visitor time-zone selection. The booking page shows open times from a snapshot your device publishes to the edge on a short timer, so the page keeps showing availability and taking bookings even while your device is offline — a slot that gets booked drops off at once, and times that have passed are never shown. If your device stays offline a long time the open times can go stale, but they never vanish; they refresh the moment it is back online.
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Graph View
|
|
2
2
|
|
|
3
3
|
The **Graph** admin page (`/graph`) renders a force-directed view of your
|
|
4
|
-
account's Neo4j subgraph.
|
|
5
|
-
|
|
4
|
+
account's Neo4j subgraph. Node captions are hidden on first load so the
|
|
5
|
+
whole-graph overview stays uncluttered, and appear the moment you interact.
|
|
6
|
+
Once shown, captions follow the zoom level so you see the most useful identity
|
|
7
|
+
at every scale.
|
|
6
8
|
|
|
7
9
|
## Default view
|
|
8
10
|
|
|
@@ -58,11 +60,25 @@ This is an in-page overlay, so the browser tab and address bar stay put. It is
|
|
|
58
60
|
not the operating system's full-screen mode. The maximize button shows only on
|
|
59
61
|
the `/graph` page, not where the graph is embedded in another pane.
|
|
60
62
|
|
|
63
|
+
## Captions hidden on first load
|
|
64
|
+
|
|
65
|
+
When you open `/graph` (or refresh the page) the whole graph auto-fits into the
|
|
66
|
+
viewport, which on a busy graph would render every caption at once and overlap
|
|
67
|
+
them into unreadable clutter. So captions start hidden. They appear on your
|
|
68
|
+
first interaction of any kind — zoom, drag, a filter chip, a search, clicking a
|
|
69
|
+
node — and stay visible for the rest of that visit. To get the clean
|
|
70
|
+
caption-free overview back, reload the page.
|
|
71
|
+
|
|
72
|
+
This is a one-off marker tied to page load, not a zoom threshold: after the
|
|
73
|
+
first interaction, captions follow the zoom tiers below and do not hide again
|
|
74
|
+
until the next reload.
|
|
75
|
+
|
|
61
76
|
## Conversation label tiers
|
|
62
77
|
|
|
63
78
|
Conversation nodes carry the most operator-meaningful identity in the
|
|
64
79
|
subgraph (the conversation name or summary, the date it started, the message
|
|
65
|
-
count).
|
|
80
|
+
count). Once captions are showing they render in one of three tiers, switched
|
|
81
|
+
by canvas scale:
|
|
66
82
|
|
|
67
83
|
| Zoom | Label shape | Example |
|
|
68
84
|
|------|-------------|---------|
|