Compare commits

..

3 Commits

Author SHA1 Message Date
naomi 97a93c31c2 feat: add feature to monitor background agents (#125)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m7s
CI / Lint & Test (push) Successful in 20m11s
CI / Build Linux (push) Successful in 21m51s
CI / Build Windows (cross-compile) (push) Successful in 32m8s
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>
2026-02-06 18:11:18 -08:00
naomi 3e7cb7ef60 feat: opus 4.6 woooo (#111)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Lint & Test (push) Successful in 16m22s
CI / Build Linux (push) Successful in 20m23s
CI / Build Windows (cross-compile) (push) Successful in 30m25s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #111
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-06 15:24:52 -08:00
naomi 136f95cd1a fix: ensure permission/stats persist until explicit disconnect (#110)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint & Test (push) Successful in 16m1s
CI / Build Linux (push) Successful in 20m27s
CI / Build Windows (cross-compile) (push) Successful in 32m18s
Also includes cached tokens in cost calculations to provide more accurate billing estimates.

Reviewed-on: #110
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-06 13:54:31 -08:00
21 changed files with 1185 additions and 102 deletions
+1 -1
View File
@@ -1636,7 +1636,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hikari-desktop"
version = "1.2.0"
version = "1.3.0"
dependencies = [
"chrono",
"dirs 5.0.1",
+4
View File
@@ -2329,6 +2329,10 @@ mod tests {
context_utilisation_percent: 0.0,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
current_request_input: None,
current_request_output_chars: 0,
current_request_thinking_chars: 0,
current_request_tools: Vec::new(),
achievements: AchievementProgress::new(),
}
}
+77 -23
View File
@@ -86,6 +86,8 @@ impl ContextWarning {
/// Get the context window limit (in tokens) for a given model
fn get_context_window_limit(model: &str) -> u64 {
match model {
// Claude 4.6 family - 200K standard (1M beta available via header)
"claude-opus-4-6" => 200_000,
// Claude 4.5 family - 200K standard context
"claude-opus-4-5-20251101"
| "claude-sonnet-4-5-20250929"
@@ -154,6 +156,16 @@ pub struct UsageStats {
// Achievement tracking
#[serde(skip)]
pub achievements: AchievementProgress,
// Track current in-flight request for cost estimation on interrupt
#[serde(skip)]
pub current_request_input: Option<String>,
#[serde(skip)]
pub current_request_output_chars: u64,
#[serde(skip)]
pub current_request_thinking_chars: u64,
#[serde(skip)]
pub current_request_tools: Vec<String>,
}
impl UsageStats {
@@ -163,13 +175,26 @@ impl UsageStats {
stats
}
pub fn add_usage(&mut self, input_tokens: u64, output_tokens: u64, model: &str) {
pub fn add_usage(
&mut self,
input_tokens: u64,
output_tokens: u64,
model: &str,
cache_creation_tokens: Option<u64>,
cache_read_tokens: Option<u64>,
) {
self.total_input_tokens += input_tokens;
self.total_output_tokens += output_tokens;
self.session_input_tokens += input_tokens;
self.session_output_tokens += output_tokens;
let cost = calculate_cost(input_tokens, output_tokens, model);
let cost = calculate_cost(
input_tokens,
output_tokens,
model,
cache_creation_tokens,
cache_read_tokens,
);
self.total_cost_usd += cost;
self.session_cost_usd += cost;
@@ -439,6 +464,10 @@ impl UsageStats {
potential_cache_hits: self.potential_cache_hits,
potential_cache_savings_tokens: self.potential_cache_savings_tokens,
achievements: AchievementProgress::new(), // Dummy for copy
current_request_input: None, // Don't copy tracking fields
current_request_output_chars: 0,
current_request_thinking_chars: 0,
current_request_tools: Vec::new(),
};
check_achievements(&stats_copy, &mut self.achievements)
}
@@ -462,9 +491,19 @@ fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
// Pricing as of February 2026
// https://platform.claude.com/docs/en/about-claude/models/overview
pub fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
// Cache pricing: https://platform.claude.com/docs/en/build-with-claude/prompt-caching
pub fn calculate_cost(
input_tokens: u64,
output_tokens: u64,
model: &str,
cache_creation_tokens: Option<u64>,
cache_read_tokens: Option<u64>,
) -> f64 {
let (input_price_per_million, output_price_per_million) = match model {
// Current generation (Claude 4.5)
// Current generation (Claude 4.6)
"claude-opus-4-6" => (5.0, 25.0),
// Previous generation (Claude 4.5)
"claude-opus-4-5-20251101" => (5.0, 25.0),
"claude-sonnet-4-5-20250929" => (3.0, 15.0),
"claude-haiku-4-5-20251001" => (1.0, 5.0),
@@ -487,10 +526,25 @@ pub fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64
_ => (3.0, 15.0),
};
// Regular input/output tokens
let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million;
let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million;
input_cost + output_cost
// Cache write tokens (cache creation) cost 1.25x the base input price
let cache_write_cost = if let Some(cache_creation) = cache_creation_tokens {
(cache_creation as f64 / 1_000_000.0) * input_price_per_million * 1.25
} else {
0.0
};
// Cache read tokens cost 0.1x (10%) the base input price
let cache_read_cost = if let Some(cache_read) = cache_read_tokens {
(cache_read as f64 / 1_000_000.0) * input_price_per_million * 0.1
} else {
0.0
};
input_cost + output_cost + cache_write_cost + cache_read_cost
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -609,7 +663,7 @@ mod tests {
#[test]
fn test_cost_calculation_sonnet() {
let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514");
let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514", None, None);
// 1000 input * $3/M = $0.003
// 2000 output * $15/M = $0.030
// Total = $0.033
@@ -618,7 +672,7 @@ mod tests {
#[test]
fn test_cost_calculation_opus() {
let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514");
let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514", None, None);
// 1000 input * $15/M = $0.015
// 2000 output * $75/M = $0.150
// Total = $0.165
@@ -627,7 +681,7 @@ mod tests {
#[test]
fn test_cost_calculation_opus_45() {
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101");
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101", None, None);
// Opus 4.5 pricing: $5/MTok input, $25/MTok output
// 1000 input tokens = $0.005, 2000 output tokens = $0.05
// Total = $0.055
@@ -636,7 +690,7 @@ mod tests {
#[test]
fn test_cost_calculation_haiku() {
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022");
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022", None, None);
// 1000 input * $1/M = $0.001
// 2000 output * $5/M = $0.010
// Total = $0.011
@@ -645,14 +699,14 @@ mod tests {
#[test]
fn test_cost_calculation_unknown_defaults_to_sonnet() {
let cost = calculate_cost(1000, 2000, "some-unknown-model");
let cost = calculate_cost(1000, 2000, "some-unknown-model", None, None);
// Should default to Sonnet pricing
assert!((cost - 0.033).abs() < 0.0001);
}
#[test]
fn test_cost_calculation_legacy_sonnet() {
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022");
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022", None, None);
// Same as Sonnet 4 pricing
assert!((cost - 0.033).abs() < 0.0001);
}
@@ -660,7 +714,7 @@ mod tests {
#[test]
fn test_usage_stats_accumulation() {
let mut stats = UsageStats::new();
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
assert_eq!(stats.total_input_tokens, 1000);
assert_eq!(stats.total_output_tokens, 2000);
@@ -672,8 +726,8 @@ mod tests {
#[test]
fn test_usage_stats_multiple_accumulations() {
let mut stats = UsageStats::new();
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
stats.add_usage(500, 500, "claude-sonnet-4-20250514");
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514", None, None);
stats.add_usage(500, 500, "claude-sonnet-4-20250514", None, None);
assert_eq!(stats.total_input_tokens, 1500);
assert_eq!(stats.total_output_tokens, 1500);
@@ -684,17 +738,17 @@ mod tests {
#[test]
fn test_usage_stats_model_updated() {
let mut stats = UsageStats::new();
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514", None, None);
assert_eq!(stats.model, Some("claude-sonnet-4-20250514".to_string()));
stats.add_usage(500, 500, "claude-opus-4-20250514");
stats.add_usage(500, 500, "claude-opus-4-20250514", None, None);
assert_eq!(stats.model, Some("claude-opus-4-20250514".to_string()));
}
#[test]
fn test_session_reset() {
let mut stats = UsageStats::new();
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
stats.reset_session();
assert_eq!(stats.total_input_tokens, 1000);
@@ -921,7 +975,7 @@ mod tests {
#[test]
fn test_usage_stats_serialization() {
let mut stats = UsageStats::new();
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
stats.increment_messages();
// UsageStats should be serializable (for events)
@@ -950,7 +1004,7 @@ mod tests {
#[test]
fn test_stats_update_event_serialization() {
let mut stats = UsageStats::new();
stats.add_usage(100, 200, "claude-sonnet-4-20250514");
stats.add_usage(100, 200, "claude-sonnet-4-20250514", None, None);
let event = StatsUpdateEvent { stats };
let json = serde_json::to_string(&event).expect("Failed to serialize");
@@ -1004,7 +1058,7 @@ mod tests {
#[test]
fn test_context_tracking_update() {
let mut stats = UsageStats::new();
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514");
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
assert_eq!(stats.context_tokens_used, 50_000);
assert_eq!(stats.context_window_limit, 200_000);
@@ -1014,8 +1068,8 @@ mod tests {
#[test]
fn test_context_tracking_accumulates() {
let mut stats = UsageStats::new();
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514");
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514");
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
assert_eq!(stats.context_tokens_used, 100_000);
assert!((stats.context_utilisation_percent - 50.0).abs() < 0.1);
@@ -1079,7 +1133,7 @@ mod tests {
#[test]
fn test_context_reset_on_session_reset() {
let mut stats = UsageStats::new();
stats.add_usage(100_000, 20_000, "claude-sonnet-4-20250514");
stats.add_usage(100_000, 20_000, "claude-sonnet-4-20250514", None, None);
assert!(stats.context_tokens_used > 0);
assert!(stats.context_utilisation_percent > 0.0);
+35
View File
@@ -4,6 +4,10 @@ use serde::{Deserialize, Serialize};
pub struct UsageInfo {
pub input_tokens: u64,
pub output_tokens: u64,
#[serde(default)]
pub cache_creation_input_tokens: Option<u64>,
#[serde(default)]
pub cache_read_input_tokens: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
@@ -193,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)]
@@ -244,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::*;
@@ -365,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();
@@ -384,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();
+385 -47
View File
@@ -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
@@ -355,6 +358,15 @@ impl WslBridge {
pub fn send_message(&mut self, message: &str) -> Result<(), String> {
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
// Track input for cost estimation on interrupt
{
let mut stats = self.stats.write();
stats.current_request_input = Some(message.to_string());
stats.current_request_output_chars = 0;
stats.current_request_thinking_chars = 0;
stats.current_request_tools.clear();
}
let input = serde_json::json!({
"type": "user",
"message": {
@@ -419,6 +431,9 @@ impl WslBridge {
// we have to kill the process. This is the only reliable way to stop it.
// See: https://github.com/anthropics/claude-code/issues/3455
if let Some(mut process) = self.process.take() {
// Estimate cost for interrupted request before killing
self.estimate_interrupted_request_cost(app);
// Kill the process immediately
let _ = process.kill();
let _ = process.wait();
@@ -426,6 +441,15 @@ impl WslBridge {
// Clear stdin
self.stdin = None;
// Clear tracking fields
{
let mut stats = self.stats.write();
stats.current_request_input = None;
stats.current_request_output_chars = 0;
stats.current_request_thinking_chars = 0;
stats.current_request_tools.clear();
}
// Keep session_id and working directory for user reference
// The user will see what session was interrupted
@@ -442,6 +466,99 @@ impl WslBridge {
}
}
fn estimate_interrupted_request_cost(&mut self, app: &AppHandle) {
// Read tracking data from stats
let (input_chars, output_chars, thinking_chars, tools, model) = {
let stats = self.stats.read();
// Only estimate if we have tracked content
if stats.current_request_input.is_none()
&& stats.current_request_output_chars == 0
&& stats.current_request_thinking_chars == 0
&& stats.current_request_tools.is_empty() {
return;
}
let input_chars = stats.current_request_input.as_ref().map(|s| s.len() as u64).unwrap_or(0);
let model = stats.model.clone().unwrap_or_else(|| "claude-sonnet-4-5-20250929".to_string());
(input_chars, stats.current_request_output_chars, stats.current_request_thinking_chars, stats.current_request_tools.clone(), model)
};
println!("[COST ESTIMATION] Estimating cost for interrupted request");
// Use conservative 3.5 chars/token for estimation (vs standard 4)
let estimated_input_tokens = (input_chars as f64 / 3.5).ceil() as u64;
let estimated_output_tokens = ((output_chars as f64 / 3.5).ceil() as u64)
+ ((thinking_chars as f64 / 3.5).ceil() as u64);
// Add tool overhead based on session averages
let mut tool_overhead_tokens = 0u64;
{
let stats = self.stats.read();
for tool_name in &tools {
if let Some(tool_stats) = stats.session_tools_usage.get(tool_name) {
if tool_stats.call_count > 0 {
// Use session average tokens per call for this tool
let avg_tokens = (tool_stats.estimated_input_tokens + tool_stats.estimated_output_tokens)
/ tool_stats.call_count;
tool_overhead_tokens += avg_tokens;
println!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens);
}
}
}
}
let total_estimated_input = estimated_input_tokens + tool_overhead_tokens;
let total_estimated_output = estimated_output_tokens;
// Add 20% safety margin to overestimate
let safety_margin = 1.2;
let conservative_input = (total_estimated_input as f64 * safety_margin).ceil() as u64;
let conservative_output = (total_estimated_output as f64 * safety_margin).ceil() as u64;
println!("[COST ESTIMATION] Input: {} chars → {} tokens (+ {} tool overhead) × 1.2 safety = {} tokens",
input_chars, estimated_input_tokens, tool_overhead_tokens, conservative_input);
println!("[COST ESTIMATION] Output: {} chars → {} tokens × 1.2 safety = {} tokens",
output_chars + thinking_chars,
estimated_output_tokens, conservative_output);
// Calculate cost (no cache tokens for interrupted requests)
let estimated_cost = calculate_cost(
conservative_input,
conservative_output,
&model,
None,
None,
);
println!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost);
// Add to stats with estimated flag
{
let mut stats_guard = self.stats.write();
stats_guard.add_usage(
conservative_input,
conservative_output,
&model,
None,
None,
);
}
// Emit stats update
let stats_update_event = StatsUpdateEvent {
stats: self.stats.read().clone(),
};
let _ = app.emit("claude:stats", stats_update_event);
// Record to historical cost tracking (mark as estimated)
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
record_cost(&app_clone, conservative_input, conservative_output, estimated_cost).await;
});
}
pub fn stop(&mut self, app: &AppHandle) {
if let Some(mut process) = self.process.take() {
let _ = process.kill();
@@ -528,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 {
@@ -536,6 +672,7 @@ fn handle_stderr(
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
@@ -545,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,
@@ -584,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;
@@ -603,48 +775,72 @@ fn process_json_line(
// Only update stats if we have usage information
if let Some(usage) = &message.usage {
if let Some(model) = &message.model {
// Calculate cost for historical tracking
let cost_usd = calculate_cost(usage.input_tokens, usage.output_tokens, model);
// Get model from message, or fall back to last known model from stats
let model = message.model.clone().or_else(|| {
let stats_guard = stats.read();
stats_guard.model.clone()
}).unwrap_or_else(|| {
println!("[WARNING] No model info available for cost calculation, using default");
"claude-sonnet-4-5-20250929".to_string()
});
// Store cost for later use in output events
message_cost = Some(MessageCost {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cost_usd,
});
// Calculate cost for historical tracking (including cache tokens)
let cost_usd = calculate_cost(
usage.input_tokens,
usage.output_tokens,
&model,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
);
// Batch all stats updates in a single write lock
{
let mut stats_guard = stats.write();
stats_guard.increment_messages();
stats_guard.add_usage(usage.input_tokens, usage.output_tokens, model);
stats_guard.get_session_duration();
println!("Assistant message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}, cost: ${:.4}",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
cost_usd
);
// Attribute tokens to tools if any tools were used in this message
if !tools_in_message.is_empty() {
let per_tool_input = usage.input_tokens / tools_in_message.len() as u64;
let per_tool_output = usage.output_tokens / tools_in_message.len() as u64;
for tool in &tools_in_message {
stats_guard.add_tool_tokens(tool, per_tool_input, per_tool_output);
}
// Store cost for later use in output events
message_cost = Some(MessageCost {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cost_usd,
});
// Batch all stats updates in a single write lock
{
let mut stats_guard = stats.write();
stats_guard.increment_messages();
stats_guard.add_usage(
usage.input_tokens,
usage.output_tokens,
&model,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
);
stats_guard.get_session_duration();
// Attribute tokens to tools if any tools were used in this message
if !tools_in_message.is_empty() {
let per_tool_input = usage.input_tokens / tools_in_message.len() as u64;
let per_tool_output = usage.output_tokens / tools_in_message.len() as u64;
for tool in &tools_in_message {
stats_guard.add_tool_tokens(tool, per_tool_input, per_tool_output);
}
}
// Record to historical cost tracking
let app_clone = app.clone();
let input = usage.input_tokens;
let output = usage.output_tokens;
tauri::async_runtime::spawn(async move {
record_cost(&app_clone, input, output, cost_usd).await;
});
// Don't emit here - we'll emit on Result message instead
// This reduces the frequency of updates
} else {
// Just increment message count if no usage info
stats.write().increment_messages();
}
// Record to historical cost tracking
let app_clone = app.clone();
let input = usage.input_tokens;
let output = usage.output_tokens;
tauri::async_runtime::spawn(async move {
record_cost(&app_clone, input, output, cost_usd).await;
});
// Don't emit here - we'll emit on Result message instead
// This reduces the frequency of updates
} else {
// Just increment message count if no usage info
stats.write().increment_messages();
@@ -652,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);
@@ -669,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",
@@ -678,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(),
},
);
}
@@ -696,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(),
},
);
}
@@ -709,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,
},
);
}
_ => {}
}
}
@@ -722,6 +980,14 @@ fn process_json_line(
ClaudeMessage::StreamEvent { event } => {
if event.event_type == "content_block_start" {
if let Some(block) = &event.content_block {
// Track tool calls for cost estimation
if block.block_type == "tool_use" {
if let Some(name) = &block.name {
let mut stats_guard = stats.write();
stats_guard.current_request_tools.push(name.clone());
}
}
let state = match block.block_type.as_str() {
"thinking" => CharacterState::Thinking,
"text" => CharacterState::Typing,
@@ -739,7 +1005,16 @@ fn process_json_line(
} else if event.event_type == "content_block_delta" {
if let Some(delta) = &event.delta {
if let Some(text) = &delta.text {
// Track output characters for cost estimation
{
let mut stats_guard = stats.write();
stats_guard.current_request_output_chars += text.len() as u64;
}
let _ = app.emit("claude:stream", text.clone());
} else if let Some(thinking) = &delta.thinking {
// Track thinking characters for cost estimation
let mut stats_guard = stats.write();
stats_guard.current_request_thinking_chars += thinking.len() as u64;
}
}
}
@@ -750,7 +1025,8 @@ fn process_json_line(
result,
permission_denials,
usage,
..
duration_ms,
num_turns,
} => {
let state = if subtype == "success" {
CharacterState::Success
@@ -758,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 {
@@ -768,12 +1052,29 @@ fn process_json_line(
stats_guard.model.clone().unwrap_or_else(|| "claude-opus-4-20250514".to_string())
};
// Calculate cost for historical tracking
let cost_usd = calculate_cost(usage_info.input_tokens, usage_info.output_tokens, &model);
// Calculate cost for historical tracking (including cache tokens)
let cost_usd = calculate_cost(
usage_info.input_tokens,
usage_info.output_tokens,
&model,
usage_info.cache_creation_input_tokens,
usage_info.cache_read_input_tokens,
);
let mut stats_guard = stats.write();
stats_guard.add_usage(usage_info.input_tokens, usage_info.output_tokens, &model);
println!("Result message tokens - input: {}, output: {}", usage_info.input_tokens, usage_info.output_tokens);
stats_guard.add_usage(
usage_info.input_tokens,
usage_info.output_tokens,
&model,
usage_info.cache_creation_input_tokens,
usage_info.cache_read_input_tokens,
);
println!("Result message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}",
usage_info.input_tokens,
usage_info.output_tokens,
usage_info.cache_creation_input_tokens,
usage_info.cache_read_input_tokens
);
// Record to historical cost tracking
let app_clone = app.clone();
@@ -784,6 +1085,15 @@ fn process_json_line(
});
}
// Clear tracking fields since request completed successfully
{
let mut stats_guard = stats.write();
stats_guard.current_request_input = None;
stats_guard.current_request_output_chars = 0;
stats_guard.current_request_thinking_chars = 0;
stats_guard.current_request_tools.clear();
}
// Always emit updated stats on result message (less frequent)
// This includes the latest session duration
let newly_unlocked = {
@@ -854,6 +1164,7 @@ fn process_json_line(
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
@@ -959,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
+22 -4
View File
@@ -37,6 +37,12 @@ async function changeDirectory(path: string): Promise<void> {
// Capture conversation history before disconnecting
const conversationHistory = claudeStore.getConversationHistory();
// Get currently granted tools and config auto-granted tools
const activeConversation = get(conversationsStore.activeConversation);
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
const config = configStore.getConfig();
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
await invoke("stop_claude", { conversationId });
// Wait for clean shutdown
@@ -50,12 +56,15 @@ 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,
},
});
// Update Discord RPC when reconnecting after directory change
const config = configStore.getConfig();
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
@@ -102,6 +111,12 @@ async function startNewConversation(): Promise<void> {
conversationId,
});
// Get granted tools before interrupting
const activeConversation = get(conversationsStore.activeConversation);
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
const config = configStore.getConfig();
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
claudeStore.addLine("system", "Starting new conversation...");
characterState.setState("thinking");
@@ -115,12 +130,15 @@ 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,
},
});
// Update Discord RPC when starting new conversation
const config = configStore.getConfig();
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
+328
View File
@@ -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 &middot;
{runningAgents.length} running &middot;
{completedAgents.length} completed &middot;
{erroredAgents.length} errored
</div>
{/if}
</div>
{/if}
+4
View File
@@ -5,6 +5,7 @@
import { characterState, characterInfo } from "$lib/stores/character";
import { isStreamerMode } from "$lib/stores/config";
import { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri";
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
interface Props {
@@ -127,6 +128,9 @@
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
// Set flag to preserve stats/permissions (don't treat next connect as new session)
setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Interrupted");
characterState.setState("idle");
+7 -3
View File
@@ -81,16 +81,20 @@
const availableModels = [
{ value: "", label: "Default (from ~/.claude)" },
// Current generation (Claude 4.5)
// Current generation (Claude 4.6)
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
// Previous generation (Claude 4.5)
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" },
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5 (Most Capable)" },
// Previous generation (Claude 4)
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
// Previous generation (Claude 4.x)
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
// Legacy (Claude 3.x)
{ value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" },
{ value: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet (Oct 2024)" },
{ value: "claude-3-5-sonnet-20240620", label: "Claude 3.5 Sonnet (Jun 2024)" },
{ value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" },
];
+13 -4
View File
@@ -338,23 +338,32 @@ User: ${formattedMessage}`;
throw new Error("No active conversation");
}
// Get current working directory before reconnecting
// Get current working directory and granted tools before reconnecting
const workingDir = await invoke<string>("get_working_directory", { conversationId });
const activeConversation = get(conversationsStore.activeConversation);
const grantedTools = activeConversation
? Array.from(activeConversation.grantedTools)
: [];
const config = configStore.getConfig();
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
// Set the flag to skip greeting on next connection
setSkipNextGreeting(true);
// Reconnect to Claude
// Reconnect to Claude with preserved permissions
await invoke("start_claude", {
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,
},
});
// Update Discord RPC when reconnecting
const config = configStore.getConfig();
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
+12 -10
View File
@@ -1,22 +1,17 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import type { PermissionRequest } from "$lib/types/messages";
import { updateDiscordRpc } from "$lib/tauri";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config";
let isVisible = $state(false);
let permission: PermissionRequest | null = $state(null);
let grantedToolsList: string[] = $state([]);
let workingDirectory = $state("");
hasPermissionPending.subscribe((pending) => {
isVisible = pending;
});
claudeStore.pendingPermission.subscribe((perm) => {
permission = perm;
if (perm) {
@@ -54,21 +49,28 @@
throw new Error("No active conversation");
}
// Prevent stats reset on reconnection
setSkipNextGreeting(true);
await invoke("stop_claude", { conversationId });
// 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(
@@ -125,7 +127,7 @@ Please continue where we left off and retry that action now that you have permis
}
function handleKeydown(event: KeyboardEvent) {
if (!isVisible || !permission) return;
if (!permission) return;
if (event.key === "Enter") {
event.preventDefault();
@@ -139,7 +141,7 @@ Please continue where we left off and retry that action now that you have permis
<svelte:window onkeydown={handleKeydown} />
{#if isVisible && permission}
{#if permission}
<div
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
>
+38 -1
View File
@@ -21,16 +21,18 @@
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,
createSummary,
sanitizeForJson,
} from "$lib/utils/conversationUtils";
import { updateDiscordRpc } from "$lib/tauri";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com";
@@ -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,
@@ -190,6 +194,9 @@
throw new Error("No active conversation");
}
await invoke("stop_claude", { conversationId });
// Clear granted permissions when user explicitly disconnects
claudeStore.revokeAllTools();
} catch (error) {
console.error("Failed to stop Claude:", error);
}
@@ -248,6 +255,9 @@
: sanitizedContent;
// Step 1: Disconnect from Claude to reset context
// Prevent stats reset on reconnection
setSkipNextGreeting(true);
if (connectionStatus === "connected") {
await invoke("stop_claude", { conversationId: activeId });
}
@@ -460,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)]' : ''}"
@@ -632,3 +665,7 @@
{#if showProfile}
<ProfilePanel onClose={() => (showProfile = false)} />
{/if}
{#if showAgentMonitor}
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
{/if}
+23 -1
View File
@@ -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"
+9 -2
View File
@@ -5,7 +5,7 @@
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import type { UserQuestionEvent } from "$lib/types/messages";
import { updateDiscordRpc } from "$lib/tauri";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config";
@@ -89,20 +89,27 @@
claudeStore.clearQuestion();
try {
// Prevent stats reset on reconnection
setSkipNextGreeting(true);
await invoke("stop_claude", { conversationId });
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(
+121
View File
@@ -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;
});
+6 -2
View File
@@ -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) => {
+3 -1
View File
@@ -10,7 +10,9 @@ export type BudgetType = "token" | "cost";
// Model pricing (per million tokens) - keep in sync with stats.rs
// Source: https://platform.claude.com/docs/en/about-claude/models/overview
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
// Current generation (Claude 4.5)
// Current generation (Claude 4.6)
"claude-opus-4-6": { input: 5.0, output: 25.0 },
// Previous generation (Claude 4.5)
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
"claude-haiku-4-5-20251001": { input: 1.0, output: 5.0 },
+59 -3
View File
@@ -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;
+32
View File
@@ -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;
}
+2
View File
@@ -10,6 +10,8 @@ export interface TerminalLine {
outputTokens: number;
costUsd: number;
};
// Indicates if this message is from a subagent
parentToolUseId?: string;
}
export interface SystemInitMessage {
+4
View File
@@ -8,6 +8,7 @@
initializeDiscordRpc,
stopDiscordRpc,
updateDiscordRpc,
setSkipNextGreeting,
} from "$lib/tauri";
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
@@ -278,6 +279,9 @@
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
// Set flag to preserve stats/permissions (don't treat next connect as new session)
setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted");
} catch (error) {