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:
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 ¬es {
+ 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 ¬es {
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.