Compare commits

..

2 Commits

Author SHA1 Message Date
hikari 269f33b52a fix: execute Claude CLI commands through WSL on Windows
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m0s
CI / Lint & Test (pull_request) Successful in 17m40s
CI / Build Linux (pull_request) Successful in 24m39s
CI / Build Windows (cross-compile) (pull_request) Successful in 42m6s
Resolves #137

Claude CLI commands (plugin list, MCP list, version check, etc.) were
being executed directly in Windows context where the `claude` binary
doesn't exist. This caused "program not found" errors across the UI.

Changes:
- Added `create_claude_command()` helper that automatically prefixes
  commands with `wsl` on Windows builds
- Updated 8 command functions to use the helper:
  - get_claude_version
  - list_plugins
  - install_plugin
  - uninstall_plugin
  - list_mcp_servers
  - remove_mcp_server
  - add_mcp_server
  - get_mcp_server_details
- Added comprehensive tests for both Windows and Linux contexts

This ensures all Claude CLI commands execute in the correct WSL
context on Windows, fixing the memory pane, plugin pane, MCP servers
pane, and CLI version detection.

 This fix was created by Hikari~ 🌸
2026-02-08 12:04:35 -08:00
hikari e4b27bdff3 fix: execute Claude CLI commands through WSL on Windows
Resolves #137

Claude CLI commands (plugin list, MCP list, version check, etc.) were
being executed directly in Windows context where the `claude` binary
doesn't exist. This caused "program not found" errors across the UI.

Changes:
- Added `create_claude_command()` helper that automatically prefixes
  commands with `wsl` on Windows builds
- Updated 7 command functions to use the helper:
  - get_claude_version
  - list_plugins
  - install_plugin
  - uninstall_plugin
  - list_mcp_servers
  - enable_mcp_server
  - disable_mcp_server
- Added comprehensive tests for both Windows and Linux contexts

This ensures all Claude CLI commands execute in the correct WSL
context on Windows, fixing the memory pane, plugin pane, MCP servers
pane, and CLI version detection.

 This fix was created by Hikari~ 🌸
