commit d8c900d69ad3e77d86f2014a18fe7c09198dae50
parent 45e793c11a5a4f5e85bc5f10a74356b83f2db93d
Author: William Casarin <jb55@jb55.com>
Date: Wed, 28 Jan 2026 18:33:36 -0800
dave: handle System message and Task tool subagents
Parse the System (init) message from Claude Code CLI to extract
session metadata including available tools, model, agents, and
slash commands. Store this in ChatSession.session_info.
Track Task tool subagents with spawn/completion events and
size-restricted output buffers. Add /cd command to set the
working directory for claude-code subprocess.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
8 files changed, 310 insertions(+), 37 deletions(-)
diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs
@@ -1,6 +1,7 @@
use crate::backend::traits::AiBackend;
use crate::messages::{
- DaveApiResponse, PendingPermission, PermissionRequest, PermissionResponse, ToolResult,
+ DaveApiResponse, PendingPermission, PermissionRequest, PermissionResponse, SessionInfo,
+ SubagentInfo, SubagentStatus, ToolResult,
};
use crate::tools::Tool;
use crate::Message;
@@ -12,6 +13,7 @@ use dashmap::DashMap;
use futures::future::BoxFuture;
use futures::StreamExt;
use std::collections::HashMap;
+use std::path::PathBuf;
use std::sync::mpsc;
use std::sync::Arc;
use tokio::sync::mpsc as tokio_mpsc;
@@ -118,7 +120,11 @@ struct PermissionRequestInternal {
}
/// Session actor task that owns a single ClaudeClient with persistent connection
-async fn session_actor(session_id: String, mut command_rx: tokio_mpsc::Receiver<SessionCommand>) {
+async fn session_actor(
+ session_id: String,
+ cwd: Option<PathBuf>,
+ mut command_rx: tokio_mpsc::Receiver<SessionCommand>,
+) {
// Permission channel - the callback sends to perm_tx, actor receives on perm_rx
let (perm_tx, mut perm_rx) = tokio_mpsc::channel::<PermissionRequestInternal>(16);
@@ -171,12 +177,21 @@ async fn session_actor(session_id: String, mut command_rx: tokio_mpsc::Receiver<
});
// Create client once - this maintains the persistent connection
- let options = ClaudeAgentOptions::builder()
- .permission_mode(PermissionMode::Default)
- .stderr_callback(stderr_callback)
- .can_use_tool(can_use_tool)
- .include_partial_messages(true)
- .build();
+ let options = match cwd {
+ Some(ref dir) => ClaudeAgentOptions::builder()
+ .permission_mode(PermissionMode::Default)
+ .stderr_callback(stderr_callback)
+ .can_use_tool(can_use_tool)
+ .include_partial_messages(true)
+ .cwd(dir)
+ .build(),
+ None => ClaudeAgentOptions::builder()
+ .permission_mode(PermissionMode::Default)
+ .stderr_callback(stderr_callback)
+ .can_use_tool(can_use_tool)
+ .include_partial_messages(true)
+ .build(),
+ };
let mut client = ClaudeClient::new(options);
// Connect once - this starts the subprocess
@@ -344,6 +359,31 @@ async fn session_actor(session_id: String, mut command_rx: tokio_mpsc::Receiver<
for block in &assistant_msg.message.content {
if let ContentBlock::ToolUse(ToolUseBlock { id, name, input }) = block {
pending_tools.insert(id.clone(), (name.clone(), input.clone()));
+
+ // Emit SubagentSpawned for Task tool calls
+ if name == "Task" {
+ let description = input
+ .get("description")
+ .and_then(|v| v.as_str())
+ .unwrap_or("task")
+ .to_string();
+ let subagent_type = input
+ .get("subagent_type")
+ .and_then(|v| v.as_str())
+ .unwrap_or("unknown")
+ .to_string();
+
+ let subagent_info = SubagentInfo {
+ task_id: id.clone(),
+ description,
+ subagent_type,
+ status: SubagentStatus::Running,
+ output: String::new(),
+ max_output_size: 4000,
+ };
+ let _ = response_tx.send(DaveApiResponse::SubagentSpawned(subagent_info));
+ ctx.request_repaint();
+ }
}
}
}
@@ -388,6 +428,16 @@ async fn session_actor(session_id: String, mut command_rx: tokio_mpsc::Receiver<
if let Some(tool_use_id) = tool_use_id {
if let Some((tool_name, tool_input)) = pending_tools.remove(tool_use_id) {
+ // Check if this is a Task tool completion
+ if tool_name == "Task" {
+ let result_text = extract_response_content(tool_use_result)
+ .unwrap_or_else(|| "completed".to_string());
+ let _ = response_tx.send(DaveApiResponse::SubagentCompleted {
+ task_id: tool_use_id.to_string(),
+ result: truncate_output(&result_text, 2000),
+ });
+ }
+
let summary = format_tool_summary(&tool_name, &tool_input, tool_use_result);
let tool_result = ToolResult { tool_name, summary };
let _ = response_tx.send(DaveApiResponse::ToolResult(tool_result));
@@ -396,8 +446,18 @@ async fn session_actor(session_id: String, mut command_rx: tokio_mpsc::Receiver<
}
}
}
- other => {
- tracing::debug!("Received unhandled message type: {:?}", other);
+ ClaudeMessage::System(system_msg) => {
+ // Handle system init message - extract session info
+ if system_msg.subtype == "init" {
+ let session_info = parse_session_info(&system_msg);
+ let _ = response_tx.send(DaveApiResponse::SessionInfo(session_info));
+ ctx.request_repaint();
+ } else {
+ tracing::debug!("Received system message subtype: {}", system_msg.subtype);
+ }
+ }
+ ClaudeMessage::ControlCancelRequest(_) => {
+ // Ignore internal control messages
}
}
}
@@ -458,6 +518,7 @@ impl AiBackend for ClaudeBackend {
_model: String,
_user_id: String,
session_id: String,
+ cwd: Option<PathBuf>,
ctx: egui::Context,
) -> (
mpsc::Receiver<DaveApiResponse>,
@@ -493,10 +554,11 @@ impl AiBackend for ClaudeBackend {
let handle = entry.or_insert_with(|| {
let (command_tx, command_rx) = tokio_mpsc::channel(16);
- // Spawn session actor
+ // Spawn session actor with cwd
let session_id_clone = session_id.clone();
+ let cwd_clone = cwd.clone();
tokio::spawn(async move {
- session_actor(session_id_clone, command_rx).await;
+ session_actor(session_id_clone, cwd_clone, command_rx).await;
});
SessionHandle { command_tx }
@@ -670,6 +732,76 @@ fn format_tool_summary(
let filename = file.rsplit('/').next().unwrap_or(file);
filename.to_string()
}
+ "Task" => {
+ let description = input
+ .get("description")
+ .and_then(|v| v.as_str())
+ .unwrap_or("task");
+ let subagent_type = input
+ .get("subagent_type")
+ .and_then(|v| v.as_str())
+ .unwrap_or("unknown");
+ format!("{} ({})", description, subagent_type)
+ }
_ => String::new(),
}
}
+
+/// Parse a System message into SessionInfo
+fn parse_session_info(system_msg: &claude_agent_sdk_rs::SystemMessage) -> SessionInfo {
+ let data = &system_msg.data;
+
+ // Extract slash_commands from data
+ let slash_commands = data
+ .get("slash_commands")
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str().map(String::from))
+ .collect()
+ })
+ .unwrap_or_default();
+
+ // Extract agents from data
+ let agents = data
+ .get("agents")
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str().map(String::from))
+ .collect()
+ })
+ .unwrap_or_default();
+
+ // Extract CLI version
+ let cli_version = data
+ .get("claude_code_version")
+ .and_then(|v| v.as_str())
+ .map(String::from);
+
+ SessionInfo {
+ tools: system_msg.tools.clone().unwrap_or_default(),
+ model: system_msg.model.clone(),
+ permission_mode: system_msg.permission_mode.clone(),
+ slash_commands,
+ agents,
+ cli_version,
+ cwd: system_msg.cwd.clone(),
+ claude_session_id: system_msg.session_id.clone(),
+ }
+}
+
+/// Truncate output to a maximum size, keeping the end (most recent) content
+fn truncate_output(output: &str, max_size: usize) -> String {
+ if output.len() <= max_size {
+ output.to_string()
+ } else {
+ let start = output.len() - max_size;
+ // Find a newline near the start to avoid cutting mid-line
+ let adjusted_start = output[start..]
+ .find('\n')
+ .map(|pos| start + pos + 1)
+ .unwrap_or(start);
+ format!("...\n{}", &output[adjusted_start..])
+ }
+}
diff --git a/crates/notedeck_dave/src/backend/openai.rs b/crates/notedeck_dave/src/backend/openai.rs
@@ -11,6 +11,7 @@ use claude_agent_sdk_rs::PermissionMode;
use futures::StreamExt;
use nostrdb::{Ndb, Transaction};
use std::collections::HashMap;
+use std::path::PathBuf;
use std::sync::mpsc;
use std::sync::Arc;
@@ -33,6 +34,7 @@ impl AiBackend for OpenAiBackend {
model: String,
user_id: String,
_session_id: String,
+ _cwd: Option<PathBuf>,
ctx: egui::Context,
) -> (
mpsc::Receiver<DaveApiResponse>,
diff --git a/crates/notedeck_dave/src/backend/traits.rs b/crates/notedeck_dave/src/backend/traits.rs
@@ -2,6 +2,7 @@ use crate::messages::DaveApiResponse;
use crate::tools::Tool;
use claude_agent_sdk_rs::PermissionMode;
use std::collections::HashMap;
+use std::path::PathBuf;
use std::sync::mpsc;
use std::sync::Arc;
@@ -25,6 +26,7 @@ pub trait AiBackend: Send + Sync {
model: String,
user_id: String,
session_id: String,
+ cwd: Option<PathBuf>,
ctx: egui::Context,
) -> (
mpsc::Receiver<DaveApiResponse>,
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -21,6 +21,7 @@ use focus_queue::FocusQueue;
use nostrdb::Transaction;
use notedeck::{ui::is_narrow, AppAction, AppContext, AppResponse};
use std::collections::HashMap;
+use std::path::PathBuf;
use std::string::ToString;
use std::sync::Arc;
use std::time::Instant;
@@ -29,7 +30,7 @@ pub use avatar::DaveAvatar;
pub use config::{AiProvider, DaveSettings, ModelConfig};
pub use messages::{
AskUserQuestionInput, DaveApiResponse, Message, PermissionResponse, PermissionResponseType,
- QuestionAnswer, ToolResult,
+ QuestionAnswer, SessionInfo, SubagentInfo, SubagentStatus, ToolResult,
};
pub use quaternion::Quaternion;
pub use session::{ChatSession, SessionId, SessionManager};
@@ -279,6 +280,35 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
tracing::debug!("Tool result: {} - {}", result.tool_name, result.summary);
session.chat.push(Message::ToolResult(result));
}
+
+ DaveApiResponse::SessionInfo(info) => {
+ tracing::debug!(
+ "Session info: model={:?}, tools={}, agents={}",
+ info.model,
+ info.tools.len(),
+ info.agents.len()
+ );
+ session.session_info = Some(info);
+ }
+
+ DaveApiResponse::SubagentSpawned(subagent) => {
+ tracing::debug!(
+ "Subagent spawned: {} ({}) - {}",
+ subagent.task_id,
+ subagent.subagent_type,
+ subagent.description
+ );
+ session.subagents.insert(subagent.task_id.clone(), subagent);
+ }
+
+ DaveApiResponse::SubagentOutput { task_id, output } => {
+ session.update_subagent_output(&task_id, &output);
+ }
+
+ DaveApiResponse::SubagentCompleted { task_id, result } => {
+ tracing::debug!("Subagent completed: {}", task_id);
+ session.complete_subagent(&task_id, &result);
+ }
}
}
@@ -1017,6 +1047,27 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
/// Handle a user send action triggered by the ui
fn handle_user_send(&mut self, app_ctx: &AppContext, ui: &egui::Ui) {
if let Some(session) = self.session_manager.get_active_mut() {
+ let input = session.input.trim().to_string();
+
+ // Handle /cd command
+ if input.starts_with("/cd ") {
+ let path_str = input.strip_prefix("/cd ").unwrap().trim();
+ let path = PathBuf::from(path_str);
+ if path.exists() && path.is_dir() {
+ session.cwd = Some(path.clone());
+ session.chat.push(Message::System(format!(
+ "Working directory set to: {}",
+ path.display()
+ )));
+ } else {
+ session
+ .chat
+ .push(Message::Error(format!("Invalid directory: {}", path_str)));
+ }
+ session.input.clear();
+ return;
+ }
+
session.chat.push(Message::User(session.input.clone()));
session.input.clear();
session.update_title_from_last_message();
@@ -1032,6 +1083,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
let user_id = calculate_user_id(app_ctx.accounts.get_selected_account().keypair());
let session_id = format!("dave-session-{}", session.id);
let messages = session.chat.clone();
+ let cwd = session.cwd.clone();
let tools = self.tools.clone();
let model_name = self.model_config.model().to_owned();
let ctx = ctx.clone();
@@ -1039,7 +1091,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
// Use backend to stream request
let (rx, task_handle) = self
.backend
- .stream_request(messages, tools, model_name, user_id, session_id, ctx);
+ .stream_request(messages, tools, model_name, user_id, session_id, cwd, ctx);
session.incoming_tokens = Some(rx);
session.task_handle = task_handle;
}
diff --git a/crates/notedeck_dave/src/messages.rs b/crates/notedeck_dave/src/messages.rs
@@ -99,6 +99,55 @@ pub struct ToolResult {
pub summary: String, // e.g., "154 lines", "exit 0", "3 matches"
}
+/// Session initialization info from Claude Code CLI
+#[derive(Debug, Clone, Default)]
+pub struct SessionInfo {
+ /// Available tools in this session
+ pub tools: Vec<String>,
+ /// Model being used (e.g., "claude-opus-4-5-20251101")
+ pub model: Option<String>,
+ /// Permission mode (e.g., "default", "plan")
+ pub permission_mode: Option<String>,
+ /// Available slash commands
+ pub slash_commands: Vec<String>,
+ /// Available agent types for Task tool
+ pub agents: Vec<String>,
+ /// Claude Code CLI version
+ pub cli_version: Option<String>,
+ /// Current working directory
+ pub cwd: Option<String>,
+ /// Session ID from Claude Code
+ pub claude_session_id: Option<String>,
+}
+
+/// Status of a subagent spawned by the Task tool
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SubagentStatus {
+ /// Subagent is running
+ Running,
+ /// Subagent completed successfully
+ Completed,
+ /// Subagent failed with an error
+ Failed,
+}
+
+/// Information about a subagent spawned by the Task tool
+#[derive(Debug, Clone)]
+pub struct SubagentInfo {
+ /// Unique ID for this subagent task
+ pub task_id: String,
+ /// Description of what the subagent is doing
+ pub description: String,
+ /// Type of subagent (e.g., "Explore", "Plan", "Bash")
+ pub subagent_type: String,
+ /// Current status
+ pub status: SubagentStatus,
+ /// Output content (truncated for display)
+ pub output: String,
+ /// Maximum output size to keep (for size-restricted window)
+ pub max_output_size: usize,
+}
+
#[derive(Debug, Clone)]
pub enum Message {
System(String),
@@ -123,6 +172,20 @@ pub enum DaveApiResponse {
PermissionRequest(PendingPermission),
/// Metadata from a completed tool execution
ToolResult(ToolResult),
+ /// Session initialization info from Claude Code CLI
+ SessionInfo(SessionInfo),
+ /// Subagent spawned by Task tool
+ SubagentSpawned(SubagentInfo),
+ /// Subagent output update
+ SubagentOutput {
+ task_id: String,
+ output: String,
+ },
+ /// Subagent completed
+ SubagentCompleted {
+ task_id: String,
+ result: String,
+ },
}
impl Message {
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -1,8 +1,11 @@
use std::collections::HashMap;
+use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use crate::agent_status::AgentStatus;
-use crate::messages::{PermissionResponse, QuestionAnswer};
+use crate::messages::{
+ PermissionResponse, QuestionAnswer, SessionInfo, SubagentInfo, SubagentStatus,
+};
use crate::{DaveApiResponse, Message};
use claude_agent_sdk_rs::PermissionMode;
use tokio::sync::oneshot;
@@ -47,6 +50,12 @@ pub struct ChatSession {
pub question_answers: HashMap<Uuid, Vec<QuestionAnswer>>,
/// Current question index for multi-question AskUserQuestion (keyed by request UUID)
pub question_index: HashMap<Uuid, usize>,
+ /// Working directory for claude-code subprocess
+ pub cwd: Option<PathBuf>,
+ /// Session info from Claude Code CLI (tools, model, agents, etc.)
+ pub session_info: Option<SessionInfo>,
+ /// Active subagents spawned by Task tool (keyed by task_id)
+ pub subagents: HashMap<String, SubagentInfo>,
}
impl Drop for ChatSession {
@@ -80,9 +89,39 @@ impl ChatSession {
permission_message_state: PermissionMessageState::None,
question_answers: HashMap::new(),
question_index: HashMap::new(),
+ cwd: None,
+ session_info: None,
+ subagents: HashMap::new(),
}
}
+ /// Update a subagent's output (appending new content, keeping only the tail)
+ pub fn update_subagent_output(&mut self, task_id: &str, new_output: &str) {
+ if let Some(subagent) = self.subagents.get_mut(task_id) {
+ subagent.output.push_str(new_output);
+ // Keep only the most recent content up to max_output_size
+ if subagent.output.len() > subagent.max_output_size {
+ let keep_from = subagent.output.len() - subagent.max_output_size;
+ subagent.output = subagent.output[keep_from..].to_string();
+ }
+ }
+ }
+
+ /// Mark a subagent as completed
+ pub fn complete_subagent(&mut self, task_id: &str, result: &str) {
+ if let Some(subagent) = self.subagents.get_mut(task_id) {
+ subagent.status = SubagentStatus::Completed;
+ subagent.output = result.to_string();
+ }
+ }
+
+ /// Get all active (running) subagents
+ pub fn active_subagents(&self) -> impl Iterator<Item = &SubagentInfo> {
+ self.subagents
+ .values()
+ .filter(|s| s.status == SubagentStatus::Running)
+ }
+
/// Update the session title from the last message (user or assistant)
pub fn update_title_from_last_message(&mut self) {
for msg in self.chat.iter().rev() {
diff --git a/crates/notedeck_dave/src/ui/ask_question.rs b/crates/notedeck_dave/src/ui/ask_question.rs
@@ -50,10 +50,7 @@ pub fn ask_user_question_ui(
.fill(ui.visuals().widgets.noninteractive.bg_fill)
.inner_margin(inner_margin)
.corner_radius(corner_radius)
- .stroke(egui::Stroke::new(
- 1.0,
- ui.visuals().selection.stroke.color,
- ))
+ .stroke(egui::Stroke::new(1.0, ui.visuals().selection.stroke.color))
.show(ui, |ui| {
ui.vertical(|ui| {
// Progress indicator if multiple questions
@@ -151,11 +148,7 @@ pub fn ask_user_question_ui(
ui.vertical(|ui| {
ui.label(egui::RichText::new(&option.label));
- ui.label(
- egui::RichText::new(&option.description)
- .weak()
- .size(11.0),
- );
+ ui.label(egui::RichText::new(&option.description).weak().size(11.0));
});
});
@@ -216,11 +209,7 @@ pub fn ask_user_question_ui(
let button_text_color = ui.visuals().widgets.active.fg_stroke.color;
let is_last_question = current_idx == num_questions - 1;
- let button_label = if is_last_question {
- "Submit"
- } else {
- "Next"
- };
+ let button_label = if is_last_question { "Submit" } else { "Next" };
let submit_response = badge::ActionButton::new(
button_label,
@@ -255,10 +244,7 @@ pub fn ask_user_question_ui(
///
/// Shows the question header(s) and selected answer(s) in a single line.
/// Uses pre-computed AnswerSummary to avoid per-frame allocations.
-pub fn ask_user_question_summary_ui(
- summary: &crate::messages::AnswerSummary,
- ui: &mut egui::Ui,
-) {
+pub fn ask_user_question_summary_ui(summary: &crate::messages::AnswerSummary, ui: &mut egui::Ui) {
let inner_margin = 8.0;
let corner_radius = 6.0;
diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs
@@ -133,10 +133,7 @@ impl<'a> DaveUi<'a> {
self
}
- pub fn question_answers(
- mut self,
- answers: &'a mut HashMap<Uuid, Vec<QuestionAnswer>>,
- ) -> Self {
+ pub fn question_answers(mut self, answers: &'a mut HashMap<Uuid, Vec<QuestionAnswer>>) -> Self {
self.question_answers = Some(answers);
self
}