generated from nhcarrigan/template
feat: add feature to monitor background agents #125
@@ -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
|
||||
|
||||
@@ -56,6 +56,10 @@ async function changeDirectory(path: string): Promise<void> {
|
||||
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<void> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
<script lang="ts">
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { agentStore, getAgentsForConversation } from "$lib/stores/agents";
|
||||
import type { AgentInfo } from "$lib/types/agents";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { isOpen, onClose }: Props = $props();
|
||||
|
||||
let now = $state(Date.now());
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// We need a reactive subscription to agents for the active conversation
|
||||
let agents: AgentInfo[] = $state([]);
|
||||
let agentsUnsubscribe: (() => void) | null = null;
|
||||
|
||||
// Track active conversation reactively
|
||||
let currentConversationId = $state<string | null>("");
|
||||
const conversationIdUnsubscribe = claudeStore.activeConversationId.subscribe((id) => {
|
||||
currentConversationId = id;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Re-subscribe when conversation changes
|
||||
if (agentsUnsubscribe) {
|
||||
agentsUnsubscribe();
|
||||
}
|
||||
if (currentConversationId) {
|
||||
const store = getAgentsForConversation(currentConversationId);
|
||||
agentsUnsubscribe = store.subscribe((value) => {
|
||||
agents = value;
|
||||
});
|
||||
} else {
|
||||
agents = [];
|
||||
}
|
||||
});
|
||||
|
||||
const runningAgents = $derived(agents.filter((a) => a.status === "running"));
|
||||
const completedAgents = $derived(agents.filter((a) => a.status === "completed"));
|
||||
const erroredAgents = $derived(agents.filter((a) => a.status === "errored"));
|
||||
|
||||
// Organize agents into a tree structure based on parent_tool_use_id
|
||||
const agentTree = $derived.by(() => {
|
||||
const topLevel = agents.filter((a) => !a.parentToolUseId);
|
||||
const childrenMap = new SvelteMap<string, AgentInfo[]>();
|
||||
|
||||
// Group children by their parent
|
||||
agents.forEach((agent) => {
|
||||
if (agent.parentToolUseId) {
|
||||
const siblings = childrenMap.get(agent.parentToolUseId) || [];
|
||||
siblings.push(agent);
|
||||
childrenMap.set(agent.parentToolUseId, siblings);
|
||||
}
|
||||
});
|
||||
|
||||
return { topLevel, childrenMap };
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
timerInterval = setInterval(() => {
|
||||
now = Date.now();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
if (agentsUnsubscribe) agentsUnsubscribe();
|
||||
conversationIdUnsubscribe();
|
||||
});
|
||||
|
||||
function formatDuration(startedAt: number, endedAt?: number): string {
|
||||
const end = endedAt || now;
|
||||
const durationMs = end - startedAt;
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function getSubagentTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
Explore: "Explorer",
|
||||
"general-purpose": "General",
|
||||
Plan: "Planner",
|
||||
Bash: "Shell",
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
|
||||
case "completed":
|
||||
return "bg-green-500/20 text-green-400 border-green-500/30";
|
||||
case "errored":
|
||||
return "bg-red-500/20 text-red-400 border-red-500/30";
|
||||
default:
|
||||
return "bg-gray-500/20 text-gray-400 border-gray-500/30";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKillAll() {
|
||||
if (!currentConversationId) return;
|
||||
|
||||
try {
|
||||
await invoke("interrupt_claude", { conversationId: currentConversationId });
|
||||
} catch (error) {
|
||||
console.error("Failed to kill Claude process:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClearCompleted() {
|
||||
if (currentConversationId) {
|
||||
agentStore.clearCompleted(currentConversationId);
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten the tree for rendering with depth information
|
||||
const flattenedAgents = $derived.by(() => {
|
||||
const result: { agent: AgentInfo; depth: number }[] = [];
|
||||
const { topLevel, childrenMap } = agentTree;
|
||||
|
||||
function addAgentAndChildren(agent: AgentInfo, depth: number) {
|
||||
result.push({ agent, depth });
|
||||
const children = childrenMap.get(agent.toolUseId);
|
||||
if (children) {
|
||||
children.forEach((child) => addAgentAndChildren(child, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
topLevel.forEach((agent) => addAgentAndChildren(agent, 0));
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40" onclick={onClose}></div>
|
||||
|
||||
<div
|
||||
class="fixed top-12 right-0 bottom-0 w-80 bg-[var(--bg-primary)] border-l border-[var(--border-color)] shadow-xl z-50 flex flex-col overflow-hidden"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--accent-primary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)]">Agent Monitor</h3>
|
||||
{#if runningAgents.length > 0}
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400 animate-pulse"
|
||||
>
|
||||
{runningAgents.length} running
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close agent monitor"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-2 px-4 py-2 border-b border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={handleKillAll}
|
||||
disabled={runningAgents.length === 0}
|
||||
class="flex-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Kills the entire Claude Code process to stop all agents"
|
||||
>
|
||||
Kill All
|
||||
</button>
|
||||
<button
|
||||
onclick={handleClearCompleted}
|
||||
disabled={completedAgents.length === 0 && erroredAgents.length === 0}
|
||||
class="flex-1 px-2 py-1 text-xs bg-[var(--bg-secondary)] hover:bg-[var(--bg-hover,var(--bg-secondary))] text-[var(--text-secondary)] rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Clear Finished
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Agent list -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{#if agents.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)] text-sm"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 mb-2 opacity-50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
<p>No agents detected yet</p>
|
||||
<p class="text-xs mt-1 opacity-70">
|
||||
Agents will appear here when Claude uses the Task tool
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each flattenedAgents as { agent, depth } (agent.toolUseId)}
|
||||
<div
|
||||
class="p-3 rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] {agent.status ===
|
||||
'running'
|
||||
? 'border-l-2 border-l-blue-500'
|
||||
: agent.status === 'errored'
|
||||
? 'border-l-2 border-l-red-500'
|
||||
: 'border-l-2 border-l-green-500'}"
|
||||
style="margin-left: {depth * 12}px; width: calc(100% - {depth * 12}px);"
|
||||
>
|
||||
<!-- Agent header -->
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if depth > 0}
|
||||
<svg
|
||||
class="w-3 h-3 text-[var(--text-secondary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
||||
agent.status
|
||||
)}"
|
||||
>
|
||||
{getSubagentTypeLabel(agent.subagentType)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] {agent.status === 'running'
|
||||
? 'text-blue-400'
|
||||
: 'text-[var(--text-secondary)]'}"
|
||||
>
|
||||
{#if agent.durationMs !== undefined}
|
||||
{Math.floor(agent.durationMs / 1000)}s
|
||||
{:else}
|
||||
{formatDuration(agent.startedAt, agent.endedAt)}
|
||||
{/if}
|
||||
{#if agent.status === "running"}
|
||||
<span class="inline-block w-1 h-1 bg-blue-400 rounded-full animate-pulse ml-1"
|
||||
></span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Agent description -->
|
||||
<p class="text-xs text-[var(--text-primary)] truncate" title={agent.description}>
|
||||
{agent.description}
|
||||
</p>
|
||||
|
||||
<!-- Status indicator -->
|
||||
<div class="mt-1 flex items-center gap-1">
|
||||
{#if agent.status === "running"}
|
||||
<span class="text-[10px] text-blue-400">Running...</span>
|
||||
{:else if agent.status === "completed"}
|
||||
<span class="text-[10px] text-green-400">Completed</span>
|
||||
{:else}
|
||||
<span class="text-[10px] text-red-400">Errored / Killed</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer summary -->
|
||||
{#if agents.length > 0}
|
||||
<div
|
||||
class="px-4 py-2 border-t border-[var(--border-color)] text-[10px] text-[var(--text-secondary)]"
|
||||
>
|
||||
{agents.length} total ·
|
||||
{runningAgents.length} running ·
|
||||
{completedAgents.length} completed ·
|
||||
{erroredAgents.length} errored
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showAgentMonitor = !showAgentMonitor)}
|
||||
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
|
||||
? 'text-[var(--trans-pink)]'
|
||||
: ''}"
|
||||
title="Agent Monitor"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
{#if activeAgentCount > 0}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 bg-blue-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px] animate-pulse"
|
||||
>
|
||||
{activeAgentCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showStats = !showStats)}
|
||||
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
||||
@@ -638,3 +665,7 @@
|
||||
{#if showProfile}
|
||||
<ProfilePanel onClose={() => (showProfile = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showAgentMonitor}
|
||||
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -209,8 +209,30 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#each lines as line (line.id)}
|
||||
<div class="terminal-line mb-2 {getLineClass(line.type)} relative group">
|
||||
<div
|
||||
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
||||
style={line.parentToolUseId
|
||||
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
||||
: ""}
|
||||
>
|
||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||
{#if line.parentToolUseId}
|
||||
<span class="text-xs mr-2 opacity-60" title="Message from subagent">
|
||||
<svg
|
||||
class="inline-block w-3 h-3 -mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
{#if line.cost && line.cost.costUsd > 0}
|
||||
<span
|
||||
class="terminal-cost text-xs mr-2"
|
||||
|
||||
@@ -96,16 +96,20 @@
|
||||
|
||||
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: grantedToolsList,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting after answering question
|
||||
const config = configStore.getConfig();
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { AgentInfo } from "$lib/types/agents";
|
||||
|
||||
// Map of conversation ID -> agents in that conversation
|
||||
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
|
||||
|
||||
function createAgentStore() {
|
||||
return {
|
||||
subscribe: agentsByConversation.subscribe,
|
||||
|
||||
addAgent(conversationId: string, agent: AgentInfo) {
|
||||
agentsByConversation.update((state) => {
|
||||
const existing = state[conversationId] || [];
|
||||
return {
|
||||
...state,
|
||||
[conversationId]: [...existing, agent],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
updateAgentId(conversationId: string, toolUseId: string, agentId: string) {
|
||||
agentsByConversation.update((state) => {
|
||||
const agents = state[conversationId];
|
||||
if (!agents) return state;
|
||||
|
||||
const agentIndex = agents.findIndex((a) => a.toolUseId === toolUseId);
|
||||
if (agentIndex === -1) return state;
|
||||
|
||||
const updated = [...agents];
|
||||
updated[agentIndex] = {
|
||||
...updated[agentIndex],
|
||||
agentId,
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
[conversationId]: updated,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
endAgent(conversationId: string, toolUseId: string, endedAt: number, isError: boolean) {
|
||||
agentsByConversation.update((state) => {
|
||||
const agents = state[conversationId];
|
||||
if (!agents) return state;
|
||||
|
||||
const agentIndex = agents.findIndex((a) => a.toolUseId === toolUseId);
|
||||
if (agentIndex === -1) return state;
|
||||
|
||||
const updated = [...agents];
|
||||
const agent = updated[agentIndex];
|
||||
const durationMs = endedAt - agent.startedAt;
|
||||
|
||||
updated[agentIndex] = {
|
||||
...agent,
|
||||
endedAt,
|
||||
status: isError ? "errored" : "completed",
|
||||
durationMs,
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
[conversationId]: updated,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
markAllErrored(conversationId: string) {
|
||||
agentsByConversation.update((state) => {
|
||||
const agents = state[conversationId];
|
||||
if (!agents) return state;
|
||||
|
||||
const now = Date.now();
|
||||
const updated = agents.map((agent) =>
|
||||
agent.status === "running"
|
||||
? { ...agent, endedAt: now, status: "errored" as const }
|
||||
: agent
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
[conversationId]: updated,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearCompleted(conversationId: string) {
|
||||
agentsByConversation.update((state) => {
|
||||
const agents = state[conversationId];
|
||||
if (!agents) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
[conversationId]: agents.filter((a) => a.status === "running"),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearConversation(conversationId: string) {
|
||||
agentsByConversation.update((state) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Unused destructured value
|
||||
const { [conversationId]: _, ...rest } = state;
|
||||
return rest;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const agentStore = createAgentStore();
|
||||
|
||||
export function getAgentsForConversation(conversationId: string) {
|
||||
return derived(agentsByConversation, ($state) => $state[conversationId] || []);
|
||||
}
|
||||
|
||||
export const runningAgentCount = derived(agentsByConversation, ($state) => {
|
||||
let count = 0;
|
||||
for (const agents of Object.values($state)) {
|
||||
count += agents.filter((a) => a.status === "running").length;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
@@ -435,7 +435,8 @@ function createConversationsStore() {
|
||||
type: TerminalLine["type"],
|
||||
content: string,
|
||||
toolName?: string,
|
||||
cost?: TerminalLine["cost"]
|
||||
cost?: TerminalLine["cost"],
|
||||
parentToolUseId?: string
|
||||
) => {
|
||||
ensureInitialized();
|
||||
const activeId = get(activeConversationId);
|
||||
@@ -448,6 +449,7 @@ function createConversationsStore() {
|
||||
timestamp: new Date(),
|
||||
toolName,
|
||||
cost,
|
||||
parentToolUseId,
|
||||
};
|
||||
|
||||
conversations.update((convs) => {
|
||||
@@ -469,7 +471,8 @@ function createConversationsStore() {
|
||||
type: TerminalLine["type"],
|
||||
content: string,
|
||||
toolName?: string,
|
||||
cost?: TerminalLine["cost"]
|
||||
cost?: TerminalLine["cost"],
|
||||
parentToolUseId?: string
|
||||
) => {
|
||||
ensureInitialized();
|
||||
|
||||
@@ -480,6 +483,7 @@ function createConversationsStore() {
|
||||
timestamp: new Date(),
|
||||
toolName,
|
||||
cost,
|
||||
parentToolUseId,
|
||||
};
|
||||
|
||||
conversations.update((convs) => {
|
||||
|
||||
+59
-3
@@ -12,6 +12,8 @@ import type {
|
||||
UserQuestionEvent,
|
||||
} from "$lib/types/messages";
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
import type { AgentStartPayload, AgentEndPayload } from "$lib/types/agents";
|
||||
import { agentStore } from "$lib/stores/agents";
|
||||
import {
|
||||
initializeNotificationRules,
|
||||
cleanupNotificationRules,
|
||||
@@ -95,6 +97,7 @@ interface OutputPayload {
|
||||
output_tokens: number;
|
||||
cost_usd: number;
|
||||
};
|
||||
parent_tool_use_id?: string;
|
||||
}
|
||||
|
||||
interface ConnectionPayload {
|
||||
@@ -175,6 +178,12 @@ export async function initializeTauriListeners() {
|
||||
} else if (status === "disconnected") {
|
||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||
|
||||
// Mark all running agents as errored on disconnect, but not during reconnects
|
||||
// (permission prompts trigger reconnects and agents may complete before reconnect)
|
||||
if (!skipNextGreeting && targetConversationId) {
|
||||
agentStore.markAllErrored(targetConversationId);
|
||||
}
|
||||
|
||||
// Only remove from connected set if we're not about to reconnect
|
||||
if (!skipNextGreeting && targetConversationId) {
|
||||
connectedConversations.delete(targetConversationId);
|
||||
@@ -247,7 +256,8 @@ export async function initializeTauriListeners() {
|
||||
unlisteners.push(stateUnlisten);
|
||||
|
||||
const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => {
|
||||
const { line_type, content, tool_name, conversation_id, cost } = event.payload;
|
||||
const { line_type, content, tool_name, conversation_id, cost, parent_tool_use_id } =
|
||||
event.payload;
|
||||
|
||||
// Convert snake_case cost to camelCase for TypeScript
|
||||
const costData = cost
|
||||
@@ -265,7 +275,8 @@ export async function initializeTauriListeners() {
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||
content,
|
||||
tool_name || undefined,
|
||||
costData
|
||||
costData,
|
||||
parent_tool_use_id
|
||||
);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id provided
|
||||
@@ -273,7 +284,8 @@ export async function initializeTauriListeners() {
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||
content,
|
||||
tool_name || undefined,
|
||||
costData
|
||||
costData,
|
||||
parent_tool_use_id
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -345,6 +357,50 @@ export async function initializeTauriListeners() {
|
||||
});
|
||||
unlisteners.push(permissionUnlisten);
|
||||
|
||||
const agentStartUnlisten = await listen<AgentStartPayload>("claude:agent-start", (event) => {
|
||||
const {
|
||||
tool_use_id,
|
||||
agent_id,
|
||||
description,
|
||||
subagent_type,
|
||||
started_at,
|
||||
conversation_id,
|
||||
parent_tool_use_id,
|
||||
} = event.payload;
|
||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||
if (targetConversationId) {
|
||||
agentStore.addAgent(targetConversationId, {
|
||||
toolUseId: tool_use_id,
|
||||
agentId: agent_id,
|
||||
description,
|
||||
subagentType: subagent_type,
|
||||
startedAt: started_at,
|
||||
status: "running",
|
||||
parentToolUseId: parent_tool_use_id,
|
||||
});
|
||||
}
|
||||
});
|
||||
unlisteners.push(agentStartUnlisten);
|
||||
|
||||
const agentUpdateUnlisten = await listen<{
|
||||
conversationId: string;
|
||||
toolUseId: string;
|
||||
agentId: string;
|
||||
}>("claude:agent-update", (event) => {
|
||||
const { conversationId, toolUseId, agentId } = event.payload;
|
||||
agentStore.updateAgentId(conversationId, toolUseId, agentId);
|
||||
});
|
||||
unlisteners.push(agentUpdateUnlisten);
|
||||
|
||||
const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => {
|
||||
const { tool_use_id, ended_at, is_error, conversation_id } = event.payload;
|
||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||
if (targetConversationId) {
|
||||
agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error);
|
||||
}
|
||||
});
|
||||
unlisteners.push(agentEndUnlisten);
|
||||
|
||||
const questionUnlisten = await listen<UserQuestionEvent>("claude:question", (event) => {
|
||||
const questionEvent = event.payload;
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export type AgentStatus = "running" | "completed" | "errored";
|
||||
|
||||
export interface AgentInfo {
|
||||
toolUseId: string;
|
||||
agentId?: string;
|
||||
description: string;
|
||||
subagentType: string;
|
||||
startedAt: number;
|
||||
endedAt?: number;
|
||||
status: AgentStatus;
|
||||
parentToolUseId?: string;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface AgentStartPayload {
|
||||
tool_use_id: string;
|
||||
agent_id?: string;
|
||||
description: string;
|
||||
subagent_type: string;
|
||||
started_at: number;
|
||||
conversation_id?: string;
|
||||
parent_tool_use_id?: string;
|
||||
}
|
||||
|
||||
export interface AgentEndPayload {
|
||||
tool_use_id: string;
|
||||
ended_at: number;
|
||||
is_error: boolean;
|
||||
conversation_id?: string;
|
||||
duration_ms?: number;
|
||||
num_turns?: number;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export interface TerminalLine {
|
||||
outputTokens: number;
|
||||
costUsd: number;
|
||||
};
|
||||
// Indicates if this message is from a subagent
|
||||
parentToolUseId?: string;
|
||||
}
|
||||
|
||||
export interface SystemInitMessage {
|
||||
|
||||
Reference in New Issue
Block a user