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 bc04ae4dd..3e2d7afb8 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 a8bce1081..f2f9837dc 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; @@ -30,6 +31,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 266733abe..0a5f5e4d7 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..33f0d2d48 --- /dev/null +++ b/desktop/src/features/agents/hooks/useCanViewAgentActivity.ts @@ -0,0 +1,44 @@ +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, + isOwnershipError: ownershipQuery.isError, + 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..5fad347de --- /dev/null +++ b/desktop/src/features/agents/lib/canViewAgentActivity.test.mjs @@ -0,0 +1,90 @@ +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, + isOwnershipError: 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, + isOwnershipError: 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, + isOwnershipError: false, + 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, + isOwnershipError: false, + isManagedLoading: false, + }); + + assert.equal(result.canView, false); + assert.equal(result.isLoading, true); +}); + +test("resolveCanViewAgentActivity keeps locally managed agents visible when ownership lookup errors", () => { + const result = resolveCanViewAgentActivity({ + relayOwnership: undefined, + isManagedAgent: true, + isOwnershipLoading: false, + isOwnershipError: true, + isManagedLoading: false, + }); + + assert.equal(result.canView, true); + assert.equal(result.isLoading, false); +}); + +test("resolveCanViewAgentActivity stays closed for non-managed agents when ownership lookup errors", () => { + const result = resolveCanViewAgentActivity({ + relayOwnership: undefined, + isManagedAgent: false, + isOwnershipLoading: false, + isOwnershipError: true, + isManagedLoading: false, + }); + + assert.equal(result.canView, false); + assert.equal(result.isLoading, false); +}); diff --git a/desktop/src/features/agents/lib/canViewAgentActivity.ts b/desktop/src/features/agents/lib/canViewAgentActivity.ts new file mode 100644 index 000000000..fea48ecfb --- /dev/null +++ b/desktop/src/features/agents/lib/canViewAgentActivity.ts @@ -0,0 +1,45 @@ +import type { AgentOwnershipStatus } from "@/shared/api/tauriAgentOwnership"; + +export type CanViewAgentActivityInput = { + relayOwnership: AgentOwnershipStatus | undefined; + isManagedAgent: boolean | undefined; + isOwnershipLoading: boolean; + isOwnershipError: 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, + isOwnershipError, + 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 || isOwnershipError)) { + return { canView: true, isLoading }; + } + + return { canView: false, isLoading }; +} diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index ee8483dc8..cc8905d0a 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -41,6 +41,7 @@ const snapshotByAgent = new Map(); // Normalized pubkeys of agents we are actively managing. Only events whose // "agent" tag matches an entry here will be decrypted (defense-in-depth). const knownAgentPubkeys = new Set(); +const knownAgentPubkeysByBridge = new Map>(); let connectionState: ConnectionState = "idle"; let errorMessage: string | null = null; @@ -275,6 +276,7 @@ export function getAgentTranscript( export function useManagedAgentObserverBridge( agents: readonly Pick[], ) { + const bridgeIdRef = React.useRef(Symbol("managed-agent-observer")); const hasActiveAgent = React.useMemo( () => agents.some( @@ -285,10 +287,27 @@ export function useManagedAgentObserverBridge( // Keep the trusted-pubkey set in sync with the current managed agent list. React.useEffect(() => { + const bridgeId = bridgeIdRef.current; + knownAgentPubkeysByBridge.set( + bridgeId, + new Set(agents.map((agent) => normalizePubkey(agent.pubkey))), + ); knownAgentPubkeys.clear(); - for (const agent of agents) { - knownAgentPubkeys.add(normalizePubkey(agent.pubkey)); + for (const pubkeys of knownAgentPubkeysByBridge.values()) { + for (const pubkey of pubkeys) { + knownAgentPubkeys.add(pubkey); + } } + + return () => { + knownAgentPubkeysByBridge.delete(bridgeId); + knownAgentPubkeys.clear(); + for (const pubkeys of knownAgentPubkeysByBridge.values()) { + for (const pubkey of pubkeys) { + knownAgentPubkeys.add(pubkey); + } + } + }; }, [agents]); React.useEffect(() => { @@ -309,6 +328,7 @@ export function resetAgentObserverStore() { transcriptByAgent.clear(); snapshotByAgent.clear(); knownAgentPubkeys.clear(); + knownAgentPubkeysByBridge.clear(); connectionState = "idle"; errorMessage = null; notifyListeners(); diff --git a/desktop/src/features/channels/lib/agentSessionCandidates.test.mjs b/desktop/src/features/channels/lib/agentSessionCandidates.test.mjs new file mode 100644 index 000000000..d02725117 --- /dev/null +++ b/desktop/src/features/channels/lib/agentSessionCandidates.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildChannelAgentSessionCandidates, + getChannelAgentSessionAgents, +} from "./agentSessionCandidates.ts"; + +const CHANNEL = { + id: "channel-1", + name: "general", + channelType: "stream", + visibility: "private", + description: "", + topic: null, + purpose: null, + memberCount: 0, + memberPubkeys: [], + lastMessageAt: null, + archivedAt: null, + participants: [], + participantPubkeys: [], + isMember: true, + ttlSeconds: null, + ttlDeadline: null, +}; + +function member(overrides) { + return { + pubkey: "aa".repeat(32), + role: "member", + isAgent: false, + joinedAt: "2024-01-01T00:00:00Z", + displayName: "Agent", + ...overrides, + }; +} + +test("buildChannelAgentSessionCandidates includes members marked isAgent", () => { + const candidates = buildChannelAgentSessionCandidates({ + channelMembers: [ + member({ + pubkey: "11".repeat(32), + role: "member", + isAgent: true, + displayName: "Ned", + }), + ], + managedAgents: [], + relayAgents: [], + }); + + assert.deepEqual( + candidates.map((agent) => ({ + name: agent.name, + pubkey: agent.pubkey, + source: agent.agentSource, + })), + [{ name: "Ned", pubkey: "11".repeat(32), source: "member-bot" }], + ); +}); + +test("getChannelAgentSessionAgents keeps isAgent member candidates in channel scope", () => { + const channelMembers = [ + member({ + pubkey: "22".repeat(32), + role: "member", + isAgent: true, + displayName: "Ned", + }), + ]; + const candidates = buildChannelAgentSessionCandidates({ + channelMembers, + managedAgents: [], + relayAgents: [], + }); + + const scoped = getChannelAgentSessionAgents({ + activeChannel: CHANNEL, + activeChannelId: CHANNEL.id, + agents: candidates, + channelMembers, + }); + + assert.equal(scoped.length, 1); + assert.equal(scoped[0].pubkey, "22".repeat(32)); +}); diff --git a/desktop/src/features/channels/lib/agentSessionCandidates.ts b/desktop/src/features/channels/lib/agentSessionCandidates.ts new file mode 100644 index 000000000..a77db42da --- /dev/null +++ b/desktop/src/features/channels/lib/agentSessionCandidates.ts @@ -0,0 +1,166 @@ +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"; +} + +function isAgentChannelMember(member: ChannelMember) { + return member.role === "bot" || member.isAgent; +} + +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 (!isAgentChannelMember(member) || 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(isAgentChannelMember) + .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 517fa0728..692174956 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -136,6 +136,7 @@ type ChannelPaneProps = { personaLookup?: Map; profiles?: UserProfileLookup; openThreadHeadId: string | null; + openAgentSessionAgent: ChannelAgentSessionAgent | null; openAgentSessionPubkey: string | null; onProfilePanelViewChange: ( view: ProfilePanelView, @@ -222,6 +223,7 @@ export const ChannelPane = React.memo(function ChannelPane({ personaLookup, profiles, openThreadHeadId, + openAgentSessionAgent, openAgentSessionPubkey, onProfilePanelViewChange, profilePanelPubkey, @@ -587,16 +589,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 094fc6567..365415333 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -58,7 +58,10 @@ import { mergeAgentNamesIntoProfiles, useChannelActivityTyping, } from "./useChannelActivityTyping"; -import { useChannelAgentSessions } from "./useChannelAgentSessions"; +import { + buildChannelAgentSessionCandidates, + useChannelAgentSessions, +} from "./useChannelAgentSessions"; import { useChannelPanelHistoryState } from "./useChannelPanelHistoryState"; import { useChannelProfilePanel } from "./useChannelProfilePanel"; import { useChannelRouteTarget } from "./useChannelRouteTarget"; @@ -272,6 +275,15 @@ export function ChannelScreen({ } return pubkeys; }, [channelMembers, managedAgents, relayAgents]); + const allAgentSessionCandidates = React.useMemo( + () => + buildChannelAgentSessionCandidates({ + channelMembers, + managedAgents, + relayAgents, + }), + [channelMembers, managedAgents, relayAgents], + ); const { botTypingEntries, channelAgentSessionAgents: activeChannelAgentSessionAgents, @@ -439,10 +451,12 @@ export function ChannelScreen({ channelAgentSessionAgents, closeAgentSession: handleCloseAgentSession, openAgentSession: handleOpenAgentSession, + openAgentSessionAgent, openThreadAndCloseAgentSession: handleOpenThreadAndCloseAgentSession, } = useChannelAgentSessions({ activeChannel, activeChannelId, + agentCandidates: allAgentSessionCandidates, // The agent list comes from three queries; treat it as loaded only once // none of them are in their initial fetch, so a channel with genuinely // zero agents can still auto-close a stale agentSession param. A disabled @@ -453,7 +467,6 @@ export function ChannelScreen({ !relayAgentsQuery.isLoading, channelMembers, handleOpenThread, - managedAgents: activeChannelAgentSessionAgents, openAgentSessionPubkey, setExpandedThreadReplyIds, setOpenAgentSessionPubkey, @@ -694,6 +707,7 @@ export function ChannelScreen({ } onThreadPanelResizeStart={handleThreadPanelResizeStart} onToggleReaction={effectiveToggleReaction} + openAgentSessionAgent={openAgentSessionAgent} openAgentSessionPubkey={openAgentSessionPubkey} openThreadHeadId={openThreadHeadId} onProfilePanelViewChange={setProfilePanelView} diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 9a0f16ce6..5e6c4e887 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, @@ -114,13 +115,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 = (
@@ -214,7 +215,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[]; agentsLoaded: boolean; channelMembers?: ChannelMember[]; handleOpenThread: (message: TimelineMessage) => void; - managedAgents: ChannelAgentSessionAgent[]; openAgentSessionPubkey: string | null; setExpandedThreadReplyIds: (value: Set) => void; setOpenAgentSessionPubkey: PanelValueSetter; @@ -36,128 +35,13 @@ 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, agentsLoaded, channelMembers, handleOpenThread, - managedAgents, openAgentSessionPubkey, setExpandedThreadReplyIds, setOpenAgentSessionPubkey, @@ -171,10 +55,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(() => { @@ -217,25 +113,41 @@ export function useChannelAgentSessions({ ); React.useEffect(() => { - // An empty agent list can mean the queries behind it are still loading - // (e.g. a reload restoring the agentSession URL param), so wait until the - // agent queries have settled. Once loaded, a channel that legitimately has - // zero agents will still auto-close a stale param. - if ( - openAgentSessionPubkey && - agentsLoaded && - !channelAgentSessionAgents.some( - (agent) => - normalizePubkey(agent.pubkey) === - normalizePubkey(openAgentSessionPubkey), - ) - ) { + if (!openAgentSessionPubkey) { + return; + } + + const inChannelList = channelAgentSessionAgents.some( + (agent) => + normalizePubkey(agent.pubkey) === + normalizePubkey(openAgentSessionPubkey), + ); + if (inChannelList) { + return; + } + + // Wait until the agent/channel/member queries have settled before treating + // an out-of-channel open session as stale — a reload restoring the + // agentSession URL param shows an empty list mid-fetch. + if (!agentsLoaded) { + return; + } + + if (ownershipQuery.isLoading || ownershipQuery.data === undefined) { + return; + } + + // Owners keep the panel open even when the agent is out of the channel + // list; non-owners get the stale param auto-closed. + if (!ownershipQuery.data.isOwner) { setOpenAgentSessionPubkey(null, { replace: true }); } }, [ agentsLoaded, channelAgentSessionAgents, openAgentSessionPubkey, + ownershipQuery.data, + ownershipQuery.isLoading, setOpenAgentSessionPubkey, ]); @@ -243,6 +155,7 @@ export function useChannelAgentSessions({ 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 930e73a94..a22138deb 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, @@ -184,6 +185,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. @@ -202,7 +206,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" ? ( 0; const metadataFields = [ ...buildPublicFields({ @@ -186,7 +187,7 @@ export function ProfileSummaryView({
) : null} - {showMemoriesIngress || showChannelsIngress || canViewActivity ? ( + {showMemoriesIngress || showChannelsIngress || canShowActivity ? (
{showMemoriesIngress ? ( ) : null} - {canViewActivity ? ( + {canShowActivity ? ( 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()]; - const activeTurns = useActiveAgentTurns(role === "bot" ? pubkey : null); + const activeTurns = useActiveAgentTurns(pubkey); + const canShowActivity = canViewActivity || activeTurns.length > 0; const channelsQuery = useChannelsQuery(); const channelIdToName = React.useMemo(() => { const map: Record = {}; @@ -280,7 +285,7 @@ export function UserProfilePopover({

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