diff --git a/AGENTS.md b/AGENTS.md
index 42fab9da..1f37c120 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,6 +45,27 @@ complete alternative. Require a current caller or explicit approval for adapter
parity. Review necessity separately from correctness; passing tests do not justify
scope growth. Split at real ownership boundaries, not by deleting safety coverage.
+For non-trivial work, make that standard operational:
+
+- Before coding, publish a short scope checkpoint: required behavior, non-goals,
+ existing owners/platform support to reuse, expected files, and a rough production
+ diff budget (separate from tests/docs). A small fix needs only a sentence, not a
+ design ceremony.
+- Use one implementation owner per end-to-end change. Reviewers challenge necessity
+ as well as correctness; delegate bounded evidence/review, not competing rewrites.
+ Review the first working slice before expanding the design, without blocking
+ ordinary human UI feedback on a full validation cycle.
+- Justify each new abstraction, lifecycle owner, timer, retry policy, or shared
+ contract expansion against a current requirement. If the implementation materially
+ exceeds the checkpoint, stop adding machinery and show the smallest alternative
+ and any behavior tradeoff before continuing. Do not silently weaken agreed behavior.
+- Assess the combined feature diff, including stacked PRs. Passing tests, splitting
+ PRs, or already-invested work do not establish proportionality. Preserve required
+ regression coverage; do not game the budget by deleting tests or compressing code.
+ Keep speculative hardening and unrelated failures outside the task.
+- Close with one verified end-to-end result and explicit remaining gaps, not a chain
+ of green intermediate repairs presented as completion.
+
Keep files cohesive and group modules and tests by owner. Treat size as a review
signal, not a quota. Extract stable boundaries only when they simplify the
requested change.
diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs
index c6818119..f5e8a2c1 100644
--- a/dev/relay-broker-api.test.mjs
+++ b/dev/relay-broker-api.test.mjs
@@ -494,3 +494,86 @@ test("both real sign and publish routes admit direct replies but reject arbitrar
await h.close();
}
});
+
+test("held optional snapshot body leaves ordinary broker capacity free and start credit untouched", async () => {
+ let release;
+ const h = await harness((call) => {
+ if (call.body?.[0]?.kinds?.includes(20001))
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ release = () => {
+ controller.enqueue(new TextEncoder().encode("[]"));
+ controller.close();
+ };
+ },
+ }),
+ );
+ return Response.json([]);
+ });
+ const presence = [{ kinds: [20001], authors: [h.event.pubkey], limit: 1 }];
+ try {
+ const snapshot = h.post("presence-snapshot", presence);
+ await vi.waitFor(() => expect(release).toBeTypeOf("function"));
+ const duplicate = await h.post("presence-snapshot", presence);
+ expect(duplicate.status).toBe(204);
+ expect((await h.post("query", filters)).status).toBe(200);
+ expect(h.calls).toHaveLength(2);
+ expect(h.calls[1].at - h.calls[0].at).toBeLessThan(400);
+ release();
+ release = undefined;
+ expect(await (await snapshot).json()).toEqual([]);
+ expect(
+ (await h.post("presence-snapshot", [{ ...presence[0], authors: [] }]))
+ .status,
+ ).toBe(400);
+ expect(
+ (
+ await h.post("presence-snapshot", [
+ { ...presence[0], authors: Array(257).fill(h.event.pubkey) },
+ ])
+ ).status,
+ ).toBe(400);
+ expect(h.calls).toHaveLength(2);
+ } finally {
+ release?.();
+ await h.close();
+ }
+});
+
+test("presence snapshot progresses while an ordinary response body is held", async () => {
+ let release;
+ const h = await harness((call) => {
+ if (call.body?.[0]?.kinds?.includes(20001)) return Response.json([]);
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ release = () => {
+ controller.enqueue(new TextEncoder().encode("[]"));
+ controller.close();
+ };
+ },
+ }),
+ );
+ });
+ let ordinary;
+ try {
+ ordinary = h.post("query", filters, undefined, "background");
+ await vi.waitFor(() => expect(release).toBeTypeOf("function"));
+ const snapshot = await h.post("presence-snapshot", [
+ { kinds: [20001], authors: [h.event.pubkey], limit: 1 },
+ ]);
+ expect(snapshot.status).toBe(200);
+ expect(await snapshot.json()).toEqual([]);
+ expect(h.calls).toHaveLength(2);
+ const missing = await h.post("stream-presence", {
+ streamId: "0".repeat(32),
+ status: "online",
+ });
+ expect(await missing.json()).toEqual({ accepted: null });
+ } finally {
+ release?.();
+ await ordinary;
+ await h.close();
+ }
+});
diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs
index b6e56d46..4685be26 100644
--- a/dev/relay-broker.mjs
+++ b/dev/relay-broker.mjs
@@ -48,6 +48,8 @@ import {
ApiPaused,
ApiCapacity,
apiFailure,
+ presenceFilter,
+ presenceText,
} from "../src/features/relay/http-admission.ts";
import { execFileSync } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
@@ -340,6 +342,7 @@ export function relayBrokerPlugin({
};
const stats = { queries: 0, errors: 0, media: 0, connects: 0 };
let inflight = 0;
+ let presenceFlight = false;
let sidebarUploads = 0;
let libraryRead;
const streams = new Map();
@@ -555,6 +558,7 @@ export function relayBrokerPlugin({
readState: true,
agentLibrary: true,
live: true,
+ presence: true,
agentActivity: true,
});
}
@@ -563,9 +567,11 @@ export function relayBrokerPlugin({
"/api/relay/stream-retry",
"/api/relay/stream-priority",
"/api/relay/stream-observer",
+ "/api/relay/stream-presence",
].includes(route) &&
req.method === "POST"
) {
+ const publishingPresence = route === "/api/relay/stream-presence";
const prioritizing = route === "/api/relay/stream-priority";
const observing = route === "/api/relay/stream-observer";
let raw = "";
@@ -574,10 +580,15 @@ export function relayBrokerPlugin({
if (Buffer.byteLength(raw) > (prioritizing ? 9000 : 256))
return json(res, 413, { error: "Live control too large" });
}
- let streamId, priority, observer;
+ let streamId, priority, observer, status;
try {
const body = JSON.parse(raw);
streamId = body.streamId;
+ if (publishingPresence) {
+ status = body.status;
+ if (status !== "online" && status !== "away")
+ throw new Error("Invalid presence");
+ }
if (observing) observer = observerGeneration(body.observer);
if (prioritizing) {
liveChannels(body.channels);
@@ -594,10 +605,27 @@ export function relayBrokerPlugin({
)
return json(res, 400, { error: "Invalid live control" });
const stream = streams.get(streamId);
+ if (publishingPresence && (!stream || stream.relay !== relay))
+ return json(res, 200, { accepted: null });
if (!stream || stream.relay !== relay)
return json(res, 404, {
error: "Live stream no longer available",
});
+ if (publishingPresence) {
+ const cancel = new AbortController();
+ const abort = () => cancel.abort();
+ res.once("close", abort);
+ try {
+ const accepted = await stream.traffic.publishPresence(
+ status,
+ cancel.signal,
+ );
+ if (!res.destroyed) return json(res, 200, { accepted });
+ } finally {
+ res.off("close", abort);
+ }
+ return;
+ }
if (prioritizing) stream.traffic.prioritize(priority);
else if (observing) stream.traffic.observe(observer);
else stream.traffic.retry();
@@ -757,6 +785,7 @@ export function relayBrokerPlugin({
if (
![
"/api/relay/query",
+ "/api/relay/presence-snapshot",
"/api/relay/sign",
"/api/relay/publish",
"/api/relay/read-state-sign",
@@ -770,10 +799,13 @@ export function relayBrokerPlugin({
req.method !== "POST"
)
return json(res, 404, { error: "Unknown broker route" });
+ const presence = route === "/api/relay/presence-snapshot";
let raw = "";
for await (const part of req) {
raw += part;
- if (raw.length > 65536)
+ if (
+ presence ? Buffer.byteLength(raw) > 20 * 1024 : raw.length > 65536
+ )
return json(res, 413, { error: "Filter body too large" });
}
let filters;
@@ -782,6 +814,8 @@ export function relayBrokerPlugin({
} catch {
return json(res, 400, { error: "Filter body is not JSON" });
}
+ if (presence && !presenceFilter(filters))
+ return json(res, 400, { error: "Invalid presence filter" });
let workflowPath;
if (route === "/api/relay/workflow-runs") {
try {
@@ -957,83 +991,96 @@ export function relayBrokerPlugin({
? "/api/invites/accept-policy"
: "/query");
const method = workflowPath ? "GET" : "POST";
- if (inflight >= MAX_INFLIGHT)
+ const lane = admissions(relay, viewer).api;
+ let releasePresence;
+ if (presence) {
+ releasePresence = !presenceFlight ? lane.tryPresence() : undefined;
+ if (!releasePresence) {
+ res.writeHead(204);
+ return res.end();
+ }
+ presenceFlight = true;
+ }
+ if (!presence && inflight >= MAX_INFLIGHT)
return json(res, 429, {
error: "Query concurrency limit",
sent: false,
});
- inflight++;
+ if (!presence) inflight++;
try {
- const lane = admissions(relay, viewer).api;
const body = workflowPath ? undefined : JSON.stringify(filters);
const admissionStart = performance.now();
let connectsBefore, upstreamStart;
let response;
const requestSignal = AbortSignal.any([
cancel.signal,
- AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
+ AbortSignal.timeout(presence ? 10000 : UPSTREAM_TIMEOUT_MS),
]);
- response = await admittedApiRequest(
- lane,
- () => {
- // Auth freshness and network timings begin at dispatch, not queue entry.
- requestSignal.throwIfAborted();
+ const request = () => {
+ // Auth freshness and network timings begin at dispatch, not queue entry.
+ requestSignal.throwIfAborted();
+ timings.push(
+ `admission;dur=${(performance.now() - admissionStart).toFixed(2)}`,
+ );
+ const authStart = performance.now();
+ const auth = finalizeEvent(
+ {
+ kind: 27235,
+ created_at: Math.floor(Date.now() / 1000),
+ content: "",
+ tags: [
+ ["u", `${relay}${upstreamPath}`],
+ ["method", method],
+ ...(body === undefined
+ ? []
+ : [
+ [
+ "payload",
+ createHash("sha256").update(body).digest("hex"),
+ ],
+ ]),
+ ["nonce", randomBytes(16).toString("hex")],
+ ],
+ },
+ key,
+ );
+ timings.push(
+ `auth;dur=${(performance.now() - authStart).toFixed(2)}`,
+ );
+ connectsBefore = upstream.connects();
+ upstreamStart = performance.now();
+ return fetchUpstream(`${relay}${upstreamPath}`, {
+ method,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization:
+ "Nostr " +
+ Buffer.from(JSON.stringify(auth)).toString("base64"),
+ },
+ body,
+ redirect: "error",
+ signal: requestSignal,
+ }).then((response) => {
timings.push(
- `admission;dur=${(performance.now() - admissionStart).toFixed(2)}`,
- );
- const authStart = performance.now();
- const auth = finalizeEvent(
- {
- kind: 27235,
- created_at: Math.floor(Date.now() / 1000),
- content: "",
- tags: [
- ["u", `${relay}${upstreamPath}`],
- ["method", method],
- ...(body === undefined
- ? []
- : [
- [
- "payload",
- createHash("sha256").update(body).digest("hex"),
- ],
- ]),
- ["nonce", randomBytes(16).toString("hex")],
- ],
- },
- key,
+ `ttfb;dur=${(performance.now() - upstreamStart).toFixed(2)}`,
);
- timings.push(
- `auth;dur=${(performance.now() - authStart).toFixed(2)}`,
+ return response;
+ });
+ };
+ response = presence
+ ? await request()
+ : await admittedApiRequest(
+ lane,
+ request,
+ requestSignal,
+ route === "/api/relay/query" &&
+ req.headers["x-buzz-read-priority"] === "background"
+ ? "background"
+ : "foreground",
);
- connectsBefore = upstream.connects();
- upstreamStart = performance.now();
- return fetchUpstream(`${relay}${upstreamPath}`, {
- method,
- headers: {
- "Content-Type": "application/json",
- Authorization:
- "Nostr " +
- Buffer.from(JSON.stringify(auth)).toString("base64"),
- },
- body,
- redirect: "error",
- signal: requestSignal,
- }).then((response) => {
- timings.push(
- `ttfb;dur=${(performance.now() - upstreamStart).toFixed(2)}`,
- );
- return response;
- });
- },
- requestSignal,
- route === "/api/relay/query" &&
- req.headers["x-buzz-read-priority"] === "background"
- ? "background"
- : "foreground",
- );
- const text =
- snapshot && response.ok
+ const text = presence
+ ? await presenceText(response)
+ : snapshot && response.ok
? await readSnapshotText(response)
: workflowPath && response.ok
? await workflowReadText(response)
@@ -1062,6 +1109,8 @@ export function relayBrokerPlugin({
} catch {
failure = apiFailure(response.status, undefined);
}
+ if (presence && failure.quota === "api")
+ lane.pause(failure.retryAfterMs);
return json(res, response.status, failure);
}
if (profile) {
@@ -1080,7 +1129,10 @@ export function relayBrokerPlugin({
});
res.end(text);
} finally {
- inflight--;
+ if (presence) {
+ presenceFlight = false;
+ releasePresence();
+ } else inflight--;
}
} catch (error) {
if (res.destroyed) return; // The browser gave up first; nothing to answer.
diff --git a/docs/presence.md b/docs/presence.md
new file mode 100644
index 00000000..5669fb93
--- /dev/null
+++ b/docs/presence.md
@@ -0,0 +1,56 @@
+# Periodically refreshed community presence
+
+Message and thread bylines and profiles show Online, Away, Offline or Unknown
+with text and distinct symbols, not color alone. This is snapshot presence, not
+an immediate live-status stream.
+
+## Ownership and bounds
+
+- One app input source derives Away after ten minutes without Buzz input. Focus
+ loss and changing communities alone do not mean Away. Same-origin windows share
+ recent input through BroadcastChannel; no machine-idle or cross-device claim.
+- Each retained connected session owns one volatile presence directory. Mounted
+ rows (including timeline overscan and offscreen thread replies) demand authors.
+ At most 256 unique authors are selected; profiles take priority, then existing
+ selections and stable acquisition order. Overflow stays Unknown with a tooltip.
+- Initial/new demand coalesces for 100ms behind a five-second start gate. Successful
+ views refresh after 60–65 seconds. Evidence expires 75 seconds after request start.
+ Empty demand makes no request; removed authors lose their evidence. Hidden views,
+ disconnect, access/cache invalidation and disposal invalidate observations.
+- One bounded complete snapshot is validated before any status changes. The
+ configured relay must sign each unique requested subject; only a successful
+ complete response can make omitted subjects Offline. This is read-time evidence,
+ not the relay's remaining lease. Invalid or failed responses mean Unknown.
+- The development broker permits one optional HTTP flight, a principal-wide
+ five-second start gate, 256 subjects, a 20 KiB request and 1 MiB response, and
+ a ten-second request lifetime. Optional work never uses ordinary read slots or
+ dispatch credit. Local busy skips retry after 5–6 seconds; network failures wait
+ 60–65 seconds, honoring longer server cooldowns.
+- Renewal uses the existing authenticated socket, with a Web Lock per scope/viewer
+ serializing same-origin windows. Publication is lossy and bounded; it never
+ enters the durable outbox, replays missed ticks, or publishes Offline on close.
+ Hidden observation does not stop connected-community renewal. Actual shared
+ relay cooldowns still take priority. A locally unsent publication
+ retries current status after 5–6 seconds through the same renewal timer; refused
+ or unconfirmed publications retain the 60–65 second interval.
+
+Bounded presence reads and publications can run during ordinary HTTP work and
+channel subscription setup; neither waits for the entire host to become idle.
+Unknown can persist during relay cooldowns or unavailability. These limits bound
+client work; they do not
+promise zero CPU/network/backend cost, instant transitions, or a delivery SLA.
+There are no presence REQs, added sockets, relay changes, or direct-adapter parity.
+
+## Validation
+
+Owner tests live with `features/presence`, relay transport/admission and the broker.
+`tests/browser/presence.spec.mjs` exercises the built app and production broker with
+modeled upstream and ephemeral keys: held snapshots versus chat, shared row demand,
+300 distinct thread authors, Unknown during held replacement reads, and real
+same-origin Web Lock handoff. `channel-opening.spec.mjs` includes matched-thread
+measurement support. See [browser measurement limits](browser-testing.md).
+
+These fixtures do not certify deployed relay capacity, native-window behavior,
+attended account use, or cross-device availability. Full browser activity/idle
+transition integration and the complete changed-call-site mutation audit remain
+separate validation work; activity derivation has controlled owner tests.
diff --git a/src/app/services.test.ts b/src/app/services.test.ts
index ff070391..83f4917c 100644
--- a/src/app/services.test.ts
+++ b/src/app/services.test.ts
@@ -121,8 +121,16 @@ async function openCommunities() {
function expectHostStopped() {
expect(signals.every((signal) => signal.aborted)).toBe(true);
for (const stream of streams) expect(stream.close).toHaveBeenCalledTimes(1);
- expect(document.addEventListener).toHaveBeenCalledTimes(3);
- expect(document.removeEventListener).toHaveBeenCalledTimes(3);
+ const added = vi.mocked(document.addEventListener).mock.calls;
+ const removed = vi.mocked(document.removeEventListener).mock.calls;
+ expect(added).toHaveLength(9); // Existing host listeners + one app presence source.
+ expect(removed).toHaveLength(added.length);
+ for (const [type, listener] of added)
+ expect(
+ removed.filter(
+ ([event, callback]) => event === type && callback === listener,
+ ),
+ ).toHaveLength(1);
expect(services.pages.snapshot()).toHaveLength(0);
}
diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx
index b0e4e260..352a6805 100644
--- a/src/bundled/profiles/ProfilePanel.tsx
+++ b/src/bundled/profiles/ProfilePanel.tsx
@@ -1,3 +1,4 @@
+import { PresenceIndicator } from "../../features/presence/react";
import {
useEffect,
useMemo,
@@ -107,6 +108,7 @@ function ProfileDetails({
{name}
+
{profile?.about && {profile.about}
}
{context?.canOpen(activity) && (
diff --git a/src/features/communities/service.ts b/src/features/communities/service.ts
index ee7951ca..8a9dfac5 100644
--- a/src/features/communities/service.ts
+++ b/src/features/communities/service.ts
@@ -1,4 +1,5 @@
// FOUNDATION: Client identity and membership selection outlive community query sessions.
+import { createPresenceActivity } from "../presence/activity";
import { Context } from "@deepseek-ai/cordis";
import { provideRelay, type RelayData } from "../relay/service";
import { connectBrokerTransport } from "../relay/transport";
@@ -31,6 +32,7 @@ export function createCommunities(ctx: Context, live: boolean) {
let unresolvedSelection: string | null = null;
let disposed = false;
const controller = new AbortController();
+ const presenceActivity = createPresenceActivity();
const listeners = new Set<() => void>();
const relayListeners = new Set<() => void>();
const sessions = new Map
();
@@ -72,8 +74,10 @@ export function createCommunities(ctx: Context, live: boolean) {
const acquire = (id: string) => {
let session = sessions.get(id);
if (!session) {
- session = provideRelay(newScope(), (signal) =>
- connectBrokerTransport("", signal, id),
+ session = provideRelay(
+ newScope(),
+ (signal) => connectBrokerTransport("", signal, id),
+ presenceActivity,
);
sessions.set(id, session);
session.subscribe(() => {
@@ -185,6 +189,7 @@ export function createCommunities(ctx: Context, live: boolean) {
ctx.effect(() => () => {
disposed = true;
controller.abort();
+ presenceActivity.dispose();
listeners.clear();
relayListeners.clear();
return Promise.all(scopes.map((scope) => scope.fiber.dispose()));
diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx
index f112d9e0..ab4e1013 100644
--- a/src/features/messages/MessageRow.tsx
+++ b/src/features/messages/MessageRow.tsx
@@ -1,4 +1,5 @@
import { memo, useCallback, useSyncExternalStore } from "react";
+import { PresenceIndicator } from "../presence/react";
import type { RelaySession } from "../relay/session";
import type { UnreadCapability } from "../relay/unread";
import { profileTarget } from "../profiles/target";
@@ -125,6 +126,12 @@ export const MessageRow = memo(function MessageRow({
{name}
+ {session && (
+
+ )}