Feature: Presence Port - #97
Merged
Merged
Conversation
Sibling seam to dataSource.ts, for state that is lossy, last-write-wins, and gone on reload: live presence, WebRTC signalling, cursors, work claims. Deliberately not part of QueryIR. The IR assumes durable entities in a model manifest, each with a globally-stable id; ephemeral state has none of those. The rule the port exists to enforce: if it must still be there after a refresh, it is not ephemeral — so durable messaging stays with DataSource. Shape is ephemeral(dataset).channel(tag). Dataset scoping because a call happens *in* a space; tag namespacing so a feature module gets a namespace it cannot collide with. Payloads are opaque, so presence, RTC, and cursors share one pipe without the transport knowing about any of them. The tag maps onto a Socket.io event, a Supabase channel topic, a Matrix to-device type, or a gossipsub topic. unicast is a tri-state rather than a boolean because 'emulated' (addressed-but-broadcast, every peer receives it) and 'native' differ in a way that matters for security, not efficiency. As a boolean, someone eventually builds a private feature on `unicast: true` and it silently is not private. authenticatedSender is declared for the same reason: AD4M supplies link.author, but a naive relay carries the sender in the payload where it is forgeable, and a work-claim lease must be able to tell the difference. planEphemeral mirrors planQuery — fail loudly at registration rather than mounting a consumer that cannot work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Liveness derivation, the heartbeat driver, and the selectors — all backend-agnostic and DOM-free, so they unit-test against a fake channel with no executor. Flux welds the equivalent into one AD4M-coupled composable that also owns profile hydration and an unrelated WebRTC protocol; splitting them is the point. Two shape decisions carry the design: focus is one value, activities is a list. A call is not a place you are instead of the Kanban board — it is something you are in while also being somewhere. Routes are exclusive, participation is not. Flux found this and modelled it flatly (currentRoute + callRoute + inCall + mediaSettings); as a list, concurrent calls in one space fall out for free, and a feature module adds an activity type without touching the schema. availability (declared) and liveness (measured) are separate. Flux fuses them into one union and carries a standing TODO about it. They are orthogonal: an agent can be busy and online, or available and stale. Also lifted from Flux, because they are load-bearing: adaptive scheduling (a change publishes immediately and pushes the next tick out a full interval, so navigating quickly does not double-send) and the join handshake (a joiner sets hello, peers re-announce at once, otherwise a joiner waits out a whole interval against a stateless lossy transport). Added: TTL eviction, which Flux lacks — its agent map only ever relabels to offline and grows forever. invisible stops publishing entirely rather than filtering on receipt. Flux keeps broadcasting full state including route when invisible and hides it client-side, so any modified client sees invisible agents and where they are. peersMatching takes a partial focus rather than offering peersAtPath: a route path is only meaningful within a dataset, and two spaces routinely share one. The tests caught exactly that unioning peers across spaces — a bug invisible in single-space testing, the same class as broadcasting a local uuid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sibling to ad4mAdapter.ts, and deliberately the same size and shape: a capability profile plus the minimum translation, with no timers, no liveness derivation, and no profile fetching. If it grows past ~120 lines something backend-agnostic has leaked in. sendBroadcastU is an unsigned broadcast never written to the perspective — exactly right for state that must not persist. The sender arrives as link.author from the executor rather than from the payload, hence authenticatedSender: true; that is what stops a peer writing into another agent's presence slot, and what will make a work-claim lease trustworthy. unicast is 'emulated': AD4M exposes real directed send (sendSignal / sendSignalU) but it is known-broken, so this addresses over broadcast with the recipient DID in the link target and a receive-side filter. That is addressing, not privacy — every peer still receives the payload — which is why planEphemeral refuses confidential consumers rather than silently exposing them. Mirrors the drill-down predicate workaround already in ad4mAdapter.ts: a backend defect degrades to an adapter workaround, not a WE outage. When sendSignalU is fixed, flip the capability and delete the filter; grep unicast:emulated. No consumer changes. One signal handler per scope, fanned out to channels by predicate suffix — registering per-channel would mean N executor subscriptions for one stream. Returns null for a personal space (no neighbourhood, nobody to signal) so consumers degrade deliberately instead of publishing into a void. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Without this every open tab heartbeats independently: N times the broadcast traffic, and peers see one agent flapping between whatever each tab happens to be showing. Leadership follows window focus, so the tab the user is actually looking at is the one whose location gets published, and a tab holding something uninterruptible can pin leadership and refuse to yield. Followers stay fully subscribed — only publishing is restricted, so every tab's UI stays live. Lives in app-framework/shared rather than schema-shared because BroadcastChannel is a DOM API: schema-shared is DOM-free and is consumed by the we-validate-schemas CLI under Node. This is host wiring, like $onError and $useQueryIR. Degrades to a permanent sole leader under electron/tauri and anywhere BroadcastChannel is unavailable, which is the correct behaviour for a single window. Adapted from Flux's useTabCoordinator, minus the Vue coupling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Solid binding over the neutral core. It owns the three things the core deliberately does not: when to start and stop (following the current perspective), what to publish as focus (following the route), and the join from a bare agentId to a displayable profile. App-lifetime, not view-lifetime — mounted in StoreProvider rather than inside a view. Flux creates a signalling service per community view and tears it down on unmount, so leaving the view loses every peer with no path back except remounting. Publishing and subscribing are asymmetric and easy to conflate. This agent has one location, so it publishes once per heartbeat to the space it is in, and subscribes only to that same space. Our own dot needs no transport at all — it is routeStore.currentPath, read locally. Presence for every joined space would let the sidebar show occupancy everywhere, but inbound traffic is (spaces x members / heartbeat) signals per second, each crossing the executor's GraphQL boundary into reactive state on the main thread; widening this is a deliberate later decision. On space change the source is torn down rather than retained. Retention without subscription only preserves state already past its TTL, and the join handshake repopulates in one round trip anyway. Flux defines deleteCommunityService and never calls it, so services accumulate holding frozen agent maps. Focus publishes datasetUri from currentPerspectiveSharedUrl, never perspective.uuid: AD4M uuids are local per-agent, so a broadcast uuid is meaningless to whoever receives it. This fails silently across peers while looking correct locally. Tab-leader gating wraps channel.publish at the store rather than living in the driver, keeping the neutral core unaware that browser tabs exist. Profiles come from adamStore.agents(), the cache $identities and $agent already use — presence never fetches one, so it cannot repeat Flux's N-peer Promise.all every five seconds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"N online now" plus an avatar row, to the right of the route buttons — the nav row already has ax: 'between', so a second child lands there. Reuses AvatarStack exactly as the members row below the title does. Hidden when nobody else is around rather than rendering "0 online now", and absent entirely in a personal space, where there is no neighbourhood and so no presence. Reads presenceStore through $store, so this needs no renderer or schema-shared changes. A $presence block would be sugar on top and touches schema-solid; keeping it out means the first consumer of the port is proved by an ordinary template. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On an unhealthy executor `sendBroadcast` hangs until a 30s RPC timeout while presence heartbeats every 5s, so six stuck calls accumulate at steady state — each one adding load to the backend that is already the problem — and the console fills with a warning every five seconds, burying whatever the real fault is. Adds an opt-in `coalesce` channel option: drop a publish while a previous one is still in flight. Correct only for idempotent last-write-wins traffic, where the next beat carries the same state; explicitly wrong for a handshake, where a dropped RTC offer is simply lost. Presence opts in; the default is off so the call module's rtc channel is unaffected. Failure logging now backs off geometrically (1st, 2nd, 4th, 8th…) and logs once on recovery with the count, so an unreachable neighbourhood reports itself without drowning the console. Neither of these makes presence work against a neighbourhood that is not syncing — they stop presence making it worse, and stop it hiding the cause. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…est it Refactors the coordinator to take its channel, focus source, and tab id as dependencies — the pattern createHeartbeatPresence already uses for now/timers — so leader election is testable without a DOM. Adds 20 tests. The public interface is unchanged; PresenceStore is untouched. Writing them surfaced two real defects, both of the silent kind: no exception, no log, just doubled heartbeats or none, which would present as an AD4M problem. Symmetric conflict resolution never terminates. "Hear another leader, step down" makes *both* step down, so nobody publishes until the timeout, then both take over again — a stable oscillation. Now the lower tab id survives. Deterministic and total. Focus still expresses preference; the id comparison only resolves conflict. Pinning was a local exemption, which is not a total order. A pinned leader skipped the comparison for itself but never told the other leader to yield, so when the pinned tab held the higher id neither stepped down and both published forever. Pinning is now asserted with a `pinned` message, and a `pinned` received while also pinned falls back to the id comparison — otherwise two tabs each holding a call would both defer and leave nobody publishing. Also fixes a genuine crash-recovery hole in the test harness rather than the code: a killed tab must stop sending as well as receiving, or its heartbeats keep holding peers off after it is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes that together make decay legible and stop it firing when it shouldn't. **bye on departure.** Leaving a space was indistinguishable from a crashed laptop: the agent simply stopped heartbeating and ran the full ladder, so it lingered for a full offlineAfter (60s) in a space it left instantly. Arrival was already one round trip via hello; departure is now symmetric. Best-effort by design — the transport is lossy, so TTL decay remains the backstop and this is only the fast path. An invisible agent stays silent: peers hold no state to retract, and a bye would disclose the departure and therefore the presence. **Two visual channels, because there are two independent facts.** tone (ring colour) carries declared availability — available/away/busy → success/warning/ danger. emphasis (opacity) carries measured liveness — online/idle/stale → full/muted/faded. Folding both into ring colour would force a false choice between showing that someone is on Do Not Disturb and showing that we are losing contact with them, and it would spend amber on a connection state when every user reads amber as "away". **Stable sort.** Most-present first, tiebroken on agentId. Not cosmetic: peers come out of a Map, so equal-liveness peers would otherwise order by insertion and the avatar row would reshuffle on every heartbeat, making a settled group look like it was churning. AvatarStack gains per-avatar tone and emphasis. Both are generic — any stack may want to distinguish or de-emphasise members — so no presence semantics leak into the design system, and tone maps to DS colour tokens rather than letting templates or stores name colours. The ring is never removed, only recoloured: it is what separates overlapping avatars. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts the two-channel encoding added in the previous commit. Ring colour now tracks liveness directly: green active, amber idle, red stale. The opacity channel was wrong for this component, for a reason that is structural rather than a matter of tuning: **avatars in a stack overlap**, so lowering opacity lets the avatar behind show through the one in front. The opaque separator ring is precisely what prevents that, so opacity and overlap cannot coexist. It also read as more confusing than it was worth — two axes encoded at once is more than a glanceable indicator should carry. Removes `emphasis` from AvatarStack rather than leaving it in the design system unused and quietly broken in the component's primary layout. `tone` stays; it is sound and generic. `availability` is no longer mapped to colour. Nothing can set it today — no idle detector, no manual control — so every peer reports `available` and the channel would be dead weight. A test records the current behaviour so that when that UI lands, deciding how the two axes combine is a deliberate change rather than an accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Deploy Preview for coasys-we ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR: The Ephemeral Port and Live Presence
Summary
Adds a fifth backend port to WE —
Ephemeral, for transient agent-to-agent state — and its firstconsumer,
Presence. The visible result is "N online now" plus an avatar row in the space header,showing who else is in the space right now.
The port is the load-bearing part. Presence proves it works; the WebRTC call module, live cursors,
typing indicators, and DM typing all ride the same seam afterwards.
Branch:
feat/presence-port· 11 commits · 58 new tests · verified across two agentsDesign rationale in full:
notes/we/August-2026/presence-port.md.Why a new port rather than
QueryIRQueryIRassumes durable entities declared in a model manifest, each with a globally-stableid.Ephemeral state has none of those — no manifest entity to name, no id surviving a restart, nothing
persisted to query. Routing presence through
$querywould either corrupt the IR's guarantees orrequire inventing a phantom entity type.
The rule this port exists to enforce, to be applied every time something is proposed for it:
Durable messaging (chat, DMs) therefore stays with
DataSource. See "Non-consumer: user-to-usermessaging" in the design note — a two-person neighbourhood gives confidentiality by membrane, which
an ephemeral transport inside a shared space cannot.
What's in it
@we/schema-shared— the neutral core (DOM-free, fully unit-tested)ephemeral.ts— the port:Dataset-scoped because a call happens in a space and DM typing in a DM neighbourhood.
Tag-namespaced so a feature module gets a namespace it cannot collide with. Payloads are opaque, so
presence, RTC, and cursors share one pipe without the transport knowing about any of them. The tag
maps onto a Socket.io event name, a Supabase channel topic, a Matrix to-device type, or a gossipsub
topic.
Plus
EphemeralCapabilitiesandplanEphemeral, mirroringAdapterCapabilities/planQuery.presence.ts—Focus,Activity,PresenceState,Peer,derivePeers,applyFocusDepth,peersMatching,callRosters,peerTone,sortByPresence, andcreateHeartbeatPresence.@we/app-framework— the bindingsshared/ad4mEphemeralAdapter.tsad4mAdapter.tsshared/tabCoordinator.tsframeworks/solid/stores/PresenceStore.tsxschemas/…/HeaderLayout/SpaceHeader.ts@we/components— one design-system additionAvatarInfo.tone— a per-avatar ring colour as a semantic token (success/warning/danger/primary/neutral), overriding the stack-levelring. Generic rather than presence-specific: anystack may want to distinguish members. The design system owns the colour, so neither templates nor
stores ever name one.
Design decisions worth reviewing
unicastis a tri-state, not a boolean'emulated'means addressed-but-broadcast: every peer receives the payload and is trusted to discardit. Fine for SDP/ICE, fatal for anything confidential. As a boolean, someone eventually builds a
private feature on
unicast: trueand it silently is not private — which is the state Flux is intoday, where every "directed" WebRTC signal and private emoji reaction is broadcast community-wide
and filtered client-side (
webrtcStore.ts:88-92).AD4M is
'emulated': it exposes real directed send (sendSignal/sendSignalU) but that isknown-broken, so the adapter addresses over broadcast. When it is fixed, flip one value and delete
the filter — grep
unicast:emulated. No consumer changes. Same doctrine as the drill-down predicateworkaround already in
ad4mAdapter.ts: a backend defect degrades to an adapter workaround, not a WEoutage.
Corollary, stated so it does not have to be rediscovered: confidentiality comes from membrane or
encryption, never from addressing.
authenticatedSenderis declared, not assumedAD4M supplies
link.authorfrom the executor, so a peer cannot write into another agent's presenceslot — which is what will make a work-claim lease trustworthy. A naive relay carries the sender in
the payload, where it is forgeable. Without the flag, a host could implement this port entirely
correctly and still ship spoofable presence.
focusis one value;activitiesis a listA call is not a place you are instead of the Kanban board — it is something you are in while also
being somewhere. Routes are exclusive; participation is not.
Flux found this and modelled it flatly:
currentRoute+callRoute+inCall+mediaSettings.As a list, concurrent calls in one space fall out for free (Flux structurally cannot express
them —
callRouteis one value, so one call per agent), and a feature module adds an activity typewithout touching the schema.
availability(declared) vsliveness(measured)Flux fuses them into one union and carries a standing
// TODO: better distinguish between manually set agent status and signalling health(useSignallingService.ts:236). They are orthogonal: anagent can be
busyandonline, oravailableandstale.Only
livenessis shown today.peerTonecolours the avatar ring green / amber / red acrossonline/idle/stale. Nothing can setavailabilityyet — no idle detector, no manual control— so every peer reports
availableand rendering it would be dead weight decided on a guess. A testpins the current behaviour so that when that UI lands, choosing how the two axes combine is a
deliberate change rather than something that falls out of code written today.
Departure is announced, not inferred
Leaving a space was indistinguishable from a crashed laptop: the agent simply stopped heartbeating
and ran the full decay ladder, so it lingered for a full
offlineAfter(60s) in a space it leftinstantly. Arrival was already one round trip via
hello;byemakes departure symmetric, and peersdrop the agent outright rather than watching it fade.
An optimisation, not a guarantee — the transport is lossy and fire-and-forget, so a lost
byejust falls back to decay. Fast path explicit, slow path derived, exactly as
hellorelates to theheartbeat.
One case that is easy to get wrong: an invisible agent sends no
bye. It published nothing, sopeers hold no state to retract, and sending one would disclose the departure and therefore the
presence.
Ring colour, not opacity
The first attempt encoded two axes at once — ring colour for declared
availability, opacity formeasured
liveness— so both could be read together. It was reverted after testing, for a reasonthat is structural rather than a matter of tuning:
It also simply read as more confusing; two axes is more than a glanceable indicator should carry. So
emphasiswas removed fromAvatarStackrather than left in the design system unused and quietlybroken in the component's own primary layout.
focusis hierarchical, and that is the privacy dial{ datasetUri, path?, nodeId? }. Publish the deepest you know; consumers slice. Space presence is aselector, not a different data model.
offspaceroutepreciseavailability: 'invisible'is the separate, stronger control that stops publishing entirely.Flux keeps broadcasting full state including route when invisible and merely hides it on receipt, so
any modified client sees invisible agents and where they are.
datasetUri, neverperspective.uuidAD4M perspective uuids are local per-agent — the same neighbourhood has a different one on every
peer, so a broadcast uuid is meaningless to whoever receives it. Same local-id vs global-uri split
already baked into
DatasetHandle. This fails silently across peers while looking correctlocally, which is why it is in the type rather than in a comment.
Presence never fetches a profile
It carries
agentIdonly; profiles come fromadamStore.agents(), the cache$identitiesand the$agentblock already use. Flux's presence map is its profile cache, so it re-hydrates every peerprofile on every heartbeat — an N-peer
Promise.allevery five seconds.Note the deliberate divergence from
spaceStore.members, which filters out agents with no cachedprofile. Presence does not: a peer with no profile still counts. So when profile fetching is failing,
"N online now" still shows a count while the members row goes empty. That is presence being
correctly more robust, not a bug.
App-lifetime, not view-lifetime; retain nothing, subscribe deliberately
Flux creates a signalling service per community view and stops it on unmount, so leaving the view
loses every peer with no path back except remounting. It also defines
deleteCommunityServiceandnever calls it, so services accumulate holding frozen agent maps — retention without
subscription, which keeps state and lets it rot.
PresenceStorelives inStoreProviderand re-scopes on space change, tearing the old source down.Retention buys nothing: presence is TTL'd, so a retained map is already stale, and the join handshake
repopulates in one round trip.
Subscription scope is current-space-only. Presence for every joined space would let the sidebar
show occupancy everywhere, but inbound traffic is
(spaces × members ÷ heartbeat)signals persecond — 20 spaces × 30 members ÷ 5s ≈ 120/s, each crossing the executor's GraphQL boundary into
reactive state on the main thread. Widening this is a deliberate later decision and realistically
wants a backend that reports presence server-side.
Lifted from Flux, and what was fixed
Kept, because load-bearing:
interval, so navigating quickly does not double-send.
hello, peers re-announce at once. Without it a joiner waits awhole interval against a stateless, lossy transport.
stays live.
Fixed:
offline, grows foreverbyedrops the peer at oncewatch{deep:true})unicast: 'emulated'so nothing builds privacy on itBackpressure (
coalesce)channel(tag, { coalesce: true })drops a publish while a previous one is still in flight.Correct only for idempotent last-write-wins traffic, where the next beat carries the same state.
Explicitly wrong for a handshake: an RTC offer dropped because the previous send is slow is simply
lost. Default off; presence opts in.
Earned from a real failure. On an unhealthy executor
sendBroadcasthangs until a 30s RPC timeoutwhile presence heartbeats every 5s, so six stuck calls accumulate at steady state — each adding load
to the backend that is already the problem — and a warning every five seconds buries the actual
fault. Failure logging now backs off geometrically (1st, 2nd, 4th, 8th…) and logs once on recovery
with the count.
Testing
Verified: 576 tests in
@we/schema-shared(38 new) and 82 in@we/app-framework(20 new);@we/schema-shared,@we/app-frameworkand@we/componentstypecheck; lint clean; all 13 templateschemas validate. Confirmed working live across two agents on separate machines.
The neutral core is tested against a fake channel and an injected clock, so liveness derivation, the
handshake, adaptive scheduling, eviction, activity replacement, and the invisible-stops-publishing
guarantee are all covered without a backend. The tab coordinator takes its channel, focus source, and
tab id as dependencies for the same reason, and is driven by an in-memory bus.
Three real defects were found by writing tests, all silent in production:
peersAtPath(peers, '/kanban')unioned peers across different spaces sharing a route path —the same class of mistake as broadcasting a local uuid, invisible in single-space testing.
Replaced with
peersMatching(peers, partialFocus), so a path cannot be expressed without itsdataset.
both step down; nobody publishes until the timeout, then both take over again. Now the lower tab
id survives — deterministic and total.
for itself but never told the other leader to yield, so when the pinned tab held the higher id
both published forever. Pinning is now asserted, with a fallback to the id comparison when both
tabs are pinned (two calls in two tabs).
None of the three throws or logs. Each would have presented as an AD4M problem.
Live verification
Exercised across two agents on separate machines: peers appear, the ring tracks the decay ladder, and
departures drop immediately. This also confirms the adapter's two assumptions about AD4M's wire
behaviour, neither of which unit tests can reach — that
link.authorarrives populated (so a peercannot write into another agent's slot) and that
sendBroadcastUdoes not loop back to the sender.Getting there needed a fresh pair of neighbourhoods. An earlier attempt failed entirely, and the
diagnosis is worth keeping because it is not obvious and will recur:
neighbourhood.sendBroadcasttimed out on the 30s RPC deadline, repeatedly.agentByDIDreturned 500 —centralized-agent-languagecould not reachsocket.ad4m.dev(
Connection refused, os error 111), so no profile resolved.client.neighbourhood.otherAgents(),which touches none of this code.
socket.ad4m.devbacks bothcentralized-agent-languageandcentralized-p-diff-sync, and thelatter serves link sync and telepresence. So one unreachable host can explain every symptom at
once, with none of it being Holochain or presence. The check is
await client.runtime.knownLinkLanguageTemplates()— new spaces take[0].AD4M's telepresence is not an out-of-band channel.
p-diff-sync'ssendBroadcastis a Holochainzome call (
bootstrap-languages/p-diff-sync/index.ts:346), so presence shares the conductor with datasync. It does not depend on the perspective's links having synced — presence lights up before space
data loads — but a wedged conductor hangs it. On a websocket or Matrix host the two are genuinely
independent;
EphemeralCapabilitiesdoes not currently express that difference.Still worth re-checking on any future backend, in priority order:
sourcemust carry adatasetUristarting
neighbourhood://, not a perspective uuid. If wrong,onlinepopulates butonlineHerenever does.
bye); ring goes green → amber at 15s → red at30s → gone at 60s.
Deliberately deferred
$presencerenderer block + tier validation. The header readspresenceStorethrough$store, so this PR needs no renderer or@we/schema-sharedrender-path changes. A block is sugarand touches
schema-solid— separate, riskier, and it shouldn't ride along.setAvailabilityexists on the store but nothing calls it, soevery peer reports
availableand onlylivenessreaches the ring. Wants an input-idle detector(no mouse/keyboard for N minutes →
away), a manual control, andAgentSettingspersistence inwe-root — the pattern is already there alongside
currentTemplateId/perspectiveOrder. Notethat auto-derived
awayshould not persist, while a manual "do not disturb" should.focusDepthexists on the store; nothing surfaces it yet. Also wantsAgentSettingspersistence.planEphemeralhas no caller. Presence needs only fan-out, so it would always pass. Themechanism is there for the call module, which needs
unicast.calls/activitiesOfTypeare unused until the call module.idlemeans "missed three beats", so a wobbly connection dips the ring toamber and back. Deliberate for now — it makes the mechanism visible — but worth softening once it
has been watched in real use.
Follows on
notes/we/August-2026/feature-modules.md— the call module is a feature module, and needs twohost capabilities that do not exist yet: an open shell-slot mechanism (
shellRegistryis a closedthree-key object, and a call bar must persist across navigation) and store registration
(
TemplateProviderbuilds its stores bag as an object literal). That work is the next PR; the callmodule is the one after.