From a1cba41d3ca21a6639ce61d6dc7eab35441ddffb Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Tue, 29 Sep 2026 19:59:32 +0530 Subject: [PATCH 1/2] feat: support opencode v2 alongside v1 with auto-detection Detect the server major via opencode --version and use matching serve flags (lowercase log level), log patterns, health paths and /api/* routes. Mobile negotiates the version per connection, remaps sessions/messages/permissions/files endpoints, handles both SSE envelopes, and mutes share/todo/question on v2. Fixes #125 --- apps/mobile/app/(tabs)/index.tsx | 15 ++- apps/mobile/app/connect.tsx | 24 ++-- .../project/[projectId]/[sessionId]/index.tsx | 22 +++- .../project/[projectId]/[sessionId]/tasks.tsx | 9 +- apps/mobile/components/hooks/event-stream.ts | 33 +++--- apps/mobile/components/session-header.tsx | 21 +++- apps/mobile/lib/diff.ts | 25 ++-- apps/mobile/lib/file-browser.ts | 64 ++++++++-- apps/mobile/lib/mentions.ts | 31 +++-- apps/mobile/lib/messages.ts | 109 ++++++++++++++++-- apps/mobile/lib/models.ts | 51 +++++--- apps/mobile/lib/permissions.ts | 41 +++++-- apps/mobile/lib/projects.ts | 26 +++++ apps/mobile/lib/questions.ts | 5 + apps/mobile/lib/recents.ts | 12 +- apps/mobile/lib/server-version.ts | 94 +++++++++++++++ apps/mobile/lib/sessions.ts | 62 ++++++++-- apps/mobile/lib/todo.ts | 3 + apps/mobile/lib/utils.ts | 22 ++++ apps/mobile/store/agents.store.ts | 8 +- apps/mobile/store/connection.store.ts | 3 + packages/crosscode/src/opencode-version.ts | 28 +++++ packages/crosscode/src/opencode.ts | 13 ++- packages/crosscode/src/port-detect.ts | 11 +- .../crosscode/src/providers/cloudflared.ts | 4 +- packages/crosscode/src/providers/tunnel.ts | 6 +- packages/crosscode/src/proxy.ts | 7 +- 27 files changed, 610 insertions(+), 139 deletions(-) create mode 100644 apps/mobile/lib/server-version.ts create mode 100644 packages/crosscode/src/opencode-version.ts diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index 1c12e37..49d7d1d 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -12,7 +12,8 @@ import { useAllowsMultipleConnections } from "@/lib/entitlement" import { useAuth } from "@/store/auth.store" import { usePaywall } from "@/store/paywall.store" import { isAtTunnelLimit, requestPaywall } from "@/lib/paywall" -import { cn, formatDirectory, getAuthHeader } from "@/lib/utils" +import { cn, formatDirectory } from "@/lib/utils" +import { detectServerVersion } from "@/lib/server-version" import { getCurrentProject } from "@/lib/projects" import AlertTriangle from "lucide-react-native/dist/esm/icons/triangle-alert" import ArrowUpDown from "lucide-react-native/dist/esm/icons/arrow-up-down" @@ -287,13 +288,11 @@ export default function HomeScreen() { await Promise.all(connections.map(async (conn) => { if (!conn.url || !conn.token) return try { - const res = await fetch(`${conn.url}/global/health`, { - method: "GET", - headers: { - "Authorization": getAuthHeader(conn.token) - } - }) - setConnectionHealth(conn.id, res.ok) + // Detecting the version doubles as the health check: a version + // means the server answered on a known API route. Always probe + // live here (already throttled above) so version and health stay fresh. + const version = await detectServerVersion(conn.url, conn.token, { force: true }) + setConnectionHealth(conn.id, version !== null) } catch { setConnectionHealth(conn.id, false) } diff --git a/apps/mobile/app/connect.tsx b/apps/mobile/app/connect.tsx index ee6abfb..a02c201 100644 --- a/apps/mobile/app/connect.tsx +++ b/apps/mobile/app/connect.tsx @@ -9,7 +9,8 @@ import { Text } from "@/components/ui/text" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import XIcon from "lucide-react-native/dist/esm/icons/x" -import { cn, getAuthHeader } from "@/lib/utils" +import { cn } from "@/lib/utils" +import { cacheServerVersion, detectServerVersion, type ServerVersion } from "@/lib/server-version" import { THEME } from "@/lib/theme" import { useColorScheme } from "nativewind" @@ -23,6 +24,7 @@ export default function Connect() { const [name, setName] = useState(`Connection ${new Date().toLocaleString()}`) const [testing, setTesting] = useState(false) const [tested, setTested] = useState<{ msg: string, error: boolean } | null>(null) + const [serverVersion, setServerVersion] = useState(null) const theme = colorScheme ?? "light" @@ -30,18 +32,18 @@ export default function Connect() { setTesting(true) try { - const res = await fetch(`${url}/global/health`, { - method: "GET", - headers: { - "Authorization": getAuthHeader(token) - } - }) - if (res.ok) { + // Detect the opencode API major: v2 answers /api/info, v1 answers + // /global/health. Reachable either way means the server is up. + const version = await detectServerVersion(url, token, { force: true }) + if (version) { + cacheServerVersion(url, version) + setServerVersion(version) setTested({ - msg: "Remote server reachable", + msg: version >= 2 ? "Remote server reachable (opencode v2)" : "Remote server reachable", error: false }) } else { + setServerVersion(null) setTested({ msg: "Remote server unreachable", error: true @@ -66,8 +68,10 @@ export default function Connect() { addConnection({ url, name, - token + token, + serverVersion: serverVersion ?? undefined, }) + cacheServerVersion(url, serverVersion ?? 1) router.replace("/") } diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx index 5254a70..49548de 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx @@ -15,7 +15,7 @@ import XIcon from "lucide-react-native/dist/esm/icons/x" import { useColorScheme } from "nativewind" import { THEME } from "@/lib/theme" import { Text } from "@/components/ui/text" -import { getMessages } from "@/lib/messages" +import { getMessages, sendPromptV2 } from "@/lib/messages" import { Pressable } from "react-native" import { Button } from "@/components/ui/button" import { MessageItem } from "@/components/message-item" @@ -34,7 +34,8 @@ import { usePermissions } from "@/store/permissions.store" import { replyToPermission } from "@/lib/permissions" import { PermissionRequest } from "@/store/permissions.store" import { PermissionBlock } from "@/components/permission-block" -import { getAuthHeader } from "@/lib/utils" +import { getAuthHeader, apiUrl } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" import { MentionFilePart, SendMention, resolveSendMentions } from "@/lib/mentions" import { notifyAgentStatus, setActiveChatScreen, clearActiveChatScreen } from "@/lib/notifications" @@ -153,6 +154,8 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi const pollQuestions = useCallback(async () => { if (!connection?.url || !connection?.token) return + // No questions API on v2; the UI is muted there (see supportsFeature). + if (getServerVersion(connection.url) >= 2) return if (appStateRef.current !== "active") { questionPollRef.current = setTimeout(pollQuestions, 5000) return @@ -318,6 +321,15 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi if (modelId && providerId) { body.model = { modelID: modelId, providerID: providerId } } + if (getServerVersion(connectionUrl) >= 2) { + return sendPromptV2(connectionUrl, connectionToken, targetSessionId, { + text, + agent: agentOverride ?? agent, + modelId, + providerId, + parts, + }) + } const res = await fetch(`${connectionUrl}/session/${targetSessionId}/message`, { method: "POST", headers: { @@ -466,7 +478,11 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi const abortStreaming = useCallback(async () => { if (!connection?.url || !isStreaming) return try { - await fetch(`${connection.url}/session/${sessionId}/abort`, { + // v2 renamed abort to interrupt. + const endpoint = getServerVersion(connection.url) >= 2 + ? `${apiUrl(connection.url, 2, "/session")}/${sessionId}/interrupt` + : `${connection.url}/session/${sessionId}/abort` + await fetch(endpoint, { method: "POST", headers: { Authorization: getAuthHeader(connection.token) }, }) diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/tasks.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/tasks.tsx index 0d13ad7..330c595 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/tasks.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/tasks.tsx @@ -14,6 +14,7 @@ import { THEME } from "@/lib/theme" import { cn } from "@/lib/utils" import { useConnections } from "@/store/connection.store" import { fetchSessionTodos, TodoTask } from "@/lib/todo" +import { getServerVersion, supportsFeature } from "@/lib/server-version" const STATUS_CONFIG: Record = { completed: { icon: CheckCircle2Icon, color: "text-green-500", bg: "bg-green-500/15", label: "Completed" }, @@ -42,6 +43,8 @@ export default function TasksPage() { const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) + const todosSupported = supportsFeature(getServerVersion(connection?.url), "todo") + const loadTodos = useCallback(async () => { if (!connection?.url || !connection?.token || !sessionId) return const data = await fetchSessionTodos(connection.url, connection.token, sessionId) @@ -168,10 +171,12 @@ export default function TasksPage() { - No tasks yet + {todosSupported ? "No tasks yet" : "Tasks need opencode v1"} - Tasks will appear here when the agent creates a task list for this session. + {todosSupported + ? "Tasks will appear here when the agent creates a task list for this session." + : "The v2 API does not expose session tasks yet. They will return automatically once supported."} ) : ( diff --git a/apps/mobile/components/hooks/event-stream.ts b/apps/mobile/components/hooks/event-stream.ts index e0a1a1f..5c18620 100644 --- a/apps/mobile/components/hooks/event-stream.ts +++ b/apps/mobile/components/hooks/event-stream.ts @@ -13,6 +13,20 @@ type SSEEvent = { properties: Record } +// v1 events look like { type, properties }; v2 wraps them as +// { id, type, data }. Accept both so either server version streams. +function normalizeEvent(parsed: unknown): SSEEvent | null { + if (!parsed || typeof parsed !== "object") return null + const raw = parsed as Record + const inner = raw.payload && raw.type === undefined ? raw.payload : raw + if (!inner || typeof inner !== "object") return null + const event = inner as Record + if (typeof event.type !== "string") return null + const properties = (event.data ?? event.properties ?? {}) as Record + if (!properties || typeof properties !== "object") return null + return { type: event.type, properties } +} + type MessagePart = Part & { sessionID?: string messageID?: string @@ -106,11 +120,8 @@ export function useGlobalSessionStatus(url?: string, token?: string) { if (dataLines.length > 0) { const data = dataLines.join("\n") try { - const parsed = JSON.parse(data) - let sseEvent: SSEEvent - if (parsed.payload && parsed.type === undefined) sseEvent = parsed.payload - else sseEvent = parsed - handleEvent(sseEvent) + const sseEvent = normalizeEvent(JSON.parse(data)) + if (sseEvent) handleEvent(sseEvent) } catch {} } } @@ -684,16 +695,8 @@ export function useEventStream(url?: string, sessionId?: string, token?: string, const data = dataLines.join("\n") try { - const parsed = JSON.parse(data) - let sseEvent: SSEEvent - - if (parsed.payload && parsed.type === undefined) { - sseEvent = parsed.payload - } else { - sseEvent = parsed - } - - handleEvent(sseEvent, sid) + const sseEvent = normalizeEvent(JSON.parse(data)) + if (sseEvent) handleEvent(sseEvent, sid) } catch { // Parse error - skip malformed event } diff --git a/apps/mobile/components/session-header.tsx b/apps/mobile/components/session-header.tsx index 30998f2..63641cb 100644 --- a/apps/mobile/components/session-header.tsx +++ b/apps/mobile/components/session-header.tsx @@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button" import { Text } from "@/components/ui/text" import { THEME } from "@/lib/theme" import { shareSession } from "@/lib/sessions" +import { getServerVersion, supportsFeature } from "@/lib/server-version" import { useConnections } from "@/store/connection.store" import { useSessions } from "@/store/sessions.store" import { useSettings } from "@/store/settings.store" @@ -56,6 +57,12 @@ function SessionHeaderInner({ const displayTitle = title && title.length > 40 ? title.slice(0, 37) + "..." : title + // share/tasks have no v2 API yet: keep the rows but mute them on v2 + // so they light back up when the server supports them again. + const serverVersion = getServerVersion(connection?.url) + const shareSupported = supportsFeature(serverVersion, "share") + const tasksSupported = supportsFeature(serverVersion, "todo") + const toggleMenu = () => setShowMenu(!showMenu) const closeMenu = () => setShowMenu(false) @@ -120,11 +127,12 @@ function SessionHeaderInner({ > handleNavigate(`/project/${projectId}/${sessionId}/tasks`)} + className={`flex-row items-center gap-3 px-4 py-2.5 active:bg-accent/50${tasksSupported ? "" : " opacity-40"}`} + disabled={!tasksSupported} + onPress={() => tasksSupported && handleNavigate(`/project/${projectId}/${sessionId}/tasks`)} > - Tasks + Tasks{tasksSupported ? "" : " (v2 soon)"} shareSupported && handleShare()} > - Share session + Share session{shareSupported ? "" : " (v2 soon)"} { try { - const query = messageID ? `?messageID=${encodeURIComponent(messageID)}` : "" - const res = await fetch(`${url}/session/${sessionId}/diff${query}`, { + const version = getServerVersion(url) + // v2 selects the turn with ?from= and wraps rows in { data }. + const query = messageID + ? version >= 2 + ? `?from=${encodeURIComponent(messageID)}` + : `?messageID=${encodeURIComponent(messageID)}` + : "" + const res = await fetch(`${apiUrl(url, version, "/session")}/${sessionId}/diff${query}`, { method: "GET", headers: { "Authorization": getAuthHeader(token), @@ -40,11 +47,13 @@ export async function fetchSessionDiffs(url: string, token: string, sessionId: s }) if (!res.ok) return [] const data = await res.json() - const records = Array.isArray(data) - ? data - : data && typeof data === "object" && Array.isArray((data as Record).diffs) - ? (data as { diffs: unknown[] }).diffs - : [] + const records = unwrapList(data).length > 0 + ? unwrapList(data) + : Array.isArray(data) + ? data + : data && typeof data === "object" && Array.isArray((data as Record).diffs) + ? (data as { diffs: unknown[] }).diffs + : [] return records.map(normalizeFileDiff).filter((diff): diff is FileDiff => diff !== null) } catch { return [] diff --git a/apps/mobile/lib/file-browser.ts b/apps/mobile/lib/file-browser.ts index 0a4fb43..3b07c75 100644 --- a/apps/mobile/lib/file-browser.ts +++ b/apps/mobile/lib/file-browser.ts @@ -1,4 +1,5 @@ -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" import { normalizeLanguage } from "@/lib/prism" export type FileEntryType = "file" | "directory" @@ -34,19 +35,24 @@ export function sortEntries(entries: FileEntry[]): FileEntry[] { }) } +function encodePathSegments(path: string): string { + return path.split("/").map((segment) => encodeURIComponent(segment)).join("/") +} + export async function listDirectory(url: string, token: string, path?: string): Promise { try { - const query = `?path=${encodeURIComponent(path || ".")}` - const res = await fetch(`${url}/file${query}`, { + const version = getServerVersion(url) + const endpoint = version >= 2 + ? `${apiUrl(url, version, "/fs/list")}?path=${encodeURIComponent(path || ".")}` + : `${url}/file?path=${encodeURIComponent(path || ".")}` + const res = await fetch(endpoint, { method: "GET", headers: { "Authorization": getAuthHeader(token), }, }) if (!res.ok) throw new Error(`Failed to list directory (${res.status})`) - const data = await res.json() - if (!Array.isArray(data)) throw new Error("Unexpected directory listing response") - const entries = data.map(normalizeFileEntry).filter((entry): entry is FileEntry => entry !== null) + const entries = unwrapList(await res.json()).map(normalizeFileEntry).filter((entry): entry is FileEntry => entry !== null) return sortEntries(entries) } catch (err) { throw err instanceof Error ? err : new Error("Failed to list directory") @@ -54,13 +60,22 @@ export async function listDirectory(url: string, token: string, path?: string): } export async function readFileContent(url: string, token: string, path: string): Promise { - const res = await fetch(`${url}/file/content?path=${encodeURIComponent(path)}`, { + const version = getServerVersion(url) + const endpoint = version >= 2 + // v2 serves raw bytes at /api/fs/read/ (no JSON envelope) + ? `${apiUrl(url, version, "/fs/read")}/${encodePathSegments(path.replace(/^\/+/, ""))}` + : `${url}/file/content?path=${encodeURIComponent(path)}` + const res = await fetch(endpoint, { method: "GET", headers: { "Authorization": getAuthHeader(token), }, }) if (!res.ok) throw new Error(`Failed to read file (${res.status})`) + if (version >= 2) { + const text = await res.text() + return text.length > MAX_CONTENT_BYTES ? text.slice(0, MAX_CONTENT_BYTES) : text + } const raw = await res.text() let text = raw try { @@ -76,7 +91,11 @@ export async function readFileContent(url: string, token: string, path: string): export async function searchFiles(url: string, token: string, query: string): Promise { try { - const res = await fetch(`${url}/find/file?query=${encodeURIComponent(query)}`, { + const version = getServerVersion(url) + const endpoint = version >= 2 + ? `${apiUrl(url, version, "/fs/find")}?query=${encodeURIComponent(query)}` + : `${url}/find/file?query=${encodeURIComponent(query)}` + const res = await fetch(endpoint, { method: "GET", headers: { "Authorization": getAuthHeader(token), @@ -84,8 +103,14 @@ export async function searchFiles(url: string, token: string, query: string): Pr }) if (!res.ok) return [] const data = await res.json() - if (!Array.isArray(data)) return [] - return data.filter((item): item is string => typeof item === "string") + const list = unwrapList(data) + if (list.length === 0) return [] + return list.flatMap((item): string[] => { + if (typeof item === "string") return [item] + // v2 returns { path, type } entries + const entry = normalizeFileEntry(item) + return entry && entry.type === "file" ? [entry.path] : [] + }) } catch { return [] } @@ -93,7 +118,11 @@ export async function searchFiles(url: string, token: string, query: string): Pr export async function fetchFileStatuses(url: string, token: string): Promise { try { - const res = await fetch(`${url}/file/status`, { + const version = getServerVersion(url) + const endpoint = version >= 2 + ? apiUrl(url, version, "/vcs/status") + : `${url}/file/status` + const res = await fetch(endpoint, { method: "GET", headers: { "Authorization": getAuthHeader(token), @@ -103,6 +132,19 @@ export async function fetchFileStatuses(url: string, token: string): Promise= 2) { + const result: FileStatuses = {} + for (const item of unwrapList>(data)) { + const file = typeof item.file === "string" ? item.file : typeof item.path === "string" ? item.path : null + const status = item.status + if (file && (status === "added" || status === "modified" || status === "deleted")) { + result[file] = status + } + } + return result + } + const records: Array<[string, unknown]> = Array.isArray(data) ? data.filter((item) => item && typeof item === "object" && typeof (item as Record).path === "string") .map((item) => [(item as Record).path as string, item]) diff --git a/apps/mobile/lib/mentions.ts b/apps/mobile/lib/mentions.ts index 423db59..2c8a9d6 100644 --- a/apps/mobile/lib/mentions.ts +++ b/apps/mobile/lib/mentions.ts @@ -1,4 +1,6 @@ -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" +import { readFileContent } from "@/lib/file-browser" export type MentionKind = "file" | "agent" @@ -66,17 +68,29 @@ export const fetchFileSuggestions = async ( opts?: { limit?: number; signal?: AbortSignal }, ): Promise => { try { + const version = getServerVersion(url) const params = new URLSearchParams({ query, limit: String(opts?.limit ?? 20), }) - const res = await fetch(`${url}/find/file?${params.toString()}`, { + const endpoint = version >= 2 + ? `${apiUrl(url, version, "/fs/find")}?${params.toString()}` + : `${url}/find/file?${params.toString()}` + const res = await fetch(endpoint, { headers: { Authorization: getAuthHeader(token) }, signal: opts?.signal, }) if (!res.ok) return [] const data = (await res.json()) as unknown - return Array.isArray(data) ? data.filter((p): p is string => typeof p === "string") : [] + const list = unwrapList(data) + return list.flatMap((item): string[] => { + if (typeof item === "string") return [item] + if (item && typeof item === "object") { + const path = (item as Record).path + if (typeof path === "string") return [path] + } + return [] + }) } catch { return [] } @@ -98,14 +112,9 @@ export const fetchMentionFilePart = async ( path: string, ): Promise => { try { - const params = new URLSearchParams({ path }) - const res = await fetch(`${url}/file/content?${params.toString()}`, { - headers: { Authorization: getAuthHeader(token) }, - }) - if (!res.ok) return null - const data = (await res.json()) as { content?: unknown; text?: unknown } - const content = typeof data.content === "string" ? data.content : typeof data.text === "string" ? data.text : null - if (content === null || content.length === 0 || content.length > MAX_MENTION_BYTES) return null + // v2 serves raw file bytes; reuse the shared reader which handles both. + const content = await readFileContent(url, token, path) + if (content.length === 0 || content.length > MAX_MENTION_BYTES) return null if (content.includes("\0")) return null return { mime: "text/plain", diff --git a/apps/mobile/lib/messages.ts b/apps/mobile/lib/messages.ts index e7a8000..7252774 100644 --- a/apps/mobile/lib/messages.ts +++ b/apps/mobile/lib/messages.ts @@ -1,24 +1,117 @@ import { Message } from "@/store/messages.store" -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapData } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" +import { updateSessionModel } from "@/lib/models" export const getMessages = async (url: string, token: string, sessionId: string, limit?: number, offset?: number) => { try { + const version = getServerVersion(url) const params = new URLSearchParams() - if (limit) params.set("limit", limit.toString()) - if (offset) params.set("offset", offset.toString()) - + if (version >= 2) { + // v2 paginates with opaque cursors instead of offsets: over-fetch + // newest-first and slice client-side to emulate limit/offset. + const pageSize = limit ?? 50 + params.set("limit", String((offset ?? 0) + pageSize)) + params.set("order", "desc") + } else { + if (limit) params.set("limit", limit.toString()) + if (offset) params.set("offset", offset.toString()) + } + const queryString = params.toString() - const res = await fetch(`${url}/session/${sessionId}/message${queryString ? `?${queryString}` : ""}`, { + const res = await fetch(`${apiUrl(url, version, "/session")}/${sessionId}/message${queryString ? `?${queryString}` : ""}`, { method: "GET", headers: { "Authorization": getAuthHeader(token) } }) if (!res.ok) return - const data = await res.json() as Message[] - if (!data) return + const data = unwrapData(await res.json()) + if (!data || !Array.isArray(data)) return + if (version >= 2 && offset) return data.slice(offset) return data } catch { return } -} \ No newline at end of file +} + +export type SendResult = { + ok: boolean + retryable: boolean + errorText?: string + errorName?: string + status?: number +} + +// v2 sends messages via POST /api/session/:id/prompt { text, files }. +// Model and agent are set on the session first (v1 accepted them inline). +export const sendPromptV2 = async ( + url: string, + token: string, + sessionId: string, + opts: { + text: string + agent: string + modelId?: string + providerId?: string + parts: Array> + }, +): Promise => { + try { + if (opts.modelId && opts.providerId) { + await updateSessionModel(url, token, sessionId, { id: opts.modelId, providerID: opts.providerId }) + } + try { + await fetch(`${apiUrl(url, 2, "/session")}/${sessionId}/agent`, { + method: "POST", + headers: { + "Authorization": getAuthHeader(token), + "Content-Type": "application/json", + }, + body: JSON.stringify({ agent: opts.agent }), + }) + } catch {} + + const texts = opts.parts + .filter((p) => p.type === "text" && typeof p.text === "string") + .map((p) => p.text as string) + const files = opts.parts + .filter((p) => p.type === "file" && typeof p.url === "string") + .map((p) => ({ + uri: p.url as string, + name: typeof p.filename === "string" ? (p.filename as string) : "file", + })) + + const res = await fetch(`${apiUrl(url, 2, "/session")}/${sessionId}/prompt`, { + method: "POST", + headers: { + "Authorization": getAuthHeader(token), + "Content-Type": "application/json", + }, + body: JSON.stringify({ text: texts.join("\n\n"), files }), + }) + + if (!res.ok) { + let errorText = `${res.status} ${res.statusText}` + let errorName = "UnknownError" + try { + const errorBody = await res.json() + if (errorBody.error) { + errorText = errorBody.error + errorName = errorBody.name || errorBody._tag || "UnknownError" + } else if (errorBody.message) { + errorText = errorBody.message + errorName = errorBody.name || errorBody._tag || "UnknownError" + } + } catch {} + + const retryable = res.status >= 500 || errorName === "APIError" + return { ok: false, retryable, errorText, errorName, status: res.status } + } + + return { ok: true, retryable: false } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to send message" + return { ok: false, retryable: true, errorText: message, errorName: "NetworkError" } + } +} diff --git a/apps/mobile/lib/models.ts b/apps/mobile/lib/models.ts index 8ed59b6..2c55750 100644 --- a/apps/mobile/lib/models.ts +++ b/apps/mobile/lib/models.ts @@ -1,4 +1,5 @@ -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" type ModelEndpoint = { type: string @@ -64,28 +65,33 @@ export type Provider = { export async function fetchModels(url: string, token: string): Promise { try { - const res = await fetch(`${url}/api/model`, { + // Unchanged across versions: both serve GET /api/model. + const endpoint = `${url.replace(/\/+$/, "")}/api/model` + const res = await fetch(endpoint, { method: "GET", headers: { "Authorization": getAuthHeader(token) } }) if (!res.ok) { - console.error(`[fetchModels] HTTP ${res.status} from ${url}/api/model`) + console.error(`[fetchModels] HTTP ${res.status} from ${endpoint}`) return [] } - const data = await res.json() - if (Array.isArray(data)) return data - if (data && typeof data === "object") { - if (Array.isArray(data.models)) return data.models - if (Array.isArray(data.data)) return data.data - const values = Object.values(data) + const json = await res.json() + // v1 may return a map of models instead of an array + if (json && typeof json === "object" && !Array.isArray(json) && !("data" in json)) { + const raw = json as Record + if (Array.isArray(raw.models)) return raw.models as Model[] + if (Array.isArray(raw.data)) return raw.data as Model[] + const values = Object.values(raw) if (values.length > 0 && typeof values[0] === "object" && !Array.isArray(values[0])) { return values as Model[] } + console.error("[fetchModels] Unexpected response shape:", JSON.stringify(json).slice(0, 200)) + return [] } - console.error("[fetchModels] Unexpected response shape:", JSON.stringify(data).slice(0, 200)) - return [] + const data = unwrapList(json) + return data } catch (error) { console.error("[fetchModels] Network error:", error) return [] @@ -94,7 +100,8 @@ export async function fetchModels(url: string, token: string): Promise export async function fetchProviders(url: string, token: string): Promise { try { - const res = await fetch(`${url}/api/provider`, { + // Unchanged across versions: both serve GET /api/provider. + const res = await fetch(`${url.replace(/\/+$/, "")}/api/provider`, { method: "GET", headers: { "Authorization": getAuthHeader(token) @@ -102,9 +109,10 @@ export async function fetchProviders(url: string, token: string): Promise>(data) + if (list.length > 0) return list as Provider[] + if (data && typeof data === "object" && !Array.isArray(data)) { + return Object.values(data) as Provider[] } return [] } catch (error) { @@ -115,6 +123,19 @@ export async function fetchProviders(url: string, token: string): Promise= 2) { + // v2 sets the model via a dedicated endpoint (PATCH /session no longer accepts it). + await fetch(`${apiUrl(url, version, "/session")}/${sessionId}/model`, { + method: "POST", + headers: { + "Authorization": getAuthHeader(token), + "Content-Type": "application/json" + }, + body: JSON.stringify({ model }) + }) + return + } await fetch(`${url}/session/${sessionId}`, { method: "PATCH", headers: { diff --git a/apps/mobile/lib/permissions.ts b/apps/mobile/lib/permissions.ts index e5e2f0b..9e4de00 100644 --- a/apps/mobile/lib/permissions.ts +++ b/apps/mobile/lib/permissions.ts @@ -1,5 +1,6 @@ import { PermissionRequest } from "@/store/permissions.store" -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" type RawPermission = Record @@ -23,6 +24,8 @@ function asStringArray(value: unknown): string[] { // messageID, callID?, title, metadata, time } // Event permission.updated properties = Permission directly // Event permission.replied properties = { sessionID, permissionID, response } +// v2 request shape: { id (^per), sessionID, action, resources, +// save, metadata, source { messageID, ... }, message } // Legacy shapes (older servers) are also accepted: // - id vs requestID vs permissionID // - permission vs action vs type @@ -49,7 +52,7 @@ export function normalizePermission(raw: RawPermission, fallbackSessionId?: stri ? asStringArray(raw.always) : asStringArray(raw.save) - const title = asString(raw.title) + const title = asString(raw.title) ?? asString(raw.message) const metadata = raw.metadata && typeof raw.metadata === "object" @@ -92,11 +95,24 @@ export function normalizePermission(raw: RawPermission, fallbackSessionId?: stri } export const getPendingPermissions = async ( - _url: string, - _token: string, - _sessionId?: string + url: string, + token: string, + sessionId?: string ): Promise => { - // OpenCode exposes no GET /permission list endpoint (see /doc spec) — + // v2 exposes pending requests at GET /api/session/:id/permission. + if (getServerVersion(url) >= 2 && sessionId) { + try { + const res = await fetch(`${apiUrl(url, 2, "/session")}/${sessionId}/permission`, { + method: "GET", + headers: { Authorization: getAuthHeader(token) }, + }) + if (!res.ok) return [] + return unwrapList(await res.json()).map((raw) => normalizePermission(raw, sessionId)) + } catch { + return [] + } + } + // OpenCode v1 exposes no GET /permission list endpoint (see /doc spec) — // pending permissions arrive via the `permission.updated` SSE event. // Keep this stub so callers don't hit a 404 polling loop. return [] @@ -110,17 +126,22 @@ export const replyToPermission = async ( _message?: string, sessionId?: string ): Promise => { - // Per /doc spec: POST /session/:id/permissions/:permissionID - // body: { response: "once" | "always" | "reject" } if (!sessionId) return false try { - const res = await fetch(`${url}/session/${sessionId}/permissions/${requestId}`, { + const version = getServerVersion(url) + // v2: POST /api/session/:id/permission/:requestID/reply { decision } + // v1: POST /session/:id/permissions/:permissionID { response } + const endpoint = version >= 2 + ? `${apiUrl(url, version, "/session")}/${sessionId}/permission/${requestId}/reply` + : `${url}/session/${sessionId}/permissions/${requestId}` + const body = version >= 2 ? { decision: reply } : { response: reply } + const res = await fetch(endpoint, { method: "POST", headers: { Authorization: getAuthHeader(token), "Content-Type": "application/json", }, - body: JSON.stringify({ response: reply }), + body: JSON.stringify(body), }) return res.ok } catch { diff --git a/apps/mobile/lib/projects.ts b/apps/mobile/lib/projects.ts index a56f5df..58e9aca 100644 --- a/apps/mobile/lib/projects.ts +++ b/apps/mobile/lib/projects.ts @@ -1,5 +1,6 @@ import { Project } from "@/store/projects.store" import { getAuthHeader } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" export const getCurrentProject = async (url: string, token: string) => { try { @@ -7,6 +8,31 @@ export const getCurrentProject = async (url: string, token: string) => { "Authorization": getAuthHeader(token) } + if (getServerVersion(url) >= 2) { + // v2 exposes the current location (directory + project) in one call. + const res = await fetch(`${url.replace(/\/+$/, "")}/api/location`, { method: "GET", headers }) + if (!res.ok) return + const data = await res.json() as { + directory?: string + project?: Project & { directory?: string } + } + if (!data?.project) return + const project = data.project + const directory = data.directory ?? project.directory ?? project.worktree ?? "" + const name = project.name + ?? directory.replace(/\/+$/, "").split("/").filter(Boolean).pop() + ?? "Project" + return { + ...project, + id: project.id, + name, + worktree: project.worktree ?? directory, + directory, + vcs: project.vcs ?? "", + time: project.time ?? { created: 0, updated: 0 }, + } + } + const [projectRes, pathRes] = await Promise.all([ fetch(`${url}/project/current`, { method: "GET", headers }), fetch(`${url}/path`, { method: "GET", headers }) diff --git a/apps/mobile/lib/questions.ts b/apps/mobile/lib/questions.ts index 804bf41..a40525e 100644 --- a/apps/mobile/lib/questions.ts +++ b/apps/mobile/lib/questions.ts @@ -1,10 +1,13 @@ import { QuestionRequest } from "@/store/questions.store" import { getAuthHeader } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" export const getPendingQuestions = async ( url: string, token: string ): Promise => { + // No v2 equivalent yet; the UI mutes questions on v2 (see supportsFeature). + if (getServerVersion(url) >= 2) return [] try { const res = await fetch(`${url}/question`, { method: "GET", @@ -26,6 +29,7 @@ export const replyToQuestion = async ( requestId: string, answers: string[][] ): Promise => { + if (getServerVersion(url) >= 2) return false try { const res = await fetch(`${url}/question/${requestId}/reply`, { method: "POST", @@ -46,6 +50,7 @@ export const rejectQuestion = async ( token: string, requestId: string ): Promise => { + if (getServerVersion(url) >= 2) return false try { const res = await fetch(`${url}/question/${requestId}/reject`, { method: "POST", diff --git a/apps/mobile/lib/recents.ts b/apps/mobile/lib/recents.ts index 24b81ea..90919b3 100644 --- a/apps/mobile/lib/recents.ts +++ b/apps/mobile/lib/recents.ts @@ -1,17 +1,19 @@ import { OpenCodeProject } from "@/store/recents.store" -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" export const getRecents = async (url: string, token: string) => { try { - const res = await fetch(`${url}/project`, { + const version = getServerVersion(url) + const res = await fetch(apiUrl(url, version, "/project"), { method: "GET", headers: { "Authorization": getAuthHeader(token) } }) if (!res.ok) return - const data = await res.json() as OpenCodeProject[] - if (!data) return + const data = unwrapList(await res.json()) + if (data.length === 0) return return data .sort((a, b) => b.time.updated - a.time.updated) @@ -19,4 +21,4 @@ export const getRecents = async (url: string, token: string) => { } catch { return } -} \ No newline at end of file +} diff --git a/apps/mobile/lib/server-version.ts b/apps/mobile/lib/server-version.ts new file mode 100644 index 0000000..01593ad --- /dev/null +++ b/apps/mobile/lib/server-version.ts @@ -0,0 +1,94 @@ +import { getAuthHeader } from "@/lib/utils" +import { useConnections } from "@/store/connection.store" + +export type ServerVersion = 1 | 2 + +export type GatedFeature = "share" | "todo" | "question" + +const memoryCache = new Map() + +function normalizeUrl(url: string): string { + return url.replace(/\/+$/, "") +} + +export function cacheServerVersion(url: string, version: ServerVersion) { + memoryCache.set(normalizeUrl(url), version) +} + +// Sync lookup: memory cache, then the persisted connection, else v1. +// Unknown servers are treated as v1 so old CLIs keep working untouched. +export function getServerVersion(url: string | undefined | null): ServerVersion { + if (!url) return 1 + const cached = memoryCache.get(normalizeUrl(url)) + if (cached) return cached + const match = useConnections.getState().connections.find((c) => normalizeUrl(c.url) === normalizeUrl(url)) + if (match?.serverVersion === 2) { + memoryCache.set(normalizeUrl(url), 2) + return 2 + } + return 1 +} + +async function probeJson(url: string, token: string, path: string): Promise { + try { + const res = await fetch(`${normalizeUrl(url)}${path}`, { + method: "GET", + headers: { "Authorization": getAuthHeader(token) }, + }) + if (!res.ok) return null + const contentType = res.headers.get("content-type") ?? "" + if (!contentType.includes("json")) return null + return (await res.json()) as unknown + } catch { + return null + } +} + +function persistVersion(url: string, version: ServerVersion) { + cacheServerVersion(url, version) + const state = useConnections.getState() + const match = state.connections.find((c) => normalizeUrl(c.url) === normalizeUrl(url)) + if (match && match.serverVersion !== version) { + state.updateConnection(match.id, { serverVersion: version }) + } +} + +// Probe the server to learn whether it speaks the v1 or v2 API. +// v2 answers GET /api/info with {"version": "2.x", ...}; v1 answers +// GET /global/health with {"healthy": true, ...}. Returns null when +// the server is unreachable. Order matters: on v2, /global/health +// serves the web UI (HTML 200), so /api/info must be checked first. +export async function detectServerVersion( + url: string, + token: string, + opts?: { force?: boolean }, +): Promise { + const key = normalizeUrl(url) + if (!opts?.force) { + const cached = memoryCache.get(key) + if (cached) return cached + } + const info = await probeJson(url, token, "/api/info") + if (info && typeof info === "object") { + const version = (info as Record).version + if (typeof version === "string" && parseInt(version.split(".")[0]!, 10) >= 2) { + persistVersion(url, 2) + return 2 + } + } + const health = await probeJson(url, token, "/global/health") + if (health && typeof health === "object" && (health as Record).healthy === true) { + persistVersion(url, 1) + return 1 + } + return null +} + +// Features removed from the v2 API (expected to return later). +// The UI mutes these on v2 instead of removing them; v1 is unaffected. +export function supportsFeature(version: ServerVersion, feature: GatedFeature): boolean { + if (version >= 2) { + return feature !== "share" && feature !== "todo" && feature !== "question" + } + return true +} diff --git a/apps/mobile/lib/sessions.ts b/apps/mobile/lib/sessions.ts index 32911e7..7889829 100644 --- a/apps/mobile/lib/sessions.ts +++ b/apps/mobile/lib/sessions.ts @@ -1,18 +1,41 @@ import { Session } from "@/store/sessions.store" -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapData, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" + +type RawSession = Record & { + id: string + directory?: string + location?: { directory?: string } + title?: string + version?: string +} + +// v2 sessions carry location.directory instead of directory and may omit +// title/version until summarized. Normalize to the v1 shape the app uses. +function normalizeSession(raw: RawSession): Session { + return { + ...(raw as object), + id: raw.id, + directory: raw.directory ?? raw.location?.directory ?? "", + title: typeof raw.title === "string" ? raw.title : "", + version: typeof raw.version === "string" ? raw.version : "", + } as Session +} export const createSession = async (url: string, token: string, directory: string): Promise => { try { - const res = await fetch(`${url}/session`, { + const version = getServerVersion(url) + const body = version >= 2 ? { location: { directory } } : { directory } + const res = await fetch(apiUrl(url, version, "/session"), { method: "POST", headers: { "Authorization": getAuthHeader(token), "Content-Type": "application/json", }, - body: JSON.stringify({ directory }), + body: JSON.stringify(body), }) if (!res.ok) return null - return await res.json() + return normalizeSession(unwrapData(await res.json())) } catch { return null } @@ -20,15 +43,16 @@ export const createSession = async (url: string, token: string, directory: strin export const getSessionsByProjectDir = async (url: string, token: string, dir: string) => { try { - const res = await fetch(`${url}/session?directory=${dir}`, { + const version = getServerVersion(url) + const res = await fetch(`${apiUrl(url, version, "/session")}?directory=${encodeURIComponent(dir)}`, { method: "GET", headers: { "Authorization": getAuthHeader(token) } }) if (!res.ok) return - const data = await res.json() as Session[] - return data + const data = unwrapList(await res.json()) + return data.map(normalizeSession) } catch { return } @@ -36,7 +60,8 @@ export const getSessionsByProjectDir = async (url: string, token: string, dir: s export const deleteSession = async (url: string, token: string, sessionId: string) => { try { - const res = await fetch(`${url}/session/${sessionId}`, { + const version = getServerVersion(url) + const res = await fetch(`${apiUrl(url, version, "/session")}/${sessionId}`, { method: "DELETE", headers: { "Authorization": getAuthHeader(token) @@ -50,6 +75,8 @@ export const deleteSession = async (url: string, token: string, sessionId: strin } export const shareSession = async (url: string, token: string, sessionId: string): Promise => { + // No v2 equivalent yet; the UI mutes this on v2 (see supportsFeature). + if (getServerVersion(url) >= 2) return null try { const res = await fetch(`${url}/session/${sessionId}/share`, { method: "POST", @@ -67,6 +94,17 @@ export const shareSession = async (url: string, token: string, sessionId: string export const revertMessage = async (url: string, token: string, sessionId: string, messageID: string, partID?: string): Promise => { try { + const version = getServerVersion(url) + if (version >= 2) { + // v2 reverts the current turn via DELETE with no message selector. + const res = await fetch(`${apiUrl(url, version, "/session")}/${sessionId}/revert`, { + method: "DELETE", + headers: { + "Authorization": getAuthHeader(token), + }, + }) + return res.ok + } const res = await fetch(`${url}/session/${sessionId}/revert`, { method: "POST", headers: { @@ -83,16 +121,18 @@ export const revertMessage = async (url: string, token: string, sessionId: strin export const forkSession = async (url: string, token: string, sessionId: string, messageID?: string): Promise => { try { - const res = await fetch(`${url}/session/${sessionId}/fork`, { + const version = getServerVersion(url) + const body = version >= 2 ? { before: messageID } : { messageID } + const res = await fetch(`${apiUrl(url, version, "/session")}/${sessionId}/fork`, { method: "POST", headers: { "Authorization": `Basic ${btoa(`opencode:${token}`)}`, "Content-Type": "application/json", }, - body: JSON.stringify({ messageID }), + body: JSON.stringify(body), }) if (!res.ok) return null - return await res.json() + return normalizeSession(unwrapData(await res.json())) } catch { return null } diff --git a/apps/mobile/lib/todo.ts b/apps/mobile/lib/todo.ts index a1e3287..0b1bba3 100644 --- a/apps/mobile/lib/todo.ts +++ b/apps/mobile/lib/todo.ts @@ -1,4 +1,5 @@ import { getAuthHeader } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" export type TodoTask = { content: string @@ -7,6 +8,8 @@ export type TodoTask = { } export async function fetchSessionTodos(url: string, token: string, sessionId: string): Promise { + // No v2 equivalent yet; the UI mutes todos on v2 (see supportsFeature). + if (getServerVersion(url) >= 2) return [] try { const res = await fetch(`${url}/session/${sessionId}/todo`, { method: "GET", diff --git a/apps/mobile/lib/utils.ts b/apps/mobile/lib/utils.ts index 5ae1564..02c6c71 100644 --- a/apps/mobile/lib/utils.ts +++ b/apps/mobile/lib/utils.ts @@ -1,5 +1,6 @@ import { clsx, type ClassValue } from 'clsx' import { twMerge } from 'tailwind-merge' +import type { ServerVersion } from "@/lib/server-version" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -16,6 +17,27 @@ export function getAuthHeader(token: string): string { return cached } +// v2 moved the API under /api/*; v1 serves routes from the root. +export function apiUrl(url: string, version: ServerVersion, path: string): string { + const base = url.replace(/\/+$/, "") + const clean = path.startsWith("/") ? path : `/${path}` + return version >= 2 ? `${base}/api${clean}` : `${base}${clean}` +} + +// v2 wraps most payloads in a { data } (sometimes { location, data }) +// envelope; v1 returns bare values. Accepts both. +export function unwrapData(json: unknown): T { + if (json && typeof json === "object" && !Array.isArray(json) && "data" in (json as Record)) { + return (json as Record).data as T + } + return json as T +} + +export function unwrapList(json: unknown): T[] { + const data = unwrapData(json) + return Array.isArray(data) ? (data as T[]) : [] +} + export function formatDirectory(path: string | undefined | null): string { if (!path) return "" const cleaned = path.replace(/\/+$/, '') diff --git a/apps/mobile/store/agents.store.ts b/apps/mobile/store/agents.store.ts index bc71a33..3259514 100644 --- a/apps/mobile/store/agents.store.ts +++ b/apps/mobile/store/agents.store.ts @@ -2,7 +2,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage" import { create } from "zustand" import { createJSONStorage, persist } from "zustand/middleware" import { Agent } from "../lib/opencode" -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" export type AgentStore = { agents: Agent[] @@ -18,14 +19,15 @@ export const useAgents = create()( setAgents: (agents) => set({ agents }), fetchAgents: async (url, token) => { try { - const res = await fetch(`${url}/agent`, { + const version = getServerVersion(url) + const res = await fetch(apiUrl(url, version, "/agent"), { method: "GET", headers: { "Authorization": getAuthHeader(token) } }) if (!res.ok) return - const all: Agent[] = await res.json() + const all = unwrapList(await res.json()) set({ agents: all.filter(a => a.mode === "primary" && !a.hidden) }) } catch (error) { console.error("[fetchAgents] Failed to fetch agents:", error) diff --git a/apps/mobile/store/connection.store.ts b/apps/mobile/store/connection.store.ts index e30a3f0..8bad105 100644 --- a/apps/mobile/store/connection.store.ts +++ b/apps/mobile/store/connection.store.ts @@ -15,6 +15,9 @@ export type Connection = { name: string added: number healthy?: boolean | null + // Detected opencode API major (1 = classic paths, 2 = /api/* paths). + // Unknown means v1 so old CLIs keep working untouched. + serverVersion?: 1 | 2 | null } type ConnectionStore = { diff --git a/packages/crosscode/src/opencode-version.ts b/packages/crosscode/src/opencode-version.ts new file mode 100644 index 0000000..c78dbce --- /dev/null +++ b/packages/crosscode/src/opencode-version.ts @@ -0,0 +1,28 @@ +import { execFile } from "child_process" + +export type OpencodeMajor = 1 | 2 + +let cached: OpencodeMajor | null = null + +export function getCachedOpencodeMajor(): OpencodeMajor | null { + return cached +} + +function parseMajor(output: string): OpencodeMajor { + const match = output.match(/(\d+)\.\d+\.\d+/) + if (match && parseInt(match[1], 10) >= 2) return 2 + return 1 +} + +// `opencode --version` prints e.g. "1.15.7" (v1) or "opencode v2.0.6" (v2). +// Defaults to 1 when the binary is missing or the output is unrecognized, +// so old setups keep working. +export function detectOpencodeMajor(): Promise { + if (cached) return Promise.resolve(cached) + return new Promise((resolve) => { + execFile("opencode", ["--version"], { encoding: "utf8", timeout: 8000 }, (err, stdout) => { + cached = parseMajor(err ? "" : stdout) + resolve(cached) + }) + }) +} diff --git a/packages/crosscode/src/opencode.ts b/packages/crosscode/src/opencode.ts index 8a2a5c4..05dd392 100644 --- a/packages/crosscode/src/opencode.ts +++ b/packages/crosscode/src/opencode.ts @@ -4,10 +4,12 @@ import chalk from "chalk" import { debug, spawnCmd } from "./util" import { logCrosscode, opencodeLogStream } from "./log" import { waitForOpencodePort } from "./port-detect" +import { detectOpencodeMajor, type OpencodeMajor } from "./opencode-version" export type OpencodeInstance = { proc: ChildProcess detectedPort: number + serverVersion: OpencodeMajor } export async function startOpencode(opts: { @@ -18,8 +20,14 @@ export async function startOpencode(opts: { }): Promise { const { port, sessionToken, spinner, children } = opts + // v2 renamed --log-level values to lowercase ("DEBUG" is rejected). + const serverVersion = await detectOpencodeMajor() + const logLevel = serverVersion >= 2 ? "debug" : "DEBUG" + logCrosscode(`Detected opencode v${serverVersion}`) + debug("opencode version", { serverVersion }) + const proc = spawnCmd("opencode", [ - "serve", "--print-logs", "--log-level", "DEBUG", + "serve", "--print-logs", "--log-level", logLevel, "--port", String(port), "--hostname", "127.0.0.1", ], { cwd: process.cwd(), @@ -43,6 +51,7 @@ export async function startOpencode(opts: { const detectedPort = await waitForOpencodePort({ proc, requestedPort: port, + healthPath: serverVersion >= 2 ? "/api/info" : "/global/health", onData: d => opencodeLogStream.write(d), }) @@ -51,5 +60,5 @@ export async function startOpencode(opts: { debug("using detected port", { detected: detectedPort, requested: port }) } - return { proc, detectedPort } + return { proc, detectedPort, serverVersion } } diff --git a/packages/crosscode/src/port-detect.ts b/packages/crosscode/src/port-detect.ts index 76e9d5b..4b66af2 100644 --- a/packages/crosscode/src/port-detect.ts +++ b/packages/crosscode/src/port-detect.ts @@ -3,12 +3,14 @@ import http from "http" const PORT_PATTERNS = [ /opencode server listening on \S*?:(\d+)/i, + // v2 prints "server listening on http://127.0.0.1:PORT" (no "opencode" prefix) + /server listening on \S*?:(\d+)/i, /listening on (?:https?:\/\/)?[^\s"']*?:(\d+)/i, ] -function probePort(port: number): Promise { +function probePort(port: number, healthPath: string): Promise { return new Promise((resolve) => { - const req = http.get({ host: "127.0.0.1", port, path: "/global/health", timeout: 700 }, (res) => { + const req = http.get({ host: "127.0.0.1", port, path: healthPath, timeout: 700 }, (res) => { res.resume() resolve(true) }) @@ -68,10 +70,11 @@ async function getListeningPorts(pid?: number): Promise { export function waitForOpencodePort(opts: { proc: ChildProcess requestedPort: number + healthPath?: string onData?: (data: Buffer) => void timeoutMs?: number }): Promise { - const { proc, requestedPort, onData, timeoutMs = 15_000 } = opts + const { proc, requestedPort, healthPath = "/global/health", onData, timeoutMs = 15_000 } = opts let buffer = "" let resolved = false @@ -108,7 +111,7 @@ export function waitForOpencodePort(opts: { if (exited || probing) return probing = true - const alive = await probePort(requestedPort) + const alive = await probePort(requestedPort, healthPath) probing = false if (alive) return finish(requestedPort) }, 250) diff --git a/packages/crosscode/src/providers/cloudflared.ts b/packages/crosscode/src/providers/cloudflared.ts index e5e086b..5f6bd47 100644 --- a/packages/crosscode/src/providers/cloudflared.ts +++ b/packages/crosscode/src/providers/cloudflared.ts @@ -66,12 +66,12 @@ export async function startCloudflaredProvider( const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() const sessionToken = ensureSessionToken(config, project) - const { detectedPort } = await startOpencode({ port, sessionToken, spinner, children }) + const { detectedPort, serverVersion } = await startOpencode({ port, sessionToken, spinner, children }) let proxyPort = await getFreePort() while (proxyPort === port) proxyPort = await getFreePort() - const proxy = createOpencodeProxy(detectedPort, sessionToken, "cf-proxy") + const proxy = createOpencodeProxy(detectedPort, sessionToken, "cf-proxy", serverVersion) proxy.listen(proxyPort, "127.0.0.1", async () => { logCrosscode(`SSE proxy started on port ${proxyPort}`) diff --git a/packages/crosscode/src/providers/tunnel.ts b/packages/crosscode/src/providers/tunnel.ts index 812da53..a8b7784 100644 --- a/packages/crosscode/src/providers/tunnel.ts +++ b/packages/crosscode/src/providers/tunnel.ts @@ -26,18 +26,18 @@ export async function startTunnelProvider( const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() const sessionToken = ensureSessionToken(config, project) - const { detectedPort } = await startOpencode({ port, sessionToken, spinner, children }) + const { detectedPort, serverVersion } = await startOpencode({ port, sessionToken, spinner, children }) let proxyPort = await getFreePort() while (proxyPort === port) proxyPort = await getFreePort() - const proxy = createOpencodeProxy(detectedPort, sessionToken, "tunnel") + const proxy = createOpencodeProxy(detectedPort, sessionToken, "tunnel", serverVersion) proxy.listen(proxyPort, "127.0.0.1", () => { logCrosscode(`SSE proxy started on port ${proxyPort}`) debug("proxy listening", { port: proxyPort, targetPort: detectedPort }) - const testReq = http.request(`http://127.0.0.1:${detectedPort}/global/health`, { + const testReq = http.request(`http://127.0.0.1:${detectedPort}${serverVersion >= 2 ? "/api/info" : "/global/health"}`, { method: "GET", headers: { "Authorization": `Basic ${Buffer.from(`opencode:${sessionToken}`).toString("base64")}`, diff --git a/packages/crosscode/src/proxy.ts b/packages/crosscode/src/proxy.ts index 0fa2ed1..8d11402 100644 --- a/packages/crosscode/src/proxy.ts +++ b/packages/crosscode/src/proxy.ts @@ -1,6 +1,7 @@ import http from "http" import { debug, censorAuth } from "./util" import { handleGitRequest } from "./git-handler" +import type { OpencodeMajor } from "./opencode-version" const MAX_BODY_SIZE = 10 * 1024 * 1024 const HOP_BY_HOP = new Set(["connection", "keep-alive", "transfer-encoding", "upgrade", "proxy-authenticate", "proxy-authorization", "te", "trailer"]) @@ -26,7 +27,9 @@ export function sanitizeUrlPath(url: string | undefined): string { return `${cleaned || "/"}${rawQuery ? `?${rawQuery}` : ""}` } -export function createOpencodeProxy(targetPort: number, sessionToken: string, logPrefix: string): http.Server { +export function createOpencodeProxy(targetPort: number, sessionToken: string, logPrefix: string, serverVersion: OpencodeMajor = 1): http.Server { + // v2 moved the API under /api/* + const eventPath = serverVersion >= 2 ? "/api/event" : "/event" return http.createServer(async (req, res) => { const safePath = sanitizeUrlPath(req.url) const targetUrl = `http://127.0.0.1:${targetPort}${safePath}` @@ -67,7 +70,7 @@ export function createOpencodeProxy(targetPort: number, sessionToken: string, lo debug("converted SSE auth to Basic format") } - const sseReq = http.get(`http://127.0.0.1:${targetPort}/event`, { + const sseReq = http.get(`http://127.0.0.1:${targetPort}${eventPath}`, { headers: { "Accept": "text/event-stream", "Authorization": sseAuth, From f63ae06a9642d028283790eabe711f4157013f6b Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Tue, 29 Sep 2026 20:00:05 +0530 Subject: [PATCH 2/2] feat(cli): support custom opencode binary via opencodeBin config Add a global opencodeBin field (~/.crosscode/config.json) so machines where v2 lives under a different command (e.g. opencode-v2) can point crosscode at it. CROSSCODE_OPENCODE_BIN env var overrides the file. Defaults to opencode; version detection and flags adapt automatically. --- docs/cli.md | 6 +++++ packages/crosscode/src/cli.ts | 13 +++++++---- packages/crosscode/src/config.ts | 9 ++++++++ packages/crosscode/src/opencode-version.ts | 15 ++++++------ packages/crosscode/src/opencode.ts | 23 ++++++++++--------- .../crosscode/src/providers/cloudflared.ts | 7 +++--- packages/crosscode/src/providers/ngrok.ts | 7 +++--- packages/crosscode/src/providers/tunnel.ts | 7 +++--- 8 files changed, 56 insertions(+), 31 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index f8eb1c1..bc44e15 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -114,6 +114,7 @@ Config file: `~/.crosscode/config.json` { "ngrokToken": "string — ngrok auth token (saved on first use)", "port": "number — local opencode port (default: 4096)", + "opencodeBin": "string — opencode executable to run, e.g. \"opencode-v2\" (default: \"opencode\")", "auth": { "email": "string — logged-in email", "sessionToken": "string — API key", @@ -122,6 +123,10 @@ Config file: `~/.crosscode/config.json` } ``` +Set `"opencodeBin": "opencode-v2"` if v2 lives under a different command on +your machine. Crosscode detects the server version from `$bin --version` and +uses the matching flags and API paths automatically. + ## Log Files All logs are stored in `~/.crosscode/`: @@ -137,6 +142,7 @@ All logs are stored in `~/.crosscode/`: | Variable | Default | Description | |---|---|---| +| `CROSSCODE_OPENCODE_BIN` | `opencode` | Opencode executable to run (overrides `opencodeBin` in config) | | `CROSSCODE_WEB_URL` | `https://crosscode.site` | Web dashboard base URL | | `CROSSCODE_AUTH_URL` | `${CROSSCODE_WEB_URL}/api/auth` | Authentication API URL | | `CROSSCODE_TUNNEL_WS_URL` | `wss://tunnel.sish.work/ws` | Tunnel server WebSocket URL | diff --git a/packages/crosscode/src/cli.ts b/packages/crosscode/src/cli.ts index ba273e2..75a303a 100644 --- a/packages/crosscode/src/cli.ts +++ b/packages/crosscode/src/cli.ts @@ -6,7 +6,7 @@ import qrcode from "qrcode-terminal" import { encodeQrPayload } from "@crosscode/shared" import { debug, checkDep, setLogWriter, getFreePort } from "./util" import { logCrosscode, closeAllLogs, crosscodeLogFile, cloudflaredLogFile, opencodeLogFile } from "./log" -import { readConfig, saveConfig, getProjectConfig, ensureSessionToken } from "./config" +import { readConfig, saveConfig, getProjectConfig, ensureSessionToken, resolveOpencodeBin } from "./config" import { initSentry, captureCliError } from "./sentry" import * as Sentry from "@sentry/node" import { loginFlow, refreshTier } from "./auth" @@ -141,9 +141,14 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} logCrosscode(`CrossCode starting up (tunnel: ${tunnelProvider}, debug: ${process.env.CROSSCODE_DEBUG === "1"})`) debug("startup config", { tunnelProvider, port, hasAuth: !!config.auth?.sessionToken }) - if (!checkDep("opencode")) { - console.error(chalk.red("[DEPENDENCY ERROR] opencode not found. Install opencode and try again.")) - logCrosscode("Dependency check failed: opencode") + const opencodeBin = resolveOpencodeBin(config) + logCrosscode(`Using opencode binary: ${opencodeBin}`) + debug("opencode binary", { opencodeBin }) + + if (!checkDep(opencodeBin)) { + console.error(chalk.red(`[DEPENDENCY ERROR] ${opencodeBin} not found. Install opencode and try again.`)) + console.log(chalk.dim(`Override the binary with "opencodeBin" in ~/.crosscode/config.json or CROSSCODE_OPENCODE_BIN.`)) + logCrosscode(`Dependency check failed: ${opencodeBin}`) missingDep = true } diff --git a/packages/crosscode/src/config.ts b/packages/crosscode/src/config.ts index 75ba9b6..7140e36 100644 --- a/packages/crosscode/src/config.ts +++ b/packages/crosscode/src/config.ts @@ -24,6 +24,9 @@ export type Config = { sessionToken?: string projectId?: string telemetry?: boolean + // Name (or path) of the opencode executable to run, e.g. "opencode-v2". + // The subcommand and flags are appended by crosscode ("$bin serve ..."). + opencodeBin?: string cloudflaredTunnel?: CloudflaredTunnel projects?: Record auth?: { @@ -33,6 +36,12 @@ export type Config = { } } +// Which opencode executable to spawn. Env wins over the config file so one +// machine can pin a different binary without editing shared config. +export function resolveOpencodeBin(config: Config): string { + return process.env.CROSSCODE_OPENCODE_BIN?.trim() || config.opencodeBin?.trim() || "opencode" +} + export function readConfig(): Config { if (!existsSync(configFile)) return {} try { diff --git a/packages/crosscode/src/opencode-version.ts b/packages/crosscode/src/opencode-version.ts index c78dbce..2f2072c 100644 --- a/packages/crosscode/src/opencode-version.ts +++ b/packages/crosscode/src/opencode-version.ts @@ -2,10 +2,10 @@ import { execFile } from "child_process" export type OpencodeMajor = 1 | 2 -let cached: OpencodeMajor | null = null +let cached: { bin: string; major: OpencodeMajor } | null = null export function getCachedOpencodeMajor(): OpencodeMajor | null { - return cached + return cached?.major ?? null } function parseMajor(output: string): OpencodeMajor { @@ -17,12 +17,13 @@ function parseMajor(output: string): OpencodeMajor { // `opencode --version` prints e.g. "1.15.7" (v1) or "opencode v2.0.6" (v2). // Defaults to 1 when the binary is missing or the output is unrecognized, // so old setups keep working. -export function detectOpencodeMajor(): Promise { - if (cached) return Promise.resolve(cached) +export function detectOpencodeMajor(bin = "opencode"): Promise { + if (cached && cached.bin === bin) return Promise.resolve(cached.major) return new Promise((resolve) => { - execFile("opencode", ["--version"], { encoding: "utf8", timeout: 8000 }, (err, stdout) => { - cached = parseMajor(err ? "" : stdout) - resolve(cached) + execFile(bin, ["--version"], { encoding: "utf8", timeout: 8000 }, (err, stdout) => { + const major = parseMajor(err ? "" : stdout) + cached = { bin, major } + resolve(major) }) }) } diff --git a/packages/crosscode/src/opencode.ts b/packages/crosscode/src/opencode.ts index 05dd392..5a2cb5a 100644 --- a/packages/crosscode/src/opencode.ts +++ b/packages/crosscode/src/opencode.ts @@ -17,16 +17,17 @@ export async function startOpencode(opts: { sessionToken: string spinner: Ora children: ChildProcess[] + opencodeBin?: string }): Promise { - const { port, sessionToken, spinner, children } = opts + const { port, sessionToken, spinner, children, opencodeBin = "opencode" } = opts // v2 renamed --log-level values to lowercase ("DEBUG" is rejected). - const serverVersion = await detectOpencodeMajor() + const serverVersion = await detectOpencodeMajor(opencodeBin) const logLevel = serverVersion >= 2 ? "debug" : "DEBUG" - logCrosscode(`Detected opencode v${serverVersion}`) - debug("opencode version", { serverVersion }) + logCrosscode(`Detected ${opencodeBin} v${serverVersion}`) + debug("opencode version", { bin: opencodeBin, serverVersion }) - const proc = spawnCmd("opencode", [ + const proc = spawnCmd(opencodeBin, [ "serve", "--print-logs", "--log-level", logLevel, "--port", String(port), "--hostname", "127.0.0.1", ], { @@ -38,14 +39,14 @@ export async function startOpencode(opts: { children.push(proc) proc.on("spawn", () => { - spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Detecting port...") - logCrosscode("opencode serve started (PID: " + proc.pid + ")") - debug("opencode spawned", { pid: proc.pid }) + spinner.text = chalk.green.italic(`${opencodeBin} serve running`) + chalk.yellow.italic(" • Detecting port...") + logCrosscode(`${opencodeBin} serve started (PID: ${proc.pid})`) + debug("opencode spawned", { bin: opencodeBin, pid: proc.pid }) }) proc.on("error", (err) => { - spinner.fail(chalk.red.italic("Failed to start opencode serve")) - logCrosscode("opencode serve error: " + err.message) - debug("opencode spawn error", { error: err.message }) + spinner.fail(chalk.red.italic(`Failed to start ${opencodeBin} serve`)) + logCrosscode(`${opencodeBin} serve error: ${err.message}`) + debug("opencode spawn error", { bin: opencodeBin, error: err.message }) }) const detectedPort = await waitForOpencodePort({ diff --git a/packages/crosscode/src/providers/cloudflared.ts b/packages/crosscode/src/providers/cloudflared.ts index 5f6bd47..d04f074 100644 --- a/packages/crosscode/src/providers/cloudflared.ts +++ b/packages/crosscode/src/providers/cloudflared.ts @@ -10,7 +10,7 @@ import { logCrosscode, cloudflaredLogStream, cloudflaredTunnelDir, cfCertPath } import { createOpencodeProxy } from "../proxy" import { startOpencode } from "../opencode" import type { Config, ProjectConfig, CloudflaredTunnel } from "../config" -import { ensureSessionToken, ensureProjectId, saveProjectConfig } from "../config" +import { ensureSessionToken, ensureProjectId, saveProjectConfig, resolveOpencodeBin } from "../config" // Use a persistent named cloudflared tunnel so the public URL is stable // (.cfargotunnel.com) across restarts and reconnects, instead of @@ -65,8 +65,9 @@ export async function startCloudflaredProvider( ) { const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() const sessionToken = ensureSessionToken(config, project) + const opencodeBin = resolveOpencodeBin(config) - const { detectedPort, serverVersion } = await startOpencode({ port, sessionToken, spinner, children }) + const { detectedPort, serverVersion } = await startOpencode({ port, sessionToken, spinner, children, opencodeBin }) let proxyPort = await getFreePort() while (proxyPort === port) proxyPort = await getFreePort() @@ -76,7 +77,7 @@ export async function startCloudflaredProvider( proxy.listen(proxyPort, "127.0.0.1", async () => { logCrosscode(`SSE proxy started on port ${proxyPort}`) debug("proxy listening", { port: proxyPort, targetPort: detectedPort }) - spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Waiting for Cloudflare tunnel...") + spinner.text = chalk.green.italic(`${opencodeBin} serve running`) + chalk.yellow.italic(" • Waiting for Cloudflare tunnel...") const namedTunnel = await ensureCloudflaredNamedTunnel(config, project) if (!namedTunnel) { diff --git a/packages/crosscode/src/providers/ngrok.ts b/packages/crosscode/src/providers/ngrok.ts index eb1bfb3..103124f 100644 --- a/packages/crosscode/src/providers/ngrok.ts +++ b/packages/crosscode/src/providers/ngrok.ts @@ -7,7 +7,7 @@ import { logCrosscode, ngrokLogStream, cloudflaredLogStream } from "../log" import { proxyAgent } from "../proxy" import { startOpencode } from "../opencode" import type { Config, ProjectConfig } from "../config" -import { ensureSessionToken, saveConfig } from "../config" +import { ensureSessionToken, saveConfig, resolveOpencodeBin } from "../config" import { setupNgrokToken } from "../auth" export async function startNgrokProvider( @@ -30,8 +30,9 @@ export async function startNgrokProvider( const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() const sessionToken = ensureSessionToken(config, project) + const opencodeBin = resolveOpencodeBin(config) - const { detectedPort } = await startOpencode({ port, sessionToken, spinner, children }) + const { detectedPort } = await startOpencode({ port, sessionToken, spinner, children, opencodeBin }) const ngrok = spawnCmd("ngrok", ["http", `--authtoken=${ngrokToken}`, `${detectedPort}`], { stdio: ["ignore", "pipe", "pipe"], @@ -41,7 +42,7 @@ export async function startNgrokProvider( ngrok.on("spawn", () => { logCrosscode("ngrok started (PID: " + ngrok.pid + ")") - spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Starting ngrok tunnel...") + spinner.text = chalk.green.italic(`${opencodeBin} serve running`) + chalk.yellow.italic(" • Starting ngrok tunnel...") debug("ngrok spawned", { pid: ngrok.pid }) }) ngrok.on("error", (err) => { diff --git a/packages/crosscode/src/providers/tunnel.ts b/packages/crosscode/src/providers/tunnel.ts index a8b7784..7f140ec 100644 --- a/packages/crosscode/src/providers/tunnel.ts +++ b/packages/crosscode/src/providers/tunnel.ts @@ -8,7 +8,7 @@ import { createOpencodeProxy, proxyAgent } from "../proxy" import { startOpencode } from "../opencode" import { connectTunnel } from "../tunnel-client" import type { Config, ProjectConfig } from "../config" -import { ensureSessionToken, ensureProjectId } from "../config" +import { ensureSessionToken, ensureProjectId, resolveOpencodeBin } from "../config" export type TunnelCallbacks = { onTunnelUrl: (url: string) => void @@ -25,8 +25,9 @@ export async function startTunnelProvider( ) { const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() const sessionToken = ensureSessionToken(config, project) + const opencodeBin = resolveOpencodeBin(config) - const { detectedPort, serverVersion } = await startOpencode({ port, sessionToken, spinner, children }) + const { detectedPort, serverVersion } = await startOpencode({ port, sessionToken, spinner, children, opencodeBin }) let proxyPort = await getFreePort() while (proxyPort === port) proxyPort = await getFreePort() @@ -53,7 +54,7 @@ export async function startTunnelProvider( }) testReq.end() - spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Connecting to tunnel server...") + spinner.text = chalk.green.italic(`${opencodeBin} serve running`) + chalk.yellow.italic(" • Connecting to tunnel server...") const projectId = ensureProjectId(config, project) logCrosscode(`Project ID: ${projectId}`)