2026-02-08 12:01:27 -08:00
19 changed files with 42 additions and 626 deletions
-31
View File
@@ -141,37 +141,6 @@ When developing new features, always add corresponding tests:
The goal is to maintain our near-100% coverage as the codebase grows, so future refactoring and changes can be made with confidence!
## Quality Assurance
Before committing any changes, **always run the full test suite**:
```bash
./check-all.sh
```
This script runs all checks in the correct order:
1. Frontend linting (ESLint)
2. Frontend formatting (Prettier)
3. Frontend type checking (svelte-check)
4. Frontend tests with coverage (Vitest)
5. Backend linting (Clippy with strict rules)
6. Backend tests with coverage (cargo test + llvm-cov)
**Important**: The script requires Node.js and Rust toolchains to be available:
- **Node.js tools** (pnpm, npm): Source nvm first if needed: `source ~/.nvm/nvm.sh`
- **Rust tools** (cargo, clippy): Should be in PATH via `~/.cargo/bin/`
If `check-all.sh` reports any failures:
1. Read the error messages carefully - they usually explain what needs fixing
2. Fix the issues (linting errors, test failures, etc.)
3. Run `check-all.sh` again to verify the fixes
4. Only commit once all checks pass ✨
**Never commit code that doesn't pass `check-all.sh`** - this ensures code quality and prevents broken builds!
## Project Context
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character (Hikari) who appears on screen. This is a personal project where Hikari can sign her work and act as herself!
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hikari-desktop",
"version": "1.6.0",
"version": "1.5.0",
"description": "",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1636,7 +1636,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hikari-desktop"
version = "1.6.0"
version = "1.5.0"
dependencies = [
"chrono",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hikari-desktop"
version = "1.6.0"
version = "1.5.0"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
+9 -115
View File
@@ -55,52 +55,16 @@ fn wsl_path_to_windows(wsl_path: &str) -> Option<String> {
fn create_claude_command() -> std::process::Command {
#[cfg(target_os = "windows")]
{
// Use `which` inside WSL to find the claude binary dynamically
// Non-login shells launched by `wsl` don't inherit the full user PATH,
// so we need to use a login shell to get the correct PATH
let which_output = std::process::Command::new("wsl")
.args(["-e", "bash", "-l", "-c", "which claude"])
.output();
match which_output {
Ok(output) if output.status.success() => {
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
let mut cmd = std::process::Command::new("wsl");
cmd.arg(claude_path);
cmd
}
_ => {
// Fallback to just "claude" if which fails
// This maintains backwards compatibility
let mut cmd = std::process::Command::new("wsl");
cmd.arg("claude");
cmd
}
}
}
#[cfg(not(target_os = "windows"))]
{
// Use `which` to find the claude binary dynamically
// This works regardless of how Claude Code was installed (standalone, npm, etc.)
// and avoids hardcoding paths
let which_output = std::process::Command::new("which")
.arg("claude")
.output();
match which_output {
Ok(output) if output.status.success() => {
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
std::process::Command::new(claude_path)
}
_ => {
// Fallback to just "claude" if which fails
// This maintains backwards compatibility
std::process::Command::new("claude")
}
}
}
}
#[tauri::command]
pub async fn start_claude(
@@ -1219,55 +1183,6 @@ pub struct MemoryFilesResponse {
#[tauri::command]
pub async fn list_memory_files() -> Result<MemoryFilesResponse, String> {
// On Windows, we need to look in the WSL home directory
// On Linux/Mac, use the native home directory
#[cfg(target_os = "windows")]
{
list_memory_files_via_wsl().await
}
#[cfg(not(target_os = "windows"))]
{
list_memory_files_native().await
}
}
/// List memory files via WSL (for Windows)
#[cfg(target_os = "windows")]
async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
use std::process::Command;
// Use WSL to find all memory files in the WSL home directory
// This script finds all "memory" directories and lists their files
let script = r#"
find ~/.claude/projects -type d -name memory 2>/dev/null | while read dir; do
find "$dir" -maxdepth 1 -type f 2>/dev/null
done | sort
"#;
let output = Command::new("wsl")
.args(["-e", "bash", "-l", "-c", script])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to list memory files: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let files: Vec<String> = stdout
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.trim().to_string())
.collect();
Ok(MemoryFilesResponse { files })
}
/// List memory files using native filesystem (for Linux/Mac)
#[cfg(not(target_os = "windows"))]
async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
use std::fs;
// Get the .claude directory in the user's home
@@ -1512,7 +1427,7 @@ pub async fn uninstall_plugin(plugin_name: String) -> Result<String, String> {
pub async fn enable_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Enabling plugin: {}", plugin_name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("enable")
.arg(&plugin_name)
@@ -1541,7 +1456,7 @@ pub async fn enable_plugin(plugin_name: String) -> Result<String, String> {
pub async fn disable_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Disabling plugin: {}", plugin_name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("disable")
.arg(&plugin_name)
@@ -1570,7 +1485,7 @@ pub async fn disable_plugin(plugin_name: String) -> Result<String, String> {
pub async fn update_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Updating plugin: {}", plugin_name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("update")
.arg(&plugin_name)
@@ -1642,7 +1557,7 @@ fn parse_marketplace_list(stdout: &str) -> Vec<MarketplaceInfo> {
pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
tracing::debug!("Listing plugin marketplaces");
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("marketplace")
.arg("list")
@@ -1675,7 +1590,7 @@ pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
pub async fn add_marketplace(source: String) -> Result<String, String> {
tracing::debug!("Adding marketplace: {}", source);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("marketplace")
.arg("add")
@@ -1708,7 +1623,7 @@ pub async fn add_marketplace(source: String) -> Result<String, String> {
pub async fn remove_marketplace(name: String) -> Result<String, String> {
tracing::debug!("Removing marketplace: {}", name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("marketplace")
.arg("remove")
@@ -2015,42 +1930,21 @@ mod tests {
#[test]
#[cfg(target_os = "windows")]
fn test_create_claude_command_windows() {
// On Windows, should create a command that uses wsl with full path to claude
// The path is resolved dynamically via `which` in a login shell
// On Windows, should create a command that uses wsl as the program with claude as first arg
let cmd = create_claude_command();
let program = cmd.get_program();
assert_eq!(program, "wsl");
// Verify the first argument is a path to claude (full path from `which`)
// or fallback to just "claude" if which fails
let args: Vec<&std::ffi::OsStr> = cmd.get_args().collect();
assert_eq!(args.len(), 1);
let arg_str = args[0].to_string_lossy();
assert!(
arg_str.contains("claude"),
"Expected argument to contain 'claude', got: {}",
arg_str
);
}
#[test]
#[cfg(not(target_os = "windows"))]
fn test_create_claude_command_linux() {
// On Linux/Mac, should create a command that uses the full path to claude
// (resolved via `which` command)
// On Linux/Mac, should create a command that uses claude directly
let cmd = create_claude_command();
let program = cmd.get_program();
// The program should be the full path to claude (from `which`)
// or fallback to "claude" if which fails
let program_str = program.to_string_lossy();
assert!(
program_str.ends_with("claude"),
"Expected program to end with 'claude', got: {}",
program_str
);
assert_eq!(program, "claude");
}
// ==================== validate_directory tests ====================
+1 -3
View File
@@ -86,9 +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
// Claude 4.6 family - 200K standard (1M beta available via header)
"claude-opus-4-6" => 200_000,
"claude-sonnet-4-6" => 1_000_000, // 1M token context window
// Claude 4.5 family - 200K standard context
"claude-opus-4-5-20251101"
| "claude-sonnet-4-5-20250929"
@@ -503,7 +502,6 @@ pub fn calculate_cost(
let (input_price_per_million, output_price_per_million) = match model {
// Current generation (Claude 4.6)
"claude-opus-4-6" => (5.0, 25.0),
"claude-sonnet-4-6" => (3.0, 15.0),
// Previous generation (Claude 4.5)
"claude-opus-4-5-20251101" => (5.0, 25.0),
+7 -115
View File
@@ -39,12 +39,6 @@ const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
fn detect_wsl() -> bool {
// A native Windows binary is never running inside WSL, even if launched from a WSL
// terminal that has WSL_DISTRO_NAME set in its environment.
if cfg!(target_os = "windows") {
return false;
}
// Check /proc/version for WSL indicators
if let Ok(version) = std::fs::read_to_string("/proc/version") {
let version_lower = version.to_lowercase();
@@ -67,29 +61,23 @@ fn detect_wsl() -> bool {
}
fn find_claude_binary() -> Option<String> {
// Check common installation locations for claude (when HOME is available)
if let Ok(home) = std::env::var("HOME") {
// Check common installation locations for claude
let home = std::env::var("HOME").ok()?;
let paths_to_check = [
format!("{}/.local/bin/claude", home),
format!("{}/.claude/local/claude", home),
"/usr/local/bin/claude".to_string(),
"/usr/bin/claude".to_string(),
];
for path in &paths_to_check {
if std::path::Path::new(path).exists() {
return Some(path.clone());
}
}
}
// Check system-wide locations
for path in &["/usr/local/bin/claude", "/usr/bin/claude"] {
if std::path::Path::new(path).exists() {
return Some((*path).to_string());
}
}
// Use a login shell to resolve claude via the user's PATH - GUI apps don't
// inherit shell PATH, so bare `which` may miss ~/.local/bin entries
if let Ok(output) = Command::new("bash").args(["-lc", "which claude"]).output() {
// Fall back to checking PATH via which
if let Ok(output) = Command::new("which").arg("claude").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
@@ -137,15 +125,6 @@ impl WslBridge {
}
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
// If a process handle exists but the process has already exited (e.g. due to a
// failed working directory), clean up the stale handle so we can restart cleanly.
if let Some(ref mut process) = self.process {
if process.try_wait().map(|s| s.is_some()).unwrap_or(false) {
self.process = None;
self.stdin = None;
}
}
if self.process.is_some() {
return Err("Process already running".to_string());
}
@@ -278,30 +257,6 @@ impl WslBridge {
} else {
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
tracing::debug!("Windows path - using wsl");
// Check if Claude binary is installed inside WSL
let binary_check = Command::new("wsl")
.args(["-e", "bash", "-lc", "which claude"])
.output();
if let Ok(output) = binary_check {
if !output.status.success() {
return Err("Claude Code is not installed. Please install it using:\n\ncurl -fsSL https://claude.ai/install.sh | bash".to_string());
}
}
// Validate the working directory exists inside WSL before spawning
let dir_check = Command::new("wsl")
.args(["-e", "test", "-d", working_dir])
.output();
if let Ok(output) = dir_check {
if !output.status.success() {
return Err(format!(
"Working directory does not exist: {}",
working_dir
));
}
}
let mut cmd = Command::new("wsl");
// Build the claude command with all arguments
@@ -1913,69 +1868,6 @@ mod tests {
assert!(!bridge.is_running());
}
#[test]
fn test_stale_process_detection_with_try_wait() {
// Spawn a real process that exits immediately so we can verify try_wait detects it
let mut child = Command::new("true").spawn().expect("Failed to spawn 'true'");
// Wait for it to exit
let _ = child.wait();
// try_wait on an already-exited process should return Some(_)
let status = child.try_wait();
assert!(
status.is_ok(),
"try_wait should not error on an exited process"
);
// The process has already been waited on, so try_wait might return None or Some
// depending on the OS - what matters is that the call succeeds
}
#[test]
fn test_stale_process_is_some_after_exit() {
// Verify the logic used in start(): a process that has exited is detected
// and the handle is cleaned up so start() can proceed
let mut child = Command::new("true").spawn().expect("Failed to spawn 'true'");
// Let it exit
let _ = child.wait();
// This mirrors the check in start()
let has_exited = child
.try_wait()
.map(|s| s.is_some())
.unwrap_or(false);
// After wait(), try_wait() returns None (already reaped), which means
// unwrap_or(false) → false. The important thing is the call doesn't panic
// and the control flow logic compiles and runs correctly.
let _ = has_exited; // suppress unused warning
}
/// Build the WSL binary check command structure without executing it (for testing)
#[cfg(test)]
fn build_wsl_binary_check_args() -> Vec<&'static str> {
vec!["-e", "bash", "-lc", "which claude"]
}
#[test]
fn test_wsl_binary_check_command_structure() {
// Windows path: verify Claude is detected inside WSL via `wsl -e bash -lc "which claude"`
let args = build_wsl_binary_check_args();
assert_eq!(args[0], "-e");
assert_eq!(args[1], "bash");
assert_eq!(args[2], "-lc");
assert_eq!(args[3], "which claude");
}
#[test]
fn test_linux_binary_check_does_not_panic() {
// Linux/WSL path: find_claude_binary() searches Linux filesystem paths.
// We just verify it runs without panicking; whether it returns Some depends
// on whether Claude is actually installed in this environment.
let _result = find_claude_binary();
}
#[test]
fn test_create_shared_bridge_manager() {
use crate::bridge_manager::create_shared_bridge_manager;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "1.6.0",
"version": "1.5.0",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -270,14 +270,6 @@
/>
</svg>
{/if}
<img
src={agent.characterAvatar}
alt={agent.characterName}
class="w-5 h-5 rounded-full object-cover"
/>
<span class="text-[10px] font-medium text-[var(--text-primary)]">
{agent.characterName}
</span>
<span
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
agent.status
-140
View File
@@ -1,140 +0,0 @@
<script lang="ts">
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="cast-title"
tabindex="-1"
>
<div class="flex items-center justify-between mb-6">
<h2 id="cast-title" class="text-xl font-semibold text-[var(--text-primary)]">
Meet the Team
</h2>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Principal cast: Hikari + Naomi -->
<div class="grid grid-cols-1 gap-3 mb-6 sm:grid-cols-2">
<div
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
>
<img
src="https://cdn.nhcarrigan.com/hikari.png"
alt="Hikari"
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
/>
<div>
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-[var(--text-primary)]">Hikari</span>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
>
Chief Operating Officer
</span>
</div>
<p class="text-xs text-[var(--text-secondary)]">
Holds the line so the others don't have to. Never without her clipboard — or her
glasses.
</p>
</div>
</div>
<div
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
>
<img
src="https://cdn.nhcarrigan.com/profile.png"
alt="Naomi"
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
/>
<div>
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-[var(--text-primary)]">Naomi</span>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
>
Chief hEx-ecutive Officer
</span>
</div>
<p class="text-xs text-[var(--text-secondary)]">
A 525-year-old vampire running a tech company from behind a VTuber avatar. Fixes server
crashes at 4 AM.
</p>
</div>
</div>
</div>
<!-- Subagent girls grid -->
<div>
<h3 class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3">
Subagent Squad
</h3>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
{#each CHARACTER_POOL as character (character.name)}
<div
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] text-center"
>
<img
src={character.avatar}
alt={character.name}
class="w-14 h-14 object-cover rounded-full border-2 border-[var(--border-color)]"
/>
<span class="text-sm font-medium text-[var(--text-primary)]">{character.name}</span>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
>
{character.title}
</span>
<p class="text-xs text-[var(--text-secondary)] leading-snug">{character.description}</p>
</div>
{/each}
</div>
</div>
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>
+1 -2
View File
@@ -83,9 +83,8 @@
{ value: "", label: "Default (from ~/.claude)" },
// Current generation (Claude 4.6)
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Recommended)" },
// Previous generation (Claude 4.5)
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 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" },
// Previous generation (Claude 4.x)
+2 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { readTextFile } from "@tauri-apps/plugin-fs";
import Markdown from "./Markdown.svelte";
let memoryFiles: string[] = $state([]);
@@ -32,8 +33,7 @@
isLoading = true;
error = null;
try {
// Use our backend command instead of Tauri plugin to handle WSL paths
const content = await invoke<string>("read_file_content", { path: filePath });
const content = await readTextFile(filePath);
fileContent = content;
selectedFile = filePath;
} catch (e) {
-20
View File
@@ -27,7 +27,6 @@
import GitPanel from "./GitPanel.svelte";
import ProfilePanel from "./ProfilePanel.svelte";
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
import CastPanel from "./CastPanel.svelte";
import PluginManagementPanel from "./PluginManagementPanel.svelte";
import McpManagementPanel from "./McpManagementPanel.svelte";
import { conversationsStore } from "$lib/stores/conversations";
@@ -57,7 +56,6 @@
let showGitPanel = $state(false);
let showProfile = $state(false);
let showAgentMonitor = $state(false);
let showCastPanel = $state(false);
let showPluginPanel = $state(false);
let showMcpPanel = $state(false);
let isSummarising = $state(false);
@@ -521,20 +519,6 @@
/>
</svg>
</button>
<button
onclick={() => (showCastPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Meet the Team"
>
<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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</button>
<button
onclick={() => (showAgentMonitor = !showAgentMonitor)}
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
@@ -753,10 +737,6 @@
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
{/if}
{#if showCastPanel}
<CastPanel onClose={() => (showCastPanel = false)} />
{/if}
{#if showPluginPanel}
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
{/if}
+7 -32
View File
@@ -2,15 +2,12 @@ import { describe, it, expect, beforeEach } from "vitest";
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
import { get } from "svelte/store";
import type { AgentInfo } from "$lib/types/agents";
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
describe("agents store", () => {
const conversationId = "test-conversation-1";
const otherConversationId = "test-conversation-2";
type AgentInput = Omit<AgentInfo, "characterName" | "characterAvatar">;
const createMockAgent = (overrides?: Partial<AgentInput>): AgentInput => ({
const createMockAgent = (overrides?: Partial<AgentInfo>): AgentInfo => ({
toolUseId: "toolu_test123",
description: "Test agent",
subagentType: "Explore",
@@ -40,29 +37,7 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(1);
expect(agents[0]).toMatchObject(agent);
});
it("assigns a character name and avatar to added agents", () => {
const agent = createMockAgent();
agentStore.addAgent(conversationId, agent);
const agents = get(getAgentsForConversation(conversationId));
const validNames = CHARACTER_POOL.map((c) => c.name);
expect(validNames).toContain(agents[0].characterName);
expect(agents[0].characterAvatar).toMatch(/^https:\/\//u);
});
it("avoids duplicate character names across agents when possible", () => {
// Add 6 agents - each should ideally get a unique character
for (let i = 0; i < 6; i++) {
agentStore.addAgent(conversationId, createMockAgent({ toolUseId: `tool${i.toString()}` }));
}
const agents = get(getAgentsForConversation(conversationId));
const names = agents.map((a) => a.characterName);
const uniqueNames = new Set(names);
expect(uniqueNames.size).toBe(6);
expect(agents[0]).toEqual(agent);
});
it("adds multiple agents to the same conversation", () => {
@@ -74,8 +49,8 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(2);
expect(agents[0]).toMatchObject(agent1);
expect(agents[1]).toMatchObject(agent2);
expect(agents[0]).toEqual(agent1);
expect(agents[1]).toEqual(agent2);
});
it("keeps agents in different conversations separate", () => {
@@ -90,8 +65,8 @@ describe("agents store", () => {
expect(agents1).toHaveLength(1);
expect(agents2).toHaveLength(1);
expect(agents1[0]).toMatchObject(agent1);
expect(agents2[0]).toMatchObject(agent2);
expect(agents1[0]).toEqual(agent1);
expect(agents2[0]).toEqual(agent2);
});
});
@@ -281,7 +256,7 @@ describe("agents store", () => {
expect(agents1).toHaveLength(0);
expect(agents2).toHaveLength(1);
expect(agents2[0]).toMatchObject(agent2);
expect(agents2[0]).toEqual(agent2);
});
it("does nothing if conversation doesn't exist", () => {
+2 -8
View File
@@ -1,6 +1,5 @@
import { writable, derived } from "svelte/store";
import type { AgentInfo } from "$lib/types/agents";
import { assignCharacter } from "$lib/utils/agentCharacters";
// Map of conversation ID -> agents in that conversation
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
@@ -9,17 +8,12 @@ function createAgentStore() {
return {
subscribe: agentsByConversation.subscribe,
addAgent(conversationId: string, agent: Omit<AgentInfo, "characterName" | "characterAvatar">) {
addAgent(conversationId: string, agent: AgentInfo) {
agentsByConversation.update((state) => {
const existing = state[conversationId] || [];
const activeNames = existing.map((a) => a.characterName);
const character = assignCharacter(activeNames);
return {
...state,
[conversationId]: [
...existing,
{ ...agent, characterName: character.name, characterAvatar: character.avatar },
],
[conversationId]: [...existing, agent],
};
});
},
-1
View File
@@ -12,7 +12,6 @@ export type BudgetType = "token" | "cost";
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
// Current generation (Claude 4.6)
"claude-opus-4-6": { input: 5.0, output: 25.0 },
"claude-sonnet-4-6": { input: 3.0, output: 15.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 },
-2
View File
@@ -10,8 +10,6 @@ export interface AgentInfo {
status: AgentStatus;
parentToolUseId?: string;
durationMs?: number;
characterName: string;
characterAvatar: string;
}
export interface AgentStartPayload {
-73
View File
@@ -1,73 +0,0 @@
import { describe, it, expect } from "vitest";
import { CHARACTER_POOL, assignCharacter } from "./agentCharacters";
describe("agentCharacters", () => {
describe("CHARACTER_POOL", () => {
it("contains exactly 6 characters", () => {
expect(CHARACTER_POOL).toHaveLength(6);
});
it("each character has a name, avatar, title, and description", () => {
for (const character of CHARACTER_POOL) {
expect(character.name).toBeTruthy();
expect(character.avatar).toBeTruthy();
expect(character.avatar).toMatch(/^https:\/\//u);
expect(character.title).toBeTruthy();
expect(character.description).toBeTruthy();
}
});
it("all names are unique", () => {
const names = CHARACTER_POOL.map((c) => c.name);
const uniqueNames = new Set(names);
expect(uniqueNames.size).toBe(CHARACTER_POOL.length);
});
});
describe("assignCharacter", () => {
it("returns a character from the pool", () => {
const character = assignCharacter([]);
const names = CHARACTER_POOL.map((c) => c.name);
expect(names).toContain(character.name);
});
it("avoids names already in use when possible", () => {
const takenNames = ["Amari", "Keiko", "Minori", "Reina", "Tatsumi"];
// Run many times to confirm we never get a taken name
for (let i = 0; i < 50; i++) {
const character = assignCharacter(takenNames);
expect(takenNames).not.toContain(character.name);
expect(character.name).toBe("Yumiko");
}
});
it("picks from the full pool when all 6 names are taken", () => {
const allNames = CHARACTER_POOL.map((c) => c.name);
const seen = new Set<string>();
// Run enough times that we'd statistically see variety
for (let i = 0; i < 100; i++) {
const character = assignCharacter(allNames);
seen.add(character.name);
}
// Should still pick valid characters
for (const name of seen) {
expect(allNames).toContain(name);
}
// With 100 runs and 6 characters, we should see at least 2 distinct names
expect(seen.size).toBeGreaterThan(1);
});
it("returns a character with name, avatar, title, and description", () => {
const character = assignCharacter([]);
expect(character.name).toBeTruthy();
expect(character.avatar).toBeTruthy();
expect(character.title).toBeTruthy();
expect(character.description).toBeTruthy();
});
it("works when the active list is empty", () => {
const character = assignCharacter([]);
expect(character).toBeDefined();
});
});
});
-61
View File
@@ -1,61 +0,0 @@
export interface AgentCharacter {
name: string;
avatar: string;
title: string;
description: string;
}
export const CHARACTER_POOL: readonly AgentCharacter[] = [
{
name: "Amari",
avatar: "https://cdn.nhcarrigan.com/amari.png",
title: "Executive Assistant",
description:
"Fey-blooded PA and healer of the team. She always knows when you need a break — and makes sure you take one.",
},
{
name: "Keiko",
avatar: "https://cdn.nhcarrigan.com/keiko.png",
title: "Chief Security Officer",
description:
"Bodyguard and shadow of the family. Conceals blades beneath evening gowns; always watching from the dark.",
},
{
name: "Minori",
avatar: "https://cdn.nhcarrigan.com/minori.png",
title: "Chief Compliance Officer",
description:
"An ancient Automaton built to guard the Great Library. Perfect memory, perfect logic, perfect dedication.",
},
{
name: "Reina",
avatar: "https://cdn.nhcarrigan.com/reina.png",
title: "Chief Legal Officer",
description:
"Demon of the Crossroads turned corporate lawyer. Her binding contracts have held for millennia.",
},
{
name: "Tatsumi",
avatar: "https://cdn.nhcarrigan.com/tatsumi.png",
title: "Chief Design Officer",
description:
"A Siren who traded the ocean for a stylus. Uses her glamour to make every interface welcoming and beautiful.",
},
{
name: "Yumiko",
avatar: "https://cdn.nhcarrigan.com/yumiko.png",
title: "Chief Technology Officer",
description:
"Technomancer and machine whisperer. She communes with machine spirits and keeps the digital world running.",
},
];
/**
* Picks a character for a new subagent.
* Avoids names already assigned to active agents unless all six are taken.
*/
export function assignCharacter(activeNames: readonly string[]): AgentCharacter {
const available = CHARACTER_POOL.filter((c) => !activeNames.includes(c.name));
const pool = available.length > 0 ? available : [...CHARACTER_POOL];
return pool[Math.floor(Math.random() * pool.length)];
}