mcpbeat Sign in

Sync Room Bridge Echo Guard Agent Skill

One bridge joins a local component to a shared room, with the direction of travel decided by role — the source publishes what it does, the follower applies what arrived and publishes nothing — plus a flag held across the apply so a locally-observed side effect of a remote command is not fed straight back. Use when two clients in a synchronised session ping-pong each other, when applying a remote pause immediately republishes a pause, or when a follower's own reactions fire on state it did not cause.

2k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
100
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill sync-room-bridge-echo-guard

The instruction itself

3 sections, as written by the author

One bridge, two directions, decided by role

A client in a shared room has a component whose state it both *publishes* and *applies*. Write two

classes for that and they drift; write one, and make the direction a runtime decision:

// adapted — names generalized, log lines and the rest of the collector elided
private var applyingRemote = false

private suspend fun watchRoomAsFollower() {
    repository.room
        .map { snapshotOf(it) to (it.inRoom && !it.isSource) }
        .distinctUntilChanged()
        .collect { (snapshot, shouldFollow) ->
            if (!shouldFollow) return@collect
            applyingRemote = true
            try { applyLocally(snapshot) } finally { applyingRemote = false }
        }
}

Every publisher then opens with the mirror-image test — *am I the source, and is this change mine?*

— and the role is read inside the collector on every emission, never captured at construction: a

member can be promoted or demoted while the bridge is running.

Traps

Role gating stops the publishers; it does not stop the follower's own reactions. It is tempting

to conclude that a strict source/follower split makes the flag redundant — publishers require the

source role, the apply path requires the follower role, so they can never see each other. They

can: a follower has *local* reactions of its own. One that watches "the user pressed play" fires

identically whether the user pressed play or a remote command did, and without the flag every

applied command triggers whatever that reaction does. This is the case people delete the flag over,

because on paper it looks unreachable.

The flag is a bet that the local callback arrives synchronously. It covers exactly the window

between setting and clearing. If the component reports its state change through a listener that

lands a tick later, the flag is already false when the echo arrives and the guard silently does

nothing. Two consequences worth internalising: the guard is a cheap first line, not a correctness

argument, and anything that must not double-fire also needs an idempotence check that does not

depend on timing — a last-applied and a last-published id, compared before acting.

Guard the derivation, not the transmission. A helper that only ever runs from inside a guarded

collector inherits the guard; adding a second check there is noise. But a publisher that is *not*

derived from local component state — a barrier answer, a snapshot published because the role or the

member list changed — has nothing to echo, and bolting the flag onto it is an active bug: those

fire precisely while a remote change is being applied. Decide per publisher by asking what triggers

it, not by pattern-matching on the word "send".

Clear the flag in finally, and set it around the *whole* apply. An apply that throws halfway

— a load failure, a cancelled scope — leaves the flag stuck true and the client silently stops

publishing anything for the rest of the session. That failure has no symptom on the device that

caused it; it shows up as "the others stopped hearing me".

Make start() idempotent. The bridge is started from wherever the room is entered, and callers

cannot know whether something else already started it. A second call launches a second copy of

every collector, and from then on every command goes out twice.

Every publisher reacts to a change, so nobody publishes the state that was already there. A

member who was already running something and *then* takes the source role produces no transition at

all — no item change, no transport event — and the session sits in silence waiting for a command

that only arrives if they happen to touch something. That needs its own collector, on the role flag

itself, publishing a full snapshot. The same argument applies a second time when a new member

arrives after the source's last command.

Dedupe by identity as well as by flag. Keep the last id published and the last id applied.

They cost two fields and they are what stops the two ends from trading the same change back and

forth once the flag's window has closed.

There is no shared "am I allowed" gate — every collector carries its own. start() launches a

handful of independent collectors into one scope, and each is its own lifecycle watching its own

source. Nothing sits above them, so a role check written once at the top of the class protects

nothing: it has to appear inside every collector, and each one added later needs its own copy. The

census below is how you notice the one that does not have it.

A plain var guard is sound only because of a property of the scope somebody else injects. The

collectors and the apply path share one confined dispatcher — a single thread, or a UI thread — so

an unsynchronised field is fine. Nothing in the bridge says so, and nothing stops a later

refactor from handing it a multi-threaded scope, at which point the flag silently becomes a

visibility bet with no compiler complaint and no runtime error. Either name the requirement where

the scope is injected, or do not depend on the flag for anything the id comparisons cannot cover.

Keep the bridge on the one interface both platforms implement. The publishers and the apply

path are the same logic everywhere; the component underneath is not. Writing the bridge against a

concrete player means writing it twice, and the second copy is the one that gets a fix late.

Verifying it

Take a census of which functions send and which check the guard, then reason about the difference —

the two lists are *not* supposed to match, and reading why is the audit:

for f in $(grep -rlE "\b(applyingRemote|isApplyingRemote|fromRemote|suppressEcho|remoteApplyInProgress)\b" \
             --include="*.kt" . | grep -v "/build/"); do
  echo "== $f"
  awk '/^ *(private |internal )?(suspend )?fun /{fn=$0}
       /\.send[A-Z]|\.report[A-Z]|\.publish[A-Z]/{print "  SEND  " fn}
       /applyingRemote|isApplyingRemote|fromRemote|suppressEcho/{print "  GUARD " fn}' "$f" | sort -u
done

Every SEND with no matching GUARD must be explainable in one sentence: either it is a helper

called only from a guarded collector, or it is triggered by something other than local component

state. A SEND you cannot explain is the echo. If the loop prints nothing at all, no guard field

exists in the tree and every apply path is a candidate feedback loop.

Then confirm the flag is exception-safe and the role is read live:

grep -rn -A45 "applyingRemote = true" --include="*.kt" . | grep -v "/build/" \
  | grep -E "applyingRemote = (true|false)|try \{|finally \{|catch \("
grep -rnE "\bis(Host|Source|Owner)\b" --include="*.kt" . | grep -v "/build/"

The first collapses the apply block down to its structural tokens, and the window has to be wide

enough to span the block: a short one shows the try and stops dozens of lines above the finally,

so it reads the same whether the shape is right or wrong. What you want out of it is = true /

try { / finally { / = false; a = false with no finally above it is a clear that only runs

on the happy path. The second should show the role being read *inside* collectors; a role captured

once into a val at construction is the bug that survives a promotion.

Behaviourally, two clients are the only real test. Drive one, watch the other, and watch the first

one's outbound log while it applies: a command that appears outbound on the applying client is the

echo, and it is invisible from either device alone.

How to use it

Copy the folder

Take maxrave-dev/sync-room-bridge-echo-guard from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.