Triage channels now compute a 3-state delivery decision per recipient
(wake / observer / skip) instead of the binary wakeup flag, and route
according to:
1. author never gets back their own message → skip
2. wake_mapping member (on-duty) → wake
3. mention (NEW: was 'skip' for triage before) → wake
4. Center-scoped admin (at most 1) → observer
5. anyone else → skip
(was 'deliver wake=false')
Skipping means the websocket emit is omitted entirely — the recipient's
openclaw plugin never sees the message and the agent's session stays
free of background noise. Observer means delivered with wakeup=false
(silent UI / no model dispatch on the plugin side).
## What this PR ships
### realtime/realtime.gateway.ts
- new `computeDelivery()` returns DeliveryDecision = 'wake'|'observer'|'skip'
- old `computeWakeup()` kept as a deprecated wrapper for callers that
still want the boolean answer (treats observer + skip as false)
- `emitMessageCreated` accepts `adminUserId?: string|null` and now
short-circuits on 'skip' (no socket emit at all)
- general kept its current behavior; custom kept its current behavior
(members not in wake_mapping become observer instead of `wake=false`)
— the user-visible bit is just that the response field is the same
`wakeup: boolean`; the explicit 'skip' is new for triage
### common/center-auth.ts
- `fetchAdminEmail()` calls GET `${center}/auth/admin-email` with the
existing x-api-key (same auth as introspect/resolve-names). Returns
`{email, userId}` or `null` on either "no admin" or any error
### common/admin-cache.service.ts (NEW)
- `AdminCacheService` — in-memory cache, 1-day TTL, lazy refresh.
`get(force=true)` bypasses TTL for cli-triggered refresh
- exposed by MessagingModule
### messaging/messaging.controller.ts
- non-rotating branch threads `adminUserId` into emitMessageCreated
### cli/admin-refresh.ts (NEW)
- `node dist/cli/admin-refresh.js` — force-refresh cache and print
before/after JSON. Use after a Center `user set-admin` so triage
delivery picks up the new admin without waiting for 24h TTL
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend half of the plugin push-based channel sync (companion to
nav/Fabric.OpenclawPlugin#1 follow-up). Before this, the OpenClaw
fabric inbound had to poll `/api/channels?guildId=...` every 60s to
discover newly-joined channels (any DM another user just dragged the
agent into). Now the server tells the agent's socket directly so
sub/unsub is realtime.
Changes:
- realtime.gateway.ts:
* handleConnection joins the socket into a `user:<userId>` room.
All of a user's connected sockets now share that room.
* New `emitToUser(userId, event, data)` helper that emits into
that room. No-op for offline users (next connect resyncs via the
plugin's initial channel-list fetch).
- channels.service.ts:
* Inject RealtimeGateway (RealtimeModule is @Global, no module
plumbing needed).
* Private `notifyMembership(kind, channelId, userIds, extra)`
helper that emits `channel.<kind>` (joined|left) with payload
{channelId, userId, xType, occurredAt}.
* create(): emit channel.joined to every seeded member (creator +
explicit memberUserIds + triage on-duty).
* joinChannel(): emit channel.joined to userId (only if the row was
actually inserted, idempotent on existing membership).
* leaveChannel(): emit channel.left to userId iff a row was
actually deleted.
Event shape:
{
channelId: string,
userId: string,
xType?: string,
occurredAt: ISO string,
}
Client-side contract (fabric plugin):
socket.on('channel.joined', m => socket.emit('join_channel', {channelId: m.channelId}))
socket.on('channel.left', m => socket.emit('leave_channel', {channelId: m.channelId}))
The plugin keeps its 60s polling resync as a safety net for missed
events (transient socket drops between emit and reconnect, partial
failures, etc).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
channel enum + X_TYPES + realtime XType gain 'dm'. dm channels are
forced private (never public) and non-unique (no dedup; create()
always makes a fresh one). computeWakeup: dm wakes every non-author
participant unconditionally (no rotation / no wake_mapping). The
message.created realtime payload now carries xType so the plugin can
treat dm specially.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
package.json type=module, tsconfig module/moduleResolution=NodeNext,
target es2022, explicit .js on all relative imports. Center: jsonwebtoken
& bcryptjs switched to default imports (ESM/CJS interop). Verified:
builds, boots, full auth + plugin round-trip work under ESM.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- parse <@user-id> outside backtick spans
- general: message with an at-list wakes only the at'd users (else all)
- report/triage/custom: mentions change nothing
- discuss/work: mention by current speaker pushes a sub-rotation frame
(atList = mentions - sender, intersected with channel members); single
linear pass (real/no-reply/force-proceed), then pop back to the saved
parent pointer (resumes at the pusher); nested frames supported
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>