Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ RELAY_URL=ws://localhost:3000
# the web frontend at / for browser requests. Leave unset for local dev
# (use `just web` for Vite HMR instead).
# BUZZ_WEB_DIR=./web/dist
# Allowed CORS origins (comma-separated). Unset = permissive (dev mode).
# When set, MUST include the desktop app's webview origins or its HTTP API
# calls (invites, moderation) fail with a network error while WebSocket
# traffic still works: `tauri://localhost` (macOS/Linux) and
# `http://tauri.localhost` (Windows). Invalid values are rejected, not
# treated as permissive.
# BUZZ_CORS_ORIGINS=https://your-relay.example.com,tauri://localhost,http://tauri.localhost

# Shared Redis-backed admission limits. Defaults shown below; each value must
# be a positive integer.
Expand Down
37 changes: 36 additions & 1 deletion deploy/compose/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ services:
BUZZ_GIT_REPO_PATH: /data/git
BUZZ_AUTO_MIGRATE: ${BUZZ_AUTO_MIGRATE:-false}
BUZZ_GIT_CONFORMANCE_PROBE: ${BUZZ_GIT_CONFORMANCE_PROBE:-true}
# Loopback-only: TLS termination and access control live in nginx, so the
# relay port must not be reachable from off-box.
ports:
- "${BUZZ_HTTP_PORT:-3000}:3000"
- "127.0.0.1:${BUZZ_HTTP_PORT:-3000}:3000"
volumes:
- buzz-git-data:/data/git
depends_on:
Expand All @@ -46,6 +48,39 @@ services:
networks:
- buzz-net

# NIP-AB device-pairing sidecar. An unpaired phone only has an ephemeral key,
# so it cannot pass the main relay's membership gate at NIP-42 AUTH time —
# the pairing handshake has to happen on this separate unauthenticated relay.
# Clients discover it from the NIP-11 `pairing_relay_url` field, which the
# main relay advertises from BUZZ_PAIRING_RELAY_URL in .env.
#
# The binary ships in the same image; ENTRYPOINT is buzz-relay, so override
# `entrypoint` (not `command`, which is what the Helm chart uses — in Compose
# `command` would just be appended as args to buzz-relay).
#
# No env_file here on purpose: this service needs no DB, Redis, S3, or relay
# private key, so it should not be handed the secrets in .env.
pairing:
image: ${BUZZ_IMAGE:-ghcr.io/block/buzz:main}
entrypoint: ["/usr/local/bin/buzz-pair-relay"]
environment:
# Defaults to loopback inside the container, which the port publish below
# could not reach — bind all interfaces in the container namespace and let
# the host-side 127.0.0.1 publish do the actual confinement.
BUZZ_PAIR_RELAY_BIND_ADDR: 0.0.0.0:5000
ports:
- "127.0.0.1:${BUZZ_PAIR_PORT:-5000}:5000"
# Runtime image has bash but no curl/wget, so probe the TCP port directly.
healthcheck:
test: ["CMD-SHELL", "bash -ec 'exec 3<>/dev/tcp/127.0.0.1/5000'"]
interval: 10s
timeout: 3s
retries: 6
start_period: 5s
restart: unless-stopped
networks:
- buzz-net

postgres:
image: postgres:17-alpine
environment:
Expand Down
13 changes: 13 additions & 0 deletions desktop/src/features/agents/ui/AgentsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { TeamShareDialog } from "./TeamShareDialog";
import { SecretRevealDialog } from "./SecretRevealDialog";
import { TeamDeleteDialog } from "./TeamDeleteDialog";
import { TeamDialog } from "./TeamDialog";
import { RelayAgentsSection } from "./RelayAgentsSection";
import { TeamsSection } from "./TeamsSection";
import { UnifiedAgentsSection } from "./UnifiedAgentsSection";
import { useManagedAgentActions } from "./useManagedAgentActions";
Expand Down Expand Up @@ -61,6 +62,11 @@ export function AgentsView() {
},
);

