diff --git a/bun.lock b/bun.lock index 12677ea976..25068f3d9a 100644 --- a/bun.lock +++ b/bun.lock @@ -715,7 +715,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.57", + "effect": "4.0.0-beta.59", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -3078,7 +3078,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="], + "effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], diff --git a/nix/hashes.json b/nix/hashes.json index bea97a0cb3..84c3b13043 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=", - "aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=", - "aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=", - "x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U=" + "x86_64-linux": "sha256-9wTDLZsuGjkWyVOb6AG2VRYPiaSj/lnXwVkSwNeDcns=", + "aarch64-linux": "sha256-gmKlL2fQxY8bo+//8m9e1TNYJK3RXa4i8xsgtd046bc=", + "aarch64-darwin": "sha256-ENSJK+7rZi3m342mjtGg9N0P6zWEypXMpI7QdFMydbc=", + "x86_64-darwin": "sha256-gkxCxGh5dlwj03vZdz20pbiAwFEDpAlu/5iU8cwZOGI=" } } diff --git a/package.json b/package.json index b15fbb2544..de3dd31f40 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.57", + "effect": "4.0.0-beta.59", "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.10.7", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 4f17538612..2649260cf3 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,8 +1,6 @@ import "@/index.css" -import { Button } from "@opencode-ai/ui/button" import * as Sentry from "@sentry/solid" import { I18nProvider } from "@opencode-ai/ui/context" -import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { MarkedProvider } from "@opencode-ai/ui/context/marked" @@ -13,9 +11,12 @@ import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" +import { Effect } from "effect" import { type Component, createMemo, + createResource, + createSignal, ErrorBoundary, For, type JSX, @@ -23,7 +24,6 @@ import { onCleanup, type ParentProps, Show, - startTransition, Suspense, } from "solid-js" import { Dynamic } from "solid-js/web" @@ -38,7 +38,6 @@ import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { NotificationProvider } from "@/context/notification" import { PermissionProvider } from "@/context/permission" -import { usePlatform } from "@/context/platform" import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { SettingsProvider } from "@/context/settings" @@ -47,6 +46,7 @@ import { WslServersProvider } from "@/context/wsl-servers" import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" +import { useCheckServerHealth } from "./utils/server-health" const HomeRoute = lazy(() => import("@/pages/home")) const loadSession = () => import("@/pages/session") @@ -75,6 +75,7 @@ declare global { __OPENCODE__?: { updaterEnabled?: boolean deepLinks?: string[] + wsl?: boolean activeServer?: string } api?: { @@ -175,48 +176,80 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { const server = useServer() - const healthy = createMemo(() => props.disableHealthCheck || server.healthy()) + const checkServerHealth = useCheckServerHealth() - const splash = ( -
- -
+ const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking") + + // performs repeated health check with a grace period for + // non-http connections, otherwise fails instantly + const [startupHealthCheck, healthCheckActions] = createResource(() => + props.disableHealthCheck + ? true + : Effect.gen(function* () { + if (!server.current) return true + const { http, type } = server.current + + while (true) { + const res = yield* Effect.promise(() => checkServerHealth(http)) + if (res.healthy) return true + if (checkMode() === "background" || type === "http") return false + } + }).pipe( + Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }), + Effect.ensuring(Effect.sync(() => setCheckMode("background"))), + Effect.runPromise, + ), ) return ( - - - - { - startTransition(() => { - server.setActive(key) - }) - }} - /> - } - > - {props.children} - - - - + + + + } + > + {/* + + + } + >*/} + {checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest} + { + if (checkMode() === "background") void healthCheckActions.refetch() + }} + onServerSelected={(key) => { + setCheckMode("blocking") + server.setActive(key) + void healthCheckActions.refetch() + }} + /> + } + > + {props.children} + + {/**/} + ) } -function ConnectionError(props: { onServerSelected?: (key: ServerConnection.Key) => void }) { - const dialog = useDialog() +function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) { const language = useLanguage() - const platform = usePlatform() const server = useServer() const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key) const name = createMemo(() => server.name || server.key) const serverToken = "\u0000server\u0000" const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken)) - const canManage = createMemo(() => server.current?.type === "sidecar" && server.current?.variant === "wsl") + + const timer = setInterval(() => props.onRetry?.(), 1000) + onCleanup(() => clearInterval(timer)) return (
@@ -228,34 +261,6 @@ function ConnectionError(props: { onServerSelected?: (key: ServerConnection.Key) {unreachable()[1]}

{language.t("app.server.retrying")}

- - -
0}>
@@ -295,21 +300,13 @@ export function AppInterface(props: { children?: JSX.Element defaultServer: ServerConnection.Key servers?: Array - serversReady?: boolean router?: Component disableHealthCheck?: boolean }) { - // ServerKey wraps the whole Router so that switching `server.key` throws - // away any session / pty state from the previous server. Preserving the - // route across servers doesn't work because session ids, pty ids, and - // most URL-addressable resources are server-scoped — you'd 404 on every - // fetch. The click handler that swaps servers also navigates back to "/" - // so the fresh MemoryRouter doesn't try to re-resolve a now-dead URL. return ( diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index ac0710c339..909d45ae71 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -8,7 +8,7 @@ import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" -import { batch, createEffect, createMemo, onCleanup, Show, startTransition, untrack } from "solid-js" +import { batch, createEffect, createMemo, createResource, onCleanup, Show, startTransition, untrack } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { DialogWslServer } from "@/components/dialog-wsl-server" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" @@ -75,6 +75,32 @@ function isWslSidecar(conn: ServerConnection.Any): conn is ServerConnection.Side return conn.type === "sidecar" && conn.variant === "wsl" } +function useDefaultServer() { + const language = useLanguage() + const platform = usePlatform() + const [defaultKey, defaultActions] = createResource( + async () => { + try { + return (await platform.getDefaultServer?.()) ?? null + } catch (err) { + showRequestError(language, err) + return null + } + }, + { initialValue: null }, + ) + const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer) + const setDefault = async (key: ServerConnection.Key | null) => { + try { + await platform.setDefaultServer?.(key) + defaultActions.mutate(key) + } catch (err) { + showRequestError(language, err) + } + } + return { defaultKey, canDefault, setDefault } +} + function ServerForm(props: ServerFormProps) { const language = useLanguage() const keyDown = (event: KeyboardEvent) => { @@ -146,6 +172,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const platform = usePlatform() const language = useLanguage() const wslServers = useWslServers() + const defaultServer = useDefaultServer() const checkServerHealth = useCheckServerHealth() let disposed = false onCleanup(() => { @@ -271,7 +298,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { }, onSuccess: async (key) => { server.remove(key) - if (server.defaultKey() === key) await server.setDefault(null) + if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null) }, onError: (err) => showRequestError(language, err), })) @@ -546,7 +573,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { async function handleRemove(key: ServerConnection.Key) { server.remove(key) - if (server.defaultKey() === key) await server.setDefault(null) + if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null) } return ( @@ -603,7 +630,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const wsl = isWslSidecar(i) const wslDistro = wsl ? i.distro : undefined const blocked = () => health(key)?.healthy === false - const canChangeDefault = () => server.canDefault() && i.type !== "ssh" + const canChangeDefault = () => defaultServer.canDefault() && i.type !== "ssh" const canRemove = () => i.type === "http" || wsl const outdated = () => { const check = wslCheck(i) @@ -632,7 +659,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { version={wslCheck(i)?.version ?? undefined} class="flex items-center gap-3 min-w-0 flex-1" badge={ - + {language.t("dialog.server.status.default")} @@ -689,15 +716,15 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { Retry start - - void server.setDefault(key)}> + + void defaultServer.setDefault(key)}> {language.t("dialog.server.menu.default")} - - void server.setDefault(null)}> + + void defaultServer.setDefault(null)}> {language.t("dialog.server.menu.defaultRemove")} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index a85e160d10..0a18096164 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1253,11 +1253,7 @@ export const PromptInput: Component = (props) => { } const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({ - queries: [ - loadAgentsQuery(sdk.directory), - loadProvidersQuery(null), - loadProvidersQuery(sdk.directory), - ], + queries: [loadAgentsQuery(sdk.directory), loadProvidersQuery(null), loadProvidersQuery(sdk.directory)], })) const agentsLoading = () => agentsQuery.isLoading diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 5442d9985d..3d4f58deec 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -6,10 +6,9 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Keybind } from "@opencode-ai/ui/keybind" import { Spinner } from "@opencode-ai/ui/spinner" import { showToast } from "@opencode-ai/ui/toast" -import { StatusPopover } from "../status-popover" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/core/util/path" -import { createEffect, createMemo, For, onCleanup, onMount, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" @@ -25,6 +24,7 @@ import { useSessionLayout } from "@/pages/session/session-layout" import { messageAgentColor } from "@/utils/agent" import { decode64 } from "@/utils/base64" import { Persist, persisted } from "@/utils/persist" +import { StatusPopover } from "../status-popover" const OPEN_APPS = [ "vscode", @@ -129,13 +129,6 @@ const showRequestError = (language: ReturnType, err: unknown }) } -function titlebarMounts() { - return { - center: document.getElementById("opencode-titlebar-center") as HTMLDivElement | undefined, - right: document.getElementById("opencode-titlebar-right") as HTMLDivElement | undefined, - } -} - export function SessionHeader() { const layout = useLayout() const command = useCommand() @@ -226,7 +219,6 @@ export function SessionHeader() { const [openRequest, setOpenRequest] = createStore({ app: undefined as OpenApp | undefined, }) - const [mounts, setMounts] = createStore(titlebarMounts()) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const current = createMemo( @@ -240,19 +232,6 @@ export function SessionHeader() { messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent), ) - const syncMounts = () => { - const next = titlebarMounts() - if (mounts.center === next.center && mounts.right === next.right) return - setMounts(next) - } - - onMount(() => { - syncMounts() - const observer = new MutationObserver(() => syncMounts()) - observer.observe(document.body, { childList: true, subtree: true }) - onCleanup(() => observer.disconnect()) - }) - const selectApp = (app: OpenApp) => { if (!options().some((item) => item.id === app)) return setPrefs("app", app) @@ -290,8 +269,12 @@ export function SessionHeader() { .catch((err: unknown) => showRequestError(language, err)) } - const centerMount = createMemo(() => mounts.center) - const rightMount = createMemo(() => mounts.right) + const [centerMount, setCenterMount] = createSignal(null) + const [rightMount, setRightMount] = createSignal(null) + onMount(() => { + setCenterMount(document.getElementById("opencode-titlebar-center")) + setRightMount(document.getElementById("opencode-titlebar-right")) + }) return ( <> diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index fcc42f04f2..952e3eac64 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -6,15 +6,16 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { useMutation, useQueryClient } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" -import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show, startTransition } from "solid-js" +import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" -import { loadMcpQuery } from "@/context/global-sync" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" -import { ServerConnection, useServer } from "@/context/server" +import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" +import { loadMcpQuery } from "@/context/global-sync" const pollMs = 10_000 @@ -36,7 +37,7 @@ const listServersByHealth = ( status: Record, ) => { if (!list.length) return list - const order = new Map(list.map((conn, index) => [conn, index] as const)) + const order = new Map(list.map((url, index) => [url, index] as const)) const rank = (value?: ServerHealth) => { if (value?.healthy === true) return 0 if (value?.healthy === false) return 2 @@ -65,7 +66,7 @@ const useServerHealth = (servers: Accessor, enabled: Acc let dead = false const refresh = async () => { - const results: Record = {} + const results: Record = {} await Promise.all( list.map(async (conn) => { results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) @@ -86,6 +87,53 @@ const useServerHealth = (servers: Accessor, enabled: Acc return status } +const useDefaultServerKey = ( + get: (() => string | Promise | null | undefined) | undefined, +) => { + const [state, setState] = createStore({ + url: undefined as string | undefined, + tick: 0, + }) + + createEffect(() => { + state.tick + let dead = false + const result = get?.() + if (!result) { + setState("url", undefined) + onCleanup(() => { + dead = true + }) + return + } + + if (result instanceof Promise) { + void result.then((next) => { + if (dead) return + setState("url", next ? normalizeServerUrl(next) : undefined) + }) + onCleanup(() => { + dead = true + }) + return + } + + setState("url", normalizeServerUrl(result)) + onCleanup(() => { + dead = true + }) + }) + + return { + key: () => { + const u = state.url + if (!u) return + return ServerConnection.key({ type: "http", http: { url: u } }) + }, + refresh: () => setState("tick", (value) => value + 1), + } +} + const useMcpToggleMutation = () => { const sync = useSync() const sdk = useSDK() @@ -111,10 +159,23 @@ const useMcpToggleMutation = () => { export function StatusPopoverBody(props: { shown: Accessor }) { const sync = useSync() const server = useServer() + const platform = usePlatform() const dialog = useDialog() const language = useLanguage() const navigate = useNavigate() + const fail = (err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + } + + createEffect(() => { + if (!props.shown()) return + }) + let dialogRun = 0 let dialogDead = false onCleanup(() => { @@ -131,6 +192,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { const health = useServerHealth(servers, props.shown) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const toggleMcp = useMcpToggleMutation() + const defaultServer = useDefaultServerKey(platform.getDefaultServer) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) @@ -189,18 +251,8 @@ export function StatusPopoverBody(props: { shown: Accessor }) { aria-disabled={blocked()} onClick={() => { if (blocked()) return - startTransition(() => { - batch(() => { - if (server.key !== key) { - if (typeof window !== "undefined" && window.history?.replaceState) { - window.history.replaceState(null, "", "/") - } - } else { - navigate("/") - } - server.setActive(key) - }) - }) + navigate("/") + queueMicrotask(() => server.setActive(key)) }} > @@ -212,7 +264,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { nameClass="text-14-regular text-text-base truncate" versionClass="text-12-regular text-text-weak truncate" badge={ - + {language.t("common.default")} @@ -236,7 +288,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { const run = ++dialogRun void import("./dialog-select-server").then((x) => { if (dialogDead || dialogRun !== run) return - dialog.show(() => navigate("/")} />) + dialog.show(() => , defaultServer.refresh) }) }} > diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 12e1ba5e3f..ff5ff9dada 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -62,26 +62,6 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = { }, } -const getTerminalColors = (theme: ReturnType): TerminalColors => { - const mode = theme.mode() === "dark" ? "dark" : "light" - const fallback = DEFAULT_TERMINAL_COLORS[mode] - const currentTheme = theme.themes()[theme.themeId()] - if (!currentTheme) return fallback - const variant = mode === "dark" ? currentTheme.dark : currentTheme.light - if (!variant?.seeds && !variant?.palette) return fallback - const resolved = resolveThemeVariant(variant, mode === "dark") - const text = resolved["text-stronger"] ?? fallback.foreground - const background = resolved["background-stronger"] ?? fallback.background - const alpha = mode === "dark" ? 0.25 : 0.2 - const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor) - return { - background, - foreground: text, - cursor: text, - selectionBackground: withAlpha(base, alpha), - } -} - const debugTerminal = (...values: unknown[]) => { if (!import.meta.env.DEV) return console.debug("[terminal]", ...values) @@ -94,11 +74,6 @@ const errorName = (err: unknown) => { return typeof errorName === "string" ? errorName : undefined } -const logTerminal = (phase: string, input: Record) => { - if (!import.meta.env.DEV) return - console.log(`[terminal ui] ${JSON.stringify({ phase, ...input })}`) -} - const useTerminalUiBindings = (input: { container: HTMLDivElement term: Term @@ -258,7 +233,28 @@ export const Terminal = (props: TerminalProps) => { }) } - const terminalColors = createMemo(() => getTerminalColors(theme)) + const getTerminalColors = (): TerminalColors => { + const mode = theme.mode() === "dark" ? "dark" : "light" + const fallback = DEFAULT_TERMINAL_COLORS[mode] + const currentTheme = theme.themes()[theme.themeId()] + if (!currentTheme) return fallback + const variant = mode === "dark" ? currentTheme.dark : currentTheme.light + if (!variant?.seeds && !variant?.palette) return fallback + const resolved = resolveThemeVariant(variant, mode === "dark") + const text = resolved["text-stronger"] ?? fallback.foreground + const background = resolved["background-stronger"] ?? fallback.background + const alpha = mode === "dark" ? 0.25 : 0.2 + const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor) + const selectionBackground = withAlpha(base, alpha) + return { + background, + foreground: text, + cursor: text, + selectionBackground, + } + } + + const terminalColors = createMemo(getTerminalColors) const scheduleFit = () => { if (disposed) return @@ -454,32 +450,20 @@ export const Terminal = (props: TerminalProps) => { output.flush(resolve) }) - // Defer the serialised `restore` buffer until the WebSocket actually - // opens against the live PTY. Previously we wrote it synchronously - // before connect, which painted stale content on screen whenever the - // sidecar had restarted (e.g. a server swap): every saved pty id - // belongs to the old sidecar, so connect eventually fails and the - // clone handler wipes the buffer — but you'd see the old bash/pwsh - // scrollback flash first. Now `restore` is only applied once we know - // the pty is real (handleOpen), and if connect fails clone clears - // `buffer` in the store so the next mount has nothing to replay. - fit.fit() - scheduleSize(t.cols, t.rows) - startResize() - - let restored = false - const applyRestore = async () => { - if (restored) return - restored = true - if (!restore) return - logTerminal("restore.apply", { - id, - serverKey: server.key ?? null, - directory, - restoreLength: restore.length, - }) + if (restore && restoreSize) { await write(restore) + fit.fit() + scheduleSize(t.cols, t.rows) if (scrollY !== undefined) t.scrollToLine(scrollY) + startResize() + } else { + fit.fit() + scheduleSize(t.cols, t.rows) + if (restore) { + await write(restore) + if (scrollY !== undefined) t.scrollToLine(scrollY) + } + startResize() } const once = { value: false } @@ -536,16 +520,6 @@ export const Terminal = (props: TerminalProps) => { next.password = password } - logTerminal("socket.open", { - id, - serverKey: server.key ?? null, - directory, - restoreLength: restore.length, - sdkUrl: sdk.url, - currentUrl: url, - wsUrl: next.toString(), - }) - const socket = new WebSocket(next) socket.binaryType = "arraybuffer" ws = socket @@ -553,16 +527,6 @@ export const Terminal = (props: TerminalProps) => { const handleOpen = () => { if (disposed) return tries = 0 - logTerminal("socket.connected", { - id, - serverKey: server.key ?? null, - directory, - currentUrl: url, - }) - // Paint the saved buffer now that we've confirmed the pty really - // exists on the current sidecar. Fire-and-forget: write()'s own - // flush keeps the data ordered with incoming WS messages. - void applyRestore() local.onConnect?.() scheduleSize(t.cols, t.rows) } @@ -617,14 +581,6 @@ export const Terminal = (props: TerminalProps) => { socket.removeEventListener("close", handleClose) if (disposed) return if (event.code === 1000) return - logTerminal("socket.closed", { - id, - serverKey: server.key ?? null, - directory, - code: event.code, - reason: event.reason || null, - currentUrl: url, - }) retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code }))) } @@ -650,13 +606,6 @@ export const Terminal = (props: TerminalProps) => { }) onCleanup(() => { - logTerminal("cleanup", { - id, - serverKey: server.key ?? null, - directory, - cursor, - restoreLength: restore.length, - }) disposed = true if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) if (sizeTimer !== undefined) clearTimeout(sizeTimer) @@ -664,30 +613,17 @@ export const Terminal = (props: TerminalProps) => { drop?.() if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000) - // Defer finalize (persistTerminal + local cleanup()) to a microtask so - // that its synchronous store write inside `persistTerminal` — which - // flows through `props.onCleanup` -> `ops.update` -> `update()` in - // `context/terminal.tsx` and calls `setStore("all", i, ...)` — does - // NOT run inside the outer solid cleanNode cascade. Running it - // synchronously mid-cascade races with solid's recursive owned - // iteration (readSignal on a stale memo re-enters updateComputation, - // which nulls an ancestor's owned while the outer loop is still - // iterating it) and crashes with "Cannot read properties of null - // (reading '1')" at node.owned[i] inside chunk-EZWYHVNM.js cleanNode. - // queueMicrotask runs after the current sync reactive flush, so the - // store write lands in a fresh tick. const finalize = () => { persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup }) cleanup() } - const schedule = () => queueMicrotask(finalize) if (!output) { - schedule() + finalize() return } - output.flush(schedule) + output.flush(finalize) }) return ( diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 5666442a4d..e53d60d5a0 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -95,15 +95,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo buffer.length = 0 } - const clearPending = () => { - if (timer) clearTimeout(timer) - timer = undefined - queue.length = 0 - buffer.length = 0 - coalesced.clear() - staleDeltas.clear() - } - const schedule = () => { if (timer) return const elapsed = Date.now() - last @@ -211,10 +202,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo } })().finally(() => { run = undefined - if (abort.signal.aborted || !started) { - clearPending() - return - } flush() }) return run @@ -238,7 +225,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo onCleanup(() => { stop() abort.abort() - clearPending() + flush() }) const sdk = createSdkForServer({ @@ -248,9 +235,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo }) return { - get url() { - return server.current?.http.url ?? currentServer.http.url - }, + url: currentServer.http.url, client: sdk, event: { on: emitter.on.bind(emitter), diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index db0c538104..0138310cdc 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -112,15 +112,8 @@ export function createChildStoreManager(input: { lifecycle.delete(key) const dispose = disposers.get(key) if (dispose) { + dispose() disposers.delete(key) - // Defer the actual solid-js root disposal. When disposeDirectory runs - // from pinForOwner's onCleanup during a parent remount, calling - // dispose() here triggers a nested cleanNode cascade on the inner - // root while the outer cascade is mid-traversal, which corrupts - // solid-js's graph walk state and throws `Cannot read properties of - // null (reading '1')` at chunk-*.js:992. Running dispose on a - // microtask lets the outer cleanup finish first. - queueMicrotask(dispose) } delete children[key] input.onDispose(key) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 928ed5ee2e..757c8e49a5 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -147,6 +147,12 @@ export type Platform = { /** Set the default server URL to use on app startup (platform-specific) */ setDefaultServer?(url: ServerConnection.Key | null): Promise | void + /** Get the configured WSL integration (desktop only) */ + getWslEnabled?(): Promise + + /** Set the configured WSL integration (desktop only) */ + setWslEnabled?(config: boolean): Promise | void + /** Manage WSL sidecar servers (Electron on Windows only) */ wslServers?: WslServersPlatform diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 5a33529b4e..dffb798310 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -232,13 +232,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const cache = new Map() const disposeAll = () => { - // Defer the dispose calls to a microtask; synchronous nested dispose - // inside a parent onCleanup corrupts solid-js's in-flight cleanNode - // traversal during mass remounts (see context/terminal.tsx for the - // same pattern). - const pending = Array.from(cache.values(), (entry) => entry.dispose) + for (const entry of cache.values()) { + entry.dispose() + } cache.clear() - if (pending.length) queueMicrotask(() => pending.forEach((d) => d())) } onCleanup(disposeAll) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 2bc8951837..636c566a0a 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -1,11 +1,8 @@ -import { showToast } from "@opencode-ai/ui/toast" import { createSimpleContext } from "@opencode-ai/ui/context" -import { type Accessor, batch, createEffect, createMemo, onCleanup, untrack } from "solid-js" +import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" import { useCheckServerHealth } from "@/utils/server-health" -import { useLanguage } from "./language" -import { usePlatform } from "./platform" type StoredProject = { worktree: string; expanded: boolean } type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http @@ -26,7 +23,7 @@ export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = fals function projectsKey(key: ServerConnection.Key) { if (!key) return "" - if (key === "sidecar" || key === "local:windows") return "local" + if (key === "sidecar") return "local" if (isLocalHost(key)) return "local" return key } @@ -84,7 +81,7 @@ export namespace ServerConnection { return Key.make(conn.http.url) case "sidecar": { if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`) - return Key.make("local:windows") + return Key.make("sidecar") } case "ssh": return Key.make(`ssh:${conn.host}`) @@ -100,13 +97,9 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( init: (props: { defaultServer: ServerConnection.Key disableHealthCheck?: boolean - serversReady?: boolean servers?: Array }) => { const checkServerHealth = useCheckServerHealth() - const language = useLanguage() - const platform = usePlatform() - const serversReady = () => props.serversReady ?? true const [store, setStore, _, ready] = persisted( Persist.global("server", ["server.v3"]), @@ -144,7 +137,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const [state, setState] = createStore({ active: props.defaultServer, - default: props.defaultServer as ServerConnection.Key | null, healthy: undefined as boolean | undefined, }) @@ -179,28 +171,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( if (state.active !== input) setState("active", input) } - async function setDefault(input: ServerConnection.Key | null) { - if (!platform.setDefaultServer) return input - try { - await platform.setDefaultServer(input) - setState("default", input) - return input - } catch (err) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - throw err - } - } - - function nextActiveKey(exclude?: ServerConnection.Key) { - const available = allServers().filter((conn) => ServerConnection.key(conn) !== exclude) - const preferred = available.find((conn) => ServerConnection.key(conn) === props.defaultServer) - const next = preferred ?? available[0] - return next ? ServerConnection.key(next) : props.defaultServer - } + createEffect(() => { + if (typeof window === "undefined") return + window.__OPENCODE__ ??= {} + window.__OPENCODE__.activeServer = state.active + }) function add(input: ServerConnection.Http) { const url_ = normalizeServerUrl(input.http.url) @@ -223,39 +198,18 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( batch(() => { setStore("list", list) if (state.active === key) { - setState("active", nextActiveKey(key)) + const next = list[0] + setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer) } }) } + const isReady = createMemo(() => ready() && !!state.active) + const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy) createEffect(() => { - const key = state.active - if (typeof window === "undefined") return - window.__OPENCODE__ ??= {} - window.__OPENCODE__.activeServer = key - }) - - const origin = createMemo(() => projectsKey(state.active)) - const projectsList = createMemo(() => store.projects[origin()] ?? []) - const current: Accessor = createMemo(() => { - const list = allServers() - const active = list.find((s) => ServerConnection.key(s) === state.active) - if (active) return active - if (!serversReady()) return - return list[0] - }) - const healthTarget = createMemo(() => { - const conn = current() - if (!conn) return "" - return [ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n") - }) - const isReady = createMemo(() => ready() && !!current()) - - createEffect(() => { - healthTarget() - const current_ = untrack(current) + const current_ = current() if (!current_) return if (props.disableHealthCheck) { @@ -266,14 +220,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( onCleanup(startHealthPolling(current_)) }) - createEffect(() => { - if (!serversReady()) return - const list = allServers() - if (!list.length) return - if (list.some((conn) => ServerConnection.key(conn) === state.active)) return - setState("active", nextActiveKey(state.active)) - }) - + const origin = createMemo(() => projectsKey(state.active)) + const projectsList = createMemo(() => store.projects[origin()] ?? []) + const current: Accessor = createMemo( + () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0], + ) const isLocal = createMemo(() => { const c = current() return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url)) @@ -295,13 +246,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( get current() { return current() }, - canDefault() { - return !!platform.getDefaultServer && !!platform.setDefaultServer - }, - defaultKey() { - return state.default - }, - setDefault, setActive, add, remove, diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index 179af4f13c..6e07e03124 100644 --- a/packages/app/src/context/terminal.test.ts +++ b/packages/app/src/context/terminal.test.ts @@ -22,7 +22,7 @@ beforeAll(async () => { }) describe("getWorkspaceTerminalCacheKey", () => { - test("uses the workspace cache key", () => { + test("uses workspace-only directory cache key", () => { expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__") }) }) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 1bb79ee054..31d2d6e04c 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -3,7 +3,6 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" import { useSDK } from "./sdk" -import { useServer } from "./server" import type { Platform } from "./platform" import { defaultTitle, titleNumber } from "./terminal-title" import { Persist, persisted, removePersisted } from "@/utils/persist" @@ -22,11 +21,6 @@ export type LocalPTY = { const WORKSPACE_KEY = "__workspace__" const MAX_TERMINAL_SESSIONS = 20 -const debugTerminal = (phase: string, input: Record) => { - if (!import.meta.env.DEV) return - console.log(`[terminal context] ${JSON.stringify({ phase, ...input })}`) -} - function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } @@ -117,11 +111,10 @@ const trimTerminal = (pty: LocalPTY) => { } export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) { + const key = getWorkspaceTerminalCacheKey(dir) for (const cache of caches) { - for (const [key, entry] of cache.entries()) { - if (!key.startsWith(`${dir}:`) || !key.endsWith(`:${WORKSPACE_KEY}`)) continue - entry.value.clear() - } + const entry = cache.get(key) + entry?.value.clear() } void removePersisted(Persist.workspace(dir, "terminal"), platform) @@ -137,25 +130,14 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat } } -function createWorkspaceTerminalSession( - sdk: ReturnType, - dir: string, - serverKey: string, - legacySessionID?: string, -) { +function createWorkspaceTerminalSession(sdk: ReturnType, dir: string, legacySessionID?: string) { const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID) - const target = { - ...Persist.workspace(dir, `${serverKey}:terminal`, legacy), - migrate: migrateTerminalState, - } - // Scope persisted terminal state by server so switching servers behaves - // like switching projects: a fresh session for the new server+dir pair, - // while the other server's state stays intact until you swap back. PTY - // ids, scrollback, and WebSocket connections are all server-scoped, so - // cross-server persistence was showing stale output on swap. const [store, setStore, _, ready] = persisted( - target, + { + ...Persist.workspace(dir, "terminal", legacy), + migrate: migrateTerminalState, + }, createStore<{ active?: string all: LocalPTY[] @@ -164,14 +146,6 @@ function createWorkspaceTerminalSession( }), ) - debugTerminal("session.create", { - dir, - serverKey, - storage: target.storage, - key: target.key, - legacySessionID: legacySessionID ?? null, - }) - const pickNextTerminalNumber = () => { const existingTitleNumbers = new Set( store.all.flatMap((pty) => { @@ -212,16 +186,6 @@ function createWorkspaceTerminalSession( onCleanup(unsub) const update = (client: ReturnType["client"], pty: Partial & { id: string }) => { - debugTerminal("session.update", { - dir, - serverKey, - id: pty.id, - title: pty.title ?? null, - hasBuffer: typeof pty.buffer === "string", - bufferLength: typeof pty.buffer === "string" ? pty.buffer.length : 0, - cursor: pty.cursor ?? null, - scrollY: pty.scrollY ?? null, - }) const index = store.all.findIndex((x) => x.id === pty.id) const previous = index >= 0 ? store.all[index] : undefined if (index >= 0) { @@ -238,18 +202,11 @@ function createWorkspaceTerminalSession( const currentIndex = store.all.findIndex((item) => item.id === pty.id) if (currentIndex >= 0) setStore("all", currentIndex, previous) } - console.error( - `Failed to update terminal ${JSON.stringify({ - ptyID: pty.id, - title: pty.title, - error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : error, - })}`, - ) + console.error("Failed to update terminal", error) }) } const clone = async (client: ReturnType["client"], id: string) => { - debugTerminal("session.clone.start", { dir, serverKey, id }) const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] if (!pty) return @@ -263,14 +220,6 @@ function createWorkspaceTerminalSession( }) if (!next?.data) return - debugTerminal("session.clone.done", { - dir, - serverKey, - id, - nextID: next.data.id ?? null, - title: next.data.title ?? pty.title, - }) - const active = store.active === pty.id batch(() => { @@ -303,19 +252,11 @@ function createWorkspaceTerminalSession( new() { const nextNumber = pickNextTerminalNumber() - debugTerminal("session.new", { dir, serverKey, nextNumber }) - sdk.client.pty .create({ title: defaultTitle(nextNumber) }) .then((pty: { data?: { id?: string; title?: string } }) => { const id = pty.data?.id if (!id) return - debugTerminal("session.new.done", { - dir, - serverKey, - id, - title: pty.data?.title ?? defaultTitle(nextNumber), - }) const newTerminal = { id, title: pty.data?.title ?? defaultTitle(nextNumber), @@ -348,12 +289,6 @@ function createWorkspaceTerminalSession( }, bind() { const client = sdk.client - debugTerminal("session.bind", { - dir, - serverKey, - active: store.active ?? null, - all: store.all.map((item) => item.id), - }) return { trim(id: string) { const index = store.all.findIndex((x) => x.id === id) @@ -422,7 +357,6 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont gate: false, init: () => { const sdk = useSDK() - const server = useServer() const params = useParams() const cache = new Map() @@ -430,9 +364,10 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont onCleanup(() => caches.delete(cache)) const disposeAll = () => { - const pending = Array.from(cache.values(), (entry) => entry.dispose) + for (const entry of cache.values()) { + entry.dispose() + } cache.clear() - for (const dispose of pending) dispose() } onCleanup(disposeAll) @@ -447,30 +382,18 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } } - const loadWorkspace = (dir: string, serverKey: string, legacySessionID?: string) => { + const loadWorkspace = (dir: string, legacySessionID?: string) => { + // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory. const key = getWorkspaceTerminalCacheKey(dir) const existing = cache.get(key) if (existing) { - debugTerminal("workspace.cache.hit", { - dir, - serverKey, - key, - legacySessionID: legacySessionID ?? null, - }) cache.delete(key) cache.set(key, existing) return existing.value } - debugTerminal("workspace.cache.miss", { - dir, - serverKey, - key, - legacySessionID: legacySessionID ?? null, - }) - const entry = createRoot((dispose) => ({ - value: createWorkspaceTerminalSession(sdk, dir, serverKey, legacySessionID), + value: createWorkspaceTerminalSession(sdk, dir, legacySessionID), dispose, })) @@ -479,21 +402,16 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return entry.value } - const workspace = createMemo(() => { - const key = server.key - if (!key) return loadWorkspace(params.dir!, "", params.id) - return loadWorkspace(params.dir!, key, params.id) - }) + const workspace = createMemo(() => loadWorkspace(params.dir!, params.id)) createEffect( on( () => ({ dir: params.dir, id: params.id }), (next, prev) => { - const prevKey = server.key - if (!prev?.dir || !prevKey) return + if (!prev?.dir) return if (next.dir === prev.dir && next.id === prev.id) return if (next.dir === prev.dir && next.id) return - loadWorkspace(prev.dir, prevKey, prev.id).trimAll() + loadWorkspace(prev.dir, prev.id).trimAll() }, { defer: true }, ), @@ -508,7 +426,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont trim: (id: string) => workspace().trim(id), trimAll: () => workspace().trimAll(), clone: (id: string) => workspace().clone(id), - bind: () => workspace().bind(), + bind: () => workspace(), open: (id: string) => workspace().open(id), close: (id: string) => workspace().close(id), move: (id: string, to: number) => workspace().move(id, to), diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 2b5feecd74..e85ea84a5b 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,5 +1,4 @@ export { AppBaseProviders, AppInterface } from "./app" -export { DialogWslServer } from "./components/dialog-wsl-server" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 3d3ec9bf05..b71be13dab 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -83,16 +83,9 @@ import { LocalWorkspace, SortableWorkspace, WorkspaceDragOverlay, - workspaceSortableDirectory, - workspaceSortableId, type WorkspaceSidebarContext, } from "./layout/sidebar-workspace" -import { - ProjectDragOverlay, - SortableProject, - projectSortableWorktree, - type ProjectSidebarContext, -} from "./layout/sidebar-project" +import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { SidebarContent } from "./layout/sidebar-shell" export default function Layout(props: ParentProps) { @@ -1844,7 +1837,7 @@ export default function Layout(props: ParentProps) { ) function handleDragStart(event: unknown) { - const id = projectSortableWorktree(getDraggableId(event)) + const id = getDraggableId(event) if (!id) return setHoverProject(undefined) setStore("activeProject", id) @@ -1853,14 +1846,11 @@ export default function Layout(props: ParentProps) { function handleDragOver(event: DragEvent) { const { draggable, droppable } = event if (draggable && droppable) { - const from = projectSortableWorktree(draggable.id?.toString()) - const to = projectSortableWorktree(droppable.id?.toString()) - if (!from || !to) return const projects = layout.projects.list() - const fromIndex = projects.findIndex((p) => p.worktree === from) - const toIndex = projects.findIndex((p) => p.worktree === to) + const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString()) + const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString()) if (fromIndex !== toIndex && toIndex !== -1) { - layout.projects.move(from, toIndex) + layout.projects.move(draggable.id.toString(), toIndex) } } } @@ -1896,7 +1886,7 @@ export default function Layout(props: ParentProps) { }) function handleWorkspaceDragStart(event: unknown) { - const id = workspaceSortableDirectory(getDraggableId(event)) + const id = getDraggableId(event) if (!id) return setStore("activeWorkspace", id) } @@ -1904,16 +1894,13 @@ export default function Layout(props: ParentProps) { function handleWorkspaceDragOver(event: DragEvent) { const { draggable, droppable } = event if (!draggable || !droppable) return - const from = workspaceSortableDirectory(draggable.id?.toString()) - const to = workspaceSortableDirectory(droppable.id?.toString()) - if (!from || !to) return const project = sidebarProject() if (!project) return const ids = workspaceIds(project) - const fromIndex = ids.findIndex((dir) => dir === from) - const toIndex = ids.findIndex((dir) => dir === to) + const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString()) + const toIndex = ids.findIndex((dir) => dir === droppable.id.toString()) if (fromIndex === -1 || toIndex === -1) return if (fromIndex === toIndex) return @@ -2274,13 +2261,13 @@ export default function Layout(props: ParentProps) { }} class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" > - + {(directory) => ( @@ -2337,7 +2324,6 @@ export default function Layout(props: ParentProps) { } const projects = () => layout.projects.list() - const projectIds = createMemo(() => projects().map((project) => project.worktree)) const projectOverlay = () => store.activeProject} /> const sidebarContent = (mobile?: boolean) => ( layout.sidebar.opened()} aimMove={aim.move} projects={projects} - projectIds={projectIds} - renderProject={(worktree) => { - const project = createMemo(() => projects().find((item) => item.worktree === worktree)) - return ( - - {(project) => ( - - )} - - ) - }} + renderProject={(project) => ( + + )} handleDragStart={handleDragStart} handleDragEnd={handleDragEnd} handleDragOver={handleDragOver} diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index a4836b9efd..2ba20092c5 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -34,17 +34,6 @@ export type ProjectSidebarContext = { sessionProps: Omit } -const PROJECT_SORTABLE_PREFIX = "project:" - -export function projectSortableId(worktree: string) { - return `${PROJECT_SORTABLE_PREFIX}${worktree}` -} - -export function projectSortableWorktree(id: string | undefined) { - if (!id?.startsWith(PROJECT_SORTABLE_PREFIX)) return - return id.slice(PROJECT_SORTABLE_PREFIX.length) -} - export const ProjectDragOverlay = (props: { projects: Accessor activeProject: Accessor @@ -286,7 +275,7 @@ export const SortableProject = (props: { }): JSX.Element => { const globalSync = useGlobalSync() const language = useLanguage() - const sortable = createSortable(projectSortableId(props.project.worktree)) + const sortable = createSortable(props.project.worktree) const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree) const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx index d9cd4d5a20..ca36af2a42 100644 --- a/packages/app/src/pages/layout/sidebar-shell.tsx +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -11,15 +11,13 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { type LocalProject } from "@/context/layout" -import { projectSortableId } from "./sidebar-project" export const SidebarContent = (props: { mobile?: boolean opened: Accessor aimMove: (event: MouseEvent) => void projects: Accessor - projectIds: Accessor - renderProject: (worktree: string) => JSX.Element + renderProject: (project: LocalProject) => JSX.Element handleDragStart: (event: unknown) => void handleDragEnd: () => void handleDragOver: (event: DragEvent) => void @@ -65,8 +63,8 @@ export const SidebarContent = (props: {
- - {(worktree) => props.renderProject(worktree)} + p.worktree)}> + {(project) => props.renderProject(project)} void } -const WORKSPACE_SORTABLE_PREFIX = "workspace:" - -export function workspaceSortableId(directory: string) { - return `${WORKSPACE_SORTABLE_PREFIX}${directory}` -} - -export function workspaceSortableDirectory(id: string | undefined) { - if (!id?.startsWith(WORKSPACE_SORTABLE_PREFIX)) return - return id.slice(WORKSPACE_SORTABLE_PREFIX.length) -} - export const WorkspaceDragOverlay = (props: { sidebarProject: Accessor activeWorkspace: Accessor @@ -312,7 +301,7 @@ export const SortableWorkspace = (props: { const params = useParams() const globalSync = useGlobalSync() const language = useLanguage() - const sortable = createSortable(workspaceSortableId(props.directory)) + const sortable = createSortable(props.directory) const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) const [menu, setMenu] = createStore({ open: false, @@ -325,9 +314,7 @@ export const SortableWorkspace = (props: { const workspaceValue = createMemo(() => { const branch = workspaceStore.vcs?.branch const name = branch ?? getFilename(props.directory) - const projectId = props.project.id - if (!projectId) return name - return props.ctx.workspaceName(props.directory, projectId, branch) ?? name + return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name }) const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) const boot = createMemo(() => open() || active()) @@ -357,7 +344,7 @@ export const SortableWorkspace = (props: { InlineEditor={props.ctx.InlineEditor} renameWorkspace={props.ctx.renameWorkspace} setEditor={props.ctx.setEditor} - projectId={props.project.id ?? ""} + projectId={props.project.id} /> ) @@ -460,21 +447,19 @@ export const LocalWorkspace = (props: { }): JSX.Element => { const globalSync = useGlobalSync() const language = useLanguage() - const worktree = createMemo(() => props.project.worktree) const workspace = createMemo(() => { - const [store, setStore] = globalSync.child(worktree()) + const [store, setStore] = globalSync.child(props.project.worktree) return { store, setStore } }) - const slug = createMemo(() => base64Encode(worktree())) + const slug = createMemo(() => base64Encode(props.project.worktree)) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const count = createMemo(() => sessions()?.length ?? 0) - const query = useQuery(() => ({ ...loadSessionsQuery(worktree()) })) - const loading = createMemo(() => query.isPending && count() === 0) + const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) })) const hasMore = createMemo(() => workspace().store.sessionTotal > count()) + const loading = () => query.isLoading && count() === 0 const loadMore = async () => { - const dir = worktree() workspace().setStore("limit", (limit) => (limit ?? 0) + 5) - await globalSync.project.loadSessions(dir) + await globalSync.project.loadSessions(props.project.worktree) } return ( diff --git a/packages/app/src/utils/scoped-cache.test.ts b/packages/app/src/utils/scoped-cache.test.ts index 26821134c8..0c6189dafe 100644 --- a/packages/app/src/utils/scoped-cache.test.ts +++ b/packages/app/src/utils/scoped-cache.test.ts @@ -24,7 +24,7 @@ describe("createScopedCache", () => { expect(disposed).toEqual(["b"]) }) - test("disposes entries on delete and clear", async () => { + test("disposes entries on delete and clear", () => { const disposed: string[] = [] const cache = createScopedCache((key) => ({ key }), { dispose: (value) => disposed.push(value.key), @@ -39,9 +39,6 @@ describe("createScopedCache", () => { cache.clear() expect(cache.peek("b")).toBeUndefined() - // clear() defers dispose to a microtask to avoid nested cleanNode cascades - // when called from inside an onCleanup; flush the queue before asserting. - await Promise.resolve() expect(disposed).toEqual(["a", "b"]) }) diff --git a/packages/app/src/utils/scoped-cache.ts b/packages/app/src/utils/scoped-cache.ts index 7044cdf03c..224c363c1e 100644 --- a/packages/app/src/utils/scoped-cache.ts +++ b/packages/app/src/utils/scoped-cache.ts @@ -89,21 +89,10 @@ export function createScopedCache(createValue: (key: string) => T, options: S } const clear = () => { - // Defer dispose() calls to a microtask. When clear() runs inside an - // onCleanup during a parent remount (e.g. context/file.tsx and - // context/comments.tsx both do this), synchronous dispose on cached - // createRoot entries starts a nested cleanNode cascade while the outer - // cascade is mid-traversal, corrupting solid-js's graph walk state and - // throwing `Cannot read properties of null (reading '1')` at - // chunk-*.js:992. Deferring lets the outer cleanup finish first. - const pending: Array<[string, Entry]> = [] - for (const entry of store) pending.push(entry) - store.clear() - if (pending.length && options.dispose) { - queueMicrotask(() => { - for (const [key, entry] of pending) dispose(key, entry) - }) + for (const [key, entry] of store) { + dispose(key, entry) } + store.clear() } return { diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 52c82dbebf..a13fd34ef7 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -87,10 +87,7 @@ export async function checkServerHealth( signal, }) .global.health() - .then((x) => { - if (x.error) return next(count, x.error) - return { healthy: x.data?.healthy === true, version: x.data?.version } - }) + .then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version })) .catch((error) => next(count, error)) return attempt(0).finally(() => timeout?.clear?.()) } diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts index 760662732e..a352e03fdd 100644 --- a/packages/desktop-electron/electron.vite.config.ts +++ b/packages/desktop-electron/electron.vite.config.ts @@ -82,13 +82,6 @@ export default defineConfig({ plugins: [appPlugin, sentry], publicDir: "../../../app/public", root: "src/renderer", - server: { - host: "127.0.0.1", - strictPort: true, - hmr: { - host: "127.0.0.1", - }, - }, define: { "import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel), }, diff --git a/packages/desktop-electron/src/main/constants.ts b/packages/desktop-electron/src/main/constants.ts index 9a6bb53c64..bf8f692e7f 100644 --- a/packages/desktop-electron/src/main/constants.ts +++ b/packages/desktop-electron/src/main/constants.ts @@ -7,5 +7,4 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod export const SETTINGS_STORE = "opencode.settings" export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" export const WSL_SERVERS_KEY = "wslServers" -export const LEGACY_LOCAL_SERVER_KEY = "localServer" export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev" diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index ad13359892..b12dcb3b17 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -37,13 +37,12 @@ const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" -import { CHANNEL, UPDATER_ENABLED, WSL_SERVERS_KEY } from "./constants" +import { CHANNEL, UPDATER_ENABLED } from "./constants" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" import { allocatePort, getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslSidecar } from "./server" -import { getStore } from "./store" import { createWslServersController } from "./wsl-servers" import { createLoadingWindow, @@ -63,7 +62,6 @@ const loadingComplete = defer() const pendingDeepLinks: string[] = [] const serverReady = defer() -void serverReady.promise.catch(() => undefined) const logger = initLogging() const wslServers = createWslServersController( app.getVersion(), @@ -83,16 +81,6 @@ logger.log("app starting", { version: app.getVersion(), packaged: app.isPackaged, }) -// NOTE: the first getStore() call here is intentional — it is the earliest -// point after `app.setName` / `app.setPath("userData", ...)` have run, so -// electron-store correctly resolves its root to the channel-specific -// userData dir (`...desktop.dev` in dev) rather than the package.json name. -logger.log("config paths", { - userData: app.getPath("userData"), - settingsStore: getStore().path, - wslServersKey: WSL_SERVERS_KEY, - wslServers: getStore().get(WSL_SERVERS_KEY) ?? null, -}) setupApp() @@ -100,14 +88,6 @@ function setupApp() { ensureLoopbackNoProxy() app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") - process.on("uncaughtException", (error) => { - logger.error("main process uncaught exception", error) - }) - - process.on("unhandledRejection", (reason) => { - logger.error("main process unhandled rejection", reason) - }) - if (!app.requestSingleInstanceLock()) { app.quit() return @@ -182,11 +162,6 @@ async function initialize() { const url = `http://${hostname}:${port}` const password = randomUUID() - const startupData: ServerReadyData = { - url, - username: "opencode", - password, - } const loadingTask = (async () => { logger.log("sidecar connection started", { url }) @@ -207,40 +182,26 @@ async function initialize() { initEmitter.emit("sqlite", { type: "Done" }) } - logger.log("spawning windows sidecar", { url }) - let startupError: Error | null = null - const startup = await (async () => { - try { - return await spawnLocalServer(hostname, port, password) - } catch (error) { - startupError = asError(error) - logger.error("windows sidecar startup failed", startupError) - return undefined - } - })() - server = startup?.listener ?? null + logger.log("spawning sidecar", { url }) + const { listener, health } = await spawnLocalServer(hostname, port, password) + server = listener + serverReady.resolve({ + url, + username: "opencode", + password, + }) // Initialize WSL sidecars in parallel; failures do not block app startup. - void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", asError(error))) + void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) - if (startup) { - await Promise.race([ - startup.health.wait, - delay(30_000).then(() => { - throw new Error("Sidecar health check timed out") - }), - ]) - .then(() => { - serverReady.resolve(startupData) - }) - .catch((error) => { - startupError = asError(error) - logger.error("sidecar health check failed", startupError) - serverReady.reject(startupError) - }) - } else { - serverReady.reject(startupError ?? new Error("Local server startup failed")) - } + await Promise.race([ + health.wait, + delay(30_000).then(() => { + throw new Error("Sidecar health check timed out") + }), + ]).catch((error) => { + logger.error("sidecar health check failed", error) + }) logger.log("loading task finished") })() @@ -249,7 +210,6 @@ async function initialize() { const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) if (show) { overlay = createLoadingWindow() - wireWindowDiagnostics(overlay, "loading") await delay(1_000) } } @@ -262,67 +222,11 @@ async function initialize() { } mainWindow = createMainWindow() - wireWindowDiagnostics(mainWindow, "main") wireMenu() overlay?.close() } -function wireWindowDiagnostics(win: BrowserWindow, label: string) { - win.webContents.on("console-message", (_event, level, message, line, sourceId) => { - // Render `message` as a block so multi-line stack traces survive; the - // previous shape stuffed the message into a JSON object which escaped - // `\n` and made stacks unreadable. - const location = sourceId ? ` [${sourceId}:${line}]` : "" - const text = `${label} renderer${location}\n${message}` - if (level >= 3) { - logger.error(text) - return - } - if (level >= 2) { - logger.warn(text) - return - } - logger.log(text) - }) - - win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { - logger.error(`${label} renderer failed load`, { - errorCode, - errorDescription, - validatedURL, - isMainFrame, - }) - }) - - win.webContents.on("render-process-gone", (_event, details) => { - logger.error(`${label} renderer process gone`, details) - }) - - win.webContents.on("preload-error", (_event, path, error) => { - logger.error(`${label} preload error`, { - path, - error: error instanceof Error ? (error.stack ?? error.message) : String(error), - }) - }) - - // DevTools accelerators on Windows/Linux where the menu isn't created. - win.webContents.on("before-input-event", (_event, input) => { - if (input.type !== "keyDown") return - const key = input.key - const toggle = - key === "F12" || - (input.control && input.shift && (key === "I" || key === "i")) || - (input.meta && input.alt && (key === "I" || key === "i")) - if (!toggle) return - win.webContents.toggleDevTools() - }) - - win.on("unresponsive", () => { - logger.error(`${label} window became unresponsive`) - }) -} - function wireMenu() { if (!mainWindow) return createMenu({ @@ -543,10 +447,6 @@ function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } -function asError(error: unknown) { - return error instanceof Error ? error : new Error(String(error)) -} - function defer() { let resolve!: (value: T) => void let reject!: (error: Error) => void diff --git a/packages/desktop-electron/src/main/migrate.ts b/packages/desktop-electron/src/main/migrate.ts index 2c0b25b6ad..70e3dc9c75 100644 --- a/packages/desktop-electron/src/main/migrate.ts +++ b/packages/desktop-electron/src/main/migrate.ts @@ -67,8 +67,7 @@ function migrateFile(datPath: string, filename: string) { } export function migrate() { - const store = getStore() - if (store.get(TAURI_MIGRATED_KEY)) { + if (getStore().get(TAURI_MIGRATED_KEY)) { log.log("tauri migration: already done, skipping") return } diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index fa476ea0ec..394fc80665 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -10,18 +10,11 @@ import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from ". export type HealthCheck = { wait: Promise } export function getDefaultServerUrl(): string | null { - const store = getStore() - const value = store.get(DEFAULT_SERVER_URL_KEY) - if (typeof value !== "string") return null - if (value === "sidecar") { - store.set(DEFAULT_SERVER_URL_KEY, "local:windows") - return "local:windows" - } - return value + const value = getStore().get(DEFAULT_SERVER_URL_KEY) + return typeof value === "string" ? value : null } export function setDefaultServerUrl(url: string | null) { - const store = getStore() if (url) { getStore().set(DEFAULT_SERVER_URL_KEY, url) return diff --git a/packages/desktop-electron/src/main/store.ts b/packages/desktop-electron/src/main/store.ts index b65f20a855..61f0c0a493 100644 --- a/packages/desktop-electron/src/main/store.ts +++ b/packages/desktop-electron/src/main/store.ts @@ -4,12 +4,10 @@ import { SETTINGS_STORE } from "./constants" const cache = new Map() -// IMPORTANT: do NOT construct Store at module import time. electron-store -// resolves `app.getPath("userData")` in its constructor, but our index.ts -// only calls `app.setName` / `app.setPath("userData", ...)` AFTER module -// imports finish. Constructing eagerly wrote settings (e.g. the WSL server -// config) to the default `%APPDATA%\@opencode-ai\desktop-electron` folder -// instead of the proper `...desktop.dev` / channel dir. +// We cannot instantiate the electron-store at module load time because +// module import hoisting causes this to run before app.setPath("userData", ...) +// in index.ts has executed, which would result in files being written to the default directory +// (e.g. bad: %APPDATA%\@opencode-ai\desktop-electron\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings). export function getStore(name = SETTINGS_STORE) { const cached = cache.get(name) if (cached) return cached diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts index 1e23c3940d..337e1ca0bc 100644 --- a/packages/desktop-electron/src/main/windows.ts +++ b/packages/desktop-electron/src/main/windows.ts @@ -177,9 +177,7 @@ export function registerRendererProtocol() { function loadWindow(win: BrowserWindow, html: string) { const devUrl = process.env.ELECTRON_RENDERER_URL if (devUrl) { - const base = new URL(devUrl) - if (base.hostname === "localhost") base.hostname = "127.0.0.1" - const url = new URL(html, base) + const url = new URL(html, devUrl) void win.loadURL(url.toString()) return } diff --git a/packages/desktop-electron/src/main/wsl-servers.ts b/packages/desktop-electron/src/main/wsl-servers.ts index 8614c6647b..34cf62f9d7 100644 --- a/packages/desktop-electron/src/main/wsl-servers.ts +++ b/packages/desktop-electron/src/main/wsl-servers.ts @@ -11,7 +11,7 @@ import type { WslServersEvent, WslServersState, } from "../preload/types" -import { LEGACY_LOCAL_SERVER_KEY, WSL_SERVERS_KEY } from "./constants" +import { WSL_SERVERS_KEY } from "./constants" import { getStore } from "./store" import { installWslDistro, @@ -375,24 +375,7 @@ function readPersistedServers(): WslServerConfig[] { const list = Array.isArray(record.servers) ? record.servers : [] return list.flatMap(normalizePersistedServer) } - const migrated = migrateLegacyLocalServer() - if (migrated.length) store.set(WSL_SERVERS_KEY, { servers: migrated }) - return migrated -} - -function migrateLegacyLocalServer(): WslServerConfig[] { - const legacy = getStore().get(LEGACY_LOCAL_SERVER_KEY) - if (!legacy || typeof legacy !== "object") return [] - const record = legacy as Record - if (record.mode !== "wsl") return [] - const distro = typeof record.distro === "string" ? record.distro : null - if (!distro) return [] - return [ - { - id: wslServerIdForDistro(distro), - distro, - }, - ] + return [] } function normalizePersistedServer(value: unknown): WslServerConfig[] { diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index f631a722be..d190597004 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -1,57 +1,5 @@ // @refresh reload -// V8's default Error.stackTraceLimit truncates at 10 frames; raise it so -// reported errors come with a useful frame budget. -Error.stackTraceLimit = 200 - -// Install global error listeners before any other module runs so that -// uncaught errors and rejected promises reach the main process with their -// full stacks intact. Electron's `console-message` event only forwards the -// rethrow site, so without these we lose the originating frame. -window.addEventListener("error", (event) => { - const err = event.error - const stack = err instanceof Error ? err.stack : null - console.error( - "[renderer uncaught]", - stack ?? event.message, - stack ? "" : `${event.filename}:${event.lineno}:${event.colno}`, - ) -}) - -window.addEventListener("unhandledrejection", (event) => { - const reason = event.reason - // Log as much as possible: stack for Errors, JSON for plain objects with - // a fallback to a tagged shape so we never end up with just - // "[object Object]" in main.log. - if (reason instanceof Error) { - console.error("[renderer unhandled rejection]", reason.stack ?? reason.message ?? String(reason)) - return - } - let serialized: string - try { - serialized = JSON.stringify( - reason, - (_key, value) => { - if (value instanceof Error) { - return { __error: true, name: value.name, message: value.message, stack: value.stack } - } - return value - }, - 2, - ) - } catch { - serialized = String(reason) - } - console.error( - "[renderer unhandled rejection]", - `type=${typeof reason}`, - `ctor=${reason?.constructor?.name ?? "null"}`, - `keys=${reason && typeof reason === "object" ? Object.keys(reason).join(",") : "n/a"}`, - "value:", - serialized, - ) -}) - import { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, @@ -76,7 +24,6 @@ import pkg from "../../package.json" import { initI18n, t } from "./i18n" import { webviewZoom } from "./webview-zoom" import "./styles.css" -import { Button } from "@opencode-ai/ui/button" import { Splash } from "@opencode-ai/ui/logo" import { useTheme } from "@opencode-ai/ui/theme" @@ -121,21 +68,6 @@ const listenForDeepLinks = () => { return window.api.onDeepLink((urls) => emitDeepLinks(urls)) } -function LocalServerStartupError(props: { message: string }) { - return ( -
-
- -

Local Server failed to start

-

{props.message}

- -
-
- ) -} - const createPlatform = (): Platform => { const os = (() => { const ua = navigator.userAgent @@ -351,19 +283,7 @@ render(() => { const [windowCount] = createResource(() => window.api.getWindowCount()) - const [startup] = createResource(async () => { - try { - return { - error: null, - sidecar: await window.api.awaitInitialization(() => undefined), - } - } catch (error) { - return { - error: error instanceof Error ? error.message : String(error), - sidecar: null, - } - } - }) + const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined)) const [defaultServer] = createResource(() => platform.getDefaultServer?.()) const [locale] = createResource(loadLocale) @@ -405,12 +325,12 @@ render(() => { const ready = createMemo( () => !defaultServer.loading && - !startup.loading && + !sidecar.loading && !windowCount.loading && !locale.loading, ) const servers = createMemo(() => { - const data = startup.latest?.sidecar + const data = sidecar() const list: ServerConnection.Any[] = [] if (data) { list.push({ @@ -442,14 +362,10 @@ render(() => { return list }) if (!ready()) return splash - if (startup.latest?.error) { - return - } return ( diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index 523b12e72f..070d0c71f4 100644 --- a/packages/desktop/src-tauri/src/server.rs +++ b/packages/desktop/src-tauri/src/server.rs @@ -24,20 +24,7 @@ pub fn get_default_server_url(app: AppHandle) -> Result, String> let value = store.get(DEFAULT_SERVER_URL_KEY); match value { - Some(v) => match v.as_str() { - Some("sidecar") => { - store.set( - DEFAULT_SERVER_URL_KEY, - serde_json::Value::String("local:windows".to_string()), - ); - store - .save() - .map_err(|e| format!("Failed to save settings: {}", e))?; - Ok(Some("local:windows".to_string())) - } - Some(value) => Ok(Some(value.to_string())), - None => Ok(None), - }, + Some(v) => Ok(v.as_str().map(String::from)), None => Ok(None), } } @@ -67,18 +54,18 @@ pub async fn set_default_server_url(app: AppHandle, url: Option) -> Resu #[tauri::command] #[specta::specta] -pub fn get_wsl_config(app: AppHandle) -> Result { - let store = app - .store(SETTINGS_STORE) - .map_err(|e| format!("Failed to open settings store: {}", e))?; +pub fn get_wsl_config(_app: AppHandle) -> Result { + // let store = app + // .store(SETTINGS_STORE) + // .map_err(|e| format!("Failed to open settings store: {}", e))?; - let enabled = store - .get(WSL_ENABLED_KEY) - .as_ref() - .and_then(|v| v.as_bool()) - .unwrap_or(false); + // let enabled = store + // .get(WSL_ENABLED_KEY) + // .as_ref() + // .and_then(|v| v.as_bool()) + // .unwrap_or(false); - Ok(WslConfig { enabled }) + Ok(WslConfig { enabled: false }) } #[tauri::command] diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index c90a890e2b..1a0da014dd 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -71,21 +71,16 @@ const createPlatform = (): Platform => { })() const wslHome = async () => { - if (os !== "windows") return undefined - const wsl = await commands.getWslConfig().catch(() => null) - if (!wsl?.enabled) return undefined + if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined return commands.wslPath("~", "windows").catch(() => undefined) } - const handleWslPicker = async (result: T): Promise => { - if (!result) return result - const wsl = await commands.getWslConfig().catch(() => null) - if (!wsl?.enabled) return result - const convert = (path: string) => commands.wslPath(path, "linux").catch(() => path) + const handleWslPicker = async (result: T | null): Promise => { + if (!result || !window.__OPENCODE__?.wsl) return result if (Array.isArray(result)) { - return (await Promise.all(result.map(convert))) as T + return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any } - return (await convert(result)) as T + return commands.wslPath(result, "linux").catch(() => result) as any } return { @@ -354,6 +349,16 @@ const createPlatform = (): Platform => { } }, + getWslEnabled: async () => { + const next = await commands.getWslConfig().catch(() => null) + if (next) return next.enabled + return window.__OPENCODE__!.wsl ?? false + }, + + setWslEnabled: async (enabled) => { + await commands.setWslConfig({ enabled }) + }, + getDefaultServer: async () => { const url = await commands.getDefaultServerUrl().catch(() => null) if (!url) return null @@ -433,7 +438,11 @@ render(() => { // Fetch sidecar credentials from Rust (available immediately, before health check) const [sidecar] = createResource(() => commands.awaitInitialization(new Channel() as any)) - const [defaultServer] = createResource(() => platform.getDefaultServer?.()) + const [defaultServer] = createResource(() => + platform.getDefaultServer?.().then((url) => { + if (url) return ServerConnection.key({ type: "http", http: { url } }) + }), + ) const [locale] = createResource(loadLocale) // Build the sidecar server connection once credentials arrive @@ -482,7 +491,7 @@ render(() => { {(_) => { return ( diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 35812f953d..2f2edb4ff5 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -61,6 +61,7 @@ const createEmbeddedWebUIBundle = async () => { await $`bun run --cwd ${appDir} build` const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist }))) .map((file) => file.replaceAll("\\", "/")) + .filter((file) => !file.endsWith(".map")) .sort() const imports = files.map((file, i) => { const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/") diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 4011269495..2026d82324 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -1,6 +1,5 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" -import { AppRuntime } from "@/effect/app-runtime" import { UI } from "../ui" import { Global } from "@opencode-ai/core/global" import { Agent } from "../../agent/agent" @@ -9,8 +8,7 @@ import path from "path" import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import matter from "gray-matter" -import { Instance } from "../../project/instance" -import { WithInstance } from "../../project/with-instance" +import { InstanceRef } from "@/effect/instance-ref" import { EOL } from "os" import type { Argv } from "yargs" import { Effect } from "effect" @@ -35,7 +33,7 @@ const AVAILABLE_PERMISSIONS = [ "skill", ] -const AgentCreateCommand = cmd({ +const AgentCreateCommand = effectCmd({ command: "create", describe: "create a new agent", builder: (yargs: Argv) => @@ -63,176 +61,172 @@ const AgentCreateCommand = cmd({ alias: ["m"], describe: "model to use in the format of provider/model", }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - const cliPath = args.path - const cliDescription = args.description - const cliMode = args.mode as AgentMode | undefined - const perms = args.permissions + handler: Effect.fn("Cli.agent.create")(function* (args) { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + const agentSvc = yield* Agent.Service + yield* Effect.promise(async () => { + const cliPath = args.path + const cliDescription = args.description + const cliMode = args.mode as AgentMode | undefined + const perms = args.permissions - const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined + const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined - if (!isFullyNonInteractive) { - UI.empty() - prompts.intro("Create agent") - } + if (!isFullyNonInteractive) { + UI.empty() + prompts.intro("Create agent") + } - const project = Instance.project + const project = ctx.project - // Determine scope/path - let targetPath: string - if (cliPath) { - targetPath = path.join(cliPath, "agent") - } else { - let scope: "global" | "project" = "global" - if (project.vcs === "git") { - const scopeResult = await prompts.select({ - message: "Location", - options: [ - { - label: "Current project", - value: "project" as const, - hint: Instance.worktree, - }, - { - label: "Global", - value: "global" as const, - hint: Global.Path.config, - }, - ], - }) - if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() - scope = scopeResult - } - targetPath = path.join( - scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"), - "agent", - ) - } - - // Get description - let description: string - if (cliDescription) { - description = cliDescription - } else { - const query = await prompts.text({ - message: "Description", - placeholder: "What should this agent do?", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(query)) throw new UI.CancelledError() - description = query - } - - // Generate agent - const spinner = prompts.spinner() - spinner.start("Generating agent configuration...") - const model = args.model ? Provider.parseModel(args.model) : undefined - const generated = await AppRuntime.runPromise( - Agent.Service.use((svc) => svc.generate({ description, model })), - ).catch((error) => { - spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) - if (isFullyNonInteractive) process.exit(1) - throw new UI.CancelledError() - }) - spinner.stop(`Agent ${generated.identifier} generated`) - - // Select permissions to allow - let selected: string[] - if (perms !== undefined) { - selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS - } else { - const result = await prompts.multiselect({ - message: "Select permissions to allow (Space to toggle)", - options: AVAILABLE_PERMISSIONS.map((permission) => ({ - label: permission, - value: permission, - })), - initialValues: AVAILABLE_PERMISSIONS, - }) - if (prompts.isCancel(result)) throw new UI.CancelledError() - selected = result - } - - // Get mode - let mode: AgentMode - if (cliMode) { - mode = cliMode - } else { - const modeResult = await prompts.select({ - message: "Agent mode", + // Determine scope/path + let targetPath: string + if (cliPath) { + targetPath = path.join(cliPath, "agent") + } else { + let scope: "global" | "project" = "global" + if (project.vcs === "git") { + const scopeResult = await prompts.select({ + message: "Location", options: [ { - label: "All", - value: "all" as const, - hint: "Can function in both primary and subagent roles", + label: "Current project", + value: "project" as const, + hint: ctx.worktree, }, { - label: "Primary", - value: "primary" as const, - hint: "Acts as a primary/main agent", - }, - { - label: "Subagent", - value: "subagent" as const, - hint: "Can be used as a subagent by other agents", + label: "Global", + value: "global" as const, + hint: Global.Path.config, }, ], - initialValue: "all" as const, }) - if (prompts.isCancel(modeResult)) throw new UI.CancelledError() - mode = modeResult + if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() + scope = scopeResult } + targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent") + } - // Build permissions config — deny anything not explicitly selected. - const permissions: Record = {} - for (const permission of AVAILABLE_PERMISSIONS) { - if (!selected.includes(permission)) { - permissions[permission] = "deny" - } + // Get description + let description: string + if (cliDescription) { + description = cliDescription + } else { + const query = await prompts.text({ + message: "Description", + placeholder: "What should this agent do?", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(query)) throw new UI.CancelledError() + description = query + } + + // Generate agent + const spinner = prompts.spinner() + spinner.start("Generating agent configuration...") + const model = args.model ? Provider.parseModel(args.model) : undefined + const generated = await Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => { + spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) + if (isFullyNonInteractive) process.exit(1) + throw new UI.CancelledError() + }) + spinner.stop(`Agent ${generated.identifier} generated`) + + // Select permissions to allow + let selected: string[] + if (perms !== undefined) { + selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS + } else { + const result = await prompts.multiselect({ + message: "Select permissions to allow (Space to toggle)", + options: AVAILABLE_PERMISSIONS.map((permission) => ({ + label: permission, + value: permission, + })), + initialValues: AVAILABLE_PERMISSIONS, + }) + if (prompts.isCancel(result)) throw new UI.CancelledError() + selected = result + } + + // Get mode + let mode: AgentMode + if (cliMode) { + mode = cliMode + } else { + const modeResult = await prompts.select({ + message: "Agent mode", + options: [ + { + label: "All", + value: "all" as const, + hint: "Can function in both primary and subagent roles", + }, + { + label: "Primary", + value: "primary" as const, + hint: "Acts as a primary/main agent", + }, + { + label: "Subagent", + value: "subagent" as const, + hint: "Can be used as a subagent by other agents", + }, + ], + initialValue: "all" as const, + }) + if (prompts.isCancel(modeResult)) throw new UI.CancelledError() + mode = modeResult + } + + // Build permissions config — deny anything not explicitly selected. + const permissions: Record = {} + for (const permission of AVAILABLE_PERMISSIONS) { + if (!selected.includes(permission)) { + permissions[permission] = "deny" } + } - // Build frontmatter - const frontmatter: { - description: string - mode: AgentMode - permission?: Record - } = { - description: generated.whenToUse, - mode, - } - if (Object.keys(permissions).length > 0) { - frontmatter.permission = permissions - } + // Build frontmatter + const frontmatter: { + description: string + mode: AgentMode + permission?: Record + } = { + description: generated.whenToUse, + mode, + } + if (Object.keys(permissions).length > 0) { + frontmatter.permission = permissions + } - // Write file - const content = matter.stringify(generated.systemPrompt, frontmatter) - const filePath = path.join(targetPath, `${generated.identifier}.md`) + // Write file + const content = matter.stringify(generated.systemPrompt, frontmatter) + const filePath = path.join(targetPath, `${generated.identifier}.md`) - await fs.mkdir(targetPath, { recursive: true }) - - if (await Filesystem.exists(filePath)) { - if (isFullyNonInteractive) { - console.error(`Error: Agent file already exists: ${filePath}`) - process.exit(1) - } - prompts.log.error(`Agent file already exists: ${filePath}`) - throw new UI.CancelledError() - } - - await Filesystem.write(filePath, content) + await fs.mkdir(targetPath, { recursive: true }) + if (await Filesystem.exists(filePath)) { if (isFullyNonInteractive) { - console.log(filePath) - } else { - prompts.log.success(`Agent created: ${filePath}`) - prompts.outro("Done") + console.error(`Error: Agent file already exists: ${filePath}`) + process.exit(1) } - }, + prompts.log.error(`Agent file already exists: ${filePath}`) + throw new UI.CancelledError() + } + + await Filesystem.write(filePath, content) + + if (isFullyNonInteractive) { + console.log(filePath) + } else { + prompts.log.success(`Agent created: ${filePath}`) + prompts.outro("Done") + } }) - }, + }), }) const AgentListCommand = effectCmd({ diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index e707526dfe..a4a209ea39 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -18,10 +18,9 @@ import type { } from "@octokit/webhooks-types" import { UI } from "../ui" import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { ModelsDev } from "@/provider/models" -import { Instance } from "@/project/instance" -import { WithInstance } from "@/project/with-instance" -import { bootstrap } from "../bootstrap" +import { InstanceRef } from "@/effect/instance-ref" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" import type { SessionID } from "../../session/schema" @@ -200,191 +199,192 @@ export const GithubCommand = cmd({ async handler() {}, }) -export const GithubInstallCommand = cmd({ +export const GithubInstallCommand = effectCmd({ command: "install", describe: "install the GitHub agent", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - { - UI.empty() - prompts.intro("Install GitHub agent") - const app = await getAppInfo() - await installGitHubApp() + handler: Effect.fn("Cli.github.install")(function* () { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + yield* Effect.promise(async () => { + { + UI.empty() + prompts.intro("Install GitHub agent") + const app = await getAppInfo() + await installGitHubApp() - const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => { - // TODO: add guide for copilot, for now just hide it - delete p["github-copilot"] - return p + const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => { + // TODO: add guide for copilot, for now just hide it + delete p["github-copilot"] + return p + }) + + const provider = await promptProvider() + const model = await promptModel() + //const key = await promptKey() + + await addWorkflowFiles() + printNextSteps() + + function printNextSteps() { + let step2 + if (provider === "amazon-bedrock") { + step2 = + "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services" + } else { + step2 = [ + ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, + "", + ...providers[provider].env.map((e) => ` - ${e}`), + ].join("\n") + } + + prompts.outro( + [ + "Next steps:", + "", + ` 1. Commit the \`${WORKFLOW_FILE}\` file and push`, + step2, + "", + " 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action", + "", + " Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples", + ].join("\n"), + ) + } + + async function getAppInfo() { + const project = ctx.project + if (project.vcs !== "git") { + prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) + throw new UI.CancelledError() + } + + // Get repo info + const info = await AppRuntime.runPromise( + Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })), + ).then((x) => x.text().trim()) + const parsed = parseGitHubRemote(info) + if (!parsed) { + prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) + throw new UI.CancelledError() + } + return { owner: parsed.owner, repo: parsed.repo, root: ctx.worktree } + } + + async function promptProvider() { + const priority: Record = { + opencode: 0, + anthropic: 1, + openai: 2, + google: 3, + } + let provider = await prompts.select({ + message: "Select provider", + maxItems: 8, + options: pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, + ), + map((x) => ({ + label: x.name, + value: x.id, + hint: priority[x.id] === 0 ? "recommended" : undefined, + })), + ), }) - const provider = await promptProvider() - const model = await promptModel() - //const key = await promptKey() + if (prompts.isCancel(provider)) throw new UI.CancelledError() - await addWorkflowFiles() - printNextSteps() + return provider + } - function printNextSteps() { - let step2 - if (provider === "amazon-bedrock") { - step2 = - "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services" - } else { - step2 = [ - ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, - "", - ...providers[provider].env.map((e) => ` - ${e}`), - ].join("\n") + async function promptModel() { + const providerData = providers[provider]! + + const model = await prompts.select({ + message: "Select model", + maxItems: 8, + options: pipe( + providerData.models, + values(), + sortBy((x) => x.name ?? x.id), + map((x) => ({ + label: x.name ?? x.id, + value: x.id, + })), + ), + }) + + if (prompts.isCancel(model)) throw new UI.CancelledError() + return model + } + + async function installGitHubApp() { + const s = prompts.spinner() + s.start("Installing GitHub app") + + // Get installation + const installation = await getInstallation() + if (installation) return s.stop("GitHub app already installed") + + // Open browser + const url = "https://github.com/apps/opencode-agent" + const command = + process.platform === "darwin" + ? `open "${url}"` + : process.platform === "win32" + ? `start "" "${url}"` + : `xdg-open "${url}"` + + exec(command, (error) => { + if (error) { + prompts.log.warn(`Could not open browser. Please visit: ${url}`) } + }) - prompts.outro( - [ - "Next steps:", - "", - ` 1. Commit the \`${WORKFLOW_FILE}\` file and push`, - step2, - "", - " 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action", - "", - " Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples", - ].join("\n"), - ) - } - - async function getAppInfo() { - const project = Instance.project - if (project.vcs !== "git") { - prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) - throw new UI.CancelledError() - } - - // Get repo info - const info = await AppRuntime.runPromise( - Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })), - ).then((x) => x.text().trim()) - const parsed = parseGitHubRemote(info) - if (!parsed) { - prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) - throw new UI.CancelledError() - } - return { owner: parsed.owner, repo: parsed.repo, root: Instance.worktree } - } - - async function promptProvider() { - const priority: Record = { - opencode: 0, - anthropic: 1, - openai: 2, - google: 3, - } - let provider = await prompts.select({ - message: "Select provider", - maxItems: 8, - options: pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, - ), - map((x) => ({ - label: x.name, - value: x.id, - hint: priority[x.id] === 0 ? "recommended" : undefined, - })), - ), - }) - - if (prompts.isCancel(provider)) throw new UI.CancelledError() - - return provider - } - - async function promptModel() { - const providerData = providers[provider]! - - const model = await prompts.select({ - message: "Select model", - maxItems: 8, - options: pipe( - providerData.models, - values(), - sortBy((x) => x.name ?? x.id), - map((x) => ({ - label: x.name ?? x.id, - value: x.id, - })), - ), - }) - - if (prompts.isCancel(model)) throw new UI.CancelledError() - return model - } - - async function installGitHubApp() { - const s = prompts.spinner() - s.start("Installing GitHub app") - - // Get installation + // Wait for installation + s.message("Waiting for GitHub app to be installed") + const MAX_RETRIES = 120 + let retries = 0 + do { const installation = await getInstallation() - if (installation) return s.stop("GitHub app already installed") + if (installation) break - // Open browser - const url = "https://github.com/apps/opencode-agent" - const command = - process.platform === "darwin" - ? `open "${url}"` - : process.platform === "win32" - ? `start "" "${url}"` - : `xdg-open "${url}"` - - exec(command, (error) => { - if (error) { - prompts.log.warn(`Could not open browser. Please visit: ${url}`) - } - }) - - // Wait for installation - s.message("Waiting for GitHub app to be installed") - const MAX_RETRIES = 120 - let retries = 0 - do { - const installation = await getInstallation() - if (installation) break - - if (retries > MAX_RETRIES) { - s.stop( - `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`, - ) - throw new UI.CancelledError() - } - - retries++ - await sleep(1000) - } while (true) // oxlint-disable-line no-constant-condition - - s.stop("Installed GitHub app") - - async function getInstallation() { - return await fetch( - `https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`, + if (retries > MAX_RETRIES) { + s.stop( + `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`, ) - .then((res) => res.json()) - .then((data) => data.installation) + throw new UI.CancelledError() } + + retries++ + await sleep(1000) + } while (true) // oxlint-disable-line no-constant-condition + + s.stop("Installed GitHub app") + + async function getInstallation() { + return await fetch( + `https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`, + ) + .then((res) => res.json()) + .then((data) => data.installation) } + } - async function addWorkflowFiles() { - const envStr = - provider === "amazon-bedrock" - ? "" - : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` + async function addWorkflowFiles() { + const envStr = + provider === "amazon-bedrock" + ? "" + : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` - await Filesystem.write( - path.join(app.root, WORKFLOW_FILE), - `name: opencode + await Filesystem.write( + path.join(app.root, WORKFLOW_FILE), + `name: opencode on: issue_comment: @@ -415,17 +415,16 @@ jobs: uses: anomalyco/opencode/github@latest${envStr} with: model: ${provider}/${model}`, - ) + ) - prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) - } + prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) } - }, + } }) - }, + }), }) -export const GithubRunCommand = cmd({ +export const GithubRunCommand = effectCmd({ command: "run", describe: "run the GitHub agent", builder: (yargs) => @@ -438,8 +437,10 @@ export const GithubRunCommand = cmd({ type: "string", describe: "GitHub personal access token (github_pat_********)", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { + handler: Effect.fn("Cli.github.run")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + yield* Effect.promise(async () => { const isMock = args.token || args.event const context = isMock ? (JSON.parse(args.event!) as Context) : github.context @@ -502,21 +503,21 @@ export const GithubRunCommand = cmd({ : "issue" : undefined const gitText = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result.text().trim() } const gitRun = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result } const gitStatus = (args: string[]) => - AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) const commitChanges = async (summary: string, actor?: string) => { const args = ["commit", "-m", summary] if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) @@ -1646,5 +1647,5 @@ query($owner: String!, $repo: String!, $number: Int!) { }) } }) - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index e4d7bd9224..2ae7cece6a 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -1,4 +1,6 @@ import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" +import { Cause } from "effect" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" @@ -9,8 +11,7 @@ import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" import { ConfigMCP } from "../../config/mcp" -import { Instance } from "../../project/instance" -import { WithInstance } from "../../project/with-instance" +import { InstanceRef } from "@/effect/instance-ref" import { Installation } from "../../installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "path" @@ -18,7 +19,6 @@ import { Global } from "@opencode-ai/core/global" import { modify, applyEdits } from "jsonc-parser" import { Filesystem } from "@/util/filesystem" import { Bus } from "../../bus" -import { AppRuntime } from "../../effect/app-runtime" import { Effect } from "effect" function getAuthStatusIcon(status: MCP.AuthStatus): string { @@ -65,35 +65,31 @@ function oauthServers(config: Config.Info) { ) } -async function listState() { - return AppRuntime.runPromise( - Effect.gen(function* () { - const cfg = yield* Config.Service - const mcp = yield* MCP.Service - const config = yield* cfg.get() - const statuses = yield* mcp.status() - const stored = yield* Effect.all( - Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])), - { concurrency: "unbounded" }, - ) - return { config, statuses, stored } - }), - ) +function listState() { + return Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const statuses = yield* mcp.status() + const stored = yield* Effect.all( + Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])), + { concurrency: "unbounded" }, + ) + return { config, statuses, stored } + }) } -async function authState() { - return AppRuntime.runPromise( - Effect.gen(function* () { - const cfg = yield* Config.Service - const mcp = yield* MCP.Service - const config = yield* cfg.get() - const auth = yield* Effect.all( - Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])), - { concurrency: "unbounded" }, - ) - return { config, auth } - }), - ) +function authState() { + return Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const auth = yield* Effect.all( + Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])), + { concurrency: "unbounded" }, + ) + return { config, auth } + }) } export const McpCommand = cmd({ @@ -110,73 +106,68 @@ export const McpCommand = cmd({ async handler() {}, }) -export const McpListCommand = cmd({ +export const McpListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list MCP servers and their status", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP Servers") + handler: Effect.fn("Cli.mcp.list")(function* () { + UI.empty() + prompts.intro("MCP Servers") - const { config, statuses, stored } = await listState() - const servers = configuredServers(config) + const { config, statuses, stored } = yield* listState() + const servers = configuredServers(config) - if (servers.length === 0) { - prompts.log.warn("No MCP servers configured") - prompts.outro("Add servers with: opencode mcp add") - return + if (servers.length === 0) { + prompts.log.warn("No MCP servers configured") + prompts.outro("Add servers with: opencode mcp add") + return + } + + for (const [name, serverConfig] of servers) { + const status = statuses[name] + const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth + const hasStoredTokens = stored[name] + + let statusIcon: string + let statusText: string + let hint = "" + + if (!status) { + statusIcon = "○" + statusText = "not initialized" + } else if (status.status === "connected") { + statusIcon = "✓" + statusText = "connected" + if (hasOAuth && hasStoredTokens) { + hint = " (OAuth)" } + } else if (status.status === "disabled") { + statusIcon = "○" + statusText = "disabled" + } else if (status.status === "needs_auth") { + statusIcon = "⚠" + statusText = "needs authentication" + } else if (status.status === "needs_client_registration") { + statusIcon = "✗" + statusText = "needs client registration" + hint = "\n " + status.error + } else { + statusIcon = "✗" + statusText = "failed" + hint = "\n " + status.error + } - for (const [name, serverConfig] of servers) { - const status = statuses[name] - const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth - const hasStoredTokens = stored[name] + const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") + prompts.log.info( + `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, + ) + } - let statusIcon: string - let statusText: string - let hint = "" - - if (!status) { - statusIcon = "○" - statusText = "not initialized" - } else if (status.status === "connected") { - statusIcon = "✓" - statusText = "connected" - if (hasOAuth && hasStoredTokens) { - hint = " (OAuth)" - } - } else if (status.status === "disabled") { - statusIcon = "○" - statusText = "disabled" - } else if (status.status === "needs_auth") { - statusIcon = "⚠" - statusText = "needs authentication" - } else if (status.status === "needs_client_registration") { - statusIcon = "✗" - statusText = "needs client registration" - hint = "\n " + status.error - } else { - statusIcon = "✗" - statusText = "failed" - hint = "\n " + status.error - } - - const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") - prompts.log.info( - `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, - ) - } - - prompts.outro(`${servers.length} server(s)`) - }, - }) - }, + prompts.outro(`${servers.length} server(s)`) + }), }) -export const McpAuthCommand = cmd({ +export const McpAuthCommand = effectCmd({ command: "auth [name]", describe: "authenticate with an OAuth-enabled MCP server", builder: (yargs) => @@ -186,98 +177,98 @@ export const McpAuthCommand = cmd({ type: "string", }) .command(McpAuthListCommand), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Authentication") + handler: Effect.fn("Cli.mcp.auth")(function* (args) { + UI.empty() + prompts.intro("MCP OAuth Authentication") - const { config, auth } = await authState() - const mcpServers = config.mcp ?? {} - const servers = oauthServers(config) + const { config, auth } = yield* authState() + const mcpServers = config.mcp ?? {} + const servers = oauthServers(config) - if (servers.length === 0) { - prompts.log.warn("No OAuth-capable MCP servers configured") - prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") - prompts.log.info(` + if (servers.length === 0) { + prompts.log.warn("No OAuth-capable MCP servers configured") + prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") + prompts.log.info(` "mcp": { "my-server": { "type": "remote", "url": "https://example.com/mcp" } }`) - prompts.outro("Done") - return + prompts.outro("Done") + return + } + + let serverName = args.name + if (!serverName) { + // Build options with auth status + const options = servers.map(([name, cfg]) => { + const authStatus = auth[name] + const icon = getAuthStatusIcon(authStatus) + const statusText = getAuthStatusText(authStatus) + const url = cfg.url + return { + label: `${icon} ${name} (${statusText})`, + value: name, + hint: url, } + }) - let serverName = args.name - if (!serverName) { - // Build options with auth status - const options = servers.map(([name, cfg]) => { - const authStatus = auth[name] - const icon = getAuthStatusIcon(authStatus) - const statusText = getAuthStatusText(authStatus) - const url = cfg.url - return { - label: `${icon} ${name} (${statusText})`, - value: name, - hint: url, - } - }) + const selected = yield* Effect.promise(() => + prompts.select({ + message: "Select MCP server to authenticate", + options, + }), + ) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + serverName = selected + } - const selected = await prompts.select({ - message: "Select MCP server to authenticate", - options, - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - serverName = selected - } + const serverConfig = mcpServers[serverName] + if (!serverConfig) { + prompts.log.error(`MCP server not found: ${serverName}`) + prompts.outro("Done") + return + } - const serverConfig = mcpServers[serverName] - if (!serverConfig) { - prompts.log.error(`MCP server not found: ${serverName}`) - prompts.outro("Done") - return - } + if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) { + prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`) + prompts.outro("Done") + return + } - if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) { - prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`) - prompts.outro("Done") - return - } + // Check if already authenticated + const authStatus = auth[serverName] ?? (yield* MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))) + if (authStatus === "authenticated") { + const confirm = yield* Effect.promise(() => + prompts.confirm({ + message: `${serverName} already has valid credentials. Re-authenticate?`, + }), + ) + if (prompts.isCancel(confirm) || !confirm) { + prompts.outro("Cancelled") + return + } + } else if (authStatus === "expired") { + prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`) + } - // Check if already authenticated - const authStatus = - auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName)))) - if (authStatus === "authenticated") { - const confirm = await prompts.confirm({ - message: `${serverName} already has valid credentials. Re-authenticate?`, - }) - if (prompts.isCancel(confirm) || !confirm) { - prompts.outro("Cancelled") - return - } - } else if (authStatus === "expired") { - prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`) - } + const spinner = prompts.spinner() + spinner.start("Starting OAuth flow...") - const spinner = prompts.spinner() - spinner.start("Starting OAuth flow...") - - // Subscribe to browser open failure events to show URL for manual opening - const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { - if (evt.properties.mcpName === serverName) { - spinner.stop("Could not open browser automatically") - prompts.log.warn("Please open this URL in your browser to authenticate:") - prompts.log.info(evt.properties.url) - spinner.start("Waiting for authorization...") - } - }) - - try { - const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName))) + // Subscribe to browser open failure events to show URL for manual opening + const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { + if (evt.properties.mcpName === serverName) { + spinner.stop("Could not open browser automatically") + prompts.log.warn("Please open this URL in your browser to authenticate:") + prompts.log.info(evt.properties.url) + spinner.start("Waiting for authorization...") + } + }) + yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe( + Effect.tap((status) => + Effect.sync(() => { if (status.status === "connected") { spinner.stop("Authentication successful!") } else if (status.status === "needs_client_registration") { @@ -301,55 +292,53 @@ export const McpAuthCommand = cmd({ } else { spinner.stop("Unexpected status: " + status.status, 1) } - } catch (error) { + }), + ), + Effect.catchCause((cause) => + Effect.sync(() => { spinner.stop("Authentication failed", 1) + const error = Cause.squash(cause) prompts.log.error(error instanceof Error ? error.message : String(error)) - } finally { - unsubscribe() - } + }), + ), + Effect.ensuring(Effect.sync(() => unsubscribe())), + ) - prompts.outro("Done") - }, - }) - }, + prompts.outro("Done") + }), }) -export const McpAuthListCommand = cmd({ +export const McpAuthListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list OAuth-capable MCP servers and their auth status", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Status") + handler: Effect.fn("Cli.mcp.auth.list")(function* () { + UI.empty() + prompts.intro("MCP OAuth Status") - const { config, auth } = await authState() - const servers = oauthServers(config) + const { config, auth } = yield* authState() + const servers = oauthServers(config) - if (servers.length === 0) { - prompts.log.warn("No OAuth-capable MCP servers configured") - prompts.outro("Done") - return - } + if (servers.length === 0) { + prompts.log.warn("No OAuth-capable MCP servers configured") + prompts.outro("Done") + return + } - for (const [name, serverConfig] of servers) { - const authStatus = auth[name] - const icon = getAuthStatusIcon(authStatus) - const statusText = getAuthStatusText(authStatus) - const url = serverConfig.url + for (const [name, serverConfig] of servers) { + const authStatus = auth[name] + const icon = getAuthStatusIcon(authStatus) + const statusText = getAuthStatusText(authStatus) + const url = serverConfig.url - prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) - } + prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) + } - prompts.outro(`${servers.length} OAuth-capable server(s)`) - }, - }) - }, + prompts.outro(`${servers.length} OAuth-capable server(s)`) + }), }) -export const McpLogoutCommand = cmd({ +export const McpLogoutCommand = effectCmd({ command: "logout [name]", describe: "remove OAuth credentials for an MCP server", builder: (yargs) => @@ -357,57 +346,54 @@ export const McpLogoutCommand = cmd({ describe: "name of the MCP server", type: "string", }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Logout") + handler: Effect.fn("Cli.mcp.logout")(function* (args) { + UI.empty() + prompts.intro("MCP OAuth Logout") - const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all())) - const serverNames = Object.keys(credentials) + const credentials = yield* McpAuth.Service.use((auth) => auth.all()) + const serverNames = Object.keys(credentials) - if (serverNames.length === 0) { - prompts.log.warn("No MCP OAuth credentials stored") - prompts.outro("Done") - return - } + if (serverNames.length === 0) { + prompts.log.warn("No MCP OAuth credentials stored") + prompts.outro("Done") + return + } - let serverName = args.name - if (!serverName) { - const selected = await prompts.select({ - message: "Select MCP server to logout", - options: serverNames.map((name) => { - const entry = credentials[name] - const hasTokens = !!entry.tokens - const hasClient = !!entry.clientInfo - let hint = "" - if (hasTokens && hasClient) hint = "tokens + client" - else if (hasTokens) hint = "tokens" - else if (hasClient) hint = "client registration" - return { - label: name, - value: name, - hint, - } - }), - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - serverName = selected - } + let serverName = args.name + if (!serverName) { + const selected = yield* Effect.promise(() => + prompts.select({ + message: "Select MCP server to logout", + options: serverNames.map((name) => { + const entry = credentials[name] + const hasTokens = !!entry.tokens + const hasClient = !!entry.clientInfo + let hint = "" + if (hasTokens && hasClient) hint = "tokens + client" + else if (hasTokens) hint = "tokens" + else if (hasClient) hint = "client registration" + return { + label: name, + value: name, + hint, + } + }), + }), + ) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + serverName = selected + } - if (!credentials[serverName]) { - prompts.log.error(`No credentials found for: ${serverName}`) - prompts.outro("Done") - return - } + if (!credentials[serverName]) { + prompts.log.error(`No credentials found for: ${serverName}`) + prompts.outro("Done") + return + } - await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName))) - prompts.log.success(`Removed OAuth credentials for ${serverName}`) - prompts.outro("Done") - }, - }) - }, + yield* MCP.Service.use((mcp) => mcp.removeAuth(serverName)) + prompts.log.success(`Removed OAuth credentials for ${serverName}`) + prompts.outro("Done") + }), }) async function resolveConfigPath(baseDir: string, global = false) { @@ -445,171 +431,171 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPat return configPath } -export const McpAddCommand = cmd({ +export const McpAddCommand = effectCmd({ command: "add", describe: "add an MCP server", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("Add MCP server") + handler: Effect.fn("Cli.mcp.add")(function* () { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + yield* Effect.promise(async () => { + UI.empty() + prompts.intro("Add MCP server") - const project = Instance.project + const project = ctx.project - // Resolve config paths eagerly for hints - const [projectConfigPath, globalConfigPath] = await Promise.all([ - resolveConfigPath(Instance.worktree), - resolveConfigPath(Global.Path.config, true), - ]) + // Resolve config paths eagerly for hints + const [projectConfigPath, globalConfigPath] = await Promise.all([ + resolveConfigPath(ctx.worktree), + resolveConfigPath(Global.Path.config, true), + ]) - // Determine scope - let configPath = globalConfigPath - if (project.vcs === "git") { - const scopeResult = await prompts.select({ - message: "Location", - options: [ - { - label: "Current project", - value: projectConfigPath, - hint: projectConfigPath, - }, - { - label: "Global", - value: globalConfigPath, - hint: globalConfigPath, - }, - ], - }) - if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() - configPath = scopeResult - } - - const name = await prompts.text({ - message: "Enter MCP server name", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(name)) throw new UI.CancelledError() - - const type = await prompts.select({ - message: "Select MCP server type", + // Determine scope + let configPath = globalConfigPath + if (project.vcs === "git") { + const scopeResult = await prompts.select({ + message: "Location", options: [ { - label: "Local", - value: "local", - hint: "Run a local command", + label: "Current project", + value: projectConfigPath, + hint: projectConfigPath, }, { - label: "Remote", - value: "remote", - hint: "Connect to a remote URL", + label: "Global", + value: globalConfigPath, + hint: globalConfigPath, }, ], }) - if (prompts.isCancel(type)) throw new UI.CancelledError() + if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() + configPath = scopeResult + } - if (type === "local") { - const command = await prompts.text({ - message: "Enter command to run", - placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(command)) throw new UI.CancelledError() + const name = await prompts.text({ + message: "Enter MCP server name", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(name)) throw new UI.CancelledError() - const mcpConfig: ConfigMCP.Info = { - type: "local", - command: command.split(" "), - } + const type = await prompts.select({ + message: "Select MCP server type", + options: [ + { + label: "Local", + value: "local", + hint: "Run a local command", + }, + { + label: "Remote", + value: "remote", + hint: "Connect to a remote URL", + }, + ], + }) + if (prompts.isCancel(type)) throw new UI.CancelledError() - await addMcpToConfig(name, mcpConfig, configPath) - prompts.log.success(`MCP server "${name}" added to ${configPath}`) - prompts.outro("MCP server added successfully") - return + if (type === "local") { + const command = await prompts.text({ + message: "Enter command to run", + placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(command)) throw new UI.CancelledError() + + const mcpConfig: ConfigMCP.Info = { + type: "local", + command: command.split(" "), } - if (type === "remote") { - const url = await prompts.text({ - message: "Enter MCP server URL", - placeholder: "e.g., https://example.com/mcp", - validate: (x) => { - if (!x) return "Required" - if (x.length === 0) return "Required" - const isValid = URL.canParse(x) - return isValid ? undefined : "Invalid URL" - }, - }) - if (prompts.isCancel(url)) throw new UI.CancelledError() + await addMcpToConfig(name, mcpConfig, configPath) + prompts.log.success(`MCP server "${name}" added to ${configPath}`) + prompts.outro("MCP server added successfully") + return + } - const useOAuth = await prompts.confirm({ - message: "Does this server require OAuth authentication?", + if (type === "remote") { + const url = await prompts.text({ + message: "Enter MCP server URL", + placeholder: "e.g., https://example.com/mcp", + validate: (x) => { + if (!x) return "Required" + if (x.length === 0) return "Required" + const isValid = URL.canParse(x) + return isValid ? undefined : "Invalid URL" + }, + }) + if (prompts.isCancel(url)) throw new UI.CancelledError() + + const useOAuth = await prompts.confirm({ + message: "Does this server require OAuth authentication?", + initialValue: false, + }) + if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() + + let mcpConfig: ConfigMCP.Info + + if (useOAuth) { + const hasClientId = await prompts.confirm({ + message: "Do you have a pre-registered client ID?", initialValue: false, }) - if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() + if (prompts.isCancel(hasClientId)) throw new UI.CancelledError() - let mcpConfig: ConfigMCP.Info + if (hasClientId) { + const clientId = await prompts.text({ + message: "Enter client ID", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(clientId)) throw new UI.CancelledError() - if (useOAuth) { - const hasClientId = await prompts.confirm({ - message: "Do you have a pre-registered client ID?", + const hasSecret = await prompts.confirm({ + message: "Do you have a client secret?", initialValue: false, }) - if (prompts.isCancel(hasClientId)) throw new UI.CancelledError() + if (prompts.isCancel(hasSecret)) throw new UI.CancelledError() - if (hasClientId) { - const clientId = await prompts.text({ - message: "Enter client ID", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), + let clientSecret: string | undefined + if (hasSecret) { + const secret = await prompts.password({ + message: "Enter client secret", }) - if (prompts.isCancel(clientId)) throw new UI.CancelledError() + if (prompts.isCancel(secret)) throw new UI.CancelledError() + clientSecret = secret + } - const hasSecret = await prompts.confirm({ - message: "Do you have a client secret?", - initialValue: false, - }) - if (prompts.isCancel(hasSecret)) throw new UI.CancelledError() - - let clientSecret: string | undefined - if (hasSecret) { - const secret = await prompts.password({ - message: "Enter client secret", - }) - if (prompts.isCancel(secret)) throw new UI.CancelledError() - clientSecret = secret - } - - mcpConfig = { - type: "remote", - url, - oauth: { - clientId, - ...(clientSecret && { clientSecret }), - }, - } - } else { - mcpConfig = { - type: "remote", - url, - oauth: {}, - } + mcpConfig = { + type: "remote", + url, + oauth: { + clientId, + ...(clientSecret && { clientSecret }), + }, } } else { mcpConfig = { type: "remote", url, + oauth: {}, } } - - await addMcpToConfig(name, mcpConfig, configPath) - prompts.log.success(`MCP server "${name}" added to ${configPath}`) + } else { + mcpConfig = { + type: "remote", + url, + } } - prompts.outro("MCP server added successfully") - }, + await addMcpToConfig(name, mcpConfig, configPath) + prompts.log.success(`MCP server "${name}" added to ${configPath}`) + } + + prompts.outro("MCP server added successfully") }) - }, + }), }) -export const McpDebugCommand = cmd({ +export const McpDebugCommand = effectCmd({ command: "debug ", describe: "debug OAuth connection for an MCP server", builder: (yargs) => @@ -618,182 +604,172 @@ export const McpDebugCommand = cmd({ type: "string", demandOption: true, }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Debug") + handler: Effect.fn("Cli.mcp.debug")(function* (args) { + const config = yield* Config.Service.use((cfg) => cfg.get()) + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + yield* Effect.promise(async () => { + UI.empty() + prompts.intro("MCP OAuth Debug") - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - const mcpServers = config.mcp ?? {} - const serverName = args.name + const mcpServers = config.mcp ?? {} + const serverName = args.name - const serverConfig = mcpServers[serverName] - if (!serverConfig) { - prompts.log.error(`MCP server not found: ${serverName}`) - prompts.outro("Done") - return + const serverConfig = mcpServers[serverName] + if (!serverConfig) { + prompts.log.error(`MCP server not found: ${serverName}`) + prompts.outro("Done") + return + } + + if (!isMcpRemote(serverConfig)) { + prompts.log.error(`MCP server ${serverName} is not a remote server`) + prompts.outro("Done") + return + } + + if (serverConfig.oauth === false) { + prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`) + prompts.outro("Done") + return + } + + prompts.log.info(`Server: ${serverName}`) + prompts.log.info(`URL: ${serverConfig.url}`) + + // Check stored auth status — services already in hand, run inline. + const { authStatus, entry } = await Effect.runPromise( + Effect.all({ + authStatus: mcp.getAuthStatus(serverName), + entry: auth.get(serverName), + }), + ) + prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) + + if (entry?.tokens) { + prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) + if (entry.tokens.expiresAt) { + const expiresDate = new Date(entry.tokens.expiresAt * 1000) + const isExpired = entry.tokens.expiresAt < Date.now() / 1000 + prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`) } - - if (!isMcpRemote(serverConfig)) { - prompts.log.error(`MCP server ${serverName} is not a remote server`) - prompts.outro("Done") - return + if (entry.tokens.refreshToken) { + prompts.log.info(` Refresh token: present`) } - - if (serverConfig.oauth === false) { - prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`) - prompts.outro("Done") - return + } + if (entry?.clientInfo) { + prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`) + if (entry.clientInfo.clientSecretExpiresAt) { + const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000) + prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`) } + } - prompts.log.info(`Server: ${serverName}`) - prompts.log.info(`URL: ${serverConfig.url}`) + const spinner = prompts.spinner() + spinner.start("Testing connection...") - // Check stored auth status - const { authStatus, entry } = await AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* MCP.Service - const auth = yield* McpAuth.Service - return { - authStatus: yield* mcp.getAuthStatus(serverName), - entry: yield* auth.get(serverName), - } - }), - ) - prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) - - if (entry?.tokens) { - prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) - if (entry.tokens.expiresAt) { - const expiresDate = new Date(entry.tokens.expiresAt * 1000) - const isExpired = entry.tokens.expiresAt < Date.now() / 1000 - prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`) - } - if (entry.tokens.refreshToken) { - prompts.log.info(` Refresh token: present`) - } - } - if (entry?.clientInfo) { - prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`) - if (entry.clientInfo.clientSecretExpiresAt) { - const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000) - prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`) - } - } - - const spinner = prompts.spinner() - spinner.start("Testing connection...") - - // Test basic HTTP connectivity first - try { - const response = await fetch(serverConfig.url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json, text/event-stream", + // Test basic HTTP connectivity first + try { + const response = await fetch(serverConfig.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "opencode-debug", version: InstallationVersion }, }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "opencode-debug", version: InstallationVersion }, - }, - id: 1, - }), + id: 1, + }), + }) + + spinner.stop(`HTTP response: ${response.status} ${response.statusText}`) + + // Check for WWW-Authenticate header + const wwwAuth = response.headers.get("www-authenticate") + if (wwwAuth) { + prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) + } + + if (response.status === 401) { + prompts.log.warn("Server returned 401 Unauthorized") + + // Try to discover OAuth metadata + const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined + const authProvider = new McpOAuthProvider( + serverName, + serverConfig.url, + { + clientId: oauthConfig?.clientId, + clientSecret: oauthConfig?.clientSecret, + scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, + }, + { + onRedirect: async () => {}, + }, + auth, + ) + + prompts.log.info("Testing OAuth flow (without completing authorization)...") + + // Try creating transport with auth provider to trigger discovery + const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { + authProvider, }) - spinner.stop(`HTTP response: ${response.status} ${response.statusText}`) - - // Check for WWW-Authenticate header - const wwwAuth = response.headers.get("www-authenticate") - if (wwwAuth) { - prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) - } - - if (response.status === 401) { - prompts.log.warn("Server returned 401 Unauthorized") - - // Try to discover OAuth metadata - const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined - const auth = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* McpAuth.Service - }), - ) - const authProvider = new McpOAuthProvider( - serverName, - serverConfig.url, - { - clientId: oauthConfig?.clientId, - clientSecret: oauthConfig?.clientSecret, - scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, - }, - { - onRedirect: async () => {}, - }, - auth, - ) - - prompts.log.info("Testing OAuth flow (without completing authorization)...") - - // Try creating transport with auth provider to trigger discovery - const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { - authProvider, + try { + const client = new Client({ + name: "opencode-debug", + version: InstallationVersion, }) + await client.connect(transport) + prompts.log.success("Connection successful (already authenticated)") + await client.close() + } catch (error) { + if (error instanceof UnauthorizedError) { + prompts.log.info(`OAuth flow triggered: ${error.message}`) - try { - const client = new Client({ - name: "opencode-debug", - version: InstallationVersion, - }) - await client.connect(transport) - prompts.log.success("Connection successful (already authenticated)") - await client.close() - } catch (error) { - if (error instanceof UnauthorizedError) { - prompts.log.info(`OAuth flow triggered: ${error.message}`) - - // Check if dynamic registration would be attempted - const clientInfo = await authProvider.clientInformation() - if (clientInfo) { - prompts.log.info(`Client ID available: ${clientInfo.client_id}`) - } else { - prompts.log.info("No client ID - dynamic registration will be attempted") - } + // Check if dynamic registration would be attempted + const clientInfo = await authProvider.clientInformation() + if (clientInfo) { + prompts.log.info(`Client ID available: ${clientInfo.client_id}`) } else { - prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`) + prompts.log.info("No client ID - dynamic registration will be attempted") } - } - } else if (response.status >= 200 && response.status < 300) { - prompts.log.success("Server responded successfully (no auth required or already authenticated)") - const body = await response.text() - try { - const json = JSON.parse(body) - if (json.result?.serverInfo) { - prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`) - } - } catch { - // Not JSON, ignore - } - } else { - prompts.log.warn(`Unexpected status: ${response.status}`) - const body = await response.text().catch(() => "") - if (body) { - prompts.log.info(`Response body: ${body.substring(0, 500)}`) + } else { + prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`) } } - } catch (error) { - spinner.stop("Connection failed", 1) - prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + } else if (response.status >= 200 && response.status < 300) { + prompts.log.success("Server responded successfully (no auth required or already authenticated)") + const body = await response.text() + try { + const json = JSON.parse(body) + if (json.result?.serverInfo) { + prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`) + } + } catch { + // Not JSON, ignore + } + } else { + prompts.log.warn(`Unexpected status: ${response.status}`) + const body = await response.text().catch(() => "") + if (body) { + prompts.log.info(`Response body: ${body.substring(0, 500)}`) + } } + } catch (error) { + spinner.stop("Connection failed", 1) + prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + } - prompts.outro("Debug complete") - }, + prompts.outro("Debug complete") }) - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index ca64526182..081bcece00 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -1,6 +1,7 @@ import { Auth } from "../../auth" import { AppRuntime } from "../../effect/app-runtime" import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" import { ModelsDev } from "@/provider/models" @@ -13,7 +14,6 @@ import os from "os" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import { Plugin } from "../../plugin" -import { WithInstance } from "../../project/with-instance" import type { Hooks } from "@opencode-ai/plugin" import { Process } from "@/util/process" import { text } from "node:stream/consumers" @@ -232,58 +232,59 @@ export const ProvidersCommand = cmd({ async handler() {}, }) -export const ProvidersListCommand = cmd({ +export const ProvidersListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list providers and credentials", - async handler(_args) { - UI.empty() - const authPath = path.join(Global.Path.data, "auth.json") - const homedir = os.homedir() - const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath - prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) - const database = await getModels() + // Lists global credentials + provider env vars; no project instance needed. + instance: false, + handler: Effect.fn("Cli.providers.list")(function* (_args) { + const authSvc = yield* Auth.Service + const modelsDev = yield* ModelsDev.Service + yield* Effect.promise(async () => { + UI.empty() + const authPath = path.join(Global.Path.data, "auth.json") + const homedir = os.homedir() + const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath + prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) + const results = Object.entries(await Effect.runPromise(authSvc.all())) + const database = await Effect.runPromise(modelsDev.get()) - for (const [providerID, result] of results) { - const name = database[providerID]?.name || providerID - prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) - } + for (const [providerID, result] of results) { + const name = database[providerID]?.name || providerID + prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + } - prompts.outro(`${results.length} credentials`) + prompts.outro(`${results.length} credentials`) - const activeEnvVars: Array<{ provider: string; envVar: string }> = [] + const activeEnvVars: Array<{ provider: string; envVar: string }> = [] - for (const [providerID, provider] of Object.entries(database)) { - for (const envVar of provider.env) { - if (process.env[envVar]) { - activeEnvVars.push({ - provider: provider.name || providerID, - envVar, - }) + for (const [providerID, provider] of Object.entries(database)) { + for (const envVar of provider.env) { + if (process.env[envVar]) { + activeEnvVars.push({ + provider: provider.name || providerID, + envVar, + }) + } } } - } - if (activeEnvVars.length > 0) { - UI.empty() - prompts.intro("Environment") + if (activeEnvVars.length > 0) { + UI.empty() + prompts.intro("Environment") - for (const { provider, envVar } of activeEnvVars) { - prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) + for (const { provider, envVar } of activeEnvVars) { + prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) + } + + prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) } - - prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) - } - }, + }) + }), }) -export const ProvidersLoginCommand = cmd({ +export const ProvidersLoginCommand = effectCmd({ command: "login [url]", describe: "log in to a provider", builder: (yargs) => @@ -302,228 +303,219 @@ export const ProvidersLoginCommand = cmd({ describe: "login method label (skips method selection)", type: "string", }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("Add credential") - if (args.url) { - const url = args.url.replace(/\/+$/, "") - const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as { - auth: { command: string[]; env: string } - } - prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Process.spawn(wellknown.auth.command, { - stdout: "pipe", - }) - if (!proc.stdout) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) - if (exit !== 0) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - await put(url, { - type: "wellknown", - key: wellknown.auth.env, - token: token.trim(), - }) - prompts.log.success("Logged into " + url) + handler: Effect.fn("Cli.providers.login")(function* (args) { + const cfgSvc = yield* Config.Service + const pluginSvc = yield* Plugin.Service + yield* Effect.promise(async () => { + UI.empty() + prompts.intro("Add credential") + if (args.url) { + const url = args.url.replace(/\/+$/, "") + const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as { + auth: { command: string[]; env: string } + } + prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) + const proc = Process.spawn(wellknown.auth.command, { + stdout: "pipe", + stderr: "inherit", + }) + if (!proc.stdout) { + prompts.log.error("Failed") prompts.outro("Done") return } - await refreshModels().catch(() => {}) - - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - - const providers = await getModels().then((x) => { - const filtered: Record = {} - for (const [key, value] of Object.entries(x)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } - } - return filtered - }) - const hooks = await AppRuntime.runPromise( - Effect.gen(function* () { - const plugin = yield* Plugin.Service - return yield* plugin.list() - }), - ) - - const priority: Record = { - opencode: 0, - openai: 1, - "github-copilot": 2, - google: 3, - anthropic: 4, - openrouter: 5, - vercel: 6, + const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) + if (exit !== 0) { + prompts.log.error("Failed") + prompts.outro("Done") + return } - const pluginProviders = resolvePluginProviders({ - hooks, - existingProviders: providers, - disabled, - enabled, - providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), + await put(url, { + type: "wellknown", + key: wellknown.auth.env, + token: token.trim(), }) - const options = [ - ...pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, - ), - map((x) => ({ - label: x.name, - value: x.id, - hint: { - opencode: "recommended", - openai: "ChatGPT Plus/Pro or API key", - }[x.id], - })), + prompts.log.success("Logged into " + url) + prompts.outro("Done") + return + } + await refreshModels().catch(() => {}) + + const config = await Effect.runPromise(cfgSvc.get()) + + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + + const providers = await getModels().then((x) => { + const filtered: Record = {} + for (const [key, value] of Object.entries(x)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filtered[key] = value + } + } + return filtered + }) + const hooks = await Effect.runPromise(pluginSvc.list()) + + const priority: Record = { + opencode: 0, + openai: 1, + "github-copilot": 2, + google: 3, + anthropic: 4, + openrouter: 5, + vercel: 6, + } + const pluginProviders = resolvePluginProviders({ + hooks, + existingProviders: providers, + disabled, + enabled, + providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), + }) + const options = [ + ...pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, ), - ...pluginProviders.map((x) => ({ + map((x) => ({ label: x.name, value: x.id, - hint: "plugin", + hint: { + opencode: "recommended", + openai: "ChatGPT Plus/Pro or API key", + }[x.id], })), - ] + ), + ...pluginProviders.map((x) => ({ + label: x.name, + value: x.id, + hint: "plugin", + })), + ] - let provider: string - if (args.provider) { - const input = args.provider - const byID = options.find((x) => x.value === input) - const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) - const match = byID ?? byName - if (!match) { - prompts.log.error(`Unknown provider "${input}"`) - process.exit(1) - } - provider = match.value - } else { - const selected = await prompts.autocomplete({ - message: "Select provider", - maxItems: 8, - options: [ - ...options, - { - value: "other", - label: "Other", - }, - ], - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - provider = selected as string + let provider: string + if (args.provider) { + const input = args.provider + const byID = options.find((x) => x.value === input) + const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) + const match = byID ?? byName + if (!match) { + prompts.log.error(`Unknown provider "${input}"`) + process.exit(1) } + provider = match.value + } else { + const selected = await prompts.autocomplete({ + message: "Select provider", + maxItems: 8, + options: [ + ...options, + { + value: "other", + label: "Other", + }, + ], + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + provider = selected as string + } - const plugin = hooks.findLast((x) => x.auth?.provider === provider) - if (plugin && plugin.auth) { - const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method) + const plugin = hooks.findLast((x) => x.auth?.provider === provider) + if (plugin && plugin.auth) { + const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method) + if (handled) return + } + + if (provider === "other") { + const custom = await prompts.text({ + message: "Enter provider id", + validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), + }) + if (prompts.isCancel(custom)) throw new UI.CancelledError() + provider = custom.replace(/^@ai-sdk\//, "") + + const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) + if (customPlugin && customPlugin.auth) { + const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method) if (handled) return } - if (provider === "other") { - const custom = await prompts.text({ - message: "Enter provider id", - validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), - }) - if (prompts.isCancel(custom)) throw new UI.CancelledError() - provider = custom.replace(/^@ai-sdk\//, "") + prompts.log.warn( + `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, + ) + } - const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) - if (customPlugin && customPlugin.auth) { - const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method) - if (handled) return - } + if (provider === "amazon-bedrock") { + prompts.log.info( + "Amazon Bedrock authentication priority:\n" + + " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + + " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + + "Configure via opencode.json options (profile, region, endpoint) or\n" + + "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", + ) + } - prompts.log.warn( - `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, - ) - } + if (provider === "opencode") { + prompts.log.info("Create an api key at https://opencode.ai/auth") + } - if (provider === "amazon-bedrock") { - prompts.log.info( - "Amazon Bedrock authentication priority:\n" + - " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + - " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + - "Configure via opencode.json options (profile, region, endpoint) or\n" + - "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", - ) - } + if (provider === "vercel") { + prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") + } - if (provider === "opencode") { - prompts.log.info("Create an api key at https://opencode.ai/auth") - } + if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { + prompts.log.info( + "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", + ) + } - if (provider === "vercel") { - prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") - } + const key = await prompts.password({ + message: "Enter your API key", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(key)) throw new UI.CancelledError() + await put(provider, { + type: "api", + key, + }) - if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { - prompts.log.info( - "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", - ) - } - - const key = await prompts.password({ - message: "Enter your API key", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(key)) throw new UI.CancelledError() - await put(provider, { - type: "api", - key, - }) - - prompts.outro("Done") - }, + prompts.outro("Done") }) - }, + }), }) -export const ProvidersLogoutCommand = cmd({ +export const ProvidersLogoutCommand = effectCmd({ command: "logout", describe: "log out from a configured provider", - async handler(_args) { - UI.empty() - const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) - prompts.intro("Remove credential") - if (credentials.length === 0) { - prompts.log.error("No credentials found") - return - } - const database = await getModels() - const selected = await prompts.select({ - message: "Select provider", - options: credentials.map(([key, value]) => ({ - label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", - value: key, - })), + // Removes a global auth credential; no project instance needed. + instance: false, + handler: Effect.fn("Cli.providers.logout")(function* (_args) { + const authSvc = yield* Auth.Service + const modelsDev = yield* ModelsDev.Service + yield* Effect.promise(async () => { + UI.empty() + const credentials: Array<[string, Auth.Info]> = Object.entries(await Effect.runPromise(authSvc.all())) + prompts.intro("Remove credential") + if (credentials.length === 0) { + prompts.log.error("No credentials found") + return + } + const database = await Effect.runPromise(modelsDev.get()) + const selected = await prompts.select({ + message: "Select provider", + options: credentials.map(([key, value]) => ({ + label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", + value: key, + })), + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + const providerID = selected as string + await Effect.runPromise(authSvc.remove(providerID)) + prompts.outro("Logout successful") }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - const providerID = selected as string - await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.remove(providerID) - }), - ) - prompts.outro("Logout successful") - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 8bf7b2345c..0124a26932 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -5,7 +5,6 @@ import { Database } from "@/storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "@/project/project" import { InstanceRef } from "@/effect/instance-ref" -import { AppRuntime } from "@/effect/app-runtime" interface SessionStats { totalSessions: number @@ -69,38 +68,28 @@ export const StatsCommand = effectCmd({ handler: Effect.fn("Cli.stats")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - return yield* run(args, ctx.project) - }), -}) - -const run = ( - args: { days?: number; tools?: number; models?: unknown; project?: string }, - currentProject: Project.Info, -) => - Effect.promise(async () => { - const stats = await aggregateSessionStats(args.days, args.project, currentProject) - + const stats = yield* aggregateSessionStats(args.days, args.project, ctx.project) let modelLimit: number | undefined if (args.models === true) { modelLimit = Infinity } else if (typeof args.models === "number") { modelLimit = args.models } - displayStats(stats, args.tools, modelLimit) - }) + }), +}) -async function getAllSessions(): Promise { - const rows = Database.use((db) => db.select().from(SessionTable).all()) - return rows.map((row) => Session.fromRow(row)) -} +const getAllSessions = Effect.sync(() => + Database.use((db) => db.select().from(SessionTable).all()).map((row) => Session.fromRow(row)), +) -export async function aggregateSessionStats( +const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( days?: number, projectFilter?: string, currentProject?: Project.Info, -): Promise { - const sessions = await getAllSessions() +) { + const svc = yield* Session.Service + const sessions = yield* getAllSessions const MS_IN_DAY = 24 * 60 * 60 * 1000 const cutoffTime = (() => { @@ -169,122 +158,111 @@ export async function aggregateSessionStats( const sessionTotalTokens: number[] = [] - const BATCH_SIZE = 20 - for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) { - const batch = filteredSessions.slice(i, i + BATCH_SIZE) + const results = yield* Effect.forEach( + filteredSessions, + (session) => + Effect.gen(function* () { + const messages = yield* svc.messages({ sessionID: session.id }) - const batchPromises = batch.map(async (session) => { - const messages = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.messages({ sessionID: session.id })), - ) + let sessionCost = 0 + let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + let sessionToolUsage: Record = {} + let sessionModelUsage: Record< + string, + { + messages: number + tokens: { input: number; output: number; cache: { read: number; write: number } } + cost: number + } + > = {} - let sessionCost = 0 - let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } - let sessionToolUsage: Record = {} - let sessionModelUsage: Record< - string, - { - messages: number - tokens: { - input: number - output: number - cache: { - read: number - write: number + for (const message of messages) { + if (message.info.role === "assistant") { + sessionCost += message.info.cost || 0 + + const modelKey = `${message.info.providerID}/${message.info.modelID}` + if (!sessionModelUsage[modelKey]) { + sessionModelUsage[modelKey] = { + messages: 0, + tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + cost: 0, + } + } + sessionModelUsage[modelKey].messages++ + sessionModelUsage[modelKey].cost += message.info.cost || 0 + + if (message.info.tokens) { + sessionTokens.input += message.info.tokens.input || 0 + sessionTokens.output += message.info.tokens.output || 0 + sessionTokens.reasoning += message.info.tokens.reasoning || 0 + sessionTokens.cache.read += message.info.tokens.cache?.read || 0 + sessionTokens.cache.write += message.info.tokens.cache?.write || 0 + + sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 + sessionModelUsage[modelKey].tokens.output += + (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) + sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0 + sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0 } } - cost: number - } - > = {} - for (const message of messages) { - if (message.info.role === "assistant") { - sessionCost += message.info.cost || 0 - - const modelKey = `${message.info.providerID}/${message.info.modelID}` - if (!sessionModelUsage[modelKey]) { - sessionModelUsage[modelKey] = { - messages: 0, - tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - cost: 0, + for (const part of message.parts) { + if (part.type === "tool" && part.tool) { + sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1 } } - sessionModelUsage[modelKey].messages++ - sessionModelUsage[modelKey].cost += message.info.cost || 0 - - if (message.info.tokens) { - sessionTokens.input += message.info.tokens.input || 0 - sessionTokens.output += message.info.tokens.output || 0 - sessionTokens.reasoning += message.info.tokens.reasoning || 0 - sessionTokens.cache.read += message.info.tokens.cache?.read || 0 - sessionTokens.cache.write += message.info.tokens.cache?.write || 0 - - sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 - sessionModelUsage[modelKey].tokens.output += - (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) - sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0 - sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0 - } } - for (const part of message.parts) { - if (part.type === "tool" && part.tool) { - sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1 - } + return { + messageCount: messages.length, + sessionCost, + sessionTokens, + sessionTotalTokens: + sessionTokens.input + + sessionTokens.output + + sessionTokens.reasoning + + sessionTokens.cache.read + + sessionTokens.cache.write, + sessionToolUsage, + sessionModelUsage, + earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created, + latestTime: session.time.updated, + } + }), + { concurrency: 20 }, + ) + + for (const result of results) { + earliestTime = Math.min(earliestTime, result.earliestTime) + latestTime = Math.max(latestTime, result.latestTime) + sessionTotalTokens.push(result.sessionTotalTokens) + + stats.totalMessages += result.messageCount + stats.totalCost += result.sessionCost + stats.totalTokens.input += result.sessionTokens.input + stats.totalTokens.output += result.sessionTokens.output + stats.totalTokens.reasoning += result.sessionTokens.reasoning + stats.totalTokens.cache.read += result.sessionTokens.cache.read + stats.totalTokens.cache.write += result.sessionTokens.cache.write + + for (const [tool, count] of Object.entries(result.sessionToolUsage)) { + stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count + } + + for (const [model, usage] of Object.entries(result.sessionModelUsage)) { + if (!stats.modelUsage[model]) { + stats.modelUsage[model] = { + messages: 0, + tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + cost: 0, } } - - return { - messageCount: messages.length, - sessionCost, - sessionTokens, - sessionTotalTokens: - sessionTokens.input + - sessionTokens.output + - sessionTokens.reasoning + - sessionTokens.cache.read + - sessionTokens.cache.write, - sessionToolUsage, - sessionModelUsage, - earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created, - latestTime: session.time.updated, - } - }) - - const batchResults = await Promise.all(batchPromises) - - for (const result of batchResults) { - earliestTime = Math.min(earliestTime, result.earliestTime) - latestTime = Math.max(latestTime, result.latestTime) - sessionTotalTokens.push(result.sessionTotalTokens) - - stats.totalMessages += result.messageCount - stats.totalCost += result.sessionCost - stats.totalTokens.input += result.sessionTokens.input - stats.totalTokens.output += result.sessionTokens.output - stats.totalTokens.reasoning += result.sessionTokens.reasoning - stats.totalTokens.cache.read += result.sessionTokens.cache.read - stats.totalTokens.cache.write += result.sessionTokens.cache.write - - for (const [tool, count] of Object.entries(result.sessionToolUsage)) { - stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count - } - - for (const [model, usage] of Object.entries(result.sessionModelUsage)) { - if (!stats.modelUsage[model]) { - stats.modelUsage[model] = { - messages: 0, - tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - cost: 0, - } - } - stats.modelUsage[model].messages += usage.messages - stats.modelUsage[model].tokens.input += usage.tokens.input - stats.modelUsage[model].tokens.output += usage.tokens.output - stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read - stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write - stats.modelUsage[model].cost += usage.cost - } + stats.modelUsage[model].messages += usage.messages + stats.modelUsage[model].tokens.input += usage.tokens.input + stats.modelUsage[model].tokens.output += usage.tokens.output + stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read + stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write + stats.modelUsage[model].cost += usage.cost } } @@ -313,7 +291,7 @@ export async function aggregateSessionStats( : sessionTotalTokens[mid] return stats -} +}) export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) { const width = 56 diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index b0f6de16b7..ada5f8677d 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -3,6 +3,7 @@ import { Effect, Schema } from "effect" import { AppRuntime, type AppServices } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" import { InstanceRef } from "@/effect/instance-ref" +import { Instance } from "@/project/instance" import { cmd, type WithDoubleDash } from "./cmd/cmd" /** @@ -82,17 +83,21 @@ export const effectCmd = (opts: EffectCmdOpts) => return } const directory = opts.directory?.(args) ?? process.cwd() - await AppRuntime.runPromise( - InstanceStore.Service.use((store) => - store.provide( - { directory }, - Effect.gen(function* () { - const ctx = yield* InstanceRef - const body = opts.handler(args) - return ctx ? yield* body.pipe(Effect.ensuring(store.dispose(ctx))) : yield* body - }), - ), - ), + // Two-phase: load ctx, then run body inside Instance.current ALS. + // Effect's InstanceRef is provided via fiber context, but that context is + // lost across `await` inside `Effect.promise(async () => ...)` callbacks + // — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())` + // there, attach() falls back to Instance.current ALS, which Node preserves + // across awaits. Matches the pre-effectCmd `bootstrap()` behavior. + const { store, ctx } = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))), ) + try { + await Instance.restore(ctx, () => + AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))), + ) + } finally { + await AppRuntime.runPromise(store.dispose(ctx)) + } }, }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 8cc969f483..4a67ba036e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -18,7 +18,7 @@ import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { NotFoundError } from "@/storage/storage" import { NamedError } from "@opencode-ai/core/util/error" -import { Cause, Effect, Schema, Scope } from "effect" +import { Cause, Effect, Option, Schema, Scope } from "effect" import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" @@ -125,7 +125,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", if (!page.cursor) return page.items const request = yield* HttpServerRequest.HttpServerRequest - const url = new URL(request.url, "http://localhost") + // toURL() honors the Host + x-forwarded-proto headers, so the Link + // header echoes the real origin instead of a hard-coded localhost. + const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost")) url.searchParams.set("limit", ctx.query.limit.toString()) url.searchParams.set("before", page.cursor) return HttpServerResponse.jsonUnsafe(page.items, { diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 1777b875aa..1f4cbcf1e0 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -11,6 +11,7 @@ import { ProjectID } from "@/project/schema" import { ModelID, ProviderID } from "@/provider/schema" import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" +import { optionalOmitUndefined } from "@/util/schema" export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({ identifier: "Session.Delivery", @@ -21,20 +22,20 @@ export const DefaultDelivery = "immediate" satisfies Delivery export class Info extends Schema.Class("Session.Info")({ id: SessionID, - parentID: SessionID.pipe(Schema.optional), + parentID: optionalOmitUndefined(SessionID), projectID: ProjectID, - workspaceID: WorkspaceID.pipe(Schema.optional), - path: Schema.String.pipe(Schema.optional), - agent: Schema.String.pipe(Schema.optional), + workspaceID: optionalOmitUndefined(WorkspaceID), + path: optionalOmitUndefined(Schema.String), + agent: optionalOmitUndefined(Schema.String), model: Schema.Struct({ id: ModelID, providerID: ProviderID, - variant: Schema.String.pipe(Schema.optional), - }).pipe(Schema.optional), + variant: optionalOmitUndefined(Schema.String), + }).pipe(optionalOmitUndefined), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, updated: V2Schema.DateTimeUtcFromMillis, - archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis), }), title: Schema.String, /* @@ -109,7 +110,7 @@ export const layer = Layer.effect( decodeMessage({ ...row.data, id: row.id, type: row.type }) function fromRow(row: typeof SessionTable.$inferSelect): Info { - return { + return new Info({ id: SessionID.make(row.id), projectID: ProjectID.make(row.project_id), workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined, @@ -129,7 +130,7 @@ export const layer = Layer.effect( updated: DateTime.makeUnsafe(row.time_updated), archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, }, - } + }) } const result: Interface = { diff --git a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts new file mode 100644 index 0000000000..de6fed8daa --- /dev/null +++ b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, test } from "bun:test" +import { Effect } from "effect" +import fs from "fs/promises" +import { Instance } from "../../src/project/instance" +import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await disposeAllInstances() +}) + +// Regression for PR #25522: when an effectCmd handler does +// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`, +// the inner runPromise creates a fresh fiber after `await` whose Effect context +// has lost the outer InstanceRef. Services that read `InstanceState.context` +// then fall back to `Instance.current` ALS, which must be installed at the JS +// callback boundary (Node ALS persists across awaits, Effect's fiber context +// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap. +// Pins effect-cmd.ts directly: the pattern test below exercises the load + +// Instance.restore + dispose triple via the shared `provideTestInstance` fixture, +// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't +// fail it. This grep guards the actual production callsite. +test("effect-cmd.ts wraps the handler body in Instance.restore", async () => { + const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8") + expect(source).toContain("Instance.restore(ctx") +}) + +test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => { + await using dir = await tmpdir({ git: true }) + await provideTestInstance({ + directory: dir.path, + fn: () => + Effect.runPromise( + Effect.promise(async () => { + await new Promise((r) => setTimeout(r, 5)) + const current = await Effect.runPromise( + Effect.sync(() => { + try { + return Instance.current + } catch { + return undefined + } + }), + ) + expect(current?.directory).toBe(dir.path) + }), + ), + }) +}) diff --git a/packages/opencode/test/server/httpapi-parity.test.ts b/packages/opencode/test/server/httpapi-parity.test.ts new file mode 100644 index 0000000000..6922d8c43f --- /dev/null +++ b/packages/opencode/test/server/httpapi-parity.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { Session } from "@/session/session" +import { MessageID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +function app(experimental: boolean) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app +} + +function runSession(fx: Effect.Effect) { + return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) +} + +function createSessionWithMessages(directory: string, count: number) { + return WithInstance.provide({ + directory, + fn: async () => { + const session = await runSession(Session.Service.use((svc) => svc.create({}))) + for (let i = 0; i < count; i++) { + await runSession( + Effect.gen(function* () { + const svc = yield* Session.Service + yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + }), + ) + } + return session.id + }, + }) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 1: Link header should reflect the request's actual Host header, +// not "localhost". HttpApi uses `new URL(request.url, "http://localhost")` +// which embeds localhost because request.url is path-only. Fix: use +// `HttpServerRequest.toURL(request)` which honors the Host header. +// ────────────────────────────────────────────────────────────────────────────── +describe("Link header host", () => { + test("HttpApi pagination Link header echoes request host", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sessionID = await createSessionWithMessages(tmp.path, 3) + + const response = await app(true).request(`/session/${sessionID}/message?limit=2`, { + headers: { + host: "opencode.test:4096", + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + const link = response.headers.get("link") + expect(link).not.toBeNull() + // Link should contain the request's Host, not "localhost". + expect(link).toContain("opencode.test") + expect(link).not.toContain("localhost") + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500. +// The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a +// `NotFoundError` from the service surfaces as a defect → 500. Hono's +// equivalent maps to 404 via `errors.notFound`. +// +// Affected endpoints (handlers without mapNotFound): todo, diff, summarize, +// fork, abort, init, deleteMessage, command, shell, revert, unrevert. +// +// FIXME: unskip when mapNotFound coverage is added (next PR). +// ────────────────────────────────────────────────────────────────────────────── +describe("404 mapping for missing session", () => { + test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const response = await app(true).request("/session/ses_does_not_exist/todo", { + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(404) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 3: 404 response body shape should match Hono's NamedError +// envelope `{ name, data: { message } }`. HttpApi returns the typed-error +// shape `{ _tag }` instead. SDK consumers reading `error.data.message` +// see undefined. +// +// FIXME: unskip when error JSON shape policy is decided + applied (separate PR). +// ────────────────────────────────────────────────────────────────────────────── +describe("Error JSON shape parity", () => { + test.todo("HttpApi 404 body matches NamedError shape", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const response = await app(true).request("/session/ses_does_not_exist", { + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(404) + const body = (await response.json()) as { name?: string; data?: { message?: string } } + expect(body.name).toBe("NotFoundError") + expect(typeof body.data?.message).toBe("string") + }) +}) diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index db6c750f9b..1e74763ae2 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -35,7 +35,7 @@ width: 100%; max-height: 100%; min-height: 280px; - overflow: hidden; + overflow: auto; pointer-events: auto; /* Hide scrollbar */ @@ -102,8 +102,7 @@ display: flex; flex-direction: column; flex: 1; - min-height: 0; - overflow-y: auto; + overflow: hidden; &:focus-visible { outline: none; diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index a377a159b0..c1c56212b5 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -29,33 +29,33 @@ const Context = createContext>() function init() { const [active, setActive] = createSignal() - let timer: ReturnType | undefined - let locked = false + const timer = { current: undefined as ReturnType | undefined } + const lock = { value: false } onCleanup(() => { - if (timer === undefined) return - clearTimeout(timer) - timer = undefined + if (timer.current === undefined) return + clearTimeout(timer.current) + timer.current = undefined }) const close = () => { const current = active() - if (!current || locked) return - locked = true + if (!current || lock.value) return + lock.value = true current.onClose?.() current.setClosing(true) const id = current.id - if (timer !== undefined) { - clearTimeout(timer) - timer = undefined + if (timer.current !== undefined) { + clearTimeout(timer.current) + timer.current = undefined } - timer = setTimeout(() => { - timer = undefined + timer.current = setTimeout(() => { + timer.current = undefined current.dispose() if (active()?.id === id) setActive(undefined) - locked = false + lock.value = false }, 100) } @@ -80,11 +80,11 @@ function init() { setActive(undefined) } - if (timer !== undefined) { - clearTimeout(timer) - timer = undefined + if (timer.current !== undefined) { + clearTimeout(timer.current) + timer.current = undefined } - locked = false + lock.value = false const id = Math.random().toString(36).slice(2) let dispose: (() => void) | undefined @@ -105,7 +105,7 @@ function init() { }} > - + {element()} @@ -115,14 +115,7 @@ function init() { if (!dispose || !setClosing) return - setActive({ - id, - node, - dispose, - owner, - onClose, - setClosing, - }) + setActive({ id, node, dispose, owner, onClose, setClosing }) } return {