notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 4ddff4beac18aa10f3be78d1372ac77596511120
parent 1fcf756160dd5cdee02ba52f0c9146209b4b30a0
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 17 Feb 2026 10:30:49 -0800

add SessionSource (Local/Remote) for lite client remote mode

Remote sessions discovered via relay have no local Claude process.
Permission responses publish as nostr events instead of oneshot
channels. Session status derived from kind-31988 state events.

- Add SessionSource enum to ChatSession (Local vs Remote)
- Remote-aware derive_status(), has_pending_permissions(),
  first_pending_permission(), handle_permission_response()
- PermissionResponseResult propagated through UI via new variants
  on KeyActionResult, SendActionResult, UiActionResult
- Load permission_request/response events in session_loader
- Guard local-only ops (state publish, git status, remote perm poll)
- Remote user messages publish to relay, skip local backend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/agent_status.rs | 12++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/notedeck_dave/src/session.rs | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/notedeck_dave/src/session_loader.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/notedeck_dave/src/ui/dave.rs | 8++++++++
Mcrates/notedeck_dave/src/ui/mod.rs | 125++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/notedeck_dave/src/update.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
7 files changed, 514 insertions(+), 51 deletions(-)

diff --git a/crates/notedeck_dave/src/agent_status.rs b/crates/notedeck_dave/src/agent_status.rs @@ -47,4 +47,16 @@ impl AgentStatus { AgentStatus::Done => "done", } } + + /// Parse a status string from a nostr event (kind-31988 content). + pub fn from_status_str(s: &str) -> Option<Self> { + match s { + "idle" => Some(AgentStatus::Idle), + "working" => Some(AgentStatus::Working), + "needs_input" => Some(AgentStatus::NeedsInput), + "error" => Some(AgentStatus::Error), + "done" => Some(AgentStatus::Done), + _ => None, + } + } } diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -23,6 +23,7 @@ mod ui; mod update; mod vec3; +use agent_status::AgentStatus; use backend::{AiBackend, BackendType, ClaudeBackend, OpenAiBackend}; use chrono::{Duration, Local}; use egui_wgpu::RenderState; @@ -121,6 +122,16 @@ pub struct Dave { /// Local ndb subscription for kind-31988 session state events. /// Fires when new session states are unwrapped from PNS events. session_state_sub: Option<nostrdb::Subscription>, + /// Permission responses queued for relay publishing (from remote sessions). + /// Built and published in the update loop where AppContext is available. + pending_perm_responses: Vec<PendingPermResponse>, +} + +/// A permission response queued for relay publishing. +struct PendingPermResponse { + perm_id: uuid::Uuid, + allowed: bool, + message: Option<String>, } /// Subscription waiting for ndb to index 1988 conversation events. @@ -290,6 +301,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr sessions_restored: false, pns_relay_sub: None, session_state_sub: None, + pending_perm_responses: Vec::new(), } } @@ -916,6 +928,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let Some(session) = self.session_manager.get_mut(session_id) else { continue; }; + // Only local sessions poll for remote responses + if session.is_remote() { + continue; + } let Some(agentic) = &mut session.agentic else { continue; }; @@ -1035,7 +1051,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr }; for session in self.session_manager.iter_mut() { - if !session.state_dirty { + if !session.state_dirty || session.is_remote() { continue; } @@ -1077,6 +1093,79 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + /// Build and queue permission response events from remote sessions. + /// Called in the update loop where AppContext is available. + fn publish_pending_perm_responses(&mut self, ctx: &AppContext<'_>) { + if self.pending_perm_responses.is_empty() { + return; + } + + let secret_key: Option<[u8; 32]> = ctx + .accounts + .get_selected_account() + .keypair() + .secret_key + .map(|sk| { + sk.as_secret_bytes() + .try_into() + .expect("secret key is 32 bytes") + }); + + let Some(sk) = secret_key else { + tracing::warn!("no secret key for publishing permission responses"); + self.pending_perm_responses.clear(); + return; + }; + + let pending = std::mem::take(&mut self.pending_perm_responses); + + // Get session info from the active session + let session = match self.session_manager.get_active() { + Some(s) => s, + None => return, + }; + let agentic = match &session.agentic { + Some(a) => a, + None => return, + }; + let session_id = match agentic.event_session_id() { + Some(id) => id.to_string(), + None => return, + }; + + for resp in pending { + let request_note_id = match agentic.perm_request_note_ids.get(&resp.perm_id) { + Some(id) => id, + None => { + tracing::warn!("no request note_id for perm_id {}", resp.perm_id); + continue; + } + }; + + match session_events::build_permission_response_event( + &resp.perm_id, + request_note_id, + resp.allowed, + resp.message.as_deref(), + &session_id, + &sk, + ) { + Ok(evt) => { + tracing::info!( + "queued remote permission response for {} ({})", + resp.perm_id, + if resp.allowed { "allow" } else { "deny" } + ); + let _ = ctx.ndb.process_event(&evt.note_json); + self.pending_relay_events.push(evt); + } + Err(e) => { + tracing::error!("failed to build permission response event: {}", e); + } + } + } + } + /// Restore sessions from kind-31988 state events in ndb. /// Called once on first `update()`. fn restore_sessions_from_ndb(&mut self, ctx: &mut AppContext<'_>) { @@ -1119,12 +1208,24 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr ); session.chat = loaded.messages; - if let (Some(root), Some(last)) = - (loaded.root_note_id, loaded.last_note_id) - { - if let Some(agentic) = &mut session.agentic { + // Determine if this is a remote session (cwd doesn't exist locally) + let cwd = std::path::PathBuf::from(&state.cwd); + if !cwd.exists() { + session.source = session::SessionSource::Remote; + } + + if let Some(agentic) = &mut session.agentic { + if let (Some(root), Some(last)) = + (loaded.root_note_id, loaded.last_note_id) + { agentic.live_threading.seed(root, last, loaded.event_count); } + // Load permission state from events + agentic.responded_perm_ids = loaded.responded_perm_ids; + agentic.perm_request_note_ids.extend(loaded.perm_request_note_ids); + // Set remote status from state event + agentic.remote_status = + AgentStatus::from_status_str(&state.status); } } } @@ -1215,12 +1316,24 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr session.chat = loaded.messages; } - if let (Some(root), Some(last)) = - (loaded.root_note_id, loaded.last_note_id) - { - if let Some(agentic) = &mut session.agentic { + // Determine if this is a remote session + let cwd_path = std::path::PathBuf::from(cwd_str); + if !cwd_path.exists() { + session.source = session::SessionSource::Remote; + } + + if let Some(agentic) = &mut session.agentic { + if let (Some(root), Some(last)) = + (loaded.root_note_id, loaded.last_note_id) + { agentic.live_threading.seed(root, last, loaded.event_count); } + // Load permission state + agentic.responded_perm_ids = loaded.responded_perm_ids; + agentic.perm_request_note_ids.extend(loaded.perm_request_note_ids); + // Set remote status + let status_str = json["status"].as_str().unwrap_or("idle"); + agentic.remote_status = AgentStatus::from_status_str(status_str); } } @@ -1302,6 +1415,17 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr KeyActionResult::SetAutoSteal(new_state) => { self.auto_steal_focus = new_state; } + KeyActionResult::PublishPermissionResponse { + perm_id, + allowed, + message, + } => { + self.pending_perm_responses.push(PendingPermResponse { + perm_id, + allowed, + message, + }); + } KeyActionResult::None => {} } } @@ -1312,6 +1436,17 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr SendActionResult::SendMessage => { self.handle_user_send(ctx, ui); } + SendActionResult::NeedsRelayPublish { + perm_id, + allowed, + message, + } => { + self.pending_perm_responses.push(PendingPermResponse { + perm_id, + allowed, + message, + }); + } SendActionResult::Handled => {} } } @@ -1336,6 +1471,18 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.handle_send_action(ctx, ui); None } + UiActionResult::PublishPermissionResponse { + perm_id, + allowed, + message, + } => { + self.pending_perm_responses.push(PendingPermResponse { + perm_id, + allowed, + message, + }); + None + } UiActionResult::Handled => None, } } @@ -1378,6 +1525,11 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr session.chat.push(Message::User(user_text)); session.update_title_from_last_message(); + + // Remote sessions: publish user message to relay but don't send to local backend + if session.is_remote() { + return; + } } self.send_user_message(app_ctx, ui.ctx()); } @@ -1526,12 +1678,13 @@ impl notedeck::App for Dave { tracing::info!("loaded {} messages into chat UI", loaded.messages.len()); session.chat = loaded.messages; - if let (Some(root), Some(last)) = - (loaded.root_note_id, loaded.last_note_id) - { - if let Some(agentic) = &mut session.agentic { + if let Some(agentic) = &mut session.agentic { + if let (Some(root), Some(last)) = + (loaded.root_note_id, loaded.last_note_id) + { agentic.live_threading.seed(root, last, loaded.event_count); } + agentic.perm_request_note_ids.extend(loaded.perm_request_note_ids); } } } else { @@ -1599,12 +1752,13 @@ impl notedeck::App for Dave { // Seed live threading from archive events so new events // thread as replies to the existing conversation. - if let (Some(root), Some(last)) = - (loaded.root_note_id, loaded.last_note_id) - { - if let Some(agentic) = &mut session.agentic { + if let Some(agentic) = &mut session.agentic { + if let (Some(root), Some(last)) = + (loaded.root_note_id, loaded.last_note_id) + { agentic.live_threading.seed(root, last, loaded.event_count); } + agentic.perm_request_note_ids.extend(loaded.perm_request_note_ids); } } self.pending_message_load = None; @@ -1636,6 +1790,9 @@ impl notedeck::App for Dave { // Process incoming AI responses for all sessions let (sessions_needing_send, events_to_publish) = self.process_events(ctx); + // Build permission response events from remote sessions + self.publish_pending_perm_responses(ctx); + // PNS-wrap and publish events to relays let pending = std::mem::take(&mut self.pending_relay_events); let all_events = events_to_publish.iter().chain(pending.iter()); @@ -1664,8 +1821,11 @@ impl notedeck::App for Dave { // published by phone/remote clients. First-response-wins with local UI. self.poll_remote_permission_responses(ctx.ndb); - // Poll git status for all agentic sessions + // Poll git status for local agentic sessions for session in self.session_manager.iter_mut() { + if session.is_remote() { + continue; + } if let Some(agentic) = &mut session.agentic { agentic.git_status.poll(); agentic.git_status.maybe_auto_refresh(); diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::mpsc::Receiver; @@ -16,6 +16,16 @@ use uuid::Uuid; pub type SessionId = u32; +/// Whether this session runs locally or is observed remotely via relays. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SessionSource { + /// Local Claude process running on this machine. + #[default] + Local, + /// Remote session observed via relay events (no local process). + Remote, +} + /// State for permission response with message #[derive(Default, Clone, Copy, PartialEq)] pub enum PermissionMessageState { @@ -64,6 +74,14 @@ pub struct AgenticSessionData { /// Subscription for remote permission response events (kind-1988, t=ai-permission). /// Set up once when the session's claude_session_id becomes known. pub perm_response_sub: Option<nostrdb::Subscription>, + /// Status as reported by the remote desktop's kind-31988 event. + /// Only meaningful when session source is Remote. + pub remote_status: Option<AgentStatus>, + /// Subscription for live kind-1988 conversation events from relays. + /// Used by remote sessions to receive new messages in real-time. + pub live_conversation_sub: Option<nostrdb::Subscription>, + /// Set of perm-id UUIDs that we (the remote/phone) have already responded to. + pub responded_perm_ids: HashSet<Uuid>, } impl AgenticSessionData { @@ -93,6 +111,9 @@ impl AgenticSessionData { live_threading: ThreadingState::new(), perm_request_note_ids: HashMap::new(), perm_response_sub: None, + remote_status: None, + live_conversation_sub: None, + responded_perm_ids: HashSet::new(), } } @@ -156,6 +177,8 @@ pub struct ChatSession { pub ai_mode: AiMode, /// Agentic-mode specific data (None in Chat mode) pub agentic: Option<AgenticSessionData>, + /// Whether this session is local (has a Claude process) or remote (relay-only). + pub source: SessionSource, } impl Drop for ChatSession { @@ -185,6 +208,7 @@ impl ChatSession { focus_requested: false, ai_mode, agentic, + source: SessionSource::Local, } } @@ -225,8 +249,29 @@ impl ChatSession { self.agentic.is_some() } + /// Check if this is a remote session (observed via relay, no local process) + pub fn is_remote(&self) -> bool { + self.source == SessionSource::Remote + } + /// Check if session has pending permission requests pub fn has_pending_permissions(&self) -> bool { + if self.is_remote() { + // Remote: check for unresponded PermissionRequest messages in chat + let responded = self + .agentic + .as_ref() + .map(|a| &a.responded_perm_ids); + return self.chat.iter().any(|msg| { + if let Message::PermissionRequest(req) = msg { + req.response.is_none() + && responded.map_or(true, |ids| !ids.contains(&req.id)) + } else { + false + } + }); + } + // Local: check oneshot senders self.agentic .as_ref() .is_some_and(|a| !a.pending_permissions.is_empty()) @@ -298,6 +343,19 @@ impl ChatSession { /// Derive status from the current session state fn derive_status(&self) -> AgentStatus { + // Remote sessions derive status from the kind-31988 state event, + // but override to NeedsInput if there are unresponded permission requests. + if self.is_remote() { + if self.has_pending_permissions() { + return AgentStatus::NeedsInput; + } + return self + .agentic + .as_ref() + .and_then(|a| a.remote_status) + .unwrap_or(AgentStatus::Idle); + } + // Check for pending permission requests (needs input) - agentic only if self.has_pending_permissions() { return AgentStatus::NeedsInput; diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs @@ -4,10 +4,11 @@ //! orders them by created_at, and converts them into `Message` variants //! for populating the chat UI. -use crate::messages::{AssistantMessage, ToolResult}; +use crate::messages::{AssistantMessage, PermissionRequest, PermissionResponseType, ToolResult}; use crate::session_events::{get_tag_value, is_conversation_role, AI_CONVERSATION_KIND}; use crate::Message; use nostrdb::{Filter, Ndb, Transaction}; +use std::collections::HashSet; /// Result of loading session messages, including threading info for live events. pub struct LoadedSession { @@ -18,6 +19,11 @@ pub struct LoadedSession { pub last_note_id: Option<[u8; 32]>, /// Total number of events found. pub event_count: u32, + /// Permission IDs that already have response events. + pub responded_perm_ids: HashSet<uuid::Uuid>, + /// Map of perm_id -> note_id for permission request events. + /// Used by remote sessions to link responses back to requests. + pub perm_request_note_ids: std::collections::HashMap<uuid::Uuid, [u8; 32]>, } /// Load conversation messages from ndb for a given session ID. @@ -40,6 +46,8 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> root_note_id: None, last_note_id: None, event_count: 0, + responded_perm_ids: HashSet::new(), + perm_request_note_ids: std::collections::HashMap::new(), } } }; @@ -67,6 +75,29 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> .map(|n| *n.id()); let last_note_id = notes.last().map(|n| *n.id()); + // First pass: collect responded perm IDs and request note IDs + let mut responded_perm_ids: HashSet<uuid::Uuid> = HashSet::new(); + let mut perm_request_note_ids: std::collections::HashMap<uuid::Uuid, [u8; 32]> = + std::collections::HashMap::new(); + + for note in &notes { + let role = get_tag_value(note, "role"); + if role == Some("permission_response") { + if let Some(perm_id_str) = get_tag_value(note, "perm-id") { + if let Ok(perm_id) = uuid::Uuid::parse_str(perm_id_str) { + responded_perm_ids.insert(perm_id); + } + } + } else if role == Some("permission_request") { + if let Some(perm_id_str) = get_tag_value(note, "perm-id") { + if let Ok(perm_id) = uuid::Uuid::parse_str(perm_id_str) { + perm_request_note_ids.insert(perm_id, *note.id()); + } + } + } + } + + // Second pass: build messages let mut messages = Vec::new(); for note in &notes { let content = note.content(); @@ -90,7 +121,39 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> let summary = truncate(content, 100); Some(Message::ToolResult(ToolResult { tool_name, summary })) } - // Skip progress, queue-operation, file-history-snapshot for UI + Some("permission_request") => { + if let Ok(content_json) = serde_json::from_str::<serde_json::Value>(content) { + let tool_name = content_json["tool_name"] + .as_str() + .unwrap_or("unknown") + .to_string(); + let tool_input = content_json + .get("tool_input") + .cloned() + .unwrap_or(serde_json::Value::Null); + let perm_id = get_tag_value(note, "perm-id") + .and_then(|s| uuid::Uuid::parse_str(s).ok()) + .unwrap_or_else(uuid::Uuid::new_v4); + + let response = if responded_perm_ids.contains(&perm_id) { + Some(PermissionResponseType::Allowed) + } else { + None + }; + + Some(Message::PermissionRequest(PermissionRequest { + id: perm_id, + tool_name, + tool_input, + response, + answer_summary: None, + cached_plan: None, + })) + } else { + None + } + } + // Skip permission_response, progress, queue-operation, etc. _ => None, }; @@ -104,6 +167,8 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> root_note_id, last_note_id, event_count, + responded_perm_ids, + perm_request_note_ids, } } diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -48,6 +48,8 @@ pub struct DaveUi<'a> { ai_mode: AiMode, /// Git status cache for current session (agentic only) git_status: Option<&'a mut GitStatusCache>, + /// Whether this is a remote session (no local Claude process) + is_remote: bool, } /// The response the app generates. The response contains an optional @@ -148,6 +150,7 @@ impl<'a> DaveUi<'a> { auto_steal_focus: false, ai_mode, git_status: None, + is_remote: false, } } @@ -208,6 +211,11 @@ impl<'a> DaveUi<'a> { self } + pub fn is_remote(mut self, is_remote: bool) -> Self { + self.is_remote = is_remote; + self + } + fn chat_margin(&self, ctx: &egui::Context) -> i8 { if self.compact || notedeck::ui::is_narrow(ctx) { 20 diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -216,6 +216,7 @@ pub fn scene_ui( let is_working = session.status() == AgentStatus::Working; let has_pending_permission = session.has_pending_permissions(); let plan_mode_active = session.is_plan_mode(); + let is_remote = session.is_remote(); let mut ui_builder = DaveUi::new( model_config.trial, @@ -229,7 +230,8 @@ pub fn scene_ui( .interrupt_pending(is_interrupt_pending) .has_pending_permission(has_pending_permission) .plan_mode_active(plan_mode_active) - .auto_steal_focus(auto_steal_focus); + .auto_steal_focus(auto_steal_focus) + .is_remote(is_remote); if let Some(agentic) = &mut session.agentic { ui_builder = ui_builder @@ -340,6 +342,7 @@ pub fn desktop_ui( let is_working = session.status() == AgentStatus::Working; let has_pending_permission = session.has_pending_permissions(); let plan_mode_active = session.is_plan_mode(); + let is_remote = session.is_remote(); let mut ui_builder = DaveUi::new( model_config.trial, @@ -352,7 +355,8 @@ pub fn desktop_ui( .interrupt_pending(is_interrupt_pending) .has_pending_permission(has_pending_permission) .plan_mode_active(plan_mode_active) - .auto_steal_focus(auto_steal_focus); + .auto_steal_focus(auto_steal_focus) + .is_remote(is_remote); if let Some(agentic) = &mut session.agentic { ui_builder = ui_builder @@ -400,6 +404,7 @@ pub fn narrow_ui( let is_working = session.status() == AgentStatus::Working; let has_pending_permission = session.has_pending_permissions(); let plan_mode_active = session.is_plan_mode(); + let is_remote = session.is_remote(); let mut ui_builder = DaveUi::new( model_config.trial, @@ -412,7 +417,8 @@ pub fn narrow_ui( .interrupt_pending(is_interrupt_pending) .has_pending_permission(has_pending_permission) .plan_mode_active(plan_mode_active) - .auto_steal_focus(auto_steal_focus); + .auto_steal_focus(auto_steal_focus) + .is_remote(is_remote); if let Some(agentic) = &mut session.agentic { ui_builder = ui_builder @@ -437,6 +443,12 @@ pub enum KeyActionResult { CloneAgent, DeleteSession(SessionId), SetAutoSteal(bool), + /// Permission response needs relay publishing (remote session). + PublishPermissionResponse { + perm_id: uuid::Uuid, + allowed: bool, + message: Option<String>, + }, } /// Handle a keybinding action. @@ -456,7 +468,7 @@ pub fn handle_key_action( match key_action { KeyAction::AcceptPermission => { if let Some(request_id) = update::first_pending_permission(session_manager) { - update::handle_permission_response( + let result = update::handle_permission_response( session_manager, request_id, PermissionResponse::Allow { message: None }, @@ -464,12 +476,24 @@ pub fn handle_key_action( if let Some(session) = session_manager.get_active_mut() { session.focus_requested = true; } + if let update::PermissionResponseResult::NeedsRelayPublish { + perm_id, + allowed, + message, + } = result + { + return KeyActionResult::PublishPermissionResponse { + perm_id, + allowed, + message, + }; + } } KeyActionResult::None } KeyAction::DenyPermission => { if let Some(request_id) = update::first_pending_permission(session_manager) { - update::handle_permission_response( + let result = update::handle_permission_response( session_manager, request_id, PermissionResponse::Deny { @@ -479,6 +503,18 @@ pub fn handle_key_action( if let Some(session) = session_manager.get_active_mut() { session.focus_requested = true; } + if let update::PermissionResponseResult::NeedsRelayPublish { + perm_id, + allowed, + message, + } = result + { + return KeyActionResult::PublishPermissionResponse { + perm_id, + allowed, + message, + }; + } } KeyActionResult::None } @@ -576,6 +612,12 @@ pub enum SendActionResult { Handled, /// Normal send - caller should send the user message SendMessage, + /// Permission response needs relay publishing (remote session). + NeedsRelayPublish { + perm_id: uuid::Uuid, + allowed: bool, + message: Option<String>, + }, } /// Handle the Send action, including tentative permission states. @@ -604,11 +646,23 @@ pub fn handle_send_action( if is_exit_plan_mode { update::exit_plan_mode(session_manager, backend, ctx); } - update::handle_permission_response( + let result = update::handle_permission_response( session_manager, request_id, PermissionResponse::Allow { message }, ); + if let update::PermissionResponseResult::NeedsRelayPublish { + perm_id, + allowed, + message, + } = result + { + return SendActionResult::NeedsRelayPublish { + perm_id, + allowed, + message, + }; + } } SendActionResult::Handled } @@ -622,11 +676,23 @@ pub fn handle_send_action( if let Some(session) = session_manager.get_active_mut() { session.input.clear(); } - update::handle_permission_response( + let result = update::handle_permission_response( session_manager, request_id, PermissionResponse::Deny { reason }, ); + if let update::PermissionResponseResult::NeedsRelayPublish { + perm_id, + allowed, + message, + } = result + { + return SendActionResult::NeedsRelayPublish { + perm_id, + allowed, + message, + }; + } } SendActionResult::Handled } @@ -642,6 +708,12 @@ pub enum UiActionResult { SendAction, /// Return an AppAction AppAction(notedeck::AppAction), + /// Permission response needs relay publishing (remote session). + PublishPermissionResponse { + perm_id: uuid::Uuid, + allowed: bool, + message: Option<String>, + }, } /// Handle a UI action from DaveUi. @@ -675,8 +747,22 @@ pub fn handle_ui_action( request_id, response, } => { - update::handle_permission_response(session_manager, request_id, response); - UiActionResult::Handled + let result = + update::handle_permission_response(session_manager, request_id, response); + if let update::PermissionResponseResult::NeedsRelayPublish { + perm_id, + allowed, + message, + } = result + { + UiActionResult::PublishPermissionResponse { + perm_id, + allowed, + message, + } + } else { + UiActionResult::Handled + } } DaveAction::Interrupt => { update::execute_interrupt(session_manager, backend, ctx); @@ -711,13 +797,13 @@ pub fn handle_ui_action( request_id, approved, } => { - if approved { + let result = if approved { update::exit_plan_mode(session_manager, backend, ctx); update::handle_permission_response( session_manager, request_id, PermissionResponse::Allow { message: None }, - ); + ) } else { update::handle_permission_response( session_manager, @@ -725,9 +811,22 @@ pub fn handle_ui_action( PermissionResponse::Deny { reason: "User rejected plan".into(), }, - ); + ) + }; + if let update::PermissionResponseResult::NeedsRelayPublish { + perm_id, + allowed, + message, + } = result + { + UiActionResult::PublishPermissionResponse { + perm_id, + allowed, + message, + } + } else { + UiActionResult::Handled } - UiActionResult::Handled } } } diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs @@ -138,21 +138,40 @@ pub fn exit_plan_mode( /// Get the first pending permission request ID for the active session. pub fn first_pending_permission(session_manager: &SessionManager) -> Option<uuid::Uuid> { - session_manager - .get_active() - .and_then(|session| session.agentic.as_ref()) - .and_then(|agentic| agentic.pending_permissions.keys().next().copied()) + let session = session_manager.get_active()?; + if session.is_remote() { + // Remote: find first unresponded PermissionRequest in chat + let responded = session + .agentic + .as_ref() + .map(|a| &a.responded_perm_ids); + for msg in &session.chat { + if let Message::PermissionRequest(req) = msg { + if req.response.is_none() + && responded.map_or(true, |ids| !ids.contains(&req.id)) + { + return Some(req.id); + } + } + } + None + } else { + // Local: check oneshot senders + session + .agentic + .as_ref() + .and_then(|a| a.pending_permissions.keys().next().copied()) + } } /// Get the tool name of the first pending permission request. pub fn pending_permission_tool_name(session_manager: &SessionManager) -> Option<&str> { + let request_id = first_pending_permission(session_manager)?; let session = session_manager.get_active()?; - let agentic = session.agentic.as_ref()?; - let request_id = agentic.pending_permissions.keys().next()?; for msg in &session.chat { if let Message::PermissionRequest(req) = msg { - if &req.id == request_id { + if req.id == request_id { return Some(&req.tool_name); } } @@ -171,22 +190,48 @@ pub fn has_pending_exit_plan_mode(session_manager: &SessionManager) -> bool { pending_permission_tool_name(session_manager) == Some("ExitPlanMode") } +/// Result of handling a permission response. +pub enum PermissionResponseResult { + /// Handled locally (oneshot sent to Claude process). + Local, + /// Needs relay publishing (remote session, no local process). + NeedsRelayPublish { + perm_id: uuid::Uuid, + allowed: bool, + message: Option<String>, + }, +} + /// Handle a permission response (from UI button or keybinding). pub fn handle_permission_response( session_manager: &mut SessionManager, request_id: uuid::Uuid, response: PermissionResponse, -) { +) -> PermissionResponseResult { let Some(session) = session_manager.get_active_mut() else { - return; + return PermissionResponseResult::Local; }; + let is_remote = session.is_remote(); + // Record the response type in the message for UI display let response_type = match &response { PermissionResponse::Allow { .. } => crate::messages::PermissionResponseType::Allowed, PermissionResponse::Deny { .. } => crate::messages::PermissionResponseType::Denied, }; + // Extract relay-publish info before we move `response` + let relay_info = if is_remote { + let allowed = matches!(&response, PermissionResponse::Allow { .. }); + let message = match &response { + PermissionResponse::Allow { message } => message.clone(), + PermissionResponse::Deny { reason } => Some(reason.clone()), + }; + Some((allowed, message)) + } else { + None + }; + // If Allow has a message, add it as a User message to the chat if let PermissionResponse::Allow { message: Some(msg) } = &response { if !msg.is_empty() { @@ -209,17 +254,33 @@ pub fn handle_permission_response( } if let Some(agentic) = &mut session.agentic { - if let Some(sender) = agentic.pending_permissions.remove(&request_id) { - if sender.send(response).is_err() { - tracing::error!( - "Failed to send permission response for request {}", - request_id - ); - } + if is_remote { + // Remote: mark as responded, signal relay publish needed + agentic.responded_perm_ids.insert(request_id); } else { - tracing::warn!("No pending permission found for request {}", request_id); + // Local: send through oneshot channel to Claude process + if let Some(sender) = agentic.pending_permissions.remove(&request_id) { + if sender.send(response).is_err() { + tracing::error!( + "Failed to send permission response for request {}", + request_id + ); + } + } else { + tracing::warn!("No pending permission found for request {}", request_id); + } } } + + if let Some((allowed, message)) = relay_info { + PermissionResponseResult::NeedsRelayPublish { + perm_id: request_id, + allowed, + message, + } + } else { + PermissionResponseResult::Local + } } /// Handle a user's response to an AskUserQuestion tool call.