Compare commits

..

13 Commits

Author SHA1 Message Date
Dax Raad 1d83c61b6f test(core): expect failed plugin inventory 2026-08-18 13:42:59 -04:00
Dax Raad e6d7dae31c fix(cli): list failed plugins without ids 2026-08-18 13:25:51 -04:00
Dax Raad 103105d52a feat(plugin): expose server plugin status 2026-08-18 13:24:37 -04:00
opencode-agent[bot] bac474aaa0 fix(tui): scope session picker to active location (#43264)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-18 17:01:15 +00:00
Aiden Cline 98c717cb5b fix(core): migrate standalone small model (#43260) 2026-08-18 11:14:36 -05:00
Major Hayden 5c8d46ab4b fix(core): make Google Vertex models work with ADC credentials (#43077)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-18 10:47:13 -05:00
Dax Raad d5e83fefda fix(core): reuse prompt cache for forks 2026-08-18 11:38:11 -04:00
Dax Raad 46378dda50 refactor(core): standardize builtin plugin ids 2026-08-18 11:07:11 -04:00
Dax b38d9d812f refactor(client): move service shutdown client-side (#43252) 2026-08-18 15:02:37 +00:00
Dax Raad 8df039d261 fix(cli): limit source maps to development channels 2026-08-18 10:53:42 -04:00
Dax Raad c92fb2d41b fix(cli): improve process failure logging 2026-08-18 10:49:52 -04:00
Shoubhit Dash 958308c913 fix(core): ignore malformed model costs (#43251) 2026-08-18 19:59:47 +05:30
Dax Raad 16390ca47d fix(cli): log background service startup 2026-08-18 10:15:36 -04:00
74 changed files with 916 additions and 1235 deletions
@@ -1,108 +0,0 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("renames, exports, and deletes a home session from its context menu", async ({ page }) => {
const sessions = fixture.sessions.map((session) => ({ ...session }))
await mockOpenCodeServer(page, {
sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.route("**/api/session/*/rename", async (route) => {
const sessionID = new URL(route.request().url()).pathname.split("/").at(-2)
const session = sessions.find((item) => item.id === sessionID)
const payload: unknown = route.request().postDataJSON()
if (!payload || typeof payload !== "object" || !("title" in payload) || typeof payload.title !== "string")
throw new Error("Invalid rename payload")
if (session) session.title = payload.title
await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
await expect(row).toBeVisible()
const container = page.locator(
`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`,
)
const titleBox = await container.locator('[data-component="home-session-title"]').boundingBox()
const avatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
await expect(container.getByRole("button", { name: "More options" })).toHaveCount(0)
await row.focus()
await row.press("Shift+F10")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeHidden()
await expect(row).toBeFocused()
const rowBox = await row.boundingBox()
await row.click({ button: "right", position: { x: 48, y: 12 } })
await expect(page).toHaveURL("/")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Export..." })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Delete..." })).toBeVisible()
const menuBox = await page.locator('[data-component="menu-v2-content"]').boundingBox()
expect(Math.abs((menuBox?.x ?? 0) - (rowBox?.x ?? 0) - 48)).toBeLessThan(4)
await page.getByRole("menuitem", { name: "Rename" }).click()
const title = page.locator('[data-component="home-session-rename"]')
await expect(title).toBeFocused()
await expect(title).toHaveValue(fixture.expected.targetTitle)
const editorBox = await title.boundingBox()
const editingAvatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
expect(editorBox?.x).toBe(titleBox?.x)
expect(editingAvatarBox).toEqual(avatarBox)
expect(
await title.evaluate((element) => ({
outline: getComputedStyle(element).outlineStyle,
shadow: getComputedStyle(element).boxShadow,
})),
).toEqual({ outline: "none", shadow: "none" })
expect(await container.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe("none")
await title.fill("Renamed from Home")
const renamed = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname.endsWith("/rename"),
)
await title.press("Enter")
expect((await renamed).postDataJSON()).toEqual({ title: "Renamed from Home" })
let renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
await expect(renamedRow).toBeVisible()
await renamedRow.click()
await expect(page).toHaveURL(new RegExp(`/session/${fixture.targetID}$`))
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from Home" })).toBeVisible()
await page.getByRole("button", { name: "Home" }).click()
await expect(page).toHaveURL("/")
renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
await expect(renamedRow).toBeVisible()
await renamedRow.click({ button: "right" })
const download = page.waitForEvent("download")
const exportItem = page.getByRole("menuitem", { name: "Export..." })
await exportItem.click()
expect((await download).suggestedFilename()).toBe("renamed-from-home.json")
await expect(exportItem).toBeHidden()
await renamedRow.click({ button: "right" })
await page.getByRole("menuitem", { name: "Delete..." }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toContainText('Delete session "Renamed from Home"?')
const removed = page.waitForRequest(
(request) => request.method() === "DELETE" && new URL(request.url()).pathname.endsWith(`/${fixture.targetID}`),
)
await dialog.getByRole("button", { name: "Delete session" }).click()
await removed
await expect(renamedRow).toBeHidden()
})
@@ -7,6 +7,7 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server"
import { pluginLabel } from "@/utils/plugin"
import { ExternalLink } from "./external-link"
type SkillItem = {
@@ -101,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
})
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
@@ -6,6 +6,7 @@ import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useMcpToggle } from "@/context/mcp"
import { pluginLabel } from "@/utils/plugin"
import { ExternalLink } from "../external-link"
import { InlineServerSelect } from "./parts/server-select"
import "./settings-v2.css"
@@ -44,7 +45,9 @@ export const SettingsExtensionsV2: Component = () => {
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data),
)
const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
const plugins = createMemo<PluginRowItem[]>(() =>
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
)
createEffect(() => {
if (serverSdk.connection.status() !== "connected") return
@@ -6,6 +6,7 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { pluginLabel } from "@/utils/plugin"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -38,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
@@ -1,12 +1,8 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { skipToken, useQuery } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { useCommand } from "@/context/command"
import {
loadHomeSessionIndex,
@@ -19,10 +15,7 @@ import { ServerConnection } from "@/context/servers"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { compareSessionTime, displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { removedSessionIDs } from "@/pages/session/session-domain"
import { pathKey } from "@/utils/path-key"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { sessionLabel, sessionTitle } from "@/utils/session-title"
import { showToast } from "@/utils/toast"
import { archiveHomeSession } from "../home-session-archive"
import type { HomeController } from "./home-controller"
@@ -47,8 +40,6 @@ export function createHomeSessionsController(home: HomeController) {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const queryClient = useQueryClient()
const [removed, setRemoved] = createStore({ keys: [] as string[] })
const projectDirectories = createMemo(() => {
const project = home.project.selected()
if (!project) return home.project.list().flatMap(directories)
@@ -74,13 +65,9 @@ export function createHomeSessionsController(home: HomeController) {
})
const indexedSessions = createMemo(() => {
const ctx = home.server.focusedContext()
const conn = home.server.focused()
if (!ctx || !conn) return []
const server = ServerConnection.key(conn)
if (!ctx) return []
return retainHomeSessions(
mergeHomeSessionIndex(sessionLoad.data ?? [], ctx.data.session.list()).filter(
(session) => !removed.keys.includes(`${server}\0${session.id}`),
),
mergeHomeSessionIndex(sessionLoad.data ?? [], ctx.data.session.list()),
HOME_SESSION_LIMIT,
Date.now(),
)
@@ -143,112 +130,6 @@ export function createHomeSessionsController(home: HomeController) {
},
])
const rename = async (server: ServerConnection.Key, session: SessionInfo, title: string) => {
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
const ctx = conn ? home.server.context(conn) : undefined
if (!conn || !ctx) return false
const next = title.trim()
if (!next || next === sessionLabel(session)) return true
return ctx.sdk.api.session
.rename({ sessionID: session.id, title: next })
.then(() => {
ctx.data.session.remember({ ...(ctx.data.session.get(session.id) ?? session), title: next })
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
current?.map((item) => (item.id === session.id ? { ...item, title: next } : item)),
)
return true
})
.catch((cause) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
})
return false
})
}
const exportSession = async (server: ServerConnection.Key, session: SessionInfo) => {
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
const ctx = conn ? home.server.context(conn) : undefined
if (!ctx) return
try {
const data = await fetchSessionExport({ sessionID: session.id, api: ctx.sdk.api })
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("toast.session.export.success.title"),
description: language.t("toast.session.export.success.description", { filename }),
})
} catch (cause) {
showToast({
variant: "error",
title: language.t("toast.session.export.failed.title"),
description:
cause instanceof Error ? cause.message : language.t("toast.session.export.failed.description"),
})
}
}
const remove = async (server: ServerConnection.Key, session: SessionInfo) => {
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
const ctx = conn ? home.server.context(conn) : undefined
if (!conn || !ctx) return false
const ids = [...removedSessionIDs(ctx.data.session.list(), session.id)]
await queryClient.cancelQueries({ queryKey: ["home-sessions", conn], exact: true })
return ctx.sdk.api.session
.remove({ sessionID: session.id })
.then(() => {
const removedIDs = new Set(ids)
setRemoved("keys", (current) => [
...new Set([...current, ...ids.map((id) => `${server}\0${id}`)]),
])
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
current?.filter((item) => !removedIDs.has(item.id)),
)
notifySessionTabsRemoved({
server: ServerConnection.key(conn),
directory: session.location.directory,
sessionIDs: ids,
})
return true
})
.catch((cause) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(cause, language.t("session.delete.failed.title")),
})
return false
})
}
function DeleteDialog(props: { server: ServerConnection.Key; session: SessionInfo }) {
const name = () => sessionTitle(props.session.title) ?? language.t("command.session.new")
const confirm = async () => {
await remove(props.server, props.session)
dialog.close()
}
return (
<DialogV2 fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" onClick={confirm}>
{language.t("session.delete.button")}
</ButtonV2>
</DialogFooter>
</DialogV2>
)
}
return {
copy: {
language,
@@ -309,10 +190,6 @@ export function createHomeSessionsController(home: HomeController) {
}),
})
},
rename,
export: exportSession,
showDelete: (server: ServerConnection.Key, session: SessionInfo) =>
dialog.show(() => <DeleteDialog server={server} session={session} />),
},
tab: {
isOpen: (record: HomeSessionRecord) =>
@@ -1,13 +1,10 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createMemo, For, onCleanup, Show, Suspense } from "solid-js"
import { createStore } from "solid-js/store"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { createMemo, For, Show, Suspense } from "solid-js"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
@@ -25,7 +22,6 @@ import {
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
const HOME_SESSION_LONG_PRESS_MS = 500
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
// tab in the background without navigating, matching browser conventions.
@@ -58,9 +54,6 @@ export type HomeSessionsViewProps = {
onCreateSession: () => void
onOpenSession: (session: SessionInfo, options?: OpenSessionOptions) => void
onArchiveSession: (session: SessionInfo) => Promise<void>
onRenameSession: (server: ServerConnection.Key, session: SessionInfo, title: string) => Promise<boolean>
onExportSession: (server: ServerConnection.Key, session: SessionInfo) => Promise<void>
onDeleteSession: (server: ServerConnection.Key, session: SessionInfo) => void
onSetHoverTarget: (element: HTMLElement) => void
onSetThumbTrack: (element: HTMLDivElement) => void
onSetContent: (element: HTMLDivElement) => void
@@ -422,238 +415,42 @@ function HomeSessionGroupHeader(props: {
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => sessionLabel(props.record.session))
const showProjectName = () => props.showProjectName && props.record.projectName
const [state, setState] = createStore({
draft: "",
editing: false,
menuX: 0,
menuY: 0,
menuOpen: false,
pendingAction: undefined as "rename" | "export" | "delete" | undefined,
renaming: false,
})
let titleRef: HTMLInputElement | undefined
let rowRef: HTMLButtonElement | undefined
let longPressTimer: ReturnType<typeof setTimeout> | undefined
let longPressStart: { x: number; y: number } | undefined
let suppressClick = false
const clearLongPress = () => {
if (longPressTimer !== undefined) clearTimeout(longPressTimer)
longPressTimer = undefined
longPressStart = undefined
}
const finishLongPress = () => {
clearLongPress()
if (!suppressClick) return
setTimeout(() => {
suppressClick = false
})
}
onCleanup(clearLongPress)
const openMenu = (element: HTMLElement, clientX: number, clientY: number) => {
const bounds = element.getBoundingClientRect()
setState({ menuX: clientX - bounds.left, menuY: clientY - bounds.top, menuOpen: true })
}
const openEditor = () => {
setState({ draft: title(), editing: true })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeEditor = () => {
if (state.renaming) return
setState("editing", false)
}
const saveEditor = async () => {
if (state.renaming) return
setState("renaming", true)
const saved = await props.onRenameSession(props.server, props.record.session, state.draft)
setState("renaming", false)
if (saved) setState("editing", false)
}
return (
<div
data-component="home-session-row-container"
data-session-id={props.record.session.id}
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px] outline-none focus:outline-none focus-visible:outline-none"
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
classList={{ group: !!showProjectName() }}
onContextMenu={(event) => {
event.preventDefault()
if (state.editing) return
openMenu(event.currentTarget, event.clientX, event.clientY)
}}
>
<Show
when={!state.editing}
fallback={
<div class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 pl-3 pr-10">
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={false}
/>
<InlineInput
ref={(element) => {
titleRef = element
}}
data-component="home-session-rename"
dir="auto"
value={state.draft}
disabled={state.renaming}
class={`
block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base
[font-weight:530] field-sizing-content outline-none focus:outline-none focus-visible:outline-none
${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}
`}
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => setState("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveEditor()
return
}
if (event.key !== "Escape") return
event.preventDefault()
closeEditor()
}}
onBlur={closeEditor}
/>
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</div>
}
<button
type="button"
data-component="home-session-row"
class={`
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
`}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.onOpenSession(props.record.session, { background: true })
}}
>
<button
ref={(element) => {
rowRef = element
}}
type="button"
data-component="home-session-row"
aria-haspopup="menu"
aria-expanded={state.menuOpen}
class={`
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
`}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onPointerDown={(event) => {
if (event.pointerType !== "touch") return
clearLongPress()
const element = event.currentTarget
const x = event.clientX
const y = event.clientY
longPressStart = { x, y }
longPressTimer = setTimeout(() => {
suppressClick = true
clearLongPress()
openMenu(element, x, y)
}, HOME_SESSION_LONG_PRESS_MS)
}}
onPointerMove={(event) => {
if (!longPressStart) return
if (Math.abs(event.clientX - longPressStart.x) <= 8 && Math.abs(event.clientY - longPressStart.y) <= 8)
return
clearLongPress()
}}
onPointerUp={finishLongPress}
onPointerCancel={() => {
clearLongPress()
suppressClick = false
}}
onKeyDown={(event) => {
if (event.key !== "ContextMenu" && (event.key !== "F10" || !event.shiftKey)) return
event.preventDefault()
const bounds = event.currentTarget.getBoundingClientRect()
openMenu(event.currentTarget, bounds.left + 12, bounds.bottom)
}}
onClick={(event) => {
if (suppressClick) {
suppressClick = false
event.preventDefault()
return
}
props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })
}}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.onOpenSession(props.record.session, { background: true })
}}
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</button>
</Show>
<MenuV2
modal={false}
placement="bottom-start"
gutter={2}
open={state.menuOpen}
onOpenChange={(open) => setState("menuOpen", open)}
>
<MenuV2.Trigger
as="span"
aria-hidden="true"
tabIndex={-1}
class="pointer-events-none absolute size-px"
style={{ left: `${state.menuX}px`, top: `${state.menuY}px` }}
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<MenuV2.Portal>
<MenuV2.Content
onCloseAutoFocus={(event) => {
event.preventDefault()
const action = state.pendingAction
if (!action) {
requestAnimationFrame(() => rowRef?.focus())
return
}
setState("pendingAction", undefined)
if (action === "rename") {
openEditor()
return
}
requestAnimationFrame(() => {
if (action === "export") {
void props.onExportSession(props.server, props.record.session)
return
}
props.onDeleteSession(props.server, props.record.session)
})
}}
>
<MenuV2.Item onSelect={() => setState({ pendingAction: "rename", menuOpen: false })}>
{props.language.t("common.rename")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => setState({ pendingAction: "export", menuOpen: false })}>
{props.language.t("common.export")}...
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => setState({ pendingAction: "delete", menuOpen: false })}>
{props.language.t("common.delete")}...
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</button>
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
<div
class={`
@@ -684,7 +481,6 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
function HomeSessionTitle(props: { title: string; showProjectName: boolean; search?: boolean }) {
return (
<span
data-component="home-session-title"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530]"
classList={{
"text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
@@ -27,9 +27,6 @@ export function HomeSessions(props: {
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
onDeleteSession={props.sessions.session.showDelete}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}
@@ -1,15 +0,0 @@
import { describe, expect, test } from "bun:test"
import { removedSessionIDs } from "./session-domain"
describe("removedSessionIDs", () => {
test("includes all descendants without unrelated sessions", () => {
const sessions = [
{ id: "root" },
{ id: "child", parentID: "root" },
{ id: "grandchild", parentID: "child" },
{ id: "other" },
]
expect([...removedSessionIDs(sessions, "root")]).toEqual(["root", "child", "grandchild"])
})
})
@@ -17,19 +17,3 @@ export function selectVisibleSessionUserMessages(messages: SessionMessageUser[],
if (!revertMessageID) return messages
return messages.filter((message) => message.id < revertMessageID)
}
export function removedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
const removed = new Set([sessionID])
const byParent = Map.groupBy(
sessions.filter((session) => session.parentID),
(session) => session.parentID!,
)
const visit = (id: string) =>
byParent.get(id)?.forEach((child) => {
if (removed.has(child.id)) return
removed.add(child.id)
visit(child.id)
})
visit(sessionID)
return removed
}
@@ -22,3 +22,19 @@ export function timelineChildTitle(input: {
if (input.taskDescription) return input.taskDescription
return input.title?.replace(/\s+\(@[^)]+ subagent\)$/, "") || input.fallback
}
export function timelineRemovedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
const removed = new Set([sessionID])
const byParent = Map.groupBy(
sessions.filter((session) => session.parentID),
(session) => session.parentID!,
)
const visit = (id: string) =>
byParent.get(id)?.forEach((child) => {
if (removed.has(child.id)) return
removed.add(child.id)
visit(child.id)
})
visit(sessionID)
return removed
}
@@ -12,13 +12,12 @@ import { useSettings } from "@/context/settings"
import { useWorkspaceLocation } from "@/context/location"
import { useTabs } from "@/context/tabs"
import type { SessionController } from "@/pages/session/session-controller"
import { removedSessionIDs } from "@/pages/session/session-domain"
import { useServerSDK } from "@/context/server-sdk"
import { sessionHref } from "@/utils/session-route"
import { sessionTitle } from "@/utils/session-title"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { showToast } from "@/utils/toast"
import { timelineChildTitle, visibleTimelineMessages } from "./controller-projection"
import { timelineChildTitle, timelineRemovedSessionIDs, visibleTimelineMessages } from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { useServer } from "@/context/server"
@@ -172,7 +171,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
return false
})
if (!success) return false
const removed = removedSessionIDs(data.session.list(), id)
const removed = timelineRemovedSessionIDs(data.session.list(), id)
void navigateAfterRemoval(id, session.parentID, next?.id)
notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] })
return true
+8
View File
@@ -0,0 +1,8 @@
import type { PluginInfo } from "@opencode-ai/client"
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+1 -1
View File
@@ -109,7 +109,7 @@ for (const item of targets) {
external: ["node-gyp"],
format: "esm",
minify: true,
sourcemap: "inline",
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
splitting: true,
compile: {
autoloadBunfig: false,
+7 -12
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bun
import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect/service"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { Schema } from "effect"
import { Effect, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -63,28 +64,22 @@ try {
})
if (unauthorizedOpenApi.status !== 401)
throw new Error("Compiled service exposed application routes without authentication")
const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), {
const stopRoute = await fetch(new URL("/api/service/stop", info.url), {
method: "POST",
headers: { "content-type": "application/json" },
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ instanceID: info.id }),
signal: AbortSignal.timeout(5_000),
})
if (unauthorizedStop.status !== 401) throw new Error("Compiled service accepted unauthenticated stop")
if (stopRoute.status !== 404) throw new Error("Compiled service exposed the removed HTTP stop route")
const winner = processes.find((process) => process.pid === info.pid)
const loser = processes.find((process) => process.pid !== info.pid)
if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner")
if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit")
const stopped = await Schema.decodeUnknownPromise(ServiceStatus.StopResponse)(
await fetch(new URL("/api/service/stop", info.url), {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ instanceID: info.id }),
signal: AbortSignal.timeout(5_000),
}).then((response) => response.json()),
await Effect.runPromise(
Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)),
)
if (!stopped.accepted) throw new Error("Compiled service rejected exact-instance stop")
if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop")
for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25)
if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed")
+11 -1
View File
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Config } from "../../config"
import { Context, Effect, FileSystem, Option } from "effect"
import { Context, Effect, FileSystem, Option, Queue } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
@@ -19,11 +19,21 @@ export default Runtime.handler(Commands, (input) =>
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
const preflight = UpdatePreflight.make()
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
const serviceStarts = yield* Queue.unbounded<{
readonly reason: "missing" | "version-mismatch"
readonly previousVersion?: string
}>()
yield* Queue.take(serviceStarts).pipe(
Effect.flatMap((event) => Effect.logInfo("background service starting", event)),
Effect.forever,
Effect.forkScoped,
)
const server = yield* ServerConnection.resolve({
server: requestedServer,
standalone: input.standalone,
mismatch: "replace",
onStart: (reason, previousVersion) => {
Queue.offerUnsafe(serviceStarts, { reason, previousVersion })
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
process.stderr.write(
reason === "version-mismatch"
@@ -1,6 +1,6 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/client"
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
@@ -14,11 +14,18 @@ export default Runtime.handler(
const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
const plugins = response.data.toSorted((a, b) => name(a).localeCompare(name(b)))
if (plugins.length === 0) {
process.stdout.write("No plugins loaded" + EOL)
return
}
process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
process.stdout.write(plugins.map(name).join(EOL) + EOL)
}),
)
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+21
View File
@@ -59,6 +59,21 @@ const Handlers = Runtime.handlers(Commands, {
Effect.gen(function* () {
yield* Heap.listen
const runFork = Effect.runForkWith(yield* Effect.context<never>())
const uncaughtException = (cause: Error, origin: "uncaughtException" | "unhandledRejection") => {
runFork(Effect.logError("uncaught exception", { cause, origin }))
}
const unhandledRejection = (cause: unknown) => {
runFork(Effect.logError("unhandled rejection", { cause }))
}
process.on("uncaughtException", uncaughtException)
process.on("unhandledRejection", unhandledRejection)
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
process.off("uncaughtException", uncaughtException)
process.off("unhandledRejection", unhandledRejection)
}),
)
yield* Effect.logInfo("cli starting", {
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
@@ -67,6 +82,12 @@ Effect.gen(function* () {
})
return yield* Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })
}).pipe(
Effect.catchCause((cause) =>
Effect.logError("cli process failed", {
cause,
args: process.argv.slice(2),
}).pipe(Effect.andThen(Effect.failCause(cause))),
),
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
+22 -12
View File
@@ -117,7 +117,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
serviceOptions === undefined
? undefined
: {
instanceID,
onListen: (address, shutdown) =>
Effect.gen(function* () {
if (!config.password) yield* ServiceConfig.password(password)
@@ -180,27 +179,38 @@ const register = Effect.fnUntraced(function* (
password,
}
const encoded = yield* encodeInfo(info)
const current = fs.readFileString(file).pipe(
Effect.flatMap(decodeInfo),
Effect.orElseSucceed(() => undefined),
)
const owns = (found: Info | undefined) =>
found?.id === info.id &&
const current = fs.readFileString(file).pipe(Effect.flatMap(decodeInfo))
const owns = (found: Info) =>
found.id === info.id &&
found.version === info.version &&
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
yield* current.pipe(
Effect.filterOrFail(owns),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.tapError(() =>
Effect.logWarning("managed service registration lost; shutting down", {
Effect.catchCause((cause) =>
Effect.logWarning("managed service registration check failed; shutting down", {
cause,
serviceID: id,
servicePID: process.pid,
registration: file,
}),
}).pipe(Effect.andThen(Effect.failCause(cause))),
),
Effect.tap((found) =>
owns(found)
? Effect.void
: Effect.logWarning("managed service registration replaced; shutting down", {
serviceID: id,
servicePID: process.pid,
registration: file,
observedServiceID: found.id,
observedServicePID: found.pid,
observedVersion: found.version,
observedURL: found.url,
}),
),
Effect.filterOrFail(owns),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.ignore,
Effect.andThen(shutdown),
Effect.forkScoped,
-5
View File
@@ -41,13 +41,8 @@ import type { Config } from "@opencode-ai/schema/config"
export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number }
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
export type Endpoint0_1Input = { readonly instanceID: string }
export type Endpoint0_1Output = { readonly accepted: boolean }
export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E>
export interface HealthApi<E = never> {
readonly get: HealthGetOperation<E>
readonly stop: HealthStopOperation<E>
}
export type Endpoint1_0Output = { readonly urls: ReadonlyArray<string> }
@@ -6,8 +6,6 @@ import { HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../../contract"
import type {
Endpoint0_0Output,
Endpoint0_1Input,
Endpoint0_1Output,
Endpoint1_0Output,
Endpoint2_0Input,
Endpoint2_0Output,
@@ -248,12 +246,7 @@ const preserveStream =
const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
preserveEffect<Endpoint0_0Output>()(raw["health.get"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint0_1 = (raw: RawClient["server.health"]) => (input: Endpoint0_1Input) =>
preserveEffect<Endpoint0_1Output>()(
raw["health.stop"]({ payload: { instanceID: input["instanceID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) })
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) })
const Endpoint1_0 = (raw: RawClient["server.server"]) => () =>
preserveEffect<Endpoint1_0Output>()(raw["server.get"]({}).pipe(Effect.mapError(mapClientError)))
+13 -57
View File
@@ -87,7 +87,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
}
if (timeouts.count >= 3) {
yield* announce("missing")
yield* evict(info, options, timing)
yield* terminate(info, options, timing)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
@@ -100,7 +100,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
yield* announce("version-mismatch", service.version)
yield* kill(service, options, timing).pipe(Effect.ignore)
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
@@ -133,8 +133,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
const existing = yield* find(options)
if (existing !== undefined) yield* kill(existing, options, defaultEnsureTiming)
const info = yield* read(options.file)
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
})
function fallback() {
@@ -243,12 +243,6 @@ const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = fal
return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
})
// Health-checked lookup without the version gate: lifecycle operations must be
// able to see (and replace or stop) a server from a different version.
const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
return (yield* registered(options.file, true)).service
})
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
// discovery window.
const poll = (timing: EnsureTiming) =>
@@ -269,59 +263,21 @@ function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = yield* read(options.file)
if (current === undefined || !same(current, info)) return
yield* signal(info.pid, "SIGTERM")
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
if (Option.isSome(done)) return
if (Option.isNone(done)) {
const latest = yield* read(options.file)
if (latest === undefined || !same(latest, info)) return
yield* signal(info.pid, "SIGKILL")
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
}
const latest = yield* read(options.file)
if (latest === undefined || !same(latest, info)) return
yield* signal(info.pid, "SIGKILL")
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
})
const kill = Effect.fnUntraced(function* (
service: LocalService,
options: { readonly file?: string },
timing: EnsureTiming,
) {
const requested = yield* requestStop(service, timing.requestTimeout)
if (requested === "rejected") return
if (requested === "unsupported") {
// A stale registration may point at a reused PID. Authenticate again
// immediately before the legacy signal fallback.
const current = yield* find(options)
if (current === undefined || !same(current.info, service.info)) return
yield* signal(service.info.pid, "SIGTERM")
}
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
if (Option.isSome(done)) return
const latest = yield* find(options)
if (latest === undefined || !same(latest.info, service.info)) return
yield* signal(service.info.pid, "SIGKILL")
yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)))
})
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
if (service.info.id === undefined || service.legacy) return "unsupported" as const
const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/service/stop", service.info.url), {
method: "POST",
headers: { ...headers(service.endpoint), "content-type": "application/json" },
body: JSON.stringify({ instanceID: service.info.id }),
signal: AbortSignal.timeout(timeout),
}),
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
const decoded = decodeStopResponse(body)
if (!response.ok || Option.isNone(decoded) || !decoded.value.accepted) return "rejected" as const
return "accepted" as const
const fs = yield* FileSystem.FileSystem
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
})
/** Effect-based local service lifecycle operations. */
@@ -1,7 +1,5 @@
import type {
HealthGetOutput,
HealthStopInput,
HealthStopOutput,
ServerGetOutput,
LocationGetInput,
LocationGetOutput,
@@ -367,18 +365,6 @@ export function make(options: ClientOptions) {
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
stop: (input: HealthStopInput, requestOptions?: RequestOptions) =>
request<HealthStopOutput>(
{
method: "POST",
path: `/api/service/stop`,
body: { instanceID: input["instanceID"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
server: {
get: (requestOptions?: RequestOptions) =>
@@ -2,8 +2,6 @@ export type JsonValue = null | boolean | number | string | Array<JsonValue> | {
export type ServiceHealth = { healthy: true; version: string; pid: number }
export type ServiceStopResponse = { accepted: boolean }
export type ModelRef = { id: string; providerID: string; variant?: string }
export type ProviderSettings = { [x: string]: any }
@@ -12,7 +10,11 @@ export type AgentColor = string
export type PermissionEffect = "allow" | "deny" | "ask"
export type PluginInfo = { id: string }
export type PluginSource =
| { type: "builtin" }
| { type: "package"; package: string }
| { type: "local"; path: string }
| { type: "sdk" }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
@@ -198,6 +200,10 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PluginInfo =
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
export type TokenUsageInfo = {
input: number
output: number
@@ -2273,10 +2279,6 @@ export const isWorktreeError = (value: unknown): value is WorktreeError =>
export type HealthGetOutput = ServiceHealth
export type HealthStopInput = { readonly instanceID: { readonly instanceID: string }["instanceID"] }
export type HealthStopOutput = ServiceStopResponse
export type ServerGetOutput = { urls: Array<string> }
export type LocationGetInput = {
+14 -46
View File
@@ -1,4 +1,4 @@
import { readFile } from "node:fs/promises"
import { readFile, rm } from "node:fs/promises"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
@@ -10,7 +10,7 @@ import {
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
import type { ServiceHealth } from "./generated/types.js"
export * from "../service.js"
@@ -68,7 +68,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
}
if (timeouts.count >= 3) {
announce("missing")
await evict(registration.info, options, timing)
await terminate(registration.info, options, timing)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
@@ -82,7 +82,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
announce("version-mismatch", service.version)
await kill(service, options, timing).catch(() => undefined)
await terminate(service.info, options, timing).catch(() => undefined)
lastSpawn = 0
}
} else {
@@ -110,8 +110,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
const existing = await find(options)
if (existing !== undefined) await kill(existing, options, defaultEnsureTiming)
const info = await read(options.file)
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
}
function fallback() {
@@ -199,10 +199,6 @@ async function registered(file?: string, allowLegacy = false, timeout?: number)
return { info, ...(await probeResult(info, allowLegacy, timeout)) }
}
async function find(options: { readonly file?: string }) {
return (await registered(options.file, true)).service
}
function signal(pid: number, name: NodeJS.Signals) {
try {
process.kill(pid, name)
@@ -230,47 +226,19 @@ function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
async function evict(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
async function terminate(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = await read(options.file)
if (current === undefined || !same(current, info)) return
signal(info.pid, "SIGTERM")
if (await waitUntilStopped(info.pid, timing)) return
if (!(await waitUntilStopped(info.pid, timing))) {
const latest = await read(options.file)
if (latest === undefined || !same(latest, info)) return
signal(info.pid, "SIGKILL")
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
}
const latest = await read(options.file)
if (latest === undefined || !same(latest, info)) return
signal(info.pid, "SIGKILL")
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
}
async function kill(service: LocalService, options: { readonly file?: string }, timing: EnsureTiming) {
const requested = await requestStop(service, timing.requestTimeout)
if (requested === "rejected") return
if (requested === "unsupported") {
const current = await find(options)
if (current === undefined || !same(current.info, service.info)) return
signal(service.info.pid, "SIGTERM")
}
if (await waitUntilStopped(service.info.pid, timing)) return
const latest = await find(options)
if (latest === undefined || !same(latest.info, service.info)) return
signal(service.info.pid, "SIGKILL")
if (!(await waitUntilStopped(service.info.pid, timing)))
throw new Error(`Server process ${service.info.pid} is still running`)
}
async function requestStop(service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
if (service.info.id === undefined || service.legacy) return "unsupported" as const
const response = await fetch(new URL("/api/service/stop", service.info.url), {
method: "POST",
headers: { ...headers(service.endpoint), "content-type": "application/json" },
body: JSON.stringify({ instanceID: service.info.id }),
signal: AbortSignal.timeout(timeout),
}).catch(() => undefined)
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined
if (!response.ok || body?.accepted !== true) return "rejected" as const
return "accepted" as const
await rm(options.file ?? fallback(), { force: true })
}
function delay(milliseconds: number) {
+6 -16
View File
@@ -28,7 +28,7 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
let requests = 0
let version = "test"
if (mode === "old" || mode === "reject-stop") version = "old"
if (mode === "old") version = "old"
if (mode === "incompatible") version = "1.9.0"
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
const id = crypto.randomUUID()
@@ -36,17 +36,6 @@ const server = Bun.serve({
port: 0,
async fetch(request) {
const pathname = new URL(request.url).pathname
if (pathname === "/api/service/stop" && mode === "reject-stop") {
await appendFile(registration + ".stop-attempts", process.pid + "\n")
return Response.json({ accepted: false })
}
if (pathname === "/api/service/stop" && mode === "graceful") {
const body = await request.json()
if (typeof body !== "object" || body === null || body.instanceID !== id) return Response.json({ accepted: false })
await writeFile(registration + ".stop", JSON.stringify(body))
setTimeout(shutdown, 25)
return Response.json({ accepted: true })
}
if (pathname !== "/api/health") return new Response(null, { status: 404 })
requests += 1
if (mode === "starting") await writeFile(registration + ".health-request", "")
@@ -63,7 +52,7 @@ const server = Bun.serve({
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
if (mode === "starting" || mode === "graceful")
return Response.json({ healthy: true, version, pid: process.pid })
return Response.json({ healthy: true, version, pid: process.pid })
},
@@ -81,9 +70,10 @@ await writeFile(
)
await rename(registration + ".tmp", registration)
function shutdown() {
async function shutdown(signal?: NodeJS.Signals) {
if (signal !== undefined) await writeFile(registration + ".signal", signal)
server.stop(true)
process.exit()
}
process.on("SIGTERM", shutdown)
process.on("SIGINT", shutdown)
process.on("SIGTERM", () => void shutdown("SIGTERM"))
process.on("SIGINT", () => void shutdown("SIGINT"))
+3 -3
View File
@@ -126,13 +126,13 @@ test("evicts an unresponsive registered service before starting its replacement"
await waitForExit(replacement.pid)
})
test("requests graceful stop of the exact service instance", async () => {
test("signals the registered service process", async () => {
const registration = await setup("graceful")
const info = await Bun.file(registration).json()
await Service.stop({ file: registration })
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await Bun.file(registration).exists()).toBe(false)
})
async function setup(mode: string) {
-16
View File
@@ -191,22 +191,6 @@ test("integration connections optionally submit a form answer", async () => {
expect(await requests[3].json()).toEqual({ methodID: "device" })
})
test("health.stop sends exact replacement identity", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ accepted: true })
},
})
expect(await client.health.stop({ instanceID: "instance" })).toEqual({ accepted: true })
expect(request?.method).toBe("POST")
expect(request?.url).toBe("http://localhost:3000/api/service/stop")
expect(await request?.json()).toEqual({ instanceID: "instance" })
})
test("MCP resource catalog uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
+14 -29
View File
@@ -143,40 +143,36 @@ test("evicts an unresponsive registered service before starting its replacement"
await waitForExit(replacement.pid)
})
test("requests graceful stop of the exact service instance", async () => {
test("signals an unresponsive registered service process", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const process = spawn(registration, "graceful")
const process = spawn(registration, "hanging")
await waitForFile(registration)
const info = await Bun.file(registration).json()
await run(Service.stop({ file: registration }))
await process.exited
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await Bun.file(registration).exists()).toBe(false)
})
test("does not spawn contenders while an incompatible service rejects replacement", async () => {
test("signals an incompatible service before starting its replacement", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const contender = join(directory, "contender.json")
const existing = spawn(registration, "reject-stop")
const existing = spawn(registration, "old")
await waitForFile(registration)
const controller = new AbortController()
const starting = Effect.runPromise(
const endpoint = await run(
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, contender, "record-start"],
}).pipe(Effect.provide(NodeFileSystem.layer)),
{ signal: controller.signal },
command: [process.execPath, fixture, registration, "delayed", "10"],
}),
)
const replacement = await Bun.file(registration).json()
await waitForLines(registration + ".stop-attempts", 2)
controller.abort()
await starting.catch(() => undefined)
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect(existing.exitCode).toBe(null)
expect(await existing.exited).toBe(0)
expect(endpoint.url).toBe(replacement.url)
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
})
test("a legacy health response is still replaced", async () => {
@@ -344,17 +340,6 @@ async function waitForFile(file: string) {
throw new Error(`Timed out waiting for ${file}`)
}
async function waitForLines(file: string, count: number) {
for (let attempt = 0; attempt < 600; attempt++) {
const text = await Bun.file(file)
.text()
.catch(() => "")
if (text.trim().split("\n").length >= count) return
await Bun.sleep(5)
}
throw new Error(`Timed out waiting for ${count} lines in ${file}`)
}
async function health(url: string) {
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
}
+1 -1
View File
@@ -149,7 +149,7 @@ export function normalize(input: unknown): Result {
"agents",
migratedAgents,
nativeAgents,
isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
migratedSmallModel !== undefined || isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
diagnostics,
)
+47 -13
View File
@@ -1,10 +1,10 @@
export * as Plugin from "./plugin.js"
export { Event, ID, Info } from "@opencode-ai/schema/plugin"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "./app.js"
import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
import { Catalog } from "./catalog.js"
@@ -23,11 +23,17 @@ import { Tool } from "./tool.js"
import { PluginHooks } from "./plugin/hooks.js"
export interface Interface {
readonly activate: (plugins: readonly Versioned[]) => Effect.Effect<void>
readonly activate: (
plugins: readonly Versioned[],
failures?: readonly Extract<Plugin.Info, { readonly status: "failed" }>[],
) => Effect.Effect<void>
readonly list: () => Effect.Effect<Plugin.Info[]>
}
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & { readonly version: string }
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & {
readonly version: string
readonly source?: Plugin.Source
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
@@ -38,6 +44,7 @@ const layer = Layer.effect(
const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = []
let host: Parameters<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>[0]
const load = Effect.fnUntraced(function* (plugin: Versioned) {
@@ -56,15 +63,18 @@ const layer = Layer.effect(
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit,
)
if (Exit.isSuccess(loaded)) return child
if (Exit.isSuccess(loaded)) return { scope: child } as const
yield* Effect.logWarning("failed to load plugin", {
"plugin.id": plugin.id,
cause: loaded.cause,
})
return undefined
return { error: Cause.pretty(loaded.cause) } as const
})
const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Versioned[]) {
const activate = Effect.fn("Plugin.activate")(function* (
plugins: readonly Versioned[],
failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[] = [],
) {
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
const ids = new Set<Plugin.ID>()
for (const definition of definitions) {
@@ -85,26 +95,40 @@ const layer = Layer.effect(
const candidate = next[index]
return definition.id === candidate?.id && definition.version === candidate.version
})
)
) {
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
return
}
yield* State.batch(
Effect.gen(function* () {
const nextInventory: Plugin.Info[] = []
for (const definition of definitions) {
const previous = active.get(definition.id)
active.delete(definition.id)
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
const loaded = yield* load(definition)
if (loaded) {
active.set(definition.id, { plugin: definition, scope: loaded })
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
}
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
})
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored) {
active.set(definition.id, { plugin: previous.plugin, scope: restored })
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
@@ -119,6 +143,7 @@ const layer = Layer.effect(
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
discard: true,
})
inventory = [...nextInventory, ...failures]
}),
)
yield* bus.publish(Plugin.Event.Updated, {})
@@ -136,7 +161,7 @@ const layer = Layer.effect(
const service = Service.of({
activate,
list: Effect.fn("Plugin.list")(function* () {
return Array.from(active.keys()).map((id) => ({ id }))
return inventory
}),
})
host = yield* PluginHost.make(service)
@@ -144,6 +169,15 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Versioned): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
status: "active",
tui: plugin.tui ?? false,
}
}
export const node = makeLocationNode({
service: Service,
layer,
@@ -7,7 +7,7 @@ import { Effect } from "effect"
const urls = [/^https:\/\/mcp\.cloudflare\.com\/mcp$/, /^https:\/\/executor\.sh\/[^/]+\/mcp$/]
export const Plugin = define({
id: "opencode.mcp.codemode-exclusion",
id: "opencode.mcp.codemode.exclusion",
effect: Effect.fn(function* (ctx) {
yield* ctx.mcp.transform((draft) => {
for (const [, server] of draft.list()) {
+8 -3
View File
@@ -6,7 +6,7 @@ import { ModelsDev } from "../models-dev.js"
import { Provider } from "../provider.js"
export const ModelsDevPlugin = define({
id: "opencode.models-dev",
id: "opencode.models.dev",
effect: Effect.fn(function* (ctx) {
const modelsDev = yield* ModelsDev.Service
const bus = yield* Bus.Service
@@ -55,8 +55,13 @@ export const ModelsDevPlugin = define({
})
function environmentNames(provider: ModelsDev.Snapshot) {
if (provider.info.id !== Provider.ID.azure) return [...provider.environment]
return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"]
if (provider.info.id === Provider.ID.azure)
return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"]
// models.dev advertises project, location, and the ADC credentials file path for
// Vertex. Those configure Google auth rather than carrying a key, so only the
// Express Mode key may become a credential; GoogleVertexPlugin handles activation.
if (provider.info.id === Provider.ID.googleVertex) return ["GOOGLE_VERTEX_API_KEY"]
return [...provider.environment]
}
function snapshots(data: readonly ModelsDev.Snapshot[]) {
@@ -60,7 +60,7 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
}
export const AmazonBedrockPlugin = define({
id: "opencode.provider.amazon-bedrock",
id: "opencode.provider.amazon.bedrock",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
@@ -10,7 +10,7 @@ import { configuredSettings } from "./configured.js"
const providerID = Provider.ID.make("cloudflare-ai-gateway")
export const CloudflareAIGatewayPlugin = define({
id: "opencode.provider.cloudflare-ai-gateway",
id: "opencode.provider.cloudflare.ai.gateway",
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(providerID)
const form = iife(() => {
@@ -10,7 +10,7 @@ import { configuredSettings } from "./configured.js"
const providerID = Provider.ID.make("cloudflare-workers-ai")
export const CloudflareWorkersAIPlugin = define({
id: "opencode.provider.cloudflare-workers-ai",
id: "opencode.provider.cloudflare.workers.ai",
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(providerID)
const form = iife(() => {
@@ -146,7 +146,7 @@ const oauth = (app: App.Info) =>
}) satisfies IntegrationOAuthMethodRegistration
export const GithubCopilotPlugin = define({
id: "opencode.provider.github-copilot",
id: "opencode.provider.github.copilot",
effect: Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
@@ -55,7 +55,7 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
}
export const GoogleVertexPlugin = define({
id: "opencode.provider.google-vertex",
id: "opencode.provider.google.vertex",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
@@ -71,6 +71,9 @@ export const GoogleVertexPlugin = define({
const project = resolveProject(item.provider.settings ?? {})
const location = String(resolveLocation(item.provider.settings ?? {}))
evt.provider.update(item.provider.id, (provider) => {
// Vertex authenticates through ADC rather than a key credential, so a
// resolvable project is what makes the provider usable.
if (project && provider.activation === "auto") provider.activation = "enabled"
provider.settings = {
...provider.settings,
...(project ? { project } : {}),
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const OpenAICompatiblePlugin = define({
id: "opencode.provider.openai-compatible",
id: "opencode.provider.openai.compatible",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
@@ -6,7 +6,7 @@ import { Provider } from "../../provider.js"
import { importModule } from "@opencode-ai/util/runtime-import"
export const SapAICorePlugin = define({
id: "opencode.provider.sap-ai-core",
id: "opencode.provider.sap.ai.core",
effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service
yield* ctx.aisdk.hook(
@@ -65,7 +65,7 @@ export function cortexFetch(upstream: FetchLike = fetch) {
}
export const SnowflakeCortexPlugin = define({
id: "opencode.provider.snowflake-cortex",
id: "opencode.provider.snowflake.cortex",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
+1 -1
View File
@@ -35,7 +35,7 @@ export const layer = Layer.effect(
return Service.of({
register: (plugin) =>
Effect.sync(() => {
plugins.set(plugin.id, { ...plugin, version: String(++revision) })
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
all: () => [...plugins.values()],
})
+45 -13
View File
@@ -2,7 +2,7 @@ export * as PluginSupervisor from "./supervisor.js"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
import { Cause, Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source.js"
@@ -19,12 +19,14 @@ const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
effect: Schema.declare<PluginDefinition["effect"]>(
(input): input is PluginDefinition["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
@@ -42,10 +44,12 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Versioned>()
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
const plugins = () => [...definitions, ...packages.values()]
for (const operation of operations) {
if (operation.type === "remove") {
if (operation.target === "*") failures.clear()
plugins()
.filter((plugin) => matches(operation.target, plugin.id))
.forEach((plugin) => enabled.delete(plugin.id))
@@ -65,21 +69,35 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const plugin = yield* load(operation).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(Effect.as(undefined)),
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
)
if (!plugin) continue
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
status: "failed",
error: plugin.error,
tui: false,
})
continue
}
failures.delete(operation.target)
const previous = packages.get(operation.target)
if (previous) enabled.delete(previous.id)
packages.set(operation.target, plugin)
enabled.add(plugin.id)
}
return [
...pre.filter((plugin) => enabled.has(plugin.id)),
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
]
return {
plugins: [
...pre.filter((plugin) => enabled.has(plugin.id)),
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
}
})
const load = Effect.fn("PluginSupervisor.load")(function* (
@@ -89,7 +107,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
if (!entrypoint) return
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const source =
operation.mtime === undefined
@@ -103,7 +121,9 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
tui: plugin.tui,
version: JSON.stringify(operation),
source: pluginSource(operation.target),
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Plugin.Versioned
})
@@ -129,13 +149,20 @@ export const layer = Layer.effect(
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed SDK plugins in boot order.
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
const pre = [
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
...sdk.all(),
]
const post = internal.post.map((plugin) => ({
...plugin,
version: "internal",
source: { type: "builtin" as const },
}))
const operations = yield* sources.operations()
// Apply config operations and load enabled package plugins into one ordered generation.
const plugins = yield* resolve(pre, post, operations)
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(plugins)
yield* registry.activate(resolved.plugins, resolved.failures)
})
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
@@ -172,4 +199,9 @@ const nodeDeps = [
PluginInternal.requirements,
] as const
function pluginSource(target: string): Plugin.Source {
if (path.isAbsolute(target)) return { type: "local", path: target }
return { type: "package", package: target }
}
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
+1 -1
View File
@@ -33,7 +33,7 @@ export const Plugins = [OpenAIPlugin, GooglePlugin, AnthropicPlugin, KimiPlugin,
function make(id: string, select: (modelID: string) => string | undefined) {
return define({
id: `opencode.system-prompt.${id}`,
id: `opencode.prompt.${id}`,
effect: Effect.fn(`SystemPromptPlugin.${id}`)(function* (ctx) {
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
+11
View File
@@ -48,6 +48,17 @@ const builtins = new Map<string, () => Promise<unknown>>([
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")],
["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")],
["@opencode-ai/ai/providers/google-vertex", () => import("@opencode-ai/ai/providers/google-vertex")],
["@opencode-ai/ai/providers/google-vertex/gemini", () => import("@opencode-ai/ai/providers/google-vertex/gemini")],
["@opencode-ai/ai/providers/google-vertex/chat", () => import("@opencode-ai/ai/providers/google-vertex/chat")],
[
"@opencode-ai/ai/providers/google-vertex/responses",
() => import("@opencode-ai/ai/providers/google-vertex/responses"),
],
[
"@opencode-ai/ai/providers/google-vertex/messages",
() => import("@opencode-ai/ai/providers/google-vertex/messages"),
],
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
+2 -1
View File
@@ -231,7 +231,8 @@ export const layer = Layer.effect(
http: {
headers: SessionModelHeaders.make(session, app),
},
promptCacheKey: SessionPromptCacheKey.make(session.id),
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
promptCacheKey: SessionPromptCacheKey.make(session.fork?.sessionID ?? session.id),
system: context.system,
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
+6 -5
View File
@@ -5,7 +5,8 @@ import { Money } from "@opencode-ai/schema/money"
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
import type { Model } from "../model.js"
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
const finite = (value: number) => (Number.isFinite(value) ? value : 0)
const safe = (value: number | undefined) => Math.max(0, finite(value ?? 0))
export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
input: safe(usage?.nonCachedInputTokens),
@@ -26,10 +27,10 @@ export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info)
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
if (!cost) return Money.USD.zero
return Money.USD.make(
(usage.input * cost.input +
(usage.output + usage.reasoning) * cost.output +
usage.cache.read * cost.cache.read +
usage.cache.write * cost.cache.write) /
(usage.input * finite(cost.input) +
(usage.output + usage.reasoning) * finite(cost.output) +
usage.cache.read * finite(cost.cache.read) +
usage.cache.write * finite(cost.cache.write)) /
1_000_000,
)
}
@@ -150,6 +150,16 @@ describe("ConfigNormalize", () => {
})
test("migrates the legacy small model to the title agent", () => {
const result = normalized({ small_model: "anthropic/claude-haiku-4-5" })
expect(result.encoded.agents).toEqual({
title: {
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
},
})
expect(result.diagnostics).toEqual([])
})
test("merges the legacy small model with the title agent", () => {
const result = normalized({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
+23 -2
View File
@@ -44,7 +44,9 @@ describe("PluginSupervisor config", () => {
const plugins = yield* Plugin.Service
yield* ready()
expect(
(yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")),
(yield* plugins.list())
.flatMap((plugin) => (plugin.id ? [plugin.id] : []))
.filter((id) => id.startsWith("opencode.provider.")),
).toEqual([Plugin.ID.make("opencode.provider.openai")])
}),
),
@@ -64,10 +66,20 @@ describe("PluginSupervisor config", () => {
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded from config",
mode: "subagent",
})
expect((yield* plugins.list()).find((plugin) => plugin.id === "config-promise-plugin")).toEqual({
id: Plugin.ID.make("config-promise-plugin"),
source: {
type: "local",
path: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
},
status: "active",
tui: true,
})
}),
),
)
@@ -143,6 +155,7 @@ describe("PluginSupervisor config", () => {
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded after invalid plugins",
})
@@ -150,6 +163,12 @@ describe("PluginSupervisor config", () => {
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
])
expect(
(yield* plugins.list()).filter((plugin) => plugin.status === "failed").map((plugin) => plugin.source),
).toEqual([
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts") },
])
}),
).pipe(Effect.provide(Logger.layer([logger])))
})
@@ -246,10 +265,12 @@ describe("PluginSupervisor config", () => {
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
const inventory = yield* plugins.list()
const ids = inventory.map((plugin) => String(plugin.id))
expect(ids).toContain("opencode.agent")
expect(ids).toContain("static-sdk")
expect(ids).not.toContain("config-promise-plugin")
expect(inventory.find((plugin) => plugin.id === "static-sdk")?.source).toEqual({ type: "sdk" })
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
+10 -2
View File
@@ -428,10 +428,18 @@ describe("LocationServiceMap", () => {
),
)
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* registry.list()).length === 0) break
if ((yield* registry.list()).some((plugin) => plugin.status === "failed")) break
yield* Effect.sleep("20 millis")
}
expect(yield* registry.list()).toEqual([])
expect(yield* registry.list()).toEqual([
{
id: Plugin.ID.make("failing-plugin"),
source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts") },
status: "failed",
error: expect.stringContaining("plugin failed"),
tui: false,
},
])
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
for (let attempt = 0; attempt < 100; attempt++) {
+48 -13
View File
@@ -89,13 +89,7 @@ describe("Plugin", () => {
yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie)
yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie)
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
expect(routed).toEqual([
"add:/target",
"remove:/target",
"connect:/target",
"disconnect:/target",
"list:/target",
])
expect(routed).toEqual(["add:/target", "remove:/target", "connect:/target", "disconnect:/target", "list:/target"])
}),
)
@@ -138,9 +132,22 @@ describe("Plugin", () => {
expect(updates).toBe(2)
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
yield* plugins.activate(
[versioned(managed(), "2")],
[
{
source: { type: "package", package: "broken" },
status: "failed",
error: "failed to resolve",
tui: false,
},
],
)
expect(updates).toBe(3)
yield* plugins.activate([])
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
expect(updates).toBe(3)
expect(updates).toBe(4)
yield* unsubscribe
}),
)
@@ -160,7 +167,7 @@ describe("Plugin", () => {
.pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
expect(yield* plugins.list()).toEqual([{ id: active }])
expect(yield* plugins.list()).toEqual([{ id: active, source: { type: "builtin" }, status: "active", tui: false }])
}),
)
@@ -189,12 +196,24 @@ describe("Plugin", () => {
})
yield* plugins.activate([versioned(good), versioned(bad)])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
{
id: Plugin.ID.make("bad"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("materialization failed"),
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
fail = false
yield* plugins.activate([versioned(good), versioned(bad, "2")])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }, { id: Plugin.ID.make("bad") }])
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
{ id: Plugin.ID.make("bad"), source: { type: "builtin" }, status: "active", tui: false },
])
}),
)
@@ -229,7 +248,15 @@ describe("Plugin", () => {
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
}),
)
@@ -261,7 +288,15 @@ describe("Plugin", () => {
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
}),
)
@@ -2,6 +2,7 @@ import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "config-promise-plugin",
tui: true,
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("configured", (agent) => {
+42 -2
View File
@@ -421,8 +421,48 @@ describe("ModelsDevPlugin", () => {
expect(yield* integrations.get(Integration.ID.make("google-vertex"))).toBeDefined()
expect(yield* integrations.get(Integration.ID.make("azure-cognitive-services"))).toBeUndefined()
expect(yield* integrations.get(Integration.ID.make("google-vertex-anthropic"))).toBeUndefined()
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.azure-cognitive-services")
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.google-vertex-anthropic")
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.azure.cognitive.services")
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.google.vertex.anthropic")
}),
)
it.effect("advertises only key-bearing Google Vertex environment variables", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
).pipe(
Effect.provideService(
ModelsDev.Service,
ModelsDev.Service.of({
get: () =>
Effect.succeed([
{
info: {
id: Provider.ID.make("google-vertex"),
name: "Google Vertex",
activation: "auto",
package: Provider.aisdk("@ai-sdk/google-vertex"),
},
environment: ["GOOGLE_VERTEX_PROJECT", "GOOGLE_VERTEX_LOCATION", "GOOGLE_APPLICATION_CREDENTIALS"],
models: [],
},
] satisfies readonly ModelsDev.Snapshot[]),
refresh: () => Effect.void,
}),
),
)
// Vertex authenticates through ADC; project, location, and the credentials
// file path are configuration, not API keys.
expect(yield* integrations.get(Integration.ID.make("google-vertex"))).toMatchObject({
methods: [{ type: "key" }, { type: "env", names: ["GOOGLE_VERTEX_API_KEY"] }],
})
}),
)
@@ -141,6 +141,52 @@ describe("GoogleVertexPlugin", () => {
),
)
it.effect("enables the provider when a project resolves and leaves it automatic otherwise", () =>
withEnv(
{
GOOGLE_VERTEX_PROJECT: undefined,
GOOGLE_CLOUD_PROJECT: undefined,
GCP_PROJECT: undefined,
GCLOUD_PROJECT: undefined,
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("google-vertex"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/google-vertex")
}),
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(Provider.ID.make("google-vertex"))).activation).toBe("auto")
}),
),
)
it.effect("enables the provider when a project resolves from env", () =>
withEnv(
{
GOOGLE_VERTEX_PROJECT: undefined,
GOOGLE_CLOUD_PROJECT: "adc-project",
GCP_PROJECT: undefined,
GCLOUD_PROJECT: undefined,
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("google-vertex"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/google-vertex")
}),
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(Provider.ID.make("google-vertex"))).activation).toBe("enabled")
}),
),
)
it.effect("resolves the advertised GOOGLE_VERTEX_PROJECT env for provider updates and SDKs", () =>
withEnv(
{
@@ -43,10 +43,10 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
describe("SnowflakeCortexPlugin", () => {
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
Effect.sync(() => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake-cortex")
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake.cortex")
const ids = ProviderPlugins.map((p) => p.id)
expect(ids.indexOf("opencode.provider.snowflake-cortex")).toBeLessThan(
ids.indexOf("opencode.provider.openai-compatible"),
expect(ids.indexOf("opencode.provider.snowflake.cortex")).toBeLessThan(
ids.indexOf("opencode.provider.openai.compatible"),
)
}),
)
@@ -48,12 +48,12 @@ describe("SystemPromptPlugin", () => {
test("uses granular IDs with a common prefix", () => {
expect(SystemPromptPlugin.Plugins.map((plugin) => plugin.id)).toEqual([
"opencode.system-prompt.openai",
"opencode.system-prompt.google",
"opencode.system-prompt.anthropic",
"opencode.system-prompt.kimi",
"opencode.system-prompt.arcee",
"opencode.system-prompt.meta",
"opencode.prompt.openai",
"opencode.prompt.google",
"opencode.prompt.anthropic",
"opencode.prompt.kimi",
"opencode.prompt.arcee",
"opencode.prompt.meta",
])
})
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Provider } from "@opencode-ai/core/provider"
describe("Provider", () => {
test("loads Vertex native provider entrypoints", async () => {
const packages = [
"@opencode-ai/ai/providers/google-vertex",
"@opencode-ai/ai/providers/google-vertex/gemini",
"@opencode-ai/ai/providers/google-vertex/chat",
"@opencode-ai/ai/providers/google-vertex/responses",
"@opencode-ai/ai/providers/google-vertex/messages",
]
for (const specifier of packages) {
const loaded = await Effect.runPromise(Provider.loadPackage(specifier))
expect(loaded.model).toBeFunction()
}
})
})
+23
View File
@@ -197,6 +197,29 @@ test("calculates step cost using the matching context tier", () => {
).toBeCloseTo(0.0002926)
})
test("ignores malformed model cost fields", () => {
const costs = [
{
input: Money.USDPerMillionTokens.make(3),
output: Money.USDPerMillionTokens.make(15),
cache: {
read: Money.USDPerMillionTokens.make(0.3),
write: Money.USDPerMillionTokens.make(3.75),
},
},
]
Object.assign(costs[0], { input: {} })
expect(
SessionUsage.calculateCost(costs, {
input: 1_000_000,
output: 100_000,
reasoning: 0,
cache: { read: 0, write: 0 },
}),
).toBe(Money.USD.make(1.5))
})
test("does not apply an ineligible tier without base pricing", () => {
expect(
SessionUsage.calculateCost(
+1
View File
@@ -37,6 +37,7 @@ export interface Context {
export interface Plugin<R = Scope.Scope> {
readonly id: string
readonly tui?: boolean
readonly effect: (context: Context) => Effect.Effect<void, never, R>
}
+1
View File
@@ -67,6 +67,7 @@ function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
export function fromPromise(plugin: Plugin) {
return define({
id: plugin.id,
tui: plugin.tui,
effect: (host) =>
Effect.gen(function* () {
const [{ ClientApi }, { OpenCodeEvent }] = yield* Effect.promise(() =>
+1
View File
@@ -38,6 +38,7 @@ export type Cleanup = () => Promise<void> | void
export interface Plugin {
readonly id: string
readonly tui?: boolean
readonly setup: (context: Context) => Promise<Cleanup | void> | Cleanup | void
}
-72
View File
@@ -48,58 +48,6 @@
"summary": "Check server health"
}
},
"/api/service/stop": {
"post": {
"tags": ["health"],
"operationId": "v2.health.stop",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "ServiceStopResponse",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceStopResponse"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Request graceful shutdown of one exact managed server instance.",
"summary": "Stop the managed server",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceStopRequest"
}
}
},
"required": true
}
}
},
"/api/server": {
"get": {
"tags": ["server"],
@@ -9783,26 +9731,6 @@
"required": ["_tag", "message"],
"additionalProperties": false
},
"ServiceStopRequest": {
"type": "object",
"properties": {
"instanceID": {
"type": "string"
}
},
"required": ["instanceID"],
"additionalProperties": false
},
"ServiceStopResponse": {
"type": "object",
"properties": {
"accepted": {
"type": "boolean"
}
},
"required": ["accepted"],
"additionalProperties": false
},
"Union_1": {
"anyOf": [
{
-22
View File
@@ -9,16 +9,6 @@ export namespace ServiceStatus {
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
}).annotate({ identifier: "ServiceHealth" })
export type Health = typeof Health.Type
export const StopRequest = Schema.Struct({
instanceID: Schema.String,
}).annotate({ identifier: "ServiceStopRequest" })
export type StopRequest = typeof StopRequest.Type
export const StopResponse = Schema.Struct({
accepted: Schema.Boolean,
}).annotate({ identifier: "ServiceStopResponse" })
export type StopResponse = typeof StopResponse.Type
}
export const HealthGroup = HttpApiGroup.make("server.health")
@@ -33,16 +23,4 @@ export const HealthGroup = HttpApiGroup.make("server.health")
}),
),
)
.add(
HttpApiEndpoint.post("health.stop", "/api/service/stop", {
payload: ServiceStatus.StopRequest,
success: ServiceStatus.StopResponse,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.health.stop",
summary: "Stop the managed server",
description: "Request graceful shutdown of one exact managed server instance.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "health" }))
+1 -1
View File
@@ -15,7 +15,7 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
OpenApi.annotations({
identifier: "v2.plugin.list",
summary: "List plugins",
description: "Retrieve currently loaded plugins.",
description: "Retrieve enabled server plugins and their current status.",
}),
),
)
+25 -4
View File
@@ -2,14 +2,35 @@ export * as Plugin from "./plugin.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { optional } from "./schema.js"
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
export type ID = typeof ID.Type
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
id: ID,
}).annotate({ identifier: "Plugin.Info" })
export const Source = Schema.Union([
Schema.Struct({ type: Schema.Literal("builtin") }),
Schema.Struct({ type: Schema.Literal("package"), package: Schema.String }),
Schema.Struct({ type: Schema.Literal("local"), path: Schema.String }),
Schema.Struct({ type: Schema.Literal("sdk") }),
]).annotate({ identifier: "Plugin.Source" })
export type Source = typeof Source.Type
export const Info = Schema.Union([
Schema.Struct({
id: ID,
source: Source,
status: Schema.Literal("active"),
tui: Schema.Boolean,
}),
Schema.Struct({
id: ID.pipe(optional),
source: Source,
status: Schema.Literal("failed"),
error: Schema.String,
tui: Schema.Boolean,
}),
]).annotate({ identifier: "Plugin.Info" })
export type Info = typeof Info.Type
const Added = ephemeral({
type: "plugin.added",
+11 -13
View File
@@ -4,17 +4,15 @@ import { Api } from "../api"
import { ServerInfo } from "../server-info"
export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) =>
handlers
.handle("health.get", () =>
Effect.gen(function* () {
const info = yield* ServerInfo.Service
return {
healthy: true as const,
version: info.app.version ?? "unknown",
// Runtimes without OS process identity (workerd) report 0.
pid: process.pid ?? 0,
}
}),
)
.handle("health.stop", () => Effect.succeed({ accepted: false })),
handlers.handle("health.get", () =>
Effect.gen(function* () {
const info = yield* ServerInfo.Service
return {
healthy: true as const,
version: info.app.version ?? "unknown",
// Runtimes without OS process identity (workerd) report 0.
pid: process.pid ?? 0,
}
}),
),
)
+6 -46
View File
@@ -1,12 +1,10 @@
export * as ServerProcess from "./process"
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
import { NodeHttpServer } from "@effect/platform-node"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect"
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Scope } from "effect"
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { randomUUID } from "node:crypto"
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
import { isAllowedCorsOrigin } from "./cors"
@@ -18,7 +16,6 @@ import { Status } from "./service-status"
import type { ServerOptions } from "./options"
export interface Lifecycle<E = never, R = never> {
readonly instanceID: string
readonly onListen: (
address: HttpServer.Address,
shutdown: Effect.Effect<void>,
@@ -51,16 +48,13 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
const hostname = options.hostname ?? "127.0.0.1"
const port = Option.fromNullishOr(options.port)
const shutdown = yield* Deferred.make<void>()
const status = yield* Status.make({
instanceID: lifecycle?.instanceID ?? randomUUID(),
managed: lifecycle !== undefined,
})
const status = yield* Status.make()
const bound = yield* listen({ hostname, port })
const application = yield* Ref.make(Option.none<App>())
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
yield* bound.http
.serve(
dispatch(password, status, application, shutdown, options.app?.version ?? "unknown").pipe(
dispatch(password, status, application, options.app?.version ?? "unknown").pipe(
HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }),
),
errorResponseLogger,
@@ -163,22 +157,15 @@ function dispatch(
password: string,
status: Status.Interface,
application: Ref.Ref<Option.Option<App>>,
shutdown: Deferred.Deferred<void>,
version: string,
): App {
const auth = ServerAuth.Config.of({ password: Option.some(password), username: "opencode" })
return Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
const lifecycle =
request.method === "GET" && url.pathname === "/api/health"
? "health"
: request.method === "POST" && url.pathname === "/api/service/stop"
? "stop"
: undefined
if (lifecycle !== undefined) {
if (request.method === "GET" && url.pathname === "/api/health") {
if (!(yield* authorizedRequest(request, auth))) return unauthorized()
return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void), version)
return yield* healthResponse(status, version)
}
const state = yield* status.current
const app = yield* Ref.get(application)
@@ -196,33 +183,6 @@ function unauthorized() {
})
}
const control = Effect.fnUntraced(function* (
request: HttpServerRequest.HttpServerRequest,
route: "health" | "stop",
status: Status.Interface,
stop: () => void,
version: string,
) {
if (route === "health") return yield* healthResponse(status, version)
const body = yield* request.json.pipe(Effect.option)
const input = Option.isSome(body) ? Schema.decodeUnknownOption(ServiceStatus.StopRequest)(body.value) : Option.none()
if (Option.isNone(input)) return HttpServerResponse.jsonUnsafe({ code: "invalid_request" }, { status: 400 })
const accepted = yield* status.requestStop(input.value)
if (accepted) {
const response = NodeHttpServerRequest.toServerResponse(request)
yield* Effect.sync(() => {
const complete = () => {
response.off("finish", complete)
response.off("close", complete)
stop()
}
response.once("finish", complete)
response.once("close", complete)
})
}
return HttpServerResponse.jsonUnsafe({ accepted })
})
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface, version: string) {
const state = yield* status.current
return HttpServerResponse.jsonUnsafe(
+1 -11
View File
@@ -1,6 +1,5 @@
export * as Status from "./service-status"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { Effect, Ref } from "effect"
export type State =
@@ -14,14 +13,9 @@ export interface Interface {
readonly ready: Effect.Effect<void>
readonly fail: Effect.Effect<void>
readonly beginStopping: Effect.Effect<void>
readonly requestStop: (request: ServiceStatus.StopRequest) => Effect.Effect<boolean>
}
export const make = Effect.fnUntraced(function* (options: {
readonly instanceID: string
readonly managed: boolean
readonly initial?: State
}) {
export const make = Effect.fnUntraced(function* (options: { readonly initial?: State } = {}) {
const current = yield* Ref.make(options.initial ?? ({ type: "starting" } satisfies State))
const beginStopping = Ref.update(current, (status) =>
status.type === "stopping" ? status : ({ type: "stopping" } satisfies State),
@@ -32,9 +26,5 @@ export const make = Effect.fnUntraced(function* (options: {
ready: Ref.update(current, (status) => (status.type === "starting" ? ({ type: "ready" } satisfies State) : status)),
fail: Ref.update(current, (status) => (status.type === "starting" ? ({ type: "failed" } satisfies State) : status)),
beginStopping,
requestStop: (request) => {
if (!options.managed || request.instanceID !== options.instanceID) return Effect.succeed(false)
return beginStopping.pipe(Effect.as(true))
},
} satisfies Interface
})
+4 -15
View File
@@ -5,7 +5,7 @@ import { Status } from "../src/service-status"
it.effect("moves from starting to ready", () =>
Effect.gen(function* () {
const status = yield* Status.make({ instanceID: "one", managed: false })
const status = yield* Status.make()
expect(yield* status.current).toEqual({ type: "starting" })
yield* status.ready
expect(yield* status.current).toEqual({ type: "ready" })
@@ -14,7 +14,7 @@ it.effect("moves from starting to ready", () =>
it.effect("keeps a startup failure until shutdown", () =>
Effect.gen(function* () {
const status = yield* Status.make({ instanceID: "one", managed: true })
const status = yield* Status.make()
yield* status.fail
yield* status.ready
yield* status.fail
@@ -22,24 +22,13 @@ it.effect("keeps a startup failure until shutdown", () =>
}),
)
it.effect("stops only the addressed managed instance", () =>
Effect.gen(function* () {
const status = yield* Status.make({ instanceID: "one", managed: true })
expect(yield* status.requestStop({ instanceID: "other" })).toBe(false)
expect(yield* status.current).toEqual({ type: "starting" })
expect(yield* status.requestStop({ instanceID: "one" })).toBe(true)
expect(yield* status.current).toEqual({ type: "stopping" })
}),
)
it.effect("keeps stopping after shutdown begins", () =>
Effect.gen(function* () {
const status = yield* Status.make({ instanceID: "one", managed: true })
const status = yield* Status.make()
yield* status.beginStopping
expect(yield* status.current).toEqual({ type: "stopping" })
expect(yield* status.requestStop({ instanceID: "one" })).toBe(true)
yield* status.beginStopping
expect(yield* status.current).toEqual({ type: "stopping" })
}),
)
@@ -1,16 +1,14 @@
import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
import { getScrollAcceleration } from "../util/scroll"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { useToast } from "../ui/toast"
export function DialogErrorDetails(props: { title: string; error: string; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
@@ -20,19 +18,21 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const [scrollable, setScrollable] = createSignal(false)
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
const [height, setHeight] = createSignal(1)
const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
let measure: (() => void) | undefined
onMount(() => dialog.setSize("large"))
createEffect(() => {
dimensions()
props.error
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
measure = () => {
measure = undefined
setScrollable(Boolean(scroll && scroll.scrollHeight > scroll.viewport.height))
if (!scroll) return
const next = Math.max(1, Math.min(maxHeight(), scroll.scrollHeight))
setHeight(next)
setScrollable(scroll.scrollHeight > next)
}
renderer.once(CliRenderEvents.FRAME, measure)
renderer.requestRender()
@@ -61,15 +61,15 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
if (!scrollable()) return
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-height())
if (event.name === "pagedown") return scroll?.scrollBy(height())
if (event.name === "pageup") return scroll?.scrollBy(-maxHeight())
if (event.name === "pagedown") return scroll?.scrollBy(maxHeight())
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<box paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
@@ -77,7 +77,6 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
esc
</text>
</box>
<text fg={theme.text.feedback.error.default}> Failed</text>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
@@ -96,7 +95,7 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
</text>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text>
<span style={{ fg: theme.text.default }}>
<b>{scrollable() ? "↑/↓" : ""}</b>
@@ -23,6 +23,7 @@ import { useStorage } from "../context/storage"
import { useConfig } from "../config"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { projectName } from "../util/project"
import { useLocation } from "../context/location"
export function DialogSessionList() {
const dialog = useDialog()
@@ -36,6 +37,7 @@ export function DialogSessionList() {
const sessionTabs = useSessionTabs()
const config = useConfig().data
const toast = useToast()
const activeLocation = useLocation()
const [filter, setFilter] = createSignal("")
const shortcuts = Keymap.useShortcuts()
const [search, setSearch] = createDebouncedSignal("", 150)
@@ -44,13 +46,21 @@ export function DialogSessionList() {
initial: { allProjects: config.tabs?.scope !== "cwd" },
})
const allProjects = () => prefs.allProjects
const pickerLocation = () =>
(route.data.type === "session" ? data.session.get(route.data.sessionID)?.location : undefined) ??
activeLocation.ref ??
data.location.default()
const [searchResults, { mutate: setSearchResults }] = createResource(
() => ({ query: search().trim(), allProjects: allProjects() }),
async ({ query, allProjects }) => {
() => ({
query: search().trim(),
allProjects: allProjects(),
location: pickerLocation(),
}),
async ({ query, allProjects, location }) => {
try {
if (!data.location.info()) await data.location.sync()
const current = data.location.info()
if (!data.location.info(location)) await data.location.sync(location)
const current = data.location.info(location)
if (!current) throw new Error("Location unavailable")
const response = await client.api.session.list({
...(allProjects
@@ -78,7 +88,7 @@ export function DialogSessionList() {
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const localSessions = createMemo(() => {
const query = filter().trim().toLowerCase()
const current = data.location.info()
const current = data.location.info(pickerLocation())
const sessions = data.session
.list()
.filter(
@@ -125,7 +135,7 @@ export function DialogSessionList() {
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
})
const currentProjectName = createMemo(() => {
const current = data.location.info()
const current = data.location.info(pickerLocation())
if (!current) return ""
const project = data.project.get(current.project.id)
return projectName(project) ?? ""
@@ -1,68 +1,114 @@
import type { PluginInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import { DialogErrorDetails } from "../../component/dialog-error-details"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
import { DialogErrorDetails } from "../../component/dialog-error-details"
const id = "opencode.plugins"
function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePlugin> }) {
type Entry =
| { readonly key: string; readonly runtime: "server"; readonly plugin: PluginInfo }
| {
readonly key: string
readonly runtime: "tui"
readonly id?: string
readonly target: string
readonly status: "active" | "inactive" | "failed"
readonly error?: string
}
export function PluginsDialog(props: {
context: Plugin.Context
plugins: ReturnType<typeof usePlugin>
server?: () => readonly PluginInfo[]
}) {
const dialog = useDialog()
const [locked, setLocked] = createSignal(false)
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<{ title: string; error: string }>()
const dialog = useDialog()
const options = createMemo(() => {
const builtins = props.plugins
const [detail, setDetail] = createSignal<Entry>()
const [initial, setInitial] = createSignal<string>()
const [server] = createResource(
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
)
onMount(() => dialog.setSize("medium"))
const entries = createMemo<Entry[]>(() => {
const builtins: Entry[] = props.plugins
.registered()
.filter((plugin) => plugin.id !== id && plugin.source === "builtin")
.map(
(plugin): DialogSelectOption<string> => ({
title: plugin.id,
value: plugin.id,
category: "Built-in",
footer: plugin.active ? "active" : "inactive",
footerColor: plugin.active
? props.context.theme.text.feedback.success.default
: props.context.theme.text.subdued,
}),
)
const external = props.plugins
.map((plugin) => ({
key: `tui:${plugin.id}`,
runtime: "tui" as const,
id: plugin.id,
target: plugin.id,
status: plugin.active ? ("active" as const) : ("inactive" as const),
}))
const external: Entry[] = props.plugins
.list()
.filter((plugin) => plugin.status !== "unsupported")
.map(
(plugin): DialogSelectOption<string> => ({
title: plugin.id ?? plugin.target,
value: plugin.id ?? plugin.target,
category: "External",
searchText: plugin.target,
footer: plugin.status,
footerColor:
plugin.status === "active"
? props.context.theme.text.feedback.success.default
: plugin.status === "failed"
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
}),
)
return [...builtins, ...external].sort((a, b) => a.title.localeCompare(b.title))
.map((plugin) => ({
key: `tui:${plugin.id ?? plugin.target}`,
runtime: "tui" as const,
id: plugin.id,
target: plugin.target,
status: plugin.status,
error: plugin.status === "failed" ? plugin.error : undefined,
}))
const serverEntries: Entry[] = (props.server?.() ?? server() ?? []).map((plugin) => ({
key: `server:${plugin.id ?? source(plugin, props.context)}`,
runtime: "server" as const,
plugin,
}))
return [
...[...builtins, ...external].sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
...serverEntries.sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
]
})
const failure = (value: string | undefined) =>
props.plugins.list().find((plugin) => {
if (plugin.status !== "failed") return false
return (plugin.id ?? plugin.target) === value
})
createEffect(() => {
if (focused()) return
const first = options()[0]
if (first) setFocused(first.value)
if (initial()) return
const first = entries().find((entry) => entry.runtime === "tui")
if (!first) return
setInitial(first.key)
setFocused(first.key)
})
const toggle = (plugin: DialogSelectOption<string>) => {
if (locked()) return
const current = props.plugins.registered().find((item) => item.id === plugin.value)
const options = createMemo(() =>
entries().map(
(entry): DialogSelectOption<string> => ({
title: label(entry, props.context),
value: entry.key,
category: entry.runtime === "tui" ? "TUI" : "Server",
searchText: entry.runtime === "tui" ? entry.target : source(entry.plugin, props.context),
footer: status(entry) === "active" ? undefined : status(entry),
footerColor:
status(entry) === "failed"
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
gutter:
status(entry) === "active"
? () => <text fg={props.context.theme.text.feedback.success.default}></text>
: status(entry) === "failed"
? () => <text fg={props.context.theme.text.feedback.error.default}></text>
: undefined,
}),
),
)
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === focused()))
const focusedTui = createMemo(() => {
const entry = focusedEntry()
if (entry?.runtime !== "tui" || !entry.id) return
return entry
})
const toggleTitle = createMemo(() => {
const entry = focusedTui()
if (!entry) return "toggle"
return props.plugins.registered().find((plugin) => plugin.id === entry.id)?.active ? "disable" : "enable"
})
const toggle = (entry: Entry | undefined) => {
if (locked() || entry?.runtime !== "tui" || !entry.id) return
const current = props.plugins.registered().find((plugin) => plugin.id === entry.id)
if (!current) return
setLocked(true)
void (current.active ? props.plugins.deactivate(current.id) : props.plugins.activate(current.id))
@@ -70,21 +116,15 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
if (ok) return
props.context.ui.toast.show({ variant: "error", message: `Failed to update plugin ${current.id}` })
})
.catch((error) => {
.catch((cause) => {
props.context.ui.toast.show({
variant: "error",
message: error instanceof Error ? error.message : String(error),
message: cause instanceof Error ? cause.message : String(cause),
})
})
.finally(() => setLocked(false))
}
const select = (plugin: DialogSelectOption<string>) => {
const failed = failure(plugin.value)
if (!failed || failed.status !== "failed") return toggle(plugin)
setDetail({ title: failed.target, error: failed.error })
}
return (
<box>
<Show
@@ -93,33 +133,42 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
<DialogSelect
title="Plugins"
options={options()}
current={initial()}
locked={locked()}
preserveSelection={true}
onMove={(option) => setFocused(option.value)}
actions={[
{
title: "toggle",
command: "plugins.toggle",
disabled: (option) => {
const failed = failure(option?.value)
return Boolean(failed && !("id" in failed && failed.id))
},
onTrigger: toggle,
},
]}
onSelect={select}
onSelect={(option) => {
const entry = entries().find((entry) => entry.key === option.value)
if (pluginError(entry)) setDetail(entry)
}}
actions={
focusedTui()
? [
{
title: toggleTitle(),
command: "plugins.toggle",
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
},
]
: []
}
footer={
<Show when={failure(focused())}>
<text fg={props.context.theme.text.subdued}>enter to view error</text>
<Show when={pluginError(focusedEntry())}>
<text>
<span style={{ fg: props.context.theme.text.default }}>
<b>enter</b>
</span>
<span style={{ fg: props.context.theme.text.subdued }}> view error</span>
</text>
</Show>
}
/>
}
>
{(item) => (
{(entry) => (
<DialogErrorDetails
title={`Plugin: ${item().title}`}
error={item().error}
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
error={pluginError(entry()) ?? "Unknown plugin error"}
onBack={() => {
setDetail()
dialog.setSize("medium")
@@ -131,6 +180,27 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
)
}
function label(entry: Entry, context: Plugin.Context) {
if (entry.runtime === "tui") return entry.id ?? entry.target
return entry.plugin.id ?? source(entry.plugin, context)
}
function source(plugin: PluginInfo, context: Plugin.Context) {
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return context.ui.format.path(plugin.source.path)
return plugin.source.type
}
function status(entry: Entry) {
if (entry.runtime === "server") return entry.plugin.status
return entry.status
}
function pluginError(entry: Entry | undefined) {
if (entry?.runtime === "server") return entry.plugin.status === "failed" ? entry.plugin.error : undefined
return entry?.error
}
function Commands(props: { context: Plugin.Context }) {
const plugins = usePlugin()
props.context.keymap.layer(() => ({
@@ -143,7 +213,7 @@ function Commands(props: { context: Plugin.Context }) {
slash: { name: "plugins" },
palette: true,
run() {
props.context.ui.dialog.show(() => <View context={props.context} plugins={plugins} />)
props.context.ui.dialog.show(() => <PluginsDialog context={props.context} plugins={plugins} />)
},
},
],
@@ -0,0 +1,127 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { DialogSessionList } from "../../../src/component/dialog-session-list"
import { ConfigProvider } from "../../../src/config"
import { ArgsProvider } from "../../../src/context/args"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocalProvider } from "../../../src/context/local"
import { LocationProvider } from "../../../src/context/location"
import { PermissionProvider } from "../../../src/context/permission"
import { RouteProvider, useRoute } from "../../../src/context/route"
import { TuiAppProvider } from "../../../src/context/runtime"
import { SessionTabsProvider } from "../../../src/context/session-tabs"
import { StorageProvider, useStorage } from "../../../src/context/storage"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
test("scopes sessions to the active session location", async () => {
const active = "/tmp/opencode/project-b"
const events = createEventStream()
const requestedProjects: string[] = []
const calls = createFetch((url) => {
if (url.pathname === "/api/location") {
const directory = url.searchParams.get("location[directory]") ?? process.cwd()
const project = directory === active ? "proj_b" : "proj_a"
return json({ directory, project: { id: project, directory, canonical: directory } })
}
if (url.pathname !== "/api/session") return undefined
const project = url.searchParams.get("project") ?? ""
requestedProjects.push(project)
return json({
data: [
{
id: project === "proj_b" ? "ses_b" : "ses_a",
projectID: project,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: project === "proj_b" ? "Project B session" : "Project A session",
location: { directory: project === "proj_b" ? active : process.cwd() },
},
],
cursor: {},
})
}, events)
const temporary = await tmpdir()
let storage!: ReturnType<typeof useStorage>
function Probe() {
const data = useData()
const dialog = useDialog()
const route = useRoute()
storage = useStorage()
onMount(() => {
data.session.remember({
id: "ses_active",
projectID: "proj_b",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 3 },
title: "Active session",
location: { directory: active },
})
route.navigate({ type: "session", sessionID: "ses_active" })
dialog.replace(() => <DialogSessionList />)
})
return null
}
const app = await testRender(
() => (
<TestTuiContexts paths={{ state: temporary.path }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ArgsProvider>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ToastProvider>
<RouteProvider>
<ClientProvider api={createApi(calls.fetch)}>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<LocalProvider>
<DialogProvider>
<Probe />
</DialogProvider>
</LocalProvider>
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</PermissionProvider>
</ClientProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</StorageProvider>
</TuiAppProvider>
</TestTuiContexts>
),
{ width: 100, height: 30, kittyKeyboard: true },
)
app.renderer.start()
try {
const frame = await app.waitForFrame((value) => value.includes("Project B session"))
expect(frame).not.toContain("Project A session")
expect(requestedProjects.at(-1)).toBe("proj_b")
} finally {
app.renderer.destroy()
await storage.flush()
await temporary[Symbol.asyncDispose]()
}
})
-72
View File
@@ -48,58 +48,6 @@
"summary": "Check server health"
}
},
"/api/service/stop": {
"post": {
"tags": ["health"],
"operationId": "v2.health.stop",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "ServiceStopResponse",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceStopResponse"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Request graceful shutdown of one exact managed server instance.",
"summary": "Stop the managed server",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceStopRequest"
}
}
},
"required": true
}
}
},
"/api/server": {
"get": {
"tags": ["server"],
@@ -9783,26 +9731,6 @@
"required": ["_tag", "message"],
"additionalProperties": false
},
"ServiceStopRequest": {
"type": "object",
"properties": {
"instanceID": {
"type": "string"
}
},
"required": ["instanceID"],
"additionalProperties": false
},
"ServiceStopResponse": {
"type": "object",
"properties": {
"accepted": {
"type": "boolean"
}
},
"required": ["accepted"],
"additionalProperties": false
},
"Union_1": {
"anyOf": [
{
-72
View File
@@ -48,58 +48,6 @@
"summary": "Check server health"
}
},
"/api/service/stop": {
"post": {
"tags": ["health"],
"operationId": "v2.health.stop",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "ServiceStopResponse",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceStopResponse"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Request graceful shutdown of one exact managed server instance.",
"summary": "Stop the managed server",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceStopRequest"
}
}
},
"required": true
}
}
},
"/api/server": {
"get": {
"tags": ["server"],
@@ -9783,26 +9731,6 @@
"required": ["_tag", "message"],
"additionalProperties": false
},
"ServiceStopRequest": {
"type": "object",
"properties": {
"instanceID": {
"type": "string"
}
},
"required": ["instanceID"],
"additionalProperties": false
},
"ServiceStopResponse": {
"type": "object",
"properties": {
"accepted": {
"type": "boolean"
}
},
"required": ["accepted"],
"additionalProperties": false
},
"Union_1": {
"anyOf": [
{