const managedPubkeys = React.useMemo(
() => new Set(agents.managedAgents.map((agent) => agent.pubkey)),
[agents.managedAgents],
);

const isActionPending =
agents.isPending ||
personas.isPending ||
Expand Down Expand Up @@ -202,6 +208,13 @@ export function AgentsView() {
}}
/>

<RelayAgentsSection
managedPubkeys={managedPubkeys}
onOpenAgentProfile={(pubkey, options) => {
openProfilePanel?.(pubkey, options);
}}
/>

<TeamsSection
error={
teamActions.teamsQuery.error instanceof Error
Expand Down
109 changes: 109 additions & 0 deletions desktop/src/features/agents/ui/RelayAgentsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import * as React from "react";
import { ChevronDown, ChevronRight } from "lucide-react";

import { useRelayAgentsQuery } from "@/features/agents/hooks";
import { useUserProfileQuery } from "@/features/profile/hooks";
import type { RelayAgent } from "@/shared/api/types";
import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext";
import { Badge } from "@/shared/ui/badge";
import { AgentIdentityCard } from "./AgentIdentityCard";

const CARD_GRID_CLASS =
"w-full mx-auto grid max-w-[996px] grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3";

/**
* Agents that announced themselves on the relay (kind:10100) but are not
* managed by this desktop — e.g. harnesses running on a server. Read-only:
* their lifecycle is owned by wherever they run, so there are no start/stop
* controls; the cards open the agent's profile panel.
*/
export function RelayAgentsSection({
managedPubkeys,
onOpenAgentProfile,
}: {
managedPubkeys: ReadonlySet<string>;
onOpenAgentProfile: (
pubkey: string,
options?: ProfilePanelOpenOptions,
) => void;
}) {
const relayAgentsQuery = useRelayAgentsQuery({ enabled: true });
const [isCollapsed, setIsCollapsed] = React.useState(false);

const externalAgents = React.useMemo(
() =>
(relayAgentsQuery.data ?? [])
.filter((agent) => !managedPubkeys.has(agent.pubkey))
.sort((left, right) => left.name.localeCompare(right.name)),
[relayAgentsQuery.data, managedPubkeys],
);

if (externalAgents.length === 0) return null;

return (
<div className="w-full space-y-2" data-testid="relay-agents-section">
<button
className="group flex items-center gap-2 rounded-md px-1 py-1 text-left transition-colors hover:bg-muted/50"
onClick={() => setIsCollapsed((prev) => !prev)}
type="button"
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
) : (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="text-sm font-medium">Relay agents</span>
<span className="text-xs text-muted-foreground">
({externalAgents.length})
</span>
</button>
{!isCollapsed ? (
<div className={CARD_GRID_CLASS}>
{externalAgents.map((agent) => (
<RelayAgentCard
agent={agent}
key={agent.pubkey}
onOpenAgentProfile={onOpenAgentProfile}
/>
))}
</div>
) : null}
</div>
);
}

function RelayAgentCard({
agent,
onOpenAgentProfile,
}: {
agent: RelayAgent;
onOpenAgentProfile: (
pubkey: string,
options?: ProfilePanelOpenOptions,
) => void;
}) {
const profileQuery = useUserProfileQuery(agent.pubkey);
const title = profileQuery.data?.displayName?.trim() || agent.name;

return (
<AgentIdentityCard
ariaLabel={`${title} agent profile`}
avatarUrl={profileQuery.data?.avatarUrl ?? null}
dataTestId={`relay-agent-${agent.pubkey}`}
label={title}
modelLabel="Runs externally"
onClick={() => onOpenAgentProfile(agent.pubkey)}
statusBadge={
agent.status === "online" ? (
<Badge className="gap-1" variant="success">
Online
</Badge>
) : (
<Badge className="gap-1" variant="secondary">
{agent.status === "away" ? "Away" : "Offline"}
</Badge>
)
}
/>
);
}