generated from nhcarrigan/template
feat: add feature to monitor background agents (#125)
Also includes a fix to persist configuration across reconnects. Reviewed-on: #125 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #125.
This commit is contained in:
@@ -197,6 +197,8 @@ pub struct OutputEvent {
|
||||
pub conversation_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<MessageCost>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_tool_use_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -248,6 +250,33 @@ pub struct UserQuestionEvent {
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentStartEvent {
|
||||
pub tool_use_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_id: Option<String>,
|
||||
pub description: String,
|
||||
pub subagent_type: String,
|
||||
pub started_at: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_tool_use_id: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub num_turns: Option<u32>,
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
+164
-7
@@ -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<String>,
|
||||
}
|
||||
|
||||
fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user