diff --git a/CLAUDE.md b/CLAUDE.md index 324b49072..9ebb19f0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,6 +135,27 @@ APP_VARIANT=fast npx expo prebuild --platform android --clean --no-install adb install -r android/app/build/outputs/apk/debugOptimized/app-debugOptimized.apk ``` +### Android dev + build scripts (Metro-enabled) + +From repo root: + +```bash +# Run on phone (installs/launches variant and sets debug_http_host) +npm run android:fast +npm run android:debug + +# Build APKs (for shipping over the wire) +npm run android:fast:build +npm run android:debug:build +``` + +Notes: +- Both dev variants are Metro-enabled by default and set `debug_http_host` via adb. +- Default Metro endpoint is `localhost:8080`. +- If `localhost:8080` is not running but `localhost:8081` is, scripts auto-fallback to `localhost:8081`. +- For localhost endpoints, scripts also run `adb reverse tcp: tcp:` automatically. +- Override with `METRO_ENDPOINT=host:port` (for example `METRO_ENDPOINT=192.168.1.25:8081`). + ## Testing with Playwright MCP **CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`. diff --git a/package.json b/package.json index cb2b16028..b32f910de 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "android": "npm run android --workspace=@getpaseo/app", "android:fast": "npm run android:fast --workspace=@getpaseo/app", "android:debug": "npm run android:debug --workspace=@getpaseo/app", + "android:fast:build": "npm run android:fast:build --workspace=@getpaseo/app", + "android:debug:build": "npm run android:debug:build --workspace=@getpaseo/app", "android:prod": "npm run android:prod --workspace=@getpaseo/app", "android:release": "npm run android:prod --workspace=@getpaseo/app", "ios": "npm run ios --workspace=@getpaseo/app", diff --git a/packages/app/package.json b/packages/app/package.json index a86cc45a0..bddd5aa37 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -6,8 +6,10 @@ "start": "expo start", "reset-project": "node ./scripts/reset-project.js", "android": "npm run android:fast", - "android:fast": "APP_VARIANT=fast expo run:android --variant=debugOptimized", - "android:debug": "APP_VARIANT=debug expo run:android --variant=debug", + "android:fast": "APP_VARIANT=fast bash ./scripts/android-run-variant.sh", + "android:debug": "APP_VARIANT=debug bash ./scripts/android-run-variant.sh", + "android:fast:build": "APP_VARIANT=fast bash ./scripts/android-build-variant.sh", + "android:debug:build": "APP_VARIANT=debug bash ./scripts/android-build-variant.sh", "android:prod": "APP_VARIANT=prod expo run:android --variant=release", "android:release": "npm run android:prod", "ios": "expo run:ios", diff --git a/packages/app/scripts/android-build-variant.sh b/packages/app/scripts/android-build-variant.sh new file mode 100755 index 000000000..929e72d30 --- /dev/null +++ b/packages/app/scripts/android-build-variant.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +app_variant="${APP_VARIANT:-fast}" + +case "$app_variant" in + fast) + gradle_task="app:assembleDebugOptimized" + apk_path="android/app/build/outputs/apk/debugOptimized/app-debugOptimized.apk" + ;; + debug) + gradle_task="app:assembleDebug" + apk_path="android/app/build/outputs/apk/debug/app-debug.apk" + ;; + *) + echo "APP_VARIANT must be one of: fast, debug" + exit 1 + ;; +esac + +APP_VARIANT="$app_variant" npx expo prebuild --platform android --clean --no-install +(cd android && ./gradlew "$gradle_task") + +echo "Built APK: ${apk_path}" diff --git a/packages/app/scripts/android-configure-metro-host.sh b/packages/app/scripts/android-configure-metro-host.sh new file mode 100755 index 000000000..95c512fd9 --- /dev/null +++ b/packages/app/scripts/android-configure-metro-host.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +app_variant="${APP_VARIANT:-fast}" +metro_endpoint="${METRO_ENDPOINT:-localhost:8080}" +launch_app="${LAUNCH_APP:-1}" + +case "$app_variant" in + fast) + package_id="sh.paseo.dev" + app_name="Paseo Dev" + ;; + debug) + package_id="sh.paseo.debug" + app_name="Paseo Debug" + ;; + *) + echo "APP_VARIANT must be one of: fast, debug" + exit 1 + ;; +esac + +if ! [[ "$metro_endpoint" =~ ^[A-Za-z0-9._-]+:[0-9]{1,5}$ ]]; then + echo "Invalid METRO_ENDPOINT '$metro_endpoint'. Expected host:port (for example macbook:8081)." + exit 1 +fi + +if ! command -v adb >/dev/null 2>&1; then + echo "adb is not installed or not on PATH." + exit 1 +fi + +if ! adb get-state >/dev/null 2>&1; then + echo "No Android device detected by adb." + exit 1 +fi + +metro_host="${metro_endpoint%:*}" +metro_port="${metro_endpoint##*:}" + +if [[ "$metro_host" == "localhost" || "$metro_host" == "127.0.0.1" ]]; then + if ! lsof -nP -iTCP:"${metro_port}" -sTCP:LISTEN >/dev/null 2>&1; then + if [[ "$metro_port" == "8080" ]] && lsof -nP -iTCP:8081 -sTCP:LISTEN >/dev/null 2>&1; then + metro_endpoint="localhost:8081" + metro_host="localhost" + metro_port="8081" + echo "Metro was not listening on localhost:8080; using localhost:8081." + else + echo "No Metro server detected on ${metro_endpoint}. Start Metro there or set METRO_ENDPOINT=host:port." + exit 1 + fi + fi + + adb reverse "tcp:${metro_port}" "tcp:${metro_port}" >/dev/null 2>&1 || true +fi + +prefs_file="${package_id}_preferences.xml" +prefs_path="shared_prefs/${prefs_file}" +prefs_xml=" + + ${metro_endpoint} +" + +adb shell run-as "$package_id" mkdir -p shared_prefs +printf '%s\n' "$prefs_xml" | adb shell run-as "$package_id" tee "$prefs_path" >/dev/null + +echo "Configured ${app_name} (${package_id}) to use Metro at ${metro_endpoint}." + +if [[ "$launch_app" == "1" ]]; then + adb shell am force-stop "$package_id" >/dev/null 2>&1 || true + adb shell monkey -p "$package_id" -c android.intent.category.LAUNCHER 1 >/dev/null + echo "Launched ${app_name}." +fi diff --git a/packages/app/scripts/android-run-variant.sh b/packages/app/scripts/android-run-variant.sh new file mode 100755 index 000000000..22d3b11c0 --- /dev/null +++ b/packages/app/scripts/android-run-variant.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +app_variant="${APP_VARIANT:-fast}" +metro_endpoint="${METRO_ENDPOINT:-localhost:8080}" + +case "$app_variant" in + fast) + gradle_variant="debugOptimized" + ;; + debug) + gradle_variant="debug" + ;; + *) + echo "APP_VARIANT must be one of: fast, debug" + exit 1 + ;; +esac + +APP_VARIANT="$app_variant" npx expo run:android --variant="$gradle_variant" +APP_VARIANT="$app_variant" METRO_ENDPOINT="$metro_endpoint" LAUNCH_APP=1 bash ./scripts/android-configure-metro-host.sh diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index b66b2acd9..6e401ed80 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -4,7 +4,6 @@ import { Text, ActivityIndicator, Platform, - Alert, } from "react-native"; import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -425,15 +424,10 @@ export function AgentInputArea({ return; } if (voice.isVoiceModeForAgent(serverId, agentId)) { - void voice.stopVoice().catch((error) => { - console.error("[AgentInputArea] Failed to stop voice mode", error); - Alert.alert("Voice failed", "Unable to stop realtime voice mode."); - }); return; } void voice.startVoice(serverId, agentId).catch((error) => { console.error("[AgentInputArea] Failed to start voice mode", error); - Alert.alert("Voice failed", "Unable to start realtime voice mode."); }); }, [agentId, isConnected, serverId, voice]); @@ -543,29 +537,26 @@ export function AgentInputArea({ const rightContent = ( - - {voice?.isVoiceSwitching ? ( - - ) : isVoiceModeForAgent ? ( - - ) : ( - - )} - + {!isVoiceModeForAgent ? ( + + {voice?.isVoiceSwitching ? ( + + ) : ( + + )} + + ) : null} {cancelButton} ); diff --git a/packages/app/src/components/voice-compact-indicator.tsx b/packages/app/src/components/voice-compact-indicator.tsx index 2db9ef2c1..b882313c9 100644 --- a/packages/app/src/components/voice-compact-indicator.tsx +++ b/packages/app/src/components/voice-compact-indicator.tsx @@ -1,13 +1,21 @@ -import { Pressable, View } from "react-native"; +import { ActivityIndicator, Alert, Pressable, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Mic, MicOff } from "lucide-react-native"; +import { Mic, MicOff, Square } from "lucide-react-native"; import { VolumeMeter } from "@/components/volume-meter"; import { useVoice } from "@/contexts/voice-context"; export function VoiceCompactIndicator() { const { theme } = useUnistyles(); - const { isVoiceMode, volume, isMuted, isDetecting, isSpeaking, toggleMute } = - useVoice(); + const { + isVoiceMode, + isVoiceSwitching, + volume, + isMuted, + isDetecting, + isSpeaking, + toggleMute, + stopVoice, + } = useVoice(); if (!isVoiceMode) { return null; @@ -26,19 +34,52 @@ export function VoiceCompactIndicator() { /> - - {isMuted ? ( - - ) : ( - - )} - + + + {isMuted ? ( + + ) : ( + + )} + + + { + void stopVoice().catch((error) => { + console.error("[VoiceCompactIndicator] Failed to stop voice mode", error); + Alert.alert("Voice failed", "Unable to stop realtime voice mode."); + }); + }} + disabled={isVoiceSwitching} + accessibilityRole="button" + accessibilityLabel="Disable realtime voice mode" + style={[ + styles.stopButton, + isVoiceSwitching ? styles.buttonDisabled : undefined, + ]} + hitSlop={8} + > + {isVoiceSwitching ? ( + + ) : ( + + )} + + ); } @@ -47,7 +88,7 @@ const styles = StyleSheet.create((theme) => ({ container: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], + gap: theme.spacing[1], paddingLeft: theme.spacing[3], paddingRight: theme.spacing[1], height: 32, @@ -63,6 +104,11 @@ const styles = StyleSheet.create((theme) => ({ meterContainer: { justifyContent: "center", }, + controlsRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, muteButton: { width: 28, height: 28, @@ -72,4 +118,17 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: "transparent", borderWidth: 0, }, + stopButton: { + width: 28, + height: 28, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.palette.red[600], + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.palette.red[800], + }, + buttonDisabled: { + opacity: 0.5, + }, })); diff --git a/packages/app/src/utils/tool-call-parsers.test.ts b/packages/app/src/utils/tool-call-parsers.test.ts index 9cf3f60be..b0bbe1c9b 100644 --- a/packages/app/src/utils/tool-call-parsers.test.ts +++ b/packages/app/src/utils/tool-call-parsers.test.ts @@ -111,6 +111,18 @@ describe("parseToolCallDisplay", () => { expect(display.toolName).toBe("Read"); }); + test("normalizes tool names - paseo_voice.speak to Speak", () => { + const input = { text: "hello from namespaced speak" }; + const display = parseToolCallDisplay("paseo_voice.speak", input, undefined); + expect(display.toolName).toBe("Speak"); + }); + + test("normalizes tool names - mcp__paseo_voice__speak to Speak", () => { + const input = { text: "hello from claude mcp speak" }; + const display = parseToolCallDisplay("mcp__paseo_voice__speak", input, undefined); + expect(display.toolName).toBe("Speak"); + }); + test("preserves unknown tool names", () => { const input = { some_arg: "value" }; const display = parseToolCallDisplay("MyCustomTool", input, undefined); diff --git a/packages/app/src/utils/tool-call-parsers.ts b/packages/app/src/utils/tool-call-parsers.ts index f8054f3e4..5c6481f15 100644 --- a/packages/app/src/utils/tool-call-parsers.ts +++ b/packages/app/src/utils/tool-call-parsers.ts @@ -1,6 +1,9 @@ import stripAnsi from "strip-ansi"; import { z } from "zod"; -import { stripShellWrapperPrefix } from "@getpaseo/server/utils/tool-call-parsers"; +import { + normalizeToolDisplayName, + stripShellWrapperPrefix, +} from "@getpaseo/server/utils/tool-call-parsers"; import { getNowMs, isPerfLoggingEnabled, perfLog } from "./perf"; const TOOL_CALL_DIFF_LOG_TAG = "[ToolCallDiff]"; @@ -1203,7 +1206,9 @@ const ToolCallDisplaySchema = z result: z.unknown(), }) .transform((data) => { - const normalizedToolName = TOOL_NAME_MAP[data.toolName] ?? data.toolName; + const normalizedToolName = normalizeToolDisplayName( + TOOL_NAME_MAP[data.toolName] ?? data.toolName + ); // Handle thinking - input is the thinking text content if (data.toolName === "thinking") { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 5ec1379a1..9ee0d58c2 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -54,7 +54,6 @@ import { generateStructuredAgentResponse, } from "./agent/agent-response-loop.js"; import type { - AgentPermissionRequest, AgentPermissionResponse, AgentPromptContentBlock, AgentPromptInput, @@ -194,15 +193,10 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round( const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; const AgentIdSchema = z.string().uuid(); const VOICE_MCP_SERVER_NAME = "paseo_voice"; -const VOICE_MODE_ENFORCED_MODE_ID: Partial> = { - claude: "default", - codex: "read-only", -}; type VoiceModeBaseConfig = { systemPrompt?: string; mcpServers?: Record; - modeId?: string; }; interface AudioBufferState { @@ -1408,10 +1402,6 @@ export class Session { }; } - private getVoiceModeEnforcedModeId(provider: AgentProvider): string | undefined { - return VOICE_MODE_ENFORCED_MODE_ID[provider]; - } - private async enableVoiceModeForAgent(agentId: string): Promise { const ensureVoiceSocket = this.ensureVoiceMcpSocketForAgent; if (!ensureVoiceSocket) { @@ -1426,14 +1416,11 @@ export class Session { const baseConfig: VoiceModeBaseConfig = { systemPrompt: existing.config.systemPrompt, mcpServers: this.cloneMcpServers(existing.config.mcpServers), - modeId: existing.config.modeId, }; this.voiceModeBaseConfig = baseConfig; - const voiceModeId = this.getVoiceModeEnforcedModeId(existing.config.provider); const refreshOverrides: Partial = { systemPrompt: this.buildVoiceModeSystemPrompt(baseConfig.systemPrompt), mcpServers: this.buildVoiceModeMcpServers(baseConfig.mcpServers, socketPath), - ...(voiceModeId ? { modeId: voiceModeId } : {}), }; try { @@ -1475,7 +1462,6 @@ export class Session { await this.agentManager.refreshAgentFromPersistence(agentId, { systemPrompt: baseConfig.systemPrompt, mcpServers: this.cloneMcpServers(baseConfig.mcpServers), - modeId: baseConfig.modeId, }); } catch (error) { this.sessionLogger.warn( @@ -4658,10 +4644,6 @@ export class Session { ].join("\n"); } - private shouldAllowVoicePermission(request: AgentPermissionRequest): boolean { - return isVoicePermissionAllowed(request); - } - private async processWithVoiceAgent( agentId: string, userText: string @@ -4703,19 +4685,14 @@ export class Session { } } if (event.type === "permission_requested") { - if (this.shouldAllowVoicePermission(event.request)) { + if (isVoicePermissionAllowed(event.request)) { await this.agentManager.respondToPermission(agentId, event.request.id, { behavior: "allow", }); } else { - await this.agentManager.respondToPermission(agentId, event.request.id, { - behavior: "deny", - message: "Voice assistant policy only allows the speak tool.", - interrupt: true, - }); - this.sessionLogger.warn( + this.sessionLogger.info( { agentId, requestName: event.request.name, requestId: event.request.id }, - "Voice assistant denied non-speak permission request" + "Voice assistant left non-speak permission request for user decision" ); } } diff --git a/packages/server/src/server/voice-permission-policy.test.ts b/packages/server/src/server/voice-permission-policy.test.ts index 079af49c0..e7f89cf80 100644 --- a/packages/server/src/server/voice-permission-policy.test.ts +++ b/packages/server/src/server/voice-permission-policy.test.ts @@ -43,8 +43,8 @@ describe("isVoicePermissionAllowed", () => { expect(result).toBe(false); }); - test("allows wrapper tools when metadata references speak", () => { - const allowed = isVoicePermissionAllowed( + test("denies wrapper tools even when metadata references speak", () => { + const denied = isVoicePermissionAllowed( buildRequest({ name: "codextool", metadata: { @@ -52,18 +52,6 @@ describe("isVoicePermissionAllowed", () => { }, }) ); - expect(allowed).toBe(true); - }); - - test("denies codextool when metadata includes shell-like operations", () => { - const denied = isVoicePermissionAllowed( - buildRequest({ - name: "codextool", - metadata: { - questions: [{ question: "Allow codextool to execute shell command?" }], - }, - }) - ); expect(denied).toBe(false); }); }); diff --git a/packages/server/src/server/voice-permission-policy.ts b/packages/server/src/server/voice-permission-policy.ts index 144876e8d..a8e1a7d79 100644 --- a/packages/server/src/server/voice-permission-policy.ts +++ b/packages/server/src/server/voice-permission-policy.ts @@ -1,36 +1,6 @@ import type { AgentPermissionRequest } from "./agent/agent-sdk-types.js"; import { isSpeakToolName } from "./agent/tool-name-normalization.js"; -const SPEAK_TOKEN_SET = ["speak"]; -const DENIED_TOKEN_SET = [ - "bash", - "shell", - "terminal", - "apply_patch", - "edit", - "read_file", - "write_file", - "delete_file", - "web_search", - "fetch_url", - "create_agent", - "list_agents", - "kill_agent", - "wait_for_agent", -]; - -function containsAny(text: string, tokens: readonly string[]): boolean { - return tokens.some((token) => text.includes(token)); -} - -function stringifyMetadata(metadata: unknown): string { - try { - return JSON.stringify(metadata ?? {})?.toLowerCase() ?? ""; - } catch { - return ""; - } -} - /** Voice assistant policy: only allow the speak tool. */ export function isVoicePermissionAllowed(request: AgentPermissionRequest): boolean { if (request.kind !== "tool") { @@ -42,22 +12,5 @@ export function isVoicePermissionAllowed(request: AgentPermissionRequest): boole return false; } - if (isSpeakToolName(normalizedName)) { - return true; - } - - const metadataText = stringifyMetadata({ - name: request.name, - title: request.title ?? null, - description: request.description ?? null, - metadata: request.metadata ?? null, - input: request.input ?? null, - }); - if (!metadataText) { - return false; - } - - const mentionsAllowedTooling = containsAny(metadataText, SPEAK_TOKEN_SET); - const mentionsDeniedTooling = containsAny(metadataText, DENIED_TOKEN_SET); - return mentionsAllowedTooling && !mentionsDeniedTooling; + return isSpeakToolName(normalizedName); }