From 71184221708fd5b703106a1214e002f30a46eeb6 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Sat, 29 Aug 2026 11:47:09 +0530 Subject: [PATCH 1/5] feat(mobile): handle agent permission requests in chat Surface permission requests (e.g. external directory access) that are not tied to a message tool part as a dedicated panel above the chat input, so users can accept/deny and unblock the stuck agent response. Also includes project directory display tweaks in the stats card. Co-authored-by: muse-spark-1.2 --- .../project/[projectId]/[sessionId]/index.tsx | 47 +++++++++++++++++++ apps/mobile/components/OpencodeStatsCard.tsx | 24 ++++++++-- apps/mobile/components/hooks/event-stream.ts | 9 +++- apps/mobile/store/opencode-stats.store.ts | 5 +- 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx index fd03426..d779c88 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx @@ -33,6 +33,7 @@ import { QuestionRequest } from "@/store/questions.store" import { usePermissions } from "@/store/permissions.store" import { getPendingPermissions, replyToPermission } from "@/lib/permissions" import { PermissionRequest } from "@/store/permissions.store" +import { PermissionBlock } from "@/components/permission-block" import { getAuthHeader } from "@/lib/utils" import { notifyAgentStatus, setActiveChatScreen, clearActiveChatScreen } from "@/lib/notifications" @@ -675,6 +676,39 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi const handleScrollBeginDrag = useCallback(() => Keyboard.dismiss(), []) + const matchedPermissionIds = useMemo(() => { + const ids = new Set() + for (const msg of rawMessages) { + for (const part of msg.parts ?? []) { + if (part.type === "tool-invocation") { + for (const p of pendingPermissions) { + if ( + p.tool?.messageID === msg.id && + p.tool?.callID === part.toolInvocation.toolCallId + ) { + ids.add(p.id) + } + } + } else if (part.type === "tool") { + for (const p of pendingPermissions) { + if (p.tool?.messageID === msg.id && p.tool?.callID === part.callID) { + ids.add(p.id) + } + } + } + } + } + return ids + }, [rawMessages, pendingPermissions]) + + // Permissions that are not tied to a specific message tool part (e.g. external + // directory access requests) cannot be rendered inline, so surface them as a + // dedicated panel so the user can accept/deny and unblock the agent. + const orphanPermissions = useMemo( + () => pendingPermissions.filter((p) => !matchedPermissionIds.has(p.id)), + [pendingPermissions, matchedPermissionIds] + ) + const renderItem = useCallback( ({ item }: { item: Message }) => { const hasQuestionTool = item.parts?.some( @@ -926,6 +960,19 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi )} + {orphanPermissions.length > 0 && ( + + {orphanPermissions.map((perm) => ( + + ))} + + )} + - - {project.projectName} - + + + {project.directory + ? (project.directory.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? project.directory) + : project.projectName} + + {project.directory && ( + + {project.directory} + + )} + handleResetProject(project.projectId, project.projectName)} + onPress={() => + handleResetProject( + project.projectId, + project.directory + ? (project.directory.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? project.directory) + : project.projectName + ) + } className="p-1" > diff --git a/apps/mobile/components/hooks/event-stream.ts b/apps/mobile/components/hooks/event-stream.ts index 0c18b0c..3b5a277 100644 --- a/apps/mobile/components/hooks/event-stream.ts +++ b/apps/mobile/components/hooks/event-stream.ts @@ -240,12 +240,17 @@ export function useEventStream(url?: string, sessionId?: string, token?: string, if (!info.error && projectId && !countedMessageIds.has(info.id)) { countedMessageIds.add(info.id) const assistant = info as AssistantMessage + const project = + useProjects.getState().projects.find((p) => p.id === projectId) + const directory = project?.directory ?? "" const projectName = - useProjects.getState().projects.find((p) => p.id === projectId)?.name ?? - "Unknown project" + directory + ? (directory.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? directory) + : (project?.name ?? "Unknown project") useOpencodeStats.getState().incrementProjectStats( projectId, projectName, + directory, assistant.tokens?.input ?? 0, assistant.tokens?.output ?? 0, assistant.cost ?? 0 diff --git a/apps/mobile/store/opencode-stats.store.ts b/apps/mobile/store/opencode-stats.store.ts index e3401e4..5891005 100644 --- a/apps/mobile/store/opencode-stats.store.ts +++ b/apps/mobile/store/opencode-stats.store.ts @@ -14,6 +14,7 @@ export const DAILY_HISTORY_LIMIT = 30 export interface ProjectStats { projectId: string projectName: string + directory?: string responseCount: number totalInputTokens: number totalOutputTokens: number @@ -43,6 +44,7 @@ type OpencodeStatsStore = { incrementProjectStats: ( projectId: string, projectName: string, + directory: string, inputTokens: number, outputTokens: number, cost: number @@ -58,7 +60,7 @@ export const useOpencodeStats = create()( (set, get) => ({ projects: {}, - incrementProjectStats: (projectId, projectName, inputTokens, outputTokens, cost) => { + incrementProjectStats: (projectId, projectName, directory, inputTokens, outputTokens, cost) => { set((state) => { const existing = state.projects[projectId] const now = new Date().toISOString() @@ -83,6 +85,7 @@ export const useOpencodeStats = create()( const updated: ProjectStats = { projectId, projectName, + directory: directory || existing?.directory, responseCount: (existing?.responseCount ?? 0) + 1, totalInputTokens: (existing?.totalInputTokens ?? 0) + inputTokens, totalOutputTokens: (existing?.totalOutputTokens ?? 0) + outputTokens, From c155f80f75ad793d2f20a16ee332cba379365931 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Sat, 29 Aug 2026 12:36:48 +0530 Subject: [PATCH 2/5] fix(mobile): normalize and surface agent permission requests The mobile expected a permission shape (id/permission/patterns/tool) that does not match OpenCode's actual Permission.Request (requestID/action/ resources/source). This caused the SSE handler to drop requests (sessionID early-return) and the poller to filter out valid entries, so no in-chat prompt ever rendered and the agent hung. - Normalize incoming permissions (id/requestID, permission/action, patterns/resources, always/save, tool/source) in lib/permissions. - Relax sessionID gating in both the SSE handler and the poller so requests without a sessionID are still attributed to the active session. - Render unmatched (e.g. external directory access) permissions in a panel above the input, and make replyToPermission try both reply endpoint shapes. Co-authored-by: muse-spark-1.2 --- .../project/[projectId]/[sessionId]/index.tsx | 6 +- apps/mobile/app/sessions.tsx | 21 +-- apps/mobile/components/hooks/event-stream.ts | 23 +++- apps/mobile/lib/permissions.ts | 127 +++++++++++++++--- 4 files changed, 131 insertions(+), 46 deletions(-) diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx index d779c88..4d286a2 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx @@ -203,8 +203,8 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi return } try { - const perms = await getPendingPermissions(connection.url, connection.token) - const sessionPerms = perms.filter((p) => p.sessionID === sessionId) + const perms = await getPendingPermissions(connection.url, connection.token, sessionId) + const sessionPerms = perms.filter((p) => !p.sessionID || p.sessionID === sessionId) const current = usePermissions.getState().permissionsBySession[sessionId!] ?? EMPTY_PERMISSIONS if ( sessionPerms.length !== current.length || @@ -250,7 +250,7 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi const handlePermissionReply = useCallback(async (requestId: string, reply: "once" | "always" | "reject", message?: string) => { if (!connection?.url || !connection?.token) return - const success = await replyToPermission(connection.url, connection.token, requestId, reply, message) + const success = await replyToPermission(connection.url, connection.token, requestId, reply, message, sessionId) if (success) { removePermission(sessionId!, requestId) } diff --git a/apps/mobile/app/sessions.tsx b/apps/mobile/app/sessions.tsx index 2d5095d..b154963 100644 --- a/apps/mobile/app/sessions.tsx +++ b/apps/mobile/app/sessions.tsx @@ -15,7 +15,6 @@ import { THEME } from "@/lib/theme" import { cn } from "@/lib/utils" import { useChatStore } from "@/store/chat.store" import { useGlobalSessionStatus } from "@/components/hooks/event-stream" -import Animated, { useSharedValue, useAnimatedStyle, withRepeat, withTiming, Easing } from "react-native-reanimated" import AlertTriangle from "lucide-react-native/dist/esm/icons/triangle-alert" import ArrowLeft from "lucide-react-native/dist/esm/icons/arrow-left" import ArrowUpDown from "lucide-react-native/dist/esm/icons/arrow-up-down" @@ -70,18 +69,6 @@ function formatTime(ts: number, now: number) { return new Date(ts).toLocaleDateString() } -function StreamingPulse() { - const pulse = useSharedValue(1) - React.useEffect(() => { - pulse.value = withRepeat(withTiming(1.6, { duration: 900, easing: Easing.out(Easing.ease) }), -1, false) - }, []) - const style = useAnimatedStyle(() => ({ - transform: [{ scale: pulse.value }], - opacity: 1.2 - pulse.value * 0.6, - })) - return -} - const SessionItem = React.memo(function SessionItem({ session, isLast, @@ -153,12 +140,8 @@ const SessionItem = React.memo(function SessionItem({ {isStreaming && ( - - - - - - Working + + )} diff --git a/apps/mobile/components/hooks/event-stream.ts b/apps/mobile/components/hooks/event-stream.ts index 3b5a277..9afd9d1 100644 --- a/apps/mobile/components/hooks/event-stream.ts +++ b/apps/mobile/components/hooks/event-stream.ts @@ -1,11 +1,12 @@ import { useEffect, useRef } from "react" import { useChatStore } from "@/store/chat.store" import { useMessages, AssistantMessage, Message, Part } from "@/store/messages.store" -import { usePermissions, PermissionRequest } from "@/store/permissions.store" +import { usePermissions } from "@/store/permissions.store" import { useOpencodeStats } from "@/store/opencode-stats.store" import { useProjects } from "@/store/projects.store" import { getAuthHeader } from "@/lib/utils" import { notifyAgentStatus } from "@/lib/notifications" +import { normalizePermission } from "@/lib/permissions" type SSEEvent = { type: string @@ -406,13 +407,19 @@ export function useEventStream(url?: string, sessionId?: string, token?: string, } case "permission.asked": { - const perm = props as unknown as PermissionRequest - if (!perm || perm.sessionID !== currentSessionId) return + const raw = props as unknown as Record + const perm = normalizePermission(raw, currentSessionId) const current = usePermissions.getState().permissionsBySession[currentSessionId] ?? [] - if (!current.some((p) => p.id === perm.id)) { + const exists = + current.some((p) => p.id === perm.id) || + (!perm.id && + current.some( + (p) => p.permission === perm.permission && p.sessionID === currentSessionId + )) + if (!exists) { usePermissions.getState().setPermissions(currentSessionId, [...current, perm]) notifyAgentStatus({ - key: `${currentSessionId}:permission:${perm.id}`, + key: `${currentSessionId}:permission:${perm.id || perm.permission}`, kind: "permission", title: "Agent needs permission", message: perm.permission || "Review the pending permission request.", @@ -424,8 +431,10 @@ export function useEventStream(url?: string, sessionId?: string, token?: string, } case "permission.replied": { - const requestID = props.requestID as string | undefined - if (!requestID || props.sessionID !== currentSessionId) return + const requestID = + (props.requestID as string | undefined) ?? (props.id as string | undefined) + if (!requestID) return + if (props.sessionID && props.sessionID !== currentSessionId) return usePermissions.getState().removePermission(currentSessionId, requestID) break } diff --git a/apps/mobile/lib/permissions.ts b/apps/mobile/lib/permissions.ts index bf302cf..c34fc58 100644 --- a/apps/mobile/lib/permissions.ts +++ b/apps/mobile/lib/permissions.ts @@ -1,9 +1,85 @@ import { PermissionRequest } from "@/store/permissions.store" import { getAuthHeader } from "@/lib/utils" +type RawPermission = Record + +function asString(value: unknown): string | undefined { + if (typeof value === "string" && value.length > 0) return value + return undefined +} + +function asStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map((v) => (typeof v === "string" ? v : typeof v === "object" && v !== null ? JSON.stringify(v) : undefined)) + .filter((v): v is string => typeof v === "string" && v.length > 0) + } + if (typeof value === "string" && value.length > 0) return [value] + return [] +} + +// OpenCode returns permission requests with varying shapes across versions: +// - id vs requestID +// - permission vs action +// - patterns vs resources +// - always vs save +// - tool { messageID, callID } vs source { type, messageID, id } +// Normalize everything into the mobile PermissionRequest shape so the UI can +// always render and reply to it. +export function normalizePermission(raw: RawPermission, fallbackSessionId?: string): PermissionRequest { + const id = asString(raw.id) ?? asString(raw.requestID) ?? asString(raw.permissionID) ?? "" + const sessionID = asString(raw.sessionID) ?? asString(raw.sessionId) ?? fallbackSessionId ?? "" + + const permission = + asString(raw.permission) ?? + asString(raw.action) ?? + asString(raw.type) ?? + "access" + + const patterns = asStringArray(raw.patterns).length + ? asStringArray(raw.patterns) + : asStringArray(raw.resources) + + const always = asStringArray(raw.always).length + ? asStringArray(raw.always) + : asStringArray(raw.save) + + const metadata = + raw.metadata && typeof raw.metadata === "object" + ? (raw.metadata as Record) + : typeof raw.permission === "object" && raw.permission !== null + ? (raw.permission as Record) + : {} + + let tool = raw.tool && typeof raw.tool === "object" ? (raw.tool as Record) : undefined + if (!tool && raw.source && typeof raw.source === "object") { + const source = raw.source as Record + tool = { + messageID: asString(source.messageID) ?? "", + callID: asString(source.id) ?? asString(source.callID) ?? "", + } + } + + const normalizedTool = + tool && typeof tool.messageID === "string" && typeof tool.callID === "string" + ? { messageID: tool.messageID, callID: tool.callID } + : undefined + + return { + id, + sessionID, + permission, + patterns, + metadata, + always, + tool: normalizedTool, + } +} + export const getPendingPermissions = async ( url: string, - token: string + token: string, + sessionId?: string ): Promise => { try { const res = await fetch(`${url}/permission`, { @@ -14,7 +90,10 @@ export const getPendingPermissions = async ( }) if (!res.ok) return [] const data = await res.json() - return Array.isArray(data) ? data : [] + const list = Array.isArray(data) ? data : Array.isArray((data as { data?: unknown }).data) ? ((data as { data: unknown[] }).data) : [] + return list + .filter((p): p is RawPermission => !!p && typeof p === "object") + .map((p) => normalizePermission(p, sessionId)) } catch { return [] } @@ -25,21 +104,35 @@ export const replyToPermission = async ( token: string, requestId: string, reply: "once" | "always" | "reject", - message?: string + message?: string, + sessionId?: string ): Promise => { - try { - const body: Record = { reply } - if (message) body.message = message - const res = await fetch(`${url}/permission/${requestId}/reply`, { - method: "POST", - headers: { - Authorization: getAuthHeader(token), - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }) - return res.ok - } catch { - return false + const tryReply = async (path: string): Promise => { + try { + const body: Record = { reply } + if (message) body.message = message + const res = await fetch(`${url}${path}`, { + method: "POST", + headers: { + Authorization: getAuthHeader(token), + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }) + return res.ok + } catch { + return false + } + } + + // OpenCode exposes the reply endpoint in a couple of shapes across versions. + const candidates = [ + `/permission/${requestId}/reply`, + sessionId ? `/session/${sessionId}/permission/${requestId}/reply` : null, + ].filter((c): c is string => !!c) + + for (const path of candidates) { + if (await tryReply(path)) return true } + return false } From a56941a49a253d8de1f230e7743ee372a05c0e35 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Sat, 29 Aug 2026 12:39:13 +0530 Subject: [PATCH 3/5] debug(mobile): add permission payload logging to diagnose missing prompt Temporary console logging in the SSE permission handler, the poller, and the session render to capture the real OpenCode permission shape and confirm the panel receives pending permissions. Co-authored-by: muse-spark-1.2 --- apps/mobile/app/project/[projectId]/[sessionId]/index.tsx | 3 +++ apps/mobile/components/hooks/event-stream.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx index 4d286a2..8948a91 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx @@ -204,6 +204,7 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi } try { const perms = await getPendingPermissions(connection.url, connection.token, sessionId) + console.log("[PERM-DEBUG] pollPermissions raw:", JSON.stringify(perms)) const sessionPerms = perms.filter((p) => !p.sessionID || p.sessionID === sessionId) const current = usePermissions.getState().permissionsBySession[sessionId!] ?? EMPTY_PERMISSIONS if ( @@ -709,6 +710,8 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi [pendingPermissions, matchedPermissionIds] ) + console.log("[PERM-DEBUG] pendingPermissions:", JSON.stringify(pendingPermissions), "orphan:", JSON.stringify(orphanPermissions.map((p) => p.id))) + const renderItem = useCallback( ({ item }: { item: Message }) => { const hasQuestionTool = item.parts?.some( diff --git a/apps/mobile/components/hooks/event-stream.ts b/apps/mobile/components/hooks/event-stream.ts index 9afd9d1..cb7686b 100644 --- a/apps/mobile/components/hooks/event-stream.ts +++ b/apps/mobile/components/hooks/event-stream.ts @@ -408,7 +408,9 @@ export function useEventStream(url?: string, sessionId?: string, token?: string, case "permission.asked": { const raw = props as unknown as Record + console.log("[PERM-DEBUG] permission.asked raw:", JSON.stringify(raw)) const perm = normalizePermission(raw, currentSessionId) + console.log("[PERM-DEBUG] permission.asked normalized:", JSON.stringify(perm)) const current = usePermissions.getState().permissionsBySession[currentSessionId] ?? [] const exists = current.some((p) => p.id === perm.id) || From 1e483cc845629374d42cb38603ff0e4fc6208963 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Sun, 30 Aug 2026 22:49:50 +0530 Subject: [PATCH 4/5] feat(mobile): add pull-to-refresh to connections list - Bypass 30s rate limit on manual refresh for immediate feedback - Re-check all connection health statuses in parallel - Re-fetch active connection project info on refresh --- apps/mobile/app/(tabs)/index.tsx | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index fa29dc6..62bea54 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -1,5 +1,5 @@ import * as React from "react" -import { FlatList, Image, Modal, Pressable, TextInput, View } from "react-native" +import { FlatList, Image, Modal, Pressable, RefreshControl, TextInput, View } from "react-native" import { useSafeAreaInsets } from "react-native-safe-area-context" import { Text } from "@/components/ui/text" @@ -256,6 +256,7 @@ export default function HomeScreen() { const theme = colorScheme ?? "light" const currentConnection = React.useMemo(() => connections.find((c) => c.id === current) ?? null, [connections, current]) + const [refreshing, setRefreshing] = React.useState(false) const [searchQuery, setSearchQuery] = React.useState("") const [filter, setFilter] = React.useState("all") const [sortBy, setSortBy] = React.useState("recent") @@ -264,9 +265,9 @@ export default function HomeScreen() { const lastHealthCheckRef = React.useRef(0) - const checkHealth = React.useCallback(async () => { + const checkHealth = React.useCallback(async (force = false) => { const now = Date.now() - if (now - lastHealthCheckRef.current < 30000) return + if (!force && now - lastHealthCheckRef.current < 30000) return lastHealthCheckRef.current = now await Promise.all(connections.map(async (conn) => { if (!conn.url || !conn.token) return @@ -284,6 +285,18 @@ export default function HomeScreen() { })) }, [connections.length, setConnectionHealth]) + const handleRefresh = React.useCallback(async () => { + setRefreshing(true) + await checkHealth(true) + if (currentConnection?.url && currentConnection?.token) { + const project = await getCurrentProject(currentConnection.url, currentConnection.token) + if (project) { + setProjectForConnection(currentConnection.id, project) + } + } + setRefreshing(false) + }, [checkHealth, currentConnection?.id, currentConnection?.url, currentConnection?.token, setProjectForConnection]) + useFocusEffect( React.useCallback(() => { checkHealth() @@ -489,6 +502,13 @@ export default function HomeScreen() { data={filteredConnections} keyExtractor={(c) => c.id} contentContainerStyle={filteredConnections.length === 0 ? undefined : { gap: 12, paddingBottom: 32 }} + refreshControl={ + + } ListEmptyComponent={ From 148cfd4dead75fca5ec72ef80d2ab4655e8123f6 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Mon, 31 Aug 2026 23:27:39 +0530 Subject: [PATCH 5/5] docs: add global install note recommending npx crosscode@latest --- README.md | 4 +++- packages/crosscode/README.md | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 636348f..157c0a5 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ npx crosscode npm install -g crosscode ``` +> **Note:** We recommend `npx crosscode@latest` over global install so you always pull the latest changes without needing to manually update. + ### Windows (WSL2) Run the same commands inside your WSL terminal. Node.js must be installed in WSL: @@ -129,7 +131,7 @@ sudo apt-get install -y nodejs npx crosscode ``` -Tip: `npx crosscode` always uses the latest version. If you installed it globally, run `npm update -g crosscode` to upgrade. +Tip: `npx crosscode@latest` always uses the latest version. If you installed it globally, run `npm update -g crosscode` to upgrade. --- diff --git a/packages/crosscode/README.md b/packages/crosscode/README.md index 829e011..06047ce 100644 --- a/packages/crosscode/README.md +++ b/packages/crosscode/README.md @@ -14,6 +14,12 @@ or, if using `pnpm`: pnpm dlx crosscode ``` +You can also install globally (though `npx crosscode@latest` is recommended so you always get the latest version): + +```bash +npm i -g crosscode +``` + Scan the QR code with the [CrossCode mobile app](https://crosscode.site) to connect. > Requires `opencode` installed. Tunnel provider (`cloudflared` or `ngrok`) required based on tier.