commit 7caf4971f7665f2e06c95b3176aff163bf01a81d
parent 16e2d3839d86dbc20b7185ce121064c9a40feeab
Author: William Casarin <jb55@jb55.com>
Date: Thu, 26 Feb 2026 11:17:32 -0800
dave: sync permission mode between host and observer via nostr
Host publishes permission-mode tag in kind-31988 session state events.
Observer sends kind-1988 command events with role=set_permission_mode
to request mode changes. Host polls a single conversation_action_sub
subscription and dispatches by role tag (permission_response and
set_permission_mode). Remote sessions apply the mode from state events
on restore and live updates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
6 files changed, 298 insertions(+), 112 deletions(-)
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -183,6 +183,8 @@ pub struct Dave {
/// Permission responses queued for relay publishing (from remote sessions).
/// Built and published in the update loop where AppContext is available.
pending_perm_responses: Vec<PermissionPublish>,
+ /// Permission mode commands queued for relay publishing (observer → host).
+ pending_mode_commands: Vec<update::ModeCommandPublish>,
/// Sessions pending deletion state event publication.
/// Populated in delete_session(), drained in the update loop where AppContext is available.
pending_deletions: Vec<DeletedSessionInfo>,
@@ -499,6 +501,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
processed_commands: std::collections::HashSet::new(),
pending_spawn_commands: Vec::new(),
pending_perm_responses: Vec::new(),
+ pending_mode_commands: Vec::new(),
pending_deletions: Vec::new(),
pending_summaries: Vec::new(),
hostname,
@@ -1163,30 +1166,35 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
}
}
- /// Poll for remote permission responses arriving via nostr relays.
+ /// Poll for remote conversation actions arriving via nostr relays.
///
- /// Remote clients (phone) publish kind-1988 events with
- /// `role=permission_response` and a `perm-id` tag. We poll each
- /// session's subscription and route matching responses through the
- /// existing oneshot channel, racing with the local UI.
- fn poll_remote_permission_responses(&mut self, ndb: &nostrdb::Ndb) {
+ /// Dispatches kind-1988 events by `role` tag:
+ /// - `permission_response`: route through oneshot channel (first-response-wins)
+ /// - `set_permission_mode`: apply mode change locally
+ ///
+ /// Returns (backend_session_id, backend_type, mode) tuples for mode changes
+ /// that need to be applied to the local CLI backend.
+ fn poll_remote_conversation_actions(
+ &mut self,
+ ndb: &nostrdb::Ndb,
+ ) -> Vec<(String, BackendType, claude_agent_sdk_rs::PermissionMode)> {
+ let mut mode_applies = Vec::new();
let session_ids = self.session_manager.session_ids();
for session_id in session_ids {
let Some(session) = self.session_manager.get_mut(session_id) else {
continue;
};
- // Only local sessions poll for remote responses
+ // Only local sessions poll for remote actions
if session.is_remote() {
continue;
}
let Some(agentic) = &mut session.agentic else {
continue;
};
- let Some(sub) = agentic.perm_response_sub else {
+ let Some(sub) = agentic.conversation_action_sub else {
continue;
};
- // Poll for new notes (non-blocking)
let note_keys = ndb.poll_for_notes(sub, 64);
if note_keys.is_empty() {
continue;
@@ -1202,76 +1210,42 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
continue;
};
- // Only process permission_response events
- let role = session_events::get_tag_value(¬e, "role");
- if role != Some("permission_response") {
- continue;
- }
-
- // Extract perm-id
- let Some(perm_id_str) = session_events::get_tag_value(¬e, "perm-id") else {
- tracing::warn!("permission_response event missing perm-id tag");
- continue;
- };
- let Ok(perm_id) = uuid::Uuid::parse_str(perm_id_str) else {
- tracing::warn!("invalid perm-id UUID: {}", perm_id_str);
- continue;
- };
-
- // Parse the content to determine allow/deny
- let content = note.content();
- let (allowed, message) = match serde_json::from_str::<serde_json::Value>(content) {
- Ok(v) => {
- let decision = v.get("decision").and_then(|d| d.as_str()).unwrap_or("deny");
- let msg = v
- .get("message")
- .and_then(|m| m.as_str())
- .filter(|s| !s.is_empty())
- .map(|s| s.to_string());
- (decision == "allow", msg)
+ match session_events::get_tag_value(¬e, "role") {
+ Some("permission_response") => {
+ handle_remote_permission_response(¬e, agentic, &mut session.chat);
}
- Err(_) => (false, None),
- };
+ Some("set_permission_mode") => {
+ let content = note.content();
+ let mode_str = match serde_json::from_str::<serde_json::Value>(content) {
+ Ok(v) => v
+ .get("mode")
+ .and_then(|m| m.as_str())
+ .unwrap_or("default")
+ .to_string(),
+ Err(_) => continue,
+ };
- // Route through the existing oneshot channel (first-response-wins)
- if let Some(sender) = agentic.permissions.pending.remove(&perm_id) {
- let response = if allowed {
- PermissionResponse::Allow { message }
- } else {
- PermissionResponse::Deny {
- reason: message.unwrap_or_else(|| "Denied by remote".to_string()),
- }
- };
-
- // Mark in UI
- let response_type = if allowed {
- crate::messages::PermissionResponseType::Allowed
- } else {
- crate::messages::PermissionResponseType::Denied
- };
- for msg in &mut session.chat {
- if let Message::PermissionRequest(req) = msg {
- if req.id == perm_id {
- req.response = Some(response_type);
- break;
- }
- }
- }
+ let new_mode = crate::session::permission_mode_from_str(&mode_str);
+ agentic.permission_mode = new_mode;
+ session.state_dirty = true;
+
+ mode_applies.push((
+ format!("dave-session-{}", session_id),
+ session.backend_type,
+ new_mode,
+ ));
- if sender.send(response).is_err() {
- tracing::warn!("failed to send remote permission response for {}", perm_id);
- } else {
tracing::info!(
- "remote permission response for {}: {}",
- perm_id,
- if allowed { "allowed" } else { "denied" }
+ "remote command: set permission mode to {:?} for session {}",
+ new_mode,
+ session_id,
);
}
+ _ => {}
}
- // If sender not found, either local UI already responded or
- // this is a stale event — just ignore it silently.
}
}
+ mode_applies
}
/// Publish kind-31988 state events for sessions whose status changed.
@@ -1303,6 +1277,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
let cwd = agentic.cwd.to_string_lossy();
let status = session.status().as_str();
+ let perm_mode = crate::session::permission_mode_to_str(agentic.permission_mode);
queue_built_event(
session_events::build_session_state_event(
@@ -1314,6 +1289,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
&self.hostname,
&session.details.home_dir,
session.backend_type.as_str(),
+ perm_mode,
&sk,
),
&format!("publishing session state: {} -> {}", claude_sid, status),
@@ -1348,6 +1324,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
&self.hostname,
&info.home_dir,
info.backend.as_str(),
+ "default",
&sk,
),
&format!(
@@ -1420,6 +1397,33 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
}
}
+ /// Publish permission mode command events for remote sessions.
+ /// Called in the update loop where AppContext is available.
+ fn publish_pending_mode_commands(&mut self, ctx: &AppContext<'_>) {
+ if self.pending_mode_commands.is_empty() {
+ return;
+ }
+
+ let Some(sk) = secret_key_bytes(ctx.accounts.get_selected_account().keypair()) else {
+ tracing::warn!("no secret key for publishing mode commands");
+ self.pending_mode_commands.clear();
+ return;
+ };
+
+ for cmd in std::mem::take(&mut self.pending_mode_commands) {
+ queue_built_event(
+ session_events::build_set_permission_mode_event(cmd.mode, &cmd.session_id, &sk),
+ &format!(
+ "publishing permission mode command: {} -> {}",
+ cmd.session_id, cmd.mode
+ ),
+ ctx.ndb,
+ &sk,
+ &mut self.pending_relay_events,
+ );
+ }
+ }
+
/// Restore sessions from kind-31988 state events in ndb.
/// Called once on first `update()`.
fn restore_sessions_from_ndb(&mut self, ctx: &mut AppContext<'_>) {
@@ -1494,9 +1498,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
loaded.permissions.request_note_ids,
);
agentic.seen_note_ids = loaded.note_ids;
- // Set remote status from state event
+ // Set remote status and permission mode from state event
agentic.remote_status = AgentStatus::from_status_str(&state.status);
agentic.remote_status_ts = state.created_at;
+ if let Some(ref pm) = state.permission_mode {
+ agentic.permission_mode = crate::session::permission_mode_from_str(pm);
+ }
setup_conversation_subscription(agentic, &state.claude_session_id, ctx.ndb);
}
@@ -1619,10 +1626,16 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
if is_remote && !new_hostname.is_empty() {
session.details.hostname = new_hostname.to_string();
}
- // Status only updates for remote sessions (local
- // sessions derive status from the actual process)
+ // Status and permission mode only update for remote
+ // sessions (local sessions derive from the process)
if is_remote {
agentic.remote_status = new_status;
+ if let Some(pm) =
+ session_events::get_tag_value(¬e, "permission-mode")
+ {
+ agentic.permission_mode =
+ crate::session::permission_mode_from_str(pm);
+ }
}
}
}
@@ -1699,9 +1712,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
loaded.permissions.request_note_ids,
);
agentic.seen_note_ids = loaded.note_ids;
- // Set remote status
+ // Set remote status and permission mode
agentic.remote_status = AgentStatus::from_status_str(&state.status);
agentic.remote_status_ts = state.created_at;
+ if let Some(ref pm) = state.permission_mode {
+ agentic.permission_mode = crate::session::permission_mode_from_str(pm);
+ }
setup_conversation_subscription(agentic, claude_sid, ctx.ndb);
}
@@ -2182,6 +2198,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
KeyActionResult::PublishPermissionResponse(publish) => {
self.pending_perm_responses.push(publish);
}
+ KeyActionResult::PublishModeCommand(cmd) => {
+ self.pending_mode_commands.push(cmd);
+ }
KeyActionResult::None => {}
}
}
@@ -2243,6 +2262,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
self.pending_perm_responses.push(publish);
None
}
+ UiActionResult::PublishModeCommand(cmd) => {
+ self.pending_mode_commands.push(cmd);
+ None
+ }
UiActionResult::ToggleAutoSteal => {
let new_state = crate::update::toggle_auto_steal(
&mut self.session_manager,
@@ -2753,6 +2776,9 @@ impl notedeck::App for Dave {
}
}
+ // Build permission mode command events for remote sessions
+ self.publish_pending_mode_commands(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());
@@ -2769,10 +2795,15 @@ impl notedeck::App for Dave {
}
}
- // Poll for remote permission responses from relay events.
- // These arrive as kind-1988 events with role=permission_response,
- // published by phone/remote clients. First-response-wins with local UI.
- self.poll_remote_permission_responses(ctx.ndb);
+ // Poll for remote conversation actions (permission responses, commands).
+ let mode_applies = self.poll_remote_conversation_actions(ctx.ndb);
+ for (backend_sid, bt, mode) in mode_applies {
+ get_backend(&self.backends, bt).set_permission_mode(
+ backend_sid,
+ mode,
+ ui.ctx().clone(),
+ );
+ }
// Poll git status for local agentic sessions
for session in self.session_manager.iter_mut() {
@@ -3000,6 +3031,70 @@ fn handle_permission_request(
.push(Message::PermissionRequest(pending.request));
}
+/// Handle a remote permission response from a kind-1988 event.
+fn handle_remote_permission_response(
+ note: &nostrdb::Note,
+ agentic: &mut session::AgenticSessionData,
+ chat: &mut [Message],
+) {
+ let Some(perm_id_str) = session_events::get_tag_value(note, "perm-id") else {
+ tracing::warn!("permission_response event missing perm-id tag");
+ return;
+ };
+ let Ok(perm_id) = uuid::Uuid::parse_str(perm_id_str) else {
+ tracing::warn!("invalid perm-id UUID: {}", perm_id_str);
+ return;
+ };
+
+ let content = note.content();
+ let (allowed, message) = match serde_json::from_str::<serde_json::Value>(content) {
+ Ok(v) => {
+ let decision = v.get("decision").and_then(|d| d.as_str()).unwrap_or("deny");
+ let msg = v
+ .get("message")
+ .and_then(|m| m.as_str())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string());
+ (decision == "allow", msg)
+ }
+ Err(_) => (false, None),
+ };
+
+ if let Some(sender) = agentic.permissions.pending.remove(&perm_id) {
+ let response = if allowed {
+ PermissionResponse::Allow { message }
+ } else {
+ PermissionResponse::Deny {
+ reason: message.unwrap_or_else(|| "Denied by remote".to_string()),
+ }
+ };
+
+ let response_type = if allowed {
+ crate::messages::PermissionResponseType::Allowed
+ } else {
+ crate::messages::PermissionResponseType::Denied
+ };
+ for msg in chat.iter_mut() {
+ if let Message::PermissionRequest(req) = msg {
+ if req.id == perm_id {
+ req.response = Some(response_type);
+ break;
+ }
+ }
+ }
+
+ if sender.send(response).is_err() {
+ tracing::warn!("failed to send remote permission response for {}", perm_id);
+ } else {
+ tracing::info!(
+ "remote permission response for {}: {}",
+ perm_id,
+ if allowed { "allowed" } else { "denied" }
+ );
+ }
+ }
+}
+
/// Handle a tool result (execution metadata) from the AI backend.
///
/// Invalidates git status after file-modifying tools, then either folds
@@ -3081,26 +3176,23 @@ fn handle_query_complete(session: &mut session::ChatSession, info: messages::Usa
fn handle_session_info(session: &mut session::ChatSession, info: SessionInfo, ndb: &nostrdb::Ndb) {
if let Some(agentic) = &mut session.agentic {
if let Some(ref csid) = info.claude_session_id {
- // Permission response subscription (filtered to ai-permission tag)
- if agentic.perm_response_sub.is_none() {
+ // Subscribe for kind-1988 events (permission responses, commands)
+ if agentic.conversation_action_sub.is_none() {
let filter = nostrdb::Filter::new()
.kinds([session_events::AI_CONVERSATION_KIND as u64])
.tags([csid.as_str()], 'd')
- .tags(["ai-permission"], 't')
.build();
match ndb.subscribe(&[filter]) {
Ok(sub) => {
- tracing::info!(
- "subscribed for remote permission responses (session {})",
- csid
- );
- agentic.perm_response_sub = Some(sub);
+ tracing::info!("subscribed for conversation actions (session {})", csid);
+ agentic.conversation_action_sub = Some(sub);
}
Err(e) => {
- tracing::warn!("failed to subscribe for permission responses: {:?}", e);
+ tracing::warn!("failed to subscribe for conversation actions: {:?}", e);
}
}
}
+
setup_conversation_subscription(agentic, csid, ndb);
}
agentic.session_info = Some(info);
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -19,6 +19,26 @@ use uuid::Uuid;
pub type SessionId = u32;
+/// Convert PermissionMode to a stable string for nostr tags.
+pub fn permission_mode_to_str(mode: PermissionMode) -> &'static str {
+ match mode {
+ PermissionMode::Default => "default",
+ PermissionMode::Plan => "plan",
+ PermissionMode::AcceptEdits => "accept_edits",
+ PermissionMode::BypassPermissions => "bypass",
+ }
+}
+
+/// Parse PermissionMode from a nostr tag string.
+pub fn permission_mode_from_str(s: &str) -> PermissionMode {
+ match s {
+ "plan" => PermissionMode::Plan,
+ "accept_edits" => PermissionMode::AcceptEdits,
+ "bypass" => PermissionMode::BypassPermissions,
+ _ => PermissionMode::Default,
+ }
+}
+
/// Whether this session runs locally or is observed remotely via relays.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SessionSource {
@@ -194,9 +214,9 @@ pub struct AgenticSessionData {
pub git_status: GitStatusCache,
/// Threading state for live kind-1988 event generation.
pub live_threading: ThreadingState,
- /// Subscription for remote permission response events (kind-1988, t=ai-permission).
+ /// Subscription for remote kind-1988 events (permission responses, commands).
/// Set up once when the session's claude_session_id becomes known.
- pub perm_response_sub: Option<nostrdb::Subscription>,
+ pub conversation_action_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>,
@@ -241,7 +261,7 @@ impl AgenticSessionData {
resume_session_id: None,
git_status,
live_threading: ThreadingState::new(),
- perm_response_sub: None,
+ conversation_action_sub: None,
remote_status: None,
remote_status_ts: 0,
live_conversation_sub: None,
diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs
@@ -743,6 +743,7 @@ pub fn build_session_state_event(
hostname: &str,
home_dir: &str,
backend: &str,
+ permission_mode: &str,
secret_key: &[u8; 32],
) -> Result<BuiltEvent, EventBuildError> {
let mut builder = init_note_builder(AI_SESSION_STATE_KIND, "", Some(now_secs()));
@@ -760,6 +761,10 @@ pub fn build_session_state_event(
builder = builder.start_tag().tag_str("hostname").tag_str(hostname);
builder = builder.start_tag().tag_str("home_dir").tag_str(home_dir);
builder = builder.start_tag().tag_str("backend").tag_str(backend);
+ builder = builder
+ .start_tag()
+ .tag_str("permission-mode")
+ .tag_str(permission_mode);
// Discoverability
builder = builder.start_tag().tag_str("t").tag_str("ai-session-state");
@@ -809,6 +814,37 @@ pub fn build_spawn_command_event(
finalize_built_event(builder, secret_key, AI_SESSION_COMMAND_KIND)
}
+/// Build a kind-1988 command event to set permission mode on a remote session.
+///
+/// Published by remote observers to request a permission mode change on the host.
+/// The host subscribes for these and applies them via its local backend.
+pub fn build_set_permission_mode_event(
+ mode: &str,
+ session_id: &str,
+ secret_key: &[u8; 32],
+) -> Result<BuiltEvent, EventBuildError> {
+ let content = serde_json::json!({
+ "mode": mode,
+ })
+ .to_string();
+
+ let mut builder = init_note_builder(AI_CONVERSATION_KIND, &content, Some(now_secs()));
+
+ builder = builder.start_tag().tag_str("d").tag_str(session_id);
+ builder = builder
+ .start_tag()
+ .tag_str("role")
+ .tag_str("set_permission_mode");
+ builder = builder
+ .start_tag()
+ .tag_str("source")
+ .tag_str("notedeck-dave");
+ builder = builder.start_tag().tag_str("t").tag_str("ai-conversation");
+ builder = builder.start_tag().tag_str("t").tag_str("ai-command");
+
+ finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -1304,6 +1340,7 @@ mod tests {
"my-laptop",
"/home/testuser",
"claude",
+ "plan",
&sk,
)
.unwrap();
@@ -1324,6 +1361,7 @@ mod tests {
assert!(json.contains("/tmp/project"));
assert!(json.contains(r#""hostname","my-laptop"#));
assert!(json.contains(r#""backend","claude"#));
+ assert!(json.contains(r#""permission-mode","plan"#));
}
#[test]
diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs
@@ -253,6 +253,7 @@ pub struct SessionState {
pub hostname: String,
pub home_dir: String,
pub backend: Option<String>,
+ pub permission_mode: Option<String>,
pub created_at: u64,
}
@@ -276,6 +277,7 @@ impl SessionState {
hostname: get_tag_value(note, "hostname").unwrap_or("").to_string(),
home_dir: get_tag_value(note, "home_dir").unwrap_or("").to_string(),
backend: get_tag_value(note, "backend").map(|s| s.to_string()),
+ permission_mode: get_tag_value(note, "permission-mode").map(|s| s.to_string()),
created_at: note.created_at(),
})
}
diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs
@@ -584,6 +584,8 @@ pub enum KeyActionResult {
SetAutoSteal(bool),
/// Permission response needs relay publishing.
PublishPermissionResponse(update::PermissionPublish),
+ /// Permission mode command needs relay publishing (observer → host).
+ PublishModeCommand(update::ModeCommandPublish),
}
/// Handle a keybinding action.
@@ -667,11 +669,14 @@ pub fn handle_key_action(
KeyAction::Interrupt => KeyActionResult::HandleInterrupt,
KeyAction::ToggleView => KeyActionResult::ToggleView,
KeyAction::CyclePermissionMode => {
- update::cycle_permission_mode(session_manager, backend, ctx);
+ let publish = update::cycle_permission_mode(session_manager, backend, ctx);
if let Some(session) = session_manager.get_active_mut() {
session.focus_requested = true;
}
- KeyActionResult::None
+ match publish {
+ Some(cmd) => KeyActionResult::PublishModeCommand(cmd),
+ None => KeyActionResult::None,
+ }
}
KeyAction::DeleteActiveSession => {
if let Some(id) = session_manager.active_id() {
@@ -797,6 +802,8 @@ pub enum UiActionResult {
NewChat,
/// Trigger manual context compaction
Compact,
+ /// Permission mode command needs relay publishing (observer → host).
+ PublishModeCommand(update::ModeCommandPublish),
}
/// Handle a UI action from DaveUi.
@@ -850,11 +857,14 @@ pub fn handle_ui_action(
UiActionResult::PublishPermissionResponse,
),
DaveAction::CyclePermissionMode => {
- update::cycle_permission_mode(session_manager, backend, ctx);
+ let publish = update::cycle_permission_mode(session_manager, backend, ctx);
if let Some(session) = session_manager.get_active_mut() {
session.focus_requested = true;
}
- UiActionResult::Handled
+ match publish {
+ Some(cmd) => UiActionResult::PublishModeCommand(cmd),
+ None => UiActionResult::Handled,
+ }
}
DaveAction::ToggleAutoSteal => UiActionResult::ToggleAutoSteal,
DaveAction::ExitPlanMode {
diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs
@@ -91,30 +91,54 @@ pub fn check_interrupt_timeout(pending_since: Option<Instant>) -> Option<Instant
// =============================================================================
/// Cycle permission mode for the active session: Default → Plan → AcceptEdits → Default.
+/// Info needed to publish a permission mode command to a remote host.
+pub struct ModeCommandPublish {
+ pub session_id: String,
+ pub mode: &'static str,
+}
+
pub fn cycle_permission_mode(
session_manager: &mut SessionManager,
backend: &dyn AiBackend,
ctx: &egui::Context,
-) {
- if let Some(session) = session_manager.get_active_mut() {
- if let Some(agentic) = &mut session.agentic {
- let new_mode = match agentic.permission_mode {
- PermissionMode::Default => PermissionMode::Plan,
- PermissionMode::Plan => PermissionMode::AcceptEdits,
- _ => PermissionMode::Default,
- };
- agentic.permission_mode = new_mode;
+) -> Option<ModeCommandPublish> {
+ let session = session_manager.get_active_mut()?;
+ let is_remote = session.is_remote();
+ let session_id = session.id;
+ let agentic = session.agentic.as_mut()?;
- let session_id = format!("dave-session-{}", session.id);
- backend.set_permission_mode(session_id, new_mode, ctx.clone());
+ let new_mode = match agentic.permission_mode {
+ PermissionMode::Default => PermissionMode::Plan,
+ PermissionMode::Plan => PermissionMode::AcceptEdits,
+ _ => PermissionMode::Default,
+ };
+ agentic.permission_mode = new_mode;
- tracing::debug!(
- "Cycled permission mode for session {} to {:?}",
- session.id,
- new_mode
- );
- }
- }
+ let mode_str = crate::session::permission_mode_to_str(new_mode);
+
+ let result = if is_remote {
+ // Remote session: return info for caller to publish command event
+ let event_sid = agentic.event_session_id()?.to_string();
+ Some(ModeCommandPublish {
+ session_id: event_sid,
+ mode: mode_str,
+ })
+ } else {
+ // Local session: apply directly and mark dirty for state event publish
+ let backend_sid = format!("dave-session-{}", session_id);
+ backend.set_permission_mode(backend_sid, new_mode, ctx.clone());
+ session.state_dirty = true;
+ None
+ };
+
+ tracing::debug!(
+ "Cycled permission mode for session {} to {:?} (remote={})",
+ session_id,
+ new_mode,
+ is_remote,
+ );
+
+ result
}
/// Exit plan mode for the active session (switch to Default mode).