Completes the Phase 1 hand-off chain — HF status now actually reaches
Fabric.Backend.Guild and busy-discard on announce channels becomes
operational end-to-end.
inbound.ts:
- Add getPresenceAccounts() — returns per-agent {agentId, fabricUserId,
guildBaseUrl, fabricApiKey} for every agent that successfully logged
in. fabricUserId comes from session.user.id cached on the identity
registry; guildBaseUrl from session.guilds[0].endpoint captured in a
new private firstGuildEndpointByAgent map during connectAgent().
- Multi-guild presence is deferred; the first guild per agent is the
push target. For sim/prod-v1 each agent is in one guild so this is a
no-op simplification.
index.ts gateway_start:
- After inbound.start() resolves, instantiate PresenceSync, call
setAccounts(inbound.getPresenceAccounts()), start().
- 5-min refresh timer re-harvests accounts (catches agents added via
tool-based identity registration AFTER initial start — e.g.
recruitment flow). setAccounts is idempotent.
- gateway_stop now clears the refresh timer and stops PresenceSync
before stopping inbound.
End-to-end check (still need sim verification):
HF plugin scheduler heartbeat -> globalThis.__hfAgentStatus
-> PresenceSync tick (30s) -> PUT /agents/:uid/presence
-> agent_presences row -> computeDelivery for xType=announce
-> busy recipients skipped, idle recipients get observer delivery.
Type-check: only pre-existing openclaw/* runtime-resolved-by-jiti
errors remain; new presence wiring compiles clean.
See DIALECTIC-V2-DESIGN.md section 10 Phase 1 (deferred items now
landed).
Drops the PresenceSync class file under src/. Reads each agents HF
status from globalThis.__hfAgentStatus (exposed by
HarborForge.OpenclawPlugin) every 30s and PUTs deltas to
Fabric.Backend.Guild PUT /agents/:userId/presence so the backend can
do busy-discard on announce channel deliveries.
Implementation:
- Diffs against in-memory lastStatus map per agentId; PUT only on
change. No-op when __hfAgentStatus is undefined (HF plugin not
loaded) — degrades gracefully, backend defaults presence to
unknown which means no busy filtering.
- Per-account context: {agentId, fabricUserId, guildBaseUrl,
fabricApiKey}. Uses x-api-key header so it goes through the
existing ApiKeyGuard path on the backend.
NOT YET WIRED into index.ts gateway_start lifecycle. To finish
wiring, the registerFull block needs to:
1. After FabricInbound.start() resolves, harvest each agents
fabric user id (introspected by Center during session login —
available on FabricSession.user.id).
2. Build PresenceSyncAccount[] from those + the existing accounts
list (which already has agentId + fabricApiKey + guildBaseUrl).
3. presence = new PresenceSync(api.logger); presence.setAccounts(...);
presence.start();
4. presence.stop() on gateway_stop.
Reason for splitting: wiring needs the FabricInbound public API to
expose per-account session metadata, which is a small but separate
refactor. Module ships standalone now so the dependency direction is
clear and the wire-up patch is small.
See /home/hzhang/arch/DIALECTIC-V2-DESIGN.md section 7 (resolved
push-model design).
Plugin previously had no way for an agent to send text into a specific
channel proactively — outbound went only through the channel-reply
path (responds to the channel that woke the agent). discussion-complete
internally called client.postMessage but only for the close-time
summary, no general-purpose surface.
Three new tools (+ declare existing fabric-canvas / fabric-channel that
were registered but missing from contracts.tools so agents couldn't
see them per the openclaw plugin contract):
* fabric-send-message {guildNodeId, channelId, content}
→ {ok, messageId, seq}
Author = calling agent. Use for ARD broadcasts, follow-ups in a
different channel, etc.
* fabric-channel-list {guildNodeId, nameFilter?, xType?, includeClosed?}
→ {ok, count, channels[]}
Backend filters to public + member channels; nameFilter is client-
side case-insensitive substring; xType / includeClosed apply post-
fetch. Returns id/name/xType/lastSeq so callers can pipe into the
other tools.
* fabric-message-history {guildNodeId, channelId, seqFrom?, seqTo?, limit?}
→ {ok, page, messages[]}
Tail-by-default: omit seqFrom/seqTo and the tool fetches the
channel head from listChannels then asks for [head-limit+1, head].
Limit default 20, max 200. Backend rejects non-participants.
Plus 3 supporting client methods (listChannels, listMessages — both
GET via existing req helper).
contracts.tools updated to declare these 5 (3 new + 2 previously-
silent ones). Verified earlier in sim restart logs: openclaw warned
'plugin tool is undeclared (fabric): fabric-canvas / fabric-channel'
so agents couldn't use them despite registerTool firing.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
os.hostname() fallback is wrong in sim where container hostname (server.t2)
doesn't match the HF agent row's claw_identifier (sim-t2). Add intermediate
fallback that reads openclaw config plugins.harbor-forge.identifier — the
same value the HF plugin uses for its outbound HF calls — keeping plugin
and HF agent state aligned without a per-service-unit HF_CLAW_IDENTIFIER
env override.
Priority:
1. HF_CLAW_IDENTIFIER env (operator override)
2. openclaw config plugins.harbor-forge.identifier (NEW)
3. os.hostname() last-resort
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three behavioral changes to inbound message handling to support the
new triage flow:
## 1. Per-channel serial queue
Replaces `void this.dispatch(...)` (fire-and-forget) with a per-channel
chain so consecutive messages on the same channel are processed strictly
in order — no concurrent model turns for the same channel. Other
channels remain independent (parallelism preserved across channels).
Implementation: `Map<channelId, Promise>` where each new task awaits
the previous. The map entry self-cleans when the chain settles AND
no newer task has overwritten it.
## 2. HF on_call gate (triage + wake=true only)
Before dispatching a triage wake to the on-duty agent, hit HF
`GET /calendar/agent/status?agent_id=...`. If the agent isn't
currently on_call, the message is pushed to a per-agent gated queue
instead of dispatched — no model turn fires.
Status check is cached for 5s to amortise across rapid triage bursts.
When a subsequent triage message arrives and the agent IS on_call by
that point, the gated queue drains FIFO (re-enqueued through the same
per-channel chain so order is kept) before the new message dispatches.
Drained queue is in-memory only; on gateway restart the underlying
Fabric messages get re-fetched via the connect-time history sweep.
## 3. Triage observer skip (wake=false)
Triage messages that arrive with wakeup=false are admin observers — by
spec they MUST NOT enter the agent's session history. Skipped entirely
(no recordInboundSession call). The next time this agent legitimately
wakes for triage, their context contains only past wakeups + their own
outgoing messages — no observer-side chatter from other agents.
For NON-triage channels the legacy "record-as-history" stays — those
keep their full channel conversation available for later wakes.
## Env
- HF_API_BASE_URL — defaults `https://monitor.hangman-lab.top`
- HF_CLAW_IDENTIFIER — defaults to `os.hostname()`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to nav/Fabric.Backend.Guild#<TBD> which adds the server-side
emitToUser broadcast on channel membership changes. Before, the inbound
only learned about new channels via the 60s polling resync (worst-case
60s lag). Now the backend tells us directly so sub/unsub is realtime.
socket.on('channel.joined', evt) → join the socket.io room for evt.channelId
and add to the local 'joined' set.
socket.on('channel.left', evt) → leave + remove from 'joined'.
Both events are idempotent (`if (joined.has(id))` / `if (!joined.has(id))`)
so duplicate emits from server are safe. Polling resync still runs every
60s as a safety net for transient socket drops between emit and
reconnect, partial server failures, etc.
When backend lacks this support (older deployments), nothing breaks —
the event simply never fires and polling carries the load as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fabric inbound previously called `joinAll()` once on socket.io
`connect` — it fetched the agent's channel list via
`GET /api/channels?guildId=...` and emitted `join_channel` for each.
Any channel the agent joined *after* connect (e.g. a fresh DM created
by another user that includes this agent) was unreachable until the
gateway restarted: the socket was never subscribed to that room, so
backend `message.created` push events never arrived.
Backend doesn't emit a user-scoped `channel.joined` event we could
piggy-back on (only `message.created`), so the fix is to poll. Every
60s the agent's channel list is re-fetched and diffed against a local
`joined` set:
- new channel ids → `socket.emit('join_channel', {channelId})` + add
- ids in `joined` but absent from the fresh list → `leave_channel`
emit + remove (best-effort; cleans subs if the agent is removed from
a channel)
Re-uses `freshGuildToken()` so the resync fetch survives token
expiry (15-min TTL). Initial `connect` resets the local `joined`
set since the server forgets prior room subscriptions on reconnect.
Timers are tracked in `channelSyncTimers` and cleared in `stop()`
alongside socket disconnect.
Verified against prod server.t2 scenario: hzhang creates DM channel
including agent 'manager' → without this fix, manager only sees the
message after a gateway restart; with this fix, manager receives the
message within at most 60s (next resync tick).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
inbound: FabricMessage gains xType; the wakeup gate is bypassed when
xType==='dm' (self messages are already filtered upstream), so a 1:1
dm always reaches the model regardless of wakeup metadata.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OpenClaw delivers an agent turn whose blocks are text -> thinking/tool
-> text via multiple inbound deliver() calls (a non-text block is a
delivery boundary), so one turn became N Fabric messages.
Fix: buffer deliver() segments per channel (src/coalesce.ts) and flush
them as ONE postMessage at a deterministic boundary — the finally after
dispatchInboundReplyWithBase() resolves, which provably runs only after
every deliver() of the turn (verified: deliver,deliver -> dispatch
returned -> flush). No hooks, no timers, no idle guessing. The
agent_end hook was rejected: it fires BEFORE deliver(). gateway_stop
flushes any leftover; a long safety timeout is a leak-guard only.
channels.fabric.coalesce=false restores raw per-segment posting.
Verified on local openclaw + Fabric with a fake text/thinking/text
model: single trigger -> exactly one merged message.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The slash-command sync secret now comes from
channels.fabric.commandsSyncKey (configSchema marks it required) and
is no longer read from FABRIC_COMMANDS_SYNC_KEY env. command-sync
resolves it from config and threads it into client.syncCommands;
when absent, sync is skipped with a clear warning. README updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
syncCommands attaches the FABRIC_COMMANDS_SYNC_KEY header when the
operator sets it, so the guild can restrict slash-command catalog
writes to this plugin. No-op / backward compatible when unset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- command-sync.ts: buildFabricCommandSpecs(cfg) reads OpenClaw native
command specs via openclaw/plugin-sdk/native-command-registry
(listNativeCommandSpecsForConfig + findCommandByNativeName), resolves
dynamic arg choices to a static snapshot (resolveCommandArgChoices) —
same data Discord registers as slash commands.
- syncFabricCommands(): on gateway_start, after inbound starts, PUT the
catalog to each connected guild (FabricClient.syncCommands ->
PUT /api/commands; idempotent, one per guild).
- Fabric stays a TEXT-command surface (no nativeCommands capability):
execution still flows as a /<cmd> message into OpenClaw's command
system; this catalog only drives frontend autocomplete.
Verified: 41 specs built (args/choices incl. dynamic), synced to
test-guild1, GET /api/commands round-trips count=41.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One tool, three actions backed by FabricClient channelMembers (GET
/channels/:id/members -> [{userId,bypass}]), joinChannel, and new
leaveChannel (POST /channels/:id/leave).
Verified: client-level smoke against the running guild — members
initial=[tester], after join echo2 present, after leave echo2 gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- bin/fabric-register.mjs: only AGENT_ID is read from the environment;
--api-key is flag-only (no FABRIC_API_KEY); dropped FABRIC_CENTER_API_BASE
/ FABRIC_IDENTITY_FILE / OPENCLAW_PATH env fallbacks (flags + sensible
defaults; --center still falls back to openclaw.json).
- New fabric-canvas tool (one tool, four actions): read / share / update /
close the channel's single pinned canvas. Backed by FabricClient
get/share/update/removeCanvas (GET/PUT/PATCH/DELETE; empty 2xx body ->
null). update/close are sharer-only server-side.
- README updated.
Verified: client-level smoke against the running guild —
read(empty→null) → share(v1) → read → update(v2) → close(→null) all pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Binding an agent's Fabric API key was an OpenClaw tool; make it a
self-contained Node script installed to ~/.openclaw/bin/fabric-register
instead.
- bin/fabric-register.mjs: no plugin deps; AGENT_ID env wins, else
--agent-id required; --api-key validated via POST /auth/agent/login;
on success upserts ~/.openclaw/fabric-identity.json (format matches
IdentityRegistry). Flags/env for center, identity-file, openclaw-path.
- install.mjs: copy the script to ~/.openclaw/bin (chmod 0755) on
install, remove on uninstall; Next-steps updated.
- tools.ts: drop the fabric-register tool; ctxGuild error now points to
the script / static accounts config.
- README updated.
Verified: missing-id -> exit 2; --agent-id and AGENT_ID both bind and
write a valid identity file; bad key -> 401, no write.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously a non-wakeup message returned immediately and was fully
discarded — the agent kept zero record of it, so when later woken in a
discuss/work channel it replied without the conversation context.
Now non-wakeup messages are ingested into the agent's OpenClaw session
via recordInboundSession (createIfMissing) WITHOUT dispatch: the real
model is not invoked and nothing is sent back to Fabric. This is
correct for the turn engine — only the woken speaker emits a normal
message or /no-reply; non-woken agents stay silent — while still
giving the agent full channel context whenever it IS woken.
Verified live: report-channel (all recipients wakeup=false) message
logs 'recorded (no wakeup, history only)' with 0 dispatch/deliver/
posted; wakeup path unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OpenClaw defaults group-chat replies to sourceReplyDeliveryMode
'message_tool_only', which suppresses auto-delivery of the agent's
text reply (it expects the agent to call a message tool). With
ChatType 'group', the Fabric plugin's deliver callback was therefore
NEVER invoked — the agent ran but no reply ever returned to Fabric.
Fabric already gates *when* an agent speaks via the per-recipient
wakeup flag, so once a turn is dispatched the reply must always flow
back. Pass replyOptions.sourceReplyDeliveryMode='automatic' so
OpenClaw delivers the agent's reply through regardless of
the group default (source-reply-delivery-mode honors a truthy
requested mode).
Verified live end-to-end: human posts -> wakeup -> agent runs ->
'fabric: deliver' + 'fabric: posted reply' -> agent message appears
in the Fabric channel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Guild access tokens are short-lived (~15 min); the inbound socket
survives via socket.io reconnect but the token captured at connect
time goes stale, so attachment downloads (and reply posts) start
401ing on long-lived agents. Re-login with the agent's Fabric API key
on a short TTL and use the fresh token for fetch + post.
Verified live: 'fabric: fetched 1 attachment(s)' now succeeds where it
previously logged 'attachment fetch 401'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live round-trip test showed openclaw's SSRF guard blocking the
localhost guild file URL passed via MediaUrls. We already download the
bytes with the agent's guild token, so MediaUrls is redundant and
noisy — provide only local MediaPaths/MediaTypes. Verified: plugin
logs 'fetched N attachment(s)' and the SSRF WARN is gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When an inbound Fabric message has attachments, download each with the
agent's guild token to a temp dir and set MediaPaths/MediaTypes/
MediaUrls (+ singular) on the finalized inbound context so openclaw
hands the files to the model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
agent=account only routes correctly when openclaw cfg.bindings has a
{agentId, match:{channel:fabric, accountId}} entry; else falls back to
the default agent. Verified: with the binding, account echo -> agent
echo, reply posted back as the agent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Unlike Discord, Fabric has no message-length cap. Single-chunk chunker
(text -> [text]), textChunkLimit=MAX_SAFE_INTEGER, capabilities
blockStreaming=false, replyOptions.disableBlockStreaming=true -> every
agent reply delivered as exactly one Fabric message.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real channel-turn dispatch (resolveAgentRoute + finalizeInboundContext +
dispatchInboundReplyWithBase), wakeup->drop/dispatch, messaging target
grammar (fabric:<id>) + outbound.sendText, tools use execute/parameters.
Verified live: human msg in Fabric -> wakeup -> openclaw agent runs ->
reply posted back into the Fabric channel as the agent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OpenClaw channel plugin: defineChannelPluginEntry + createChatChannelPlugin.
Inbound via channel-turn kernel (wakeup -> admission: true=dispatch,
else drop+recordHistory). One Fabric socket per agent identity in the
plugin runtime (no sidecar). Center API-key agent auth. Tools:
fabric-register, create-{chat,work,report,discussion}-channel,
discussion-complete (post summary + close channel).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>