Compare commits

..
Author SHA1 Message Date
Kit Langton e3650cc425 refactor(tool): clean up native-friendly parameter schemas 2026-04-23 16:16:01 -04:00
opencode-agent[bot] 3f8c659056 chore: generate 2026-04-23 20:10:56 +00:00
Kit LangtonandGitHub 3910a6e527 refactor(tool): migrate tool framework + all 18 built-in tools to Effect Schema (#23244) 2026-04-23 16:09:34 -04:00
opencode-agent[bot] 24892559ae chore: generate 2026-04-23 19:38:54 +00:00
Kit LangtonandGitHub cd93533b1f refactor(bus): migrate BusEvent to Effect Schema (#24040) 2026-04-23 15:37:44 -04:00
Kit LangtonandGitHub 0590452456 refactor(schema): use Schema.Int and consolidate PositiveInt/NonNegativeInt (#24029) 2026-04-23 13:22:48 -04:00
Kit LangtonandGitHub 93940a1859 refactor(provider): migrate provider domain to Effect Schema (#24027) 2026-04-23 13:17:33 -04:00
Frank 1e439b8226 sync 2026-04-23 13:13:29 -04:00
Kit LangtonandGitHub 8b2f8355b2 docs(schema): mark sync/index.ts migrated with compat-bridge note (#24024) 2026-04-23 12:54:46 -04:00
opencode-agent[bot] aed03078f8 chore: generate 2026-04-23 16:44:32 +00:00
Kit LangtonandGitHub c50d65b4d6 refactor(sync): make session events schema-first (#24019) 2026-04-23 12:43:08 -04:00
opencode-agent[bot] 353532b1c1 chore: generate 2026-04-23 16:00:01 +00:00
Shoubhit DashandGitHub 9df7c78ebe fix(npm): respect npmrc for version lookups (#24016) 2026-04-23 21:28:54 +05:30
opencode eb7555d3c6 sync release versions for v1.14.22 2026-04-23 15:56:49 +00:00
opencode-agent[bot] 2cd89d64e9 chore: generate 2026-04-23 15:31:16 +00:00
Kit LangtonandGitHub 0517ab4695 refactor(session): migrate session domain to Effect Schema (#24005) 2026-04-23 11:30:02 -04:00
James LongandGitHub bbf67d0fff fix(tui): render all non-synthetic text parts of a user message (#24009) 2026-04-23 11:24:40 -04:00
Shoubhit DashandGitHub 38deb0f3ee fix(npm): respect npmrc config (#24001) 2026-04-23 19:54:01 +05:30
Simon KleeandGitHub 9b6db08d21 chore: add to TEAM_MEMBERS (#23975) 2026-04-23 13:02:40 +02:00
opencode-agent[bot] 3ae74cb047 chore: generate 2026-04-23 10:58:14 +00:00
Brendan AllanandGitHub 6002500bc0 feat(project): add icon_url_override field to projects (#23955) 2026-04-23 18:57:04 +08:00
Brendan AllanandGitHub 785f3589ab fix: add keyed prop to Show components for proper reactivity (#23935) 2026-04-23 17:32:01 +08:00
Frank a419f1c50f zen: hy3 preview 2026-04-23 02:56:41 -04:00
opencode 871789ce64 sync release versions for v1.14.21 2026-04-23 05:45:21 +00:00
Brendan AllanandGitHub df27baa00d refactor: remove redundant pending check from working memo (#23929) 2026-04-23 13:15:02 +08:00
137 changed files with 4405 additions and 1350 deletions
+1
View File
@@ -13,3 +13,4 @@ R44VC0RP
rekram1-node
RhysSullivan
thdxr
simonklee
+16 -16
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.21",
"version": "1.14.22",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.21",
"version": "1.14.22",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.21",
"version": "1.14.22",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.21",
"version": "1.14.22",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.21",
"version": "1.14.22",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.21",
"version": "1.14.22",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -225,7 +225,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.14.21",
"version": "1.14.22",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -269,7 +269,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.21",
"version": "1.14.22",
"dependencies": {
"@opencode-ai/shared": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -298,7 +298,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.21",
"version": "1.14.22",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -314,7 +314,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.21",
"version": "1.14.22",
"bin": {
"opencode": "./bin/opencode",
},
@@ -459,7 +459,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.21",
"version": "1.14.22",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -494,7 +494,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.21",
"version": "1.14.22",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -509,7 +509,7 @@
},
"packages/shared": {
"name": "@opencode-ai/shared",
"version": "1.14.21",
"version": "1.14.22",
"bin": {
"opencode": "./bin/opencode",
},
@@ -533,7 +533,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.21",
"version": "1.14.22",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -568,7 +568,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.21",
"version": "1.14.22",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -617,7 +617,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.21",
"version": "1.14.22",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.21",
"version": "1.14.22",
"description": "",
"type": "module",
"exports": {
+2 -33
View File
@@ -10,9 +10,8 @@ import type {
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/shared/util/path"
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import { createStore, produce, reconcile, unwrap } from "solid-js/store"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk"
import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap"
@@ -24,7 +23,6 @@ import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils"
import { formatServerError } from "@/utils/server-errors"
import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query"
@@ -56,15 +54,10 @@ function createGlobalSync() {
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
const [projectCache, setProjectCache, projectInit] = persisted(
Persist.global("globalSync.project", ["globalSync.project.v1"]),
createStore({ value: [] as Project[] }),
)
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: projectCache.value,
project: [],
session_todo: {},
provider: { all: [], connected: [], default: {} },
provider_auth: {},
@@ -73,32 +66,18 @@ function createGlobalSync() {
})
const queryClient = useQueryClient()
let active = true
let projectWritten = false
let bootedAt = 0
let bootingRoot = false
let eventFrame: number | undefined
let eventTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
active = false
})
onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
if (eventTimer !== undefined) clearTimeout(eventTimer)
})
const cacheProjects = () => {
setProjectCache(
"value",
untrack(() => globalStore.project.map(sanitizeProject)),
)
}
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
projectWritten = true
setGlobalStore("project", next)
cacheProjects()
}
const setBootStore = ((...input: unknown[]) => {
@@ -117,16 +96,6 @@ function createGlobalSync() {
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
if (projectInit instanceof Promise) {
void projectInit.then(() => {
if (!active) return
if (projectWritten) return
const cached = projectCache.value
if (cached.length === 0) return
setGlobalStore("project", cached)
})
}
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return
if (!todos) {
@@ -156,13 +156,12 @@ export function createChildStoreManager(input: {
const init = () =>
createRoot((dispose) => {
const initialMeta = meta[0].value
const initialIcon = icon[0].value
const pathQuery = useQuery(() => loadPathQuery(directory))
const child = createStore<State>({
project: "",
projectMeta: initialMeta,
projectMeta: undefined,
icon: initialIcon,
provider_ready: false,
provider: { all: [], connected: [], default: {} },
@@ -208,11 +207,6 @@ export function createChildStoreManager(input: {
child[1]("vcs", (value) => value ?? cached)
})
onPersistedInit(meta[2], () => {
if (child[0].projectMeta !== initialMeta) return
child[1]("projectMeta", meta[0].value)
})
onPersistedInit(icon[2], () => {
if (child[0].icon !== initialIcon) return
child[1]("icon", icon[0].value)
+2 -32
View File
@@ -391,37 +391,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree)
const local = childStore.projectMeta
const localOverride =
local?.name !== undefined ||
local?.commands?.start !== undefined ||
local?.icon?.override !== undefined ||
local?.icon?.color !== undefined
const base = {
...metadata,
...project,
icon: {
url: metadata?.icon?.url,
override: metadata?.icon?.override ?? childStore.icon,
color: metadata?.icon?.color,
},
}
const isGlobal = projectID === "global" || (metadata?.id === undefined && localOverride)
if (!isGlobal) return base
return {
...base,
id: base.id ?? "global",
name: local?.name,
commands: local?.commands,
icon: {
url: base.icon?.url,
override: local?.icon?.override,
color: local?.icon?.color,
},
}
return { ...metadata, ...project }
}
const roots = createMemo(() => {
@@ -516,7 +486,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
for (const project of projects) {
if (project.icon?.color || project.icon.url) continue
if (project.icon?.color || project.icon?.override || project.icon?.url) continue
const worktree = project.worktree
const existing = colors[worktree]
const color = existing ?? pickAvailableColor(used)
@@ -22,9 +22,7 @@ const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
return id === OPENCODE_PROJECT_ID
? "https://opencode.ai/favicon.svg"
: icon?.color
? undefined
: icon?.override || icon?.url
: (icon?.override ?? (icon?.color ? undefined : icon?.url))
}
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
@@ -269,10 +267,10 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
</Show>
</div>
</div>
<Show when={currentChild()}>
<Show when={currentChild()} keyed>
{(child) => (
<div class="w-full">
<SessionItem {...props} session={child()} level={(props.level ?? 0) + 1} />
<SessionItem {...props} session={child} level={(props.level ?? 0) + 1} />
</div>
)}
</Show>
@@ -259,7 +259,7 @@ export function MessageTimeline(props: {
if (!id) return idle
return sync.data.session_status[id] ?? idle
})
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
const working = createMemo(() => sessionStatus().type !== "idle")
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
const [timeoutDone, setTimeoutDone] = createSignal(true)
@@ -812,7 +812,7 @@ export function MessageTimeline(props: {
</Show>
</div>
</div>
<Show when={sessionID()}>
<Show when={sessionID()} keyed>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
@@ -878,12 +878,12 @@ export function MessageTimeline(props: {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.21",
"version": "1.14.22",
"type": "module",
"license": "MIT",
"scripts": {
@@ -187,6 +187,8 @@ export async function handler(
// Try another provider => stop retrying if using fallback provider
if (
res.status !== 200 &&
// ie. 400 error is usually provider error like malformed request
res.status !== 400 &&
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
res.status !== 404 &&
// ie. cannot change codex model providers mid-session
@@ -226,7 +228,7 @@ export async function handler(
logger.debug("STATUS: " + res.status + " " + res.statusText)
// Handle non-streaming response
if (!isStream || res.status === 429) {
if (!isStream || [400, 404, 429].includes(res.status)) {
const json = await res.json()
await rateLimiter?.track()
if (json.usage) {
@@ -238,6 +240,9 @@ export async function handler(
await reload(billingSource, authInfo, costInfo)
json.cost = calculateOccurredCost(billingSource, costInfo)
}
if (res.status === 400) {
logger.metric({ "error.response": JSON.stringify(json) })
}
if (json.error?.message) {
json.error.message = `Error from provider${providerInfo.displayName ? ` (${providerInfo.displayName})` : ""}: ${json.error.message}`
}
@@ -393,7 +398,7 @@ export async function handler(
type: "error",
error: {
type: "error",
message: error.message,
message: "Internal server error",
},
}),
{ status: 500 },
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.21",
"version": "1.14.22",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.21",
"version": "1.14.22",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.21",
"version": "1.14.22",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.14.21",
"version": "1.14.22",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.21",
"version": "1.14.22",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.21",
"version": "1.14.22",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.21"
version = "1.14.22"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.21/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.22/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.21/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.22/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.21/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.22/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.21/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.22/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.21/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.22/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.21",
"version": "1.14.22",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
@@ -0,0 +1,2 @@
ALTER TABLE `project` ADD `icon_url_override` text;
UPDATE `project` SET `icon_url_override` = `icon_url` WHERE `icon_url` IS NOT NULL;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.21",
"version": "1.14.22",
"name": "opencode",
"type": "module",
"license": "MIT",
+27 -13
View File
@@ -147,6 +147,17 @@ import `z` do so only for local `ZodOverride` bridges or for `z.ZodType`
type annotations — the `export const <Info|Spec>` values are all Effect
Schema at source.
A file is considered "done" when:
- its exported schema values (`Info`, `Input`, `Event`, `Definition`, etc.)
are authored as Effect Schema
- any remaining zod is either a derived compat bridge (via `zod()` /
`zodObject()`), a `z.ZodType` type annotation, or a documented
`ZodOverride` escape hatch — never a hand-written parallel source of truth
Files that meet this bar but still carry a compat bridge are checked off
with an inline note describing the bridge and what unblocks its removal.
- [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
- [x] server, layout
- [x] keybinds
@@ -159,6 +170,7 @@ Schema at source.
These are the highest-priority next targets. Each is a small, self-contained
schema module with a clear domain.
- [x] `src/account/schema.ts`
- [x] `src/control-plane/schema.ts`
- [x] `src/permission/schema.ts`
- [x] `src/project/schema.ts`
@@ -166,8 +178,10 @@ schema module with a clear domain.
- [x] `src/pty/schema.ts`
- [x] `src/question/schema.ts`
- [x] `src/session/schema.ts`
- [x] `src/storage/schema.ts`
- [x] `src/sync/schema.ts`
- [x] `src/tool/schema.ts`
- [x] `src/util/schema.ts`
### Session domain
@@ -248,21 +262,21 @@ Possible later tightening after the Schema-first migration is stable:
- promote repeated opaque strings and timestamp numbers into branded/newtype
leaf schemas where that adds domain value without changing the wire format
- [ ] `src/session/compaction.ts`
- [ ] `src/session/message-v2.ts`
- [ ] `src/session/message.ts`
- [ ] `src/session/prompt.ts`
- [ ] `src/session/revert.ts`
- [ ] `src/session/session.ts`
- [ ] `src/session/status.ts`
- [ ] `src/session/summary.ts`
- [ ] `src/session/todo.ts`
- [x] `src/session/compaction.ts`
- [x] `src/session/message-v2.ts`
- [x] `src/session/message.ts`
- [x] `src/session/prompt.ts`
- [x] `src/session/revert.ts`
- [x] `src/session/session.ts`
- [x] `src/session/status.ts`
- [x] `src/session/summary.ts`
- [x] `src/session/todo.ts`
### Provider domain
- [ ] `src/provider/auth.ts`
- [ ] `src/provider/models.ts`
- [ ] `src/provider/provider.ts`
- [x] `src/provider/auth.ts`
- [x] `src/provider/models.ts`
- [x] `src/provider/provider.ts`
### Tool schemas
@@ -362,7 +376,7 @@ piecewise.
- [ ] `src/snapshot/index.ts`
- [ ] `src/storage/db.ts`
- [ ] `src/storage/storage.ts`
- [ ] `src/sync/index.ts`
- [x] `src/sync/index.ts` — public API (`SyncEvent.define`) is Schema-first; still stores derived zod `schema`/`properties` on each `Definition` as a compat bridge for `BusEvent` until `bus/bus-event.ts` migrates
- [ ] `src/util/fn.ts`
- [ ] `src/util/log.ts`
- [ ] `src/util/update-schema.ts`
+2 -2
View File
@@ -372,7 +372,7 @@ export class Agent implements ACPAgent {
}
if (part.tool === "todowrite") {
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
const parsedTodos = z.array(Todo.Info.zod).safeParse(JSON.parse(part.state.output))
if (parsedTodos.success) {
await this.connection
.sessionUpdate({
@@ -901,7 +901,7 @@ export class Agent implements ACPAgent {
}
if (part.tool === "todowrite") {
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
const parsedTodos = z.array(Todo.Info.zod).safeParse(JSON.parse(part.state.output))
if (parsedTodos.success) {
await this.connection
.sessionUpdate({
+12 -8
View File
@@ -1,15 +1,19 @@
import z from "zod"
import type { ZodType } from "zod"
import { Schema } from "effect"
import { zodObject } from "@/util/effect-zod"
export type Definition = ReturnType<typeof define>
export type Definition<Type extends string = string, Properties extends Schema.Top = Schema.Top> = {
type: Type
properties: Properties
}
const registry = new Map<string, Definition>()
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = {
type,
properties,
}
export function define<Type extends string, Properties extends Schema.Top>(
type: Type,
properties: Properties,
): Definition<Type, Properties> {
const result = { type, properties }
registry.set(type, result)
return result
}
@@ -21,7 +25,7 @@ export function payloads() {
return z
.object({
type: z.literal(type),
properties: def.properties,
properties: zodObject(def.properties),
})
.meta({
ref: `Event.${def.type}`,
+10 -15
View File
@@ -1,5 +1,4 @@
import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream, Schema } from "effect"
import { EffectBridge } from "@/effect"
import { Log } from "../util"
import { BusEvent } from "./bus-event"
@@ -9,16 +8,18 @@ import { makeRuntime } from "@/effect/run-service"
const log = Log.create({ service: "bus" })
type BusProperties<D extends BusEvent.Definition<string, Schema.Top>> = Schema.Schema.Type<D["properties"]>
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
z.object({
directory: z.string(),
Schema.Struct({
directory: Schema.String,
}),
)
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
type: D["type"]
properties: z.infer<D["properties"]>
properties: BusProperties<D>
}
type State = {
@@ -27,10 +28,7 @@ type State = {
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: z.output<D["properties"]>,
) => Effect.Effect<void>
readonly publish: <D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
readonly subscribeCallback: <D extends BusEvent.Definition>(
@@ -79,7 +77,7 @@ export const layer = Layer.effect(
})
}
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) {
return Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const payload: Payload = { type: def.type, properties }
@@ -175,14 +173,11 @@ const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
export async function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) {
return runPromise((svc) => svc.publish(def, properties))
}
export function subscribe<D extends BusEvent.Definition>(
def: D,
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
) {
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => unknown) {
return runSync((svc) => svc.subscribeCallback(def, callback))
}
+3 -2
View File
@@ -11,6 +11,7 @@ import { ShareNext } from "../../share"
import { EOL } from "os"
import { Filesystem } from "../../util"
import { AppRuntime } from "@/effect/app-runtime"
import { Schema } from "effect"
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
export type ShareData =
@@ -154,10 +155,10 @@ export const ImportCommand = cmd({
return
}
const info = Session.Info.parse({
const info = Schema.decodeUnknownSync(Session.Info)({
...exportData.info,
projectID: Instance.project.id,
})
}) as Session.Info
const row = Session.toRow(info)
Database.use((db) =>
db
+13 -13
View File
@@ -1,14 +1,14 @@
import { BusEvent } from "@/bus/bus-event"
import { SessionID } from "@/session/schema"
import z from "zod"
import { Schema } from "effect"
export const TuiEvent = {
PromptAppend: BusEvent.define("tui.prompt.append", z.object({ text: z.string() })),
PromptAppend: BusEvent.define("tui.prompt.append", Schema.Struct({ text: Schema.String })),
CommandExecute: BusEvent.define(
"tui.command.execute",
z.object({
command: z.union([
z.enum([
Schema.Struct({
command: Schema.Union([
Schema.Literals([
"session.list",
"session.new",
"session.share",
@@ -26,23 +26,23 @@ export const TuiEvent = {
"prompt.submit",
"agent.cycle",
]),
z.string(),
Schema.String,
]),
}),
),
ToastShow: BusEvent.define(
"tui.toast.show",
z.object({
title: z.string().optional(),
message: z.string(),
variant: z.enum(["info", "success", "warning", "error"]),
duration: z.number().default(5000).optional().describe("Duration in milliseconds"),
Schema.Struct({
title: Schema.optional(Schema.String),
message: Schema.String,
variant: Schema.Literals(["info", "success", "warning", "error"]),
duration: Schema.optional(Schema.Number).annotate({ description: "Duration in milliseconds" }),
}),
),
SessionSelect: BusEvent.define(
"tui.session.select",
z.object({
sessionID: SessionID.zod.describe("Session ID to navigate to"),
Schema.Struct({
sessionID: SessionID.annotate({ description: "Session ID to navigate to" }),
}),
),
}
@@ -1250,7 +1250,17 @@ function UserMessage(props: {
}) {
const ctx = use()
const local = useLocal()
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
const text = createMemo(() => {
const texts = props.parts
.map((x) => {
if (x.type === "text" && !x.synthetic) {
return x.text
}
return null
})
.filter(Boolean)
return texts.join("\n\n")
})
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
@@ -1285,7 +1295,7 @@ function UserMessage(props: {
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
flexShrink={0}
>
<text fg={theme.text}>{text()?.text}</text>
<text fg={theme.text}>{text()}</text>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
@@ -4,10 +4,10 @@ import { useTheme } from "@tui/context/theme"
import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "../component/border"
import { TextAttributes } from "@opentui/core"
import z from "zod"
import { Schema } from "effect"
import { type TuiEvent } from "../event"
export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
export type ToastOptions = Schema.Schema.Type<typeof TuiEvent.ToastShow.properties>
export function Toast() {
const toast = useToast()
+6 -6
View File
@@ -3,7 +3,7 @@ import { InstanceState } from "@/effect"
import { EffectBridge } from "@/effect"
import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context } from "effect"
import { Effect, Layer, Context, Schema } from "effect"
import z from "zod"
import { Config } from "../config"
import { MCP } from "../mcp"
@@ -18,11 +18,11 @@ type State = {
export const Event = {
Executed: BusEvent.define(
"command.executed",
z.object({
name: z.string(),
sessionID: SessionID.zod,
arguments: z.string(),
messageID: MessageID.zod,
Schema.Struct({
name: Schema.String,
sessionID: SessionID,
arguments: Schema.String,
messageID: MessageID,
}),
),
}
+1 -2
View File
@@ -4,6 +4,7 @@ import { Schema } from "effect"
import z from "zod"
import { Bus } from "@/bus"
import { zod } from "@/util/effect-zod"
import { PositiveInt } from "@/util/schema"
import { Log } from "../util"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
@@ -15,8 +16,6 @@ import { ConfigPermission } from "./permission"
const log = Log.create({ service: "config" })
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
+4 -24
View File
@@ -25,7 +25,7 @@ import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "e
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { InstanceRef } from "@/effect/instance-ref"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
import { ConfigAgent } from "./agent"
import { ConfigCommand } from "./command"
import { ConfigFormatter } from "./formatter"
@@ -88,9 +88,6 @@ export type Layout = ConfigLayout.Layout
const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info })
const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
// The Effect Schema is the canonical source of truth. The `.zod` compatibility
// surface is derived so existing Hono validators keep working without a parallel
// Zod definition.
@@ -252,26 +249,9 @@ export const Info = Schema.Struct({
})),
)
// Schema.Struct produces readonly types by default, but the service code
// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
// readonly recursively so callers get the same mutable shape zod inferred.
//
// `Types.DeepMutable` from effect-smol would be a drop-in, but its fallback
// branch `{ -readonly [K in keyof T]: ... }` collapses `unknown` to `{}`
// (since `keyof unknown = never`), which widens `Record<string, unknown>`
// fields like `ConfigPlugin.Options`. The local version gates on
// `extends object` so `unknown` passes through.
//
// Tuple branch preserves `ConfigPlugin.Spec`'s `readonly [string, Options]`
// shape (otherwise the general array branch widens it to an array).
type DeepMutable<T> = T extends readonly [unknown, ...unknown[]]
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: T extends readonly (infer U)[]
? DeepMutable<U>[]
: T extends object
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: T
// Uses the shared `DeepMutable` from `@/util/schema`. See the definition
// there for why the local variant is needed over `Types.DeepMutable` from
// effect-smol (the upstream version collapses `unknown` to `{}`).
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
// with the file and scope it came from so later runtime code can make location-sensitive decisions.
+1 -3
View File
@@ -1,8 +1,6 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
import { PositiveInt, withStatics } from "@/util/schema"
export const Model = Schema.Struct({
id: Schema.optional(Schema.String),
+2 -2
View File
@@ -1,9 +1,9 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { PositiveInt, withStatics } from "@/util/schema"
export const Server = Schema.Struct({
port: Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))).annotate({
port: Schema.optional(PositiveInt).annotate({
description: "Port to listen on",
}),
hostname: Schema.optional(Schema.String).annotate({ description: "Hostname to listen on" }),
@@ -1,4 +1,5 @@
import z from "zod"
import { Schema } from "effect"
import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn"
import { Database, asc, eq, inArray } from "@/storage"
@@ -25,36 +26,37 @@ import { errorData } from "@/util/error"
import { AppRuntime } from "@/effect/app-runtime"
import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
import { NonNegativeInt } from "@/util/schema"
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
export const ConnectionStatus = z.object({
workspaceID: WorkspaceID.zod,
status: z.enum(["connected", "connecting", "disconnected", "error"]),
export const ConnectionStatus = Schema.Struct({
workspaceID: WorkspaceID,
status: Schema.Literals(["connected", "connecting", "disconnected", "error"]),
})
export type ConnectionStatus = z.infer<typeof ConnectionStatus>
export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
const Restore = z.object({
workspaceID: WorkspaceID.zod,
sessionID: SessionID.zod,
total: z.number().int().min(0),
step: z.number().int().min(0),
const Restore = Schema.Struct({
workspaceID: WorkspaceID,
sessionID: SessionID,
total: NonNegativeInt,
step: NonNegativeInt,
})
export const Event = {
Ready: BusEvent.define(
"workspace.ready",
z.object({
name: z.string(),
Schema.Struct({
name: Schema.String,
}),
),
Failed: BusEvent.define(
"workspace.failed",
z.object({
message: z.string(),
Schema.Struct({
message: Schema.String,
}),
),
Restore: BusEvent.define("workspace.restore", Restore),
+3 -3
View File
@@ -3,7 +3,7 @@ import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Git } from "@/git"
import { Effect, Layer, Context, Scope } from "effect"
import { Effect, Layer, Context, Schema, Scope } from "effect"
import * as Stream from "effect/Stream"
import { formatPatch, structuredPatch } from "diff"
import fuzzysort from "fuzzysort"
@@ -76,8 +76,8 @@ export type Content = z.infer<typeof Content>
export const Event = {
Edited: BusEvent.define(
"file.edited",
z.object({
file: z.string(),
Schema.Struct({
file: Schema.String,
}),
),
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { Cause, Effect, Layer, Context } from "effect"
import { Cause, Effect, Layer, Context, Schema } from "effect"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
@@ -25,9 +25,9 @@ const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = {
Updated: BusEvent.define(
"file.watcher.updated",
z.object({
file: z.string(),
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
Schema.Struct({
file: Schema.String,
event: Schema.Literals(["add", "change", "unlink"]),
}),
),
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { Schema } from "effect"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Log } from "../util"
import { Process } from "@/util"
@@ -17,8 +18,8 @@ const log = Log.create({ service: "ide" })
export const Event = {
Installed: BusEvent.define(
"ide.installed",
z.object({
ide: z.string(),
Schema.Struct({
ide: Schema.String,
}),
),
}
+16 -13
View File
@@ -21,14 +21,14 @@ export type ReleaseType = "patch" | "minor" | "major"
export const Event = {
Updated: BusEvent.define(
"installation.updated",
z.object({
version: z.string(),
Schema.Struct({
version: Schema.String,
}),
),
UpdateAvailable: BusEvent.define(
"installation.update-available",
z.object({
version: z.string(),
Schema.Struct({
version: Schema.String,
}),
),
}
@@ -132,6 +132,17 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
)
const viewVersion = Effect.fnUntraced(function* (method: "npm" | "pnpm" | "bun", spec: string) {
const args = method === "bun" ? ["pm", "view", spec, "version", "--json"] : ["view", spec, "version", "--json"]
const result = yield* run([method, ...args])
if (result.code !== 0 || !result.stdout.trim()) {
return yield* new UpgradeFailedError({
stderr: result.stderr || result.stdout || `Failed to resolve ${spec}`,
})
}
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(result.stdout)
})
const getBrewFormula = Effect.fnUntraced(function* () {
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
@@ -217,15 +228,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
}
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
const channel = InstallationChannel
const response = yield* httpOk.execute(
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
)
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
return data.version
return yield* viewVersion(detectedMethod, `opencode-ai@${InstallationChannel}`)
}
if (detectedMethod === "choco") {
+4 -3
View File
@@ -8,6 +8,7 @@ import { Log } from "../util"
import { Process } from "../util"
import { LANGUAGE_EXTENSIONS } from "./language"
import z from "zod"
import { Schema } from "effect"
import type * as LSPServer from "./server"
import { NamedError } from "@opencode-ai/shared/util/error"
import { withTimeout } from "../util/timeout"
@@ -41,9 +42,9 @@ export const InitializeError = NamedError.create(
export const Event = {
Diagnostics: BusEvent.define(
"lsp.client.diagnostics",
z.object({
serverID: z.string(),
path: z.string(),
Schema.Struct({
serverID: Schema.String,
path: Schema.String,
}),
),
}
+1 -1
View File
@@ -19,7 +19,7 @@ import { zod, ZodOverride } from "@/util/effect-zod"
const log = Log.create({ service: "lsp" })
export const Event = {
Updated: BusEvent.define("lsp.updated", z.object({})),
Updated: BusEvent.define("lsp.updated", Schema.Struct({})),
}
const Position = Schema.Struct({
+6 -6
View File
@@ -25,7 +25,7 @@ import { BusEvent } from "../bus/bus-event"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import open from "open"
import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
import { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect"
import { EffectBridge } from "@/effect"
import { InstanceState } from "@/effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
@@ -47,16 +47,16 @@ export type Resource = z.infer<typeof Resource>
export const ToolsChanged = BusEvent.define(
"mcp.tools.changed",
z.object({
server: z.string(),
Schema.Struct({
server: Schema.String,
}),
)
export const BrowserOpenFailed = BusEvent.define(
"mcp.browser.open.failed",
z.object({
mcpName: z.string(),
url: z.string(),
Schema.Struct({
mcpName: Schema.String,
url: Schema.String,
}),
)
+70 -21
View File
@@ -1,14 +1,19 @@
export * as Npm from "."
import path from "path"
import { fileURLToPath } from "url"
import npa from "npm-package-arg"
import semver from "semver"
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import Config from "@npmcli/config"
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
import { Effect, Schema, Context, Layer, Option, FileSystem, Stream } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Global } from "@opencode-ai/shared/global"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "../effect/cross-spawn-spawner"
import { makeRuntime } from "../effect/runtime"
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
@@ -40,12 +45,39 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
const npmPath = fileURLToPath(new URL("../..", import.meta.url))
export function sanitize(pkg: string) {
if (!illegal) return pkg
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
}
const loadOptions = (dir: string) =>
Effect.tryPromise({
try: async () => {
const config = new Config({
npmPath,
cwd: dir,
env: { ...process.env },
argv: [process.execPath, process.execPath],
execPath: process.execPath,
platform: process.platform,
definitions,
flatten,
nerfDarts,
shorthands,
warn: false,
})
await config.load()
return config.flat
},
catch: (cause) =>
new InstallFailedError({
cause,
dir,
}),
})
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
let entrypoint: Option.Option<string>
try {
@@ -76,12 +108,41 @@ export const layer = Layer.effect(
const global = yield* Global.Service
const fs = yield* FileSystem.FileSystem
const flock = yield* EffectFlock.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
const runView = Effect.fnUntraced(function* (cmd: string[]) {
const handle = yield* spawner.spawn(
ChildProcess.make(cmd[0], cmd.slice(1), {
extendEnv: true,
}),
)
const [stdout, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
if (code !== 0 || !stdout.trim()) {
return yield* Effect.fail(stderr || stdout || `Failed to run ${cmd.join(" ")}`)
}
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(stdout)
}, Effect.scoped)
const viewLatestVersion = Effect.fnUntraced(function* (pkg: string) {
return yield* runView(["npm", "view", pkg, "dist-tags.latest", "--json"]).pipe(
Effect.catch(() =>
runView(["pnpm", "view", pkg, "dist-tags.latest", "--json"]).pipe(
Effect.catch(() => runView(["bun", "pm", "view", pkg, "dist-tags.latest", "--json"])),
),
),
)
})
const reify = (input: { dir: string; add?: string[] }) =>
Effect.gen(function* () {
yield* flock.acquire(`npm-install:${input.dir}`)
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const add = input.add ?? []
const npmOptions = yield* loadOptions(input.dir)
const arborist = new Arborist({
...npmOptions,
path: input.dir,
binLinks: true,
progress: false,
@@ -91,14 +152,15 @@ export const layer = Layer.effect(
return yield* Effect.tryPromise({
try: () =>
arborist.reify({
add: input?.add || [],
...npmOptions,
add,
save: true,
saveType: "prod",
}),
catch: (cause) =>
new InstallFailedError({
cause,
add: input?.add,
add,
dir: input.dir,
}),
}) as Effect.Effect<ArboristTree, InstallFailedError>
@@ -109,29 +171,15 @@ export const layer = Layer.effect(
)
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
const response = yield* Effect.tryPromise({
try: () => fetch(`https://registry.npmjs.org/${pkg}`),
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => undefined))
if (!response || !response.ok) {
return false
}
const data = yield* Effect.tryPromise({
try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => undefined))
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
const latestVersion = yield* viewLatestVersion(pkg).pipe(Effect.option)
if (Option.isNone(latestVersion)) {
return false
}
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)
if (range) return !semver.satisfies(latestVersion.value, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
return semver.lt(cachedVersion, latestVersion.value)
})
const add = Effect.fn("Npm.add")(function* (pkg: string) {
@@ -270,6 +318,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.layer),
Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
+6 -8
View File
@@ -73,16 +73,14 @@ export class Approval extends Schema.Class<Approval>("PermissionApproval")({
}
export const Event = {
Asked: BusEvent.define("permission.asked", Request.zod),
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
zod(
Schema.Struct({
sessionID: SessionID,
requestID: PermissionID,
reply: Reply,
}),
),
Schema.Struct({
sessionID: SessionID,
requestID: PermissionID,
reply: Reply,
}),
),
}
@@ -8,6 +8,7 @@ export const ProjectTable = sqliteTable("project", {
vcs: text(),
name: text(),
icon_url: text(),
icon_url_override: text(),
icon_color: text(),
...Timestamps,
time_initialized: integer(),
+11 -2
View File
@@ -53,14 +53,20 @@ export const Info = Schema.Struct({
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
export const Event = {
Updated: BusEvent.define("project.updated", Info.zod),
Updated: BusEvent.define("project.updated", Info),
}
type Row = typeof ProjectTable.$inferSelect
export function fromRow(row: Row): Info {
const icon =
row.icon_url || row.icon_color ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined
row.icon_url || row.icon_url_override || row.icon_color
? {
url: row.icon_url ?? undefined,
override: row.icon_url_override ?? undefined,
color: row.icon_color ?? undefined,
}
: undefined
return {
id: row.id,
worktree: row.worktree,
@@ -289,6 +295,7 @@ export const layer: Layer.Layer<
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_url_override: result.icon?.override,
icon_color: result.icon?.color,
time_created: result.time.created,
time_updated: result.time.updated,
@@ -303,6 +310,7 @@ export const layer: Layer.Layer<
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_url_override: result.icon?.override,
icon_color: result.icon?.color,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
@@ -365,6 +373,7 @@ export const layer: Layer.Layer<
.set({
name: input.name,
icon_url: input.icon?.url,
icon_url_override: input.icon?.override,
icon_color: input.icon?.color,
commands: input.commands,
time_updated: Date.now(),
+3 -3
View File
@@ -1,4 +1,4 @@
import { Effect, Layer, Context, Stream, Scope } from "effect"
import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import { Bus } from "@/bus"
@@ -107,8 +107,8 @@ export type Mode = z.infer<typeof Mode>
export const Event = {
BranchUpdated: BusEvent.define(
"vcs.branch.updated",
z.object({
branch: z.string().optional(),
Schema.Struct({
branch: Schema.optional(Schema.String),
}),
),
}
+8 -15
View File
@@ -1,13 +1,12 @@
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect"
import { zod } from "@/util/effect-zod"
import { namedSchemaError } from "@/util/named-schema-error"
import { withStatics } from "@/util/schema"
import { Plugin } from "../plugin"
import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
import z from "zod"
const When = Schema.Struct({
key: Schema.String,
@@ -70,22 +69,16 @@ export const CallbackInput = Schema.Struct({
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type CallbackInput = Schema.Schema.Type<typeof CallbackInput>
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
export const OauthMissing = namedSchemaError("ProviderAuthOauthMissing", { providerID: ProviderID })
export const OauthCodeMissing = NamedError.create(
"ProviderAuthOauthCodeMissing",
z.object({ providerID: ProviderID.zod }),
)
export const OauthCodeMissing = namedSchemaError("ProviderAuthOauthCodeMissing", { providerID: ProviderID })
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
export const OauthCallbackFailed = namedSchemaError("ProviderAuthOauthCallbackFailed", {})
export const ValidationFailed = NamedError.create(
"ProviderAuthValidationFailed",
z.object({
field: z.string(),
message: z.string(),
}),
)
export const ValidationFailed = namedSchemaError("ProviderAuthValidationFailed", {
field: Schema.String,
message: Schema.String,
})
export type Error =
| Auth.AuthError
+72 -78
View File
@@ -1,7 +1,7 @@
import { Global } from "../global"
import { Log } from "../util"
import path from "path"
import z from "zod"
import { Schema } from "effect"
import { Installation } from "../installation"
import { Flag } from "../flag/flag"
import { lazy } from "@/util/lazy"
@@ -21,91 +21,85 @@ const filepath = path.join(
)
const ttl = 5 * 60 * 1000
type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[]
const JsonValue: z.ZodType<JsonValue> = z.lazy(() =>
z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]),
)
const Cost = z.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
context_over_200k: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
})
.optional(),
const Cost = Schema.Struct({
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
context_over_200k: Schema.optional(
Schema.Struct({
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
}),
),
})
export const Model = z.object({
id: z.string(),
name: z.string(),
family: z.string().optional(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
tool_call: z.boolean(),
interleaved: z
.union([
z.literal(true),
z
.object({
field: z.enum(["reasoning_content", "reasoning_details"]),
})
.strict(),
])
.optional(),
cost: Cost.optional(),
limit: z.object({
context: z.number(),
input: z.number().optional(),
output: z.number(),
export const Model = Schema.Struct({
id: Schema.String,
name: Schema.String,
family: Schema.optional(Schema.String),
release_date: Schema.String,
attachment: Schema.Boolean,
reasoning: Schema.Boolean,
temperature: Schema.Boolean,
tool_call: Schema.Boolean,
interleaved: Schema.optional(
Schema.Union([
Schema.Literal(true),
Schema.Struct({
field: Schema.Literals(["reasoning_content", "reasoning_details"]),
}),
]),
),
cost: Schema.optional(Cost),
limit: Schema.Struct({
context: Schema.Number,
input: Schema.optional(Schema.Number),
output: Schema.Number,
}),
modalities: z
.object({
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
})
.optional(),
experimental: z
.object({
modes: z
.record(
z.string(),
z.object({
cost: Cost.optional(),
provider: z
.object({
body: z.record(z.string(), JsonValue).optional(),
headers: z.record(z.string(), z.string()).optional(),
})
.optional(),
modalities: Schema.optional(
Schema.Struct({
input: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])),
output: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])),
}),
),
experimental: Schema.optional(
Schema.Struct({
modes: Schema.optional(
Schema.Record(
Schema.String,
Schema.Struct({
cost: Schema.optional(Cost),
provider: Schema.optional(
Schema.Struct({
body: Schema.optional(Schema.Record(Schema.String, Schema.MutableJson)),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}),
),
}),
)
.optional(),
})
.optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
),
),
}),
),
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
provider: Schema.optional(
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
),
})
export type Model = z.infer<typeof Model>
export type Model = Schema.Schema.Type<typeof Model>
export const Provider = z.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()),
id: z.string(),
npm: z.string().optional(),
models: z.record(z.string(), Model),
export const Provider = Schema.Struct({
api: Schema.optional(Schema.String),
name: Schema.String,
env: Schema.Array(Schema.String),
id: Schema.String,
npm: Schema.optional(Schema.String),
models: Schema.Record(Schema.String, Model),
})
export type Provider = z.infer<typeof Provider>
export type Provider = Schema.Schema.Type<typeof Provider>
function url() {
return Flag.OPENCODE_MODELS_URL || "https://models.dev"
+10 -17
View File
@@ -1,4 +1,3 @@
import z from "zod"
import os from "os"
import fuzzysort from "fuzzysort"
import { Config } from "../config"
@@ -8,7 +7,6 @@ import { Log } from "../util"
import { Npm } from "../npm"
import { Hash } from "@opencode-ai/shared/util/hash"
import { Plugin } from "../plugin"
import { NamedError } from "@opencode-ai/shared/util/error"
import { type LanguageModelV3 } from "@ai-sdk/provider"
import * as ModelsDev from "./models"
import { Auth } from "../auth"
@@ -16,6 +14,7 @@ import { Env } from "../env"
import { InstallationVersion } from "../installation/version"
import { Flag } from "../flag/flag"
import { zod } from "@/util/effect-zod"
import { namedSchemaError } from "@/util/named-schema-error"
import { iife } from "@/util/iife"
import { Global } from "../global"
import path from "path"
@@ -1047,7 +1046,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
id: ProviderID.make(provider.id),
source: "custom",
name: provider.name,
env: provider.env ?? [],
env: [...(provider.env ?? [])],
options: {},
models,
}
@@ -1713,18 +1712,12 @@ export function parseModel(model: string) {
}
}
export const ModelNotFoundError = NamedError.create(
"ProviderModelNotFoundError",
z.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
suggestions: z.array(z.string()).optional(),
}),
)
export const ModelNotFoundError = namedSchemaError("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
})
export const InitError = NamedError.create(
"ProviderInitError",
z.object({
providerID: ProviderID.zod,
}),
)
export const InitError = namedSchemaError("ProviderInitError", {
providerID: ProviderID,
})
+38 -37
View File
@@ -3,13 +3,14 @@ import { Bus } from "@/bus"
import { InstanceState } from "@/effect"
import { Instance } from "@/project/instance"
import type { Proc } from "#pty"
import z from "zod"
import { Log } from "../util"
import { lazy } from "@opencode-ai/shared/util/lazy"
import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin"
import { PtyID } from "./schema"
import { Effect, Layer, Context } from "effect"
import { Effect, Layer, Context, Schema, Types } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { EffectBridge } from "@/effect"
const log = Log.create({ service: "pty" })
@@ -53,47 +54,47 @@ const meta = (cursor: number) => {
const pty = lazy(() => import("#pty"))
export const Info = z
.object({
id: PtyID.zod,
title: z.string(),
command: z.string(),
args: z.array(z.string()),
cwd: z.string(),
status: z.enum(["running", "exited"]),
pid: z.number(),
})
.meta({ ref: "Pty" })
export type Info = z.infer<typeof Info>
export const CreateInput = z.object({
command: z.string().optional(),
args: z.array(z.string()).optional(),
cwd: z.string().optional(),
title: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
export const Info = Schema.Struct({
id: PtyID,
title: Schema.String,
command: Schema.String,
args: Schema.Array(Schema.String),
cwd: Schema.String,
status: Schema.Literals(["running", "exited"]),
pid: Schema.Number,
})
.annotate({ identifier: "Pty" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type CreateInput = z.infer<typeof CreateInput>
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
export const UpdateInput = z.object({
title: z.string().optional(),
size: z
.object({
rows: z.number(),
cols: z.number(),
})
.optional(),
})
export const CreateInput = Schema.Struct({
command: Schema.optional(Schema.String),
args: Schema.optional(Schema.Array(Schema.String)),
cwd: Schema.optional(Schema.String),
title: Schema.optional(Schema.String),
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type UpdateInput = z.infer<typeof UpdateInput>
export type CreateInput = Types.DeepMutable<Schema.Schema.Type<typeof CreateInput>>
export const UpdateInput = Schema.Struct({
title: Schema.optional(Schema.String),
size: Schema.optional(
Schema.Struct({
rows: Schema.Number,
cols: Schema.Number,
}),
),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInput>>
export const Event = {
Created: BusEvent.define("pty.created", z.object({ info: Info })),
Updated: BusEvent.define("pty.updated", z.object({ info: Info })),
Exited: BusEvent.define("pty.exited", z.object({ id: PtyID.zod, exitCode: z.number() })),
Deleted: BusEvent.define("pty.deleted", z.object({ id: PtyID.zod })),
Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })),
Updated: BusEvent.define("pty.updated", Schema.Struct({ info: Info })),
Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: Schema.Number })),
Deleted: BusEvent.define("pty.deleted", Schema.Struct({ id: PtyID })),
}
export interface Interface {
+4 -4
View File
@@ -94,9 +94,9 @@ class Rejected extends Schema.Class<Rejected>("QuestionRejected")({
}) {}
export const Event = {
Asked: BusEvent.define("question.asked", Request.zod),
Replied: BusEvent.define("question.replied", zod(Replied)),
Rejected: BusEvent.define("question.rejected", zod(Rejected)),
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define("question.replied", Replied),
Rejected: BusEvent.define("question.rejected", Rejected),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
@@ -194,7 +194,7 @@ export const layer = Layer.effect(
yield* bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
answers: input.answers.map((a) => [...a]),
})
yield* Deferred.succeed(existing.deferred, input.answers)
})
+3 -3
View File
@@ -1,7 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { Schema } from "effect"
export const Event = {
Connected: BusEvent.define("server.connected", z.object({})),
Disposed: BusEvent.define("global.disposed", z.object({})),
Connected: BusEvent.define("server.connected", Schema.Struct({})),
Disposed: BusEvent.define("global.disposed", Schema.Struct({})),
}
+1 -2
View File
@@ -1,4 +1,3 @@
import z from "zod"
import sessionProjectors from "../session/projectors"
import { SyncEvent } from "@/sync"
import { Session } from "@/session"
@@ -10,7 +9,7 @@ export function initProjectors() {
projectors: sessionProjectors,
convertEvent: (type, data) => {
if (type === "session.updated") {
const id = (data as z.infer<typeof Session.Event.Updated.schema>).sessionID
const id = (data as SyncEvent.Event<typeof Session.Event.Updated>["data"]).sessionID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) return data
@@ -3,6 +3,7 @@ import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod"
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import { zodObject } from "@/util/effect-zod"
import { Instance } from "@/project/instance"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
@@ -107,7 +108,7 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspace status",
content: {
"application/json": {
schema: resolver(z.array(Workspace.ConnectionStatus)),
schema: resolver(z.array(zodObject(Workspace.ConnectionStatus))),
},
},
},
@@ -1,7 +1,7 @@
import { Hono, type Context } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import { streamSSE } from "hono/streaming"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
@@ -18,7 +18,7 @@ import { errors } from "../error"
const log = Log.create({ service: "server" })
export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
export const GlobalDisposedEvent = BusEvent.define("global.disposed", Schema.Struct({}))
async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
return streamSSE(c, async (stream) => {
@@ -1,6 +1,7 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import * as EffectZod from "@/util/effect-zod"
import { ProviderID, ModelID } from "@/provider/schema"
import { ToolRegistry } from "@/tool"
import { Worktree } from "@/worktree"
@@ -213,7 +214,7 @@ export const ExperimentalRoutes = lazy(() =>
tools.map((t) => ({
id: t.id,
description: t.description,
parameters: z.toJSONSchema(t.parameters),
parameters: EffectZod.toJsonSchema(t.parameters),
})),
)
},
@@ -335,7 +336,7 @@ export const ExperimentalRoutes = lazy(() =>
description: "List of sessions",
content: {
"application/json": {
schema: resolver(Session.GlobalInfo.array()),
schema: resolver(Session.GlobalInfo.zod.array()),
},
},
},
@@ -23,7 +23,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
description: "List of sessions",
content: {
"application/json": {
schema: resolver(Pty.Info.array()),
schema: resolver(Pty.Info.zod.array()),
},
},
},
@@ -46,18 +46,18 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
description: "Created session",
content: {
"application/json": {
schema: resolver(Pty.Info),
schema: resolver(Pty.Info.zod),
},
},
},
...errors(400),
},
}),
validator("json", Pty.CreateInput),
validator("json", Pty.CreateInput.zod),
async (c) =>
jsonRequest("PtyRoutes.create", c, function* () {
const pty = yield* Pty.Service
return yield* pty.create(c.req.valid("json"))
return yield* pty.create(c.req.valid("json") as Pty.CreateInput)
}),
)
.get(
@@ -71,7 +71,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
description: "Session info",
content: {
"application/json": {
schema: resolver(Pty.Info),
schema: resolver(Pty.Info.zod),
},
},
},
@@ -105,7 +105,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
description: "Updated session",
content: {
"application/json": {
schema: resolver(Pty.Info),
schema: resolver(Pty.Info.zod),
},
},
},
@@ -113,11 +113,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
}),
validator("param", z.object({ ptyID: PtyID.zod })),
validator("json", Pty.UpdateInput),
validator("json", Pty.UpdateInput.zod),
async (c) =>
jsonRequest("PtyRoutes.update", c, function* () {
const pty = yield* Pty.Service
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json") as Pty.UpdateInput)
}),
)
.delete(
@@ -23,6 +23,7 @@ import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { zodObject } from "@/util/effect-zod"
import { Bus } from "@/bus"
import { NamedError } from "@opencode-ai/shared/util/error"
import { jsonRequest, runRequest } from "./trace"
@@ -42,7 +43,7 @@ export const SessionRoutes = lazy(() =>
description: "List of sessions",
content: {
"application/json": {
schema: resolver(Session.Info.array()),
schema: resolver(Session.Info.zod.array()),
},
},
},
@@ -87,7 +88,7 @@ export const SessionRoutes = lazy(() =>
description: "Get session status",
content: {
"application/json": {
schema: resolver(z.record(z.string(), SessionStatus.Info)),
schema: resolver(z.record(z.string(), SessionStatus.Info.zod)),
},
},
},
@@ -112,7 +113,7 @@ export const SessionRoutes = lazy(() =>
description: "Get session",
content: {
"application/json": {
schema: resolver(Session.Info),
schema: resolver(Session.Info.zod),
},
},
},
@@ -122,7 +123,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: Session.GetInput,
sessionID: Session.GetInput.zod,
}),
),
async (c) => {
@@ -145,7 +146,7 @@ export const SessionRoutes = lazy(() =>
description: "List of children",
content: {
"application/json": {
schema: resolver(Session.Info.array()),
schema: resolver(Session.Info.zod.array()),
},
},
},
@@ -155,7 +156,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: Session.ChildrenInput,
sessionID: Session.ChildrenInput.zod,
}),
),
async (c) => {
@@ -177,7 +178,7 @@ export const SessionRoutes = lazy(() =>
description: "Todo list",
content: {
"application/json": {
schema: resolver(Todo.Info.array()),
schema: resolver(Todo.Info.zod.array()),
},
},
},
@@ -210,13 +211,13 @@ export const SessionRoutes = lazy(() =>
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(Session.Info),
schema: resolver(Session.Info.zod),
},
},
},
},
}),
validator("json", Session.CreateInput),
validator("json", Session.CreateInput.zod),
async (c) =>
jsonRequest("SessionRoutes.create", c, function* () {
const body = c.req.valid("json") ?? {}
@@ -245,7 +246,7 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: Session.RemoveInput,
sessionID: Session.RemoveInput.zod,
}),
),
async (c) =>
@@ -267,7 +268,7 @@ export const SessionRoutes = lazy(() =>
description: "Successfully updated session",
content: {
"application/json": {
schema: resolver(Session.Info),
schema: resolver(Session.Info.zod),
},
},
},
@@ -375,7 +376,7 @@ export const SessionRoutes = lazy(() =>
description: "200",
content: {
"application/json": {
schema: resolver(Session.Info),
schema: resolver(Session.Info.zod),
},
},
},
@@ -384,14 +385,14 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: Session.ForkInput.shape.sessionID,
sessionID: SessionID.zod,
}),
),
validator("json", Session.ForkInput.omit({ sessionID: true })),
validator("json", zodObject(Session.ForkInput).omit({ sessionID: true })),
async (c) =>
jsonRequest("SessionRoutes.fork", c, function* () {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const body = c.req.valid("json") as { messageID?: MessageID }
const svc = yield* Session.Service
return yield* svc.fork({ ...body, sessionID })
}),
@@ -438,7 +439,7 @@ export const SessionRoutes = lazy(() =>
description: "Successfully shared session",
content: {
"application/json": {
schema: resolver(Session.Info),
schema: resolver(Session.Info.zod),
},
},
},
@@ -480,18 +481,13 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: SessionSummary.DiffInput.shape.sessionID,
}),
),
validator(
"query",
z.object({
messageID: SessionSummary.DiffInput.shape.messageID,
sessionID: SessionID.zod,
}),
),
validator("query", zodObject(SessionSummary.DiffInput).omit({ sessionID: true })),
async (c) =>
jsonRequest("SessionRoutes.diff", c, function* () {
const query = c.req.valid("query")
const query = c.req.valid("query") as Omit<SessionSummary.DiffInput, "sessionID">
const params = c.req.valid("param")
const summary = yield* SessionSummary.Service
return yield* summary.diff({
@@ -511,7 +507,7 @@ export const SessionRoutes = lazy(() =>
description: "Successfully unshared session",
content: {
"application/json": {
schema: resolver(Session.Info),
schema: resolver(Session.Info.zod),
},
},
},
@@ -872,7 +868,7 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })),
async (c) => {
c.status(200)
c.header("Content-Type", "application/json")
@@ -910,7 +906,7 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
@@ -960,11 +956,11 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
validator("json", zodObject(SessionPrompt.CommandInput).omit({ sessionID: true })),
async (c) =>
jsonRequest("SessionRoutes.command", c, function* () {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const body = c.req.valid("json") as Omit<SessionPrompt.CommandInput, "sessionID">
const svc = yield* SessionPrompt.Service
return yield* svc.command({ ...body, sessionID })
}),
@@ -993,11 +989,11 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
validator("json", zodObject(SessionPrompt.ShellInput).omit({ sessionID: true })),
async (c) =>
jsonRequest("SessionRoutes.shell", c, function* () {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const body = c.req.valid("json") as Omit<SessionPrompt.ShellInput, "sessionID">
const svc = yield* SessionPrompt.Service
return yield* svc.shell({ ...body, sessionID })
}),
@@ -1013,7 +1009,7 @@ export const SessionRoutes = lazy(() =>
description: "Updated session",
content: {
"application/json": {
schema: resolver(Session.Info),
schema: resolver(Session.Info.zod),
},
},
},
@@ -1026,16 +1022,14 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
validator("json", SessionRevert.RevertInput.omit({ sessionID: true })),
validator("json", zodObject(SessionRevert.RevertInput).omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
log.info("revert", c.req.valid("json"))
const body = c.req.valid("json") as Omit<SessionRevert.RevertInput, "sessionID">
log.info("revert", body)
return jsonRequest("SessionRoutes.revert", c, function* () {
const svc = yield* SessionRevert.Service
return yield* svc.revert({
sessionID,
...c.req.valid("json"),
})
return yield* svc.revert({ sessionID, ...body })
})
},
)
@@ -1050,7 +1044,7 @@ export const SessionRoutes = lazy(() =>
description: "Updated session",
content: {
"application/json": {
schema: resolver(Session.Info),
schema: resolver(Session.Info.zod),
},
},
},
@@ -1,9 +1,12 @@
import { Hono, type Context } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { Schema } from "effect"
import z from "zod"
import { Bus } from "@/bus"
import { Session } from "@/session"
import type { SessionID } from "@/session/schema"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { zodObject } from "@/util/effect-zod"
import { AsyncQueue } from "@/util/queue"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
@@ -96,9 +99,9 @@ export const TuiRoutes = lazy(() =>
...errors(400),
},
}),
validator("json", TuiEvent.PromptAppend.properties),
validator("json", zodObject(TuiEvent.PromptAppend.properties)),
async (c) => {
await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json"))
await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json") as { text: string })
return c.json(true)
},
)
@@ -305,9 +308,12 @@ export const TuiRoutes = lazy(() =>
},
},
}),
validator("json", TuiEvent.ToastShow.properties),
validator("json", zodObject(TuiEvent.ToastShow.properties)),
async (c) => {
await Bus.publish(TuiEvent.ToastShow, c.req.valid("json"))
await Bus.publish(
TuiEvent.ToastShow,
c.req.valid("json") as Schema.Schema.Type<typeof TuiEvent.ToastShow.properties>,
)
return c.json(true)
},
)
@@ -336,7 +342,7 @@ export const TuiRoutes = lazy(() =>
return z
.object({
type: z.literal(def.type),
properties: def.properties,
properties: zodObject(def.properties),
})
.meta({
ref: `Event.${def.type}`,
@@ -345,8 +351,9 @@ export const TuiRoutes = lazy(() =>
),
),
async (c) => {
const evt = c.req.valid("json")
await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties)
const evt = c.req.valid("json") as { type: string; properties: Record<string, unknown> }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)! as any, evt.properties as any)
return c.json(true)
},
)
@@ -368,9 +375,9 @@ export const TuiRoutes = lazy(() =>
...errors(400, 404),
},
}),
validator("json", TuiEvent.SessionSelect.properties),
validator("json", zodObject(TuiEvent.SessionSelect.properties)),
async (c) => {
const { sessionID } = c.req.valid("json")
const { sessionID } = c.req.valid("json") as { sessionID: SessionID }
await runRequest(
"TuiRoutes.sessionSelect",
c,
+3 -3
View File
@@ -13,7 +13,7 @@ import { Plugin } from "@/plugin"
import { Config } from "@/config"
import { NotFoundError } from "@/storage"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, Context } from "effect"
import { Effect, Layer, Context, Schema } from "effect"
import { InstanceState } from "@/effect"
import { isOverflow as overflow, usable } from "./overflow"
import { makeRuntime } from "@/effect/run-service"
@@ -24,8 +24,8 @@ const log = Log.create({ service: "session.compaction" })
export const Event = {
Compacted: BusEvent.define(
"session.compacted",
z.object({
sessionID: SessionID.zod,
Schema.Struct({
sessionID: SessionID,
}),
),
}
+41 -35
View File
@@ -17,7 +17,7 @@ import type { Provider } from "@/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Schema, Types } from "effect"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { namedSchemaError } from "@/util/named-schema-error"
import { EffectLogger } from "@/effect"
@@ -64,9 +64,7 @@ export class OutputFormatText extends Schema.Class<OutputFormatText>("OutputForm
export class OutputFormatJsonSchema extends Schema.Class<OutputFormatJsonSchema>("OutputFormatJsonSchema")({
type: Schema.Literal("json_schema"),
schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }),
retryCount: Schema.Number.check(Schema.isInt())
.check(Schema.isGreaterThanOrEqualTo(0))
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))),
retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))),
}) {
static readonly zod = zod(this)
}
@@ -138,8 +136,8 @@ export type ReasoningPart = Types.DeepMutable<Schema.Schema.Type<typeof Reasonin
const filePartSourceBase = {
text: Schema.Struct({
value: Schema.String,
start: Schema.Number.check(Schema.isInt()),
end: Schema.Number.check(Schema.isInt()),
start: Schema.Int,
end: Schema.Int,
}).annotate({ identifier: "FilePartSourceText" }),
}
@@ -157,7 +155,7 @@ export const SymbolSource = Schema.Struct({
path: Schema.String,
range: LSP.Range,
name: Schema.String,
kind: Schema.Number.check(Schema.isInt()),
kind: Schema.Int,
})
.annotate({ identifier: "SymbolSource" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -196,8 +194,8 @@ export const AgentPart = Schema.Struct({
source: Schema.optional(
Schema.Struct({
value: Schema.String,
start: Schema.Number.check(Schema.isInt()),
end: Schema.Number.check(Schema.isInt()),
start: Schema.Int,
end: Schema.Int,
}),
),
})
@@ -501,8 +499,8 @@ export const AgentPartInput = Schema.Struct({
source: Schema.optional(
Schema.Struct({
value: Schema.String,
start: Schema.Number.check(Schema.isInt()),
end: Schema.Number.check(Schema.isInt()),
start: Schema.Int,
end: Schema.Int,
}),
),
})
@@ -576,54 +574,62 @@ export const Info = Object.assign(_Info, {
})
export type Info = User | Assistant
const UpdatedEventSchema = Schema.Struct({
sessionID: SessionID,
info: _Info,
})
const RemovedEventSchema = Schema.Struct({
sessionID: SessionID,
messageID: MessageID,
})
const PartUpdatedEventSchema = Schema.Struct({
sessionID: SessionID,
part: _Part,
time: Schema.Number,
})
const PartRemovedEventSchema = Schema.Struct({
sessionID: SessionID,
messageID: MessageID,
partID: PartID,
})
export const Event = {
Updated: SyncEvent.define({
type: "message.updated",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
info: Info.zod,
}),
schema: UpdatedEventSchema,
}),
Removed: SyncEvent.define({
type: "message.removed",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
schema: RemovedEventSchema,
}),
PartUpdated: SyncEvent.define({
type: "message.part.updated",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
part: Part.zod,
time: z.number(),
}),
schema: PartUpdatedEventSchema,
}),
PartDelta: BusEvent.define(
"message.part.delta",
z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod,
field: z.string(),
delta: z.string(),
Schema.Struct({
sessionID: SessionID,
messageID: MessageID,
partID: PartID,
field: Schema.String,
delta: Schema.String,
}),
),
PartRemoved: SyncEvent.define({
type: "message.part.removed",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod,
}),
schema: PartRemovedEventSchema,
}),
}
+177 -176
View File
@@ -1,191 +1,192 @@
import z from "zod"
import { Schema } from "effect"
import { SessionID } from "./schema"
import { ModelID, ProviderID } from "../provider/schema"
import { NamedError } from "@opencode-ai/shared/util/error"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { namedSchemaError } from "@/util/named-schema-error"
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const AuthError = NamedError.create(
"ProviderAuthError",
z.object({
providerID: z.string(),
message: z.string(),
}),
)
export const ToolCall = z
.object({
state: z.literal("call"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
})
.meta({
ref: "ToolCall",
})
export type ToolCall = z.infer<typeof ToolCall>
export const ToolPartialCall = z
.object({
state: z.literal("partial-call"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
})
.meta({
ref: "ToolPartialCall",
})
export type ToolPartialCall = z.infer<typeof ToolPartialCall>
export const ToolResult = z
.object({
state: z.literal("result"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
result: z.string(),
})
.meta({
ref: "ToolResult",
})
export type ToolResult = z.infer<typeof ToolResult>
export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).meta({
ref: "ToolInvocation",
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
export const AuthError = namedSchemaError("ProviderAuthError", {
providerID: Schema.String,
message: Schema.String,
})
export type ToolInvocation = z.infer<typeof ToolInvocation>
export const TextPart = z
.object({
type: z.literal("text"),
text: z.string(),
})
.meta({
ref: "TextPart",
})
export type TextPart = z.infer<typeof TextPart>
const AuthErrorEffect = Schema.Struct({
name: Schema.Literal("ProviderAuthError"),
data: Schema.Struct({
providerID: Schema.String,
message: Schema.String,
}),
})
export const ReasoningPart = z
.object({
type: z.literal("reasoning"),
text: z.string(),
providerMetadata: z.record(z.string(), z.any()).optional(),
})
.meta({
ref: "ReasoningPart",
})
export type ReasoningPart = z.infer<typeof ReasoningPart>
const OutputLengthErrorEffect = Schema.Struct({
name: Schema.Literal("MessageOutputLengthError"),
data: Schema.Struct({}),
})
export const ToolInvocationPart = z
.object({
type: z.literal("tool-invocation"),
toolInvocation: ToolInvocation,
})
.meta({
ref: "ToolInvocationPart",
})
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
const UnknownErrorEffect = Schema.Struct({
name: Schema.Literal("UnknownError"),
data: Schema.Struct({
message: Schema.String,
}),
})
export const SourceUrlPart = z
.object({
type: z.literal("source-url"),
sourceId: z.string(),
url: z.string(),
title: z.string().optional(),
providerMetadata: z.record(z.string(), z.any()).optional(),
})
.meta({
ref: "SourceUrlPart",
})
export type SourceUrlPart = z.infer<typeof SourceUrlPart>
export const ToolCall = Schema.Struct({
state: Schema.Literal("call"),
step: Schema.optional(Schema.Number),
toolCallId: Schema.String,
toolName: Schema.String,
args: Schema.Unknown,
})
.annotate({ identifier: "ToolCall" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type ToolCall = Schema.Schema.Type<typeof ToolCall>
export const FilePart = z
.object({
type: z.literal("file"),
mediaType: z.string(),
filename: z.string().optional(),
url: z.string(),
})
.meta({
ref: "FilePart",
})
export type FilePart = z.infer<typeof FilePart>
export const ToolPartialCall = Schema.Struct({
state: Schema.Literal("partial-call"),
step: Schema.optional(Schema.Number),
toolCallId: Schema.String,
toolName: Schema.String,
args: Schema.Unknown,
})
.annotate({ identifier: "ToolPartialCall" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type ToolPartialCall = Schema.Schema.Type<typeof ToolPartialCall>
export const StepStartPart = z
.object({
type: z.literal("step-start"),
})
.meta({
ref: "StepStartPart",
})
export type StepStartPart = z.infer<typeof StepStartPart>
export const ToolResult = Schema.Struct({
state: Schema.Literal("result"),
step: Schema.optional(Schema.Number),
toolCallId: Schema.String,
toolName: Schema.String,
args: Schema.Unknown,
result: Schema.String,
})
.annotate({ identifier: "ToolResult" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type ToolResult = Schema.Schema.Type<typeof ToolResult>
export const MessagePart = z
.discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart])
.meta({
ref: "MessagePart",
})
export type MessagePart = z.infer<typeof MessagePart>
export const ToolInvocation = Schema.Union([ToolCall, ToolPartialCall, ToolResult])
.annotate({ identifier: "ToolInvocation", discriminator: "state" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type ToolInvocation = Schema.Schema.Type<typeof ToolInvocation>
export const Info = z
.object({
id: z.string(),
role: z.enum(["user", "assistant"]),
parts: z.array(MessagePart),
metadata: z
.object({
time: z.object({
created: z.number(),
completed: z.number().optional(),
export const TextPart = Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
})
.annotate({ identifier: "TextPart" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type TextPart = Schema.Schema.Type<typeof TextPart>
export const ReasoningPart = Schema.Struct({
type: Schema.Literal("reasoning"),
text: Schema.String,
providerMetadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
})
.annotate({ identifier: "ReasoningPart" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type ReasoningPart = Schema.Schema.Type<typeof ReasoningPart>
export const ToolInvocationPart = Schema.Struct({
type: Schema.Literal("tool-invocation"),
toolInvocation: ToolInvocation,
})
.annotate({ identifier: "ToolInvocationPart" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type ToolInvocationPart = Schema.Schema.Type<typeof ToolInvocationPart>
export const SourceUrlPart = Schema.Struct({
type: Schema.Literal("source-url"),
sourceId: Schema.String,
url: Schema.String,
title: Schema.optional(Schema.String),
providerMetadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
})
.annotate({ identifier: "SourceUrlPart" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type SourceUrlPart = Schema.Schema.Type<typeof SourceUrlPart>
export const FilePart = Schema.Struct({
type: Schema.Literal("file"),
mediaType: Schema.String,
filename: Schema.optional(Schema.String),
url: Schema.String,
})
.annotate({ identifier: "FilePart" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type FilePart = Schema.Schema.Type<typeof FilePart>
export const StepStartPart = Schema.Struct({
type: Schema.Literal("step-start"),
})
.annotate({ identifier: "StepStartPart" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type StepStartPart = Schema.Schema.Type<typeof StepStartPart>
export const MessagePart = Schema.Union([
TextPart,
ReasoningPart,
ToolInvocationPart,
SourceUrlPart,
FilePart,
StepStartPart,
])
.annotate({ identifier: "MessagePart", discriminator: "type" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type MessagePart = Schema.Schema.Type<typeof MessagePart>
export const Info = Schema.Struct({
id: Schema.String,
role: Schema.Literals(["user", "assistant"]),
parts: Schema.Array(MessagePart),
metadata: Schema.Struct({
time: Schema.Struct({
created: Schema.Number,
completed: Schema.optional(Schema.Number),
}),
error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])),
sessionID: SessionID,
tool: Schema.Record(
Schema.String,
Schema.StructWithRest(
Schema.Struct({
title: Schema.String,
snapshot: Schema.optional(Schema.String),
time: Schema.Struct({
start: Schema.Number,
end: Schema.Number,
}),
}),
error: z
.discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
.optional(),
sessionID: SessionID.zod,
tool: z.record(
z.string(),
z
.object({
title: z.string(),
snapshot: z.string().optional(),
time: z.object({
start: z.number(),
end: z.number(),
}),
})
.catchall(z.any()),
),
assistant: z
.object({
system: z.string().array(),
modelID: ModelID.zod,
providerID: ProviderID.zod,
path: z.object({
cwd: z.string(),
root: z.string(),
}),
cost: z.number(),
summary: z.boolean().optional(),
tokens: z.object({
input: z.number(),
output: z.number(),
reasoning: z.number(),
cache: z.object({
read: z.number(),
write: z.number(),
}),
}),
})
.optional(),
snapshot: z.string().optional(),
})
.meta({ ref: "MessageMetadata" }),
})
.meta({
ref: "Message",
})
export type Info = z.infer<typeof Info>
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
assistant: Schema.optional(
Schema.Struct({
system: Schema.Array(Schema.String),
modelID: ModelID,
providerID: ProviderID,
path: Schema.Struct({
cwd: Schema.String,
root: Schema.String,
}),
cost: Schema.Number,
summary: Schema.optional(Schema.Boolean),
tokens: Schema.Struct({
input: Schema.Number,
output: Schema.Number,
reasoning: Schema.Number,
cache: Schema.Struct({
read: Schema.Number,
write: Schema.Number,
}),
}),
}),
),
snapshot: Schema.optional(Schema.String),
}).annotate({ identifier: "MessageMetadata" }),
})
.annotate({ identifier: "Message" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = Schema.Schema.Type<typeof Info>
export * as Message from "./message"
+4 -2
View File
@@ -62,14 +62,16 @@ export function toPartialRow(info: DeepPartial<Session.Info>) {
export default [
SyncEvent.project(Session.Event.Created, (db, data) => {
db.insert(SessionTable).values(Session.toRow(data.info)).run()
db.insert(SessionTable)
.values(Session.toRow(data.info as Session.Info))
.run()
}),
SyncEvent.project(Session.Event.Updated, (db, data) => {
const info = data.info
const row = db
.update(SessionTable)
.set(toPartialRow(info))
.set(toPartialRow(info as Session.Patch))
.where(eq(SessionTable.id, data.sessionID))
.returning()
.get()
+76 -76
View File
@@ -1,6 +1,7 @@
import path from "path"
import os from "os"
import z from "zod"
import * as EffectZod from "@/util/effect-zod"
import { SessionID, MessageID, PartID } from "./schema"
import { MessageV2 } from "./message-v2"
import { Log } from "../util"
@@ -43,7 +44,9 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Truncate } from "@/tool"
import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util"
import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
import { Cause, Effect, Exit, Layer, Option, Scope, Context, Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { EffectLogger } from "@/effect"
import { InstanceState } from "@/effect"
import { TaskTool, type TaskPromptOps } from "@/tool/task"
@@ -69,7 +72,7 @@ const elog = EffectLogger.create({ service: "session.prompt" })
export interface Interface {
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
readonly loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts>
readonly loop: (input: LoopInput) => Effect.Effect<MessageV2.WithParts>
readonly shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts>
readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts>
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
@@ -403,7 +406,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
providerID: input.model.providerID,
agent: input.agent,
})) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters))
tools[item.id] = tool({
description: item.description,
inputSchema: jsonSchema(schema),
@@ -1532,9 +1535,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
},
)
const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn(
"SessionPrompt.loop",
)(function* (input: z.infer<typeof LoopInput>) {
const loop: (input: LoopInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.loop")(function* (
input: LoopInput,
) {
return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID))
})
@@ -1701,91 +1704,88 @@ export const defaultLayer = Layer.suspend(() =>
),
),
)
export const PromptInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
model: z
.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
})
.optional(),
agent: z.string().optional(),
noReply: z.boolean().optional(),
tools: z
.record(z.string(), z.boolean())
.optional()
.describe("@deprecated tools and permissions have been merged, you can set permissions on the session itself now"),
format: MessageV2.Format.zod.optional(),
system: z.string().optional(),
variant: z.string().optional(),
parts: z.array(
z.discriminatedUnion("type", [
MessageV2.TextPartInput.zod as unknown as z.ZodObject<any>,
MessageV2.FilePartInput.zod as unknown as z.ZodObject<any>,
MessageV2.AgentPartInput.zod as unknown as z.ZodObject<any>,
MessageV2.SubtaskPartInput.zod as unknown as z.ZodObject<any>,
]),
),
const ModelRef = Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
})
export const PromptInput = Schema.Struct({
sessionID: SessionID,
messageID: Schema.optional(MessageID),
model: Schema.optional(ModelRef),
agent: Schema.optional(Schema.String),
noReply: Schema.optional(Schema.Boolean),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
description:
"@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
}),
format: Schema.optional(MessageV2.Format),
system: Schema.optional(Schema.String),
variant: Schema.optional(Schema.String),
parts: Schema.Array(
Schema.Union([
MessageV2.TextPartInput,
MessageV2.FilePartInput,
MessageV2.AgentPartInput,
MessageV2.SubtaskPartInput,
]).annotate({ discriminator: "type" }),
),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
// `z.discriminatedUnion` erases the discriminated members' shapes back to
// `{}` because the derived `.zod` on each input is typed as an opaque
// `z.ZodType`. Restore the precise `parts` type from the exported Schema
// input types so callers see a proper tagged union.
// `{}` when walked from the generic `z.ZodType` input. Restore the precise
// `parts` type from the exported Schema input types so callers see a proper
// tagged union.
type PartInputUnion =
| MessageV2.TextPartInput
| MessageV2.FilePartInput
| MessageV2.AgentPartInput
| MessageV2.SubtaskPartInput
export type PromptInput = Omit<z.infer<typeof PromptInput>, "parts"> & {
export type PromptInput = Omit<Schema.Schema.Type<typeof PromptInput>, "parts"> & {
parts: PartInputUnion[]
}
export const LoopInput = z.object({
sessionID: SessionID.zod,
})
export class LoopInput extends Schema.Class<LoopInput>("SessionPrompt.LoopInput")({
sessionID: SessionID,
}) {
static readonly zod = zod(this)
}
export const ShellInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
agent: z.string(),
model: z
.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
})
.optional(),
command: z.string(),
})
export type ShellInput = z.infer<typeof ShellInput>
export const ShellInput = Schema.Struct({
sessionID: SessionID,
messageID: Schema.optional(MessageID),
agent: Schema.String,
model: Schema.optional(ModelRef),
command: Schema.String,
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type ShellInput = Schema.Schema.Type<typeof ShellInput>
export const CommandInput = z.object({
messageID: MessageID.zod.optional(),
sessionID: SessionID.zod,
agent: z.string().optional(),
model: z.string().optional(),
arguments: z.string(),
command: z.string(),
variant: z.string().optional(),
// Inlined (no `.meta({ ref })`) to keep the original SDK output — the
export const CommandInput = Schema.Struct({
messageID: Schema.optional(MessageID),
sessionID: SessionID,
agent: Schema.optional(Schema.String),
model: Schema.optional(Schema.String),
arguments: Schema.String,
command: Schema.String,
variant: Schema.optional(Schema.String),
// Inlined (no identifier annotation) to keep the original SDK output — the
// PromptInput call site below references FilePartInput by ref via the
// Schema export in message-v2.ts.
parts: z
.array(
z.discriminatedUnion("type", [
z.object({
id: PartID.zod.optional(),
type: z.literal("file"),
mime: z.string(),
filename: z.string().optional(),
url: z.string(),
source: MessageV2.FilePartSource.zod.optional(),
parts: Schema.optional(
Schema.Array(
Schema.Union([
Schema.Struct({
id: Schema.optional(PartID),
type: Schema.Literal("file"),
mime: Schema.String,
filename: Schema.optional(Schema.String),
url: Schema.String,
source: Schema.optional(MessageV2.FilePartSource),
}),
]),
)
.optional(),
})
export type CommandInput = z.infer<typeof CommandInput>
]).annotate({ discriminator: "type" }),
),
),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type CommandInput = Schema.Schema.Type<typeof CommandInput>
/** @internal Exported for testing */
export function createStructuredOutputTool(input: {
+9 -8
View File
@@ -1,10 +1,11 @@
import z from "zod"
import { Effect, Layer, Context } from "effect"
import { Effect, Layer, Context, Schema } from "effect"
import { Bus } from "../bus"
import { Snapshot } from "../snapshot"
import { Storage } from "@/storage"
import { SyncEvent } from "../sync"
import { Log } from "../util"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import * as Session from "./session"
import { MessageV2 } from "./message-v2"
import { SessionID, MessageID, PartID } from "./schema"
@@ -13,12 +14,12 @@ import { SessionSummary } from "./summary"
const log = Log.create({ service: "session.revert" })
export const RevertInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod.optional(),
})
export type RevertInput = z.infer<typeof RevertInput>
export const RevertInput = Schema.Struct({
sessionID: SessionID,
messageID: MessageID,
partID: Schema.optional(PartID),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type RevertInput = Schema.Schema.Type<typeof RevertInput>
export interface Interface {
readonly revert: (input: RevertInput) => Effect.Effect<Session.Info>
+148 -111
View File
@@ -15,7 +15,6 @@ import { PartTable, SessionTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage"
import { Log } from "../util"
import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"
import { InstanceState } from "@/effect"
@@ -27,7 +26,9 @@ import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider"
import { Permission } from "@/permission"
import { Global } from "@/global"
import { Effect, Layer, Option, Context } from "effect"
import { Effect, Layer, Option, Context, Schema, Types } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const log = Log.create({ service: "session" })
@@ -114,140 +115,176 @@ function getForkedTitle(title: string): string {
return `${title} (fork #1)`
}
export const Info = z
.object({
id: SessionID.zod,
slug: z.string(),
projectID: ProjectID.zod,
workspaceID: WorkspaceID.zod.optional(),
directory: z.string(),
parentID: SessionID.zod.optional(),
summary: z
.object({
additions: z.number(),
deletions: z.number(),
files: z.number(),
diffs: Snapshot.FileDiff.zod.array().optional(),
})
.optional(),
share: z
.object({
url: z.string(),
})
.optional(),
title: z.string(),
version: z.string(),
time: z.object({
created: z.number(),
updated: z.number(),
compacting: z.number().optional(),
archived: z.number().optional(),
}),
permission: Permission.Ruleset.zod.optional(),
revert: z
.object({
messageID: MessageID.zod,
partID: PartID.zod.optional(),
snapshot: z.string().optional(),
diff: z.string().optional(),
})
.optional(),
})
.meta({
ref: "Session",
})
export type Info = z.output<typeof Info>
export const ProjectInfo = z
.object({
id: ProjectID.zod,
name: z.string().optional(),
worktree: z.string(),
})
.meta({
ref: "ProjectSummary",
})
export type ProjectInfo = z.output<typeof ProjectInfo>
export const GlobalInfo = Info.extend({
project: ProjectInfo.nullable(),
}).meta({
ref: "GlobalSession",
const Summary = Schema.Struct({
additions: Schema.Number,
deletions: Schema.Number,
files: Schema.Number,
diffs: Schema.optional(Schema.Array(Snapshot.FileDiff)),
})
export type GlobalInfo = z.output<typeof GlobalInfo>
export const CreateInput = z
.object({
parentID: SessionID.zod.optional(),
title: z.string().optional(),
permission: Info.shape.permission,
workspaceID: WorkspaceID.zod.optional(),
})
.optional()
export type CreateInput = z.output<typeof CreateInput>
export const ForkInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() })
export const GetInput = SessionID.zod
export const ChildrenInput = SessionID.zod
export const RemoveInput = SessionID.zod
export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() })
export const SetArchivedInput = z.object({ sessionID: SessionID.zod, time: z.number().optional() })
export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset.zod })
export const SetRevertInput = z.object({
sessionID: SessionID.zod,
revert: Info.shape.revert,
summary: Info.shape.summary,
const Share = Schema.Struct({
url: Schema.String,
})
const Time = Schema.Struct({
created: Schema.Number,
updated: Schema.Number,
compacting: Schema.optional(Schema.Number),
archived: Schema.optional(Schema.Number),
})
const Revert = Schema.Struct({
messageID: MessageID,
partID: Schema.optional(PartID),
snapshot: Schema.optional(Schema.String),
diff: Schema.optional(Schema.String),
})
export const Info = Schema.Struct({
id: SessionID,
slug: Schema.String,
projectID: ProjectID,
workspaceID: Schema.optional(WorkspaceID),
directory: Schema.String,
parentID: Schema.optional(SessionID),
summary: Schema.optional(Summary),
share: Schema.optional(Share),
title: Schema.String,
version: Schema.String,
time: Time,
permission: Schema.optional(Permission.Ruleset),
revert: Schema.optional(Revert),
})
.annotate({ identifier: "Session" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
export const ProjectInfo = Schema.Struct({
id: ProjectID,
name: Schema.optional(Schema.String),
worktree: Schema.String,
})
.annotate({ identifier: "ProjectSummary" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type ProjectInfo = Types.DeepMutable<Schema.Schema.Type<typeof ProjectInfo>>
export const GlobalInfo = Schema.Struct({
...Info.fields,
project: Schema.NullOr(ProjectInfo),
})
.annotate({ identifier: "GlobalSession" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type GlobalInfo = Types.DeepMutable<Schema.Schema.Type<typeof GlobalInfo>>
export const CreateInput = Schema.optional(
Schema.Struct({
parentID: Schema.optional(SessionID),
title: Schema.optional(Schema.String),
permission: Schema.optional(Permission.Ruleset),
workspaceID: Schema.optional(WorkspaceID),
}),
).pipe(withStatics((s) => ({ zod: zod(s) })))
export type CreateInput = Types.DeepMutable<Schema.Schema.Type<typeof CreateInput>>
export const ForkInput = Schema.Struct({
sessionID: SessionID,
messageID: Schema.optional(MessageID),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const GetInput = SessionID
export const ChildrenInput = SessionID
export const RemoveInput = SessionID
export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema.String }).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export const SetArchivedInput = Schema.Struct({
sessionID: SessionID,
time: Schema.optional(Schema.Number),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const SetPermissionInput = Schema.Struct({
sessionID: SessionID,
permission: Permission.Ruleset,
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const SetRevertInput = Schema.Struct({
sessionID: SessionID,
revert: Schema.optional(Revert),
summary: Schema.optional(Summary),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const MessagesInput = Schema.Struct({
sessionID: SessionID,
limit: Schema.optional(Schema.Number),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
const CreatedEventSchema = Schema.Struct({
sessionID: SessionID,
info: Info,
})
const UpdatedShare = Schema.Struct({
url: Schema.optional(Schema.NullOr(Schema.String)),
})
const UpdatedTime = Schema.Struct({
created: Schema.optional(Schema.NullOr(Schema.Number)),
updated: Schema.optional(Schema.NullOr(Schema.Number)),
compacting: Schema.optional(Schema.NullOr(Schema.Number)),
archived: Schema.optional(Schema.NullOr(Schema.Number)),
})
const UpdatedInfo = Schema.Struct({
id: Schema.optional(Schema.NullOr(SessionID)),
slug: Schema.optional(Schema.NullOr(Schema.String)),
projectID: Schema.optional(Schema.NullOr(ProjectID)),
workspaceID: Schema.optional(Schema.NullOr(WorkspaceID)),
directory: Schema.optional(Schema.NullOr(Schema.String)),
parentID: Schema.optional(Schema.NullOr(SessionID)),
summary: Schema.optional(Schema.NullOr(Summary)),
share: Schema.optional(UpdatedShare),
title: Schema.optional(Schema.NullOr(Schema.String)),
version: Schema.optional(Schema.NullOr(Schema.String)),
time: Schema.optional(UpdatedTime),
permission: Schema.optional(Schema.NullOr(Permission.Ruleset)),
revert: Schema.optional(Schema.NullOr(Revert)),
})
const UpdatedEventSchema = Schema.Struct({
sessionID: SessionID,
info: UpdatedInfo,
})
export const MessagesInput = z.object({ sessionID: SessionID.zod, limit: z.number().optional() })
export const Event = {
Created: SyncEvent.define({
type: "session.created",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
info: Info,
}),
schema: CreatedEventSchema,
}),
Updated: SyncEvent.define({
type: "session.updated",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
info: updateSchema(Info).extend({
share: updateSchema(Info.shape.share.unwrap()).optional(),
time: updateSchema(Info.shape.time).optional(),
}),
}),
busSchema: z.object({
sessionID: SessionID.zod,
info: Info,
}),
schema: UpdatedEventSchema,
busSchema: CreatedEventSchema,
}),
Deleted: SyncEvent.define({
type: "session.deleted",
version: 1,
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
info: Info,
}),
schema: CreatedEventSchema,
}),
Diff: BusEvent.define(
"session.diff",
z.object({
sessionID: SessionID.zod,
diff: Snapshot.FileDiff.zod.array(),
Schema.Struct({
sessionID: SessionID,
diff: Schema.Array(Snapshot.FileDiff),
}),
),
Error: BusEvent.define(
"session.error",
z.object({
sessionID: SessionID.zod.optional(),
// z.lazy defers access to break circular dep: session → message-v2 → provider → plugin → session
error: z.lazy(() => (MessageV2.Assistant.zod as unknown as z.ZodObject<any>).shape.error),
Schema.Struct({
sessionID: Schema.optional(SessionID),
// Reuses MessageV2.Assistant.fields.error (already Schema.optional) so
// the derived zod keeps the same discriminated-union shape on the bus.
error: MessageV2.Assistant.fields.error,
}),
),
}
@@ -379,7 +416,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Session") {}
type Patch = z.infer<typeof Event.Updated.schema>["info"]
export type Patch = Types.DeepMutable<SyncEvent.Event<typeof Event.Updated>["data"]["info"]>
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
+24 -24
View File
@@ -2,42 +2,42 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect"
import { SessionID } from "./schema"
import { Effect, Layer, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { Effect, Layer, Context, Schema } from "effect"
import z from "zod"
export const Info = z
.union([
z.object({
type: z.literal("idle"),
}),
z.object({
type: z.literal("retry"),
attempt: z.number(),
message: z.string(),
next: z.number(),
}),
z.object({
type: z.literal("busy"),
}),
])
.meta({
ref: "SessionStatus",
})
export type Info = z.infer<typeof Info>
export const Info = Schema.Union([
Schema.Struct({
type: Schema.Literal("idle"),
}),
Schema.Struct({
type: Schema.Literal("retry"),
attempt: Schema.Number,
message: Schema.String,
next: Schema.Number,
}),
Schema.Struct({
type: Schema.Literal("busy"),
}),
])
.annotate({ identifier: "SessionStatus" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = Schema.Schema.Type<typeof Info>
export const Event = {
Status: BusEvent.define(
"session.status",
z.object({
sessionID: SessionID.zod,
Schema.Struct({
sessionID: SessionID,
status: Info,
}),
),
// deprecated
Idle: BusEvent.define(
"session.idle",
z.object({
sessionID: SessionID.zod,
Schema.Struct({
sessionID: SessionID,
}),
),
}
+8 -6
View File
@@ -1,8 +1,9 @@
import z from "zod"
import { Effect, Layer, Context } from "effect"
import { Effect, Layer, Context, Schema } from "effect"
import { Bus } from "@/bus"
import { Snapshot } from "@/snapshot"
import { Storage } from "@/storage"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import * as Session from "./session"
import { MessageV2 } from "./message-v2"
import { SessionID, MessageID } from "./schema"
@@ -155,9 +156,10 @@ export const defaultLayer = Layer.suspend(() =>
),
)
export const DiffInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
})
export const DiffInput = Schema.Struct({
sessionID: SessionID,
messageID: Schema.optional(MessageID),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type DiffInput = Schema.Schema.Type<typeof DiffInput>
export * as SessionSummary from "./summary"
+16 -12
View File
@@ -1,26 +1,30 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { SessionID } from "./schema"
import { Effect, Layer, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { Effect, Layer, Context, Schema } from "effect"
import z from "zod"
import { Database, eq, asc } from "../storage"
import { TodoTable } from "./session.sql"
export const Info = z
.object({
content: z.string().describe("Brief description of the task"),
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
priority: z.string().describe("Priority level of the task: high, medium, low"),
})
.meta({ ref: "Todo" })
export type Info = z.infer<typeof Info>
export const Info = Schema.Struct({
content: Schema.String.annotate({ description: "Brief description of the task" }),
status: Schema.String.annotate({
description: "Current status of the task: pending, in_progress, completed, cancelled",
}),
priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }),
})
.annotate({ identifier: "Todo" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = Schema.Schema.Type<typeof Info>
export const Event = {
Updated: BusEvent.define(
"todo.updated",
z.object({
sessionID: SessionID.zod,
todos: z.array(Info),
Schema.Struct({
sessionID: SessionID,
todos: Schema.Array(Info),
}),
),
}
+1 -1
View File
@@ -181,7 +181,7 @@ export const layer = Layer.effect(
yield* watch(Session.Event.Updated, (evt) =>
Effect.gen(function* () {
const info = yield* session.get(evt.properties.sessionID)
const info = evt.properties.info
yield* sync(info.id, [{ type: "session", data: info }])
}),
)
@@ -168,6 +168,7 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
vcs: data.vcs,
name: data.name ?? undefined,
icon_url: data.icon?.url,
icon_url_override: data.icon?.override,
icon_color: data.icon?.color,
time_created: data.time?.created ?? now,
time_updated: data.time?.updated ?? now,
+40 -22
View File
@@ -1,5 +1,4 @@
import z from "zod"
import type { ZodObject } from "zod"
import { Database, eq } from "@/storage"
import { GlobalBus } from "@/bus/global"
import { Bus as ProjectBus } from "@/bus"
@@ -9,34 +8,48 @@ import { EventSequenceTable, EventTable } from "./event.sql"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { EventID } from "./schema"
import { Flag } from "@/flag/flag"
import { Schema as EffectSchema } from "effect"
import { zodObject } from "@/util/effect-zod"
import type { DeepMutable } from "@/util/schema"
export type Definition = {
type: string
// Keep `Event["data"]` mutable because projectors mutate the persisted shape
// when writing to the database. Bus payloads (`Properties`) stay readonly —
// subscribers only read.
export type Definition<
Type extends string = string,
Schema extends EffectSchema.Top = EffectSchema.Top,
BusSchema extends EffectSchema.Top = Schema,
> = {
type: Type
version: number
aggregate: string
schema: z.ZodObject
// This is temporary and only exists for compatibility with bus
// event definitions
properties: z.ZodObject
schema: Schema
// Bus event payload schema. Defaults to `schema` unless `busSchema` was
// passed at definition time (see `session.updated`, whose projector
// expands the persisted data to a `{ sessionID, info }` bus payload).
properties: BusSchema
}
export type Event<Def extends Definition = Definition> = {
id: string
seq: number
aggregateID: string
data: z.infer<Def["schema"]>
data: DeepMutable<EffectSchema.Schema.Type<Def["schema"]>>
}
export type Properties<Def extends Definition = Definition> = EffectSchema.Schema.Type<Def["properties"]>
export type SerializedEvent<Def extends Definition = Definition> = Event<Def> & { type: string }
type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void
type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown>
export const registry = new Map<string, Definition>()
let projectors: Map<Definition, ProjectorFunc> | undefined
const versions = new Map<string, number>()
let frozen = false
let convertEvent: (type: string, event: Event["data"]) => Promise<Record<string, unknown>> | Record<string, unknown>
let convertEvent: ConvertEvent
export function reset() {
frozen = false
@@ -44,7 +57,7 @@ export function reset() {
convertEvent = (_, data) => data
}
export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: typeof convertEvent }) {
export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: ConvertEvent }) {
projectors = new Map(input.projectors)
// Install all the latest event defs to the bus. We only ever emit
@@ -54,13 +67,13 @@ export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; co
for (let [type, version] of versions.entries()) {
let def = registry.get(versionedType(type, version))!
BusEvent.define(def.type, def.properties || def.schema)
BusEvent.define(def.type, def.properties)
}
// Freeze the system so it clearly errors if events are defined
// after `init` which would cause bugs
frozen = true
convertEvent = input.convertEvent || ((_, data) => data)
convertEvent = input.convertEvent ?? ((_, data) => data)
}
export function versionedType<A extends string>(type: A): A
@@ -72,9 +85,15 @@ export function versionedType(type: string, version?: number) {
export function define<
Type extends string,
Agg extends string,
Schema extends ZodObject<Record<Agg, z.ZodType<string>>>,
BusSchema extends ZodObject = Schema,
>(input: { type: Type; version: number; aggregate: Agg; schema: Schema; busSchema?: BusSchema }) {
Schema extends EffectSchema.Top,
BusSchema extends EffectSchema.Top = Schema,
>(input: {
type: Type
version: number
aggregate: Agg
schema: Schema
busSchema?: BusSchema
}): Definition<Type, Schema, BusSchema> {
if (frozen) {
throw new Error("Error defining sync event: sync system has been frozen")
}
@@ -84,7 +103,7 @@ export function define<
version: input.version,
aggregate: input.aggregate,
schema: input.schema,
properties: input.busSchema ? input.busSchema : input.schema,
properties: (input.busSchema ?? input.schema) as BusSchema,
}
versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0))
@@ -141,12 +160,11 @@ function process<Def extends Definition>(def: Def, event: Event<Def>, options: {
Database.effect(() => {
if (options?.publish) {
const result = convertEvent(def.type, event.data)
const publish = (data: unknown) => ProjectBus.publish(def, data as Properties<Def>)
if (result instanceof Promise) {
void result.then((data) => {
void ProjectBus.publish({ type: def.type, properties: def.schema }, data)
})
void result.then(publish)
} else {
void ProjectBus.publish({ type: def.type, properties: def.schema }, result)
void publish(result)
}
GlobalBus.emit("event", {
@@ -266,7 +284,7 @@ export function payloads() {
id: z.string(),
seq: z.number(),
aggregateID: z.literal(def.aggregate),
data: def.schema,
data: zodObject(def.schema),
})
.meta({
ref: `SyncEvent.${def.type}`,
+10 -7
View File
@@ -1,6 +1,5 @@
import z from "zod"
import * as path from "path"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import * as Tool from "./tool"
import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher"
@@ -16,8 +15,8 @@ import { File } from "../file"
import { Format } from "../format"
import * as Bom from "@/util/bom"
const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
export const Parameters = Schema.Struct({
patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }),
})
export const ApplyPatchTool = Tool.define(
@@ -28,7 +27,10 @@ export const ApplyPatchTool = Tool.define(
const format = yield* Format.Service
const bus = yield* Bus.Service
const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
const run = Effect.fn("ApplyPatchTool.execute")(function* (
params: Schema.Schema.Type<typeof Parameters>,
ctx: Tool.Context,
) {
if (!params.patchText) {
return yield* Effect.fail(new Error("patchText is required"))
}
@@ -297,8 +299,9 @@ export const ApplyPatchTool = Tool.define(
return {
description: DESCRIPTION,
parameters: PatchParams,
execute: (params: z.infer<typeof PatchParams>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
run(params, ctx).pipe(Effect.orDie),
}
}),
)
+11 -15
View File
@@ -1,4 +1,4 @@
import z from "zod"
import { Schema } from "effect"
import os from "os"
import { createWriteStream } from "node:fs"
import * as Tool from "./tool"
@@ -50,20 +50,16 @@ const FILES = new Set([
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
const Parameters = z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.describe(
`The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
.string()
.describe(
export const Parameters = Schema.Struct({
command: Schema.String.annotate({ description: "The command to execute" }),
timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }),
workdir: Schema.optional(Schema.String).annotate({
description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
}),
description: Schema.String.annotate({
description:
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
}),
})
type Part = {
@@ -587,7 +583,7 @@ export const BashTool = Tool.define(
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const cwd = params.workdir
? yield* resolvePath(params.workdir, Instance.directory, shell)
+19 -19
View File
@@ -1,10 +1,24 @@
import z from "zod"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import { HttpClient } from "effect/unstable/http"
import * as Tool from "./tool"
import * as McpExa from "./mcp-exa"
import DESCRIPTION from "./codesearch.txt"
export const Parameters = Schema.Struct({
query: Schema.String.annotate({
description:
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
}),
tokensNum: Schema.Finite.pipe(
Schema.check(Schema.isGreaterThanOrEqualTo(1000), Schema.isLessThanOrEqualTo(50000)),
Schema.withDecodingDefaultTypeKey(Effect.succeed(5000)),
)
.annotate({
description:
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
}),
})
export const CodeSearchTool = Tool.define(
"codesearch",
Effect.gen(function* () {
@@ -12,22 +26,8 @@ export const CodeSearchTool = Tool.define(
return {
description: DESCRIPTION,
parameters: z.object({
query: z
.string()
.describe(
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
),
tokensNum: z
.number()
.min(1000)
.max(50000)
.default(5000)
.describe(
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
),
}),
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "codesearch",
@@ -45,7 +45,7 @@ export const CodeSearchTool = Tool.define(
McpExa.CodeArgs,
{
query: params.query,
tokensNum: params.tokensNum || 5000,
tokensNum: params.tokensNum,
},
"30 seconds",
)
+11 -8
View File
@@ -3,9 +3,8 @@
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts
import z from "zod"
import * as path from "path"
import { Effect, Semaphore } from "effect"
import { Effect, Schema, Semaphore } from "effect"
import * as Tool from "./tool"
import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff"
@@ -45,11 +44,15 @@ function lock(filePath: string) {
return next
}
const Parameters = z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
export const Parameters = Schema.Struct({
filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }),
oldString: Schema.String.annotate({ description: "The text to replace" }),
newString: Schema.String.annotate({
description: "The text to replace it with (must be different from oldString)",
}),
replaceAll: Schema.optional(Schema.Boolean).annotate({
description: "Replace all occurrences of oldString (default false)",
}),
})
export const EditTool = Tool.define(
@@ -63,7 +66,7 @@ export const EditTool = Tool.define(
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
if (!params.filePath) {
throw new Error("filePath is required")
+9 -11
View File
@@ -1,6 +1,5 @@
import path from "path"
import z from "zod"
import { Effect, Option } from "effect"
import { Effect, Option, Schema } from "effect"
import * as Stream from "effect/Stream"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -9,6 +8,13 @@ import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./glob.txt"
import * as Tool from "./tool"
export const Parameters = Schema.Struct({
pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }),
path: Schema.optional(Schema.String).annotate({
description: `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
}),
})
export const GlobTool = Tool.define(
"glob",
Effect.gen(function* () {
@@ -17,15 +23,7 @@ export const GlobTool = Tool.define(
return {
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The glob pattern to match files against"),
path: z
.string()
.optional()
.describe(
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
),
}),
parameters: Parameters,
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
const ins = yield* InstanceState.context
+12 -6
View File
@@ -1,5 +1,5 @@
import path from "path"
import z from "zod"
import { Schema } from "effect"
import { Effect, Option } from "effect"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -10,6 +10,16 @@ import * as Tool from "./tool"
const MAX_LINE_LENGTH = 2000
export const Parameters = Schema.Struct({
pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }),
path: Schema.optional(Schema.String).annotate({
description: "The directory to search in. Defaults to the current working directory.",
}),
include: Schema.optional(Schema.String).annotate({
description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
}),
})
export const GrepTool = Tool.define(
"grep",
Effect.gen(function* () {
@@ -18,11 +28,7 @@ export const GrepTool = Tool.define(
return {
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The regex pattern to search for in file contents"),
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
}),
parameters: Parameters,
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
const empty = {
+7 -6
View File
@@ -1,15 +1,16 @@
import z from "zod"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import * as Tool from "./tool"
export const Parameters = Schema.Struct({
tool: Schema.String,
error: Schema.String,
})
export const InvalidTool = Tool.define(
"invalid",
Effect.succeed({
description: "Do not use",
parameters: z.object({
tool: z.string(),
error: z.string(),
}),
parameters: Parameters,
execute: (params: { tool: string; error: string }) =>
Effect.succeed({
title: "Invalid Tool",
+13 -8
View File
@@ -1,5 +1,4 @@
import z from "zod"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import * as Tool from "./tool"
import path from "path"
import { LSP } from "../lsp"
@@ -21,6 +20,17 @@ const operations = [
"outgoingCalls",
] as const
export const Parameters = Schema.Struct({
operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }),
filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }),
line: Schema.Number.check(Schema.isInt())
.check(Schema.isGreaterThanOrEqualTo(1))
.annotate({ description: "The line number (1-based, as shown in editors)" }),
character: Schema.Number.check(Schema.isInt())
.check(Schema.isGreaterThanOrEqualTo(1))
.annotate({ description: "The character offset (1-based, as shown in editors)" }),
})
export const LspTool = Tool.define(
"lsp",
Effect.gen(function* () {
@@ -29,12 +39,7 @@ export const LspTool = Tool.define(
return {
description: DESCRIPTION,
parameters: z.object({
operation: z.enum(operations).describe("The LSP operation to perform"),
filePath: z.string().describe("The absolute or relative path to the file"),
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
}),
parameters: Parameters,
execute: (
args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number },
ctx: Tool.Context,
+4 -3
View File
@@ -1,6 +1,5 @@
import z from "zod"
import path from "path"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import * as Tool from "./tool"
import { Question } from "../question"
import { Session } from "../session"
@@ -17,6 +16,8 @@ function getLastModel(sessionID: SessionID) {
return undefined
}
export const Parameters = Schema.Struct({})
export const PlanExitTool = Tool.define(
"plan_exit",
Effect.gen(function* () {
@@ -26,7 +27,7 @@ export const PlanExitTool = Tool.define(
return {
description: EXIT_DESCRIPTION,
parameters: z.object({}),
parameters: Parameters,
execute: (_params: {}, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* session.get(ctx.sessionID)
+6 -7
View File
@@ -1,26 +1,25 @@
import z from "zod"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import * as Tool from "./tool"
import { Question } from "../question"
import DESCRIPTION from "./question.txt"
const parameters = z.object({
questions: z.array(Question.Prompt.zod).describe("Questions to ask"),
export const Parameters = Schema.Struct({
questions: Schema.mutable(Schema.Array(Question.Prompt)).annotate({ description: "Questions to ask" }),
})
type Metadata = {
answers: ReadonlyArray<Question.Answer>
}
export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Service>(
export const QuestionTool = Tool.define<typeof Parameters, Metadata, Question.Service>(
"question",
Effect.gen(function* () {
const question = yield* Question.Service
return {
description: DESCRIPTION,
parameters,
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
Effect.gen(function* () {
const answers = yield* question.ask({
sessionID: ctx.sessionID,
+21 -9
View File
@@ -1,5 +1,4 @@
import z from "zod"
import { Effect, Option, Scope } from "effect"
import { Effect, Option, Schema, Scope } from "effect"
import { createReadStream } from "fs"
import * as path from "path"
import { createInterface } from "readline"
@@ -19,10 +18,19 @@ const MAX_BYTES = 50 * 1024
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
const SAMPLE_BYTES = 4096
const parameters = z.object({
filePath: z.string().describe("The absolute path to the file or directory to read"),
offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(),
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
// `offset` and `limit` were originally `z.coerce.number()` — the runtime
// coercion was useful when the tool was called from a shell but serves no
// purpose in the LLM tool-call path (the model emits typed JSON). The JSON
// Schema output is identical (`type: "number"`), so the LLM view is
// unchanged; purely CLI-facing uses must now send numbers rather than strings.
export const Parameters = Schema.Struct({
filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }),
offset: Schema.optional(Schema.Number).annotate({
description: "The line number to start reading from (1-indexed)",
}),
limit: Schema.optional(Schema.Number).annotate({
description: "The maximum number of lines to read (defaults to 2000)",
}),
})
export const ReadTool = Tool.define(
@@ -140,7 +148,10 @@ export const ReadTool = Tool.define(
return nonPrintableCount / bytes.length > 0.3
}
const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
const run = Effect.fn("ReadTool.execute")(function* (
params: Schema.Schema.Type<typeof Parameters>,
ctx: Tool.Context,
) {
if (params.offset !== undefined && params.offset < 1) {
return yield* Effect.fail(new Error("offset must be greater than or equal to 1"))
}
@@ -275,8 +286,9 @@ export const ReadTool = Tool.define(
return {
description: DESCRIPTION,
parameters,
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
run(params, ctx).pipe(Effect.orDie),
}
}),
)
+11 -1
View File
@@ -15,7 +15,9 @@ import { SkillTool } from "./skill"
import * as Tool from "./tool"
import { Config } from "../config"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
import { Schema } from "effect"
import z from "zod"
import { ZodOverride } from "@/util/effect-zod"
import { Plugin } from "../plugin"
import { Provider } from "../provider"
import { ProviderID, type ModelID } from "../provider/schema"
@@ -120,9 +122,17 @@ export const layer: Layer.Layer<
const custom: Tool.Def[] = []
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
// Plugin tools define their args as a raw Zod shape. Wrap the
// derived Zod object in a `Schema.declare` so it slots into the
// Schema-typed framework, and annotate with `ZodOverride` so the
// walker emits the original Zod object for LLM JSON Schema.
const zodParams = z.object(def.args)
const parameters = Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success).annotate({
[ZodOverride]: zodParams,
})
return {
id,
parameters: z.object(def.args),
parameters,
description: def.description,
execute: (args, toolCtx) =>
Effect.gen(function* () {
+4 -5
View File
@@ -1,15 +1,14 @@
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream"
import { Ripgrep } from "../file/ripgrep"
import { Skill } from "../skill"
import * as Tool from "./tool"
import DESCRIPTION from "./skill.txt"
const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"),
export const Parameters = Schema.Struct({
name: Schema.String.annotate({ description: "The name of the skill from available_skills" }),
})
export const SkillTool = Tool.define(
@@ -21,7 +20,7 @@ export const SkillTool = Tool.define(
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* skill.get(params.name)
if (!info) {
+16 -15
View File
@@ -1,13 +1,12 @@
import * as Tool from "./tool"
import DESCRIPTION from "./task.txt"
import z from "zod"
import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent"
import type { SessionPrompt } from "../session/prompt"
import { Config } from "../config"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
export interface TaskPromptOps {
cancel(sessionID: SessionID): void
@@ -17,17 +16,15 @@ export interface TaskPromptOps {
const id = "task"
const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
task_id: z
.string()
.describe(
export const Parameters = Schema.Struct({
description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }),
prompt: Schema.String.annotate({ description: "The task for the agent to perform" }),
subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
task_id: Schema.optional(Schema.String).annotate({
description:
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
)
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
}),
command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }),
})
export const TaskTool = Tool.define(
@@ -37,7 +34,10 @@ export const TaskTool = Tool.define(
const config = yield* Config.Service
const sessions = yield* Session.Service
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
const run = Effect.fn("TaskTool.execute")(function* (
params: Schema.Schema.Type<typeof Parameters>,
ctx: Tool.Context,
) {
const cfg = yield* config.get()
if (!ctx.extra?.bypassAgentCheck) {
@@ -168,8 +168,9 @@ export const TaskTool = Tool.define(
return {
description: DESCRIPTION,
parameters,
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
run(params, ctx).pipe(Effect.orDie),
}
}),
)
+18 -8
View File
@@ -1,26 +1,36 @@
import z from "zod"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import * as Tool from "./tool"
import DESCRIPTION_WRITE from "./todowrite.txt"
import { Todo } from "../session/todo"
const parameters = z.object({
todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
// Todo.Info is still a zod schema (session/todo.ts). Inline the field shape
// here rather than referencing its `.shape` — the LLM-visible JSON Schema is
// identical, and it removes the last zod dependency from this tool.
const TodoItem = Schema.Struct({
content: Schema.String.annotate({ description: "Brief description of the task" }),
status: Schema.String.annotate({
description: "Current status of the task: pending, in_progress, completed, cancelled",
}),
priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }),
})
export const Parameters = Schema.Struct({
todos: Schema.mutable(Schema.Array(TodoItem)).annotate({ description: "The updated todo list" }),
})
type Metadata = {
todos: Todo.Info[]
}
export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Service>(
export const TodoWriteTool = Tool.define<typeof Parameters, Metadata, Todo.Service>(
"todowrite",
Effect.gen(function* () {
const todo = yield* Todo.Service
return {
description: DESCRIPTION_WRITE,
parameters,
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "todowrite",
@@ -42,6 +52,6 @@ export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Servi
},
}
}),
} satisfies Tool.DefWithoutID<typeof parameters, Metadata>
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
}),
)
+46 -28
View File
@@ -1,5 +1,4 @@
import z from "zod"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import type { MessageV2 } from "../session/message-v2"
import type { Permission } from "../permission"
import type { SessionID, MessageID } from "../session/schema"
@@ -32,29 +31,39 @@ export interface ExecuteResult<M extends Metadata = Metadata> {
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
}
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
export interface Def<
Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,
M extends Metadata = Metadata,
> {
id: string
description: string
parameters: Parameters
execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
formatValidationError?(error: z.ZodError): string
execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
formatValidationError?(error: unknown): string
}
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
Def<Parameters, M>,
"id"
>
export type DefWithoutID<
Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,
M extends Metadata = Metadata,
> = Omit<Def<Parameters, M>, "id">
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
export interface Info<
Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,
M extends Metadata = Metadata,
> {
id: string
init: () => Effect.Effect<DefWithoutID<Parameters, M>>
}
type Init<Parameters extends z.ZodType, M extends Metadata> =
type Init<Parameters extends Schema.Decoder<unknown>, M extends Metadata> =
| DefWithoutID<Parameters, M>
| (() => Effect.Effect<DefWithoutID<Parameters, M>>)
export type InferParameters<T> =
T extends Info<infer P, any> ? z.infer<P> : T extends Effect.Effect<Info<infer P, any>, any, any> ? z.infer<P> : never
T extends Info<infer P, any>
? Schema.Schema.Type<P>
: T extends Effect.Effect<Info<infer P, any>, any, any>
? Schema.Schema.Type<P>
: never
export type InferMetadata<T> =
T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
@@ -65,7 +74,7 @@ export type InferDef<T> =
? Def<P, M>
: never
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadata>(
id: string,
init: Init<Parameters, Result>,
truncate: Truncate.Interface,
@@ -74,6 +83,10 @@ function wrap<Parameters extends z.ZodType, Result extends Metadata>(
return () =>
Effect.gen(function* () {
const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }
// Compile the parser closure once per tool init; `decodeUnknownEffect`
// allocates a new closure per call, so hoisting avoids re-closing it for
// every LLM tool invocation.
const decode = Schema.decodeUnknownEffect(toolInfo.parameters)
const execute = toolInfo.execute
toolInfo.execute = (args, ctx) => {
const attrs = {
@@ -83,19 +96,17 @@ function wrap<Parameters extends z.ZodType, Result extends Metadata>(
...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),
}
return Effect.gen(function* () {
yield* Effect.try({
try: () => toolInfo.parameters.parse(args),
catch: (error) => {
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
return new Error(toolInfo.formatValidationError(error), { cause: error })
}
return new Error(
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
{ cause: error },
)
},
})
const result = yield* execute(args, ctx)
const decoded = yield* decode(args).pipe(
Effect.mapError((error) =>
toolInfo.formatValidationError
? new Error(toolInfo.formatValidationError(error), { cause: error })
: new Error(
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
{ cause: error },
),
),
)
const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)
if (result.metadata.truncated !== undefined) {
return result
}
@@ -116,7 +127,12 @@ function wrap<Parameters extends z.ZodType, Result extends Metadata>(
})
}
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
export function define<
Parameters extends Schema.Decoder<unknown>,
Result extends Metadata,
R,
ID extends string = string,
>(
id: ID,
init: Effect.Effect<Init<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
@@ -131,7 +147,9 @@ export function define<Parameters extends z.ZodType, Result extends Metadata, R,
)
}
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
export function init<P extends Schema.Decoder<unknown>, M extends Metadata>(
info: Info<P, M>,
): Effect.Effect<Def<P, M>> {
return Effect.gen(function* () {
const init = yield* info.init()
return {
+11 -11
View File
@@ -1,5 +1,4 @@
import z from "zod"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import * as Tool from "./tool"
import TurndownService from "turndown"
@@ -10,13 +9,14 @@ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
const parameters = z.object({
url: z.string().describe("The URL to fetch content from"),
format: z
.enum(["text", "markdown", "html"])
.default("markdown")
.describe("The format to return the content in (text, markdown, or html). Defaults to markdown."),
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
export const Parameters = Schema.Struct({
url: Schema.String.annotate({ description: "The URL to fetch content from" }),
format: Schema.Literals(["text", "markdown", "html"])
.pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed("markdown" as const)))
.annotate({
description: "The format to return the content in (text, markdown, or html). Defaults to markdown.",
}),
timeout: Schema.optional(Schema.Finite).annotate({ description: "Optional timeout in seconds (max 120)" }),
})
export const WebFetchTool = Tool.define(
@@ -27,8 +27,8 @@ export const WebFetchTool = Tool.define(
return {
description: DESCRIPTION,
parameters,
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) =>
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
throw new Error("URL must start with http:// or https://")
+23 -22
View File
@@ -1,27 +1,28 @@
import z from "zod"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import { HttpClient } from "effect/unstable/http"
import * as Tool from "./tool"
import * as McpExa from "./mcp-exa"
import DESCRIPTION from "./websearch.txt"
const Parameters = z.object({
query: z.string().describe("Websearch query"),
numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
livecrawl: z
.enum(["fallback", "preferred"])
.optional()
.describe(
export const Parameters = Schema.Struct({
query: Schema.String.annotate({ description: "Websearch query" }),
numResults: Schema.Finite.pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed(8))).annotate({
description: "Number of search results to return (default: 8)",
}),
livecrawl: Schema.Literals(["fallback", "preferred"]).pipe(
Schema.withDecodingDefaultTypeKey(Effect.succeed("fallback" as const)),
).annotate({
description:
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
),
type: z
.enum(["auto", "fast", "deep"])
.optional()
.describe("Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"),
contextMaxCharacters: z
.number()
.optional()
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
}),
type: Schema.Literals(["auto", "fast", "deep"]).pipe(
Schema.withDecodingDefaultTypeKey(Effect.succeed("auto" as const)),
).annotate({
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
}),
contextMaxCharacters: Schema.optional(Schema.Finite).annotate({
description: "Maximum characters for context string optimized for LLMs (default: 10000)",
}),
})
export const WebSearchTool = Tool.define(
@@ -34,7 +35,7 @@ export const WebSearchTool = Tool.define(
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
},
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "websearch",
@@ -55,9 +56,9 @@ export const WebSearchTool = Tool.define(
McpExa.SearchArgs,
{
query: params.query,
type: params.type || "auto",
numResults: params.numResults || 8,
livecrawl: params.livecrawl || "fallback",
type: params.type,
numResults: params.numResults,
livecrawl: params.livecrawl,
contextMaxCharacters: params.contextMaxCharacters,
},
"25 seconds",
+9 -5
View File
@@ -1,4 +1,4 @@
import z from "zod"
import { Schema } from "effect"
import * as path from "path"
import { Effect } from "effect"
import * as Tool from "./tool"
@@ -17,6 +17,13 @@ import * as Bom from "@/util/bom"
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
export const Parameters = Schema.Struct({
content: Schema.String.annotate({ description: "The content to write to the file" }),
filePath: Schema.String.annotate({
description: "The absolute path to the file to write (must be absolute, not relative)",
}),
})
export const WriteTool = Tool.define(
"write",
Effect.gen(function* () {
@@ -27,10 +34,7 @@ export const WriteTool = Tool.define(
return {
description: DESCRIPTION,
parameters: z.object({
content: z.string().describe("The content to write to the file"),
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
}),
parameters: Parameters,
execute: (params: { content: string; filePath: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
const filepath = path.isAbsolute(params.filePath)
+68 -14
View File
@@ -8,6 +8,14 @@ import z from "zod"
*/
export const ZodOverride: unique symbol = Symbol.for("effect-zod/override")
/**
* Annotation key for attaching a `z.preprocess(...)` coercion to the derived
* zod schema without changing the Effect Schema's runtime behaviour. Useful
* for LLM-facing JSON Schemas that need to accept looser inputs (e.g. string
* number) than the underlying Schema admits.
*/
export const ZodPreprocess: unique symbol = Symbol.for("effect-zod/preprocess")
// AST nodes are immutable and frequently shared across schemas (e.g. a single
// Schema.Class embedded in multiple parents). Memoizing by node identity
// avoids rebuilding equivalent Zod subtrees and keeps derived children stable
@@ -22,6 +30,43 @@ export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Ty
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
}
/**
* Derive a Zod value from an Effect Schema (or a Schema-backed export with a
* `.zod` static) and narrow the result to `z.ZodObject<any>` so `.shape`,
* `.omit`, `.extend`, and friends are accessible.
*
* The `zod()` walker returns `z.ZodType<T>` because not every AST node decodes
* to an object; this helper keeps the "I started from a `Schema.Struct`" cast
* in one place instead of sprinkling `as unknown as z.ZodObject<any>` across
* call sites.
*
* The return is intentionally loose carrying Schema field types through the
* mapped `.omit()` / `.extend()` surface triggers brand-intersection
* explosions for branded primitives (`string & Brand<"SessionID">` extends
* `object` via the brand and gets walked into the prototype by `DeepPartial`,
* `updateSchema`, etc.), and zod's inference through `z.ZodType<T | undefined>`
* wrappers also can't reconstruct `T` cleanly. Consumers that care about the
* post-`.omit()` shape should cast `c.req.valid(...)` to the expected type.
*/
export function zodObject<S extends Schema.Top>(schema: S): z.ZodObject<any> {
const derived: z.ZodTypeAny = "zod" in schema && isZodType(schema.zod) ? schema.zod : walk(schema.ast)
return derived as unknown as z.ZodObject<any>
}
function isZodType(value: unknown): value is z.ZodTypeAny {
return typeof value === "object" && value !== null && "_zod" in value
}
/**
* Emit a JSON Schema for a tool/route parameter schema derives the zod form
* via the walker so Effect Schema inputs flow through the same zod-openapi
* pipeline the LLM/SDK layer already depends on. `io: "input"` mirrors what
* `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper.
*/
export function toJsonSchema<S extends Schema.Top>(schema: S) {
return z.toJSONSchema(zod(schema), { io: "input" })
}
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
const cached = walkCache.get(ast)
if (cached) return cached
@@ -32,8 +77,17 @@ function walk(ast: SchemaAST.AST): z.ZodTypeAny {
function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
if (override) return override
// `description` annotations layer on top of an override so callers can
// reuse a shared override schema (e.g. `SessionID`) and still add a
// per-field description on the outer wrapper.
const base = override ?? bodyWithChecks(ast)
const desc = SchemaAST.resolveDescription(ast)
const ref = SchemaAST.resolveIdentifier(ast)
const described = desc ? base.describe(desc) : base
return ref ? described.meta({ ref }) : described
}
function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny {
// Schema.Class wraps its fields in a Declaration AST plus an encoding that
// constructs the class instance. For the Zod derivation we want the plain
// field shape (the decoded/consumer view), not the class instance — so
@@ -41,17 +95,18 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
// Schema.decodeTo / Schema.transform attach encoding to non-Declaration
// nodes, where we do apply the transform.
//
// Schema.withDecodingDefault also attaches encoding, but we want `.default(v)`
// on the inner Zod rather than a transform wrapper — so optional ASTs whose
// encoding resolves a default from Option.none() route through body()/opt().
// Schema.withDecodingDefault and Schema.withDecodingDefaultTypeKey both
// attach encodings. For JSON Schema we want those as plain `.default(v)`
// annotations rather than transform wrappers, so ASTs whose encoding
// resolves a default from Option.none() route through body()/opt().
const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration"
const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
const base = hasTransform ? encoded(ast) : body(ast)
const fallback = hasEncoding ? extractDefault(ast) : undefined
const hasTransform = hasEncoding && fallback === undefined
const base = hasTransform ? encoded(ast) : body(ast, fallback)
const checked = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
const desc = SchemaAST.resolveDescription(ast)
const ref = SchemaAST.resolveIdentifier(ast)
const described = desc ? checked.describe(desc) : checked
return ref ? described.meta({ ref }) : described
const defaulted = fallback !== undefined && !SchemaAST.isOptional(ast) ? checked.default(fallback.value) : checked
const preprocess = (ast.annotations as { [ZodPreprocess]?: (val: unknown) => unknown } | undefined)?.[ZodPreprocess]
return preprocess ? z.preprocess(preprocess, defaulted) : defaulted
}
// Walk the encoded side and apply each link's decode to produce the decoded
@@ -185,8 +240,8 @@ function issueMessage(issue: any): string | undefined {
return undefined
}
function body(ast: SchemaAST.AST): z.ZodTypeAny {
if (SchemaAST.isOptional(ast)) return opt(ast)
function body(ast: SchemaAST.AST, fallback?: { value: unknown }): z.ZodTypeAny {
if (SchemaAST.isOptional(ast)) return opt(ast, fallback)
switch (ast._tag) {
case "String":
@@ -219,7 +274,7 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny {
}
}
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
function opt(ast: SchemaAST.AST, fallback = extractDefault(ast)): z.ZodTypeAny {
if (ast._tag !== "Union") return fail(ast)
const items = ast.types.filter((item) => item._tag !== "Undefined")
const inner =
@@ -231,7 +286,6 @@ function opt(ast: SchemaAST.AST): z.ZodTypeAny {
// Schema.withDecodingDefault attaches an encoding `Link` whose transformation
// decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke
// it to extract the default and emit `.default(...)` instead of `.optional()`.
const fallback = extractDefault(ast)
if (fallback !== undefined) return inner.default(fallback.value)
return inner.optional()
}
+49 -8
View File
@@ -1,5 +1,43 @@
import { Schema } from "effect"
/**
* Integer greater than zero.
*/
export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
/**
* Integer greater than or equal to zero.
*/
export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
/**
* Strip `readonly` from a nested type. Stand-in for `effect`'s `Types.DeepMutable`
* until `effect:core/x228my` ("Types.DeepMutable widens unknown to `{}`") lands.
*
* The upstream version falls through `unknown` into `{ -readonly [K in keyof T]: ... }`
* where `keyof unknown = never`, so `unknown` collapses to `{}`. This local
* version gates the object branch on `extends object` (which `unknown` does
* not) so `unknown` passes through untouched.
*
* Primitive bailout matches upstream without it, branded strings like
* `string & Brand<"SessionID">` fall into the object branch and get their
* prototype methods walked.
*
* Tuple branch preserves readonly tuples (e.g. `ConfigPlugin.Spec`'s
* `readonly [string, Options]`); the general array branch would otherwise
* widen them to unbounded arrays.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
export type DeepMutable<T> = T extends string | number | boolean | bigint | symbol | Function
? T
: T extends readonly [unknown, ...unknown[]]
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: T extends readonly (infer U)[]
? DeepMutable<U>[]
: T extends object
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: T
/**
* Attach static methods to a schema object. Designed to be used with `.pipe()`:
*
@@ -16,13 +54,16 @@ export const withStatics =
(schema: S): S & M =>
Object.assign(schema, methods(schema))
declare const NewtypeBrand: unique symbol
type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
/**
* Nominal wrapper for scalar types. The class itself is a valid schema
* pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc.
*
* Overrides `~type.make` on the derived `Schema.Opaque` so `Schema.Schema.Type`
* of a field using this newtype resolves to `Self` rather than the underlying
* branded phantom. Without that override, passing a class instance to code
* typed against `Schema.Schema.Type<FieldSchema>` would require a cast even
* though the values are structurally equivalent at runtime.
*
* @example
* class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
* static make(id: string): QuestionID {
@@ -34,10 +75,8 @@ type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
*/
export function Newtype<Self>() {
return <const Tag extends string, S extends Schema.Top>(tag: Tag, schema: S) => {
type Branded = NewtypeBrand<Tag>
abstract class Base {
declare readonly [NewtypeBrand]: Tag
declare readonly _newtype: Tag
static make(value: Schema.Schema.Type<S>): Self {
return value as unknown as Self
@@ -46,8 +85,10 @@ export function Newtype<Self>() {
Object.setPrototypeOf(Base, schema)
return Base as unknown as (abstract new (_: never) => Branded) & {
return Base as unknown as (abstract new (_: never) => { readonly _newtype: Tag }) & {
readonly make: (value: Schema.Schema.Type<S>) => Self
} & Omit<Schema.Opaque<Self, S, {}>, "make">
} & Omit<Schema.Opaque<Self, S, {}>, "make" | "~type.make"> & {
readonly "~type.make": Self
}
}
}
+6 -6
View File
@@ -13,7 +13,7 @@ import { errorMessage } from "../util/error"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Git } from "@/git"
import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
import { Effect, Layer, Path, Schema, Scope, Context, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -26,15 +26,15 @@ const log = Log.create({ service: "worktree" })
export const Event = {
Ready: BusEvent.define(
"worktree.ready",
z.object({
name: z.string(),
branch: z.string(),
Schema.Struct({
name: Schema.String,
branch: Schema.String,
}),
),
Failed: BusEvent.define(
"worktree.failed",
z.object({
message: z.string(),
Schema.Struct({
message: Schema.String,
}),
),
}
@@ -1,6 +1,5 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Stream } from "effect"
import z from "zod"
import { Deferred, Effect, Layer, Schema, Stream } from "effect"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { Instance } from "../../src/project/instance"
@@ -9,8 +8,8 @@ import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture
import { testEffect } from "../lib/effect"
const TestEvent = {
Ping: BusEvent.define("test.effect.ping", z.object({ value: z.number() })),
Pong: BusEvent.define("test.effect.pong", z.object({ message: z.string() })),
Ping: BusEvent.define("test.effect.ping", Schema.Struct({ value: Schema.Number })),
Pong: BusEvent.define("test.effect.pong", Schema.Struct({ message: Schema.String })),
}
const node = CrossSpawnSpawner.defaultLayer
@@ -1,11 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test"
import z from "zod"
import { Schema } from "effect"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
const TestEvent = BusEvent.define("test.integration", z.object({ value: z.number() }))
const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number }))
function withInstance(directory: string, fn: () => Promise<void>) {
return Instance.provide({ directory, fn })
@@ -42,7 +42,7 @@ describe("Bus integration: acquireRelease subscriber pattern", () => {
await using tmp = await tmpdir()
const received: Array<{ type: string; value?: number }> = []
const OtherEvent = BusEvent.define("test.other", z.object({ value: z.number() }))
const OtherEvent = BusEvent.define("test.other", Schema.Struct({ value: Schema.Number }))
await withInstance(tmp.path, async () => {
Bus.subscribeAll((evt) => {
+3 -3
View File
@@ -1,13 +1,13 @@
import { afterEach, describe, expect, test } from "bun:test"
import z from "zod"
import { Schema } from "effect"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
const TestEvent = {
Ping: BusEvent.define("test.ping", z.object({ value: z.number() })),
Pong: BusEvent.define("test.pong", z.object({ message: z.string() })),
Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })),
Pong: BusEvent.define("test.pong", Schema.Struct({ message: Schema.String })),
}
function withInstance(directory: string, fn: () => Promise<void>) {
@@ -3,6 +3,7 @@ import { Effect, Layer, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { Installation } from "../../src/installation"
import { InstallationChannel } from "../../src/installation/version"
const encoder = new TextEncoder()
@@ -68,11 +69,15 @@ describe("installation", () => {
expect(result).toBe("4.0.0-beta.1")
})
test("reads npm registry versions", async () => {
test("reads npm versions via npm view", async () => {
const calls: string[][] = []
const layer = testLayer(
() => jsonResponse({ version: "1.5.0" }),
() => {
throw new Error("unexpected http request")
},
(cmd, args) => {
if (cmd === "npm" && args.includes("registry")) return "https://registry.npmjs.org\n"
calls.push([cmd, ...args])
if (cmd === "npm" && args[0] === "view") return '"1.5.0"\n'
return ""
},
)
@@ -81,18 +86,47 @@ describe("installation", () => {
Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.5.0")
expect(calls).toContainEqual(["npm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"])
})
test("reads npm registry versions for bun method", async () => {
test("reads npm versions via bun pm view", async () => {
const calls: string[][] = []
const layer = testLayer(
() => jsonResponse({ version: "1.6.0" }),
() => "",
() => {
throw new Error("unexpected http request")
},
(cmd, args) => {
calls.push([cmd, ...args])
if (cmd === "bun" && args[0] === "pm") return '"1.6.0"\n'
return ""
},
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.6.0")
expect(calls).toContainEqual(["bun", "pm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"])
})
test("reads npm versions via pnpm view", async () => {
const calls: string[][] = []
const layer = testLayer(
() => {
throw new Error("unexpected http request")
},
(cmd, args) => {
calls.push([cmd, ...args])
if (cmd === "pnpm" && args[0] === "view") return '"1.7.0"\n'
return ""
},
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("pnpm")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.7.0")
expect(calls).toContainEqual(["pnpm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"])
})
test("reads scoop manifest versions", async () => {
+125
View File
@@ -1,7 +1,58 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Global } from "@opencode-ai/shared/global"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { Npm } from "../src/npm"
import { tmpdir } from "./fixture/fixture"
const win = process.platform === "win32"
const encoder = new TextEncoder()
function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
const spawner = ChildProcessSpawner.make((command) => {
const std = ChildProcess.isStandardCommand(command) ? command : undefined
const output = handler(std?.command ?? "", std?.args ?? [])
return Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(0),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty,
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
getOutputFd: () => Stream.empty,
unref: Effect.succeed(Effect.void),
}),
)
})
return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)
}
function testLayer(spawnHandler?: (cmd: string, args: readonly string[]) => string) {
return Npm.layer.pipe(
Layer.provide(mockSpawner(spawnHandler)),
Layer.provide(EffectFlock.layer),
Layer.provide(AppFileSystem.layer),
Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer),
)
}
const writePackage = (dir: string, pkg: Record<string, unknown>) =>
Bun.write(
path.join(dir, "package.json"),
JSON.stringify({
version: "1.0.0",
...pkg,
}),
)
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
@@ -16,3 +67,77 @@ describe("Npm.sanitize", () => {
expect(Npm.sanitize(spec)).toBe(expected)
})
})
describe("Npm.install", () => {
test("respects omit from project .npmrc", async () => {
await using tmp = await tmpdir()
await writePackage(tmp.path, {
name: "fixture",
dependencies: {
"prod-pkg": "file:./prod-pkg",
},
devDependencies: {
"dev-pkg": "file:./dev-pkg",
},
})
await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n")
await fs.mkdir(path.join(tmp.path, "prod-pkg"))
await fs.mkdir(path.join(tmp.path, "dev-pkg"))
await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" })
await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" })
await Npm.install(tmp.path)
await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined()
await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow()
})
})
describe("Npm.outdated", () => {
test("checks latest via npm view", async () => {
const calls: string[][] = []
const layer = testLayer((cmd, args) => {
calls.push([cmd, ...args])
if (cmd === "npm" && args[0] === "view") return '"2.0.0"\n'
return ""
})
const result = await Effect.runPromise(
Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer)),
)
expect(result).toBe(true)
expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"])
})
test("keeps range comparison behavior", async () => {
const layer = testLayer((cmd, args) => {
if (cmd === "npm" && args[0] === "view") return '"2.3.0"\n'
return ""
})
const result = await Effect.runPromise(
Npm.Service.use((svc) => svc.outdated("example", "^2.0.0")).pipe(Effect.provide(layer)),
)
expect(result).toBe(false)
})
test("falls back when npm view is unavailable", async () => {
const calls: string[][] = []
const layer = testLayer((cmd, args) => {
calls.push([cmd, ...args])
if (cmd === "pnpm" && args[0] === "view") return '"2.0.0"\n'
return ""
})
const result = await Effect.runPromise(
Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer)),
)
expect(result).toBe(true)
expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"])
expect(calls).toContainEqual(["pnpm", "view", "example", "dist-tags.latest", "--json"])
})
})

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