Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

---

Expand Down
26 changes: 23 additions & 3 deletions apps/mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<FilterType>("all")
const [sortBy, setSortBy] = React.useState<SortType>("recent")
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -489,6 +502,13 @@ export default function HomeScreen() {
data={filteredConnections}
keyExtractor={(c) => c.id}
contentContainerStyle={filteredConnections.length === 0 ? undefined : { gap: 12, paddingBottom: 32 }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={THEME[theme].primary}
/>
}
ListEmptyComponent={
<View className="items-center justify-center py-20 gap-4">
<View className="w-16 h-16 rounded-2xl bg-muted items-center justify-center">
Expand Down
7 changes: 4 additions & 3 deletions apps/mobile/app/project/[projectId]/[sessionId]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,9 @@ 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)
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 (
sessionPerms.length !== current.length ||
Expand Down Expand Up @@ -250,7 +251,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)
}
Expand Down
21 changes: 2 additions & 19 deletions apps/mobile/app/sessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <Animated.View style={style} className="absolute w-5 h-5 rounded-full bg-emerald-500/30" />
}

const SessionItem = React.memo(function SessionItem({
session,
isLast,
Expand Down Expand Up @@ -153,12 +140,8 @@ const SessionItem = React.memo(function SessionItem({
</Text>
</View>
{isStreaming && (
<View className="flex-row items-center gap-1.5 ml-3 shrink-0 bg-emerald-500/10 px-2.5 py-1 rounded-full border border-emerald-500/20">
<View className="w-5 h-5 items-center justify-center">
<StreamingPulse />
<View className="w-2 h-2 rounded-full bg-emerald-500" />
</View>
<Text className="text-[11px] font-semibold text-emerald-600">Working</Text>
<View className="ml-3 shrink-0">
<ActivityIndicator size="small" color="#10b981" />
</View>
)}
</Pressable>
Expand Down
25 changes: 18 additions & 7 deletions apps/mobile/components/hooks/event-stream.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -406,13 +407,21 @@ 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<string, unknown>
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] ?? []
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.",
Expand All @@ -424,8 +433,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
}
Expand Down
127 changes: 110 additions & 17 deletions apps/mobile/lib/permissions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,85 @@
import { PermissionRequest } from "@/store/permissions.store"
import { getAuthHeader } from "@/lib/utils"

type RawPermission = Record<string, unknown>

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<string, unknown>)
: typeof raw.permission === "object" && raw.permission !== null
? (raw.permission as Record<string, unknown>)
: {}

let tool = raw.tool && typeof raw.tool === "object" ? (raw.tool as Record<string, unknown>) : undefined
if (!tool && raw.source && typeof raw.source === "object") {
const source = raw.source as Record<string, unknown>
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<PermissionRequest[]> => {
try {
const res = await fetch(`${url}/permission`, {
Expand All @@ -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 []
}
Expand All @@ -25,21 +104,35 @@ export const replyToPermission = async (
token: string,
requestId: string,
reply: "once" | "always" | "reject",
message?: string
message?: string,
sessionId?: string
): Promise<boolean> => {
try {
const body: Record<string, unknown> = { 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<boolean> => {
try {
const body: Record<string, unknown> = { 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
}
6 changes: 6 additions & 0 deletions packages/crosscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading