feat(desktop): scope the agents UI per community

Filter every managed-agent surface to the active community's relay so
concurrently running workspaces stop bleeding into each other's UI
(Phase 3 of lazy multi-workspace agents, building on the Phase 1 relay
pinning and Phase 2 lazy activation):

- Add agentRelayScope helpers: a frontend mirror of the backend relay
  normalizer plus agentBelongsToRelay / partitionAgentsByRelay /
  hasRunningAgentInCommunity. A blank pin follows the active community
  (same defense-in-depth fallback as effective_agent_relay_url), and a
  missing provider/community degrades to unscoped rather than blanking
  every surface.
- Scope the useManagedAgentActions list — and the bulk stop, start/
  stop/delete lookups, and presence derived from it — to the active
  relay via a new lenient useActiveRelayUrl hook. Persona delete keeps
  counting instances against the unscoped record set since deleting a
  persona removes instances in every community.
- Gate the 5s managed-agents liveness poll on a running agent in this
  community; agents running in other communities render no process
  state here, so they no longer keep the poll alive.
- Hold auto-restart for agents pinned to other communities — their
  working/observer signals are read from the active relay only, so a
  pure workspace switch must never fire a restart — and re-check the
  pin in the pre-fire re-fetch so a rebind cannot restart a foreign
  agent.
- Scope mergeKnownAgentPubkeys' managed-agent source to the active
  relay; the relay-agent (kind:10100) source stays unfiltered.
- Surface an "N agents running in other communities" line in the
  agents header so concurrent background agents stay discoverable.

Tested: desktop pnpm test (2939 passed, incl. new agentRelayScope,
knownAgentPubkeys, and autoRestartPolicy unit tests), tsc --noEmit,
biome check, Playwright smoke project (537 passed, 5 failed; 2 of the
failures passed on rerun and the other 3 — channels intro-scroll,
video review mode, shared-compute empty state — reproduce on baseline
HEAD without this change, so all 5 are pre-existing flakes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-07-17 21:06:50 -04:00
co-authored by Claude Fable 5
parent 2e2babb969
commit ee40613898
12 changed files with 414 additions and 14 deletions
@@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
agentBelongsToRelay,
hasRunningAgentInCommunity,
normalizeRelayUrlForCompare,
partitionAgentsByRelay,
} from "./agentRelayScope.ts";
const RELAY_A = "ws://relay-a.example.com:3000";
const RELAY_B = "wss://relay-b.example.com";
// ── normalizeRelayUrlForCompare ──────────────────────────────────────────────
// Must agree with the Rust `normalize_relay_url` (desktop/src-tauri/src/relay.rs)
// because record pins are stamped by the backend and compared here. These
// vectors mirror the Rust unit tests.
test("normalize_stripsWhitespaceAndTrailingSlashes", () => {
assert.equal(
normalizeRelayUrlForCompare(" wss://relay.example.com// "),
"wss://relay.example.com",
);
});
test("normalize_lowercasesSchemeAndAuthority_preservesPathCase", () => {
assert.equal(
normalizeRelayUrlForCompare("WSS://Relay.Example.COM:3000/Path"),
"wss://relay.example.com:3000/Path",
);
});
test("normalize_schemelessInput_passesThroughTrimmed", () => {
assert.equal(normalizeRelayUrlForCompare("not-a-url/"), "not-a-url");
});
// ── agentBelongsToRelay ──────────────────────────────────────────────────────
test("belongs_exactMatch", () => {
assert.equal(agentBelongsToRelay(RELAY_A, RELAY_A), true);
});
test("belongs_cosmeticDifferences_stillMatch", () => {
// Trailing slash and scheme/host case must not split an agent from its
// community — this is exactly what the shared normalizer exists for.
assert.equal(
agentBelongsToRelay("WS://RELAY-A.example.com:3000/", RELAY_A),
true,
);
});
test("belongs_differentRelay_doesNotMatch", () => {
assert.equal(agentBelongsToRelay(RELAY_A, RELAY_B), false);
});
test("belongs_blankPin_followsActiveCommunity", () => {
// Defense-in-depth mirror of the backend's `effective_agent_relay_url`
// blank fallback: a record that escaped stamping follows the visited
// community instead of vanishing from every community.
assert.equal(agentBelongsToRelay("", RELAY_A), true);
assert.equal(agentBelongsToRelay(" ", RELAY_B), true);
assert.equal(agentBelongsToRelay(undefined, RELAY_A), true);
});
test("belongs_noActiveCommunityRelay_degradesToUnscoped", () => {
assert.equal(agentBelongsToRelay(RELAY_A, null), true);
assert.equal(agentBelongsToRelay(RELAY_A, ""), true);
assert.equal(agentBelongsToRelay(RELAY_A, undefined), true);
});
// ── partitionAgentsByRelay: the agent-list filter ────────────────────────────
test("partition_scopesListToActiveCommunity", () => {
// Two communities' agents coexist after lazy activation; the list the
// user sees in community A must contain only A's agents (plus blank-pin
// strays), with B's surfaced only through the "other communities" count.
const agents = [
{ pubkey: "a1", relayUrl: RELAY_A },
{ pubkey: "b1", relayUrl: RELAY_B },
{ pubkey: "a2", relayUrl: `${RELAY_A}/` },
{ pubkey: "stray", relayUrl: "" },
];
const { inCommunity, other } = partitionAgentsByRelay(agents, RELAY_A);
assert.deepEqual(
inCommunity.map((agent) => agent.pubkey),
["a1", "a2", "stray"],
);
assert.deepEqual(
other.map((agent) => agent.pubkey),
["b1"],
);
});
test("partition_undefinedAgents_yieldsEmpty", () => {
const { inCommunity, other } = partitionAgentsByRelay(undefined, RELAY_A);
assert.deepEqual(inCommunity, []);
assert.deepEqual(other, []);
});
// ── hasRunningAgentInCommunity: the polling gate ─────────────────────────────
test("pollingGate_runningAgentInOtherCommunity_doesNotPoll", () => {
// A workspace switch leaves the previous community's agents running; they
// must not keep this community's 5s liveness poll alive.
const agents = [
{ relayUrl: RELAY_B, status: "running" },
{ relayUrl: RELAY_A, status: "stopped" },
];
assert.equal(hasRunningAgentInCommunity(agents, RELAY_A), false);
});
test("pollingGate_runningAgentInThisCommunity_polls", () => {
const agents = [
{ relayUrl: RELAY_B, status: "running" },
{ relayUrl: RELAY_A, status: "running" },
];
assert.equal(hasRunningAgentInCommunity(agents, RELAY_A), true);
});
test("pollingGate_blankPinRunningAgent_polls", () => {
assert.equal(
hasRunningAgentInCommunity([{ relayUrl: "", status: "running" }], RELAY_A),
true,
);
});
@@ -0,0 +1,98 @@
/**
* Relay-scoping for managed agents. Every managed-agent record is pinned to
* its home relay (`ManagedAgent.relayUrl`, stamped by the backend at create),
* and the desktop UI presents agents per community — so every surface that
* lists, counts, or acts on managed agents must scope to the active
* community's relay through these helpers. Agents pinned to other relays
* keep running in the background; they are just not "in" this community.
*/
/**
* Canonical form of a relay URL for identity comparisons — NOT for
* connecting. Frontend mirror of `normalize_relay_url` in
* `desktop/src-tauri/src/relay.rs`; the two must agree because record pins
* are stamped by the backend and compared here: trim, strip trailing
* slashes, lowercase scheme + authority (case-insensitive per RFC 3986),
* preserve any path or query case-sensitively.
*
* Distinct from `normalizeRelayUrl` in `communityStorage.ts` (input
* canonicalisation: prepends `wss://`) and in `selfProfileStorage.ts`
* (storage keys: lowercases the whole URL, path included).
*/
export function normalizeRelayUrlForCompare(url: string): string {
const trimmed = url.trim().replace(/\/+$/, "");
const schemeEnd = trimmed.indexOf("://");
if (schemeEnd === -1) {
return trimmed;
}
const scheme = trimmed.slice(0, schemeEnd);
const rest = trimmed.slice(schemeEnd + "://".length);
const pathStart = rest.indexOf("/");
const authority = pathStart === -1 ? rest : rest.slice(0, pathStart);
const path = pathStart === -1 ? "" : rest.slice(pathStart);
return `${scheme.toLowerCase()}://${authority.toLowerCase()}${path}`;
}
/**
* Whether an agent record belongs to the given community relay.
*
* A blank pin follows the active community — the same defense-in-depth
* fallback as the backend's `effective_agent_relay_url` for records that
* escaped stamping. A blank/absent community relay (no provider, no active
* community yet) degrades to unscoped rather than blanking every surface.
*/
export function agentBelongsToRelay(
agentRelayUrl: string | null | undefined,
communityRelayUrl: string | null | undefined,
): boolean {
const community = communityRelayUrl?.trim() ?? "";
if (community === "") {
return true;
}
const pinned = agentRelayUrl?.trim() ?? "";
if (pinned === "") {
return true;
}
return (
normalizeRelayUrlForCompare(pinned) ===
normalizeRelayUrlForCompare(community)
);
}
/**
* Split agents into those pinned to the active community's relay and the
* rest. `inCommunity` drives the agents screen; `other` exists only for
* cross-community affordances (the "running in other communities" count).
*/
export function partitionAgentsByRelay<T extends { relayUrl?: string | null }>(
agents: readonly T[] | undefined,
communityRelayUrl: string | null | undefined,
): { inCommunity: T[]; other: T[] } {
const inCommunity: T[] = [];
const other: T[] = [];
for (const agent of agents ?? []) {
if (agentBelongsToRelay(agent.relayUrl, communityRelayUrl)) {
inCommunity.push(agent);
} else {
other.push(agent);
}
}
return { inCommunity, other };
}
/**
* The managed-agents polling gate: poll only while an agent *in this
* community* is running. Agents running in other communities don't render
* process state on this community's surfaces, so they must not keep its
* 5s liveness poll alive.
*/
export function hasRunningAgentInCommunity(
agents: readonly { relayUrl?: string | null; status: string }[] | undefined,
communityRelayUrl: string | null | undefined,
): boolean {
return (agents ?? []).some(
(agent) =>
agent.status === "running" &&
agentBelongsToRelay(agent.relayUrl, communityRelayUrl),
);
}
+8 -4
View File
@@ -1,6 +1,7 @@
import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { hasRunningAgentInCommunity } from "@/features/agents/agentRelayScope";
import {
connectAcpRuntime,
discoverAcpAuthMethods,
@@ -11,6 +12,7 @@ import {
ensureChannelAgentPresetInChannel,
provisionChannelManagedAgent,
} from "@/features/agents/channelAgents";
import { useActiveRelayUrl } from "@/features/communities/useCommunities";
import { resolveSnapshotAvatarPng } from "@/features/agents/ui/snapshotAvatarPng";
import {
channelsQueryKey,
@@ -310,6 +312,7 @@ export function useRelayAgentsQuery(options?: { enabled?: boolean }) {
}
export function useManagedAgentsQuery(options?: { enabled?: boolean }) {
const activeRelayUrl = useActiveRelayUrl();
return useQuery({
enabled: options?.enabled ?? true,
queryKey: managedAgentsQueryKey,
@@ -321,10 +324,11 @@ export function useManagedAgentsQuery(options?: { enabled?: boolean }) {
// with no relay event to signal it, so this poll is the only liveness
// path for them. When nothing is running there IS an event path —
// `agents-data-changed` (control-plane changes) — so the idle branch
// drops its poll entirely rather than falling back to 30s.
return agents?.some((agent) => agent.status === "running")
? 5_000
: false;
// drops its poll entirely rather than falling back to 30s. Scoped to
// the active community's relay: agents left running in other
// communities render no process state on this community's surfaces,
// so they must not keep its poll alive.
return hasRunningAgentInCommunity(agents, activeRelayUrl) ? 5_000 : false;
},
});
}
@@ -34,3 +34,60 @@ test("normalisesCaseAndWhitespace_dedupingAcrossSources", () => {
assert.deepEqual([...merged], [MANAGED]);
});
// ── relay scoping ────────────────────────────────────────────────────────────
const RELAY_A = "ws://relay-a.example.com:3000";
const RELAY_B = "wss://relay-b.example.com";
test("managedAgentPinnedToOtherRelay_excludedWhenScoped", () => {
// With multiple communities' agents running concurrently, a managed agent
// pinned to relay B is not an agent "in" community A — it must not enter
// A's known-agent baseline just because it's locally managed.
const merged = mergeKnownAgentPubkeys(
[
{ pubkey: MANAGED, relayUrl: RELAY_B },
// Cosmetic URL differences must not split an agent from its community.
{ pubkey: RELAY, relayUrl: `${RELAY_A}/` },
],
undefined,
RELAY_A,
);
assert.deepEqual([...merged], [RELAY]);
});
test("blankPinnedRelay_followsActiveCommunity", () => {
// Defense-in-depth mirror of the backend's blank-relay fallback: a record
// that escaped stamping belongs to whichever community is visited.
const merged = mergeKnownAgentPubkeys(
[{ pubkey: MANAGED, relayUrl: "" }],
undefined,
RELAY_A,
);
assert.deepEqual([...merged], [MANAGED]);
});
test("relayAgents_neverRelayFiltered", () => {
// Relay agents come from the active relay's own kind:10100 profiles —
// already community-scoped at the source, so the merge must keep them
// even when a managed-agent scope is in effect.
const merged = mergeKnownAgentPubkeys(
[{ pubkey: MANAGED, relayUrl: RELAY_B }],
[{ pubkey: RELAY }],
RELAY_A,
);
assert.deepEqual([...merged], [RELAY]);
});
test("noActiveRelay_degradesToUnscopedMerge", () => {
const merged = mergeKnownAgentPubkeys(
[{ pubkey: MANAGED, relayUrl: RELAY_B }],
undefined,
null,
);
assert.deepEqual([...merged], [MANAGED]);
});
@@ -1,3 +1,4 @@
import { agentBelongsToRelay } from "@/features/agents/agentRelayScope";
import { normalizePubkey } from "@/shared/lib/pubkey";
/**
@@ -5,15 +6,27 @@ import { normalizePubkey } from "@/shared/lib/pubkey";
* normalised via `normalizePubkey` so membership checks work against
* normalised pubkeys.
*
* Structurally typed on `{ pubkey }` so node unit tests don't need to build
* full `ManagedAgent`/`RelayAgent` values.
* Managed agents are scoped to `activeRelayUrl` (the active community's
* relay): the baseline answers "is this pubkey an agent *in this
* community*", and a locally managed agent pinned to another community's
* relay is not — it neither posts here nor appears in this community's
* directory. An agent genuinely present on both relays is still covered by
* the relay-agent source (kind:10100 from the active relay), which is never
* relay-filtered. Omitting `activeRelayUrl` degrades to the unscoped merge.
*
* Structurally typed on `{ pubkey, relayUrl? }` so node unit tests don't
* need to build full `ManagedAgent`/`RelayAgent` values.
*/
export function mergeKnownAgentPubkeys(
managedAgents: readonly { pubkey: string }[] | undefined,
managedAgents:
| readonly { pubkey: string; relayUrl?: string | null }[]
| undefined,
relayAgents: readonly { pubkey: string }[] | undefined,
activeRelayUrl?: string | null,
): ReadonlySet<string> {
const pubkeys = new Set<string>();
for (const agent of managedAgents ?? []) {
if (!agentBelongsToRelay(agent.relayUrl, activeRelayUrl)) continue;
pubkeys.add(normalizePubkey(agent.pubkey));
}
for (const agent of relayAgents ?? []) {
@@ -23,6 +23,7 @@ function greenInputs(overrides = {}) {
workingSource: "none",
connected: true,
isLocalBackend: true,
inActiveCommunity: true,
isRunning: true,
edgeConsumed: false,
quiescentForMs: AUTO_RESTART_QUIESCENCE_MS,
@@ -55,6 +56,7 @@ const NEVER_FIRE_ROWS = [
["typing source alone defers", { workingSource: "typing" }],
["observer relay not connected", { connected: false }],
["remote backend", { isLocalBackend: false }],
["agent pinned to another community's relay", { inActiveCommunity: false }],
["agent not running", { isRunning: false }],
[
"edge already consumed (one attempt per rising edge)",
@@ -72,6 +74,30 @@ for (const [label, overrides] of NEVER_FIRE_ROWS) {
});
}
// ── workspace switch ─────────────────────────────────────────────────────────
test("a pure workspace switch never fires a restart", () => {
// Switching communities leaves the previous community's agents running,
// and their working/observer signals are read from the ACTIVE relay only —
// so from the new community they read as idle even mid-turn. Even with a
// fully elapsed quiescence window and every other gate green, an agent
// pinned to another community's relay must hold, whatever the (blind)
// connected/working signals claim.
for (const overrides of [
{},
{ connected: true, working: false, workingSource: "none" },
{ quiescentForMs: AUTO_RESTART_QUIESCENCE_MS * 10 },
]) {
assert.equal(
decideAutoRestart(
greenInputs({ ...overrides, inActiveCommunity: false }),
),
"hold",
"an out-of-community fire is a kill decided on blind data",
);
}
});
// ── the quiescence window ───────────────────────────────────────────────────
test("arms (does not fire) before the window elapses", () => {
@@ -33,6 +33,13 @@ export type AutoRestartInputs = {
connected: boolean;
/** Only local agents can be restarted by this loop. */
isLocalBackend: boolean;
/** Whether the agent is pinned to the active community's relay. Agents
* from other communities keep running after a workspace switch, but their
* working/observer signals are read from the ACTIVE relay only — they
* look idle and disconnected here regardless of what they're actually
* doing, so any decision about them would be made on blind data. A pure
* workspace switch must never fire a restart. */
inActiveCommunity: boolean;
/** Agent process status from the summary ("running" required). */
isRunning: boolean;
/** Edge-trigger state: true when this needsRestart rising edge has
@@ -58,6 +65,7 @@ export function decideAutoRestart(
workingSource,
connected,
isLocalBackend,
inActiveCommunity,
isRunning,
edgeConsumed,
quiescentForMs,
@@ -67,6 +75,7 @@ export function decideAutoRestart(
if (!autoRestartEnabled) return "hold";
if (!needsRestart) return "hold";
if (!isLocalBackend) return "hold";
if (!inActiveCommunity) return "hold";
if (!isRunning) return "hold";
if (!connected) return "hold";
// Any working signal — observer OR typing — defers. `working` and
@@ -5,12 +5,14 @@ import {
managedAgentsQueryKey,
useManagedAgentsQuery,
} from "@/features/agents/hooks";
import { useActiveRelayUrl } from "@/features/communities/useCommunities";
import {
startManagedAgent,
stopManagedAgent,
} from "@/shared/api/tauriManagedAgents";
import { listManagedAgents } from "@/shared/api/tauri";
import type { ManagedAgent } from "@/shared/api/types";
import { agentBelongsToRelay } from "../agentRelayScope";
import { getAgentObserverSnapshot } from "../observerRelayStore";
import { getAgentWorkingState } from "../agentWorkingSignal";
import {
@@ -36,6 +38,7 @@ const POLICY_TICK_MS = 15_000;
export function useAutoRestartPolicy() {
const queryClient = useQueryClient();
const agents: ManagedAgent[] | undefined = useManagedAgentsQuery().data;
const activeRelayUrl = useActiveRelayUrl();
const edgesRef = React.useRef(new Map<string, AutoRestartEdgeState>());
const inFlightRef = React.useRef(new Set<string>());
const [, setTick] = React.useState(0);
@@ -71,6 +74,7 @@ export function useAutoRestartPolicy() {
workingSource: working.source,
connected: observer.connectionState === "open",
isLocalBackend: agent.backend.type === "local",
inActiveCommunity: agentBelongsToRelay(agent.relayUrl, activeRelayUrl),
isRunning,
edgeConsumed: edge.consumed,
quiescentForMs: edge.armedAt === null ? 0 : now - edge.armedAt,
@@ -97,13 +101,16 @@ export function useAutoRestartPolicy() {
void (async () => {
try {
// Pre-fire re-fetch: shrink the stale-decision window to ~0.
// Pre-fire re-fetch: shrink the stale-decision window to ~0. The
// relay pin is re-checked too — a rebind (community relay edit)
// between decision and fire must not restart a foreign agent.
const fresh = await listManagedAgents();
const current = fresh.find((a) => a.pubkey === agent.pubkey);
if (
!current?.needsRestart ||
!current.autoRestartOnConfigChange ||
current.status !== "running" ||
!agentBelongsToRelay(current.relayUrl, activeRelayUrl) ||
getAgentWorkingState(agent.pubkey).source !== "none"
) {
return;
+19 -2
View File
@@ -136,7 +136,21 @@ export function AgentsView() {
) : null}
</div>
}
description="Set up and manage your agents."
description={
<>
<span>Set up and manage your agents.</span>
{agents.otherCommunityRunningCount > 0 ? (
<span
className="mt-1 block text-xs"
data-testid="other-community-running-count"
>
{agents.otherCommunityRunningCount === 1
? "1 agent running in other communities."
: `${agents.otherCommunityRunningCount} agents running in other communities.`}
</span>
) : null}
</>
}
title="Agents"
/>
<div className="flex flex-col gap-8">
@@ -323,7 +337,10 @@ export function AgentsView() {
{personas.personaToDelete ? (
<PersonaDeleteDialog
instanceCount={
(agents.managedAgents ?? []).filter(
// Deleting a persona removes its instances in EVERY community,
// so count against the unscoped record set — the community-scoped
// list would understate what the delete is about to do.
(agents.allManagedAgents ?? []).filter(
(a) => a.personaId === personas.personaToDelete?.id,
).length
}
@@ -12,8 +12,10 @@ import {
useStopManagedAgentMutation,
useDeleteManagedAgentMutation,
} from "@/features/agents/hooks";
import { partitionAgentsByRelay } from "@/features/agents/agentRelayScope";
import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
import { useChannelsQuery } from "@/features/channels/hooks";
import { useActiveRelayUrl } from "@/features/communities/useCommunities";
import { usePresenceQuery } from "@/features/presence/hooks";
import type {
AgentPersona,
@@ -75,16 +77,33 @@ export function useManagedAgentActions() {
return () => window.clearTimeout(timeoutId);
}, []);
// The agents screen is presented per community ("Agents in this
// community"), so scope the list — and every action derived from it (bulk
// stop, start/stop/delete lookups, presence) — to records pinned to the
// active community's relay. Agents pinned elsewhere keep running; they
// surface only through `otherCommunityRunningCount`.
const activeRelayUrl = useActiveRelayUrl();
const allManagedAgents = managedAgentsQuery.data;
const relayPartition = React.useMemo(
() => partitionAgentsByRelay(allManagedAgents, activeRelayUrl),
[allManagedAgents, activeRelayUrl],
);
const managedAgents = React.useMemo(
() =>
[...(managedAgentsQuery.data ?? [])].sort((left, right) => {
[...relayPartition.inCommunity].sort((left, right) => {
const activeScore = (s: string) =>
s === "running" || s === "deployed" ? 1 : 0;
const diff = activeScore(right.status) - activeScore(left.status);
if (diff !== 0) return diff;
return left.name.localeCompare(right.name);
}),
[managedAgentsQuery.data],
[relayPartition],
);
const otherCommunityRunningCount = React.useMemo(
() => relayPartition.other.filter(isManagedAgentActive).length,
[relayPartition],
);
// Observer ingestion is owner-global (useAgentObserverIngestion in
// AppShell); this hook only reads derived state.
@@ -395,6 +414,10 @@ export function useManagedAgentActions() {
managedAgentLogQuery,
managedPresenceQuery,
managedAgents,
/** Unscoped record set — for cross-community concerns only (e.g. how
* many instances a persona delete would remove across ALL communities). */
allManagedAgents,
otherCommunityRunningCount,
managedPubkeys,
channelIdToName,
channelsByPubkey,
@@ -5,6 +5,7 @@ import {
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import { mergeKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys";
import { useActiveRelayUrl } from "@/features/communities/useCommunities";
import { useStableSet } from "@/shared/hooks/useStableReference";
const EMPTY_KNOWN_AGENT_PUBKEYS: ReadonlySet<string> = new Set();
@@ -41,10 +42,13 @@ export function KnownAgentPubkeysProvider({
}) {
const managedAgents = useManagedAgentsQuery().data;
const relayAgents = useRelayAgentsQuery().data;
// Scope managed agents to the active community's relay: another
// community's still-running agents are not agents "in" this community.
const activeRelayUrl = useActiveRelayUrl();
const merged = React.useMemo(
() => mergeKnownAgentPubkeys(managedAgents, relayAgents),
[managedAgents, relayAgents],
() => mergeKnownAgentPubkeys(managedAgents, relayAgents, activeRelayUrl),
[managedAgents, relayAgents, activeRelayUrl],
);
const stable = useStableSet(merged);
@@ -112,6 +112,19 @@ export function useCommunities(): UseCommunitiesReturn {
return ctx;
}
/**
* Lenient read of the active community's relay URL — the key that scopes
* managed agents to a community (records are pinned to their home relay).
* Unlike `useCommunities`, this returns `null` instead of throwing outside
* `CommunitiesProvider`, so relay-scoping consumers (agent lists, polling
* gates, the auto-restart policy) degrade to unscoped in provider-less
* mounts rather than crashing.
*/
export function useActiveRelayUrl(): string | null {
const ctx = useContext(CommunitiesContext);
return ctx?.activeCommunity?.relayUrl ?? null;
}
function useCommunitiesInternal(): UseCommunitiesReturn {
const [communities, setCommunitiesState] =
useState<Community[]>(loadCommunities);