diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 7560a63..e437716 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -197,6 +197,8 @@ pub struct OutputEvent { pub conversation_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_tool_use_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -248,6 +250,33 @@ pub struct UserQuestionEvent { pub conversation_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentStartEvent { + pub tool_use_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + pub description: String, + pub subagent_type: String, + pub started_at: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_tool_use_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentEndEvent { + pub tool_use_id: String, + pub ended_at: u64, + pub is_error: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub num_turns: Option, +} + #[cfg(test)] mod tests { use super::*; @@ -369,6 +398,7 @@ mod tests { tool_name: None, conversation_id: None, cost: None, + parent_tool_use_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); @@ -388,6 +418,7 @@ mod tests { output_tokens: 50, cost_usd: 0.005, }), + parent_tool_use_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index d925b09..8b0e405 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -2,6 +2,7 @@ use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::Arc; use std::thread; +use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter}; use tempfile::NamedTempFile; @@ -13,9 +14,9 @@ use crate::commands::record_cost; use crate::config::ClaudeStartOptions; use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats}; use crate::types::{ - CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, MessageCost, - OutputEvent, PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent, - UserQuestionEvent, WorkingDirectoryEvent, + AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, + ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent, + QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent, WorkingDirectoryEvent, }; use parking_lot::RwLock; @@ -193,6 +194,8 @@ impl WslBridge { "--input-format", "stream-json", "--verbose", + "--debug", + "hooks", ]); // Add model if specified @@ -642,6 +645,25 @@ fn handle_stderr( for line in reader.lines() { match line { Ok(line) if !line.is_empty() => { + // Check if this is a SubagentStart hook message + if line.contains("[SubagentStart Hook]") { + if let Some(agent_data) = parse_subagent_start_hook(&line) { + eprintln!("[DEBUG] Parsed SubagentStart hook: agent_id={}, parent={:?}", + agent_data.agent_id, agent_data.parent_tool_use_id); + + // Emit an agent-update event with the agent_id + let _ = app.emit( + "claude:agent-update", + serde_json::json!({ + "conversationId": conversation_id.clone(), + "toolUseId": agent_data.parent_tool_use_id, + "agentId": agent_data.agent_id, + }), + ); + } + } + + // Still emit the stderr line as output let _ = app.emit( "claude:output", OutputEvent { @@ -650,6 +672,7 @@ fn handle_stderr( tool_name: None, conversation_id: conversation_id.clone(), cost: None, + parent_tool_use_id: None, }, ); } @@ -659,6 +682,41 @@ fn handle_stderr( } } +#[derive(Debug)] +struct SubagentStartData { + agent_id: String, + parent_tool_use_id: Option, +} + +fn parse_subagent_start_hook(line: &str) -> Option { + // Parse: [SubagentStart Hook] agent_id=agent-xxx, parent_tool_use_id=Some("toolu_xxx"), ... + + // Extract agent_id + let agent_id = line + .split("agent_id=") + .nth(1)? + .split(',') + .next()? + .trim() + .to_string(); + + // Extract parent_tool_use_id if present + let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") { + line.split("parent_tool_use_id=Some(\"") + .nth(1)? + .split('"') + .next() + .map(|s| s.to_string()) + } else { + None + }; + + Some(SubagentStartData { + agent_id, + parent_tool_use_id, + }) +} + fn process_json_line( line: &str, app: &AppHandle, @@ -698,7 +756,7 @@ fn process_json_line( } } - ClaudeMessage::Assistant { message, .. } => { + ClaudeMessage::Assistant { message, parent_tool_use_id } => { let mut state = CharacterState::Typing; let mut tool_name = None; @@ -790,7 +848,7 @@ fn process_json_line( for block in &message.content { match block { - ContentBlock::ToolUse { name, input, .. } => { + ContentBlock::ToolUse { id, name, input } => { tool_name = Some(name.clone()); state = get_tool_state(name); @@ -807,6 +865,42 @@ fn process_json_line( } } + // Emit agent-start event for Task tool invocations + if name == "Task" { + let description = input + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("Subagent") + .to_string(); + let subagent_type = input + .get("subagent_type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + eprintln!( + "[DEBUG] Emitting agent-start: id={}, desc={}, type={}, parent={:?}", + id, description, subagent_type, parent_tool_use_id + ); + + let _ = app.emit( + "claude:agent-start", + AgentStartEvent { + tool_use_id: id.clone(), + agent_id: None, // Will be updated when SubagentStart hook is received + description, + subagent_type, + started_at: now, + conversation_id: conversation_id.clone(), + parent_tool_use_id: parent_tool_use_id.clone(), + }, + ); + } + let desc = format_tool_description(name, input); let _ = app.emit( "claude:output", @@ -816,6 +910,7 @@ fn process_json_line( tool_name: Some(name.clone()), conversation_id: conversation_id.clone(), cost: None, // Tool use doesn't have separate cost + parent_tool_use_id: parent_tool_use_id.clone(), }, ); } @@ -834,6 +929,7 @@ fn process_json_line( tool_name: None, conversation_id: conversation_id.clone(), cost: message_cost.clone(), // Include cost with assistant text + parent_tool_use_id: parent_tool_use_id.clone(), }, ); } @@ -847,10 +943,34 @@ fn process_json_line( tool_name: None, conversation_id: conversation_id.clone(), cost: None, + parent_tool_use_id: parent_tool_use_id.clone(), + }, + ); + } + ContentBlock::ToolResult { + tool_use_id, + is_error, + .. + } => { + // Emit agent-end for all tool results + // The frontend will ignore IDs that don't match known agents + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let _ = app.emit( + "claude:agent-end", + AgentEndEvent { + tool_use_id: tool_use_id.clone(), + ended_at: now, + is_error: is_error.unwrap_or(false), + conversation_id: conversation_id.clone(), + duration_ms: None, + num_turns: None, }, ); } - _ => {} } } @@ -905,7 +1025,8 @@ fn process_json_line( result, permission_denials, usage, - .. + duration_ms, + num_turns, } => { let state = if subtype == "success" { CharacterState::Success @@ -913,6 +1034,14 @@ fn process_json_line( CharacterState::Error }; + // Log turn metrics if available + if let Some(duration) = duration_ms { + println!("Turn completed in {}ms", duration); + } + if let Some(turns) = num_turns { + println!("Turn count: {}", turns); + } + // Track token usage from Result messages if available // This captures tokens from tool outputs and other operations if let Some(usage_info) = usage { @@ -1035,6 +1164,7 @@ fn process_json_line( tool_name: None, conversation_id: conversation_id.clone(), cost: None, + parent_tool_use_id: None, }, ); } @@ -1140,6 +1270,33 @@ fn process_json_line( // Increment message count for user messages stats.write().increment_messages(); + // Process content blocks for tool results (e.g., background Task agent completions) + for block in &message.content { + if let ContentBlock::ToolResult { + tool_use_id, + is_error, + .. + } = block + { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let _ = app.emit( + "claude:agent-end", + AgentEndEvent { + tool_use_id: tool_use_id.clone(), + ended_at: now, + is_error: is_error.unwrap_or(false), + conversation_id: conversation_id.clone(), + duration_ms: None, + num_turns: None, + }, + ); + } + } + // Extract text content from the message let message_text = message .content diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index acd8034..a1e4147 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -56,6 +56,10 @@ async function changeDirectory(path: string): Promise { conversationId, options: { working_dir: validatedPath, + model: config.model || null, + api_key: config.api_key || null, + custom_instructions: config.custom_instructions || null, + mcp_servers_json: config.mcp_servers_json || null, allowed_tools: allAllowedTools, }, }); @@ -126,6 +130,10 @@ async function startNewConversation(): Promise { conversationId, options: { working_dir: workingDir, + model: config.model || null, + api_key: config.api_key || null, + custom_instructions: config.custom_instructions || null, + mcp_servers_json: config.mcp_servers_json || null, allowed_tools: allAllowedTools, }, }); diff --git a/src/lib/components/AgentMonitorPanel.svelte b/src/lib/components/AgentMonitorPanel.svelte new file mode 100644 index 0000000..9c3dde7 --- /dev/null +++ b/src/lib/components/AgentMonitorPanel.svelte @@ -0,0 +1,328 @@ + + +{#if isOpen} + + +
+ +
+ +
+
+ + + +

Agent Monitor

+ {#if runningAgents.length > 0} + + {runningAgents.length} running + + {/if} +
+ +
+ + +
+ + +
+ + +
+ {#if agents.length === 0} +
+ + + +

No agents detected yet

+

+ Agents will appear here when Claude uses the Task tool +

+
+ {:else} + {#each flattenedAgents as { agent, depth } (agent.toolUseId)} +
+ +
+
+ {#if depth > 0} + + + + {/if} + + {getSubagentTypeLabel(agent.subagentType)} + +
+ + {#if agent.durationMs !== undefined} + {Math.floor(agent.durationMs / 1000)}s + {:else} + {formatDuration(agent.startedAt, agent.endedAt)} + {/if} + {#if agent.status === "running"} + + {/if} + +
+ + +

+ {agent.description} +

+ + +
+ {#if agent.status === "running"} + Running... + {:else if agent.status === "completed"} + Completed + {:else} + Errored / Killed + {/if} +
+
+ {/each} + {/if} +
+ + + {#if agents.length > 0} +
+ {agents.length} total · + {runningAgents.length} running · + {completedAgents.length} completed · + {erroredAgents.length} errored +
+ {/if} +
+{/if} diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index e50bc79..0e57896 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -355,6 +355,10 @@ User: ${formattedMessage}`; conversationId, options: { working_dir: workingDir, + model: config.model || null, + api_key: config.api_key || null, + custom_instructions: config.custom_instructions || null, + mcp_servers_json: config.mcp_servers_json || null, allowed_tools: allAllowedTools, }, }); diff --git a/src/lib/components/PermissionModal.svelte b/src/lib/components/PermissionModal.svelte index c41ffe8..4e0e06a 100644 --- a/src/lib/components/PermissionModal.svelte +++ b/src/lib/components/PermissionModal.svelte @@ -57,16 +57,20 @@ // Small delay to ensure clean shutdown await new Promise((resolve) => setTimeout(resolve, 500)); + const config = configStore.getConfig(); await invoke("start_claude", { conversationId, options: { working_dir: workingDirectory || "/home/naomi", + model: config.model || null, + api_key: config.api_key || null, + custom_instructions: config.custom_instructions || null, + mcp_servers_json: config.mcp_servers_json || null, allowed_tools: newGrantedTools, }, }); // Update Discord RPC when reconnecting after permission grant - const config = configStore.getConfig(); const activeConversation = get(conversationsStore.activeConversation); if (activeConversation) { await updateDiscordRpc( diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 69d9fed..2e6451f 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -21,9 +21,11 @@ import HelpPanel from "./HelpPanel.svelte"; import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte"; import { achievementProgress } from "$lib/stores/achievements"; + import { runningAgentCount } from "$lib/stores/agents"; import SessionHistoryPanel from "./SessionHistoryPanel.svelte"; import GitPanel from "./GitPanel.svelte"; import ProfilePanel from "./ProfilePanel.svelte"; + import AgentMonitorPanel from "./AgentMonitorPanel.svelte"; import { conversationsStore } from "$lib/stores/conversations"; import { generateContextInjection, @@ -48,8 +50,10 @@ let showSessionHistory = $state(false); let showGitPanel = $state(false); let showProfile = $state(false); + let showAgentMonitor = $state(false); let isSummarising = $state(false); const progress = $derived($achievementProgress); + const activeAgentCount = $derived($runningAgentCount); let currentConfig: HikariConfig = $state({ model: null, api_key: null, @@ -466,6 +470,29 @@ /> +