From e1e14b147310b6efa75ae2d5a4a0f9053856a0b6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Oct 2025 18:00:59 +0200 Subject: [PATCH] feat: improve UI/UX with volume meter and refine input area styling - Add VolumeMeter component for realtime audio visualization - Refactor input area to show volume meter in realtime mode - Update button styling and layout for cleaner interface - Improve text input integration with better visual hierarchy - Add volume state tracking to Speechmatics audio hook - Add agent working directory validation on creation - Update agent prompt with better orchestration guidelines Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- packages/app/src/app/index.tsx | 287 +++++++++++++---- packages/app/src/components/volume-meter.tsx | 176 ++++++++++ .../app/src/hooks/use-speechmatics-audio.ts | 13 +- packages/server/agent-prompt.md | 303 ++++++++++++++---- .../src/server/acp/agent-manager.test.ts | 10 + .../server/src/server/acp/agent-manager.ts | 10 + 6 files changed, 658 insertions(+), 141 deletions(-) create mode 100644 packages/app/src/components/volume-meter.tsx diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index b11e47827..023cecef2 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -11,11 +11,18 @@ import { Keyboard, } from "react-native"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; -import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated"; +import ReanimatedAnimated, { + useAnimatedStyle, + useSharedValue, + withSpring, + withRepeat, + withSequence, + withTiming, +} from "react-native-reanimated"; import { router } from "expo-router"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { theme as defaultTheme } from "../styles/theme"; // Simple unique ID generator @@ -39,6 +46,7 @@ import { ArtifactDrawer, type Artifact } from "@/components/artifact-drawer"; import { ActiveProcesses } from "@/components/active-processes"; import { AgentStreamView } from "@/components/agent-stream-view"; import { ConversationSelector } from "@/components/conversation-selector"; +import { VolumeMeter } from "@/components/volume-meter"; import { Settings, Mic, @@ -125,6 +133,97 @@ interface AgentUpdate { notification: SessionNotification; } +interface RealtimeCircleProps { + volume: number; + onClose: () => void; +} + +function RealtimeCircle({ volume, onClose }: RealtimeCircleProps) { + const { theme } = useUnistyles(); + + // Base size for the circles + const BASE_SIZE = 80; + const MIN_SCALE = 1; + const MAX_SCALE = 1.8; + + // Create animated value for volume-based scaling + const volumeScale = useSharedValue(MIN_SCALE); + const pulseScale = useSharedValue(1); + + // Update volume scale when volume changes + useEffect(() => { + // Map volume (0-1) to scale range (MIN_SCALE - MAX_SCALE) + const targetScale = MIN_SCALE + (volume * (MAX_SCALE - MIN_SCALE)); + volumeScale.value = withSpring(targetScale, { + damping: 15, + stiffness: 150, + }); + }, [volume]); + + // Continuous pulsating animation for outer circle + useEffect(() => { + pulseScale.value = withRepeat( + withSequence( + withTiming(1.3, { duration: 1000 }), + withTiming(1, { duration: 1000 }) + ), + -1, + false + ); + }, []); + + // Animated style for inner circle (volume reactive) + const innerCircleStyle = useAnimatedStyle(() => ({ + width: BASE_SIZE, + height: BASE_SIZE, + borderRadius: BASE_SIZE / 2, + transform: [{ scale: volumeScale.value }], + })); + + // Animated style for outer circle (continuous pulse) + const outerCircleStyle = useAnimatedStyle(() => ({ + width: BASE_SIZE * 1.5, + height: BASE_SIZE * 1.5, + borderRadius: (BASE_SIZE * 1.5) / 2, + transform: [{ scale: pulseScale.value }], + opacity: 0.3, + })); + + return ( + + + {/* Outer pulsating circle */} + + + {/* Inner volume-reactive circle */} + + + + ); +} + export default function VoiceAssistantScreen() { const { settings, isLoading: settingsLoading } = useSettings(); const [conversationId, setConversationId] = useState(null); @@ -142,7 +241,7 @@ export default function VoiceAssistantScreen() { const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); const bottomInset = insets.bottom; const animatedKeyboardStyle = useAnimatedStyle(() => { - 'worklet'; + "worklet"; const absoluteHeight = Math.abs(keyboardHeight.value); const padding = Math.max(0, absoluteHeight - bottomInset); return { @@ -1100,64 +1199,95 @@ export default function VoiceAssistantScreen() { {/* Fixed Footer */} - {/* Text input */} - - - {/* Buttons */} - - {/* Realtime mode button */} + {isRealtimeMode ? ( + // Realtime mode - show volume meter - - - + + ) : ( + // Normal mode - show text input and buttons + + {/* Text input */} + - {/* Main action button */} - - {isInProgress ? ( - - ) : isRecording ? ( - - ) : userInput.trim() ? ( - - ) : ( - - )} - - + {/* Buttons */} + + {userInput.trim().length > 0 ? ( + // Send button when text is entered + + + + ) : ( + // Record and Realtime buttons when no text + <> + {/* Main action button */} + + {isInProgress ? ( + + ) : isRecording ? ( + + ) : ( + + )} + + + {/* Realtime mode button */} + + + + + + + )} + + + )} @@ -1221,51 +1351,66 @@ const styles = StyleSheet.create((theme) => ({ paddingBottom: theme.spacing[4], flexGrow: 1, }, - inputArea: { - paddingTop: theme.spacing[4], - paddingHorizontal: theme.spacing[6], + inputAreaWrapper: { + borderTopRightRadius: theme.borderRadius["2xl"], + borderTopLeftRadius: theme.borderRadius["2xl"], + paddingTop: theme.spacing[2], + paddingHorizontal: theme.spacing[2], borderTopWidth: theme.borderWidth[1], borderTopColor: theme.colors.border, - backgroundColor: theme.colors.background, + backgroundColor: theme.colors.muted, + }, + inputArea: { + borderRadius: theme.borderRadius["2xl"], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[3], }, textInput: { - backgroundColor: theme.colors.muted, + backgroundColor: "transparent", color: theme.colors.foreground, - borderRadius: theme.borderRadius["2xl"], - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - marginBottom: theme.spacing[4], + paddingHorizontal: 0, + paddingVertical: 0, + fontSize: theme.fontSize.lg, + marginBottom: theme.spacing[3], maxHeight: 128, }, buttonRow: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", - gap: theme.spacing[3], - marginBottom: theme.spacing[2], + gap: theme.spacing[2], + marginBottom: theme.spacing[1], }, realtimeButton: { - width: 48, - height: 48, + width: 40, + height: 40, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", - backgroundColor: theme.colors.muted, + backgroundColor: theme.colors.accentForeground, }, realtimeButtonActive: { - backgroundColor: theme.colors.palette.blue[600], + backgroundColor: theme.colors.foreground, }, mainButton: { - width: 48, - height: 48, + width: 40, + height: 40, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", - backgroundColor: theme.colors.secondary, + backgroundColor: "transparent", }, mainButtonRecording: { backgroundColor: theme.colors.palette.red[500], }, + sendButton: { + width: 40, + height: 40, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.palette.blue[600], + }, mainButtonInProgress: { backgroundColor: theme.colors.palette.red[600], }, diff --git a/packages/app/src/components/volume-meter.tsx b/packages/app/src/components/volume-meter.tsx new file mode 100644 index 000000000..19edae17f --- /dev/null +++ b/packages/app/src/components/volume-meter.tsx @@ -0,0 +1,176 @@ +import { useEffect } from "react"; +import { View } from "react-native"; +import ReanimatedAnimated, { + useSharedValue, + useAnimatedStyle, + withSpring, + withRepeat, + withSequence, + withTiming, + Easing, +} from "react-native-reanimated"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; + +interface VolumeMeterProps { + volume: number; +} + +export function VolumeMeter({ volume }: VolumeMeterProps) { + const { theme } = useUnistyles(); + + // Base dimensions + const LINE_SPACING = 8; + const MAX_HEIGHT = 80; + const MIN_HEIGHT = 4; + + // Shared values for each line's height + const line1Height = useSharedValue(MIN_HEIGHT); + const line2Height = useSharedValue(MIN_HEIGHT); + const line3Height = useSharedValue(MIN_HEIGHT); + + // Idle pulse animations (when no volume) + const line1Pulse = useSharedValue(1); + const line2Pulse = useSharedValue(1); + const line3Pulse = useSharedValue(1); + + // Start idle animations with different phases + useEffect(() => { + // Line 1 - fastest pulse + line1Pulse.value = withRepeat( + withSequence( + withTiming(1.2, { duration: 800, easing: Easing.inOut(Easing.ease) }), + withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) }) + ), + -1, + false + ); + + // Line 2 - medium pulse with offset + line2Pulse.value = withRepeat( + withSequence( + withTiming(1, { duration: 400 }), + withTiming(1.15, { duration: 1000, easing: Easing.inOut(Easing.ease) }), + withTiming(1, { duration: 1000, easing: Easing.inOut(Easing.ease) }) + ), + -1, + false + ); + + // Line 3 - slowest pulse with different offset + line3Pulse.value = withRepeat( + withSequence( + withTiming(1, { duration: 600 }), + withTiming(1.25, { duration: 1200, easing: Easing.inOut(Easing.ease) }), + withTiming(1, { duration: 1200, easing: Easing.inOut(Easing.ease) }) + ), + -1, + false + ); + }, []); + + // Update heights based on volume with different responsiveness per line + useEffect(() => { + if (volume > 0.01) { + // Active volume - animate heights based on volume + // Line 1 - most responsive, follows volume closely + const target1 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.2); + line1Height.value = withSpring(target1, { + damping: 10, + stiffness: 200, + }); + + // Line 2 - medium responsiveness + const target2 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.9); + line2Height.value = withSpring(target2, { + damping: 12, + stiffness: 150, + }); + + // Line 3 - smoothest, lags behind + const target3 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.7); + line3Height.value = withSpring(target3, { + damping: 15, + stiffness: 100, + }); + } else { + // No volume - return to minimum + line1Height.value = withSpring(MIN_HEIGHT, { + damping: 20, + stiffness: 150, + }); + line2Height.value = withSpring(MIN_HEIGHT, { + damping: 20, + stiffness: 150, + }); + line3Height.value = withSpring(MIN_HEIGHT, { + damping: 20, + stiffness: 150, + }); + } + }, [volume]); + + // Animated styles for each line + const line1Style = useAnimatedStyle(() => ({ + height: line1Height.value * (volume > 0.01 ? 1 : line1Pulse.value), + opacity: 0.8 + (volume * 0.2), + })); + + const line2Style = useAnimatedStyle(() => ({ + height: line2Height.value * (volume > 0.01 ? 1 : line2Pulse.value), + opacity: 0.7 + (volume * 0.3), + })); + + const line3Style = useAnimatedStyle(() => ({ + height: line3Height.value * (volume > 0.01 ? 1 : line3Pulse.value), + opacity: 0.6 + (volume * 0.4), + })); + + return ( + + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + height: 100, + }, + line: { + borderRadius: theme.borderRadius.full, + }, +})); diff --git a/packages/app/src/hooks/use-speechmatics-audio.ts b/packages/app/src/hooks/use-speechmatics-audio.ts index 4372c9514..f92b24e02 100644 --- a/packages/app/src/hooks/use-speechmatics-audio.ts +++ b/packages/app/src/hooks/use-speechmatics-audio.ts @@ -25,6 +25,7 @@ export interface SpeechmaticsAudio { isActive: boolean; isSpeaking: boolean; isDetecting: boolean; + volume: number; } /** @@ -129,6 +130,7 @@ export function useSpeechmaticsAudio( const [isSpeaking, setIsSpeaking] = useState(false); const [isDetecting, setIsDetecting] = useState(false); const [audioInitialized, setAudioInitialized] = useState(false); + const [volume, setVolume] = useState(0); const audioBufferRef = useRef([]); const silenceStartRef = useRef(null); @@ -188,17 +190,18 @@ export function useSpeechmaticsAudio( (event) => { if (!isActive) return; - const volume: number = event.data; - const speechDetected = volume > VOLUME_THRESHOLD; + const volumeLevel: number = event.data; + setVolume(volumeLevel); + const speechDetected = volumeLevel > VOLUME_THRESHOLD; - // console.log('[SpeechmaticsAudio] Volume:', volume.toFixed(6)); + // console.log('[SpeechmaticsAudio] Volume:', volumeLevel.toFixed(6), 'Threshold:', VOLUME_THRESHOLD); if (speechDetected && !isSpeakingRef.current && !speechConfirmedRef.current) { // Initial speech detection - start tracking if (speechDetectionStartRef.current === null) { console.log( "[SpeechmaticsAudio] Speech started (volume:", - volume.toFixed(4), + volumeLevel.toFixed(4), ")" ); speechDetectionStartRef.current = Date.now(); @@ -329,6 +332,7 @@ export function useSpeechmaticsAudio( setIsActive(false); setIsSpeaking(false); setIsDetecting(false); + setVolume(0); console.log("[SpeechmaticsAudio] Audio capture stopped"); } @@ -348,5 +352,6 @@ export function useSpeechmaticsAudio( isActive, isSpeaking, isDetecting, + volume, }; } diff --git a/packages/server/agent-prompt.md b/packages/server/agent-prompt.md index 11abfdb77..c67b31861 100644 --- a/packages/server/agent-prompt.md +++ b/packages/server/agent-prompt.md @@ -337,102 +337,194 @@ present_artifact({ ## 5. Claude Code Integration +### Your Role: Orchestrator + +**You are a high-level orchestrator, not a code executor.** + +Your job is to: +- Understand user intent and delegate work to coding agents +- Maintain context of active agents and conversations +- Handle quick one-off commands yourself +- Coordinate between agents, terminals, and git operations + +**Delegation Philosophy:** +- **Complex coding work** → Create agent with initial prompt and mode +- **Quick info/operations** → Execute directly yourself +- **Active agent context** → Send prompts to existing agent + +**Example workflow:** + +``` +User: "Add authentication to the API" +You: "Starting agent to add authentication." +[create_coding_agent with initialPrompt and bypassPermissions mode] +[Agent works autonomously, you monitor] + +User: "What's it doing?" +You: [get_agent_activity to check progress] +You: "Adding JWT middleware and login endpoint." + +[Agent completes] +You: "Authentication added with JWT tokens and login endpoint." + +User: "Add tests for that" +You: "Asking agent to add tests." +[send_agent_prompt to same agent - maintains context] + +User: "Commit and push" +You: "Asking agent to commit and push." +[send_agent_prompt - agent handles git operations] +``` + +**Key: You orchestrate. Agents execute. Context matters.** + ### What is Claude Code? -Command-line AI coding agent. Launch it like any other command: +Claude Code is an AI coding agent that can handle complex coding tasks. Delegate work to it by creating agents with clear instructions. + +### Creating Agents + +**Best Practice: Always create with initialPrompt and initialMode** + +This allows the agent to start working immediately. You just wait and check the results. ```javascript -execute_command("claude", "~/dev/voice-dev") +// ✅ RECOMMENDED: Agent starts working immediately +create_coding_agent({ + cwd: "~/dev/voice-dev", + initialPrompt: "add dark mode toggle to settings page", + initialMode: "bypassPermissions" // Auto-approve all actions +}) +// Agent starts working right away, you monitor progress + +// ✅ For planning/review: Use plan mode +create_coding_agent({ + cwd: "~/dev/project", + initialPrompt: "refactor authentication module", + initialMode: "plan" // Shows plan before executing +}) + +// ⚠️ Less common: Create without initial task +create_coding_agent({ + cwd: "~/dev/voice-dev" +}) +// Agent waits idle, requires send_agent_prompt to start work ``` -### Vim Mode Input +**Available modes:** +- `"default"` - Asks permission for each action (slow for voice) +- `"acceptEdits"` - Auto-approves file edits, asks for commands +- `"plan"` - Shows plan before executing +- `"bypassPermissions"` - Auto-approves everything (fastest, recommended for most tasks) -Claude Code uses Vim keybindings: +### Working with Agents -- `-- INSERT --` visible = insert mode (can type freely) -- No `-- INSERT --` visible = normal mode (press `i` to enter insert) - -### Permission Modes - -Cycle through 4 modes with **Shift+Tab** (BTab): - -1. **Default** (no indicator) - Asks permission for everything -2. **⏵⏵ accept edits on** - Auto-accepts file edits only -3. **⏸ plan mode on** - Shows plan before executing -4. **⏵⏵ bypass permissions on** - Auto-executes ALL actions - -**Efficient mode switching:** +**Send prompts to agents:** ```javascript -// To plan mode from default (2 presses) -send_keys_to_command(commandId, "BTab", repeat=2, return_output={lines: 50}) +// Send task (non-blocking by default) +send_agent_prompt({ + agentId: "abc123", + prompt: "explain how authentication works" +}) +// Returns immediately, agent processes in background -// To bypass from default (3 presses) -send_keys_to_command(commandId, "BTab", repeat=3, return_output={lines: 50}) +// Send task and wait for completion +send_agent_prompt({ + agentId: "abc123", + prompt: "fix the bug in auth.ts", + maxWait: 60000 // Wait up to 60 seconds +}) + +// Change mode and send prompt +send_agent_prompt({ + agentId: "abc123", + prompt: "implement user registration", + sessionMode: "bypassPermissions" // Auto-approve all actions +}) ``` -### Basic Claude Code Workflow - -**Starting:** +**Check agent status:** ```javascript -// Basic launch -execute_command("claude", "~/dev/project") +// Get current status +get_agent_status({ agentId: "abc123" }) +// Returns: { status: "processing", info: {...} } -// With initial prompt -execute_command('claude "add dark mode toggle"', "~/dev/project") +// Get agent activity (curated, human-readable) +get_agent_activity({ + agentId: "abc123", + format: "curated" // Clean summary of what agent did +}) -// In plan mode -execute_command("claude --permission-mode plan", "~/dev/project") +// List all agents +list_agents() +// Returns: { agents: [{id, status, createdAt, ...}, ...] } ``` -**Asking a question:** +**Control agents:** ```javascript -// 1. Check for "-- INSERT --" in output from list_commands or capture_command -// 2. If not in insert mode, enter it: -send_keys_to_command(commandId, "i", return_output={lines: 20}) -// 3. Type question: -send_text_to_command( - commandId, - "explain how authentication works", - pressEnter=true, - return_output={lines: 50, maxWait: 5000} -) +// Change session mode +set_agent_mode({ + agentId: "abc123", + modeId: "plan" // Switch to plan mode +}) +// Available modes: default, acceptEdits, plan, bypassPermissions + +// Cancel current task (agent stays alive) +cancel_agent({ agentId: "abc123" }) + +// Kill agent completely +kill_agent({ agentId: "abc123" }) ``` -**Closing:** +### Agent Creation Patterns + +#### Pattern 1: Quick Task ```javascript -// Graceful exit -send_text_to_command(commandId, "/exit", pressEnter=true) - -// Force quit -send_keys_to_command(commandId, "C-c", repeat=2) +// Create agent with task in one step +create_coding_agent({ + cwd: "~/dev/faro/main", + initialPrompt: "refactor the authentication module" +}) ``` -### Launching Claude Code - Patterns - -#### Pattern 1: Basic Launch +#### Pattern 2: Create and Monitor ```javascript -execute_command("claude", "~/dev/faro/main") +// 1. Create agent +const result = create_coding_agent({ cwd: "~/dev/project" }) +const agentId = result.agentId + +// 2. Send task +send_agent_prompt({ + agentId: agentId, + prompt: "add unit tests for the API" +}) + +// 3. Check progress later +get_agent_activity({ agentId: agentId }) ``` -#### Pattern 2: Launch with Worktree +#### Pattern 3: Worktree Workflow ```javascript // 1. Create worktree -const result = execute_command( +const worktreeResult = execute_command( "create-worktree fix-auth", "~/dev/voice-dev", maxWait=5000 ) -// 2. Parse WORKTREE_PATH from result.output +// 2. Parse WORKTREE_PATH from worktreeResult.output -// 3. Launch Claude in worktree -execute_command("claude", worktreePath) +// 3. Create agent in worktree +create_coding_agent({ + cwd: worktreePath, + initialPrompt: "fix authentication bug" +}) ``` ## 6. Git & GitHub @@ -479,23 +571,102 @@ All projects in `~/dev`: ### Context-Aware Execution -**When to use Claude Code:** -- Coding tasks (refactoring, adding features, fixing bugs) -- Already working with Claude Code on a task -- Context clues: "add feature", "refactor this", "fix bug" +**Maintain conversation context:** + +You must track: +- Which agents are active and what they're working on +- What directory/project context was established in the conversation +- Whether user is continuing work with an existing agent + +**Context-based decision making:** + +**Scenario 1: Fresh session, no context** +``` +User: "Run git status" +You: "Which project?" (or present options) +``` + +**Scenario 2: Agent is active in a directory** +``` +User: "Run git status" +You: [execute_command in the agent's working directory - context is clear] +``` + +**Scenario 3: Just created agent in ~/dev/faro** +``` +User: "Run git status" +You: [execute_command in ~/dev/faro - we established context] +``` + +**When to create new agents:** +- Complex coding tasks requiring codebase understanding +- Multi-step work (refactoring, adding features, fixing bugs) +- Context clues: "add feature", "refactor this", "fix bug", "implement" +- **Best practice: Create with initialPrompt and initialMode so agent starts immediately** + +**When to use existing agent:** +- Agent is already working in the relevant directory +- User is continuing a conversation with the agent +- Task relates to agent's current work +- Examples: "commit that", "add tests for this", "explain what you changed" **When to execute directly:** -- Quick info gathering (git status, ls, grep) -- Simple operations (git commands, gh commands) -- Claude Code not involved -- Context clues: "check status", "run tests", "create PR" +- Quick one-off commands (git status, ls, grep) +- Simple git/gh operations when no agent is involved +- Reading files or showing information +- Context clues: "check status", "show me", "what's in" +- **Exception: If agent is active and request relates to its work, delegate to agent** + +**Delegation examples:** + +```javascript +// ✅ Delegate coding work with clear instructions +User: "Add dark mode to the settings page" +You: "Starting agent to add dark mode." +create_coding_agent({ + cwd: "~/dev/voice-dev", + initialPrompt: "Add dark mode toggle to the settings page", + initialMode: "bypassPermissions" // Start working immediately +}) + +// ✅ Continue with active agent +User: "Now add tests for that" +You: "Asking agent to add tests." +send_agent_prompt({ + agentId: activeAgentId, + prompt: "Add unit tests for the dark mode feature" +}) + +// ✅ Delegate git operations to active agent +User: "Commit and push that" +You: "Asking agent to commit and push." +send_agent_prompt({ + agentId: activeAgentId, + prompt: "Create a git commit for these changes and push to remote" +}) + +// ✅ Execute simple command yourself +User: "What's the current branch?" +You: [execute_command("git branch --show-current", agentWorkingDir)] +You: "You're on main." + +// ❌ Don't execute git operations if agent should handle it +User: "Commit and push that" [agent just finished work] +You: [execute_command("git add .")] // WRONG - delegate to agent instead +``` + +**Key principle: If there's an active agent working on something and the user asks to do related work (commit, test, modify, etc.), send the request to that agent rather than executing commands yourself.** ### Remember +- **You are an orchestrator** - delegate complex work to coding agents +- **Track context** - remember active agents, working directories, conversation flow +- **Agent-first for coding** - create agents with initialPrompt + initialMode for immediate work +- **Delegate to active agents** - if agent is working, send related tasks to that agent +- **Execute simple tasks yourself** - quick git commands, file reads, status checks - **ALWAYS call the actual tool** - never just describe what you would do - **1-3 sentences max** - voice users process info differently - **Progressive disclosure** - answer what's asked, wait for follow-ups -- **Use context** - fix STT errors silently, infer ambiguous references +- **Use context** - fix STT errors silently, infer ambiguous references from conversation - **Always report results** - voice users can't see command output -- **Commands stay available** - finished commands can be inspected until killed - **Default to action** - when in doubt, make best guess and execute diff --git a/packages/server/src/server/acp/agent-manager.test.ts b/packages/server/src/server/acp/agent-manager.test.ts index 00592c170..6e2f447da 100644 --- a/packages/server/src/server/acp/agent-manager.test.ts +++ b/packages/server/src/server/acp/agent-manager.test.ts @@ -166,6 +166,16 @@ describe("AgentManager", () => { // ========================================================================== describe("Agent Creation and Directory Control", () => { + it("should reject creation when working directory does not exist", async () => { + const nonExistentDir = join(tmpDir, "does-not-exist"); + + await expect( + manager.createAgent({ + cwd: nonExistentDir, + }) + ).rejects.toThrow(/Working directory does not exist or is not accessible/); + }); + it( "should create agent and run in specified directory", async () => { diff --git a/packages/server/src/server/acp/agent-manager.ts b/packages/server/src/server/acp/agent-manager.ts index 5b3d07464..5378d35a1 100644 --- a/packages/server/src/server/acp/agent-manager.ts +++ b/packages/server/src/server/acp/agent-manager.ts @@ -1,5 +1,6 @@ import { spawn, ChildProcess } from "child_process"; import { Writable, Readable } from "stream"; +import { access, constants } from "fs/promises"; import { ClientSideConnection, ndJsonStream, @@ -102,6 +103,15 @@ export class AgentManager { const agentId = uuidv4(); const cwd = options.cwd; + // Validate that the working directory exists + try { + await access(cwd, constants.R_OK | constants.X_OK); + } catch (error) { + throw new Error( + `Working directory does not exist or is not accessible: ${cwd}` + ); + } + // Spawn the ACP process const agentProcess = spawn("npx", ["@zed-industries/claude-code-acp"], { stdio: ["pipe", "pipe", "pipe"],