Compare commits

..
Author SHA1 Message Date
Kit Langton 7399d57aee feat(client): generate complete protocol client 2026-06-26 22:54:23 -04:00
244 changed files with 8506 additions and 9851 deletions
-10
View File
@@ -83,7 +83,6 @@
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
@@ -106,15 +105,12 @@
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"jsonc-parser": "3.3.1",
"semver": "catalog:",
"solid-js": "catalog:",
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
@@ -996,7 +992,6 @@
"@typescript/native-preview": "catalog:",
"solid-js": "catalog:",
"tailwindcss": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
@@ -1055,7 +1050,6 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
@@ -5323,8 +5317,6 @@
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
@@ -5983,8 +5975,6 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-JXQ9PAqqRlJtHa8T3ZxqdRRyxC+0ip+2wSnehvKXUbI=",
"aarch64-linux": "sha256-nI+RaxDXmAcjhSjCtIvyi292xBEg0E5NXaoyGrJE69s=",
"aarch64-darwin": "sha256-UleKpm7Khf3kibm4BqZJ9bmu0N2kOjNd77g1Bd2tmfw=",
"x86_64-darwin": "sha256-V4AQH868dOfVhEj1mKTnZlhpZwFqBYsHS28Tx9VXHkE="
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
}
}
+1 -3
View File
@@ -2,7 +2,6 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.3.14",
@@ -154,7 +153,6 @@
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch"
}
}
-1
View File
@@ -38,7 +38,6 @@
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
+12 -22
View File
@@ -38,7 +38,7 @@ import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider, useNotification } from "@/context/notification"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
@@ -316,7 +316,9 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
return (
<PermissionProvider directory={props.directory}>
<LayoutProvider>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)
@@ -343,23 +345,13 @@ function NewAppLayout(props: ParentProps) {
function TargetServerScopedProviders(props: ServerScopedShellProps) {
return (
<PermissionProvider directory={props.directory}>
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</NotificationProvider>
</PermissionProvider>
)
}
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
const notification = useNotification()
createEffect(() => {
const sessionID = props.sessionID?.()
if (!notification.ready() || !sessionID) return
if (notification.session.unseenCount(sessionID) === 0) return
notification.session.markViewed(sessionID)
})
return null
}
function SessionProviders(props: ParentProps) {
return (
<TerminalProvider>
@@ -568,13 +560,11 @@ export function AppInterface(props: {
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</TabsProvider>
)}
>
+22 -77
View File
@@ -3,7 +3,6 @@ import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language"
type Mem = Performance & {
@@ -52,62 +51,31 @@ const bad = (n: number | undefined, limit: number, low = false) => {
const session = (path: string) => path.includes("/session")
function Cell(props: {
bad?: boolean
dim?: boolean
inline?: boolean
label: string
tip: string
value: string
wide?: boolean
}) {
const content = () => (
<div
classList={{
"flex min-w-0 items-center": true,
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
"justify-center text-center": !props.inline,
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
"col-span-2": !!props.wide && !props.inline,
}}
>
<div
classList={{
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
}}
>
{props.label}
</div>
<div
classList={{
"uppercase leading-none font-bold tabular-nums": true,
"text-[11px]": !!props.inline,
"text-[13px] sm:text-[14px]": !props.inline,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div>
)
if (props.inline) {
return (
<TooltipV2 value={props.tip} placement="top">
{content()}
</TooltipV2>
)
}
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
return (
<Tooltip value={props.tip} placement="top">
{content()}
<div
classList={{
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
"col-span-2": !!props.wide,
}}
>
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
<div
classList={{
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div>
</Tooltip>
)
}
export function DebugBar(props: { inline?: boolean } = {}) {
export function DebugBar() {
const language = useLanguage()
const location = useLocation()
const routing = useIsRouting()
@@ -133,7 +101,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
},
})
const na = () => language.t("debugBar.na").toUpperCase()
const na = () => language.t("debugBar.na")
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
const heapv = () => {
const value = heap()
@@ -395,30 +363,15 @@ export function DebugBar(props: { inline?: boolean } = {}) {
return (
<aside
aria-label={language.t("debugBar.ariaLabel")}
classList={{
"pointer-events-auto hidden overflow-hidden text-text-strong md:block": true,
"mt-[-6px] w-full shrink-0 px-3 py-1": !!props.inline,
"fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]":
!props.inline,
}}
class="pointer-events-auto fixed bottom-3 right-3 z-50 hidden w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] md:block sm:bottom-4 sm:right-4 sm:w-[324px]"
>
<div
classList={{
"font-mono": true,
"gap-[9px]": !!props.inline,
"gap-px": !props.inline,
"flex w-full flex-nowrap items-center justify-start": !!props.inline,
"grid-cols-5": !props.inline,
grid: !props.inline,
}}
>
<div class="grid grid-cols-5 gap-px font-mono">
<Cell
label={language.t("debugBar.nav.label")}
tip={language.t("debugBar.nav.tip")}
value={navv()}
bad={bad(state.nav.dur, 400)}
dim={state.nav.dur === undefined && !state.nav.pending}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.fps.label")}
@@ -426,7 +379,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
bad={bad(state.fps, 50, true)}
dim={state.fps === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.frame.label")}
@@ -434,7 +386,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={time(state.gap) ?? na()}
bad={bad(state.gap, 50)}
dim={state.gap === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.jank.label")}
@@ -442,7 +393,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={state.jank === undefined ? na() : `${state.jank}`}
bad={bad(state.jank, 8)}
dim={state.jank === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.long.label")}
@@ -450,7 +400,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={longv()}
bad={bad(state.long.block, 200)}
dim={state.long.count === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.delay.label")}
@@ -458,7 +407,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={time(state.delay) ?? na()}
bad={bad(state.delay, 100)}
dim={state.delay === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.inp.label")}
@@ -466,7 +414,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={time(state.inp) ?? na()}
bad={bad(state.inp, 200)}
dim={state.inp === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.cls.label")}
@@ -474,7 +421,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)}
dim={state.cls === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.mem.label")}
@@ -489,7 +435,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
value={heapv()}
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
inline={props.inline}
wide
/>
</div>
@@ -65,7 +65,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
promptInputs: undefined as undefined | Record<string, string>,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined,
})
@@ -74,7 +73,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string }
@@ -85,7 +83,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.promptInputs = undefined
draft.state = undefined
draft.error = undefined
return
@@ -93,7 +90,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
if (action.type === "method.reset") {
draft.methodIndex = undefined
draft.authorization = undefined
draft.promptInputs = undefined
draft.state = undefined
draft.error = undefined
return
@@ -103,12 +99,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
draft.error = undefined
return
}
if (action.type === "auth.inputs") {
draft.promptInputs = action.inputs
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
@@ -161,15 +151,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
const method = methods()[index]
dispatch({ type: "method.select", index })
if (method.type === "api" && method.prompts?.length) {
if (!inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.inputs", inputs })
return
}
if (method.type === "oauth") {
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
@@ -209,7 +190,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
}
}
function AuthPromptsView() {
function OAuthPromptsView() {
const [formStore, setFormStore] = createStore({
value: {} as Record<string, string>,
index: 0,
@@ -217,7 +198,8 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
const value = method()
return value?.prompts ?? []
if (value?.type !== "oauth") return []
return value.prompts ?? []
})
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
@@ -248,10 +230,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
setFormStore("index", next)
return
}
if (method()?.type === "api") {
dispatch({ type: "auth.inputs", inputs: value })
return
}
await selectMethod(store.methodIndex, value)
}
@@ -436,7 +414,6 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
auth: {
type: "api",
key: apiKey,
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
},
})
await complete()
@@ -645,7 +622,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
</div>
</Match>
<Match when={store.state === "prompt"}>
<AuthPromptsView />
<OAuthPromptsView />
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
@@ -59,7 +59,6 @@ const ModelList: Component<{
class="w-full"
placement="right-start"
gutter={12}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
>
{node}
+26 -68
View File
@@ -35,8 +35,6 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -1345,9 +1343,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const agentsLoading = () => props.controls.agents.loading
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
const providersLoading = () => props.controls.model.loading
const providersShouldFadeIn = createMemo<boolean>((prev) => prev ?? providersLoading())
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
const [promptReady] = createResource(
() => prompt.ready.promise,
@@ -1361,10 +1359,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const modelControlState = createMemo<ComposerModelControlState>(() => ({
loading: providersLoading(),
shouldAnimate: providersShouldFadeIn(),
paid: props.controls.model.paid,
title: language.t("command.model.choose"),
keybind: command.keybindParts("model.choose"),
keybind: command.keybind("model.choose"),
model: props.controls.model.selection,
providerID: props.controls.model.selection.current()?.provider?.id,
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
@@ -1381,7 +1378,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
title: language.t("command.agent.cycle"),
keybind: command.keybindParts("agent.cycle"),
keybind: command.keybind("agent.cycle"),
options: props.controls.agents.options,
current: props.controls.agents.current,
style: control(),
@@ -1499,14 +1496,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="flex h-11 items-center px-2">
<div class="flex min-w-0 flex-1 items-center gap-0">
{fileAttachmentInput()}
<TooltipV2
<TooltipKeybind
placement="top"
value={
<>
{language.t("prompt.action.attachFile")}
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
</>
}
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<IconButton
data-action="prompt-attach"
@@ -1520,30 +1513,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
/>
</TooltipV2>
</TooltipKeybind>
<Show when={showAgentControl()}>
<ComposerAgentControl state={agentControlState()} />
</Show>
{props.toolbar}
<ComposerModelControl state={modelControlState()} />
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
<Show when={store.mode !== "shell" && showVariantControl()}>
<div
data-component="prompt-variant-control"
classList={{
"animate-in fade-in": providersShouldFadeIn(),
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
!props.controls.model.selection.variant.current() && !store.variantOpen,
}}
>
<TooltipV2
<TooltipKeybind
placement="top"
gutter={4}
value={
<>
{language.t("command.model.variant.cycle")}
<KeybindV2 keys={command.keybindParts("model.variant.cycle")} variant="neutral" />
</>
}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
@@ -1561,11 +1549,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipV2>
</TooltipKeybind>
</div>
</Show>
</div>
<TooltipV2 placement="top" inactive={!working() && blank()} value={tip()}>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
@@ -1580,7 +1568,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</TooltipV2>
</Tooltip>
</div>
</DockShellForm>
</div>
@@ -1777,7 +1765,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={!agentsLoading()}>
<div
data-component="prompt-agent-control"
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<TooltipKeybind
placement="top"
@@ -1806,7 +1794,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={store.mode !== "shell"}>
<div
data-component="prompt-model-control"
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<Show
when={props.controls.model.paid}
@@ -1885,7 +1873,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={showVariantControl()}>
<div
data-component="prompt-variant-control"
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<TooltipKeybind
placement="top"
@@ -1926,7 +1914,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type ComposerAgentControlState = {
title: string
keybind: string[]
keybind: string
options: string[]
current: string
style: JSX.CSSProperties | undefined
@@ -1935,10 +1923,9 @@ type ComposerAgentControlState = {
type ComposerModelControlState = {
loading: boolean
shouldAnimate: boolean
paid: boolean
title: string
keybind: string[]
keybind: string
model: ReturnType<typeof useLocal>["model"]
providerID?: string
modelName: string
@@ -1953,16 +1940,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
<Icon name="sliders" size="small" />
</div>
<TooltipV2
placement="top"
gutter={4}
value={
<>
{props.state.title}
<KeybindV2 keys={props.state.keybind} variant="neutral" />
</>
}
>
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<Select
size="normal"
options={props.state.options}
@@ -1974,7 +1952,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost"
/>
</TooltipV2>
</TooltipKeybind>
</div>
)
}
@@ -1985,23 +1963,13 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
<Show
when={props.state.paid}
fallback={
<TooltipV2
placement="top"
gutter={4}
value={
<>
{props.state.title}
<KeybindV2 keys={props.state.keybind} variant="neutral" />
</>
}
>
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
style={props.state.style}
onClick={props.state.onUnpaidClick}
>
@@ -2019,19 +1987,10 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</Button>
</TooltipV2>
</TooltipKeybind>
}
>
<TooltipV2
placement="top"
gutter={4}
value={
<>
{props.state.title}
<KeybindV2 keys={props.state.keybind} variant="neutral" />
</>
}
>
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<ModelSelectorPopover
model={props.state.model}
triggerAs={Button}
@@ -2041,7 +2000,6 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
style: props.state.style,
class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
classList: { "animate-in fade-in": props.state.shouldAnimate },
"data-action": "prompt-model",
}}
onClose={props.state.onClose}
@@ -2060,7 +2018,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</ModelSelectorPopover>
</TooltipV2>
</TooltipKeybind>
</Show>
</Show>
)
+1 -3
View File
@@ -99,8 +99,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const path = () => `${location.pathname}${location.search}${location.hash}`
const creating = createMemo(() => {
const route = layout.route()
if (route.type === "draft" || route.type === "dir-new-sesssion") return true
if (!params.dir) return false
if (params.id) return false
const parts = location.pathname.replace(/\/+$/, "").split("/")
@@ -468,7 +466,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Show when={!creating()}>
<Show when={!(creating() && params.dir)}>
<TooltipV2
placement="bottom"
value={
+245 -343
View File
@@ -1,9 +1,9 @@
import { createStore, reconcile } from "solid-js/store"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { useParams, useSearchParams } from "@solidjs/router"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ServerSDK } from "./server-sdk"
import type { ServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk"
import { useServerSync } from "./server-sync"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
@@ -12,11 +12,6 @@ import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { type DraftTab, useTabs } from "./tabs"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
type NotificationBase = {
directory?: string
@@ -112,360 +107,267 @@ function buildNotificationIndex(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
gate: false,
init: () => {
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
const [search] = useSearchParams<{ draftId?: string }>()
const global = useGlobal()
const server = useServer()
const tabs = useTabs()
init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
const params = useParams()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
const owner = getOwner()
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
const activeServer = createMemo(() => {
if (params.serverKey) return requireServerKey(params.serverKey)
if (search.draftId) {
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
if (draft) return draft.server
}
return server.key
const empty: Notification[] = []
const currentDirectory = createMemo(() => {
return props.directory?.() ?? decode64(params.dir)
})
const activeDirectory = createMemo(() => decode64(params.dir))
const activeSession = createMemo(() => params.id)
const ensure = (key: ServerConnection.Key) => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
if (!conn) throw new Error(`Notification server not found: ${key}`)
const ctx = global.ensureServerCtx(conn)
const existing = states.get(ctx.sdk.scope)
if (existing) return existing.state
const root = createRoot(
(dispose) => ({
dispose,
state: createServerNotificationState({
sdk: ctx.sdk,
sync: ctx.sync,
active: () => server.scope(activeServer()) === ctx.sdk.scope,
directory: activeDirectory,
sessionID: activeSession,
platform,
settings,
language,
}),
}),
owner ?? undefined,
const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
)
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
const meta = { pruned: false, disposed: false }
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
setIndex(scope, "unseen", key, unseen)
setIndex(scope, "unseenCount", key, unseen.length)
setIndex(
scope,
"unseenHasError",
key,
unseen.some((notification) => notification.type === "error"),
)
states.set(ctx.sdk.scope, root)
return root.state
}
const appendToIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
}
}
}
const removeFromIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
updateUnseen("session", notification.session, unseen)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
updateUnseen("project", notification.directory, unseen)
}
}
}
createEffect(() => {
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
})
createEffect(() => {
const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
states.forEach((value, scope) => {
if (scopes.has(scope)) return
value.dispose()
states.delete(scope)
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
const list = pruneNotifications(store.list)
batch(() => {
setStore("list", list)
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
})
})
onCleanup(() => states.forEach((value) => value.dispose()))
const append = (notification: Notification) => {
const list = pruneNotifications([...store.list, notification])
const keep = new Set(list)
const removed = store.list.filter((n) => !keep.has(n))
const selected = () => ensure(activeServer())
batch(() => {
if (keep.has(notification)) appendToIndex(notification)
removed.forEach((n) => removeFromIndex(n))
setStore("list", list)
})
}
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const sync = serverSync().ensureDirSyncContext(directory)
const session = sync.session.get(sessionID)
if (session) return session
return sync.session
.sync(sessionID)
.then(() => sync.session.get(sessionID))
.catch(() => undefined)
}
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
const activeDirectory = currentDirectory()
const activeSession = currentSession()
if (!activeDirectory) return false
if (!activeSession) return false
if (!sessionID) return false
if (directory !== activeDirectory) return false
return sessionID === activeSession
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
void playSoundById(settings.sounds.agent())
}
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "turn-complete",
session: sessionID,
})
const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
}
})
}
const handleSessionError = (
directory: string,
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number,
) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
void playSoundById(settings.sounds.errors())
}
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "error",
session: sessionID ?? "global",
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, href)
}
})
}
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
return
}
handleSessionError(directory, event, time)
})
onCleanup(() => {
meta.disposed = true
unsub()
})
return {
ready: () => selected().ready(),
ensureServerState: ensure,
ready,
session: {
all: (session: string) => selected().session.all(session),
unseen: (session: string) => selected().session.unseen(session),
unseenCount: (session: string) => selected().session.unseenCount(session),
unseenHasError: (session: string) => selected().session.unseenHasError(session),
markViewed: (session: string) => selected().session.markViewed(session),
all(session: string) {
return index.session.all[session] ?? empty
},
unseen(session: string) {
return index.session.unseen[session] ?? empty
},
unseenCount(session: string) {
return index.session.unseenCount[session] ?? 0
},
unseenHasError(session: string) {
return index.session.unseenHasError[session] ?? false
},
markViewed(session: string) {
const unseen = index.session.unseen[session] ?? empty
if (!unseen.length) return
const projects = [
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
]
batch(() => {
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
updateUnseen("session", session, [])
projects.forEach((directory) => {
const next = (index.project.unseen[directory] ?? empty).filter(
(notification) => notification.session !== session,
)
updateUnseen("project", directory, next)
})
})
},
},
project: {
all: (directory: string) => selected().project.all(directory),
unseen: (directory: string) => selected().project.unseen(directory),
unseenCount: (directory: string) => selected().project.unseenCount(directory),
unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
markViewed: (directory: string) => selected().project.markViewed(directory),
all(directory: string) {
return index.project.all[directory] ?? empty
},
unseen(directory: string) {
return index.project.unseen[directory] ?? empty
},
unseenCount(directory: string) {
return index.project.unseenCount[directory] ?? 0
},
unseenHasError(directory: string) {
return index.project.unseenHasError[directory] ?? false
},
markViewed(directory: string) {
const unseen = index.project.unseen[directory] ?? empty
if (!unseen.length) return
const sessions = [
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
]
batch(() => {
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
updateUnseen("project", directory, [])
sessions.forEach((session) => {
const next = (index.session.unseen[session] ?? empty).filter(
(notification) => notification.directory !== directory,
)
updateUnseen("session", session, next)
})
})
},
},
}
},
})
type NotificationState = ReturnType<typeof createServerNotificationState>
function createServerNotificationState(input: {
sdk: ServerSDK
sync: ServerSync
active: Accessor<boolean>
directory: Accessor<string | undefined>
sessionID: Accessor<string | undefined>
platform: ReturnType<typeof usePlatform>
settings: ReturnType<typeof useSettings>
language: ReturnType<typeof useLanguage>
}) {
const serverSDK = () => input.sdk
const serverSync = () => input.sync
const platform = input.platform
const settings = input.settings
const language = input.language
const empty: Notification[] = []
const currentDirectory = input.directory
const currentSession = input.sessionID
const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
)
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
const meta = { pruned: false, disposed: false }
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
setIndex(scope, "unseen", key, unseen)
setIndex(scope, "unseenCount", key, unseen.length)
setIndex(
scope,
"unseenHasError",
key,
unseen.some((notification) => notification.type === "error"),
)
}
const appendToIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
}
}
}
const removeFromIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
updateUnseen("session", notification.session, unseen)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
updateUnseen("project", notification.directory, unseen)
}
}
}
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
const list = pruneNotifications(store.list)
batch(() => {
setStore("list", list)
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
})
})
const append = (notification: Notification) => {
const list = pruneNotifications([...store.list, notification])
const keep = new Set(list)
const removed = store.list.filter((n) => !keep.has(n))
batch(() => {
if (keep.has(notification)) appendToIndex(notification)
removed.forEach((n) => removeFromIndex(n))
setStore("list", list)
})
}
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const sync = serverSync().ensureDirSyncContext(directory)
const session = sync.session.get(sessionID)
if (session) return session
return sync.session
.sync(sessionID)
.then(() => sync.session.get(sessionID))
.catch(() => undefined)
}
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
if (!input.active()) return false
const activeDirectory = currentDirectory()
const activeSession = currentSession()
if (!activeSession) return false
if (!sessionID) return false
if (activeDirectory && directory !== activeDirectory) return false
return sessionID === activeSession
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
void playSoundById(settings.sounds.agent())
}
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "turn-complete",
session: sessionID,
})
const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
}
})
}
const handleSessionError = (
directory: string,
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number,
) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
void playSoundById(settings.sounds.errors())
}
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "error",
session: sessionID ?? "global",
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, href)
}
})
}
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
return
}
handleSessionError(directory, event, time)
})
onCleanup(() => {
meta.disposed = true
unsub()
})
return {
ready,
session: {
all(session: string) {
return index.session.all[session] ?? empty
},
unseen(session: string) {
return index.session.unseen[session] ?? empty
},
unseenCount(session: string) {
return index.session.unseenCount[session] ?? 0
},
unseenHasError(session: string) {
return index.session.unseenHasError[session] ?? false
},
markViewed(session: string) {
const unseen = index.session.unseen[session] ?? empty
if (!unseen.length) return
const projects = [
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
]
batch(() => {
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
updateUnseen("session", session, [])
projects.forEach((directory) => {
const next = (index.project.unseen[directory] ?? empty).filter(
(notification) => notification.session !== session,
)
updateUnseen("project", directory, next)
})
})
},
},
project: {
all(directory: string) {
return index.project.all[directory] ?? empty
},
unseen(directory: string) {
return index.project.unseen[directory] ?? empty
},
unseenCount(directory: string) {
return index.project.unseenCount[directory] ?? 0
},
unseenHasError(directory: string) {
return index.project.unseenHasError[directory] ?? false
},
markViewed(directory: string) {
const unseen = index.project.unseen[directory] ?? empty
if (!unseen.length) return
const sessions = [
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
]
batch(() => {
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
updateUnseen("project", directory, [])
sessions.forEach((session) => {
const next = (index.session.unseen[session] ?? empty).filter(
(notification) => notification.directory !== directory,
)
updateUnseen("session", session, next)
})
})
},
},
}
}
+8 -115
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
import type { Event } from "@opencode-ai/sdk/v2/client"
describe("resumeStreamAfterPageShow", () => {
@@ -15,23 +15,19 @@ describe("resumeStreamAfterPageShow", () => {
})
describe("coalesceServerEvents", () => {
const delta = (value: string, field = "text", partID = "part") => ({
const delta = (value: string, field = "text") => ({
directory: "/repo",
payload: {
type: "message.part.delta",
properties: { messageID: "msg", partID, field, delta: value },
properties: { messageID: "msg", partID: "part", field, delta: value },
} as Event,
})
test("merges adjacent deltas for the same field", () => {
const first = delta("hello ")
const second = delta("world")
first.payload.id = "first"
second.payload.id = "second"
const result = coalesceServerEvents([first, second])
const result = coalesceServerEvents([delta("hello "), delta("world")])
expect(result).toHaveLength(1)
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } })
})
test("preserves event boundaries and distinct fields", () => {
@@ -49,112 +45,9 @@ describe("coalesceServerEvents", () => {
])
})
test("preserves event ID order across interleaved deltas", () => {
const first = delta("a")
const other = delta("b", "text", "other")
const last = delta("c")
first.payload.id = "1"
other.payload.id = "2"
last.payload.id = "3"
test("drops stale deltas", () => {
const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"]))
const result = coalesceServerEvents([first, other, last])
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
})
})
describe("enqueueServerEvent", () => {
const partUpdated = (text: string) =>
({
type: "message.part.updated",
properties: {
sessionID: "session",
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
},
}) as Event
test("preserves part updates across message remove and re-add barriers", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
enqueue({
type: "message.updated",
properties: {
sessionID: "session",
info: {
id: "message",
sessionID: "session",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
},
},
} as Event)
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated",
"message.removed",
"message.updated",
"message.part.updated",
])
})
test("preserves deltas after a replacement snapshot", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("a"))
enqueue(partUpdated("ab"))
enqueue({
type: "message.part.delta",
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
} as Event)
const result = coalesceServerEvents(events)
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
})
test("preserves updates after session deletion", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({
type: "session.deleted",
properties: { sessionID: "session", info: { id: "session" } },
} as Event)
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated",
"session.deleted",
"message.part.updated",
])
})
test("does not coalesce edge-triggered session statuses", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (status: "retry" | "busy") =>
enqueueServerEvent(events, {
directory: "/repo",
payload: {
type: "session.status",
properties: {
sessionID: "session",
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
},
} as Event,
})
enqueue("retry")
enqueue("busy")
expect(events).toHaveLength(2)
expect(result).toEqual([])
})
})
+50 -41
View File
@@ -17,56 +17,34 @@ const isAbortError = (error: unknown) =>
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
type QueuedServerEvent = { directory: string; payload: Event }
const coalescedKey = (event: QueuedServerEvent) => {
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
if (event.payload.type === "message.part.updated") {
const part = event.payload.properties.part
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
}
return undefined
}
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
const key = coalescedKey(event)
const previous = queue[queue.length - 1]
if (key && previous && coalescedKey(previous) === key) {
queue[queue.length - 1] = event
return false
}
queue.push(event)
return true
}
export function coalesceServerEvents(events: QueuedServerEvent[]) {
export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set<string>) {
const output: QueuedServerEvent[] = []
const deltas = new Map<string, number>()
events.forEach((event) => {
if (stale && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return
}
if (event.payload.type !== "message.part.delta") {
deltas.clear()
output.push(event)
return
}
const props = event.payload.properties
const previous = output[output.length - 1]
if (
!previous ||
previous.payload.type !== "message.part.delta" ||
previous.directory !== event.directory ||
previous.payload.properties.messageID !== props.messageID ||
previous.payload.properties.partID !== props.partID ||
previous.payload.properties.field !== props.field
) {
const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}`
const index = deltas.get(id)
const existing = index === undefined ? undefined : output[index]
if (!existing || existing.payload.type !== "message.part.delta") {
deltas.set(id, output.length)
output.push({
directory: event.directory,
payload: { ...event.payload, properties: { ...props } },
})
return
}
output[output.length - 1] = {
directory: event.directory,
payload: {
...event.payload,
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
},
}
existing.payload.properties.delta += props.delta
})
return output
}
@@ -107,9 +85,20 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
let queue: Queued[] = []
let buffer: Queued[] = []
const coalesced = new Map<string, number>()
const staleDeltas = new Set<string>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
@@ -117,12 +106,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
if (queue.length === 0) return
const events = queue
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
queue = buffer
buffer = events
queue.length = 0
coalesced.clear()
staleDeltas.clear()
last = Date.now()
const output = coalesceServerEvents(events)
const output = coalesceServerEvents(events, skip)
batch(() => {
output.forEach((event) => emitter.emit(event.directory, event.payload))
})
@@ -192,12 +184,29 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
if (event.payload.type !== "sync") {
const directory = event.directory ?? "global"
const payload = event.payload as Event
if (enqueueServerEvent(queue, { directory, payload })) schedule()
const directory = event.directory ?? "global"
if (event.payload.type === "sync") {
continue
}
const payload = event.payload as Event
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
File diff suppressed because it is too large Load Diff
+66 -537
View File
@@ -18,67 +18,44 @@ import { rootSession } from "@/utils/session-route"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 2
const historyMessagePageSize = 200
const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set()
type OptimisticItem = {
message: Message
parts: Part[]
confirmedParts?: Part[]
confirmedMessage?: boolean
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
type MessageLoadState = {
touchedMessages: Set<string>
removedMessages: Set<string>
retainedMessages: Set<string>
touchedParts: Map<string, Set<string>>
deltaParts: Map<string, Set<string>>
carriedDeltaParts: Map<string, Set<string>>
removedParts: Map<string, Set<string>>
optimisticParts: Map<string, Set<string>>
orphanParents: Set<string>
clearedMessageParts: Set<string>
}
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
function mergeOptimisticPage(
page: { session: Message[]; part: { id: string; part: Part[] }[]; cursor?: string; complete: boolean },
items: OptimisticItem[],
) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, item.part]))
const observed: { messageID: string; parts: Part[] }[] = []
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
if (!result.found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
const confirmed = result.found
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
: []
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
part.set(
item.message.id,
merge(
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
item.parts.filter((part) => !confirmed.includes(part)),
),
)
if (result.found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, merge(current ?? [], item.parts))
}
return {
...page,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
observed,
confirmed,
}
}
@@ -98,38 +75,7 @@ function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
return [...items.values()].sort((x, y) => cmp(x.id, y.id))
}
function reconcileFetched<T extends { id: string }>(
fetched: T[],
current: readonly T[],
options: {
touched?: ReadonlySet<string>
retained?: ReadonlySet<string>
preserveUnfetched?: boolean | ((item: T) => boolean)
} = {},
) {
const result = new Map(fetched.map((item) => [item.id, item]))
const live = new Map(current.map((item) => [item.id, item]))
if (options.preserveUnfetched) {
for (const item of current) {
if (!result.has(item.id) && (options.preserveUnfetched === true || options.preserveUnfetched(item)))
result.set(item.id, item)
}
}
for (const id of options.retained ?? emptyIDs) {
if (result.has(id)) continue
const item = live.get(id)
if (item) result.set(id, item)
}
// Events observed while the request is pending are the freshest client state for those identities.
for (const id of options.touched ?? emptyIDs) {
const item = live.get(id)
if (item) result.set(id, item)
if (!item) result.delete(id)
}
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
}
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
export function createServerSession(client: OpencodeClient) {
const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>,
@@ -149,32 +95,10 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const messageLoads = new Map<string, MessageLoadState>()
const pendingParts = new Map<string, Map<string, Set<string>>>()
const orphanParts = new Map<string, Set<string>>()
const removedMessages = new Map<string, Set<string>>()
const deltaBases = new Map<string, { base: string; sessionID: string }>()
const deleteMessageParts = (
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
messageID: string,
) => {
for (const part of cache.part[messageID] ?? []) {
delete cache.part_text_accum_delta[part.id]
deltaBases.delete(part.id)
}
delete cache.part[messageID]
}
const seen = new Set<string>()
const infoSeen = new Set<string>()
const pinned = new Map<string, number>()
const generations = new Map<string, object>()
const generation = (sessionID: string) => {
const current = generations.get(sessionID)
if (current) return current
const created = {}
generations.set(sessionID, created)
return created
}
const generations = new Map<string, number>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number | undefined>,
cursor: {} as Record<string, string | undefined>,
@@ -191,11 +115,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const preserve = new Set([
...pinned.keys(),
...requests.keys(),
...inflight.keys(),
...inflightDiff.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(),
...Object.entries(data.permission)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
@@ -219,7 +138,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
if (!preserve.has(sessionID)) stale.push(sessionID)
}
stale.forEach((sessionID) => infoSeen.delete(sessionID))
stale.forEach((sessionID) => generations.delete(sessionID))
setData(
"info",
produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])),
@@ -233,27 +151,21 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
if (cached && !options?.force) return Promise.resolve(cached)
const pending = requests.get(sessionID)
if (pending) return pending
const active = generation(sessionID)
const generation = generations.get(sessionID) ?? 0
const request = client.session.get({ sessionID }).then((result) => {
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
if (generations.get(sessionID) !== active) return result.data
if ((generations.get(sessionID) ?? 0) !== generation) return result.data
return remember(result.data)
})
requests.set(sessionID, request)
const cleanup = () => {
if (requests.get(sessionID) === request) requests.delete(sessionID)
if (
generations.get(sessionID) === active &&
!data.info[sessionID] &&
!requests.has(sessionID) &&
!messageLoads.has(sessionID) &&
!inflight.has(sessionID) &&
!inflightDiff.has(sessionID) &&
!inflightTodo.has(sessionID)
)
generations.delete(sessionID)
}
void request.then(cleanup, cleanup)
void request.then(
() => {
if (requests.get(sessionID) === request) requests.delete(sessionID)
},
() => {
if (requests.get(sessionID) === request) requests.delete(sessionID)
},
)
return request
}
@@ -283,121 +195,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
if (items.size === 0) optimistic.delete(sessionID)
}
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const parts = item.parts.filter((part) => part.id !== partID)
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
}
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const parts = item.parts.filter((value) => value.id !== part.id)
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, {
...item,
parts,
confirmedParts: merge(item.confirmedParts ?? [], [part]),
confirmedMessage: true,
})
}
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const confirmed = new Set(confirmedParts.map((part) => part.id))
const parts = item.parts.filter((part) => !confirmed.has(part.id))
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, {
...item,
parts,
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
confirmedMessage: true,
})
}
const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
const load = messageLoads.get(sessionID)
if (!load) return
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
const parts = load.touchedParts.get(messageID)
if (parts) {
parts.add(partID)
return
}
load.touchedParts.set(messageID, new Set([partID]))
}
const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
load.touchedMessages.clear()
load.retainedMessages.clear()
load.touchedParts.clear()
load.carriedDeltaParts.clear()
load.clearedMessageParts.clear()
for (const messageID of load.removedMessages) {
load.touchedMessages.add(messageID)
load.clearedMessageParts.add(messageID)
}
for (const [messageID, parts] of load.deltaParts) {
load.touchedParts.set(messageID, new Set(parts))
load.carriedDeltaParts.set(messageID, new Set(parts))
const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
}
for (const [messageID, parts] of load.removedParts) {
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
}
for (const [messageID, parts] of load.optimisticParts) {
load.removedMessages.delete(messageID)
load.clearedMessageParts.add(messageID)
load.touchedMessages.add(messageID)
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
}
}
const evict = (sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
const evicted = new Set(sessionIDs)
for (const [partID, item] of deltaBases) {
if (evicted.has(item.sessionID)) deltaBases.delete(partID)
}
sessionIDs.forEach((sessionID) => {
generations.delete(sessionID)
generations.set(sessionID, (generations.get(sessionID) ?? 0) + 1)
clearOptimistic(sessionID)
requests.delete(sessionID)
inflight.delete(sessionID)
inflightDiff.delete(sessionID)
inflightTodo.delete(sessionID)
messageLoads.delete(sessionID)
pendingParts.delete(sessionID)
orphanParts.delete(sessionID)
removedMessages.delete(sessionID)
})
setData(
produce((draft) => {
@@ -424,7 +230,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
...inflight.keys(),
...inflightDiff.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(),
...Object.entries(data.permission)
.filter(([, items]) => items.length > 0)
@@ -442,11 +247,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
)
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return client.session.messages({ sessionID, limit, before })
})
const fetchMessages = async (sessionID: string, limit: number, before?: string) => {
const response = await retry(() => client.session.messages({ sessionID, limit, before }))
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return {
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
@@ -459,153 +261,30 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
}
}
const replaceMessages = (sessionID: string, messages: Message[]) => {
const messageIDs = new Set(messages.map((message) => message.id))
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
setData("message", sessionID, reconcile(messages, { key: "id" }))
setData(
produce((draft) => {
for (const message of dropped) deleteMessageParts(draft, message.id)
}),
)
return messageIDs
}
const replaceParts = (
sessionID: string,
items: MessagePage["part"],
messageIDs: Set<string>,
load?: MessageLoadState,
) => {
for (const item of items) {
if (!messageIDs.has(item.id)) continue
const fetched = load?.clearedMessageParts.has(item.id)
? []
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
const fetchedIDs = new Set(fetched.map((part) => part.id))
const pending = pendingParts.get(sessionID)?.get(item.id)
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
for (const part of fetched) {
const accumulated = data.part_text_accum_delta[part.id]
const base = deltaBases.get(part.id)?.base
const preserveDelta =
base !== undefined &&
accumulated !== undefined &&
"text" in part &&
typeof part.text === "string" &&
part.text.startsWith(base) &&
accumulated.startsWith(part.text) &&
accumulated !== part.text
if (preserveDelta) touched.add(part.id)
if (load?.carriedDeltaParts.get(item.id)?.has(part.id) && !preserveDelta) touched.delete(part.id)
}
for (const partID of load?.carriedDeltaParts.get(item.id) ?? []) {
if (!fetchedIDs.has(partID)) touched.delete(partID)
}
const parts = reconcileFetched(fetched, data.part[item.id] ?? [], { touched })
if (!parts.length) {
orphanParts.get(sessionID)?.delete(item.id)
setData(produce((draft) => deleteMessageParts(draft, item.id)))
continue
}
const partIDs = new Set(parts.map((part) => part.id))
setData(
"part_text_accum_delta",
produce((draft) => {
for (const part of data.part[item.id] ?? []) {
if (!partIDs.has(part.id) || !touched.has(part.id)) {
delete draft[part.id]
deltaBases.delete(part.id)
}
}
}),
)
setData("part", item.id, reconcile(parts, { key: "id" }))
orphanParts.get(sessionID)?.delete(item.id)
}
}
const applyMessagePage = (
sessionID: string,
page: MessagePage,
load: MessageLoadState | undefined,
preserveUnfetched: boolean | ((message: Message) => boolean),
cleanupOrphans: boolean,
) => {
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
merged.observed.forEach((item) => {
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
})
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
touched: touchedMessages,
retained: load?.retainedMessages,
preserveUnfetched,
})
batch(() => {
const messageIDs = replaceMessages(sessionID, messages)
replaceParts(sessionID, merged.part, messageIDs, load)
const orphans = orphanParts.get(sessionID)
if (cleanupOrphans && page.complete && orphans) {
for (const messageID of orphans) {
if (!messageIDs.has(messageID)) setData(produce((draft) => deleteMessageParts(draft, messageID)))
}
orphanParts.delete(sessionID)
}
setMeta("limit", sessionID, messages.length)
setMeta("cursor", sessionID, merged.cursor)
setMeta("complete", sessionID, merged.complete)
setMeta("at", sessionID, Date.now())
})
}
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
if (meta.loading[sessionID]) return
const active = generation(sessionID)
const load: MessageLoadState = {
touchedMessages: new Set(),
removedMessages: new Set(),
retainedMessages: new Set(),
touchedParts: new Map(),
deltaParts: new Map(),
carriedDeltaParts: new Map(),
removedParts: new Map(),
optimisticParts: new Map(),
orphanParents: new Set(),
clearedMessageParts: new Set(),
}
messageLoads.set(sessionID, load)
const generation = generations.get(sessionID) ?? 0
setMeta("loading", sessionID, true)
let applied = false
await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
await fetchMessages(sessionID, limit, before)
.then((page) => {
if (generations.get(sessionID) !== active) return
const first = page.session.reduce<Message | undefined>(
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
undefined,
)
const preserveUnfetched =
mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
applyMessagePage(
sessionID,
page,
messageLoads.get(sessionID) === load ? load : undefined,
preserveUnfetched,
mode !== "prepend",
)
applied = true
if ((generations.get(sessionID) ?? 0) !== generation) return
const next = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
next.confirmed.forEach((messageID) => clearOptimistic(sessionID, messageID))
const messages = mode === "prepend" ? merge(data.message[sessionID] ?? [], next.session) : next.session
batch(() => {
setData("message", sessionID, reconcile(messages, { key: "id" }))
for (const item of next.part) {
const parts = item.part.filter((part) => !SKIP_PARTS.has(part.type))
if (parts.length) setData("part", item.id, reconcile(parts, { key: "id" }))
}
setMeta("limit", sessionID, messages.length)
setMeta("cursor", sessionID, next.cursor)
setMeta("complete", sessionID, next.complete)
setMeta("at", sessionID, Date.now())
})
})
.finally(() => {
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
for (const messageID of load.orphanParents) {
if (!orphanParts.get(sessionID)?.has(messageID)) continue
setData(produce((draft) => deleteMessageParts(draft, messageID)))
orphanParts.get(sessionID)?.delete(messageID)
}
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
}
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
if ((generations.get(sessionID) ?? 0) === generation) setMeta("loading", sessionID, false)
})
}
@@ -660,13 +339,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const eventID = eventSessionID(event)
if (eventID) {
touch(eventID)
if (
!data.info[eventID] &&
event.type !== "session.created" &&
event.type !== "session.updated" &&
event.type !== "session.deleted"
)
void resolve(eventID).catch(() => {})
if (!data.info[eventID]) void resolve(eventID).catch(() => {})
}
switch (event.type) {
case "session.created":
@@ -705,21 +378,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
}
case "message.updated": {
const info = cleanMessage((event.properties as { info: Message }).info)
const load = messageLoads.get(info.sessionID)
load?.touchedMessages.add(info.id)
load?.removedMessages.delete(info.id)
const items = optimistic.get(info.sessionID)
const item = items?.get(info.id)
if (items && item) {
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
}
const orphans = orphanParts.get(info.sessionID)
orphans?.delete(info.id)
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
const removedMessagesForSession = removedMessages.get(info.sessionID)
removedMessagesForSession?.delete(info.id)
if (removedMessagesForSession?.size === 0) removedMessages.delete(info.sessionID)
const messages = data.message[info.sessionID]
if (!messages) {
setData("message", info.sessionID, [info])
@@ -737,20 +395,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
}
case "message.removed": {
const props = event.properties as { sessionID: string; messageID: string }
const load = messageLoads.get(props.sessionID)
load?.touchedMessages.add(props.messageID)
load?.removedMessages.add(props.messageID)
load?.clearedMessageParts.add(props.messageID)
load?.deltaParts.delete(props.messageID)
load?.carriedDeltaParts.delete(props.messageID)
load?.removedParts.delete(props.messageID)
load?.optimisticParts.delete(props.messageID)
pendingParts.get(props.sessionID)?.delete(props.messageID)
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
removedMessagesForSession.add(props.messageID)
removedMessages.set(props.sessionID, removedMessagesForSession)
clearOptimistic(props.sessionID, props.messageID)
setData(
produce((draft) => {
const messages = draft.message[props.sessionID]
@@ -758,7 +402,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const result = Binary.search(messages, props.messageID, (message) => message.id)
if (result.found) messages.splice(result.index, 1)
}
deleteMessageParts(draft, props.messageID)
for (const part of draft.part[props.messageID] ?? []) delete draft.part_text_accum_delta[part.id]
delete draft.part[props.messageID]
}),
)
return
@@ -766,42 +411,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
case "message.part.updated": {
const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) return
const messages = data.message[part.sessionID]
const load = messageLoads.get(part.sessionID)
const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
if (
missing &&
(!load ||
load.clearedMessageParts.has(part.messageID) ||
removedMessages.get(part.sessionID)?.has(part.messageID))
)
return
if (missing) {
const orphans = orphanParts.get(part.sessionID) ?? new Set<string>()
orphans.add(part.messageID)
orphanParts.set(part.sessionID, orphans)
load?.orphanParents.add(part.messageID)
}
const deltas = load?.deltaParts.get(part.messageID)
deltas?.delete(part.id)
if (deltas?.size === 0) load?.deltaParts.delete(part.messageID)
const carried = load?.carriedDeltaParts.get(part.messageID)
carried?.delete(part.id)
if (carried?.size === 0) load?.carriedDeltaParts.delete(part.messageID)
const removed = load?.removedParts.get(part.messageID)
removed?.delete(part.id)
if (removed?.size === 0) load?.removedParts.delete(part.messageID)
const pending = pendingParts.get(part.sessionID)?.get(part.messageID)
pending?.delete(part.id)
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
const optimistic = load?.optimisticParts.get(part.messageID)
optimistic?.delete(part.id)
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
deltaBases.delete(part.id)
trackPartChange(part.sessionID, part.messageID, part.id)
confirmOptimisticPart(part.sessionID, part.messageID, part)
setData(
"part_text_accum_delta",
produce((draft) => void delete draft[part.id]),
@@ -822,34 +431,10 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return
}
case "message.part.removed": {
const props = event.properties as { sessionID: string; messageID: string; partID: string }
// Part removal is event-only on the server, so its tombstone lasts until a later update or eviction.
const pending = pendingParts.get(props.sessionID) ?? new Map<string, Set<string>>()
const parts = pending.get(props.messageID) ?? new Set<string>()
parts.add(props.partID)
pending.set(props.messageID, parts)
pendingParts.set(props.sessionID, pending)
const deltas = messageLoads.get(props.sessionID)?.deltaParts.get(props.messageID)
deltas?.delete(props.partID)
if (deltas?.size === 0) messageLoads.get(props.sessionID)?.deltaParts.delete(props.messageID)
const load = messageLoads.get(props.sessionID)
const carried = load?.carriedDeltaParts.get(props.messageID)
carried?.delete(props.partID)
if (carried?.size === 0) load?.carriedDeltaParts.delete(props.messageID)
if (load) {
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
parts.add(props.partID)
load.removedParts.set(props.messageID, parts)
const optimistic = load.optimisticParts.get(props.messageID)
optimistic?.delete(props.partID)
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
}
trackPartChange(props.sessionID, props.messageID, props.partID)
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
const props = event.properties as { messageID: string; partID: string }
setData(
produce((draft) => {
delete draft.part_text_accum_delta[props.partID]
deltaBases.delete(props.partID)
const parts = draft.part[props.messageID]
if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id)
@@ -860,31 +445,13 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return
}
case "message.part.delta": {
const props = event.properties as {
sessionID: string
messageID: string
partID: string
field: string
delta: string
}
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
const parts = data.part[props.messageID]
if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id)
if (!result.found) return
trackPartChange(props.sessionID, props.messageID, props.partID)
const load = messageLoads.get(props.sessionID)
if (load) {
const parts = load.deltaParts.get(props.messageID) ?? new Set<string>()
parts.add(props.partID)
load.deltaParts.set(props.messageID, parts)
const carried = load.carriedDeltaParts.get(props.messageID)
carried?.delete(props.partID)
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
}
const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field]
if (!deltaBases.has(props.partID) && typeof current === "string")
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
setData(
"part_text_accum_delta",
props.partID,
@@ -992,70 +559,32 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
},
optimistic: {
add(input: { sessionID: string; message: Message; parts: Part[] }) {
const parts = input.parts
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
.sort((a, b) => cmp(a.id, b.id))
const load = messageLoads.get(input.sessionID)
if (load?.clearedMessageParts.has(input.message.id)) {
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>()
parts.forEach((part) => touched.add(part.id))
load.touchedParts.set(input.message.id, touched)
}
if (load) {
load.removedMessages.delete(input.message.id)
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
}
const items = optimistic.get(input.sessionID)
const removedMessagesForSession = removedMessages.get(input.sessionID)
removedMessagesForSession?.delete(input.message.id)
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
if (!items)
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
if (items) items.set(input.message.id, input)
if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, input]]))
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
setData(
"part_text_accum_delta",
produce((draft) => {
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) {
delete draft[part.id]
deltaBases.delete(part.id)
}
}),
"part",
input.message.id,
input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
)
setData("part", input.message.id, parts)
},
remove(input: { sessionID: string; messageID: string }) {
const item = optimistic.get(input.sessionID)?.get(input.messageID)
if (!item) return
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID)
clearOptimistic(input.sessionID, input.messageID)
if (item.confirmedMessage) {
const partIDs = new Set(item.parts.map((part) => part.id))
setData(
produce((draft) => {
for (const part of item.parts) {
delete draft.part_text_accum_delta[part.id]
deltaBases.delete(part.id)
}
const parts = draft.part[input.messageID]
if (!parts) return
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id))
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID]
}),
)
return
}
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
setData(
"part",
produce((draft) => void delete draft[input.messageID]),
)
},
},
diff(sessionID: string, options?: { force?: boolean }) {
touch(sessionID)
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightDiff, sessionID, () => {
const active = generation(sessionID)
const generation = generations.get(sessionID) ?? 0
return retry(() => client.session.diff({ sessionID })).then((result) => {
if (generations.get(sessionID) !== active) return
if ((generations.get(sessionID) ?? 0) !== generation) return
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
})
})
@@ -1064,9 +593,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
touch(sessionID)
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightTodo, sessionID, () => {
const active = generation(sessionID)
const generation = generations.get(sessionID) ?? 0
return retry(() => client.session.todo({ sessionID })).then((result) => {
if (generations.get(sessionID) !== active) return
if ((generations.get(sessionID) ?? 0) !== generation) return
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
})
})
+15 -17
View File
@@ -163,29 +163,27 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
const draftID = uuid()
void startTransition(() => {
setStore(
produce((tabs) => {
tabs.push({ type: "draft", draftID, ...draft })
}),
)
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
})
setStore(
produce((tabs) => {
tabs.push({ type: "draft", draftID, ...draft })
}),
)
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
},
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
void startTransition(() => {
setStore(
(tab) => tab.type === "draft" && tab.draftID === draftID,
produce((tab) => Object.assign(tab, draft)),
)
})
setStore(
(tab) => tab.type === "draft" && tab.draftID === draftID,
produce((tab) => Object.assign(tab, draft)),
)
},
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
// Keep the replacement and navigation atomic so /new-session never renders
// after its backing draft tab has been removed from the store.
// We're viewing this draft when /new-session?draftId=… points at it. Promoting
// replaces the draft tab with a session tab, so the draft route would stop resolving
// and fall back home. Navigate to the new session first so we leave /new-session
// before the draft is removed from the store.
const active = location.pathname === "/new-session" && location.query.draftId === draftID
const next = { type: "session" as const, ...session }
void startTransition(() => {
startTransition(() => {
setStore(
produce((tabs) => {
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
+9 -1
View File
@@ -1,7 +1,6 @@
@import "@opencode-ai/ui/styles/tailwind";
@import "@opencode-ai/session-ui/styles";
@import "@opencode-ai/ui/v2/styles/tailwind.css";
@import "tw-animate-css";
@font-face {
font-family: "JetBrainsMono Nerd Font Mono";
@@ -132,4 +131,13 @@
transform: rotate(360deg);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
+27 -34
View File
@@ -68,7 +68,6 @@ import { archiveHomeSession } from "./home-session-archive"
import { showToast } from "@/utils/toast"
const HOME_SESSION_LIMIT = 64
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
@@ -302,7 +301,6 @@ export function NewHome() {
function selectProject(conn: ServerConnection.Any, directory: string) {
const key = ServerConnection.key(conn)
if (global.servers.health[key]?.healthy === false) return
if (
!global
.ensureServerCtx(conn)
@@ -343,15 +341,15 @@ export function NewHome() {
}
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
const state = notification.ensureServerState(ServerConnection.key(conn))
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
if (ServerConnection.key(conn) !== server.key) return 0
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
}
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
const state = notification.ensureServerState(ServerConnection.key(conn))
if (ServerConnection.key(conn) !== server.key) return
directories(project)
.filter((directory) => state.project.unseenCount(directory) > 0)
.forEach((directory) => state.project.markViewed(directory))
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
}
function openSession(session: Session) {
@@ -409,7 +407,7 @@ export function NewHome() {
return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16">
<HomeProjectColumn
projects={projects()}
selected={selection()}
@@ -456,7 +454,7 @@ export function NewHome() {
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<ScrollView class="mt-3 -mr-3 min-h-0 flex-1">
<ScrollView class="mt-3 min-h-0 flex-1">
<Show
when={!sessionLoad.isLoading}
fallback={
@@ -469,7 +467,7 @@ export function NewHome() {
when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
>
<div class="flex flex-col gap-6 pt-3 pr-3 pb-16">
<div class="pt-3 flex flex-col gap-6">
<For each={groups()}>
{(group, index) => (
<div class="flex min-w-0 flex-col gap-4">
@@ -768,18 +766,15 @@ function HomeProjectRow(props: {
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
language: ReturnType<typeof useLanguage>
}) {
const global = useGlobal()
const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
const [state, setState] = createStore({ menuOpen: false })
return (
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
<button
type="button"
data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
disabled={serverUnreachable()}
onClick={() => props.selectProject(props.server, props.project.worktree)}
>
<HomeProjectAvatar project={props.project} />
@@ -969,7 +964,7 @@ function HomeSessionSearch(props: {
style={{
top: "-6px",
left: "-6px",
width: "calc(100% + 12px)",
width: "calc(100% + 14px)",
}}
>
<div class="flex flex-col pt-9">
@@ -1137,7 +1132,7 @@ function HomeSessionSearchResultRow(props: {
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
const language = useLanguage()
return (
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
<div class="flex h-7 min-w-0 items-center justify-between pl-[18px]">
<div class={HOME_SECTION_LABEL}>{props.title}</div>
<Show when={props.onNewSession}>
{(onNewSession) => (
@@ -1194,24 +1189,22 @@ function HomeSessionRow(props: {
</span>
</Show>
</button>
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
<IconButtonV2
data-action="home-session-archive"
variant="ghost-muted"
size="large"
icon={<IconV2 name="archive" />}
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.record.session)
}}
/>
</TooltipV2>
</div>
</Show>
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
<IconButtonV2
data-action="home-session-archive"
variant="ghost-muted"
size="large"
icon={<IconV2 name="archive" />}
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.record.session)
}}
/>
</TooltipV2>
</div>
</div>
)
}
+10 -2
View File
@@ -1,18 +1,26 @@
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { useNavigate, useParams } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useNotification } from "@/context/notification"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const notification = useNotification()
const navigate = useNavigate()
const params = useParams<{ id?: string }>()
setNavigate(navigate)
createEffect(() => setV2Toast(true))
createEffect(() => {
if (!notification.ready() || !params.id) return
if (notification.session.unseenCount(params.id) === 0) return
notification.session.markViewed(params.id)
})
const update: TitlebarUpdate = {
version: () => {
@@ -36,7 +44,7 @@ export default function NewLayout(props: ParentProps) {
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense>
</main>
{import.meta.env.DEV && <DebugBar inline />}
{import.meta.env.DEV && <DebugBar />}
<HelpButton />
<ToastRegion v2 />
</div>
-16
View File
@@ -63,22 +63,6 @@ termctrl show opencode-v2-dev
termctrl stop opencode-v2-dev
```
## Server/API debugging
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
```bash
bun dev api get /health
bun dev api get /openapi.json
bun dev api <operationId> --param key=value
```
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
-3
View File
@@ -25,15 +25,12 @@
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"jsonc-parser": "3.3.1",
"semver": "catalog:",
"solid-js": "catalog:"
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
@@ -3,14 +3,11 @@ import { Runtime } from "../../framework/runtime"
import { Effect, Option } from "effect"
import { Daemon } from "../../services/daemon"
import { Standalone } from "../../services/standalone"
import { Updater } from "../../services/updater"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
const directory = Option.getOrUndefined(input.directory)
if (directory !== undefined) process.chdir(directory)
const updater = yield* Updater.Service
yield* updater.check()
const daemon = yield* Daemon.Service
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
const { runTui } = yield* Effect.promise(() => import("../../tui"))
@@ -11,7 +11,6 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Daemon } from "../../services/daemon"
import { Updater } from "../../services/updater"
export default Runtime.handler(
Commands.commands.serve,
@@ -33,8 +32,6 @@ export default Runtime.handler(
if (input.register) yield* daemon.register(address)
const url = HttpServer.formatAddress(address)
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped)
return yield* (input.stdio ? waitForStdinClose() : Effect.never)
}).pipe(Effect.annotateLogs({ role: "server" })),
)
+3 -4
View File
@@ -2,7 +2,6 @@ import * as Effect from "effect/Effect"
import * as Command from "effect/unstable/cli/Command"
import { Spec } from "./spec"
import { Daemon } from "../services/daemon"
import { Updater } from "../services/updater"
import { Scope } from "effect"
export type Input<Value> =
@@ -12,11 +11,11 @@ export type Input<Value> =
? Input
: never
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Updater.Service | Scope.Scope>
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Scope.Scope>
type Loader<Node extends Spec.Any> = () => Promise<{
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Updater.Service | Scope.Scope>
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Scope.Scope>
}>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Updater.Service | Scope.Scope>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Scope.Scope>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node>
+1 -5
View File
@@ -9,8 +9,6 @@ import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Daemon } from "./services/daemon"
import { Logging } from "@opencode-ai/core/observability/logging"
import { Updater } from "./services/updater"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
Layer.provide(NodeFileSystem.layer),
@@ -35,11 +33,9 @@ const Handlers = Runtime.handlers(Commands, {
serve: () => import("./commands/handlers/serve"),
})
Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe(
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Daemon.defaultLayer),
Effect.provide(Updater.defaultLayer),
Effect.provide(LoggingLayer),
Effect.provide(NodeServices.layer),
Effect.scoped,
-33
View File
@@ -1,33 +0,0 @@
import { describe, expect, test } from "bun:test"
import { action, decodePolicy } from "./updater"
describe("updater", () => {
test("reads autoupdate from JSONC", () => {
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
expect(decodePolicy('{ "autoupdate": "invalid" }')).toBeUndefined()
})
test("automatically updates patches and minors", () => {
expect(action("1.2.3", "1.2.4", true)).toBe("upgrade")
expect(action("1.2.3", "1.3.0", true)).toBe("upgrade")
expect(action("1.2.3", "1.2.4", "notify")).toBe("upgrade")
expect(action("1.2.3", "1.3.0", "notify")).toBe("upgrade")
})
test("skips when autoupdate is disabled", () => {
expect(action("1.2.3", "1.2.4", false)).toBe("none")
})
test("never automatically updates majors", () => {
expect(action("1.2.3", "2.0.0", true)).toBe("none")
})
test("reports up-to-date only when versions match", () => {
expect(action("1.2.3", "1.2.3", true)).toBe("none")
})
test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
})
})
-158
View File
@@ -1,158 +0,0 @@
import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { AppProcess } from "@opencode-ai/core/process"
import {
InstallationChannel,
InstallationLocal,
InstallationVersion,
} from "@opencode-ai/core/installation/version"
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import semver from "semver"
export type Policy = boolean | "notify"
export type Action = "none" | "upgrade"
type Method = "npm" | "pnpm" | "bun" | "yarn"
const packageName = "@opencode-ai/cli"
export interface Interface {
readonly check: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
export function decodePolicy(text: string): Policy | undefined {
// The CLI only projects this host-level preference instead of initializing
// the location-scoped server configuration graph.
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length || typeof input !== "object" || input === null || !("autoupdate" in input)) return
const value = input.autoupdate
if (typeof value === "boolean" || value === "notify") return value
}
export function action(current: string, latest: string, policy: Policy): Action {
if (policy === false) return "none"
if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
// Major upgrades are never installed automatically.
if (semver.major(latest) !== semver.major(current)) return "none"
return "upgrade"
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
fs
.readFileString(path.join(global.config, name))
.pipe(Effect.map(decodePolicy), Effect.catch(() => Effect.succeed(undefined))),
)
return values.findLast((value) => value !== undefined) ?? true
})
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
return yield* appProcess
.run(ChildProcess.make(command[0], command.slice(1)), {
timeout,
maxOutputBytes: 100_000,
maxErrorBytes: 100_000,
})
.pipe(
Effect.map((result) => ({
code: result.exitCode,
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
})),
Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })),
)
})
const method = Effect.fnUntraced(function* () {
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
]
const results = yield* Effect.forEach(
checks,
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(packageName))?.check.method
})
const latest = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({
try: () =>
fetch(
`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`,
{ headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) },
),
catch: (cause) => new Error("Failed to check for updates", { cause }),
})
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
const data = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) => new Error("Failed to read update information", { cause }),
})
if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
return yield* Effect.fail(new Error("Update information did not include a version"))
}
return data.version
})
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
const target = `${packageName}@${version}`
const commands: Record<Method, string[]> = {
npm: ["npm", "install", "--global", target],
pnpm: ["pnpm", "install", "--global", target],
bun: ["bun", "install", "--global", target],
yarn: ["yarn", "global", "add", target],
}
const result = yield* run(commands[method], "5 minutes")
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
const check = Effect.fn("cli.updater.check")(function* () {
if (InstallationLocal || Flag.OPENCODE_DISABLE_AUTOUPDATE)
return yield* Effect.logInfo("update check skipped", {
reason: InstallationLocal ? "local-install" : "disabled",
version: InstallationVersion,
channel: InstallationChannel,
})
const policy = yield* readPolicy()
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
return yield* Effect.gen(function* () {
const version = yield* latest()
yield* Effect.logInfo("update check", {
current: InstallationVersion,
latest: version,
})
const next = action(InstallationVersion, version, policy)
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
const detected = yield* method()
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected })
})
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
return Service.of({ check })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer), Layer.provide(Global.defaultLayer))
export * as Updater from "./updater"
+1 -1
View File
@@ -7,7 +7,7 @@ Private generation target for clients derived directly from OpenCode's authorita
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
The generated surface includes every standard HTTP group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Custom transports such as the PTY WebSocket connection remain outside the generic HTTP client. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
The generated surface includes every group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API.
+5 -14
View File
@@ -1,27 +1,18 @@
import { NodeFileSystem } from "@effect/platform-node"
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract"
import { Api } from "@opencode-ai/server/api"
import { Effect } from "effect"
import { fileURLToPath } from "url"
import { endpointNames, groupNames } from "../src/contract"
const contract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints })
const contract = compile(Api, { groupNames, endpointNames })
await Effect.runPromise(
Effect.all(
[
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
write(
emitPromise(contract, {
outputTypes: {
"events.subscribe": {
name: "OpenCodeEventEncoded",
import: 'import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"',
},
},
}),
fileURLToPath(new URL("../src/generated", import.meta.url)),
),
write(
emitEffectImported(contract, { module: "../contract", api: "ClientApi" }),
emitEffectImported(contract, { module: "../contract", api: "Api" }),
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
),
],
+1 -3
View File
@@ -11,7 +11,7 @@ class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocatio
{ error: [InvalidRequestError, SessionNotFoundError] },
) {}
export const ClientApi = makeDefaultApi({
export const Api = makeDefaultApi({
locationMiddleware: LocationMiddleware,
sessionLocationMiddleware: SessionLocationMiddleware,
})
@@ -49,5 +49,3 @@ export const endpointNames = {
"permission.saved.remove": "removeSaved",
"question.request.list": "listRequests",
} as const
export const omitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"])
-13
View File
@@ -2,24 +2,11 @@
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
export * from "./generated-effect/index"
export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
export { Credential } from "@opencode-ai/schema/credential"
export { FileSystem } from "@opencode-ai/schema/filesystem"
export { Integration } from "@opencode-ai/schema/integration"
export { Location } from "@opencode-ai/schema/location"
export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Project } from "@opencode-ai/schema/project"
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
export { Provider } from "@opencode-ai/schema/provider"
export { Pty } from "@opencode-ai/schema/pty"
export { Question } from "@opencode-ai/schema/question"
export { Reference } from "@opencode-ai/schema/reference"
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
export { Session } from "@opencode-ai/schema/session"
export { SessionInput } from "@opencode-ai/schema/session-input"
export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Skill } from "@opencode-ai/schema/skill"
export { Prompt } from "@opencode-ai/schema/prompt"
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
+182 -196
View File
@@ -3,10 +3,10 @@ import { Effect, Stream, Schema } from "effect"
import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../contract"
import { Api } from "../contract"
import { ClientError } from "./client-error"
type RawClient = HttpApiClient.ForApi<typeof ClientApi>
type RawClient = HttpApiClient.ForApi<typeof Api>
const mapClientError = <E>(error: E) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
@@ -21,14 +21,14 @@ const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw
type Endpoint1_0Request = Parameters<RawClient["server.location"]["location.get"]>[0]
type Endpoint1_0Input = { readonly location?: Endpoint1_0Request["query"]["location"] }
const Endpoint1_0 = (raw: RawClient["server.location"]) => (input?: Endpoint1_0Input) =>
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
raw["location.get"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup1 = (raw: RawClient["server.location"]) => ({ get: Endpoint1_0(raw) })
type Endpoint2_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] }
const Endpoint2_0 = (raw: RawClient["server.agent"]) => (input?: Endpoint2_0Input) =>
raw["agent.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
raw["agent.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup2 = (raw: RawClient["server.agent"]) => ({ list: Endpoint2_0(raw) })
@@ -46,14 +46,14 @@ type Endpoint3_0Input = {
const Endpoint3_0 = (raw: RawClient["server.session"]) => (input?: Endpoint3_0Input) =>
raw["session.list"]({
query: {
workspace: input?.["workspace"],
limit: input?.["limit"],
order: input?.["order"],
search: input?.["search"],
directory: input?.["directory"],
project: input?.["project"],
subpath: input?.["subpath"],
cursor: input?.["cursor"],
workspace: input?.workspace,
limit: input?.limit,
order: input?.order,
search: input?.search,
directory: input?.directory,
project: input?.project,
subpath: input?.subpath,
cursor: input?.cursor,
},
}).pipe(Effect.mapError(mapClientError))
@@ -66,7 +66,7 @@ type Endpoint3_1Input = {
}
const Endpoint3_1 = (raw: RawClient["server.session"]) => (input?: Endpoint3_1Input) =>
raw["session.create"]({
payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] },
payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -81,7 +81,7 @@ const Endpoint3_2 = (raw: RawClient["server.session"]) => () =>
type Endpoint3_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
type Endpoint3_3Input = { readonly sessionID: Endpoint3_3Request["params"]["sessionID"] }
const Endpoint3_3 = (raw: RawClient["server.session"]) => (input: Endpoint3_3Input) =>
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
@@ -92,7 +92,7 @@ type Endpoint3_4Input = {
readonly agent: Endpoint3_4Request["payload"]["agent"]
}
const Endpoint3_4 = (raw: RawClient["server.session"]) => (input: Endpoint3_4Input) =>
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
Effect.mapError(mapClientError),
)
@@ -102,117 +102,95 @@ type Endpoint3_5Input = {
readonly model: Endpoint3_5Request["payload"]["model"]
}
const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Input) =>
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.rename"]>[0]
type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint3_6Input = {
readonly sessionID: Endpoint3_6Request["params"]["sessionID"]
readonly title: Endpoint3_6Request["payload"]["title"]
readonly id?: Endpoint3_6Request["payload"]["id"]
readonly prompt: Endpoint3_6Request["payload"]["prompt"]
readonly delivery?: Endpoint3_6Request["payload"]["delivery"]
readonly resume?: Endpoint3_6Request["payload"]["resume"]
}
const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) =>
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint3_7Input = {
readonly sessionID: Endpoint3_7Request["params"]["sessionID"]
readonly id?: Endpoint3_7Request["payload"]["id"]
readonly prompt: Endpoint3_7Request["payload"]["prompt"]
readonly delivery?: Endpoint3_7Request["payload"]["delivery"]
readonly resume?: Endpoint3_7Request["payload"]["resume"]
}
const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
raw["session.prompt"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
params: { sessionID: input.sessionID },
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint3_7Input = { readonly sessionID: Endpoint3_7Request["params"]["sessionID"] }
const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] }
const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint3_9Input = { readonly sessionID: Endpoint3_9Request["params"]["sessionID"] }
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint3_10Input = {
readonly sessionID: Endpoint3_10Request["params"]["sessionID"]
readonly messageID: Endpoint3_10Request["payload"]["messageID"]
readonly files?: Endpoint3_10Request["payload"]["files"]
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint3_9Input = {
readonly sessionID: Endpoint3_9Request["params"]["sessionID"]
readonly messageID: Endpoint3_9Request["payload"]["messageID"]
readonly files?: Endpoint3_9Request["payload"]["files"]
}
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
params: { sessionID: input.sessionID },
payload: { messageID: input.messageID, files: input.files },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] }
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] }
const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] }
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.history"]>[0]
type Endpoint3_14Input = {
readonly sessionID: Endpoint3_14Request["params"]["sessionID"]
readonly limit?: Endpoint3_14Request["query"]["limit"]
readonly after?: Endpoint3_14Request["query"]["after"]
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint3_13Input = {
readonly sessionID: Endpoint3_13Request["params"]["sessionID"]
readonly after?: Endpoint3_13Request["query"]["after"]
}
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
raw["session.history"]({
params: { sessionID: input["sessionID"] },
query: { limit: input["limit"], after: input["after"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint3_15Input = {
readonly sessionID: Endpoint3_15Request["params"]["sessionID"]
readonly after?: Endpoint3_15Request["query"]["after"]
}
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
Stream.unwrap(
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
Effect.mapError(mapClientError),
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
),
)
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint3_16Input = { readonly sessionID: Endpoint3_16Request["params"]["sessionID"] }
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint3_14Input = { readonly sessionID: Endpoint3_14Request["params"]["sessionID"] }
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_17Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint3_17Input = {
readonly sessionID: Endpoint3_17Request["params"]["sessionID"]
readonly messageID: Endpoint3_17Request["params"]["messageID"]
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint3_15Input = {
readonly sessionID: Endpoint3_15Request["params"]["sessionID"]
readonly messageID: Endpoint3_15Request["params"]["messageID"]
}
const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
@@ -224,18 +202,16 @@ const adaptGroup3 = (raw: RawClient["server.session"]) => ({
get: Endpoint3_3(raw),
switchAgent: Endpoint3_4(raw),
switchModel: Endpoint3_5(raw),
rename: Endpoint3_6(raw),
prompt: Endpoint3_7(raw),
compact: Endpoint3_8(raw),
wait: Endpoint3_9(raw),
stage: Endpoint3_10(raw),
clear: Endpoint3_11(raw),
commit: Endpoint3_12(raw),
context: Endpoint3_13(raw),
history: Endpoint3_14(raw),
events: Endpoint3_15(raw),
interrupt: Endpoint3_16(raw),
message: Endpoint3_17(raw),
prompt: Endpoint3_6(raw),
compact: Endpoint3_7(raw),
wait: Endpoint3_8(raw),
stage: Endpoint3_9(raw),
clear: Endpoint3_10(raw),
commit: Endpoint3_11(raw),
context: Endpoint3_12(raw),
events: Endpoint3_13(raw),
interrupt: Endpoint3_14(raw),
message: Endpoint3_15(raw),
})
type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
@@ -247,8 +223,8 @@ type Endpoint4_0Input = {
}
const Endpoint4_0 = (raw: RawClient["server.message"]) => (input: Endpoint4_0Input) =>
raw["session.messages"]({
params: { sessionID: input["sessionID"] },
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
params: { sessionID: input.sessionID },
query: { limit: input.limit, order: input.order, cursor: input.cursor },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup4 = (raw: RawClient["server.message"]) => ({ list: Endpoint4_0(raw) })
@@ -256,14 +232,14 @@ const adaptGroup4 = (raw: RawClient["server.message"]) => ({ list: Endpoint4_0(r
type Endpoint5_0Request = Parameters<RawClient["server.model"]["model.list"]>[0]
type Endpoint5_0Input = { readonly location?: Endpoint5_0Request["query"]["location"] }
const Endpoint5_0 = (raw: RawClient["server.model"]) => (input?: Endpoint5_0Input) =>
raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
raw["model.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup5 = (raw: RawClient["server.model"]) => ({ list: Endpoint5_0(raw) })
type Endpoint6_0Request = Parameters<RawClient["server.provider"]["provider.list"]>[0]
type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["location"] }
const Endpoint6_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint6_0Input) =>
raw["provider.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
raw["provider.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint6_1Request = Parameters<RawClient["server.provider"]["provider.get"]>[0]
type Endpoint6_1Input = {
@@ -271,7 +247,7 @@ type Endpoint6_1Input = {
readonly location?: Endpoint6_1Request["query"]["location"]
}
const Endpoint6_1 = (raw: RawClient["server.provider"]) => (input: Endpoint6_1Input) =>
raw["provider.get"]({ params: { providerID: input["providerID"] }, query: { location: input["location"] } }).pipe(
raw["provider.get"]({ params: { providerID: input.providerID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
@@ -280,7 +256,7 @@ const adaptGroup6 = (raw: RawClient["server.provider"]) => ({ list: Endpoint6_0(
type Endpoint7_0Request = Parameters<RawClient["server.integration"]["integration.list"]>[0]
type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] }
const Endpoint7_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint7_0Input) =>
raw["integration.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
raw["integration.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint7_1Request = Parameters<RawClient["server.integration"]["integration.get"]>[0]
type Endpoint7_1Input = {
@@ -288,10 +264,9 @@ type Endpoint7_1Input = {
readonly location?: Endpoint7_1Request["query"]["location"]
}
const Endpoint7_1 = (raw: RawClient["server.integration"]) => (input: Endpoint7_1Input) =>
raw["integration.get"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
raw["integration.get"]({ params: { integrationID: input.integrationID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint7_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
type Endpoint7_2Input = {
@@ -302,9 +277,9 @@ type Endpoint7_2Input = {
}
const Endpoint7_2 = (raw: RawClient["server.integration"]) => (input: Endpoint7_2Input) =>
raw["integration.connect.key"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
payload: { key: input["key"], label: input["label"] },
params: { integrationID: input.integrationID },
query: { location: input.location },
payload: { key: input.key, label: input.label },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
@@ -317,9 +292,9 @@ type Endpoint7_3Input = {
}
const Endpoint7_3 = (raw: RawClient["server.integration"]) => (input: Endpoint7_3Input) =>
raw["integration.connect.oauth"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
params: { integrationID: input.integrationID },
query: { location: input.location },
payload: { methodID: input.methodID, inputs: input.inputs, label: input.label },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
@@ -329,8 +304,8 @@ type Endpoint7_4Input = {
}
const Endpoint7_4 = (raw: RawClient["server.integration"]) => (input: Endpoint7_4Input) =>
raw["integration.attempt.status"]({
params: { attemptID: input["attemptID"] },
query: { location: input["location"] },
params: { attemptID: input.attemptID },
query: { location: input.location },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
@@ -341,9 +316,9 @@ type Endpoint7_5Input = {
}
const Endpoint7_5 = (raw: RawClient["server.integration"]) => (input: Endpoint7_5Input) =>
raw["integration.attempt.complete"]({
params: { attemptID: input["attemptID"] },
query: { location: input["location"] },
payload: { code: input["code"] },
params: { attemptID: input.attemptID },
query: { location: input.location },
payload: { code: input.code },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
@@ -353,8 +328,8 @@ type Endpoint7_6Input = {
}
const Endpoint7_6 = (raw: RawClient["server.integration"]) => (input: Endpoint7_6Input) =>
raw["integration.attempt.cancel"]({
params: { attemptID: input["attemptID"] },
query: { location: input["location"] },
params: { attemptID: input.attemptID },
query: { location: input.location },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup7 = (raw: RawClient["server.integration"]) => ({
@@ -375,9 +350,9 @@ type Endpoint8_0Input = {
}
const Endpoint8_0 = (raw: RawClient["server.credential"]) => (input: Endpoint8_0Input) =>
raw["credential.update"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
payload: { label: input["label"] },
params: { credentialID: input.credentialID },
query: { location: input.location },
payload: { label: input.label },
}).pipe(Effect.mapError(mapClientError))
type Endpoint8_1Request = Parameters<RawClient["server.credential"]["credential.remove"]>[0]
@@ -386,22 +361,21 @@ type Endpoint8_1Input = {
readonly location?: Endpoint8_1Request["query"]["location"]
}
const Endpoint8_1 = (raw: RawClient["server.credential"]) => (input: Endpoint8_1Input) =>
raw["credential.remove"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
raw["credential.remove"]({ params: { credentialID: input.credentialID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup8 = (raw: RawClient["server.credential"]) => ({ update: Endpoint8_0(raw), remove: Endpoint8_1(raw) })
type Endpoint9_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] }
const Endpoint9_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_0Input) =>
raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
raw["permission.request.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint9_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
type Endpoint9_1Input = { readonly projectID?: Endpoint9_1Request["query"]["projectID"] }
const Endpoint9_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_1Input) =>
raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe(
raw["permission.saved.list"]({ query: { projectID: input?.projectID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
@@ -409,7 +383,7 @@ const Endpoint9_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_
type Endpoint9_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
type Endpoint9_2Input = { readonly id: Endpoint9_2Request["params"]["id"] }
const Endpoint9_2 = (raw: RawClient["server.permission"]) => (input: Endpoint9_2Input) =>
raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError))
raw["permission.saved.remove"]({ params: { id: input.id } }).pipe(Effect.mapError(mapClientError))
type Endpoint9_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
type Endpoint9_3Input = {
@@ -424,15 +398,15 @@ type Endpoint9_3Input = {
}
const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3Input) =>
raw["session.permission.create"]({
params: { sessionID: input["sessionID"] },
params: { sessionID: input.sessionID },
payload: {
id: input["id"],
action: input["action"],
resources: input["resources"],
save: input["save"],
metadata: input["metadata"],
source: input["source"],
agent: input["agent"],
id: input.id,
action: input.action,
resources: input.resources,
save: input.save,
metadata: input.metadata,
source: input.source,
agent: input.agent,
},
}).pipe(
Effect.mapError(mapClientError),
@@ -442,7 +416,7 @@ const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3
type Endpoint9_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
type Endpoint9_4Input = { readonly sessionID: Endpoint9_4Request["params"]["sessionID"] }
const Endpoint9_4 = (raw: RawClient["server.permission"]) => (input: Endpoint9_4Input) =>
raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.permission.list"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
@@ -453,7 +427,7 @@ type Endpoint9_5Input = {
readonly requestID: Endpoint9_5Request["params"]["requestID"]
}
const Endpoint9_5 = (raw: RawClient["server.permission"]) => (input: Endpoint9_5Input) =>
raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
raw["session.permission.get"]({ params: { sessionID: input.sessionID, requestID: input.requestID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
@@ -467,8 +441,8 @@ type Endpoint9_6Input = {
}
const Endpoint9_6 = (raw: RawClient["server.permission"]) => (input: Endpoint9_6Input) =>
raw["session.permission.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { reply: input["reply"], message: input["message"] },
params: { sessionID: input.sessionID, requestID: input.requestID },
payload: { reply: input.reply, message: input.message },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup9 = (raw: RawClient["server.permission"]) => ({
@@ -481,58 +455,60 @@ const adaptGroup9 = (raw: RawClient["server.permission"]) => ({
reply: Endpoint9_6(raw),
})
type Endpoint10_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
type Endpoint10_0Input = {
readonly location?: Endpoint10_0Request["query"]["location"]
readonly path?: Endpoint10_0Request["query"]["path"]
}
type Endpoint10_0Request = Parameters<RawClient["server.fs"]["fs.read"]>[0]
type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] }
const Endpoint10_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint10_0Input) =>
raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe(
Effect.mapError(mapClientError),
)
raw["fs.read"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint10_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
type Endpoint10_1Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
type Endpoint10_1Input = {
readonly location?: Endpoint10_1Request["query"]["location"]
readonly query: Endpoint10_1Request["query"]["query"]
readonly type?: Endpoint10_1Request["query"]["type"]
readonly limit?: Endpoint10_1Request["query"]["limit"]
readonly path?: Endpoint10_1Request["query"]["path"]
}
const Endpoint10_1 = (raw: RawClient["server.fs"]) => (input: Endpoint10_1Input) =>
const Endpoint10_1 = (raw: RawClient["server.fs"]) => (input?: Endpoint10_1Input) =>
raw["fs.list"]({ query: { location: input?.location, path: input?.path } }).pipe(Effect.mapError(mapClientError))
type Endpoint10_2Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
type Endpoint10_2Input = {
readonly location?: Endpoint10_2Request["query"]["location"]
readonly query: Endpoint10_2Request["query"]["query"]
readonly type?: Endpoint10_2Request["query"]["type"]
readonly limit?: Endpoint10_2Request["query"]["limit"]
}
const Endpoint10_2 = (raw: RawClient["server.fs"]) => (input: Endpoint10_2Input) =>
raw["fs.find"]({
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
query: { location: input.location, query: input.query, type: input.type, limit: input.limit },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup10 = (raw: RawClient["server.fs"]) => ({ list: Endpoint10_0(raw), find: Endpoint10_1(raw) })
const adaptGroup10 = (raw: RawClient["server.fs"]) => ({
read: Endpoint10_0(raw),
list: Endpoint10_1(raw),
find: Endpoint10_2(raw),
})
type Endpoint11_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
const Endpoint11_0 = (raw: RawClient["server.command"]) => (input?: Endpoint11_0Input) =>
raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
raw["command.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup11 = (raw: RawClient["server.command"]) => ({ list: Endpoint11_0(raw) })
type Endpoint12_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] }
const Endpoint12_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint12_0Input) =>
raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
raw["skill.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup12 = (raw: RawClient["server.skill"]) => ({ list: Endpoint12_0(raw) })
const Endpoint13_0 = (raw: RawClient["server.event"]) => () =>
Stream.unwrap(
raw["event.subscribe"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
),
)
raw["event.subscribe"]({}).pipe(Effect.mapError(mapClientError))
const adaptGroup13 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint13_0(raw) })
type Endpoint14_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
const Endpoint14_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_0Input) =>
raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
raw["pty.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint14_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
type Endpoint14_1Input = {
@@ -545,14 +521,8 @@ type Endpoint14_1Input = {
}
const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Input) =>
raw["pty.create"]({
query: { location: input?.["location"] },
payload: {
command: input?.["command"],
args: input?.["args"],
cwd: input?.["cwd"],
title: input?.["title"],
env: input?.["env"],
},
query: { location: input?.location },
payload: { command: input?.command, args: input?.args, cwd: input?.cwd, title: input?.title, env: input?.env },
}).pipe(Effect.mapError(mapClientError))
type Endpoint14_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
@@ -561,7 +531,7 @@ type Endpoint14_2Input = {
readonly location?: Endpoint14_2Request["query"]["location"]
}
const Endpoint14_2 = (raw: RawClient["server.pty"]) => (input: Endpoint14_2Input) =>
raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
raw["pty.get"]({ params: { ptyID: input.ptyID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
@@ -574,9 +544,9 @@ type Endpoint14_3Input = {
}
const Endpoint14_3 = (raw: RawClient["server.pty"]) => (input: Endpoint14_3Input) =>
raw["pty.update"]({
params: { ptyID: input["ptyID"] },
query: { location: input["location"] },
payload: { title: input["title"], size: input["size"] },
params: { ptyID: input.ptyID },
query: { location: input.location },
payload: { title: input.title, size: input.size },
}).pipe(Effect.mapError(mapClientError))
type Endpoint14_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
@@ -585,27 +555,44 @@ type Endpoint14_4Input = {
readonly location?: Endpoint14_4Request["query"]["location"]
}
const Endpoint14_4 = (raw: RawClient["server.pty"]) => (input: Endpoint14_4Input) =>
raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
raw["pty.remove"]({ params: { ptyID: input.ptyID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint14_5Request = Parameters<RawClient["server.pty"]["pty.connectToken"]>[0]
type Endpoint14_5Input = {
readonly ptyID: Endpoint14_5Request["params"]["ptyID"]
readonly location?: Endpoint14_5Request["query"]["location"]
}
const Endpoint14_5 = (raw: RawClient["server.pty"]) => (input: Endpoint14_5Input) =>
raw["pty.connectToken"]({ params: { ptyID: input.ptyID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint14_6Request = Parameters<RawClient["server.pty"]["pty.connect"]>[0]
type Endpoint14_6Input = { readonly ptyID: Endpoint14_6Request["params"]["ptyID"] }
const Endpoint14_6 = (raw: RawClient["server.pty"]) => (input: Endpoint14_6Input) =>
raw["pty.connect"]({ params: { ptyID: input.ptyID } }).pipe(Effect.mapError(mapClientError))
const adaptGroup14 = (raw: RawClient["server.pty"]) => ({
list: Endpoint14_0(raw),
create: Endpoint14_1(raw),
get: Endpoint14_2(raw),
update: Endpoint14_3(raw),
remove: Endpoint14_4(raw),
connectToken: Endpoint14_5(raw),
connect: Endpoint14_6(raw),
})
type Endpoint15_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
const Endpoint15_0 = (raw: RawClient["server.question"]) => (input?: Endpoint15_0Input) =>
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
raw["question.request.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint15_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
type Endpoint15_1Input = { readonly sessionID: Endpoint15_1Request["params"]["sessionID"] }
const Endpoint15_1 = (raw: RawClient["server.question"]) => (input: Endpoint15_1Input) =>
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.question.list"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
@@ -618,8 +605,8 @@ type Endpoint15_2Input = {
}
const Endpoint15_2 = (raw: RawClient["server.question"]) => (input: Endpoint15_2Input) =>
raw["session.question.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { answers: input["answers"] },
params: { sessionID: input.sessionID, requestID: input.requestID },
payload: { answers: input.answers },
}).pipe(Effect.mapError(mapClientError))
type Endpoint15_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
@@ -628,7 +615,7 @@ type Endpoint15_3Input = {
readonly requestID: Endpoint15_3Request["params"]["requestID"]
}
const Endpoint15_3 = (raw: RawClient["server.question"]) => (input: Endpoint15_3Input) =>
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
raw["session.question.reject"]({ params: { sessionID: input.sessionID, requestID: input.requestID } }).pipe(
Effect.mapError(mapClientError),
)
@@ -642,7 +629,7 @@ const adaptGroup15 = (raw: RawClient["server.question"]) => ({
type Endpoint16_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
const Endpoint16_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint16_0Input) =>
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
raw["reference.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup16 = (raw: RawClient["server.reference"]) => ({ list: Endpoint16_0(raw) })
@@ -656,9 +643,9 @@ type Endpoint17_0Input = {
}
const Endpoint17_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_0Input) =>
raw["projectCopy.create"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
params: { projectID: input.projectID },
query: { location: input.location },
payload: { strategy: input.strategy, directory: input.directory, name: input.name },
}).pipe(Effect.mapError(mapClientError))
type Endpoint17_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
@@ -670,9 +657,9 @@ type Endpoint17_1Input = {
}
const Endpoint17_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_1Input) =>
raw["projectCopy.remove"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { directory: input["directory"], force: input["force"] },
params: { projectID: input.projectID },
query: { location: input.location },
payload: { directory: input.directory, force: input.force },
}).pipe(Effect.mapError(mapClientError))
type Endpoint17_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
@@ -681,10 +668,9 @@ type Endpoint17_2Input = {
readonly location?: Endpoint17_2Request["query"]["location"]
}
const Endpoint17_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_2Input) =>
raw["projectCopy.refresh"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
raw["projectCopy.refresh"]({ params: { projectID: input.projectID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup17 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint17_0(raw),
@@ -714,4 +700,4 @@ const adaptClient = (raw: RawClient) => ({
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient))
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
+190 -108
View File
@@ -15,8 +15,6 @@ import type {
SessionsSwitchAgentOutput,
SessionsSwitchModelInput,
SessionsSwitchModelOutput,
SessionsRenameInput,
SessionsRenameOutput,
SessionsPromptInput,
SessionsPromptOutput,
SessionsCompactInput,
@@ -31,8 +29,6 @@ import type {
SessionsCommitOutput,
SessionsContextInput,
SessionsContextOutput,
SessionsHistoryInput,
SessionsHistoryOutput,
SessionsEventsInput,
SessionsEventsOutput,
SessionsInterruptInput,
@@ -79,6 +75,8 @@ import type {
PermissionsGetOutput,
PermissionsReplyInput,
PermissionsReplyOutput,
FilesReadInput,
FilesReadOutput,
FilesListInput,
FilesListOutput,
FilesFindInput,
@@ -98,6 +96,10 @@ import type {
PtysUpdateOutput,
PtysRemoveInput,
PtysRemoveOutput,
PtysConnectTokenInput,
PtysConnectTokenOutput,
PtysConnectInput,
PtysConnectOutput,
QuestionsListRequestsInput,
QuestionsListRequestsOutput,
QuestionsListInput,
@@ -137,6 +139,7 @@ interface RequestDescriptor {
readonly successStatus: number
readonly declaredStatuses: ReadonlyArray<number>
readonly empty: boolean
readonly binary: boolean
}
export function make(options: ClientOptions) {
@@ -188,6 +191,13 @@ export function make(options: ClientOptions) {
} catch {}
return undefined as A
}
if (descriptor.binary) {
try {
return new Uint8Array(await response.arrayBuffer()) as A
} catch (cause) {
throw new ClientError("Transport", { cause })
}
}
return (await json(response)) as A
}
@@ -252,7 +262,14 @@ export function make(options: ClientOptions) {
health: {
get: (requestOptions?: RequestOptions) =>
request<HealthGetOutput>(
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
{
method: "GET",
path: `/api/health`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
@@ -262,10 +279,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/location`,
query: { location: input?.["location"] },
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -276,10 +294,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/agent`,
query: { location: input?.["location"] },
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -291,18 +310,19 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session`,
query: {
workspace: input?.["workspace"],
limit: input?.["limit"],
order: input?.["order"],
search: input?.["search"],
directory: input?.["directory"],
project: input?.["project"],
subpath: input?.["subpath"],
cursor: input?.["cursor"],
workspace: input?.workspace,
limit: input?.limit,
order: input?.order,
search: input?.search,
directory: input?.directory,
project: input?.project,
subpath: input?.subpath,
cursor: input?.cursor,
},
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
binary: false,
},
requestOptions,
),
@@ -311,15 +331,11 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session`,
body: {
id: input?.["id"],
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
},
body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
@@ -331,6 +347,7 @@ export function make(options: ClientOptions) {
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
@@ -342,6 +359,7 @@ export function make(options: ClientOptions) {
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
@@ -350,10 +368,11 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
body: { agent: input["agent"] },
body: { agent: input.agent },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
@@ -362,22 +381,11 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
body: { model: input["model"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
rename: (input: SessionsRenameInput, requestOptions?: RequestOptions) =>
request<SessionsRenameOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/rename`,
body: { title: input["title"] },
body: { model: input.model },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
@@ -386,10 +394,11 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
body: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
body: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
successStatus: 200,
declaredStatuses: [409, 404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
@@ -401,6 +410,7 @@ export function make(options: ClientOptions) {
successStatus: 204,
declaredStatuses: [404, 503, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
@@ -412,6 +422,7 @@ export function make(options: ClientOptions) {
successStatus: 204,
declaredStatuses: [404, 503, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
@@ -420,10 +431,11 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
body: { messageID: input["messageID"], files: input["files"] },
body: { messageID: input.messageID, files: input.files },
successStatus: 200,
declaredStatuses: [404, 409, 500, 400, 401],
declaredStatuses: [404, 500, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
@@ -433,8 +445,9 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
successStatus: 204,
declaredStatuses: [404, 409, 500, 400, 401],
declaredStatuses: [404, 500, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
@@ -444,8 +457,9 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
successStatus: 204,
declaredStatuses: [404, 409, 400, 401],
declaredStatuses: [404, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
@@ -457,30 +471,20 @@ export function make(options: ClientOptions) {
successStatus: 200,
declaredStatuses: [404, 500, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) =>
request<SessionsHistoryOutput>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/history`,
query: { limit: input["limit"], after: input["after"] },
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
),
events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
sse<SessionsEventsOutput>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/event`,
query: { after: input["after"] },
query: { after: input.after },
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
),
@@ -492,6 +496,7 @@ export function make(options: ClientOptions) {
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
@@ -503,6 +508,7 @@ export function make(options: ClientOptions) {
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
@@ -513,10 +519,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/message`,
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
query: { limit: input.limit, order: input.order, cursor: input.cursor },
successStatus: 200,
declaredStatuses: [400, 404, 500, 401],
empty: false,
binary: false,
},
requestOptions,
),
@@ -527,10 +534,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/model`,
query: { location: input?.["location"] },
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -541,10 +549,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/provider`,
query: { location: input?.["location"] },
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -553,10 +562,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/provider/${encodeURIComponent(input.providerID)}`,
query: { location: input["location"] },
query: { location: input.location },
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -567,10 +577,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/integration`,
query: { location: input?.["location"] },
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -579,10 +590,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/integration/${encodeURIComponent(input.integrationID)}`,
query: { location: input["location"] },
query: { location: input.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -591,11 +603,12 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
query: { location: input["location"] },
body: { key: input["key"], label: input["label"] },
query: { location: input.location },
body: { key: input.key, label: input.label },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
binary: false,
},
requestOptions,
),
@@ -604,11 +617,12 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
query: { location: input["location"] },
body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
query: { location: input.location },
body: { methodID: input.methodID, inputs: input.inputs, label: input.label },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
binary: false,
},
requestOptions,
),
@@ -617,10 +631,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
query: { location: input.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -629,11 +644,12 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`,
query: { location: input["location"] },
body: { code: input["code"] },
query: { location: input.location },
body: { code: input.code },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
binary: false,
},
requestOptions,
),
@@ -642,10 +658,11 @@ export function make(options: ClientOptions) {
{
method: "DELETE",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
query: { location: input.location },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
binary: false,
},
requestOptions,
),
@@ -656,11 +673,12 @@ export function make(options: ClientOptions) {
{
method: "PATCH",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
query: { location: input["location"] },
body: { label: input["label"] },
query: { location: input.location },
body: { label: input.label },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
binary: false,
},
requestOptions,
),
@@ -669,10 +687,11 @@ export function make(options: ClientOptions) {
{
method: "DELETE",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
query: { location: input["location"] },
query: { location: input.location },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
binary: false,
},
requestOptions,
),
@@ -683,10 +702,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/permission/request`,
query: { location: input?.["location"] },
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -695,10 +715,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/permission/saved`,
query: { projectID: input?.["projectID"] },
query: { projectID: input?.projectID },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
@@ -710,6 +731,7 @@ export function make(options: ClientOptions) {
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
binary: false,
},
requestOptions,
),
@@ -719,17 +741,18 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
body: {
id: input["id"],
action: input["action"],
resources: input["resources"],
save: input["save"],
metadata: input["metadata"],
source: input["source"],
agent: input["agent"],
id: input.id,
action: input.action,
resources: input.resources,
save: input.save,
metadata: input.metadata,
source: input.source,
agent: input.agent,
},
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
@@ -741,6 +764,7 @@ export function make(options: ClientOptions) {
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
@@ -752,6 +776,7 @@ export function make(options: ClientOptions) {
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
@@ -760,24 +785,39 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`,
body: { reply: input["reply"], message: input["message"] },
body: { reply: input.reply, message: input.message },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
},
files: {
read: (input?: FilesReadInput, requestOptions?: RequestOptions) =>
request<FilesReadOutput>(
{
method: "GET",
path: `/api/fs/read/*`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: true,
},
requestOptions,
),
list: (input?: FilesListInput, requestOptions?: RequestOptions) =>
request<FilesListOutput>(
{
method: "GET",
path: `/api/fs/list`,
query: { location: input?.["location"], path: input?.["path"] },
query: { location: input?.location, path: input?.path },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -786,10 +826,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/fs/find`,
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
query: { location: input.location, query: input.query, type: input.type, limit: input.limit },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -800,10 +841,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/command`,
query: { location: input?.["location"] },
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -814,18 +856,26 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/skill`,
query: { location: input?.["location"] },
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
events: {
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventsSubscribeOutput> =>
sse<EventsSubscribeOutput>(
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
subscribe: (requestOptions?: RequestOptions) =>
request<EventsSubscribeOutput>(
{
method: "GET",
path: `/api/event`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
@@ -835,10 +885,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/pty`,
query: { location: input?.["location"] },
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -847,17 +898,12 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/pty`,
query: { location: input?.["location"] },
body: {
command: input?.["command"],
args: input?.["args"],
cwd: input?.["cwd"],
title: input?.["title"],
env: input?.["env"],
},
query: { location: input?.location },
body: { command: input?.command, args: input?.args, cwd: input?.cwd, title: input?.title, env: input?.env },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -866,10 +912,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input["location"] },
query: { location: input.location },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -878,11 +925,12 @@ export function make(options: ClientOptions) {
{
method: "PUT",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input["location"] },
body: { title: input["title"], size: input["size"] },
query: { location: input.location },
body: { title: input.title, size: input.size },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -891,10 +939,36 @@ export function make(options: ClientOptions) {
{
method: "DELETE",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input["location"] },
query: { location: input.location },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
binary: false,
},
requestOptions,
),
connectToken: (input: PtysConnectTokenInput, requestOptions?: RequestOptions) =>
request<PtysConnectTokenOutput>(
{
method: "POST",
path: `/api/pty/${encodeURIComponent(input.ptyID)}/connect-token`,
query: { location: input.location },
successStatus: 200,
declaredStatuses: [403, 404, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
connect: (input: PtysConnectInput, requestOptions?: RequestOptions) =>
request<PtysConnectOutput>(
{
method: "GET",
path: `/api/pty/${encodeURIComponent(input.ptyID)}/connect`,
successStatus: 200,
declaredStatuses: [403, 404, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -905,10 +979,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/question/request`,
query: { location: input?.["location"] },
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -920,6 +995,7 @@ export function make(options: ClientOptions) {
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
@@ -928,10 +1004,11 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
body: { answers: input["answers"] },
body: { answers: input.answers },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
@@ -943,6 +1020,7 @@ export function make(options: ClientOptions) {
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
@@ -953,10 +1031,11 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/reference`,
query: { location: input?.["location"] },
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
@@ -967,11 +1046,12 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
query: { location: input.location },
body: { strategy: input.strategy, directory: input.directory, name: input.name },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
binary: false,
},
requestOptions,
),
@@ -980,11 +1060,12 @@ export function make(options: ClientOptions) {
{
method: "DELETE",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
body: { directory: input["directory"], force: input["force"] },
query: { location: input.location },
body: { directory: input.directory, force: input.force },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
binary: false,
},
requestOptions,
),
@@ -993,10 +1074,11 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
query: { location: input["location"] },
query: { location: input.location },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
binary: false,
},
requestOptions,
),
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1,2 +1 @@
export * from "./generated/index"
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
@@ -19,7 +19,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Api } from "@opencode-ai/server/api"
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract"
import { Api as ClientApi, endpointNames, groupNames } from "../src/contract"
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
expect(AgentV2.ID).toBe(Agent.ID)
@@ -38,8 +38,8 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
})
test("client and Server contracts generate identically", () => {
const server = compile(Api, { groupNames, endpointNames, omitEndpoints })
const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints })
const server = compile(Api, { groupNames, endpointNames })
const client = compile(ClientApi, { groupNames, endpointNames })
expect(emitPromise(client)).toEqual(emitPromise(server))
})
+1 -100
View File
@@ -15,53 +15,7 @@ test("sessions.get returns the decoded Effect projection", async () => {
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
})
test("events.subscribe exposes and decodes the native Effect event stream", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
),
),
)
const events = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.events.subscribe().pipe(Stream.runCollect)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"])
const durable = events[1]
if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event")
expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000)
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
})
test("events.subscribe terminates on Effect protocol decode failures", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(`data: {"type":"server.connected"}\n\n`, {
headers: { "content-type": "text/event-stream" },
}),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error._tag).toBe("ClientError")
})
test("session methods retain decoded Effect inputs and outputs", async () => {
const historyQueries: Array<Record<string, string>> = []
let historyPage = 0
const httpClient = HttpClient.make((request) => {
const url = request.url
if (url.includes("/event")) {
@@ -74,18 +28,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
),
)
}
if (url.includes("/history")) {
historyPage++
historyQueries.push(Object.fromEntries(request.urlParams.params))
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
),
),
)
}
if (url.includes("/prompt")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
}
@@ -130,18 +72,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
const history = yield* client.sessions.history({
sessionID: Session.ID.make("ses_test"),
after: 0,
limit: 1,
})
const historyNext = history.hasMore
? yield* client.sessions.history({
sessionID: Session.ID.make("ses_test"),
after: history.data.at(-1)?.durable?.seq,
limit: 2,
})
: undefined
const events = yield* client.sessions
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
.pipe(Stream.runCollect)
@@ -150,7 +80,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_model"),
})
return { page, active, created, admitted, context, history, historyNext, events, message }
return { page, active, created, admitted, context, events, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
@@ -162,39 +92,10 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([])
expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000)
expect(result.history).toEqual(expect.objectContaining({ hasMore: true }))
expect(result.historyNext).toEqual({ data: [], hasMore: false })
expect(historyQueries[0]).toEqual({ limit: "1", after: "0" })
expect(historyQueries[1]).toEqual({ limit: "2", after: "1" })
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
})
test("sessions.history retains the typed SessionNotFoundError", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
{ status: 404 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.sessions
.history({
sessionID: Session.ID.make("ses_missing"),
})
.pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error._tag).toBe("SessionNotFoundError")
})
const session = {
data: {
id: "ses_test",
+13 -69
View File
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src"
import { isUnauthorizedError, OpenCode } from "../src"
test("exposes every standard HTTP API group", () => {
test("exposes every authoritative API group", () => {
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
expect(Object.keys(client)).toEqual([
@@ -34,8 +34,15 @@ test("exposes every standard HTTP API group", () => {
"attemptComplete",
"attemptCancel",
])
expect(Object.keys(client.files)).toEqual(["list", "find"])
expect(Object.keys(client.ptys)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.permissions)).toEqual([
"listRequests",
"listSaved",
"removeSaved",
"create",
"list",
"get",
"reply",
])
})
test("sessions.get returns the wire projection", async () => {
@@ -54,38 +61,8 @@ test("sessions.get returns the wire projection", async () => {
expect(result.time.created).toBe(1_717_171_717_000)
})
test("events.subscribe exposes the Promise event stream wire projection", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(
`: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
})
const events = []
for await (const event of client.events.subscribe()) events.push(event)
expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent])
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000)
})
test("events.subscribe terminates on malformed Promise SSE data", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }),
})
await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
name: "ClientError",
reason: "MalformedResponse",
})
})
test("session methods use the public HTTP contract", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = []
let historyPage = 0
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
@@ -96,12 +73,6 @@ test("session methods use the public HTTP contract", async () => {
headers: { "content-type": "text/event-stream" },
})
}
if (url.includes("/history")) {
historyPage++
return Response.json(
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
)
}
if (url.includes("/prompt")) return Response.json(admission)
if (url.includes("/context")) return Response.json({ data: [] })
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
@@ -112,7 +83,7 @@ test("session methods use the public HTTP contract", async () => {
},
})
const page = await client.sessions.list({ limit: 10, order: "desc" })
const page = await client.sessions.list({ limit: "10", order: "desc" })
const active = await client.sessions.active()
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
@@ -128,13 +99,8 @@ test("session methods use the public HTTP contract", async () => {
await client.sessions.compact({ sessionID: "ses_test" })
await client.sessions.wait({ sessionID: "ses_test" })
const context = await client.sessions.context({ sessionID: "ses_test" })
const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 })
const historyAfter = history.data.at(-1)?.durable?.seq
const historyNext = history.hasMore
? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
: undefined
const events = []
for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event)
for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event)
await client.sessions.interrupt({ sessionID: "ses_test" })
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
@@ -143,8 +109,6 @@ test("session methods use the public HTTP contract", async () => {
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
expect(context).toEqual([])
expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
expect(historyNext).toEqual({ data: [], hasMore: false })
expect(events).toEqual([modelSwitchedEvent])
expect(message).toEqual(modelSwitchedMessage)
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
@@ -157,8 +121,6 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/session/ses_test/compact"],
["POST", "http://localhost:3000/api/session/ses_test/wait"],
["GET", "http://localhost:3000/api/session/ses_test/context"],
["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"],
["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"],
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
@@ -186,24 +148,6 @@ test("middleware errors remain declared client errors", async () => {
}
})
test("sessions.history decodes SessionNotFoundError", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
Response.json(
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
{ status: 404 },
),
})
try {
await client.sessions.history({ sessionID: "ses_missing" })
throw new Error("Expected request to fail")
} catch (error) {
expect(isSessionNotFoundError(error)).toBe(true)
}
})
const session = {
data: {
id: "ses_test",
@@ -215,16 +215,6 @@ export async function handler(
body: reqBody,
})
if (providerInfo.id.startsWith("console.")) {
const resEndpointId = res.headers.get("x-opencode-endpoint-id")
const resEndpointModelId = res.headers.get("x-opencode-upstream-model-id")
if (resEndpointId && resEndpointModelId)
logger.metric({
provider: resEndpointId,
"provider.model": resEndpointModelId,
})
}
if (res.status !== 200) {
logger.metric({
"llm.error.code": res.status,
-2
View File
@@ -16,8 +16,6 @@
"opencode": "./bin/opencode"
},
"exports": {
"./effect/layer-node": "./src/effect/layer-node.ts",
"./effect/app-node": "./src/effect/app-node.ts",
"./session/runner": "./src/session/runner/index.ts",
"./system-context": "./src/system-context/index.ts",
"./*": "./src/*.ts"
-3
View File
@@ -1,6 +1,5 @@
export * as AgentV2 from "./agent"
import { makeLocationNode } from "./effect/app-node"
import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { State } from "./state"
@@ -107,5 +106,3 @@ export const layer = Layer.effect(
)
export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [] })
-3
View File
@@ -1,6 +1,5 @@
export * as AISDK from "./aisdk"
import { makeLocationNode } from "./effect/app-node"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
import { ModelV2 } from "./model"
@@ -232,6 +231,4 @@ export const locationLayer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
export const defaultLayer = locationLayer
-3
View File
@@ -2,7 +2,6 @@ export * as BackgroundJob from "./background-job"
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
import { Identifier } from "./id/id"
import { makeGlobalNode } from "./effect/app-node"
export type Status = "running" | "completed" | "error" | "cancelled"
@@ -363,5 +362,3 @@ export const make = Effect.gen(function* () {
export const layer = Layer.effect(Service, make)
export const defaultLayer = layer
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
-3
View File
@@ -1,6 +1,5 @@
export * as Catalog from "./catalog"
import { makeLocationNode } from "./effect/app-node"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog"
import { ModelV2 } from "./model"
@@ -297,5 +296,3 @@ export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(Policy.locationLayer),
)
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] })
-3
View File
@@ -1,6 +1,5 @@
export * as CommandV2 from "./command"
import { makeLocationNode } from "./effect/app-node"
import { Context, Effect, Layer, Types } from "effect"
import { Command } from "@opencode-ai/schema/command"
import { State } from "./state"
@@ -60,5 +59,3 @@ export const layer = Layer.effect(
)
export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [] })
-7
View File
@@ -1,6 +1,5 @@
export * as Config from "./config"
import { makeLocationNode } from "./effect/app-node"
import path from "path"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect"
@@ -219,9 +218,3 @@ export const layer = Layer.effect(
)
export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer))
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, Location.node, Policy.node],
})
-3
View File
@@ -5,7 +5,6 @@ import { Context, Effect, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database"
import { makeGlobalNode } from "./effect/app-node"
import { CredentialTable } from "./credential/sql"
export const ID = Credential.ID
@@ -136,5 +135,3 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
+3 -3
View File
@@ -24,8 +24,8 @@ import {
import * as NodeChildProcess from "node:child_process"
import { PassThrough } from "node:stream"
import launch from "cross-spawn"
import { makeGlobalNode } from "./effect/app-node"
import { filesystem, path } from "./effect/app-node-platform"
import { LayerNode } from "./effect/layer-node"
import { filesystem, path } from "./effect/layer-node-platform"
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
@@ -503,6 +503,6 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
export const node = LayerNode.make({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
export * as CrossSpawnSpawner from "./cross-spawn-spawner"
+2 -2
View File
@@ -8,7 +8,7 @@ import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version"
import { makeGlobalNode } from "../effect/app-node"
import { LayerNode } from "../effect/layer-node"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
@@ -60,4 +60,4 @@ export const defaultLayer = Layer.unwrap(
}),
).pipe(Layer.provide(Global.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] })
export const node = LayerNode.make({ service: Service, layer: layerFromPath(path()), deps: [] })
@@ -1,24 +0,0 @@
import { Layer } from "effect"
import { buildLocationServiceMap } from "../location-services"
import { LocationServiceMap } from "../location-service-map"
import { LayerNode } from "./layer-node"
import { makeGlobalNode } from "./app-node"
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) {
const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement]))
if (!LayerNode.hasUnbound(root, LocationServiceMap.node)) {
// If the location service map is not needed, we shouldn't pull it
// in. Compile the graph normally
return LayerNode.compile(root, replacementMap)
}
const locationMap = buildLocationServiceMap(replacementMap)
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
const app = LayerNode.bind(root, LocationServiceMap.node, locationMapNode)
return LayerNode.compile(app, replacementMap)
}
export * as AppNodeBuilder from "./app-node-builder"
-14
View File
@@ -1,14 +0,0 @@
import { LayerNode } from "./layer-node"
export const tags = LayerNode.tags({
location: ["global"],
global: [],
})
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["global"]>
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["location"]>
export const makeGlobalNode = tags.make("global")
export const makeLocationNode = tags.make("location")
export * as Node from "./app-node"
@@ -3,16 +3,16 @@ import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { FileSystem, Path } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { HttpClient } from "effect/unstable/http"
import { makeGlobalNode } from "./app-node"
import { LayerNode } from "./layer-node"
export const filesystem = makeGlobalNode({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
export const path = makeGlobalNode({ service: Path.Path, layer: NodePath.layer, deps: [] })
export const httpClient = makeGlobalNode({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
export const requestExecutor = makeGlobalNode({
export const filesystem = LayerNode.make({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
export const path = LayerNode.make({ service: Path.Path, layer: NodePath.layer, deps: [] })
export const httpClient = LayerNode.make({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
export const requestExecutor = LayerNode.make({
service: RequestExecutor.Service,
layer: RequestExecutor.layer,
deps: [httpClient],
})
export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
export const llmClient = LayerNode.make({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
export * as LayerNodePlatform from "./app-node-platform"
export * as LayerNodePlatform from "./layer-node-platform"
+156 -181
View File
@@ -1,11 +1,10 @@
import { Brand, Context, Layer } from "effect"
type AnyNode = Node<unknown, unknown, any>
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
type AnyNode = Node<unknown, unknown, any>
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
export type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
export type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
type NodeTag<Item> = [Item] extends [never] ? undefined : Item extends Node<unknown, unknown, infer T> ? T : never
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
Missing<Layer.Services<Implementation>, Dependencies>,
@@ -15,17 +14,17 @@ type CheckDependencies<Implementation extends Layer.Any, Dependencies extends No
declare const $OutputType: unique symbol
declare const $ErrorType: unique symbol
export type Tag<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tag">
export type Tier<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tier">
const makeTag = Brand.nominal<Tag>()
const makeTier = Brand.nominal<Tier>()
export interface Node<A, E = never, T extends Tag | undefined = undefined> {
readonly kind: "layer" | "unbound" | "group"
export type Node<A, E = never, T extends Tier | undefined = undefined> = {
readonly kind: "layer" | "group"
readonly name: string
readonly service?: Context.Service.Any
readonly implementation?: Layer.Any
readonly dependencies: readonly AnyNode[]
readonly tag?: T
readonly tier?: T
readonly [$OutputType]?: () => A
readonly [$ErrorType]?: () => E
}
@@ -35,55 +34,22 @@ type NodeIdentity =
| { readonly name: string; readonly service?: never }
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never
export type TagConfig = Readonly<Record<string, readonly string[]>>
type TagNames<Config extends TagConfig> = keyof Config & string
type NodeInTags<Names extends string> = Node<unknown, unknown, Tag<Names> | undefined>
type CheckTags<Items extends NodeList, Names extends string> = [Exclude<Items[number], NodeInTags<Names>>] extends [
never,
]
? unknown
: { readonly "Invalid tag dependencies": Exclude<Items[number], NodeInTags<Names>> }
export interface Tags<Config extends TagConfig> {
readonly values: { readonly [Name in TagNames<Config>]: Tag<Name> }
readonly make: <Name extends TagNames<Config>>(
name: Name,
) => <const Implementation extends Layer.Any, const Items extends NodeList>(
input: DistributiveOmit<MakeInput<Implementation, Items, Tag<Name>>, "tag"> &
CheckTags<Items, Name | Extract<Config[Name][number], string>>,
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tag<Name>>
}
export function tags<const Config extends { readonly [Name in keyof Config]: readonly (keyof Config & string)[] }>(
config: Config,
): Tags<Config> {
const names = Object.keys(config) as TagNames<Config>[]
const values = Object.fromEntries(names.map((name) => [name, makeTag(name)])) as Tags<Config>["values"]
return {
values,
make: ((name: TagNames<Config>) => (input: DistributiveOmit<MakeInput<Layer.Any, NodeList, Tag>, "tag">) =>
make({ ...input, tag: values[name] })) as Tags<Config>["make"],
}
}
// Nodes ---------------------------------------------------------------------
type MakeInput<
type NodeInput<
Implementation extends Layer.Any,
Items extends NodeList,
T extends Tag | undefined = undefined,
T extends Tier | undefined = undefined,
> = NodeIdentity & {
readonly layer: Implementation
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
readonly tag?: T
readonly tier?: T
}
export function make<
const Implementation extends Layer.Any,
const Items extends NodeList,
const T extends Tag | undefined = undefined,
const T extends Tier | undefined = undefined,
>(
input: MakeInput<Implementation, Items, T>,
input: NodeInput<Implementation, Items, T>,
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, T> {
return {
kind: "layer",
@@ -91,26 +57,53 @@ export function make<
service: input.service,
implementation: input.layer,
dependencies: input.deps,
tag: input.tag,
tier: input.tier,
}
}
export function unbound<R, Shape, const T extends Tag>(service: Context.Key<R, Shape>, tag: T): Node<R, never, T> {
return {
kind: "unbound",
name: service.key,
service,
dependencies: [],
tag,
}
}
export function group<const Items extends readonly AnyNode[]>(
export function group<const Items extends NodeList>(
dependencies: Items,
): Node<Output<Items[number]>, Error<Items[number]>, NodeTag<Items[number]>> {
): Node<Output<Items[number]>, Error<Items[number]>> {
return { kind: "group", name: "group", dependencies }
}
type AllowedTierNames<Names extends readonly string[], Name extends Names[number]> = Names extends readonly [
infer Head extends string,
...infer Tail extends readonly string[],
]
? Head extends Name
? Head | Tail[number]
: AllowedTierNames<Tail, Name>
: never
type NodeInTiers<Names extends string> = Node<unknown, unknown, Tier<Names>>
export interface Tiers<Names extends readonly [string, ...string[]]> {
readonly names: Names
readonly values: { readonly [K in Names[number]]: Tier<K> }
readonly make: <Name extends Names[number]>(
name: Name,
) => <
const Implementation extends Layer.Any,
const Items extends NodeList<NodeInTiers<AllowedTierNames<Names, Name>>>,
>(
input: DistributiveOmit<NodeInput<Implementation, Items, Tier<Name>>, "tier">,
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tier<Name>>
}
export function tiers<const Names extends readonly [string, ...string[]]>(names: Names): Tiers<Names> {
const values = Object.fromEntries(names.map((name) => [name, makeTier(name)])) as Tiers<Names>["values"]
return {
names,
values,
make: ((name: Names[number]) => (input: DistributiveOmit<NodeInput<Layer.Any, NodeList, Tier>, "tier">) =>
make({ ...input, tier: values[name] })) as Tiers<Names>["make"],
}
}
const defaultTiers = tiers(["untiered"])
const untiered = defaultTiers.values.untiered
export type Replacement = {
readonly source: Layer.Any
readonly replacement: Layer.Any
@@ -127,147 +120,129 @@ export function replace<A, E, R, E2>(
return { source, replacement }
}
// Tree -----------------------------------------------------------------------
type Visit<Result> = (node: AnyNode, context: VisitContext<Result>) => Result
type VisitContext<Result> = {
readonly cache: Map<AnyNode, Result>
readonly visit: (node: AnyNode) => Result
export function buildLayer<
A,
E,
const Names extends readonly [string, ...string[]] = readonly ["untiered"],
const Built extends Layer.Any = Layer.Layer<never, never, never>,
>(
node: Node<A, E, any>,
options?: {
readonly tiers?: Tiers<Names>
readonly buildTier?: (tier: Names[number], layers: readonly Layer.Any[]) => Built
readonly replacements?: readonly Replacement[]
},
): Layer.Layer<A | Layer.Success<Built>, E | Layer.Error<Built>, never> {
const tiers = options?.tiers ?? (defaultTiers as unknown as Tiers<Names>)
const replacementMap = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
const plans = plan(node, tiers, replacementMap)
const layers: RuntimeLayer[] = tiers.names.map((name) => {
const tier = tiers.values[name as Names[number]]
const layers = plans.get(tier) ?? []
return (options?.buildTier?.(name, layers) ?? combine(layers)) as RuntimeLayer
})
if (layers.length === 0) return Layer.empty as never
return layers.slice(1).reduce((result, layer) => result.pipe(Layer.provideMerge(layer)), layers[0]) as never
}
function walk<Result>(
export function combine(layers: readonly Layer.Any[]): RuntimeLayer {
return layers.reduce<RuntimeLayer>(
(result, layer) => (layer as RuntimeLayer).pipe(Layer.provideMerge(result)),
Layer.empty as RuntimeLayer,
)
}
function plan(
root: AnyNode,
visit: Visit<Result>,
options: {
readonly cache?: Map<AnyNode, Result>
readonly resolve?: (node: AnyNode) => AnyNode
readonly detectCycles?: boolean
} = {},
tiers: Tiers<readonly [string, ...string[]]>,
replacements: ReadonlyMap<Layer.Any, Layer.Any>,
) {
const cache = options.cache ?? new Map<AnyNode, Result>()
const indexes = new Map(tiers.names.map((name, index) => [tiers.values[name], index]))
const plans = new Map<Tier, Layer.Any[]>()
const activeImplementations = new Map<Tier, Map<string, AnyNode>>()
const serviceTiers = new Map<string, Tier>()
const visiting = new Set<AnyNode>()
const stack: AnyNode[] = []
const boundaryVisited = new Map<AnyNode, Set<Tier>>()
const boundaryServices = new Map<Tier, Map<string, AnyNode>>()
const recur = (node: AnyNode): Result => {
const target = options.resolve?.(node) ?? node
const cached = cache.get(target)
if (cached !== undefined || cache.has(target)) return cached!
const validateBoundary = (node: AnyNode, origin: Tier) => {
const checked = boundaryVisited.get(node) ?? new Set<Tier>()
boundaryVisited.set(node, checked)
if (checked.has(origin)) return false
checked.add(origin)
const services = boundaryServices.get(origin) ?? new Map<string, AnyNode>()
boundaryServices.set(origin, services)
const key = node.name
const existing = services.get(key)
if (existing && existing !== node) {
throw new Error(`Tier ${origin} has conflicting implementations for ${key}`)
}
services.set(key, node)
return true
}
if (options.detectCycles !== false && visiting.has(target)) {
const start = stack.indexOf(target)
const visit = (node: AnyNode, currentTier?: Tier, origins: readonly Tier[] = []) => {
if (node.kind === "group") {
node.dependencies.forEach((dependency) => visit(dependency, currentTier, origins))
return
}
const tier = node.tier ?? untiered
if (!indexes.has(tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
const key = node.name
const serviceTier = serviceTiers.get(key)
if (serviceTier && serviceTier !== tier) {
throw new Error(`Service ${key} belongs to both tier ${serviceTier} and tier ${tier}`)
}
serviceTiers.set(key, tier)
const nextOrigins = [...origins]
if (currentTier) {
const current = indexes.get(currentTier)!
const required = indexes.get(tier)!
if (required < current) {
throw new Error(`Tier ${currentTier} cannot depend on lower tier ${tier}`)
}
if (required > current) nextOrigins.push(currentTier)
}
const unseenOrigins = nextOrigins.filter((origin) => validateBoundary(node, origin))
// A node may need to be emitted more than once because the final output is a
// flat list of layers applied with Layer.provideMerge. If another node for
// the same service was emitted afterward, this node is no longer the active
// implementation for subsequent consumers. Re-emitting restores the intended
// implementation ordering while Effect memoization avoids reacquiring the layer.
const implementations = activeImplementations.get(tier) ?? new Map<string, AnyNode>()
activeImplementations.set(tier, implementations)
if (implementations.get(key) === node && unseenOrigins.length === 0) return
if (visiting.has(node)) {
const start = stack.indexOf(node)
throw new Error(
`Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`,
`Cycle detected in layer graph: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`,
)
}
visiting.add(target)
stack.push(target)
visiting.add(node)
stack.push(node)
try {
const result = visit(target, { cache, visit: recur })
if (!cache.has(target)) cache.set(target, result)
return result
node.dependencies.forEach((dependency) => visit(dependency, tier, unseenOrigins))
const layers = plans.get(tier) ?? []
plans.set(tier, layers)
layers.push(replacements.get(node.implementation!) ?? node.implementation!)
implementations.set(key, node)
} finally {
stack.pop()
visiting.delete(target)
visiting.delete(node)
}
}
return recur(root)
visit(root)
return plans
}
export function hoist<A, E, T extends Tag>(
root: Node<A, E, any>,
tag: T,
): {
readonly node: Node<A, E>
readonly hoisted: Node<unknown, E>
} {
const hoisted = new Map<string, AnyNode>()
const node = walk<AnyNode>(root, (node, context) => {
if (node.kind === "group") {
return { ...node, dependencies: node.dependencies.map(context.visit) }
}
if (node.tag === tag) {
const existing = hoisted.get(node.name)
if (existing && existing !== node) {
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
}
hoisted.set(node.name, node)
return group([])
}
if (node.kind === "unbound") {
return node
}
return { ...node, dependencies: node.dependencies.map(context.visit) }
})
return {
node: node as Node<A, E>,
hoisted: group(Array.from(hoisted.values())) as Node<unknown, E>,
}
}
export function compile<A, E>(
root: Node<A, E, any>,
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
): Layer.Layer<A, E> {
const cache = new Map<AnyNode, RuntimeLayer>()
const compileNode = (node: AnyNode) =>
walk<RuntimeLayer>(
node,
(node, context) => {
if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`)
const dependencies = node.dependencies.flatMap(flatten).map(context.visit)
const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer
return dependencies.length === 0
? implementation
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
},
{ cache },
)
const layers = flatten(root).map((node) => compileNode(node))
const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
return layer as Layer.Layer<A, E>
}
export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`)
return walk<boolean>(root, (node, context) => {
if (node === source) return true
return node.dependencies.some(context.visit)
})
}
export function bind<A, E, T extends Tag | undefined>(
root: Node<A, E, T>,
source: AnyNode,
replacement: AnyNode,
): Node<A, E, T> {
if (source.kind !== "unbound") throw new Error(`Cannot bind non-unbound layer node: ${source.name}`)
if (source.name !== replacement.name) {
throw new Error(`Cannot bind ${source.name} to ${replacement.name}`)
}
if (source.tag !== replacement.tag) {
throw new Error(`Cannot bind ${source.name} across tags`)
}
return walk<AnyNode>(
root,
(target, context) => {
if (target.kind === "unbound") return target
const dependencies: AnyNode[] = []
const clone = { ...target, dependencies }
context.cache.set(target, clone)
dependencies.push(...target.dependencies.map(context.visit))
return clone
},
{ detectCycles: false, resolve: (node) => (node === source ? replacement : node) },
) as Node<A, E, T>
}
function flatten(node: AnyNode): readonly AnyNode[] {
return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node]
function requireTier(node: AnyNode, indexes: ReadonlyMap<Tier, number>) {
if (!node.tier || !indexes.has(node.tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
}
export * as LayerNode from "./layer-node"
+11
View File
@@ -0,0 +1,11 @@
import { LayerNode } from "./layer-node"
export const tiers = LayerNode.tiers(["location", "global"])
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["global"]>
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["location"]>
export const makeGlobalNode = tiers.make("global")
export const makeLocationNode = tiers.make("location")
export * as ScopedNode from "./scoped-node"
+17 -83
View File
@@ -1,13 +1,13 @@
export * as EventV2 from "./event"
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import { and, asc, eq, gt, inArray } from "drizzle-orm"
import { and, asc, eq, gt } from "drizzle-orm"
import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
import { makeGlobalNode } from "./effect/app-node"
import { LayerNode } from "./effect/layer-node"
import { isDeepStrictEqual } from "node:util"
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
@@ -47,71 +47,6 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
},
) {}
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
const definition = Durable.get(event.type)
if (!definition?.durable) {
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
}
return {
id: event.id,
type: definition.type,
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
data: Schema.decodeUnknownSync(definition.data)(event.data),
}
}
export const readAggregate = Effect.fn("EventV2.readAggregate")(function* <A>(
db: Database.Interface["db"],
input: {
readonly aggregateID: string
readonly after?: number
readonly limit: number
readonly manifest: {
readonly definitions: ReadonlyMap<string, Definition>
readonly schema: Schema.Decoder<A, never>
}
},
) {
const after = input.after ?? -1
const rows = yield* db
.select()
.from(EventTable)
.where(
and(
eq(EventTable.aggregate_id, input.aggregateID),
gt(EventTable.seq, after),
inArray(EventTable.type, Array.from(input.manifest.definitions.keys())),
),
)
.orderBy(asc(EventTable.seq))
.limit(input.limit + 1)
.all()
.pipe(Effect.orDie)
const page = rows.slice(0, input.limit)
const decode = Schema.decodeUnknownSync(input.manifest.schema)
const events = page.map((event) =>
decode({
id: event.id,
type: input.manifest.definitions.get(event.type)?.type ?? event.type,
durable: {
aggregateID: event.aggregate_id,
seq: event.seq,
version: input.manifest.definitions.get(event.type)?.durable?.version,
},
data: event.data,
}),
)
return {
events,
hasMore: rows.length > input.limit,
}
})
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
"EventV2.SubscriberOverflow",
{ capacity: Schema.Int },
) {}
export const define = Event.define
export const versionedType = Event.versionedType
@@ -149,20 +84,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
export const allBounded = (events: Interface, capacity: number) =>
Effect.gen(function* () {
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity)
const unsubscribe = yield* events.listen((event) =>
Queue.offer(queue, event).pipe(
Effect.flatMap((accepted) =>
accepted ? Effect.void : Queue.fail(queue, new SubscriberOverflowError({ capacity })).pipe(Effect.asVoid),
),
),
)
yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid))
return Stream.fromQueue(queue)
})
export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
}
@@ -538,6 +459,19 @@ export const layerWith = (options?: LayerOptions) =>
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
const decodeSerializedEvent = (event: SerializedEvent) => {
const definition = Durable.get(event.type)
if (!definition?.durable) {
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
}
return {
id: event.id,
type: definition.type,
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
data: Schema.decodeUnknownSync(definition.data)(event.data),
}
}
const readAfter = (aggregateID: string, after: number) =>
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
Effect.andThen(
@@ -635,6 +569,6 @@ export const layerWith = (options?: LayerOptions) =>
)
export const layer = layerWith()
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
-3
View File
@@ -1,6 +1,5 @@
export * as FileMutation from "./file-mutation"
import { makeLocationNode } from "./effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { dirname } from "path"
import { KeyedMutex } from "./effect/keyed-mutex"
@@ -193,8 +192,6 @@ function sameBytes(left: Uint8Array, right: Uint8Array) {
export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
/**
* Deferred until the corresponding V2 integrations exist.
*/
-7
View File
@@ -1,6 +1,5 @@
export * as FileSystem from "./filesystem"
import { makeLocationNode } from "./effect/app-node"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "./fs-util"
@@ -114,9 +113,3 @@ const baseLayer = Layer.effect(
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
export const locationLayer = layer
export const node = makeLocationNode({
service: Service,
layer: baseLayer,
deps: [FSUtil.node, Location.node, FileSystemSearch.node],
})
+3 -6
View File
@@ -1,6 +1,5 @@
export * as FileSystemSearch from "./search"
import { makeLocationNode } from "../effect/app-node"
import path from "path"
import { Context, Effect, Layer, Scope } from "effect"
import { Fff } from "#fff"
@@ -230,8 +229,6 @@ export const fffLayer = Layer.effect(
}),
)
const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)))
export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] })
export const locationLayer = Layer.unwrap(
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
)
-7
View File
@@ -3,7 +3,6 @@ export * as Watcher from "./watcher"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
import { makeLocationNode } from "../effect/app-node"
import { Cause, Context, Effect, Layer } from "effect"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
import path from "path"
@@ -134,9 +133,3 @@ export const layer = Layer.effect(
)
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
})
+3 -3
View File
@@ -7,8 +7,8 @@ import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { Glob } from "./util/glob"
import { serviceUse } from "./effect/service-use"
import { makeGlobalNode } from "./effect/app-node"
import { filesystem } from "./effect/app-node-platform"
import { LayerNode } from "./effect/layer-node"
import { filesystem } from "./effect/layer-node-platform"
export namespace FSUtil {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
@@ -201,7 +201,7 @@ export namespace FSUtil {
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [filesystem] })
export const node = LayerNode.make({ service: Service, layer: layer, deps: [filesystem] })
// Pure helpers that don't need Effect (path manipulation, sync operations)
export function mimeType(p: string): string {
+2 -2
View File
@@ -7,7 +7,7 @@ import { ChildProcess } from "effect/unstable/process"
import { AbsolutePath, RelativePath } from "./schema"
import { FSUtil } from "./fs-util"
import { AppProcess } from "./process"
import { makeGlobalNode } from "./effect/app-node"
import { LayerNode } from "./effect/layer-node"
import { File } from "./file"
import { KeyedMutex } from "./effect/keyed-mutex"
@@ -944,7 +944,7 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
interface Result {
readonly exitCode: number
+2 -2
View File
@@ -5,7 +5,7 @@ import os from "os"
import { Context, Effect, Layer } from "effect"
import { Flock } from "./util/flock"
import { Flag } from "./flag/flag"
import { makeGlobalNode } from "./effect/app-node"
import { LayerNode } from "./effect/layer-node"
const app = "opencode"
const data = path.join(xdgData!, app)
@@ -77,7 +77,7 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] })
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
export const layerWith = (input: Partial<Interface>) =>
Layer.effect(
-3
View File
@@ -1,6 +1,5 @@
export * as Image from "./image"
import { makeLocationNode } from "./effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Config } from "./config"
import { FileSystem } from "./filesystem"
@@ -77,5 +76,3 @@ export const layer = Layer.effect(
)
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
-7
View File
@@ -9,7 +9,6 @@ import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { SystemContext } from "./system-context/index"
import { SystemContextRegistry } from "./system-context/registry"
import { makeLocationNode } from "./effect/app-node"
class File extends Schema.Class<File>("InstructionContext.File")({
path: AbsolutePath,
@@ -88,12 +87,6 @@ export const layer = Layer.effectDiscard(
}),
)
export const node = makeLocationNode({
name: "instruction-context",
layer,
deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node],
})
function render(files: ReadonlyArray<File>) {
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
}
-3
View File
@@ -1,6 +1,5 @@
export * as Integration from "./integration"
import { makeLocationNode } from "./effect/app-node"
import {
Cause,
Clock,
@@ -516,5 +515,3 @@ export const locationLayer = Layer.effect(
})
}),
)
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Credential.node, EventV2.node] })
+151
View File
@@ -0,0 +1,151 @@
import { Effect, Layer, LayerMap } from "effect"
import { Location } from "./location"
import { Policy } from "./policy"
import { Config } from "./config"
import { PluginV2 } from "./plugin"
import { Catalog } from "./catalog"
import { Integration } from "./integration"
import { CommandV2 } from "./command"
import { AgentV2 } from "./agent"
import { PluginInternal } from "./plugin/internal"
import { Project } from "./project"
import { ProjectCopy } from "./project/copy"
import { ProjectDirectories } from "./project/directories"
import { EventV2 } from "./event"
import { Credential } from "./credential"
import { Npm } from "./npm"
import { ModelsDev } from "./models-dev"
import { FSUtil } from "./fs-util"
import { Git } from "./git"
import { Global } from "./global"
import { Database } from "./database/database"
import { PermissionV2 } from "./permission"
import { PermissionSaved } from "./permission/saved"
import { FileSystem } from "./filesystem"
import { Ripgrep } from "./ripgrep"
import { Watcher } from "./filesystem/watcher"
import { LocationMutation } from "./location-mutation"
import { FileMutation } from "./file-mutation"
import { Reference } from "./reference"
import { ReferenceGuidance } from "./reference/guidance"
import { RepositoryCache } from "./repository-cache"
import { Pty } from "./pty"
import { SkillV2 } from "./skill"
import { SkillGuidance } from "./skill/guidance"
import { BuiltInTools } from "./tool/builtins"
import { Image } from "./image"
import { ToolRegistry } from "./tool/registry"
import { ApplicationTools } from "./tool/application-tools"
import { ToolOutputStore } from "./tool-output-store"
import { AppProcess } from "./process"
import { SessionStore } from "./session/store"
import { SessionTodo } from "./session/todo"
import { QuestionV2 } from "./question"
import { LLMClient } from "@opencode-ai/llm"
import { RequestExecutor } from "@opencode-ai/llm/route"
import * as SessionRunnerLLM from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { FetchHttpClient } from "effect/unstable/http"
import { Snapshot } from "./snapshot"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
const boot = Layer.effectDiscard(
Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }),
)
const location = Location.layer(ref)
const systemContext = SystemContextBuiltIns.locationLayer
const base = Layer.mergeAll(
location,
Policy.locationLayer,
Config.locationLayer,
Reference.locationLayer,
PluginV2.locationLayer,
Catalog.locationLayer,
Integration.locationLayer,
CommandV2.locationLayer,
AgentV2.locationLayer,
PluginInternal.locationLayer,
ProjectCopy.locationLayer,
FileSystem.locationLayer,
Watcher.locationLayer,
Pty.locationLayer,
SkillV2.locationLayer,
systemContext,
LocationMutation.locationLayer.pipe(Layer.orDie),
).pipe(Layer.provideMerge(location))
const resources = ToolOutputStore.layer.pipe(Layer.provide(base))
const permissionsAndTools = ToolRegistry.layer.pipe(
Layer.provideMerge(PermissionV2.locationLayer),
Layer.provide(resources),
Layer.provide(base),
)
const services = Layer.mergeAll(base, resources, permissionsAndTools)
const image = Image.layer.pipe(Layer.provide(services))
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services))
const todos = SessionTodo.layer.pipe(Layer.provide(services))
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
const builtInTools = BuiltInTools.locationLayer.pipe(
Layer.provide(services),
Layer.provide(mutation),
Layer.provide(resources),
Layer.provide(todos),
Layer.provide(questions),
Layer.provide(image),
)
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
const snapshot = Snapshot.locationLayer.pipe(Layer.provide(services))
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(services),
Layer.provide(model),
Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
Layer.provide(snapshot),
)
// Kick off a background project copy refresh to update locations now that we
// have a location
const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services))
return Layer.mergeAll(
boot,
services,
image,
mutation,
resources,
todos,
questions,
model,
snapshot,
runner,
builtInTools,
referenceGuidance,
projectCopyRefresh,
).pipe(Layer.fresh)
},
idleTimeToLive: "60 minutes",
dependencies: [
Project.defaultLayer,
EventV2.defaultLayer,
Credential.defaultLayer,
Npm.defaultLayer,
ModelsDev.defaultLayer,
FSUtil.defaultLayer,
Git.defaultLayer,
AppProcess.defaultLayer,
Global.defaultLayer,
Ripgrep.defaultLayer,
Database.defaultLayer,
ProjectDirectories.defaultLayer,
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
PermissionSaved.defaultLayer,
RepositoryCache.defaultLayer,
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
FetchHttpClient.layer,
ToolOutputStore.defaultCleanupLayer,
ApplicationTools.layer,
],
}) {}
-7
View File
@@ -1,6 +1,5 @@
export * as LocationMutation from "./location-mutation"
import { makeLocationNode } from "./effect/app-node"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "./fs-util"
@@ -154,9 +153,3 @@ export const layer = Layer.effect(
)
export const locationLayer = layer
export const node = makeLocationNode({
service: Service,
layer: layer.pipe(Layer.orDie),
deps: [FSUtil.node, Location.node],
})
-18
View File
@@ -1,18 +0,0 @@
import { Context, Effect, Layer, LayerMap } from "effect"
import { LayerNode } from "./effect/layer-node"
import { Node } from "./effect/app-node"
import { Location } from "./location"
import type { LocationError, LocationServices } from "./location-services"
export class Service extends Context.Service<
Service,
LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>
>()("@opencode/example/LocationServiceMap") {
static get(ref: Location.Ref) {
return Layer.unwrap(Effect.map(Service, (locations) => locations.get(ref)))
}
}
export const node = LayerNode.unbound(Service, Node.tags.values.global)
export * as LocationServiceMap from "./location-service-map"
-114
View File
@@ -1,114 +0,0 @@
import { Effect, Layer, LayerMap } from "effect"
import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
import { CommandV2 } from "./command"
import { Config } from "./config"
import { LayerNode } from "./effect/layer-node"
import { Node } from "./effect/app-node"
import { FileMutation } from "./file-mutation"
import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search"
import { Watcher } from "./filesystem/watcher"
import { Image } from "./image"
import { Integration } from "./integration"
import { Location } from "./location"
import { LocationMutation } from "./location-mutation"
import { LocationServiceMap } from "./location-service-map"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
import { PluginInternal } from "./plugin/internal"
import { Policy } from "./policy"
import { ProjectCopy } from "./project/copy"
import { Pty } from "./pty"
import { QuestionV2 } from "./question"
import { Reference } from "./reference"
import { ReferenceGuidance } from "./reference/guidance"
import * as SessionRunnerLLM from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SessionRuntime } from "./session/runtime"
import { SessionTodo } from "./session/todo"
import { SkillV2 } from "./skill"
import { SkillGuidance } from "./skill/guidance"
import { Snapshot } from "./snapshot"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { SystemContextRegistry } from "./system-context/registry"
import { BuiltInTools } from "./tool/builtins"
import { ReadToolFileSystem } from "./tool/read-filesystem"
import { ToolRegistry } from "./tool/registry"
import { ToolOutputStore } from "./tool-output-store"
export { LocationServiceMap } from "./location-service-map"
export const locationServices = LayerNode.group([
Location.node,
Policy.node,
Config.node,
AgentV2.node,
CommandV2.node,
Reference.node,
Integration.node,
Catalog.node,
AISDK.node,
PluginV2.node,
PluginInternal.node,
ProjectCopy.node,
ProjectCopy.refreshNode,
FileSystemSearch.node,
FileSystem.node,
Watcher.node,
Pty.node,
SkillV2.node,
SystemContextRegistry.node,
SystemContextBuiltIns.node,
LocationMutation.node,
FileMutation.node,
PermissionV2.node,
ToolOutputStore.node,
ToolRegistry.node,
ToolRegistry.toolsNode,
Image.node,
SkillGuidance.node,
ReferenceGuidance.node,
SessionTodo.node,
QuestionV2.node,
ReadToolFileSystem.node,
BuiltInTools.node,
SessionRunnerModel.node,
Snapshot.node,
SessionRuntime.node,
SessionRunnerLLM.node,
])
export type LocationServices = LayerNode.Output<typeof locationServices>
export type LocationError = LayerNode.Error<typeof locationServices>
export function buildLocationServiceMap(
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
): Layer.Layer<LocationServiceMap.Service> {
return Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
(ref: Location.Ref) => {
const location = LayerNode.hoist(
LayerNode.bind(locationServices, Location.node, Location.boundNode(ref)),
Node.tags.values.global,
)
return LayerNode.compile(location.node, replacements).pipe(
Layer.fresh,
Layer.tap(() =>
Effect.logInfo("booting location services", {
directory: ref.directory,
workspaceID: ref.workspaceID,
}),
),
Layer.provide(LayerNode.compile(location.hoisted, replacements)),
)
},
{ idleTimeToLive: "60 minutes" },
),
)
}
// This is temporary for backwards compatibility
export const locationServiceMapLayer = buildLocationServiceMap()
-11
View File
@@ -1,8 +1,6 @@
import { Context, Effect, Layer } from "effect"
import { Info, Ref, response } from "@opencode-ai/schema/location"
import { Project } from "./project"
import { LayerNode } from "./effect/layer-node"
import { makeLocationNode, tags } from "./effect/app-node"
export * as Location from "./location"
@@ -14,8 +12,6 @@ export interface Interface extends Info {
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
export const node = LayerNode.unbound(Service, tags.values.location)
export const layer = (ref: Ref) =>
Layer.effect(
Service,
@@ -30,10 +26,3 @@ export const layer = (ref: Ref) =>
})
}),
)
export const boundNode = (ref: Ref) =>
makeLocationNode({
service: Service,
layer: layer(ref),
deps: [Project.node],
})
+3 -3
View File
@@ -9,8 +9,8 @@ import { Hash } from "./util/hash"
import { FSUtil } from "./fs-util"
import { InstallationChannel, InstallationVersion } from "./installation/version"
import { EventV2 } from "./event"
import { makeGlobalNode } from "./effect/app-node"
import { httpClient } from "./effect/app-node-platform"
import { LayerNode } from "./effect/layer-node"
import { httpClient } from "./effect/layer-node-platform"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -244,6 +244,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
export * as ModelsDev from "./models-dev"
+3 -3
View File
@@ -7,8 +7,8 @@ import { NodeFileSystem } from "@effect/platform-node"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { EffectFlock } from "./util/effect-flock"
import { makeGlobalNode } from "./effect/app-node"
import { filesystem } from "./effect/app-node-platform"
import { LayerNode } from "./effect/layer-node"
import { filesystem } from "./effect/layer-node-platform"
import { makeRuntime } from "./effect/runtime"
import { NpmConfig } from "./npm-config"
@@ -253,7 +253,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer),
)
export const node = makeGlobalNode({
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node],
-7
View File
@@ -1,6 +1,5 @@
export * as PermissionV2 from "./permission"
import { makeLocationNode } from "./effect/app-node"
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { EventV2 } from "./event"
@@ -301,9 +300,3 @@ export const layer = Layer.effect(
)
export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer))
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node],
})
-3
View File
@@ -3,7 +3,6 @@ export * as PermissionSaved from "./saved"
import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { makeGlobalNode } from "../effect/app-node"
import { ProjectV2 } from "../project"
import { PermissionTable } from "./sql"
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
@@ -77,5 +76,3 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
-24
View File
@@ -1,6 +1,5 @@
export * as PluginV2 from "./plugin"
import { makeLocationNode } from "./effect/app-node"
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect"
import { Plugin } from "@opencode-ai/schema/plugin"
@@ -13,10 +12,7 @@ import { Integration } from "./integration"
import { KeyedMutex } from "./effect/keyed-mutex"
import { PluginHost } from "./plugin/host"
import { Reference } from "./reference"
import { SessionV2 } from "./session"
import { SessionRuntime } from "./session/runtime"
import { SkillV2 } from "./skill"
import { Location } from "./location"
import { State } from "./state"
export const ID = Plugin.ID
@@ -152,25 +148,5 @@ export const locationLayer = layer.pipe(
Layer.provideMerge(CommandV2.locationLayer),
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(Reference.locationLayer),
Layer.provideMerge(SessionV2.defaultLayer),
Layer.provideMerge(SessionRuntime.layer),
Layer.provideMerge(SkillV2.locationLayer),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [
EventV2.node,
AgentV2.node,
AISDK.node,
Catalog.node,
CommandV2.node,
Integration.node,
Reference.node,
SessionV2.node,
SessionRuntime.node,
SkillV2.node,
Location.node,
],
})
-61
View File
@@ -13,11 +13,7 @@ import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider"
import { Reference } from "../reference"
import type { DeepMutable } from "../schema"
import { SessionV2 } from "../session"
import { SessionMessage } from "../session/message"
import { SessionRuntime } from "../session/runtime"
import { SkillV2 } from "../skill"
import { Location } from "../location"
const mutable = <T>(value: T) => value as DeepMutable<T>
@@ -28,10 +24,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
const commands = yield* CommandV2.Service
const integration = yield* Integration.Service
const reference = yield* Reference.Service
const session = yield* SessionV2.Service
const sessionRuntime = yield* SessionRuntime.Service
const skill = yield* SkillV2.Service
const location = yield* Location.Service
return {
options: {},
@@ -212,60 +205,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}),
),
},
session: {
create: (input) =>
session.create(
input.parentID
? {
id: input.id ? SessionV2.ID.make(input.id) : undefined,
parentID: SessionV2.ID.make(input.parentID),
title: input.title,
agent: input.agent ? AgentV2.ID.make(input.agent) : undefined,
model: input.model
? {
id: ModelV2.ID.make(input.model.id),
providerID: ProviderV2.ID.make(input.model.providerID),
variant: input.model.variant ? ModelV2.VariantID.make(input.model.variant) : undefined,
}
: undefined,
}
: {
id: input.id ? SessionV2.ID.make(input.id) : undefined,
location: { directory: location.directory, workspaceID: location.workspaceID },
title: input.title,
agent: input.agent ? AgentV2.ID.make(input.agent) : undefined,
model: input.model
? {
id: ModelV2.ID.make(input.model.id),
providerID: ProviderV2.ID.make(input.model.providerID),
variant: input.model.variant ? ModelV2.VariantID.make(input.model.variant) : undefined,
}
: undefined,
},
),
get: (sessionID) => session.get(SessionV2.ID.make(sessionID)),
messages: (input) =>
session.messages({
sessionID: SessionV2.ID.make(input.sessionID),
limit: input.limit,
order: input.order,
cursor: input.cursor
? { id: SessionMessage.ID.make(input.cursor.id), direction: input.cursor.direction }
: undefined,
}),
context: (sessionID) => session.context(SessionV2.ID.make(sessionID)),
prompt: (input) =>
sessionRuntime.prompt({
id: input.id ? SessionMessage.ID.make(input.id) : undefined,
sessionID: SessionV2.ID.make(input.sessionID),
prompt: input.prompt,
delivery: input.delivery,
resume: input.resume,
}),
resume: (sessionID) => sessionRuntime.resume(SessionV2.ID.make(sessionID)),
wait: (sessionID) => sessionRuntime.wait(SessionV2.ID.make(sessionID)),
interrupt: (sessionID) => sessionRuntime.interrupt(SessionV2.ID.make(sessionID)),
},
skill: {
reload: skill.reload,
transform: (callback) =>
+2 -29
View File
@@ -1,7 +1,5 @@
export * as PluginInternal from "./internal"
import { makeLocationNode } from "../effect/app-node"
import { httpClient } from "../effect/app-node-platform"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, Layer, Scope } from "effect"
import { AgentV2 } from "../agent"
@@ -60,7 +58,7 @@ export function define<R>(plugin: Plugin<R>) {
return plugin
}
const layer = Layer.effectDiscard(
export const locationLayer = Layer.effectDiscard(
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
@@ -122,34 +120,9 @@ const layer = Layer.effectDiscard(
}),
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
}),
)
export const locationLayer = layer.pipe(
).pipe(
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(FileSystem.locationLayer),
Layer.provideMerge(FetchHttpClient.layer),
)
export const node = makeLocationNode({
name: "plugin-internal",
layer,
deps: [
Catalog.node,
CommandV2.node,
PluginV2.node,
Integration.node,
AgentV2.node,
Config.node,
Location.node,
ModelsDev.node,
Npm.node,
EventV2.node,
FSUtil.node,
FileSystem.node,
Global.node,
httpClient,
SkillV2.node,
Reference.node,
],
})
-10
View File
@@ -81,16 +81,6 @@ export function fromPromise(plugin: Plugin) {
transform: transform(host.reference),
reload: () => run(host.reference.reload()),
},
session: {
create: (input) => run(host.session.create(input)),
get: (sessionID) => run(host.session.get(sessionID)),
messages: (input) => run(host.session.messages(input)),
context: (sessionID) => run(host.session.context(sessionID)),
prompt: (input) => run(host.session.prompt(input)),
resume: (sessionID) => run(host.session.resume(sessionID)),
wait: (sessionID) => run(host.session.wait(sessionID)),
interrupt: (sessionID) => run(host.session.interrupt(sessionID)),
},
skill: {
transform: transform(host.skill),
reload: () => run(host.skill.reload()),
-3
View File
@@ -1,6 +1,5 @@
export * as Policy from "./policy"
import { makeLocationNode } from "./effect/app-node"
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
import { Wildcard } from "./util/wildcard"
import { Location } from "./location"
@@ -45,5 +44,3 @@ export const layer = Layer.effect(
)
export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
+2 -2
View File
@@ -3,7 +3,7 @@ import type { PlatformError } from "effect/PlatformError"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
import { makeGlobalNode } from "./effect/app-node"
import { LayerNode } from "./effect/layer-node"
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
command: Schema.String,
@@ -257,6 +257,6 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
export const node = LayerNode.make({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
export * as AppProcess from "./process"
+2 -2
View File
@@ -6,7 +6,7 @@ import path from "path"
import { AbsolutePath } from "./schema"
import { FSUtil } from "./fs-util"
import { Git } from "./git"
import { makeGlobalNode } from "./effect/app-node"
import { LayerNode } from "./effect/layer-node"
import { Hash } from "./util/hash"
import { ProjectDirectories } from "./project/directories"
import { ProjectSchema } from "./project/schema"
@@ -134,7 +134,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provideMerge(ProjectDirectories.defaultLayer),
)
export const node = makeGlobalNode({
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, ProjectDirectories.node],
+2 -8
View File
@@ -5,7 +5,7 @@ import path from "path"
import { AbsolutePath } from "../schema"
import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { makeLocationNode } from "../effect/app-node"
import { LayerNode } from "../effect/layer-node"
import { Project } from "../project"
import { ProjectDirectories } from "./directories"
import { makeGitWorktreeStrategy } from "./copy-strategies"
@@ -279,14 +279,8 @@ export const layer = Layer.effect(
)
export const locationLayer = layer
export const node = makeLocationNode({
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node],
})
export const refreshNode = makeLocationNode({
name: "project-copy-refresh",
layer: Layer.effectDiscard(refreshAfterBoot),
deps: [node, Location.node],
})
+2 -2
View File
@@ -3,7 +3,7 @@ export * as ProjectDirectories from "./directories"
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { makeGlobalNode } from "../effect/app-node"
import { LayerNode } from "../effect/layer-node"
import { AbsolutePath, optional } from "../schema"
import { ProjectSchema } from "./schema"
import { ProjectDirectoryTable } from "./sql"
@@ -156,4 +156,4 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
-3
View File
@@ -1,6 +1,5 @@
export * as Pty from "./pty"
import { makeLocationNode } from "./effect/app-node"
import type { Disp, Proc } from "#pty"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Pty } from "@opencode-ai/schema/pty"
@@ -314,5 +313,3 @@ export const layer = Layer.effect(
)
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Location.node, Config.node] })
+2 -2
View File
@@ -4,7 +4,7 @@ import { WorkspaceV2 } from "../workspace"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { PtyID } from "./schema"
import { Cache, Context, Duration, Effect, Layer } from "effect"
import { makeGlobalNode } from "../effect/app-node"
import { LayerNode } from "../effect/layer-node"
const DEFAULT_TTL = Duration.seconds(60)
const CAPACITY = 10_000
@@ -54,4 +54,4 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
export const layer = Layer.effect(Service, make())
export const defaultLayer = layer
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] })
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
@@ -0,0 +1,7 @@
export * as PublicEventManifest from "./public-event-manifest"
import { Event } from "@opencode-ai/schema/event"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
export const Definitions = EventManifest.ServerDefinitions
export const Latest = Event.latest(Definitions)
-3
View File
@@ -1,6 +1,5 @@
export * as QuestionV2 from "./question"
import { makeLocationNode } from "./effect/app-node"
import { Context, Deferred, Effect, Layer, Schema } from "effect"
import { Question } from "@opencode-ai/schema/question"
import { EventV2 } from "./event"
@@ -149,5 +148,3 @@ export const layer = Layer.effect(
)
export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
-7
View File
@@ -1,6 +1,5 @@
export * as Reference from "./reference"
import { makeLocationNode } from "./effect/app-node"
import { Context, Effect, Layer, Scope, Types } from "effect"
import { Reference } from "@opencode-ai/schema/reference"
import { Global } from "./global"
@@ -121,9 +120,3 @@ export const layer = Layer.effect(
)
export const locationLayer = layer
export const node = makeLocationNode({
service: Service,
layer,
deps: [Global.node, EventV2.node, RepositoryCache.node],
})
-3
View File
@@ -1,6 +1,5 @@
export * as ReferenceGuidance from "./guidance"
import { makeLocationNode } from "../effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Reference } from "../reference"
import { SystemContext } from "../system-context/index"
@@ -65,5 +64,3 @@ export const layer = Layer.effect(
)
export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [Reference.node] })
-7
View File
@@ -5,7 +5,6 @@ import { Git } from "./git"
import { Global } from "./global"
import { Repository } from "./repository"
import { AbsolutePath } from "./schema"
import { makeGlobalNode } from "./effect/app-node"
import { EffectFlock } from "./util/effect-flock"
export type Result = {
@@ -230,12 +229,6 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(Global.defaultLayer),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node],
})
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
if (!input.reuse) return "cloned" as const
if (input.branchMatches === false || input.refresh) return "refreshed" as const
+2 -2
View File
@@ -3,7 +3,7 @@ export * as Ripgrep from "./ripgrep"
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Entry, Match } from "@opencode-ai/schema/filesystem"
import { makeGlobalNode } from "./effect/app-node"
import { LayerNode } from "./effect/layer-node"
import { AppProcess, collectStream, waitForAbort } from "./process"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { RipgrepBinary } from "./ripgrep/binary"
@@ -279,4 +279,4 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)))
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
export const node = LayerNode.make({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
+3 -3
View File
@@ -4,8 +4,8 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
import { makeGlobalNode } from "../effect/app-node"
import { httpClient } from "../effect/app-node-platform"
import { LayerNode } from "../effect/layer-node"
import { httpClient } from "../effect/layer-node-platform"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { which } from "../util/which"
@@ -130,7 +130,7 @@ export namespace RipgrepBinary {
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
export const node = makeGlobalNode({
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
+284 -311
View File
@@ -25,9 +25,8 @@ import { ProjectTable } from "./project/sql"
import path from "path"
import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index"
import { SessionRuntimeCoordinator } from "./session/runtime-coordinator"
import { SessionStore } from "./session/store"
import { makeGlobalNode } from "./effect/app-node"
import { SessionExecution } from "./session/execution"
import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input"
@@ -35,7 +34,6 @@ import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util"
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
export const RevertState = Revert.State
export type RevertState = Revert.State
@@ -75,14 +73,12 @@ const ListAllInput = Schema.Struct(ListInputBase)
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
export type ListInput = typeof ListInput.Type
type CreateBaseInput = {
type CreateInput = {
id?: SessionSchema.ID
title?: string
agent?: AgentV2.ID
model?: ModelV2.Ref
location: Location.Ref
}
type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
type CompactInput = {
sessionID: SessionSchema.ID
@@ -96,7 +92,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
"Session.OperationUnavailableError",
{
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact"]),
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact", "wait"]),
},
) {}
@@ -106,9 +102,6 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
@@ -116,7 +109,7 @@ export type Error = NotFoundError | MessageDecodeError | OperationUnavailableErr
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info>
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly messages: (input: {
sessionID: SessionSchema.ID
@@ -138,17 +131,11 @@ export interface Interface {
sessionID: SessionSchema.ID
after?: number
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
readonly history: (input: {
sessionID: SessionSchema.ID
after?: number
limit: number
}) => Effect.Effect<{ events: ReadonlyArray<SessionEvent.DurableEvent>; hasMore: boolean }, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: {
sessionID: SessionSchema.ID
model: ModelV2.Ref
}) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -169,7 +156,7 @@ export interface Interface {
resume?: boolean
}) => Effect.Effect<void, OperationUnavailableError>
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
@@ -178,300 +165,300 @@ export interface Interface {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
files?: boolean
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | Snapshot.Error>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const database = yield* Database.Service
const db = database.db
const events = yield* EventV2.Service
const projects = yield* ProjectV2.Service
const runtimeCoordinator = yield* SessionRuntimeCoordinator.Service
const store = yield* SessionStore.Service
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
() =>
new MessageDecodeError({
sessionID: SessionSchema.ID.make(row.session_id),
messageID: SessionMessage.ID.make(row.id),
}),
),
)
const result = Service.of({
create: Effect.fn("V2Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
const recorded = yield* store.get(sessionID)
if (recorded) return recorded
const parent = input.parentID ? yield* store.get(input.parentID) : undefined
if (input.parentID && parent === undefined) return yield* new NotFoundError({ sessionID: input.parentID })
const location = parent?.location ?? input.location
if (location === undefined)
return yield* Effect.die(new Error("V2Session.create requires either location or an existing parentID"))
const project = yield* projects.resolve(location.directory)
yield* db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const now = Date.now()
const info = SessionV1.SessionInfo.make({
id: sessionID,
slug: Slug.create(),
version: InstallationVersion,
projectID: project.id,
parentID: input.parentID,
directory: location.directory,
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
workspaceID: location.workspaceID ? WorkspaceV2.ID.make(location.workspaceID) : undefined,
title: input.title ?? `New session - ${new Date(now).toISOString()}`,
agent: input.agent,
model: input.model
? {
id: ModelV2.ID.make(input.model.id),
providerID: input.model.providerID,
variant: input.model.variant,
}
: undefined,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: now, updated: now },
})
const projected = yield* events.publish(SessionV1.Event.Created, { sessionID, info }, { location }).pipe(
Effect.as({ type: "created" } as const),
Effect.catchDefect((defect) => {
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
return Effect.die(defect)
}
// Concurrent creation lost the projection race. The existing Session identity wins.
return store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
),
)
}),
)
if (projected.type === "existing") return projected.session
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
return yield* result.get(sessionID).pipe(Effect.orDie)
}),
get: Effect.fn("V2Session.get")(function* (sessionID) {
const session = yield* store.get(sessionID)
if (!session) return yield* new NotFoundError({ sessionID })
return session
}),
list: Effect.fn("V2Session.list")(function* (input = {}) {
const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_created
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.anchor) {
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
)!
: or(
lt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
)!,
)
}
const query = db
.select()
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(
order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
)
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
}),
messages: Effect.fn("V2Session.messages")(function* (input) {
yield* result.get(input.sessionID)
const direction = input.cursor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const anchor = input.cursor
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
)
.get()
.pipe(Effect.orDie)
: undefined
if (input.cursor && !anchor) return []
const boundary = anchor
? order === "asc"
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
}),
message: Effect.fn("V2Session.message")(function* (input) {
const stored = yield* store.message(input.messageID)
return stored?.sessionID === input.sessionID ? stored.message : undefined
}),
context: Effect.fn("V2Session.context")(function* (sessionID) {
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
events: (input) =>
Stream.unwrap(
result
.get(input.sessionID)
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
history: Effect.fn("V2Session.history")(function* (input) {
yield* result.get(input.sessionID)
return yield* EventV2.readAggregate(db, {
...input,
aggregateID: input.sessionID,
manifest: SessionDurable,
})
}),
prompt: Effect.fn("V2Session.prompt")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const prompt = resolvePrompt(input.prompt)
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
const admitted = yield* SessionInput.admit(db, events, {
id: messageID,
sessionID: input.sessionID,
prompt,
delivery,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInput.LifecycleConflict
? new PromptConflictError({ sessionID: input.sessionID, messageID })
: Effect.die(defect),
export const layer = Layer.unwrap(
Effect.promise(() => import("./location-layer")).pipe(
Effect.map(({ LocationServiceMap }) =>
Layer.effect(
Service,
Effect.gen(function* () {
const database = yield* Database.Service
const db = database.db
const events = yield* EventV2.Service
const projects = yield* ProjectV2.Service
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
() =>
new MessageDecodeError({
sessionID: SessionSchema.ID.make(row.session_id),
messageID: SessionMessage.ID.make(row.id),
}),
),
)
if (!SessionInput.equivalent(admitted, expected))
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false)
return yield* Effect.die("SessionV2.prompt with resume moved to SessionRuntime.Service")
return admitted
}),
),
),
shell: Effect.fn("V2Session.shell")(function* () {
return yield* new OperationUnavailableError({ operation: "shell" })
}),
skill: Effect.fn("V2Session.skill")(function* () {
return yield* new OperationUnavailableError({ operation: "skill" })
}),
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
agent: input.agent,
})
}),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
const session = yield* result.get(input.sessionID)
if (
session.model?.providerID === input.model.providerID &&
session.model.id === input.model.id &&
(session.model.variant ?? "default") === (input.model.variant ?? "default")
)
return
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
model: input.model,
})
}),
rename: Effect.fn("V2Session.rename")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.Renamed, {
sessionID: input.sessionID,
timestamp: yield* DateTime.now,
title: input.title,
})
}),
compact: Effect.fn("V2Session.compact")(function* (input) {
yield* result.get(input.sessionID)
return yield* new OperationUnavailableError({ operation: "compact" })
}),
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
yield* result.get(sessionID)
return yield* Effect.die("SessionV2.wait moved to SessionRuntime.Service")
}),
active: runtimeCoordinator.active,
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
yield* result.get(sessionID)
return yield* Effect.die("SessionV2.resume moved to SessionRuntime.Service")
}),
interrupt: Effect.fn("V2Session.interrupt")(() => Effect.die("SessionV2.interrupt moved to SessionRuntime.Service")),
revert: {
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
yield* result.get(input.sessionID)
return yield* Effect.die("SessionV2.revert.stage moved to SessionRuntime.Service")
}),
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
yield* result.get(sessionID)
return yield* Effect.die("SessionV2.revert.clear moved to SessionRuntime.Service")
}),
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
const session = yield* result.get(sessionID)
return yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
}),
},
})
return result
}),
const result = Service.of({
create: Effect.fn("V2Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
const recorded = yield* store.get(sessionID)
if (recorded) return recorded
const project = yield* projects.resolve(input.location.directory)
yield* db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const now = Date.now()
const info = SessionV1.SessionInfo.make({
id: sessionID,
slug: Slug.create(),
version: InstallationVersion,
projectID: project.id,
directory: input.location.directory,
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
title: `New session - ${new Date(now).toISOString()}`,
agent: input.agent,
model: input.model
? {
id: ModelV2.ID.make(input.model.id),
providerID: input.model.providerID,
variant: input.model.variant,
}
: undefined,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: now, updated: now },
})
const projected = yield* events
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
.pipe(
Effect.as({ type: "created" } as const),
Effect.catchDefect((defect) => {
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
return Effect.die(defect)
}
// Concurrent creation lost the projection race. The existing Session identity wins.
return store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
),
)
}),
)
if (projected.type === "existing") return projected.session
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
return yield* result.get(sessionID).pipe(Effect.orDie)
}),
get: Effect.fn("V2Session.get")(function* (sessionID) {
const session = yield* store.get(sessionID)
if (!session) return yield* new NotFoundError({ sessionID })
return session
}),
list: Effect.fn("V2Session.list")(function* (input = {}) {
const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_created
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.anchor) {
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
)!
: or(
lt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
)!,
)
}
const query = db
.select()
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(
order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
)
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
}),
messages: Effect.fn("V2Session.messages")(function* (input) {
yield* result.get(input.sessionID)
const direction = input.cursor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const anchor = input.cursor
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, input.sessionID),
eq(SessionMessageTable.id, input.cursor.id),
),
)
.get()
.pipe(Effect.orDie)
: undefined
if (input.cursor && !anchor) return []
const boundary = anchor
? order === "asc"
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
}),
message: Effect.fn("V2Session.message")(function* (input) {
const stored = yield* store.message(input.messageID)
return stored?.sessionID === input.sessionID ? stored.message : undefined
}),
context: Effect.fn("V2Session.context")(function* (sessionID) {
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
events: (input) =>
Stream.unwrap(
result
.get(input.sessionID)
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
prompt: Effect.fn("V2Session.prompt")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const prompt = resolvePrompt(input.prompt)
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
const admitted = yield* SessionInput.admit(db, events, {
id: messageID,
sessionID: input.sessionID,
prompt,
delivery,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInput.LifecycleConflict
? new PromptConflictError({ sessionID: input.sessionID, messageID })
: Effect.die(defect),
),
)
if (!SessionInput.equivalent(admitted, expected))
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
return admitted
}),
),
),
shell: Effect.fn("V2Session.shell")(function* () {
return yield* new OperationUnavailableError({ operation: "shell" })
}),
skill: Effect.fn("V2Session.skill")(function* () {
return yield* new OperationUnavailableError({ operation: "skill" })
}),
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
agent: input.agent,
})
}),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
const session = yield* result.get(input.sessionID)
if (
session.model?.providerID === input.model.providerID &&
session.model.id === input.model.id &&
(session.model.variant ?? "default") === (input.model.variant ?? "default")
)
return
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
model: input.model,
})
}),
compact: Effect.fn("V2Session.compact")(function* (input) {
yield* result.get(input.sessionID)
return yield* new OperationUnavailableError({ operation: "compact" })
}),
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
yield* result.get(sessionID)
return yield* new OperationUnavailableError({ operation: "wait" })
}),
active: execution.active,
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
yield* result.get(sessionID)
yield* execution.resume(sessionID)
}),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)),
),
revert: {
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
const session = yield* result.get(input.sessionID)
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(EventV2.Service, events),
Effect.provide(locations.get(session.location)),
)
}),
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* SessionRevert.clear(session).pipe(
Effect.provideService(EventV2.Service, events),
Effect.provide(locations.get(session.location)),
)
}),
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
}),
},
})
return result
}),
),
),
),
)
export const defaultLayer = layer.pipe(
Layer.provide(
Layer.unwrap(Effect.promise(() => import("./location-layer")).pipe(Effect.map((m) => m.LocationServiceMap.layer))),
),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(SessionRuntimeCoordinator.defaultLayer),
Layer.orDie,
)
@@ -479,7 +466,6 @@ const resolvePrompt = (input: PromptInput.Prompt) =>
Prompt.make({
text: input.text,
agents: input.agents,
system: input.system,
files: input.files?.map((file) => {
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
@@ -489,16 +475,3 @@ const resolvePrompt = (input: PromptInput.Prompt) =>
}
}),
})
export const node = makeGlobalNode({
service: Service,
layer: layer.pipe(Layer.orDie),
deps: [
Database.node,
EventV2.node,
ProjectV2.node,
SessionRuntimeCoordinator.node,
SessionStore.node,
SessionProjector.node,
],
})
-7
View File
@@ -1,8 +1,6 @@
export * as SessionExecution from "./execution"
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "../effect/layer-node"
import { Node } from "../effect/app-node"
import { SessionRunner } from "./runner/index"
import { SessionSchema } from "./schema"
@@ -15,15 +13,11 @@ export interface Interface {
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionExecution") {}
export const node = LayerNode.unbound(Service, Node.tags.values.global)
/** Low-level compatibility layer for callers that only need durable Session recording. */
export const noopLayer = Layer.succeed(
Service,
@@ -32,6 +26,5 @@ export const noopLayer = Layer.succeed(
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: () => Effect.void,
}),
)
+2 -12
View File
@@ -1,6 +1,5 @@
import { Cause, Effect, Layer } from "effect"
import { LocationServiceMap } from "../../location-service-map"
import { makeGlobalNode } from "../../effect/app-node"
import { LocationServiceMap } from "../../location-layer"
import { SessionRunCoordinator } from "../run-coordinator"
import { SessionRunner } from "../runner"
import { SessionSchema } from "../schema"
@@ -12,7 +11,7 @@ export const layer = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const locations = yield* LocationServiceMap
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID)
@@ -33,17 +32,8 @@ export const layer = Layer.effect(
interrupt: coordinator.interrupt,
resume: coordinator.run,
wake: coordinator.wake,
awaitIdle: coordinator.awaitIdle,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))
export const node = makeGlobalNode({
service: SessionExecution.Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node],
})
export * as SessionExecutionLocal from "./local"
@@ -123,7 +123,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
},
"session.next.moved": () => Effect.void,
"session.next.renamed": () => Effect.void,
"session.next.prompted": (event) => {
return adapter.appendMessage(
SessionMessage.User.make({
@@ -133,7 +132,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
system: event.data.prompt.system,
time: { created: event.data.timestamp },
}),
)
+2 -10
View File
@@ -4,7 +4,7 @@ import { and, desc, eq, gt, or, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { makeGlobalNode } from "../effect/app-node"
import { LayerNode } from "../effect/layer-node"
import { SessionEvent } from "./event"
import { SessionV1 } from "../v1/session"
import { WorkspaceTable } from "../control-plane/workspace.sql"
@@ -347,14 +347,6 @@ export const layer = Layer.effectDiscard(
yield* run(db, event)
}),
)
yield* events.project(SessionEvent.Renamed, (event) =>
db
.update(SessionTable)
.set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* events.project(SessionEvent.Prompted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
@@ -464,4 +456,4 @@ export const layer = Layer.effectDiscard(
)
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
export const node = makeGlobalNode({ name: "session-projector", layer, deps: [EventV2.node, Database.node] })
export const node = LayerNode.make({ name: "session-projector", layer, deps: [EventV2.node, Database.node] })
+1 -13
View File
@@ -12,8 +12,6 @@ export interface Coordinator<Key, E> {
readonly wake: (key: Key) => Effect.Effect<void>
/** Stops active execution and waits for its cleanup. */
readonly interrupt: (key: Key) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
type Entry<E> = {
@@ -102,15 +100,5 @@ export const make = <Key, E>(options: {
return Fiber.interrupt(entry.owner)
})
// Each successful drain reuses its entry.done across coalesced wakes, so one await
// already spans steered and queued continuation. Re-check after it settles to cover a
// fresh wake (or a failure/stopping successor) that installs a new entry.
const awaitIdle = (key: Key): Effect.Effect<void> =>
Effect.suspend(() => {
const entry = active.get(key)
if (entry === undefined) return Effect.void
return Deferred.await(entry.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
})
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt, awaitIdle }
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt }
})
+8 -32
View File
@@ -36,8 +36,6 @@ import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
import { MAX_STEPS_PROMPT } from "./max-steps"
import { Snapshot } from "../../snapshot"
import { makeLocationNode } from "../../effect/app-node"
import { llmClient } from "../../effect/app-node-platform"
/**
* Runs one durable coding-agent Session until it settles.
@@ -194,16 +192,13 @@ export const layer = Layer.effect(
const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message)
// Mirror V1 (session/llm/request.ts): append the current turn's per-request system string after the
// agent prompt and durable baseline. The current turn's user prompt is the latest user message in context.
const turnSystem = context.findLast((message) => message.type === "user")?.system
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
providerOptions: { openai: { promptCacheKey } },
system: [agent.info?.system, system.baseline, turnSystem]
system: [agent.info?.system, system.baseline]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
@@ -292,18 +287,19 @@ export const layer = Layer.effect(
}
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
return yield* Effect.interrupt
}
if (streamInterrupted || toolsInterrupted) {
if (
(stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) ||
(settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
) {
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
if (publisher.hasActiveAssistant())
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
}
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
const failure = Cause.squash(settled.cause)
@@ -311,7 +307,7 @@ export const layer = Layer.effect(
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
}
const stepSettlement = publisher.stepSettlement()
if (stepSettlement && !streamInterrupted && !toolsInterrupted && !publisher.hasProviderError()) {
if (stepSettlement && !publisher.hasProviderError()) {
const endSnapshot = yield* snapshots.capture()
const files =
startSnapshot && endSnapshot
@@ -409,23 +405,3 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer
export const node = makeLocationNode({
service: Service,
layer,
deps: [
EventV2.node,
llmClient,
AgentV2.node,
ToolRegistry.node,
SessionRunnerModel.node,
SessionStore.node,
Location.node,
SystemContextRegistry.node,
SkillGuidance.node,
ReferenceGuidance.node,
Config.node,
Snapshot.node,
Database.node,
],
})
@@ -1,6 +1,5 @@
export * as SessionRunnerModel from "./model"
import { makeLocationNode } from "../../effect/app-node"
import { type Model } from "@opencode-ai/llm"
import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages"
import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
@@ -214,5 +213,3 @@ export const locationLayer = Layer.effect(
})
}),
)
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Catalog.node, Integration.node] })

Some files were not shown because too many files have changed in this diff Show More