generated from nhcarrigan/template
feat: agent monitor characters, cast panel, WSL fixes, and Sonnet 4.6 (#149)
## Summary ### New Features - **Claude Sonnet 4.6 support** — added `claude-sonnet-4-6` as a selectable model in the config sidebar - **Anime girl characters for subagents** — each subagent in the agent monitor is automatically assigned one of six characters (Amari, Keiko, Minori, Reina, Tatsumi, Yumiko) with a unique name, CDN avatar, title, and lore-flavoured description; assignment avoids duplicates when possible - **"Meet the Team" cast panel** — a new modal accessible from the status bar introduces the full cast: Naomi (Chief hEx-ecutive Officer), Hikari (Chief Operating Officer), and the six subagent girls with their C-suite titles and character bios ### Bug Fixes - **"Already running" error on invalid working directory** — if a spawned Claude process exits unexpectedly (e.g. because the working directory doesn't exist), `try_wait()` now detects the stale handle and clears it before allowing a restart - **Working directory pre-validation** — on Windows, the app now runs `wsl -e test -d <dir>` before launching Claude; invalid directories surface a clear error immediately - **WSL binary detection** — on Windows, `wsl -e bash -lc "which claude"` is used to probe for the Claude binary inside WSL; on Linux/WSLg, `bash -lc "which claude"` is used as a login-shell fallback so GUI apps find the binary even without shell PATH - **WSL detection fix for production builds** — `detect_wsl()` now short-circuits at compile time on Windows targets, preventing inherited `WSL_DISTRO_NAME` env vars from misrouting native Windows binaries through the Linux code path ✨ This PR was crafted with love by Hikari~ 🌸 Reviewed-on: #149 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #149.
This commit is contained in:
@@ -270,6 +270,14 @@
|
||||
/>
|
||||
</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
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<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>
|
||||
@@ -83,8 +83,9 @@
|
||||
{ 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 (Recommended)" },
|
||||
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
|
||||
{ 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)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
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";
|
||||
@@ -56,6 +57,7 @@
|
||||
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);
|
||||
@@ -519,6 +521,20 @@
|
||||
/>
|
||||
</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
|
||||
@@ -737,6 +753,10 @@
|
||||
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showCastPanel}
|
||||
<CastPanel onClose={() => (showCastPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showPluginPanel}
|
||||
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -2,12 +2,15 @@ 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";
|
||||
|
||||
const createMockAgent = (overrides?: Partial<AgentInfo>): AgentInfo => ({
|
||||
type AgentInput = Omit<AgentInfo, "characterName" | "characterAvatar">;
|
||||
|
||||
const createMockAgent = (overrides?: Partial<AgentInput>): AgentInput => ({
|
||||
toolUseId: "toolu_test123",
|
||||
description: "Test agent",
|
||||
subagentType: "Explore",
|
||||
@@ -37,7 +40,29 @@ describe("agents store", () => {
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0]).toEqual(agent);
|
||||
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);
|
||||
});
|
||||
|
||||
it("adds multiple agents to the same conversation", () => {
|
||||
@@ -49,8 +74,8 @@ describe("agents store", () => {
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(2);
|
||||
expect(agents[0]).toEqual(agent1);
|
||||
expect(agents[1]).toEqual(agent2);
|
||||
expect(agents[0]).toMatchObject(agent1);
|
||||
expect(agents[1]).toMatchObject(agent2);
|
||||
});
|
||||
|
||||
it("keeps agents in different conversations separate", () => {
|
||||
@@ -65,8 +90,8 @@ describe("agents store", () => {
|
||||
|
||||
expect(agents1).toHaveLength(1);
|
||||
expect(agents2).toHaveLength(1);
|
||||
expect(agents1[0]).toEqual(agent1);
|
||||
expect(agents2[0]).toEqual(agent2);
|
||||
expect(agents1[0]).toMatchObject(agent1);
|
||||
expect(agents2[0]).toMatchObject(agent2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -256,7 +281,7 @@ describe("agents store", () => {
|
||||
|
||||
expect(agents1).toHaveLength(0);
|
||||
expect(agents2).toHaveLength(1);
|
||||
expect(agents2[0]).toEqual(agent2);
|
||||
expect(agents2[0]).toMatchObject(agent2);
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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[]>>({});
|
||||
@@ -8,12 +9,17 @@ function createAgentStore() {
|
||||
return {
|
||||
subscribe: agentsByConversation.subscribe,
|
||||
|
||||
addAgent(conversationId: string, agent: AgentInfo) {
|
||||
addAgent(conversationId: string, agent: Omit<AgentInfo, "characterName" | "characterAvatar">) {
|
||||
agentsByConversation.update((state) => {
|
||||
const existing = state[conversationId] || [];
|
||||
const activeNames = existing.map((a) => a.characterName);
|
||||
const character = assignCharacter(activeNames);
|
||||
return {
|
||||
...state,
|
||||
[conversationId]: [...existing, agent],
|
||||
[conversationId]: [
|
||||
...existing,
|
||||
{ ...agent, characterName: character.name, characterAvatar: character.avatar },
|
||||
],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 },
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface AgentInfo {
|
||||
status: AgentStatus;
|
||||
parentToolUseId?: string;
|
||||
durationMs?: number;
|
||||
characterName: string;
|
||||
characterAvatar: string;
|
||||
}
|
||||
|
||||
export interface AgentStartPayload {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
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)];
|
||||
}
|
||||
Reference in New Issue
Block a user