From d7402d594616534e920f1bd447f938cb14cd56ba Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 20:30:39 -0700 Subject: [PATCH 1/3] feat(agents): gate activity by relay ownership - Add a relay ownership endpoint backed by agent_owner_pubkey and is_agent_owner so desktop activity visibility uses the same source of truth as observer telemetry authorization. - Add a Tauri ownership command, frontend API wrapper, and useCanViewAgentActivity hook with local managed-agent optimism only while relay ownership is loading. - Replace profile popover, profile panel, and members sidebar activity gates so owned agents can open activity across different builds and worktrees without relying on local managed-agent lists. - Refactor channel agent session candidate resolution so owned agents can keep an activity panel open when channel metadata is stale, while preserving local-only lifecycle controls. - Add focused desktop unit coverage for the ownership predicate and stale-metadata session resolution path, plus E2E bridge support for ownership mocks. --- crates/buzz-relay/src/api/agents.rs | 75 +++++++ crates/buzz-relay/src/api/bridge.rs | 6 +- crates/buzz-relay/src/api/mod.rs | 1 + crates/buzz-relay/src/router.rs | 4 + .../src-tauri/src/commands/agent_ownership.rs | 38 ++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/relay.rs | 26 +++ .../agents/hooks/useCanViewAgentActivity.ts | 43 ++++ .../agentSessionOwnershipResolution.test.mjs | 82 ++++++++ .../agents/lib/canViewAgentActivity.test.mjs | 60 ++++++ .../agents/lib/canViewAgentActivity.ts | 43 ++++ .../channels/lib/agentSessionCandidates.ts | 162 ++++++++++++++ .../src/features/channels/ui/ChannelPane.tsx | 13 +- .../features/channels/ui/ChannelScreen.tsx | 18 +- .../channels/ui/MembersSidebarMemberCard.tsx | 15 +- .../channels/ui/useChannelActivityTyping.ts | 2 +- .../channels/ui/useChannelAgentSessions.ts | 198 +++++------------- .../features/profile/ui/UserProfilePanel.tsx | 7 +- .../profile/ui/UserProfilePopover.tsx | 8 +- desktop/src/shared/api/tauriAgentOwnership.ts | 34 +++ desktop/src/testing/e2eBridge.ts | 16 ++ 22 files changed, 684 insertions(+), 170 deletions(-) create mode 100644 crates/buzz-relay/src/api/agents.rs create mode 100644 desktop/src-tauri/src/commands/agent_ownership.rs create mode 100644 desktop/src/features/agents/hooks/useCanViewAgentActivity.ts create mode 100644 desktop/src/features/agents/lib/agentSessionOwnershipResolution.test.mjs create mode 100644 desktop/src/features/agents/lib/canViewAgentActivity.test.mjs create mode 100644 desktop/src/features/agents/lib/canViewAgentActivity.ts create mode 100644 desktop/src/features/channels/lib/agentSessionCandidates.ts create mode 100644 desktop/src/shared/api/tauriAgentOwnership.ts diff --git a/crates/buzz-relay/src/api/agents.rs b/crates/buzz-relay/src/api/agents.rs new file mode 100644 index 000000000..a7d7be4c0 --- /dev/null +++ b/crates/buzz-relay/src/api/agents.rs @@ -0,0 +1,75 @@ +//! Agent ownership lookup — GET /api/agents/:pubkey/ownership (NIP-98 auth). +//! +//! Returns the relay-authoritative `agent_owner_pubkey` mapping and whether +//! the authenticated caller is the registered owner. Used by the desktop to +//! gate observer activity visibility without relying on channel membership or +//! local managed-agent store state. + +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use serde::Serialize; + +use crate::state::AppState; + +use super::bridge::{canonical_url, check_nip98_replay, verify_bridge_auth}; +use super::{api_error, internal_error}; + +/// Response body for the agent-ownership lookup endpoint. +#[derive(Debug, Serialize)] +pub struct AgentOwnershipResponse { + /// Hex-encoded pubkey of the agent whose ownership was queried. + pub agent_pubkey: String, + /// Hex-encoded pubkey of the registered owner, if one is set. + pub owner_pubkey: Option, + /// Whether the authenticated caller is the registered owner of the agent. + pub is_owner: bool, +} + +/// Resolve whether the authenticated user owns `agent_pubkey` per relay DB. +pub async fn get_agent_ownership( + State(state): State>, + headers: HeaderMap, + Path(agent_pubkey): Path, +) -> Result, (StatusCode, Json)> { + let agent_hex = agent_pubkey.trim().to_ascii_lowercase(); + if agent_hex.len() != 64 || !agent_hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(api_error(StatusCode::BAD_REQUEST, "invalid agent pubkey")); + } + + let agent_bytes = hex::decode(&agent_hex) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid agent pubkey hex"))?; + + let path = format!("/api/agents/{agent_hex}/ownership"); + let url = canonical_url(&state.config.relay_url, &path); + let (actor_pubkey, event_id_bytes) = + verify_bridge_auth(&headers, "GET", &url, None, state.config.require_auth_token)?; + check_nip98_replay(&state, event_id_bytes)?; + + let actor_bytes = actor_pubkey.to_bytes().to_vec(); + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + super::relay_members::enforce_relay_membership(&state, &actor_bytes, auth_tag).await?; + + let owner_pubkey = state + .db + .get_agent_channel_policy(&agent_bytes) + .await + .map_err(|e| internal_error(&format!("ownership lookup failed: {e}")))? + .and_then(|(_policy, owner)| owner); + + let is_owner = state + .db + .is_agent_owner(&agent_bytes, &actor_bytes) + .await + .map_err(|e| internal_error(&format!("ownership check failed: {e}")))?; + + Ok(Json(AgentOwnershipResponse { + agent_pubkey: agent_hex, + owner_pubkey: owner_pubkey.map(hex::encode), + is_owner, + })) +} diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 342d0e341..99f6dbda3 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -24,7 +24,7 @@ use super::{api_error, internal_error, not_found}; /// /// Returns the authenticated public key and an event ID for replay detection. /// For X-Pubkey dev mode, the event ID is a zero hash (no replay concern). -fn verify_bridge_auth( +pub(crate) fn verify_bridge_auth( headers: &HeaderMap, method: &str, url: &str, @@ -73,7 +73,7 @@ fn verify_bridge_auth( /// /// Uses moka's `entry` API for atomic insert-if-absent — no race window /// between "check if seen" and "mark as seen". -fn check_nip98_replay( +pub(crate) fn check_nip98_replay( state: &AppState, event_id_bytes: [u8; 32], ) -> Result<(), (StatusCode, Json)> { @@ -95,7 +95,7 @@ fn check_nip98_replay( } /// Reconstruct the canonical URL for NIP-98 verification from the relay config. -fn canonical_url(relay_url: &str, path: &str) -> String { +pub(crate) fn canonical_url(relay_url: &str, path: &str) -> String { let base = relay_url .trim() .trim_end_matches('/') diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index e7c1b6fd7..6d519a162 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -1,5 +1,6 @@ //! HTTP API — media, git, NIP-05, and the Nostr HTTP bridge. +pub mod agents; pub mod bridge; pub mod events; pub mod git; diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 226592a07..703a5b109 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -64,6 +64,10 @@ pub fn build_router(state: Arc) -> Router { .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) .route("/count", post(api::bridge::count_events)) + .route( + "/api/agents/{pubkey}/ownership", + get(api::agents::get_agent_ownership), + ) // Webhook trigger (secret-authenticated, no NIP-98) .route("/hooks/{id}", post(api::bridge::workflow_webhook)) // Huddle audio WebSocket route diff --git a/desktop/src-tauri/src/commands/agent_ownership.rs b/desktop/src-tauri/src/commands/agent_ownership.rs new file mode 100644 index 000000000..a607d06d7 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_ownership.rs @@ -0,0 +1,38 @@ +//! Relay-authoritative agent ownership lookup for activity visibility gates. + +use reqwest::Method; +use serde::Serialize; +use tauri::State; + +use crate::{ + app_state::AppState, + relay::{get_relay_json, relay_api_base_url_with_override}, +}; + +#[derive(Debug, Serialize, serde::Deserialize)] +pub struct AgentOwnershipStatus { + /// Lowercase hex pubkey of the queried agent. + pub agent_pubkey: String, + /// Lowercase hex owner pubkey from relay `agent_owner_pubkey`, if set. + pub owner_pubkey: Option, + /// True iff the current workspace identity is the relay-recorded owner. + pub is_owner: bool, +} + +/// Resolve whether the current identity owns `agent_pubkey` per relay DB. +#[tauri::command] +pub async fn resolve_agent_ownership( + agent_pubkey: String, + state: State<'_, AppState>, +) -> Result { + let agent_hex = agent_pubkey.trim().to_ascii_lowercase(); + if agent_hex.len() != 64 { + return Err("agent pubkey must be 64 hex characters".to_string()); + } + + let api_base = relay_api_base_url_with_override(&state); + let path = format!("/api/agents/{agent_hex}/ownership"); + let url = format!("{api_base}{path}"); + + get_relay_json::(&state, Method::GET, &url, &[]).await +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 559577bf7..698ea6cbc 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ mod agent_discovery; mod agent_models; +mod agent_ownership; mod agent_settings; mod agents; mod canvas; @@ -29,6 +30,7 @@ mod workspace; pub use agent_discovery::*; pub use agent_models::*; +pub use agent_ownership::*; pub use agent_settings::*; pub use agents::*; pub use canvas::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 10a8f98a0..715a5f257 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -749,6 +749,7 @@ pub fn run() { unarchive_identity, list_archived_identities, resolve_oa_owner, + resolve_agent_ownership, list_relay_agents, list_managed_agents, create_managed_agent, diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 332eb4f33..adc0e4178 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -281,6 +281,32 @@ pub async fn query_relay_at( parse_json_response(response).await } +// ── HTTP bridge: GET (JSON) ───────────────────────────────────────────────── + +/// Execute an authenticated GET against the relay HTTP API and deserialize JSON. +pub async fn get_relay_json( + state: &AppState, + method: Method, + url: &str, + body: &[u8], +) -> Result { + let auth = build_nip98_auth_header(&method, url, body, state)?; + + let response = state + .http_client + .request(method, url) + .header("Authorization", auth) + .send() + .await + .map_err(|e| classify_request_error(&e))?; + + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + + parse_json_response(response).await +} + // ── Command response parsing ──────────────────────────────────────────────── /// Parse a command-event OK message of the form `"response:"`. diff --git a/desktop/src/features/agents/hooks/useCanViewAgentActivity.ts b/desktop/src/features/agents/hooks/useCanViewAgentActivity.ts new file mode 100644 index 000000000..a6bc220fd --- /dev/null +++ b/desktop/src/features/agents/hooks/useCanViewAgentActivity.ts @@ -0,0 +1,43 @@ +import { useQuery } from "@tanstack/react-query"; + +import { useIsManagedAgent } from "@/features/agent-memory/hooks"; +import { resolveCanViewAgentActivity } from "@/features/agents/lib/canViewAgentActivity"; +import { resolveAgentOwnership } from "@/shared/api/tauriAgentOwnership"; + +export const agentOwnershipQueryKey = (agentPubkey: string) => + ["agentOwnership", agentPubkey.toLowerCase()] as const; + +export function useAgentOwnershipQuery( + agentPubkey: string | null | undefined, + enabled = true, +) { + return useQuery({ + enabled: enabled && Boolean(agentPubkey), + queryKey: agentOwnershipQueryKey(agentPubkey ?? ""), + queryFn: () => resolveAgentOwnership(agentPubkey as string), + staleTime: 60_000, + }); +} + +/** + * Relay-authoritative gate for observer activity visibility. + * + * Returns `{ canView, isLoading }`. While ownership is loading, locally + * managed agents may show activity optimistically; the final answer always + * comes from relay `is_agent_owner`. + */ +export function useCanViewAgentActivity( + agentPubkey: string | null | undefined, + options?: { enabled?: boolean }, +) { + const enabled = (options?.enabled ?? true) && Boolean(agentPubkey); + const ownershipQuery = useAgentOwnershipQuery(agentPubkey, enabled); + const isManagedAgent = useIsManagedAgent(enabled ? agentPubkey : null); + + return resolveCanViewAgentActivity({ + relayOwnership: ownershipQuery.data, + isManagedAgent, + isOwnershipLoading: ownershipQuery.isLoading, + isManagedLoading: isManagedAgent === undefined, + }); +} diff --git a/desktop/src/features/agents/lib/agentSessionOwnershipResolution.test.mjs b/desktop/src/features/agents/lib/agentSessionOwnershipResolution.test.mjs new file mode 100644 index 000000000..70a7b4df8 --- /dev/null +++ b/desktop/src/features/agents/lib/agentSessionOwnershipResolution.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getChannelAgentSessionAgents, + resolveOpenAgentSessionAgent, +} from "../../channels/lib/agentSessionCandidates.ts"; + +const agent = (pubkey, source) => ({ + pubkey, + name: pubkey.slice(0, 8), + status: "deployed", + agentSource: source, + canInterruptTurn: source === "managed", +}); + +test("resolveOpenAgentSessionAgent prefers channel-scoped candidate", () => { + const channelAgent = agent("aa".repeat(32), "managed"); + const otherAgent = agent("bb".repeat(32), "relay"); + + const resolved = resolveOpenAgentSessionAgent({ + allAgentCandidates: [channelAgent, otherAgent], + channelAgentSessionAgents: [channelAgent], + openAgentSessionPubkey: channelAgent.pubkey, + }); + + assert.equal(resolved, channelAgent); +}); + +test("resolveOpenAgentSessionAgent falls back to owned agent outside channel list", () => { + const ownedAgent = agent("cc".repeat(32), "relay"); + + const resolved = resolveOpenAgentSessionAgent({ + allAgentCandidates: [ownedAgent], + channelAgentSessionAgents: [], + openAgentSessionPubkey: ownedAgent.pubkey, + }); + + assert.equal(resolved, ownedAgent); +}); + +test("resolveOpenAgentSessionAgent synthesizes minimal agent when metadata is stale", () => { + const pubkey = "dd".repeat(32); + + const resolved = resolveOpenAgentSessionAgent({ + allAgentCandidates: [], + channelAgentSessionAgents: [], + openAgentSessionPubkey: pubkey, + }); + + assert.deepEqual(resolved, { + pubkey, + name: pubkey.slice(0, 8), + status: "deployed", + agentSource: "relay", + canInterruptTurn: false, + }); +}); + +test("getChannelAgentSessionAgents keeps managed agents visible in channel membership", () => { + const activeChannel = { + id: "channel-1", + name: "general", + }; + const candidates = [agent("ee".repeat(32), "managed")]; + + const visible = getChannelAgentSessionAgents({ + activeChannel, + activeChannelId: activeChannel.id, + agents: candidates, + channelMembers: [ + { + pubkey: candidates[0].pubkey, + role: "bot", + displayName: "Scout", + }, + ], + }); + + assert.equal(visible.length, 1); + assert.equal(visible[0]?.pubkey, candidates[0].pubkey); +}); diff --git a/desktop/src/features/agents/lib/canViewAgentActivity.test.mjs b/desktop/src/features/agents/lib/canViewAgentActivity.test.mjs new file mode 100644 index 000000000..cfd3c8d87 --- /dev/null +++ b/desktop/src/features/agents/lib/canViewAgentActivity.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveCanViewAgentActivity } from "./canViewAgentActivity.ts"; + +test("resolveCanViewAgentActivity returns true when relay confirms ownership", () => { + const result = resolveCanViewAgentActivity({ + relayOwnership: { + agentPubkey: "aa".repeat(32), + ownerPubkey: "bb".repeat(32), + isOwner: true, + }, + isManagedAgent: false, + isOwnershipLoading: false, + isManagedLoading: false, + }); + + assert.equal(result.canView, true); + assert.equal(result.isLoading, false); +}); + +test("resolveCanViewAgentActivity returns false when relay denies ownership", () => { + const result = resolveCanViewAgentActivity({ + relayOwnership: { + agentPubkey: "aa".repeat(32), + ownerPubkey: "bb".repeat(32), + isOwner: false, + }, + isManagedAgent: true, + isOwnershipLoading: false, + isManagedLoading: false, + }); + + assert.equal(result.canView, false); + assert.equal(result.isLoading, false); +}); + +test("resolveCanViewAgentActivity optimistically allows locally managed agents while loading", () => { + const result = resolveCanViewAgentActivity({ + relayOwnership: undefined, + isManagedAgent: true, + isOwnershipLoading: true, + isManagedLoading: false, + }); + + assert.equal(result.canView, true); + assert.equal(result.isLoading, true); +}); + +test("resolveCanViewAgentActivity stays closed for non-managed agents while loading", () => { + const result = resolveCanViewAgentActivity({ + relayOwnership: undefined, + isManagedAgent: false, + isOwnershipLoading: true, + isManagedLoading: false, + }); + + assert.equal(result.canView, false); + assert.equal(result.isLoading, true); +}); diff --git a/desktop/src/features/agents/lib/canViewAgentActivity.ts b/desktop/src/features/agents/lib/canViewAgentActivity.ts new file mode 100644 index 000000000..436a68169 --- /dev/null +++ b/desktop/src/features/agents/lib/canViewAgentActivity.ts @@ -0,0 +1,43 @@ +import type { AgentOwnershipStatus } from "@/shared/api/tauriAgentOwnership"; + +export type CanViewAgentActivityInput = { + relayOwnership: AgentOwnershipStatus | undefined; + isManagedAgent: boolean | undefined; + isOwnershipLoading: boolean; + isManagedLoading: boolean; +}; + +export type CanViewAgentActivityResult = { + canView: boolean; + isLoading: boolean; +}; + +/** + * Unified predicate for Show Activity / Activity log ingresses. + * + * Final permission comes from relay `is_agent_owner`. While the relay lookup + * is in flight, locally managed agents may show activity optimistically. + */ +export function resolveCanViewAgentActivity({ + relayOwnership, + isManagedAgent, + isOwnershipLoading, + isManagedLoading, +}: CanViewAgentActivityInput): CanViewAgentActivityResult { + if (relayOwnership?.isOwner === true) { + return { canView: true, isLoading: false }; + } + + if (relayOwnership?.isOwner === false) { + return { canView: false, isLoading: false }; + } + + const isLoading = + isOwnershipLoading || (isManagedAgent === undefined && isManagedLoading); + + if (isManagedAgent === true && isOwnershipLoading) { + return { canView: true, isLoading: true }; + } + + return { canView: false, isLoading }; +} diff --git a/desktop/src/features/channels/lib/agentSessionCandidates.ts b/desktop/src/features/channels/lib/agentSessionCandidates.ts new file mode 100644 index 000000000..9606eb706 --- /dev/null +++ b/desktop/src/features/channels/lib/agentSessionCandidates.ts @@ -0,0 +1,162 @@ +import type { + Channel, + ChannelMember, + ManagedAgent, + RelayAgent, +} from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export type ChannelAgentSessionAgent = Pick< + ManagedAgent, + "pubkey" | "name" | "status" +> & { + agentSource: "managed" | "member-bot" | "relay"; + canInterruptTurn: boolean; + channelIds?: string[]; + channels?: string[]; +}; + +function relayStatusToManagedStatus( + status: RelayAgent["status"], +): ManagedAgent["status"] { + return status === "offline" ? "stopped" : "deployed"; +} + +export function buildChannelAgentSessionCandidates({ + channelMembers, + managedAgents, + relayAgents, +}: { + channelMembers?: ChannelMember[]; + managedAgents: ManagedAgent[]; + relayAgents: RelayAgent[]; +}): ChannelAgentSessionAgent[] { + const byPubkey = new Map(); + + for (const agent of relayAgents) { + byPubkey.set(normalizePubkey(agent.pubkey), { + pubkey: agent.pubkey, + name: agent.name, + status: relayStatusToManagedStatus(agent.status), + agentSource: "relay", + canInterruptTurn: false, + channelIds: agent.channelIds, + channels: agent.channels, + }); + } + + for (const agent of managedAgents) { + const key = normalizePubkey(agent.pubkey); + const existing = byPubkey.get(key); + byPubkey.set(key, { + pubkey: agent.pubkey, + name: agent.name, + status: agent.status, + agentSource: "managed", + canInterruptTurn: true, + channelIds: existing?.channelIds, + channels: existing?.channels, + }); + } + + for (const member of channelMembers ?? []) { + const key = normalizePubkey(member.pubkey); + if (member.role !== "bot" || byPubkey.has(key)) { + continue; + } + + byPubkey.set(key, { + pubkey: member.pubkey, + name: member.displayName ?? member.pubkey.slice(0, 8), + status: "deployed", + agentSource: "member-bot", + canInterruptTurn: false, + }); + } + + return [...byPubkey.values()]; +} + +export function getChannelAgentSessionAgents({ + activeChannel, + activeChannelId, + agents, + channelMembers, +}: { + activeChannel: Channel | null; + activeChannelId: string | null; + agents: ChannelAgentSessionAgent[]; + channelMembers?: ChannelMember[]; +}): ChannelAgentSessionAgent[] { + if (!activeChannelId || !activeChannel) { + return []; + } + + const memberPubkeys = channelMembers + ? new Set(channelMembers.map((member) => normalizePubkey(member.pubkey))) + : null; + const botMemberPubkeys = channelMembers + ? new Set( + channelMembers + .filter((member) => member.role === "bot") + .map((member) => normalizePubkey(member.pubkey)), + ) + : null; + + return agents.filter((agent) => { + const normalizedPubkey = normalizePubkey(agent.pubkey); + const channelIds = agent.channelIds ?? []; + const channels = agent.channels ?? []; + const hasDeclaredChannelScope = + channelIds.length > 0 || channels.length > 0; + const matchesDeclaredChannel = + channelIds.includes(activeChannelId) || + channels.includes(activeChannel.name); + + if (agent.agentSource === "member-bot") { + return botMemberPubkeys?.has(normalizedPubkey) ?? matchesDeclaredChannel; + } + + if (agent.agentSource === "managed") { + return memberPubkeys?.has(normalizedPubkey) ?? matchesDeclaredChannel; + } + + if (matchesDeclaredChannel) { + return true; + } + + return ( + !hasDeclaredChannelScope && Boolean(memberPubkeys?.has(normalizedPubkey)) + ); + }); +} + +export function resolveOpenAgentSessionAgent({ + allAgentCandidates, + channelAgentSessionAgents, + openAgentSessionPubkey, +}: { + allAgentCandidates: ChannelAgentSessionAgent[]; + channelAgentSessionAgents: ChannelAgentSessionAgent[]; + openAgentSessionPubkey: string | null; +}): ChannelAgentSessionAgent | null { + if (!openAgentSessionPubkey) { + return null; + } + + const normalized = normalizePubkey(openAgentSessionPubkey); + return ( + channelAgentSessionAgents.find( + (agent) => normalizePubkey(agent.pubkey) === normalized, + ) ?? + allAgentCandidates.find( + (agent) => normalizePubkey(agent.pubkey) === normalized, + ) ?? { + pubkey: openAgentSessionPubkey, + name: openAgentSessionPubkey.slice(0, 8), + status: "deployed", + agentSource: "relay", + canInterruptTurn: false, + } + ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index a28d39617..3542a3d94 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -124,6 +124,7 @@ type ChannelPaneProps = { personaLookup?: Map; profiles?: UserProfileLookup; openThreadHeadId: string | null; + openAgentSessionAgent: ChannelAgentSessionAgent | null; openAgentSessionPubkey: string | null; profilePanelPubkey?: string | null; threadHeadMessage: TimelineMessage | null; @@ -246,6 +247,7 @@ export const ChannelPane = React.memo(function ChannelPane({ personaLookup, profiles, openThreadHeadId, + openAgentSessionAgent, openAgentSessionPubkey, profilePanelPubkey, targetMessageId, @@ -606,16 +608,7 @@ export const ChannelPane = React.memo(function ChannelPane({ const isOverlay = useIsThreadPanelOverlay(); const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay; - - const selectedAgent = React.useMemo( - () => - openAgentSessionPubkey - ? (agentSessionAgents.find( - (agent) => agent.pubkey === openAgentSessionPubkey, - ) ?? null) - : null, - [agentSessionAgents, openAgentSessionPubkey], - ); + const selectedAgent = openAgentSessionAgent; return (
{!isSinglePanelView ? ( diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index beb57c630..7f8582b0b 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -61,7 +61,10 @@ import { mergeAgentNamesIntoProfiles, useChannelActivityTyping, } from "./useChannelActivityTyping"; -import { useChannelAgentSessions } from "./useChannelAgentSessions"; +import { + buildChannelAgentSessionCandidates, + useChannelAgentSessions, +} from "./useChannelAgentSessions"; import { useChannelProfilePanel } from "./useChannelProfilePanel"; import { useChannelRouteTarget } from "./useChannelRouteTarget"; import type { ChannelScreenProps } from "./ChannelScreen.types"; @@ -222,6 +225,15 @@ export function ChannelScreen({ } return pubkeys; }, [channelMembers, managedAgents, relayAgents]); + const allAgentSessionCandidates = React.useMemo( + () => + buildChannelAgentSessionCandidates({ + channelMembers, + managedAgents, + relayAgents, + }), + [channelMembers, managedAgents, relayAgents], + ); const { botTypingEntries, channelAgentSessionAgents: activeChannelAgentSessionAgents, @@ -414,14 +426,15 @@ export function ChannelScreen({ channelAgentSessionAgents, closeAgentSession: handleCloseAgentSession, openAgentSession: handleOpenAgentSession, + openAgentSessionAgent, openAgentSessionPubkey, openThreadAndCloseAgentSession: handleOpenThreadAndCloseAgentSession, } = useChannelAgentSessions({ activeChannel, activeChannelId, + agentCandidates: allAgentSessionCandidates, channelMembers, handleOpenThread, - managedAgents: activeChannelAgentSessionAgents, setExpandedThreadReplyIds, setOpenThreadHeadId, setProfilePanelPubkey, @@ -653,6 +666,7 @@ export function ChannelScreen({ } onThreadPanelResizeStart={handleThreadPanelResizeStart} onToggleReaction={effectiveToggleReaction} + openAgentSessionAgent={openAgentSessionAgent} openAgentSessionPubkey={openAgentSessionPubkey} openThreadHeadId={openThreadHeadId} profilePanelPubkey={profilePanelPubkey} diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 3ba6310d1..307350adc 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -10,6 +10,7 @@ import { Trash2, } from "lucide-react"; +import { useCanViewAgentActivity } from "@/features/agents/hooks/useCanViewAgentActivity"; import { getManagedAgentPrimaryActionLabel, isManagedAgentActive, @@ -110,13 +111,13 @@ export function MembersSidebarMemberCard({ }: MembersSidebarMemberCardProps) { const roleLabel = formatRoleLabel(member, memberIsBot); const disabled = isActionPending || isArchived; - const canViewActivity = - memberIsBot && - managedAgent?.backend.type === "local" && - Boolean(onViewActivity); + const { canView: canViewActivity } = useCanViewAgentActivity(member.pubkey, { + enabled: Boolean(onViewActivity), + }); + const canShowActivity = canViewActivity && Boolean(onViewActivity); const hasActions = memberIsBot - ? Boolean(managedAgent) || canRemoveMember || canViewActivity - : canRemoveMember || canChangeRole; + ? Boolean(managedAgent) || canRemoveMember || canShowActivity + : canRemoveMember || canChangeRole || canShowActivity; const memberIdentity = (
@@ -207,7 +208,7 @@ export function MembersSidebarMemberCard({ & { - agentSource: "managed" | "member-bot" | "relay"; - canInterruptTurn: boolean; - channelIds?: string[]; - channels?: string[]; -}; +import { + type ChannelAgentSessionAgent, + getChannelAgentSessionAgents, + resolveOpenAgentSessionAgent, +} from "../lib/agentSessionCandidates"; + +export type { ChannelAgentSessionAgent } from "../lib/agentSessionCandidates"; +export { + buildChannelAgentSessionCandidates, + getChannelAgentSessionAgents, + resolveOpenAgentSessionAgent, +} from "../lib/agentSessionCandidates"; type UseChannelAgentSessionsOptions = { activeChannel: Channel | null; activeChannelId: string | null; + agentCandidates: ChannelAgentSessionAgent[]; channelMembers?: ChannelMember[]; handleOpenThread: (message: TimelineMessage) => void; - managedAgents: ChannelAgentSessionAgent[]; setExpandedThreadReplyIds: (value: Set) => void; setOpenThreadHeadId: (value: string | null) => void; setProfilePanelPubkey: (value: string | null) => void; @@ -32,127 +31,12 @@ type UseChannelAgentSessionsOptions = { setThreadScrollTargetId: (value: string | null) => void; }; -function relayStatusToManagedStatus( - status: RelayAgent["status"], -): ManagedAgent["status"] { - return status === "offline" ? "stopped" : "deployed"; -} - -export function buildChannelAgentSessionCandidates({ - channelMembers, - managedAgents, - relayAgents, -}: { - channelMembers?: ChannelMember[]; - managedAgents: ManagedAgent[]; - relayAgents: RelayAgent[]; -}): ChannelAgentSessionAgent[] { - const byPubkey = new Map(); - - for (const agent of relayAgents) { - byPubkey.set(normalizePubkey(agent.pubkey), { - pubkey: agent.pubkey, - name: agent.name, - status: relayStatusToManagedStatus(agent.status), - agentSource: "relay", - canInterruptTurn: false, - channelIds: agent.channelIds, - channels: agent.channels, - }); - } - - for (const agent of managedAgents) { - const key = normalizePubkey(agent.pubkey); - const existing = byPubkey.get(key); - byPubkey.set(key, { - pubkey: agent.pubkey, - name: agent.name, - status: agent.status, - agentSource: "managed", - canInterruptTurn: true, - channelIds: existing?.channelIds, - channels: existing?.channels, - }); - } - - for (const member of channelMembers ?? []) { - const key = normalizePubkey(member.pubkey); - if (member.role !== "bot" || byPubkey.has(key)) { - continue; - } - - byPubkey.set(key, { - pubkey: member.pubkey, - name: member.displayName ?? member.pubkey.slice(0, 8), - status: "deployed", - agentSource: "member-bot", - canInterruptTurn: false, - }); - } - - return [...byPubkey.values()]; -} - -export function getChannelAgentSessionAgents({ - activeChannel, - activeChannelId, - agents, - channelMembers, -}: { - activeChannel: Channel | null; - activeChannelId: string | null; - agents: ChannelAgentSessionAgent[]; - channelMembers?: ChannelMember[]; -}): ChannelAgentSessionAgent[] { - if (!activeChannelId || !activeChannel) { - return []; - } - - const memberPubkeys = channelMembers - ? new Set(channelMembers.map((member) => normalizePubkey(member.pubkey))) - : null; - const botMemberPubkeys = channelMembers - ? new Set( - channelMembers - .filter((member) => member.role === "bot") - .map((member) => normalizePubkey(member.pubkey)), - ) - : null; - - return agents.filter((agent) => { - const normalizedPubkey = normalizePubkey(agent.pubkey); - const channelIds = agent.channelIds ?? []; - const channels = agent.channels ?? []; - const hasDeclaredChannelScope = - channelIds.length > 0 || channels.length > 0; - const matchesDeclaredChannel = - channelIds.includes(activeChannelId) || - channels.includes(activeChannel.name); - - if (agent.agentSource === "member-bot") { - return botMemberPubkeys?.has(normalizedPubkey) ?? matchesDeclaredChannel; - } - - if (agent.agentSource === "managed") { - return memberPubkeys?.has(normalizedPubkey) ?? matchesDeclaredChannel; - } - - if (matchesDeclaredChannel) { - return true; - } - - return ( - !hasDeclaredChannelScope && Boolean(memberPubkeys?.has(normalizedPubkey)) - ); - }); -} - export function useChannelAgentSessions({ activeChannel, activeChannelId, + agentCandidates, channelMembers, handleOpenThread, - managedAgents, setExpandedThreadReplyIds, setOpenThreadHeadId, setProfilePanelPubkey, @@ -168,10 +52,22 @@ export function useChannelAgentSessions({ getChannelAgentSessionAgents({ activeChannel, activeChannelId, - agents: managedAgents, + agents: agentCandidates, channelMembers, }), - [activeChannel, activeChannelId, channelMembers, managedAgents], + [activeChannel, activeChannelId, agentCandidates, channelMembers], + ); + + const ownershipQuery = useAgentOwnershipQuery(openAgentSessionPubkey); + + const openAgentSessionAgent = React.useMemo( + () => + resolveOpenAgentSessionAgent({ + allAgentCandidates: agentCandidates, + channelAgentSessionAgents, + openAgentSessionPubkey, + }), + [agentCandidates, channelAgentSessionAgents, openAgentSessionPubkey], ); const closeAgentSession = React.useCallback(() => { @@ -210,22 +106,38 @@ export function useChannelAgentSessions({ ); React.useEffect(() => { - if ( - openAgentSessionPubkey && - !channelAgentSessionAgents.some( - (agent) => - normalizePubkey(agent.pubkey) === - normalizePubkey(openAgentSessionPubkey), - ) - ) { + if (!openAgentSessionPubkey) { + return; + } + + const inChannelList = channelAgentSessionAgents.some( + (agent) => + normalizePubkey(agent.pubkey) === + normalizePubkey(openAgentSessionPubkey), + ); + if (inChannelList) { + return; + } + + if (ownershipQuery.isLoading || ownershipQuery.data === undefined) { + return; + } + + if (!ownershipQuery.data.isOwner) { setOpenAgentSessionPubkey(null); } - }, [channelAgentSessionAgents, openAgentSessionPubkey]); + }, [ + channelAgentSessionAgents, + openAgentSessionPubkey, + ownershipQuery.data, + ownershipQuery.isLoading, + ]); return { channelAgentSessionAgents, closeAgentSession, openAgentSession, + openAgentSessionAgent, openAgentSessionPubkey, openThreadAndCloseAgentSession, selectAgentSession, diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index f71a5f575..b083516de 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -6,6 +6,7 @@ import { useAgentMemoryQuery, useIsManagedAgent, } from "@/features/agent-memory/hooks"; +import { useCanViewAgentActivity } from "@/features/agents/hooks/useCanViewAgentActivity"; import { MemoryRefreshButton } from "@/features/agent-memory/ui/MemorySection"; import { useRelayAgentsQuery, @@ -178,6 +179,9 @@ export function UserProfilePanel({ ); const isBot = Boolean(relayAgent || managedAgent); const isOwner = useIsManagedAgent(isBot ? pubkey : null); + const { canView: canViewActivity } = useCanViewAgentActivity(pubkey, { + enabled: Boolean(onOpenAgentSession), + }); // Populate the active-turns store for this agent so useActiveAgentTurns works // even if the Agents page hasn't been visited yet. @@ -196,7 +200,6 @@ export function UserProfilePanel({ }); const isSelf = currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase(); - const canViewActivity = isOwner === true && Boolean(onOpenAgentSession); const isFollowing = !isSelf && (contactListQuery.data?.contacts.some( @@ -317,7 +320,7 @@ export function UserProfilePanel({ {view === "summary" ? ( a.pubkey === pubkey); const managedAgent = managedAgentsQuery.data?.find( (a) => a.pubkey === pubkey, ); - const canViewActivity = role === "bot" && Boolean(onOpenAgentSession); const profile = profileQuery.data; const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()]; const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()]; @@ -274,7 +278,7 @@ export function UserProfilePopover({

) : null} - {canViewActivity ? ( + {canViewActivity && onOpenAgentSession ? (
) : null} - {showMemoriesIngress || showChannelsIngress || canViewActivity ? ( + {showMemoriesIngress || showChannelsIngress || canShowActivity ? (
{showMemoriesIngress ? ( ) : null} - {canViewActivity ? ( + {canShowActivity ? ( 0; const channelsQuery = useChannelsQuery(); const channelIdToName = React.useMemo(() => { const map: Record = {}; @@ -278,7 +279,7 @@ export function UserProfilePopover({

) : null} - {canViewActivity && onOpenAgentSession ? ( + {canShowActivity && onOpenAgentSession ? (