diff --git a/bun.lock b/bun.lock
index 33df62f49e..ad047015c7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -353,6 +353,7 @@
"react-native-screens": "~4.23.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.7.2",
+ "react-native-zeroconf": "0.14.0",
"whisper.rn": "0.5.5",
},
"devDependencies": {
@@ -4764,6 +4765,8 @@
"react-native-worklets": ["react-native-worklets@0.7.2", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "7.27.1", "@babel/plugin-transform-class-properties": "7.27.1", "@babel/plugin-transform-classes": "7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", "@babel/plugin-transform-optional-chaining": "7.27.1", "@babel/plugin-transform-shorthand-properties": "7.27.1", "@babel/plugin-transform-template-literals": "7.27.1", "@babel/plugin-transform-unicode-regex": "7.27.1", "@babel/preset-typescript": "7.27.1", "convert-source-map": "2.0.0", "semver": "7.7.3" }, "peerDependencies": { "@babel/core": "*", "react": "*", "react-native": "*" } }, "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog=="],
+ "react-native-zeroconf": ["react-native-zeroconf@0.14.0", "", { "dependencies": { "events": "^3.0.0" }, "peerDependencies": { "react-native": ">=0.60" } }, "sha512-TqjORroaVZrBYLzk3YtviQy8lUl/iiMacknxixRYlmGaqgsv4LJXIYafpnvPa3y2SC4/qu2mvF8D1/VTTxylgQ=="],
+
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["react-remove-scroll@2.5.5", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.3", "react-style-singleton": "^2.2.1", "tslib": "^2.1.0", "use-callback-ref": "^1.3.0", "use-sidecar": "^1.1.2" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw=="],
diff --git a/packages/mobile-voice/AGENTS.md b/packages/mobile-voice/AGENTS.md
index a239731503..53ddc77028 100644
--- a/packages/mobile-voice/AGENTS.md
+++ b/packages/mobile-voice/AGENTS.md
@@ -123,6 +123,46 @@ Run all commands from `packages/mobile-voice`.
- Rebuild the dev client after native module additions or changes.
- For optional native capability usage, prefer runtime fallback paths instead of hard crashes.
+## Expo Native Config (EAS)
+
+- Treat `packages/mobile-voice/app.json` as the source of truth for iOS native metadata in EAS cloud builds.
+- Do not rely on manual edits in `ios/mobilevoice/Info.plist`, entitlements files, or `PrivacyInfo.xcprivacy`; for this package they are generated outputs.
+- Keep generated native folders untracked in git (`/ios`, `/android`) to avoid mixed CNG/bare behavior during EAS builds.
+- Put App Store compliance and permission metadata in app config using these fields:
+ - `expo.ios.infoPlist` for Info.plist keys (usage strings, ATS, Bonjour, and related keys).
+ - `expo.ios.config.usesNonExemptEncryption` for export-compliance encryption declaration.
+ - `expo.ios.entitlements` for iOS entitlements.
+ - `expo.ios.privacyManifests` for Apple privacy manifest declarations.
+- Keep `app.json` entries explicit and review-friendly:
+ - Permission descriptions should be complete, product-specific sentences.
+ - Compliance keys should be set intentionally rather than relying on implicit defaults.
+ - Preserve existing JSON style in this package (concise arrays and stable key grouping).
+- After native config changes, verify resolved config with `bunx expo config --type prebuild --json` and check the resulting `ios` fields.
+
+Example shape:
+
+```json
+{
+ "expo": {
+ "ios": {
+ "infoPlist": {
+ "NSCameraUsageDescription": "...",
+ "NSMicrophoneUsageDescription": "..."
+ },
+ "config": {
+ "usesNonExemptEncryption": false
+ },
+ "entitlements": {
+ "com.apple.developer.kernel.extended-virtual-addressing": true
+ },
+ "privacyManifests": {
+ "NSPrivacyAccessedAPITypes": []
+ }
+ }
+ }
+}
+```
+
## Common Pitfalls
- Black screen + "No script URL provided" often means a stale dev client binary.
diff --git a/packages/mobile-voice/app.json b/packages/mobile-voice/app.json
index f426749c68..3dcbf0232b 100644
--- a/packages/mobile-voice/app.json
+++ b/packages/mobile-voice/app.json
@@ -10,11 +10,17 @@
"ios": {
"icon": "./assets/images/icon.png",
"bundleIdentifier": "com.anomalyco.mobilevoice",
+ "config": {
+ "usesNonExemptEncryption": false
+ },
"entitlements": {
"com.apple.developer.kernel.extended-virtual-addressing": true
},
"infoPlist": {
- "NSMicrophoneUsageDescription": "This app needs microphone access for live speech-to-text dictation.",
+ "NSMicrophoneUsageDescription": "Control uses the microphone while you hold Record to turn your speech into text for an OpenCode session.",
+ "NSCameraUsageDescription": "Control uses the camera to scan the OpenCode pairing QR code shown on your computer.",
+ "NSLocalNetworkUsageDescription": "Control uses your local network to discover and connect to OpenCode servers running on your computer.",
+ "NSBonjourServices": ["_http._tcp."],
"NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true,
"NSExceptionDomains": {
@@ -23,8 +29,7 @@
"NSExceptionAllowsInsecureHTTPLoads": true
}
}
- },
- "ITSAppUsesNonExemptEncryption": false
+ }
}
},
"android": {
@@ -40,7 +45,10 @@
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.RECORD_AUDIO",
- "android.permission.MODIFY_AUDIO_SETTINGS"
+ "android.permission.MODIFY_AUDIO_SETTINGS",
+ "android.permission.ACCESS_NETWORK_STATE",
+ "android.permission.ACCESS_WIFI_STATE",
+ "android.permission.CHANGE_WIFI_MULTICAST_STATE"
],
"predictiveBackGestureEnabled": false
},
diff --git a/packages/mobile-voice/package.json b/packages/mobile-voice/package.json
index 4186248411..41535a8b00 100644
--- a/packages/mobile-voice/package.json
+++ b/packages/mobile-voice/package.json
@@ -50,6 +50,7 @@
"react-native-screens": "~4.23.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.7.2",
+ "react-native-zeroconf": "0.14.0",
"whisper.rn": "0.5.5"
},
"devDependencies": {
diff --git a/packages/mobile-voice/src/app/index.tsx b/packages/mobile-voice/src/app/index.tsx
index e749d7be60..c45171ded4 100644
--- a/packages/mobile-voice/src/app/index.tsx
+++ b/packages/mobile-voice/src/app/index.tsx
@@ -37,8 +37,9 @@ import * as FileSystem from "expo-file-system/legacy"
import { fetch as expoFetch } from "expo/fetch"
import { buildPermissionCardModel } from "@/lib/pending-permissions"
import { unregisterRelayDevice } from "@/lib/relay-client"
+import { useMdnsDiscovery } from "@/hooks/use-mdns-discovery"
import { useMonitoring, type MonitorJob, type PermissionDecision } from "@/hooks/use-monitoring"
-import { looksLikeLocalHost, useServerSessions } from "@/hooks/use-server-sessions"
+import { DEFAULT_RELAY_URL, looksLikeLocalHost, useServerSessions } from "@/hooks/use-server-sessions"
import { ensureNotificationPermissions, getDevicePushToken } from "@/notifications/monitoring-notifications"
const CONTROL_HEIGHT = 86
@@ -229,6 +230,20 @@ function formatSessionUpdated(updatedMs: number): string {
}
}
+function formatWorkingDirectory(directory?: string): string {
+ if (!directory) return "Not available"
+
+ if (directory.startsWith("/Users/")) {
+ const segments = directory.split("/")
+ if (segments.length >= 4) {
+ const tail = segments.slice(3).join("/")
+ return tail.length > 0 ? `~/${tail}` : "~"
+ }
+ }
+
+ return directory
+}
+
type DropdownMode = "none" | "server" | "session"
type Pair = {
@@ -638,10 +653,17 @@ export default function DictationScreen() {
findServerForSession,
} = useServerSessions()
+ const { discoveredServers, discoveryStatus, discoveryError, discoveryAvailable, refreshDiscovery } = useMdnsDiscovery(
+ {
+ enabled: onboardingComplete && localNetworkPermissionState !== "denied",
+ },
+ )
+
const {
beginMonitoring,
activePermissionRequest,
devicePushToken,
+ latestAssistantContext,
latestAssistantResponse,
monitorJob,
monitorStatus,
@@ -1755,7 +1777,29 @@ export default function DictationScreen() {
const agentStateText = hasAssistantResponse ? latestAssistantResponse : "Waiting for agent…"
const shouldShowSend = hasCompletedSession && hasTranscript && !hasPendingPermission
const activeServer = servers.find((s) => s.id === activeServerId) ?? null
+ const discoveredServerOptions = useMemo(() => {
+ const saved = new Set(servers.map((server) => server.url.replace(/\/+$/, "")))
+ return discoveredServers.filter((server) => !saved.has(server.url.replace(/\/+$/, "")))
+ }, [discoveredServers, servers])
+ const discoveredServerEmptyLabel =
+ discoveryStatus === "error"
+ ? "Unable to discover local servers"
+ : discoveryStatus === "scanning"
+ ? "Scanning local network..."
+ : "No local servers found"
const activeSession = activeServer?.sessions.find((s) => s.id === activeSessionId) ?? null
+ let currentSessionModelLabel = "Not available"
+ if (latestAssistantContext?.modelID) {
+ currentSessionModelLabel = latestAssistantContext.modelID
+ if (latestAssistantContext.providerID) {
+ currentSessionModelLabel = `${latestAssistantContext.providerID}/${latestAssistantContext.modelID}`
+ }
+ }
+ const currentSessionDirectory = latestAssistantContext?.workingDirectory ?? activeSession?.directory
+ const currentSessionUpdated = activeSession ? formatSessionUpdated(activeSession.updated) : ""
+ const sessionList = activeSession
+ ? (activeServer?.sessions ?? []).filter((session) => session.id !== activeSession.id)
+ : (activeServer?.sessions ?? [])
const canSendToSession = !!activeServer && activeServer.status === "online" && !!activeSession
const isReplyingToActivePermission =
activePermissionRequest !== null && respondingPermissionID === activePermissionRequest.id
@@ -1956,8 +2000,8 @@ export default function DictationScreen() {
],
}))
- const menuRows =
- effectiveDropdownMode === "server" ? Math.max(servers.length, 1) : Math.max(activeServer?.sessions.length ?? 0, 1)
+ const serverMenuRows = 2 + Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1)
+ const menuRows = effectiveDropdownMode === "server" ? serverMenuRows : Math.max(activeServer?.sessions.length ?? 0, 1)
const expandedRowsHeight = Math.min(menuRows, DROPDOWN_VISIBLE_ROWS) * 42
const dropdownFooterExtraHeight =
effectiveDropdownMode === "server"
@@ -2069,10 +2113,11 @@ export default function DictationScreen() {
}
if (next === "server") {
refreshAllServerHealth()
+ refreshDiscovery()
}
return next
})
- }, [refreshAllServerHealth])
+ }, [refreshAllServerHealth, refreshDiscovery])
const toggleSessionMenu = useCallback(() => {
if (!activeServer || activeServer.status !== "online") return
@@ -2158,6 +2203,20 @@ export default function DictationScreen() {
[devicePushToken, removeServer, serversRef],
)
+ const handleConnectDiscoveredServer = useCallback(
+ (url: string) => {
+ const ok = addServer(url, DEFAULT_RELAY_URL, "")
+ if (!ok) {
+ Alert.alert("Could not add server", "The discovered server could not be added. Try scanning the QR code.")
+ return
+ }
+
+ setDropdownMode("none")
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
+ },
+ [addServer],
+ )
+
const handleStartScan = useCallback(async () => {
scanLockRef.current = false
const current =
@@ -2467,9 +2526,9 @@ export default function DictationScreen() {
}
const onboardingSteps = [
{
- title: "Allow mic access.",
+ title: "Microphone access.",
body: "Control only listens while you hold the record button.",
- primaryLabel: microphonePermissionState === "pending" ? "Requesting microphone..." : "Allow microphone",
+ primaryLabel: microphonePermissionState === "pending" ? "Requesting microphone access..." : "Continue",
primaryDisabled: microphonePermissionState === "pending",
secondaryLabel: "Continue without granting",
visualTag: "MIC",
@@ -2480,7 +2539,7 @@ export default function DictationScreen() {
{
title: "Turn on notifications.",
body: "Get alerts when your OpenCode run finishes, fails, or needs your attention.",
- primaryLabel: notificationPermissionState === "pending" ? "Requesting notifications..." : "Allow notifications",
+ primaryLabel: notificationPermissionState === "pending" ? "Requesting notification access..." : "Continue",
primaryDisabled: notificationPermissionState === "pending",
secondaryLabel: "Continue without granting",
visualTag: "PUSH",
@@ -2489,9 +2548,9 @@ export default function DictationScreen() {
visualTagStyle: styles.onboardingVisualTagNotifications,
},
{
- title: "Enable local network.",
+ title: "Local network access.",
body: "This lets Control discover your machine on the same network.",
- primaryLabel: localNetworkPermissionState === "pending" ? "Requesting local network..." : "Allow local network",
+ primaryLabel: localNetworkPermissionState === "pending" ? "Requesting local network access..." : "Continue",
primaryDisabled: localNetworkPermissionState === "pending",
secondaryLabel: "Continue without granting",
visualTag: "LAN",
@@ -2501,10 +2560,10 @@ export default function DictationScreen() {
},
{
title: "Pair your computer.",
- body: "Start `opencode serve` on your computer, then scan the QR code to pair.",
- primaryLabel: "Scan OpenCode QR",
+ body: "Start `opencode serve --mdns` on your computer. Control can discover nearby servers automatically, or you can scan a QR code.",
+ primaryLabel: "Scan OpenCode QR (optional)",
primaryDisabled: false,
- secondaryLabel: "I will do this later",
+ secondaryLabel: "Skip and use discovery",
visualTag: "PAIR",
visualSurfaceStyle: styles.onboardingVisualSurfacePair,
visualOrbStyle: styles.onboardingVisualOrbPair,
@@ -2705,52 +2764,133 @@ export default function DictationScreen() {
bounces={false}
>
{effectiveDropdownMode === "server" ? (
- servers.length === 0 ? (
- No servers yet
- ) : (
- servers.map((server) => (
- handleSelectServer(server.id)}
- style={({ pressed }) => [styles.serverRow, pressed && styles.serverRowPressed]}
- >
-
- {server.name}
- handleDeleteServer(server.id)} hitSlop={8}>
- ✕
+ <>
+ Saved:
+
+ {servers.length === 0 ? (
+ No saved servers
+ ) : (
+ servers.map((server) => (
+ handleSelectServer(server.id)}
+ style={({ pressed }) => [styles.serverRow, pressed && styles.serverRowPressed]}
+ >
+
+ {server.name}
+ handleDeleteServer(server.id)} hitSlop={8}>
+ ✕
+
-
- ))
- )
+ ))
+ )}
+
+
+ Discovered:
+ {discoveryStatus === "scanning" ? : null}
+
+
+ {!discoveryAvailable ? (
+
+ Discovery unavailable in this build
+
+ ) : discoveredServerOptions.length === 0 ? (
+
+ {discoveredServerEmptyLabel}
+
+ ) : (
+ discoveredServerOptions.map((server, index) => (
+ handleConnectDiscoveredServer(server.url)}
+ style={({ pressed }) => [
+ styles.serverRow,
+ index === discoveredServerOptions.length - 1 && styles.serverRowLast,
+ pressed && styles.serverRowPressed,
+ ]}
+ >
+
+
+
+ {server.name}
+
+
+ {server.url}
+
+
+ Connect
+
+ ))
+ )}
+
+ {discoveryStatus === "error" && discoveryError ? (
+
+ {discoveryError}
+
+ ) : null}
+ >
) : activeServer ? (
- activeServer.sessions.length === 0 ? (
- activeServer.sessionsLoading ? null : (
- No sessions available
- )
- ) : (
- activeServer.sessions.map((session, index) => (
- handleSelectSession(session.id)}
- style={({ pressed }) => [
- styles.serverRow,
- index === activeServer.sessions.length - 1 && styles.serverRowLast,
- pressed && styles.serverRowPressed,
- ]}
- >
-
-
- {session.title}
+ <>
+ {activeSession ? (
+ <>
+
+ Current session
+
+
+ Working dir
+
+ {formatWorkingDirectory(currentSessionDirectory)}
+
+
+
+
+ Model
+
+ {currentSessionModelLabel}
+
+
+
+
+ Updated
+ {currentSessionUpdated || "Just now"}
+
+
+
+
+ >
+ ) : null}
+
+ {sessionList.length === 0 ? (
+ activeServer.sessionsLoading ? null : (
+
+ {activeSession ? "No other sessions available" : "No sessions available"}
- {formatSessionUpdated(session.updated)}
-
- ))
- )
+ )
+ ) : (
+ sessionList.map((session, index) => (
+ handleSelectSession(session.id)}
+ style={({ pressed }) => [
+ styles.serverRow,
+ index === sessionList.length - 1 && styles.serverRowLast,
+ pressed && styles.serverRowPressed,
+ ]}
+ >
+
+
+ {session.title}
+
+ {formatSessionUpdated(session.updated)}
+
+ ))
+ )}
+ >
) : (
Select a server first
)}
@@ -3756,12 +3896,68 @@ const styles = StyleSheet.create({
dropdownListContent: {
paddingBottom: 2,
},
+ currentSessionSummary: {
+ paddingHorizontal: 4,
+ paddingTop: 2,
+ paddingBottom: 8,
+ gap: 5,
+ },
+ currentSessionLabel: {
+ color: "#A3ACC0",
+ fontSize: 12,
+ fontWeight: "700",
+ letterSpacing: 0.4,
+ textTransform: "uppercase",
+ },
+ currentSessionMetaRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ },
+ currentSessionMetaKey: {
+ width: 74,
+ color: "#7C8599",
+ fontSize: 12,
+ fontWeight: "600",
+ },
+ currentSessionMetaValue: {
+ flex: 1,
+ color: "#D7DCE6",
+ fontSize: 13,
+ fontWeight: "500",
+ },
+ currentSessionDivider: {
+ width: "100%",
+ height: 1,
+ backgroundColor: "#222733",
+ marginBottom: 4,
+ },
+ serverGroupHeaderRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ marginTop: 8,
+ },
+ serverGroupLabel: {
+ color: "#8F97AA",
+ fontSize: 12,
+ fontWeight: "700",
+ letterSpacing: 0.4,
+ textTransform: "uppercase",
+ paddingHorizontal: 4,
+ paddingVertical: 4,
+ },
serverEmptyText: {
color: "#6F7686",
- fontSize: 13,
+ fontSize: 14,
textAlign: "center",
paddingVertical: 10,
},
+ serverGroupEmptyText: {
+ textAlign: "left",
+ paddingHorizontal: 4,
+ paddingVertical: 8,
+ },
serverRow: {
flexDirection: "row",
alignItems: "center",
@@ -3794,15 +3990,36 @@ const styles = StyleSheet.create({
serverNameText: {
flex: 1,
color: "#D6DAE4",
- fontSize: 14,
+ fontSize: 16,
fontWeight: "500",
},
sessionUpdatedText: {
color: "#8E96A8",
- fontSize: 12,
+ fontSize: 14,
fontWeight: "500",
marginLeft: 8,
},
+ discoveredServerCopy: {
+ flex: 1,
+ gap: 2,
+ },
+ discoveredServerMeta: {
+ color: "#818A9E",
+ fontSize: 12,
+ fontWeight: "500",
+ },
+ discoveredServerAction: {
+ color: "#B9C2D8",
+ fontSize: 13,
+ fontWeight: "700",
+ },
+ discoveryErrorText: {
+ color: "#7D8598",
+ fontSize: 11,
+ fontWeight: "500",
+ paddingHorizontal: 4,
+ paddingTop: 4,
+ },
serverDeleteIcon: {
color: "#8C93A3",
fontSize: 15,
@@ -3845,7 +4062,7 @@ const styles = StyleSheet.create({
sessionMenuActionText: {
flex: 1,
color: "#D6DAE4",
- fontSize: 14,
+ fontSize: 16,
fontWeight: "500",
},
statusLeft: {
diff --git a/packages/mobile-voice/src/hooks/use-mdns-discovery.ts b/packages/mobile-voice/src/hooks/use-mdns-discovery.ts
new file mode 100644
index 0000000000..20ac379fff
--- /dev/null
+++ b/packages/mobile-voice/src/hooks/use-mdns-discovery.ts
@@ -0,0 +1,281 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { Platform } from "react-native"
+
+type ZeroconfService = {
+ name?: unknown
+ fullName?: unknown
+ host?: unknown
+ port?: unknown
+ addresses?: unknown
+}
+
+type ZeroconfInstance = {
+ scan: (type?: string, protocol?: string, domain?: string, implType?: string) => void
+ stop: (implType?: string) => void
+ removeDeviceListeners: () => void
+ getServices: () => Record
+ on: (event: string, listener: (...args: unknown[]) => void) => void
+}
+
+type ZeroconfModule = {
+ default: new () => ZeroconfInstance
+ ImplType?: {
+ DNSSD?: string
+ }
+}
+
+export type DiscoveredServer = {
+ id: string
+ name: string
+ host: string
+ port: number
+ url: string
+}
+
+type DiscoveryStatus = "idle" | "scanning" | "error"
+
+type UseMdnsDiscoveryInput = {
+ enabled: boolean
+}
+
+function toErrorMessage(error: unknown): string {
+ if (error instanceof Error && error.message.trim().length > 0) {
+ return error.message
+ }
+
+ const next = String(error ?? "")
+ return next.trim().length > 0 ? next : "Unknown discovery error"
+}
+
+function cleanHost(input: string): string {
+ const trimmed = input.trim().replace(/\.$/, "")
+ if (!trimmed) return ""
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
+ return trimmed.slice(1, -1)
+ }
+ return trimmed
+}
+
+function isIPv4(input: string): boolean {
+ return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(input)
+}
+
+function hostTier(input: string): number {
+ if (input.endsWith(".local")) return 0
+ if (isIPv4(input)) {
+ if (input === "127.0.0.1") return 4
+ if (input.startsWith("10.") || input.startsWith("192.168.") || /^172\.(1[6-9]|2\d|3[0-1])\./.test(input)) {
+ return 1
+ }
+ if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(input)) {
+ return 1
+ }
+ return 2
+ }
+ if (input.includes(":")) return 3
+ return 2
+}
+
+function formatHostForURL(input: string): string {
+ return input.includes(":") ? `[${input}]` : input
+}
+
+function isOpenCodeService(service: ZeroconfService): boolean {
+ if (typeof service.name === "string" && service.name.toLowerCase().startsWith("opencode-")) {
+ return true
+ }
+
+ if (typeof service.fullName === "string" && service.fullName.toLowerCase().includes("opencode-")) {
+ return true
+ }
+
+ return false
+}
+
+function parseService(service: ZeroconfService): DiscoveredServer | null {
+ const port = typeof service.port === "number" ? service.port : Number(service.port)
+ if (!Number.isFinite(port) || port <= 0) {
+ return null
+ }
+
+ const hosts = new Set()
+
+ if (typeof service.host === "string") {
+ const host = cleanHost(service.host)
+ if (host.length > 0) {
+ hosts.add(host)
+ }
+ }
+
+ if (Array.isArray(service.addresses)) {
+ for (const address of service.addresses) {
+ if (typeof address !== "string") continue
+ const host = cleanHost(address)
+ if (host.length > 0) {
+ hosts.add(host)
+ }
+ }
+ }
+
+ const sortedHosts = [...hosts].sort((a, b) => hostTier(a) - hostTier(b))
+ const host = sortedHosts[0]
+ if (!host) {
+ return null
+ }
+
+ const name = typeof service.name === "string" && service.name.trim().length > 0 ? service.name.trim() : host
+ const fullName =
+ typeof service.fullName === "string" && service.fullName.trim().length > 0
+ ? service.fullName.trim()
+ : `${name}:${port}`
+ const url = `http://${formatHostForURL(host)}:${port}`
+
+ return {
+ id: `${fullName}|${url}`,
+ name,
+ host,
+ port,
+ url,
+ }
+}
+
+export function useMdnsDiscovery(input: UseMdnsDiscoveryInput) {
+ const [discoveredServers, setDiscoveredServers] = useState([])
+ const [discoveryStatus, setDiscoveryStatus] = useState("idle")
+ const [discoveryError, setDiscoveryError] = useState(null)
+ const [discoveryAvailable, setDiscoveryAvailable] = useState(Platform.OS !== "web")
+ const startScanRef = useRef<(() => void) | null>(null)
+
+ const refreshDiscovery = useCallback(() => {
+ startScanRef.current?.()
+ }, [])
+
+ useEffect(() => {
+ if (!input.enabled) {
+ startScanRef.current = null
+ setDiscoveredServers([])
+ setDiscoveryStatus("idle")
+ setDiscoveryError(null)
+ return
+ }
+
+ if (Platform.OS === "web") {
+ setDiscoveryAvailable(false)
+ setDiscoveryStatus("idle")
+ setDiscoveryError(null)
+ return
+ }
+
+ let active = true
+ let zeroconf: ZeroconfInstance | null = null
+ let androidImplType: string | undefined
+
+ const rebuildServices = () => {
+ if (!active || !zeroconf) return
+ const values = Object.values(zeroconf.getServices() ?? {})
+ const next = new Map()
+
+ for (const value of values) {
+ if (!isOpenCodeService(value)) continue
+ const parsed = parseService(value)
+ if (!parsed) continue
+ if (!next.has(parsed.url)) {
+ next.set(parsed.url, parsed)
+ }
+ }
+
+ setDiscoveredServers(
+ [...next.values()].sort((a, b) => {
+ const nameOrder = a.name.localeCompare(b.name)
+ if (nameOrder !== 0) return nameOrder
+ return a.url.localeCompare(b.url)
+ }),
+ )
+ }
+
+ const startScan = () => {
+ if (!active || !zeroconf) return
+
+ try {
+ zeroconf.stop(androidImplType)
+ } catch {
+ // noop
+ }
+
+ try {
+ zeroconf.scan("http", "tcp", "local.", androidImplType)
+ setDiscoveryStatus("scanning")
+ setDiscoveryError(null)
+ } catch (error) {
+ setDiscoveryStatus("error")
+ setDiscoveryError(toErrorMessage(error))
+ }
+ }
+
+ startScanRef.current = startScan
+
+ void import("react-native-zeroconf")
+ .then((module) => {
+ if (!active) return
+
+ const mod = module as ZeroconfModule
+ const Zeroconf = mod.default
+ if (typeof Zeroconf !== "function") {
+ setDiscoveryAvailable(false)
+ setDiscoveryStatus("error")
+ setDiscoveryError("mDNS module unavailable")
+ return
+ }
+
+ zeroconf = new Zeroconf()
+ androidImplType = Platform.OS === "android" ? (mod.ImplType?.DNSSD ?? "DNSSD") : undefined
+ setDiscoveryAvailable(true)
+
+ zeroconf.on("resolved", rebuildServices)
+ zeroconf.on("remove", rebuildServices)
+ zeroconf.on("update", rebuildServices)
+ zeroconf.on("error", (error) => {
+ if (!active) return
+ setDiscoveryStatus("error")
+ setDiscoveryError(toErrorMessage(error))
+ })
+
+ startScan()
+ })
+ .catch((error) => {
+ if (!active) return
+ setDiscoveryAvailable(false)
+ setDiscoveryStatus("error")
+ setDiscoveryError(toErrorMessage(error))
+ })
+
+ return () => {
+ active = false
+ startScanRef.current = null
+ if (!zeroconf) return
+
+ try {
+ zeroconf.stop(androidImplType)
+ } catch {
+ // noop
+ }
+
+ try {
+ zeroconf.removeDeviceListeners()
+ } catch {
+ // noop
+ }
+ }
+ }, [input.enabled])
+
+ return useMemo(
+ () => ({
+ discoveredServers,
+ discoveryStatus,
+ discoveryError,
+ discoveryAvailable,
+ refreshDiscovery,
+ }),
+ [discoveredServers, discoveryStatus, discoveryError, discoveryAvailable, refreshDiscovery],
+ )
+}
diff --git a/packages/mobile-voice/src/hooks/use-monitoring.ts b/packages/mobile-voice/src/hooks/use-monitoring.ts
index 5a8694dfbb..deb3e62be5 100644
--- a/packages/mobile-voice/src/hooks/use-monitoring.ts
+++ b/packages/mobile-voice/src/hooks/use-monitoring.ts
@@ -121,6 +121,7 @@ export function useMonitoring({
const [monitorJob, setMonitorJob] = useState(null)
const [monitorStatus, setMonitorStatus] = useState("")
const [latestAssistantResponse, setLatestAssistantResponse] = useState("")
+ const [latestAssistantContext, setLatestAssistantContext] = useState(null)
const [pendingPermissions, setPendingPermissions] = useState([])
const [replyingPermissionID, setReplyingPermissionID] = useState(null)
const [appState, setAppState] = useState(AppState.currentState)
@@ -248,18 +249,20 @@ export function useMonitoring({
}
const payload = (await response.json()) as unknown
- const text = findLatestAssistantCompletionText(payload)
+ const latest = findLatestAssistantCompletion(payload)
if (latestAssistantRequestRef.current !== requestID) return
if (activeSessionIdRef.current !== sessionID) return
- setLatestAssistantResponse(text)
- if (text) {
+ setLatestAssistantResponse(latest.text)
+ setLatestAssistantContext(latest.context)
+ if (latest.text) {
setAgentStateDismissed(false)
}
} catch {
if (latestAssistantRequestRef.current !== requestID) return
if (activeSessionIdRef.current !== sessionID) return
setLatestAssistantResponse("")
+ setLatestAssistantContext(null)
}
},
[activeSessionIdRef, setAgentStateDismissed],
@@ -443,6 +446,7 @@ export function useMonitoring({
useEffect(() => {
setLatestAssistantResponse("")
+ setLatestAssistantContext(null)
setPendingPermissions([])
setAgentStateDismissed(false)
if (!activeServerId || !activeSessionId) return
@@ -787,6 +791,7 @@ export function useMonitoring({
monitorStatus,
setMonitorStatus,
latestAssistantResponse,
+ latestAssistantContext,
activePermissionRequest,
pendingPermissionCount: pendingPermissions.length,
respondingPermissionID: replyingPermissionID,
@@ -798,6 +803,10 @@ export function useMonitoring({
type SessionMessageInfo = {
role?: unknown
time?: unknown
+ modelID?: unknown
+ providerID?: unknown
+ path?: unknown
+ agent?: unknown
}
type SessionMessagePart = {
@@ -810,6 +819,18 @@ type SessionMessagePayload = {
parts?: unknown
}
+type LatestAssistantContext = {
+ providerID: string | null
+ modelID: string | null
+ workingDirectory: string | null
+ agent: string | null
+}
+
+type LatestAssistantSnapshot = {
+ text: string
+ context: LatestAssistantContext | null
+}
+
function cleanTranscriptText(text: string): string {
return text.replace(/[ \t]+$/gm, "").trimEnd()
}
@@ -818,8 +839,39 @@ function cleanSessionText(text: string): string {
return cleanTranscriptText(text).trimStart()
}
-function findLatestAssistantCompletionText(payload: unknown): string {
- if (!Array.isArray(payload)) return ""
+function maybeString(value: unknown): string | null {
+ if (typeof value !== "string") return null
+ const trimmed = value.trim()
+ return trimmed.length > 0 ? trimmed : null
+}
+
+function extractAssistantContext(info: SessionMessageInfo): LatestAssistantContext | null {
+ const providerID = maybeString(info.providerID)
+ const modelID = maybeString(info.modelID)
+ const pathValue = info.path
+ const pathRecord = pathValue && typeof pathValue === "object" ? (pathValue as { cwd?: unknown }) : null
+ const workingDirectory = maybeString(pathRecord?.cwd)
+ const agent = maybeString(info.agent)
+
+ if (!providerID && !modelID && !workingDirectory && !agent) {
+ return null
+ }
+
+ return {
+ providerID,
+ modelID,
+ workingDirectory,
+ agent,
+ }
+}
+
+function findLatestAssistantCompletion(payload: unknown): LatestAssistantSnapshot {
+ if (!Array.isArray(payload)) {
+ return {
+ text: "",
+ context: null,
+ }
+ }
for (let index = payload.length - 1; index >= 0; index -= 1) {
const candidate = payload[index] as SessionMessagePayload
@@ -832,6 +884,7 @@ function findLatestAssistantCompletionText(payload: unknown): string {
const time = info.time as { completed?: unknown } | undefined
if (!time || typeof time !== "object") continue
if (typeof time.completed !== "number") continue
+ const context = extractAssistantContext(info)
const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
const text = parts
@@ -840,10 +893,16 @@ function findLatestAssistantCompletionText(payload: unknown): string {
.filter((part) => part.length > 0)
.join("\n\n")
- if (text.length > 0) {
- return text
+ if (text.length > 0 || context) {
+ return {
+ text,
+ context,
+ }
}
}
- return ""
+ return {
+ text: "",
+ context: null,
+ }
}
diff --git a/packages/mobile-voice/src/types/css-modules.d.ts b/packages/mobile-voice/src/types/css-modules.d.ts
new file mode 100644
index 0000000000..51d191ff9d
--- /dev/null
+++ b/packages/mobile-voice/src/types/css-modules.d.ts
@@ -0,0 +1,4 @@
+declare module "*.module.css" {
+ const classes: Record
+ export default classes
+}
diff --git a/packages/mobile-voice/src/types/react-native-zeroconf.d.ts b/packages/mobile-voice/src/types/react-native-zeroconf.d.ts
new file mode 100644
index 0000000000..93aa5677be
--- /dev/null
+++ b/packages/mobile-voice/src/types/react-native-zeroconf.d.ts
@@ -0,0 +1,23 @@
+declare module "react-native-zeroconf" {
+ export const ImplType: {
+ NSD: string
+ DNSSD: string
+ }
+
+ export type ZeroconfService = {
+ name?: string
+ fullName?: string
+ host?: string
+ port?: number
+ addresses?: string[]
+ txt?: Record
+ }
+
+ export default class Zeroconf {
+ scan(type?: string, protocol?: string, domain?: string, implType?: string): void
+ stop(implType?: string): void
+ removeDeviceListeners(): void
+ getServices(): Record
+ on(event: string, listener: (...args: unknown[]) => void): this
+ }
+}
diff --git a/packages/opencode/src/server/push-relay.ts b/packages/opencode/src/server/push-relay.ts
index da03d70ebd..f0188d3de7 100644
--- a/packages/opencode/src/server/push-relay.ts
+++ b/packages/opencode/src/server/push-relay.ts
@@ -294,15 +294,6 @@ async function post(input: { type: Type; sessionID: string }) {
const content = await notify(input)
- console.log("[ APN RELAY ] posting event", {
- serverID: next.pair.serverID,
- relayURL: next.relayURL,
- secretHash: secretHash(next.relaySecret),
- type: input.type,
- sessionID: input.sessionID,
- title: content.title,
- })
-
log.info("[ APN RELAY ] posting event", {
serverID: next.pair.serverID,
relayURL: next.relayURL,
@@ -328,15 +319,6 @@ async function post(input: { type: Type; sessionID: string }) {
})
.then(async (res) => {
if (res.ok) {
- console.log("[ APN RELAY ] relay accepted event", {
- status: res.status,
- serverID: next.pair.serverID,
- secretHash: secretHash(next.relaySecret),
- type: input.type,
- sessionID: input.sessionID,
- title: content.title,
- })
-
log.info("[ APN RELAY ] relay accepted event", {
status: res.status,
serverID: next.pair.serverID,