Merge branch 'dev' into nxl/scout-repo-tools

This commit is contained in:
Shoubhit Dash 2026-04-24 16:39:56 +05:30 committed by GitHub
commit 3bf0c79396
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
140 changed files with 2532 additions and 883 deletions

View file

@ -1,5 +1,10 @@
name: "Setup Bun"
description: "Setup Bun with caching and install dependencies"
inputs:
install-flags:
description: "Additional flags to pass to 'bun install'"
required: false
default: ""
runs:
using: "composite"
steps:
@ -46,8 +51,8 @@ runs:
# e.g. ./patches/ for standard-openapi
# https://github.com/oven-sh/bun/issues/28147
if [ "$RUNNER_OS" = "Windows" ]; then
bun install --linker hoisted
bun install --linker hoisted ${{ inputs.install-flags }}
else
bun install
bun install ${{ inputs.install-flags }}
fi
shell: bash

View file

@ -402,12 +402,14 @@ jobs:
fail-fast: false
matrix:
settings:
- host: macos-latest
- host: macos-26-intel
target: x86_64-apple-darwin
platform_flag: --mac --x64
- host: macos-latest
bun_install_flags: --os=darwin --cpu=x64
- host: macos-26
target: aarch64-apple-darwin
platform_flag: --mac --arm64
bun_install_flags: --os=darwin --cpu=arm64
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: "windows-2025"
target: aarch64-pc-windows-msvc
@ -437,6 +439,8 @@ jobs:
run: echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
- uses: ./.github/actions/setup-bun
with:
install-flags: ${{ matrix.settings.bun_install_flags }}
- name: Azure login
if: runner.os == 'Windows'

View file

@ -270,7 +270,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({
opacity: value,
transform: `scale(${0.95 + value * 0.05})`,
transform: `scale(${0.98 + value * 0.02})`,
filter: `blur(${(1 - value) * 2}px)`,
"pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const),
})
@ -345,7 +345,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
promptPlaceholder({
mode: store.mode,
commentCount: commentCount(),
example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "",
example: suggest() ? (store.mode === "shell" ? "git status" : language.t(EXAMPLES[store.placeholder])) : "",
suggest: suggest(),
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
@ -1403,12 +1403,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!working() && blank())}
disabled={!working() && blank()}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : "arrow-up"}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-8"
style={buttons()}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
@ -1451,14 +1450,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
<div
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0"
style={{
padding: "0 4px 0 8px",
padding: "0 0px 0 8px",
...shell(),
}}
>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" />
<Icon name="console" />
<span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span>
<div class="flex-1" />
<Button
variant="ghost"
class="text-text-base"
onClick={() => {
setStore("mode", "normal")
}}
>
{language.t("common.cancel")}
</Button>
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
<Show when={!agentsLoading()}>
@ -1565,33 +1574,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</TooltipKeybind>
</Show>
</div>
<div
data-component="prompt-variant-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
<Show when={variants().length > 2}>
<div
data-component="prompt-variant-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
</Show>
</Show>
</Show>
</div>

View file

@ -12,7 +12,7 @@ describe("promptPlaceholder", () => {
suggest: true,
t,
})
expect(value).toBe("prompt.placeholder.shell")
expect(value).toBe("prompt.placeholder.shell:example")
})
test("returns summarize placeholders for comment context", () => {

View file

@ -7,7 +7,7 @@ type PromptPlaceholderInput = {
}
export function promptPlaceholder(input: PromptPlaceholderInput) {
if (input.mode === "shell") return input.t("prompt.placeholder.shell")
if (input.mode === "shell") return input.t("prompt.placeholder.shell", { example: input.example })
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
if (!input.suggest) return input.t("prompt.placeholder.simple")

View file

@ -128,27 +128,25 @@ export const SettingsGeneral: Component = () => {
return
}
const actions =
platform.update && platform.restart
? [
{
label: language.t("toast.update.action.installRestart"),
onClick: async () => {
await platform.update!()
await platform.restart!()
},
const actions = platform.updateAndRestart
? [
{
label: language.t("toast.update.action.installRestart"),
onClick: async () => {
await platform.updateAndRestart!()
},
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
: [
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
},
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
: [
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss" as const,
},
]
showToast({
persistent: true,

View file

@ -49,11 +49,11 @@ export type Platform = {
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
/** Check for updates (Tauri only) */
/** Check for a downloadable desktop update */
checkUpdate?(): Promise<UpdateInfo>
/** Install updates (Tauri only) */
update?(): Promise<void>
/** Install the downloaded update using the platform restart flow */
updateAndRestart?(): Promise<void>
/** Fetch override */
fetch?: typeof fetch

View file

@ -210,7 +210,7 @@ export const dict = {
"common.saving": "جارٍ الحفظ...",
"common.default": "افتراضي",
"common.attachment": "مرفق",
"prompt.placeholder.shell": "أدخل أمر shell...",
"prompt.placeholder.shell": "أدخل أمر shell... {{example}}",
"prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"',
"prompt.placeholder.simple": "اسأل أي شيء...",
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",

View file

@ -210,7 +210,7 @@ export const dict = {
"common.saving": "Salvando...",
"common.default": "Padrão",
"common.attachment": "anexo",
"prompt.placeholder.shell": "Digite comando do shell...",
"prompt.placeholder.shell": "Digite comando do shell... {{example}}",
"prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"',
"prompt.placeholder.simple": "Pergunte qualquer coisa...",
"prompt.placeholder.summarizeComments": "Resumir comentários…",

View file

@ -228,7 +228,7 @@ export const dict = {
"common.default": "Podrazumijevano",
"common.attachment": "prilog",
"prompt.placeholder.shell": "Unesi shell naredbu...",
"prompt.placeholder.shell": "Unesi shell naredbu... {{example}}",
"prompt.placeholder.normal": 'Pitaj bilo šta... "{{example}}"',
"prompt.placeholder.simple": "Pitaj bilo šta...",
"prompt.placeholder.summarizeComments": "Sažmi komentare…",

View file

@ -226,7 +226,7 @@ export const dict = {
"common.default": "Standard",
"common.attachment": "vedhæftning",
"prompt.placeholder.shell": "Indtast shell-kommando...",
"prompt.placeholder.shell": "Indtast shell-kommando... {{example}}",
"prompt.placeholder.normal": 'Spørg om hvad som helst... "{{example}}"',
"prompt.placeholder.simple": "Spørg om hvad som helst...",
"prompt.placeholder.summarizeComments": "Opsummér kommentarer…",

View file

@ -215,7 +215,7 @@ export const dict = {
"common.saving": "Speichert...",
"common.default": "Standard",
"common.attachment": "Anhang",
"prompt.placeholder.shell": "Shell-Befehl eingeben...",
"prompt.placeholder.shell": "Shell-Befehl eingeben... {{example}}",
"prompt.placeholder.normal": 'Fragen Sie alles... "{{example}}"',
"prompt.placeholder.simple": "Fragen Sie alles...",
"prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…",

View file

@ -230,7 +230,7 @@ export const dict = {
"common.default": "Default",
"common.attachment": "attachment",
"prompt.placeholder.shell": "Enter shell command...",
"prompt.placeholder.shell": "Enter shell command... {{example}}",
"prompt.placeholder.normal": 'Ask anything... "{{example}}"',
"prompt.placeholder.simple": "Ask anything...",
"prompt.placeholder.summarizeComments": "Summarize comments…",

View file

@ -227,7 +227,7 @@ export const dict = {
"common.default": "Predeterminado",
"common.attachment": "adjunto",
"prompt.placeholder.shell": "Introduce comando de shell...",
"prompt.placeholder.shell": "Introduce comando de shell... {{example}}",
"prompt.placeholder.normal": 'Pregunta cualquier cosa... "{{example}}"',
"prompt.placeholder.simple": "Pregunta cualquier cosa...",
"prompt.placeholder.summarizeComments": "Resumir comentarios…",

View file

@ -210,7 +210,7 @@ export const dict = {
"common.saving": "Enregistrement...",
"common.default": "Défaut",
"common.attachment": "pièce jointe",
"prompt.placeholder.shell": "Entrez une commande shell...",
"prompt.placeholder.shell": "Entrez une commande shell... {{example}}",
"prompt.placeholder.normal": 'Demandez n\'importe quoi... "{{example}}"',
"prompt.placeholder.simple": "Demandez n'importe quoi...",
"prompt.placeholder.summarizeComments": "Résumer les commentaires…",

View file

@ -209,7 +209,7 @@ export const dict = {
"common.saving": "保存中...",
"common.default": "デフォルト",
"common.attachment": "添付ファイル",
"prompt.placeholder.shell": "シェルコマンドを入力...",
"prompt.placeholder.shell": "シェルコマンドを入力... {{example}}",
"prompt.placeholder.normal": '何でも聞いてください... "{{example}}"',
"prompt.placeholder.simple": "何でも聞いてください...",
"prompt.placeholder.summarizeComments": "コメントを要約…",

View file

@ -209,7 +209,7 @@ export const dict = {
"common.saving": "저장 중...",
"common.default": "기본값",
"common.attachment": "첨부 파일",
"prompt.placeholder.shell": "셸 명령어 입력...",
"prompt.placeholder.shell": "셸 명령어 입력... {{example}}",
"prompt.placeholder.normal": '무엇이든 물어보세요... "{{example}}"',
"prompt.placeholder.simple": "무엇이든 물어보세요...",
"prompt.placeholder.summarizeComments": "댓글 요약…",

View file

@ -230,7 +230,7 @@ export const dict = {
"common.default": "Standard",
"common.attachment": "vedlegg",
"prompt.placeholder.shell": "Skriv inn shell-kommando...",
"prompt.placeholder.shell": "Skriv inn shell-kommando... {{example}}",
"prompt.placeholder.normal": 'Spør om hva som helst... "{{example}}"',
"prompt.placeholder.simple": "Spør om hva som helst...",
"prompt.placeholder.summarizeComments": "Oppsummer kommentarer…",

View file

@ -211,7 +211,7 @@ export const dict = {
"common.saving": "Zapisywanie...",
"common.default": "Domyślny",
"common.attachment": "załącznik",
"prompt.placeholder.shell": "Wpisz polecenie terminala...",
"prompt.placeholder.shell": "Wpisz polecenie terminala... {{example}}",
"prompt.placeholder.normal": 'Zapytaj o cokolwiek... "{{example}}"',
"prompt.placeholder.simple": "Zapytaj o cokolwiek...",
"prompt.placeholder.summarizeComments": "Podsumuj komentarze…",

View file

@ -227,7 +227,7 @@ export const dict = {
"common.default": "По умолчанию",
"common.attachment": "вложение",
"prompt.placeholder.shell": "Введите команду оболочки...",
"prompt.placeholder.shell": "Введите команду оболочки... {{example}}",
"prompt.placeholder.normal": 'Спросите что угодно... "{{example}}"',
"prompt.placeholder.simple": "Спросите что угодно...",
"prompt.placeholder.summarizeComments": "Суммировать комментарии…",

View file

@ -227,7 +227,7 @@ export const dict = {
"common.default": "ค่าเริ่มต้น",
"common.attachment": "ไฟล์แนบ",
"prompt.placeholder.shell": "ป้อนคำสั่งเชลล์...",
"prompt.placeholder.shell": "ป้อนคำสั่งเชลล์... {{example}}",
"prompt.placeholder.normal": 'ถามอะไรก็ได้... "{{example}}"',
"prompt.placeholder.simple": "ถามอะไรก็ได้...",
"prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…",

View file

@ -232,7 +232,7 @@ export const dict = {
"common.default": "Varsayılan",
"common.attachment": "ek",
"prompt.placeholder.shell": "Kabuk komutu girin...",
"prompt.placeholder.shell": "Kabuk komutu girin... {{example}}",
"prompt.placeholder.normal": 'Bir şeyler sorun... "{{example}}"',
"prompt.placeholder.simple": "Bir şeyler sorun...",
"prompt.placeholder.summarizeComments": "Yorumları özetle…",

View file

@ -249,7 +249,7 @@ export const dict = {
"common.default": "默认",
"common.attachment": "附件",
"prompt.placeholder.shell": "输入 shell 命令...",
"prompt.placeholder.shell": "输入 shell 命令... {{example}}",
"prompt.placeholder.normal": '随便问点什么... "{{example}}"',
"prompt.placeholder.simple": "随便问点什么...",
"prompt.placeholder.summarizeComments": "总结评论…",

View file

@ -227,7 +227,7 @@ export const dict = {
"common.default": "預設",
"common.attachment": "附件",
"prompt.placeholder.shell": "輸入 shell 命令...",
"prompt.placeholder.shell": "輸入 shell 命令... {{example}}",
"prompt.placeholder.normal": '隨便問點什麼... "{{example}}"',
"prompt.placeholder.simple": "隨便問點什麼...",
"prompt.placeholder.summarizeComments": "摘要評論…",

View file

@ -244,10 +244,9 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
}
async function installUpdate() {
if (!platform.update || !platform.restart) return
if (!platform.updateAndRestart) return
await platform
.update()
.then(() => platform.restart!())
.updateAndRestart()
.then(() => setStore("actionError", undefined))
.catch((err) => {
setStore("actionError", formatError(err, language.t))

View file

@ -366,7 +366,7 @@ export default function Layout(props: ParentProps) {
const useUpdatePolling = () =>
onMount(() => {
if (!platform.checkUpdate || !platform.update || !platform.restart) return
if (!platform.checkUpdate || !platform.updateAndRestart) return
let toastId: number | undefined
let interval: ReturnType<typeof setInterval> | undefined
@ -384,8 +384,7 @@ export default function Layout(props: ParentProps) {
{
label: language.t("toast.update.action.installRestart"),
onClick: async () => {
await platform.update!()
await platform.restart!()
await platform.updateAndRestart!()
},
},
{

View file

@ -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 },

View file

@ -53,9 +53,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
anthropic_version: "bedrock-2023-05-31",
anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined,
}
: {
service_tier: "standard_only",
}),
: {}),
}),
createBinaryStreamDecoder: () => {
if (!isBedrock) return undefined

View file

@ -337,11 +337,16 @@ function setupAutoUpdater() {
})
}
let updateReady = false
let downloadedUpdateVersion: string | undefined
async function checkUpdate() {
if (!UPDATER_ENABLED) return { updateAvailable: false }
updateReady = false
if (downloadedUpdateVersion) {
logger.log("returning cached downloaded update", {
version: downloadedUpdateVersion,
})
return { updateAvailable: true, version: downloadedUpdateVersion }
}
logger.log("checking for updates", {
currentVersion: app.getVersion(),
channel: autoUpdater.channel,
@ -367,7 +372,7 @@ async function checkUpdate() {
logger.log("update available", { version })
await autoUpdater.downloadUpdate()
logger.log("update download completed", { version })
updateReady = true
downloadedUpdateVersion = version
return { updateAvailable: true, version }
} catch (error) {
logger.error("update check failed", error)
@ -376,7 +381,15 @@ async function checkUpdate() {
}
async function installUpdate() {
if (!updateReady) return
if (!downloadedUpdateVersion) {
logger.log("install update skipped", {
reason: "no downloaded update ready",
})
return
}
logger.log("installing downloaded update", {
version: downloadedUpdateVersion,
})
killSidecar()
autoUpdater.quitAndInstall()
}

View file

@ -170,7 +170,7 @@ const createPlatform = (): Platform => {
return window.api.checkUpdate()
},
update: async () => {
updateAndRestart: async () => {
const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
if (!config.updaterEnabled) return
await window.api.installUpdate()

View file

@ -297,10 +297,15 @@ const createPlatform = (): Platform => {
return { updateAvailable: true, version: next.version }
},
update: async () => {
updateAndRestart: async () => {
if (!UPDATER_ENABLED || !update) return
if (ostype() === "windows") await commands.killSidecar().catch(() => undefined)
await update.install().catch(() => undefined)
const installed = await update
.install()
.then(() => true)
.catch(() => false)
if (!installed) return
await relaunch()
},
restart: async () => {

View file

@ -409,7 +409,7 @@ Current instance route inventory:
- `project` - `bridged` (partial)
bridged endpoints: `GET /project`, `GET /project/current`
defer git-init mutation first
- `workspace` - `next`
- `workspace` - `bridged`
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
defer create/remove mutations first
- `file` - `later`
@ -448,7 +448,7 @@ Recommended near-term sequence:
- [x] port `config` providers read endpoint
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
- [x] port `GET /config` full read endpoint
- [ ] port `workspace` read endpoints
- [x] port `workspace` read endpoints
- [ ] port `file` JSON read endpoints
- [ ] decide when to remove the flag and make Effect routes the default

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
@ -243,8 +254,8 @@ Working rule for this cluster:
5. Errors and event payloads last
- `NamedError.create(...)` shapes can stay temporarily if converting them to
`Schema.TaggedErrorClass` would force unrelated churn
- `SyncEvent.define(...)` and `BusEvent.define(...)` payloads can keep using
derived `.zod` until the sync/bus layers are migrated
- `SyncEvent.define(...)` and `BusEvent.define(...)` payloads can use
derived `.zod` at remaining zod-based HTTP/OpenAPI boundaries
Possible later tightening after the Schema-first migration is stable:
@ -263,9 +274,9 @@ Possible later tightening after the Schema-first migration is stable:
### 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
@ -273,25 +284,25 @@ Each tool declares its parameters via a zod schema. Tools are consumed by
both the in-process runtime and the AI SDK's tool-calling layer, so the
emitted JSON Schema must stay byte-identical.
- [ ] `src/tool/apply_patch.ts`
- [ ] `src/tool/bash.ts`
- [ ] `src/tool/codesearch.ts`
- [ ] `src/tool/edit.ts`
- [ ] `src/tool/glob.ts`
- [ ] `src/tool/grep.ts`
- [ ] `src/tool/invalid.ts`
- [ ] `src/tool/lsp.ts`
- [ ] `src/tool/plan.ts`
- [ ] `src/tool/question.ts`
- [ ] `src/tool/read.ts`
- [ ] `src/tool/registry.ts`
- [ ] `src/tool/skill.ts`
- [ ] `src/tool/task.ts`
- [ ] `src/tool/todo.ts`
- [ ] `src/tool/tool.ts`
- [ ] `src/tool/webfetch.ts`
- [ ] `src/tool/websearch.ts`
- [ ] `src/tool/write.ts`
- [x] `src/tool/apply_patch.ts`
- [x] `src/tool/bash.ts`
- [x] `src/tool/codesearch.ts`
- [x] `src/tool/edit.ts`
- [x] `src/tool/glob.ts`
- [x] `src/tool/grep.ts`
- [x] `src/tool/invalid.ts`
- [x] `src/tool/lsp.ts`
- [x] `src/tool/plan.ts`
- [x] `src/tool/question.ts`
- [x] `src/tool/read.ts`
- [x] `src/tool/registry.ts`
- [x] `src/tool/skill.ts`
- [x] `src/tool/task.ts`
- [x] `src/tool/todo.ts`
- [x] `src/tool/tool.ts`
- [x] `src/tool/webfetch.ts`
- [x] `src/tool/websearch.ts`
- [x] `src/tool/write.ts`
### HTTP route boundaries
@ -302,8 +313,8 @@ which means touching them is largely mechanical once the domain side is
done.
- [ ] `src/server/error.ts`
- [ ] `src/server/event.ts`
- [ ] `src/server/projectors.ts`
- [x] `src/server/event.ts`
- [x] `src/server/projectors.ts`
- [ ] `src/server/routes/control/index.ts`
- [ ] `src/server/routes/control/workspace.ts`
- [ ] `src/server/routes/global.ts`
@ -335,7 +346,7 @@ piecewise.
- [ ] `src/acp/agent.ts`
- [ ] `src/agent/agent.ts`
- [ ] `src/bus/bus-event.ts`
- [x] `src/bus/bus-event.ts`
- [ ] `src/bus/index.ts`
- [ ] `src/cli/cmd/tui/config/tui-migrate.ts`
- [ ] `src/cli/cmd/tui/config/tui-schema.ts`
@ -343,9 +354,9 @@ piecewise.
- [ ] `src/cli/cmd/tui/event.ts`
- [ ] `src/cli/ui.ts`
- [ ] `src/command/index.ts`
- [ ] `src/control-plane/adaptors/worktree.ts`
- [ ] `src/control-plane/types.ts`
- [ ] `src/control-plane/workspace.ts`
- [x] `src/control-plane/adaptors/worktree.ts`
- [x] `src/control-plane/types.ts`
- [x] `src/control-plane/workspace.ts`
- [ ] `src/file/index.ts`
- [ ] `src/file/ripgrep.ts`
- [ ] `src/file/watcher.ts`
@ -365,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; `payloads()` still derives zod for the remaining HTTP/OpenAPI boundary
- [ ] `src/util/fn.ts`
- [ ] `src/util/log.ts`
- [ ] `src/util/update-schema.ts`

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}`,

View file

@ -1,5 +1,4 @@
import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream, Schema as EffectSchema, Types } 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,12 @@ import { makeRuntime } from "@/effect/run-service"
const log = Log.create({ service: "bus" })
type BusProperties<D extends BusEvent.Definition = BusEvent.Definition> = D extends {
effectProperties: infer Properties extends EffectSchema.Top
}
? Types.DeepMutable<EffectSchema.Schema.Type<Properties>>
: z.infer<D["properties"]>
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,
}),
)

View file

@ -24,6 +24,7 @@ import { DialogProvider as DialogProviderList } from "@tui/component/dialog-prov
import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider } from "@tui/context/project"
import { EditorContextProvider } from "@tui/context/editor"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
@ -177,7 +178,9 @@ export function tui(input: {
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App onSnapshot={input.onSnapshot} />
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>

View file

@ -1,9 +1,11 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
import { pathToFileURL } from "bun"
import fuzzysort from "fuzzysort"
import path from "path"
import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { useEditorContext } from "@tui/context/editor"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { getScrollAcceleration } from "../../util/scroll"
@ -77,6 +79,7 @@ export function Autocomplete(props: {
agentStyleId: number
promptPartTypeId: () => number
}) {
const editor = useEditorContext()
const sdk = useSDK()
const sync = useSync()
const command = useCommandDialog()
@ -221,6 +224,70 @@ export function Autocomplete(props: {
}
}
function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) {
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item)
const urlObj = pathToFileURL(fullPath)
const filename =
lineRange && !item.endsWith("/")
? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
: item
if (lineRange && !item.endsWith("/")) {
urlObj.searchParams.set("start", String(lineRange.startLine))
if (lineRange.endLine !== undefined) {
urlObj.searchParams.set("end", String(lineRange.endLine))
}
}
return {
filename,
url: urlObj.href,
part: {
type: "file" as const,
mime: "text/plain",
filename,
url: urlObj.href,
source: {
type: "file" as const,
text: {
start: 0,
end: 0,
value: "",
},
path: item,
},
},
}
}
function normalizeMentionPath(filePath: string) {
const baseDir = sync.path.directory || process.cwd()
const absolute = path.resolve(filePath)
const relative = path.relative(baseDir, absolute)
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
return relative.split(path.sep).join("/")
}
return absolute.split(path.sep).join("/")
}
function insertFileMention(input: { filePath: string; lineStart: number; lineEnd: number }) {
const item = normalizeMentionPath(input.filePath)
const lineRange = {
startLine: input.lineStart,
endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined,
}
const { filename, part } = createFilePart(item, lineRange)
const index = store.visible === "@" ? store.index : props.input().cursorOffset
command.keybinds(true)
setStore("visible", false)
setStore("index", index)
insertPart(filename, part)
}
const [files] = createResource(
() => search(),
async (query) => {
@ -250,18 +317,7 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4
options.push(
...sortedFiles.map((item): AutocompleteOption => {
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
const fullPath = `${baseDir}/${item}`
const urlObj = pathToFileURL(fullPath)
let filename = item
if (lineRange && !item.endsWith("/")) {
filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
urlObj.searchParams.set("start", String(lineRange.startLine))
if (lineRange.endLine !== undefined) {
urlObj.searchParams.set("end", String(lineRange.endLine))
}
}
const url = urlObj.href
const { filename, url, part } = createFilePart(item, lineRange)
const isDir = item.endsWith("/")
return {
@ -270,21 +326,7 @@ export function Autocomplete(props: {
isDirectory: isDir,
path: item,
onSelect: () => {
insertPart(filename, {
type: "file",
mime: "text/plain",
filename,
url,
source: {
type: "file",
text: {
start: 0,
end: 0,
value: "",
},
path: item,
},
})
insertPart(filename, part)
},
}
}),
@ -501,6 +543,14 @@ export function Autocomplete(props: {
}
onMount(() => {
const unsubscribeMention = editor.onMention((mention) => {
insertFileMention(mention)
})
onCleanup(() => {
unsubscribeMention()
})
props.ref({
get visible() {
return store.visible

View file

@ -12,6 +12,7 @@ import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { useEditorContext } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
@ -21,7 +22,7 @@ import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useRenderer, type JSX } from "@opentui/solid"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import * as Editor from "@tui/util/editor"
import { useExit } from "../../context/exit"
import * as Clipboard from "../../util/clipboard"
@ -94,6 +95,7 @@ export function Prompt(props: PromptProps) {
const local = useLocal()
const args = useArgs()
const sdk = useSDK()
const editor = useEditorContext()
const route = useRoute()
const project = useProject()
const sync = useSync()
@ -104,11 +106,34 @@ export function Prompt(props: PromptProps) {
const stash = usePromptStash()
const command = useCommandDialog()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const { theme, syntax } = useTheme()
const kv = useKV()
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const editorPath = createMemo(() => editor.selection()?.filePath)
const editorSelectionLabel = createMemo(() => {
const selection = editor.selection()?.selection
if (!selection) return
if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return
if (selection.start.line === selection.end.line) return `#${selection.start.line}`
return `#${selection.start.line}-${selection.end.line}`
})
const editorFileLabel = createMemo(() => {
const value = editorPath()
if (!value) return
const filename = path.basename(value)
const file = /^index\.[^./]+$/.test(filename)
? [path.basename(path.dirname(value)), filename].filter(Boolean).join("/")
: filename
return `${file.split(path.sep).join("/")}${editorSelectionLabel() ?? ""}`
})
const editorFileLabelDisplay = createMemo(() => {
const file = editorFileLabel()
if (!file) return
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
})
const [auto, setAuto] = createSignal<AutocompleteRef>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
@ -721,6 +746,27 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const variant = local.model.variant.current()
const editorSelection = editor.selection()
const editorParts = editorSelection
? [
{
id: PartID.ascending(),
type: "text" as const,
text: (() => {
const start = editorSelection.selection.start
const end = editorSelection.selection.end
if (start.line === end.line && start.character === end.character) {
return `Note: The user opened the file "${editorSelection.filePath}".`
}
if (start.line === end.line) {
return `Note: The user selected line ${start.line} from "${editorSelection.filePath}": ${editorSelection.text}`
}
return `Note: The user selected lines ${start.line} to ${end.line} from "${editorSelection.filePath}": ${editorSelection.text}`
})(),
synthetic: true,
},
]
: []
if (store.mode === "shell") {
void sdk.client.session.shell({
@ -773,6 +819,7 @@ export function Prompt(props: PromptProps) {
model: selectedModel,
variant,
parts: [
...editorParts,
{
id: PartID.ascending(),
type: "text",
@ -1332,6 +1379,7 @@ export function Prompt(props: PromptProps) {
</Show>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>

View file

@ -0,0 +1,319 @@
import { readdirSync, readFileSync, statSync } from "node:fs"
import os from "node:os"
import path from "node:path"
import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import z from "zod"
import { createSimpleContext } from "./helper"
const MCP_PROTOCOL_VERSION = "2025-11-25"
const JsonRpcMessageSchema = z.object({
id: z.union([z.number(), z.string(), z.null()]).optional(),
method: z.string().optional(),
params: z.unknown().optional(),
result: z.unknown().optional(),
error: z
.object({
code: z.number().optional(),
message: z.string().optional(),
})
.optional(),
})
const PositionSchema = z.object({
line: z.number(),
character: z.number(),
})
const EditorSelectionSchema = z.object({
text: z.string(),
filePath: z.string(),
selection: z.object({
start: PositionSchema,
end: PositionSchema,
}),
})
const EditorMentionSchema = z.object({
filePath: z.string(),
lineStart: z.number(),
lineEnd: z.number(),
})
const EditorServerInfoSchema = z.object({
protocolVersion: z.string().optional(),
serverInfo: z
.object({
name: z.string().optional(),
version: z.string().optional(),
})
.optional(),
})
type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
export type EditorSelection = z.infer<typeof EditorSelectionSchema>
export type EditorMention = z.infer<typeof EditorMentionSchema>
type EditorServerInfo = z.infer<typeof EditorServerInfoSchema>
type EditorConnection = {
url: string
authToken?: string
source: string
}
type EditorLockFile = {
port: number
authToken?: string
transport?: string
workspaceFolders: string[]
mtimeMs: number
}
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
name: "EditorContext",
init: () => {
const mentionListeners = new Set<(mention: EditorMention) => void>()
const [store, setStore] = createStore<{
status: "disabled" | "connecting" | "connected"
selection: EditorSelection | undefined
server: EditorServerInfo | undefined
}>({
status: "disabled",
selection: undefined,
server: undefined,
})
onMount(() => {
let socket: WebSocket | undefined
let closed = false
let reconnect: ReturnType<typeof setTimeout> | undefined
let attempt = 0
let requestID = 0
const pending = new Map<number, string>()
const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
}
const request = (method: string, params?: unknown) => {
requestID += 1
pending.set(requestID, method)
send({ id: requestID, method, params })
}
const scheduleReconnect = (delay: number) => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
reconnect = setTimeout(connect, delay)
}
const connect = () => {
if (closed) return
const connection = resolveEditorConnection()
if (!connection) {
setStore("status", "disabled")
scheduleReconnect(1000)
return
}
setStore("status", "connecting")
const current = openEditorSocket(connection)
socket = current
current.addEventListener("open", () => {
if (socket !== current) {
current.close()
return
}
attempt = 0
setStore("status", "connected")
request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "opencode", version: "0.0.0" },
})
})
current.addEventListener("message", (event) => {
const message = parseMessage(event.data)
if (!message) return
const selection =
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
if (selection?.success) {
setStore("selection", selection.data)
return
}
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
if (mention?.success) {
mentionListeners.forEach((listener) => listener(mention.data))
return
}
if (typeof message.id !== "number") return
const method = pending.get(message.id)
if (!method) return
pending.delete(message.id)
if (message.error) return
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
if (initialize?.success) {
setStore("server", initialize.data)
send({ method: "notifications/initialized" })
return
}
})
current.addEventListener("close", () => {
if (socket !== current) return
socket = undefined
pending.clear()
if (closed) return
setStore("status", "connecting")
attempt += 1
const delay = Math.min(1000 * 2 ** (attempt - 1), 30000)
scheduleReconnect(delay)
})
}
scheduleReconnect(0)
onCleanup(() => {
closed = true
if (reconnect) clearTimeout(reconnect)
socket?.close()
})
})
return {
enabled() {
return Boolean(resolveEditorConnection())
},
connected() {
return store.status === "connected"
},
selection() {
return store.selection
},
onMention(listener: (mention: EditorMention) => void) {
mentionListeners.add(listener)
return () => mentionListeners.delete(listener)
},
server() {
return store.server
},
}
},
})
function parsePort(value: string | undefined) {
if (!value) return
const parsed = Number.parseInt(value, 10)
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) return
return parsed
}
function resolveEditorConnection(): EditorConnection | undefined {
const lock = resolveEditorLockFile()
if (lock) {
return {
url: `ws://127.0.0.1:${lock.port}`,
authToken: lock.authToken,
source: `lock:${lock.port}`,
}
}
const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT)
if (!port) return
return {
url: `ws://127.0.0.1:${port}`,
source: `env:${port}`,
}
}
function resolveEditorLockFile() {
const directory = path.join(os.homedir(), ".claude", "ide")
let entries: string[]
try {
entries = readdirSync(directory)
} catch {
return
}
const cwd = process.cwd()
const locks = entries
.filter((entry) => entry.endsWith(".lock"))
.map((entry) => readEditorLockFile(path.join(directory, entry)))
.filter((entry): entry is EditorLockFile => Boolean(entry))
.sort((left, right) => scoreEditorLock(right, cwd) - scoreEditorLock(left, cwd))
return locks[0]
}
function readEditorLockFile(filePath: string): EditorLockFile | undefined {
const port = parsePort(path.basename(filePath, ".lock"))
if (!port) return
try {
const parsed = JSON.parse(readFileSync(filePath, "utf-8")) as unknown
if (!isRecord(parsed)) return
if (parsed.transport !== undefined && parsed.transport !== "ws") return
return {
port,
authToken: typeof parsed.authToken === "string" ? parsed.authToken : undefined,
transport: typeof parsed.transport === "string" ? parsed.transport : undefined,
workspaceFolders: Array.isArray(parsed.workspaceFolders)
? parsed.workspaceFolders.filter((value): value is string => typeof value === "string")
: [],
mtimeMs: statSync(filePath).mtimeMs,
}
} catch {
return
}
}
function scoreEditorLock(lock: EditorLockFile, cwd: string) {
const workspaceMatch = lock.workspaceFolders.some((folder) => pathContains(folder, cwd)) ? 1 : 0
return workspaceMatch * 1_000_000_000_000 + lock.mtimeMs
}
function pathContains(parent: string, child: string) {
const relative = path.relative(path.resolve(parent), path.resolve(child))
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
}
function openEditorSocket(connection: EditorConnection) {
if (!connection.authToken) return new WebSocket(connection.url)
return new WebSocket(connection.url, {
headers: {
"x-claude-code-ide-authorization": connection.authToken,
},
} as any)
}
function parseMessage(value: unknown) {
if (typeof value !== "string") return
try {
return JsonRpcMessageSchema.parse(JSON.parse(value))
} catch {
return
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

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" }),
}),
),
}

View file

@ -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()

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,
}),
),
}

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"]),

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.
@ -205,6 +202,19 @@ export const Info = Schema.Struct({
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
}),
),
tool_output: Schema.optional(
Schema.Struct({
max_lines: Schema.optional(PositiveInt).annotate({
description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)",
}),
max_bytes: Schema.optional(PositiveInt).annotate({
description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)",
}),
}),
).annotate({
description:
"Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.",
}),
compaction: Schema.optional(
Schema.Struct({
auto: Schema.optional(Schema.Boolean).annotate({
@ -253,26 +263,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.

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),

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" }),

View file

@ -1,12 +1,6 @@
import { lazy } from "@/util/lazy"
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdaptor } from "../types"
export type WorkspaceAdaptorEntry = {
type: string
name: string
description: string
}
import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types"
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),

View file

@ -1,13 +1,15 @@
import z from "zod"
import { Schema } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Worktree } from "@/worktree"
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const WorktreeConfig = z.object({
name: WorkspaceInfo.shape.name,
branch: WorkspaceInfo.shape.branch.unwrap(),
directory: WorkspaceInfo.shape.directory.unwrap(),
})
const WorktreeConfig = Schema.Struct({
name: WorkspaceInfo.fields.name,
branch: Schema.String,
directory: Schema.String,
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const WorktreeAdaptor: WorkspaceAdaptor = {
name: "Worktree",
@ -22,7 +24,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
}
},
async create(info) {
const config = WorktreeConfig.parse(info)
const config = WorktreeConfig.zod.parse(info)
await AppRuntime.runPromise(
Worktree.Service.use((svc) =>
svc.createFromInfo({
@ -34,11 +36,11 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
)
},
async remove(info) {
const config = WorktreeConfig.parse(info)
const config = WorktreeConfig.zod.parse(info)
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
},
target(info) {
const config = WorktreeConfig.parse(info)
const config = WorktreeConfig.zod.parse(info)
return {
type: "local",
directory: config.directory,

View file

@ -1,17 +1,28 @@
import z from "zod"
import { Schema } from "effect"
import { ProjectID } from "@/project/schema"
import { WorkspaceID } from "./schema"
import { zod } from "@/util/effect-zod"
import { type DeepMutable, withStatics } from "@/util/schema"
export const WorkspaceInfo = z.object({
id: WorkspaceID.zod,
type: z.string(),
name: z.string(),
branch: z.string().nullable(),
directory: z.string().nullable(),
extra: z.unknown().nullable(),
projectID: ProjectID.zod,
export const WorkspaceInfo = Schema.Struct({
id: WorkspaceID,
type: Schema.String,
name: Schema.String,
branch: Schema.NullOr(Schema.String),
directory: Schema.NullOr(Schema.String),
extra: Schema.NullOr(Schema.Unknown),
projectID: ProjectID,
})
export type WorkspaceInfo = z.infer<typeof WorkspaceInfo>
.annotate({ identifier: "Workspace" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
export const WorkspaceAdaptorEntry = Schema.Struct({
type: Schema.String,
name: Schema.String,
description: Schema.String,
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceAdaptorEntry = Schema.Schema.Type<typeof WorkspaceAdaptorEntry>
export type Target =
| {

View file

@ -1,4 +1,4 @@
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"
@ -15,7 +15,7 @@ import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/shared/util/slug"
import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors"
import { WorkspaceInfo } from "./types"
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse"
import { Session } from "@/session"
@ -25,36 +25,36 @@ import { errorData } from "@/util/error"
import { AppRuntime } from "@/effect/app-runtime"
import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { zod as effectZod, zodObject } from "@/util/effect-zod"
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
export const Info = WorkspaceInfoSchema
export type Info = WorkspaceInfo
export const ConnectionStatus = Schema.Struct({
workspaceID: WorkspaceID,
status: Schema.Literals(["connected", "connecting", "disconnected", "error"]),
})
export type Info = z.infer<typeof Info>
export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
export const ConnectionStatus = z.object({
workspaceID: WorkspaceID.zod,
status: z.enum(["connected", "connecting", "disconnected", "error"]),
})
export type ConnectionStatus = z.infer<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),
@ -73,15 +73,16 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
}
}
const CreateInput = z.object({
id: WorkspaceID.zod.optional(),
type: Info.shape.type,
branch: Info.shape.branch,
projectID: ProjectID.zod,
extra: Info.shape.extra,
})
export const CreateInput = Schema.Struct({
id: Schema.optional(WorkspaceID),
type: Info.fields.type,
branch: Info.fields.branch,
projectID: ProjectID,
extra: Info.fields.extra,
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
export const create = fn(CreateInput, async (input) => {
export const create = fn(CreateInput.zod, async (input) => {
const id = WorkspaceID.ascending(input.id)
const adaptor = await getAdaptor(input.projectID, input.type)
@ -137,12 +138,13 @@ export const create = fn(CreateInput, async (input) => {
return info
})
const SessionRestoreInput = z.object({
workspaceID: WorkspaceID.zod,
sessionID: SessionID.zod,
})
export const SessionRestoreInput = Schema.Struct({
workspaceID: WorkspaceID,
sessionID: SessionID,
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type SessionRestoreInput = Schema.Schema.Type<typeof SessionRestoreInput>
export const sessionRestore = fn(SessionRestoreInput, async (input) => {
export const sessionRestore = fn(SessionRestoreInput.zod, async (input) => {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,

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,
}),
),
}

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"]),
}),
),
}

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,
}),
),
}

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,
}),
),
}

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,
}),
),
}

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({

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,
}),
)

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,
}),
),
}

View file

@ -53,7 +53,7 @@ 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

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),
}),
),
}

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

View file

@ -111,12 +111,13 @@ export type ParsedStreamError =
| {
type: "api_error"
message: string
isRetryable: false
isRetryable: boolean
responseBody: string
}
export function parseStreamError(input: unknown): ParsedStreamError | undefined {
const body = json(input)
const raw = json(input)
const body = typeof raw?.message === "string" ? (json(raw.message) ?? raw) : raw
if (!body) return
const responseBody = JSON.stringify(body)
@ -150,6 +151,13 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined
isRetryable: false,
responseBody,
}
case "server_error":
return {
type: "api_error",
message: typeof body?.error?.message === "string" ? body?.error?.message : "Server error.",
isRetryable: true,
responseBody,
}
}
}

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"

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,
})

View file

@ -0,0 +1 @@
README.md

View file

@ -1,5 +1,5 @@
This is a temporary package used primarily for GitHub Copilot compatibility.
Avoid making changes to these files unless you only want to affect the Copilot provider.
These DO NOT apply for openai-compatible providers or majority of providers supporting completions/responses apis. THIS IS ONLY FOR GITHUB COPILOT!!!
Also, this should ONLY be used for the Copilot provider.
Avoid making edits to these files

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 {

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)
})

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({})),
}

View file

@ -3,6 +3,8 @@ import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod"
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import { zodObject } from "@/util/effect-zod"
import { Instance } from "@/project/instance"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
@ -24,15 +26,7 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspace adaptors",
content: {
"application/json": {
schema: resolver(
z.array(
z.object({
type: z.string(),
name: z.string(),
description: z.string(),
}),
),
),
schema: resolver(z.array(zodObject(WorkspaceAdaptorEntry))),
},
},
},
@ -53,7 +47,7 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspace created",
content: {
"application/json": {
schema: resolver(Workspace.Info),
schema: resolver(Workspace.Info.zod),
},
},
},
@ -62,12 +56,12 @@ export const WorkspaceRoutes = lazy(() =>
}),
validator(
"json",
Workspace.create.schema.omit({
Workspace.CreateInput.zodObject.omit({
projectID: true,
}),
),
async (c) => {
const body = c.req.valid("json")
const body = c.req.valid("json") as Omit<Workspace.CreateInput, "projectID">
const workspace = await Workspace.create({
projectID: Instance.project.id,
...body,
@ -86,7 +80,7 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspaces",
content: {
"application/json": {
schema: resolver(z.array(Workspace.Info)),
schema: resolver(z.array(Workspace.Info.zod)),
},
},
},
@ -107,7 +101,7 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspace status",
content: {
"application/json": {
schema: resolver(z.array(Workspace.ConnectionStatus)),
schema: resolver(z.array(zodObject(Workspace.ConnectionStatus))),
},
},
},
@ -129,7 +123,7 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspace removed",
content: {
"application/json": {
schema: resolver(Workspace.Info.optional()),
schema: resolver(Workspace.Info.zod.optional()),
},
},
},
@ -139,7 +133,7 @@ export const WorkspaceRoutes = lazy(() =>
validator(
"param",
z.object({
id: Workspace.Info.shape.id,
id: zodObject(Workspace.Info).shape.id,
}),
),
async (c) => {
@ -169,11 +163,11 @@ export const WorkspaceRoutes = lazy(() =>
...errors(400),
},
}),
validator("param", z.object({ id: Workspace.Info.shape.id })),
validator("json", Workspace.sessionRestore.schema.omit({ workspaceID: true })),
validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })),
validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })),
async (c) => {
const { id } = c.req.valid("param")
const body = c.req.valid("json")
const body = c.req.valid("json") as Omit<Workspace.SessionRestoreInput, "workspaceID">
log.info("session restore route requested", {
workspaceID: id,
sessionID: body.sessionID,

View file

@ -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) => {

View file

@ -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),
})),
)
},

View file

@ -14,6 +14,7 @@ import { PermissionApi, permissionHandlers } from "./permission"
import { ProjectApi, projectHandlers } from "./project"
import { ProviderApi, providerHandlers } from "./provider"
import { QuestionApi, questionHandlers } from "./question"
import { WorkspaceApi, workspaceHandlers } from "./workspace"
import { memoMap } from "@/effect/memo-map"
const Query = Schema.Struct({
@ -112,6 +113,7 @@ const PermissionSecured = PermissionApi.middleware(Authorization)
const ProjectSecured = ProjectApi.middleware(Authorization)
const ProviderSecured = ProviderApi.middleware(Authorization)
const ConfigSecured = ConfigApi.middleware(Authorization)
const WorkspaceSecured = WorkspaceApi.middleware(Authorization)
export const routes = Layer.mergeAll(
HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)),
@ -119,6 +121,7 @@ export const routes = Layer.mergeAll(
HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)),
HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)),
HttpApiBuilder.layer(WorkspaceSecured).pipe(Layer.provide(workspaceHandlers)),
).pipe(
Layer.provide(auth),
Layer.provide(normalize),

View file

@ -0,0 +1,82 @@
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import * as InstanceState from "@/effect/instance-state"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const root = "/experimental/workspace"
export const WorkspacePaths = {
adaptors: `${root}/adaptor`,
list: root,
status: `${root}/status`,
} as const
export const WorkspaceApi = HttpApi.make("workspace")
.add(
HttpApiGroup.make("workspace")
.add(
HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, {
success: Schema.Array(WorkspaceAdaptorEntry),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.adaptor.list",
summary: "List workspace adaptors",
description: "List all available workspace adaptors for the current project.",
}),
),
HttpApiEndpoint.get("list", WorkspacePaths.list, {
success: Schema.Array(Workspace.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.list",
summary: "List workspaces",
description: "List all workspaces.",
}),
),
HttpApiEndpoint.get("status", WorkspacePaths.status, {
success: Schema.Array(Workspace.ConnectionStatus),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.status",
summary: "Workspace status",
description: "Get connection status for workspaces in the current project.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "workspace",
description: "Experimental HttpApi workspace routes.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const workspaceHandlers = Layer.unwrap(
Effect.gen(function* () {
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
const ctx = yield* InstanceState.context
return yield* Effect.promise(() => listAdaptors(ctx.project.id))
})
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
return Workspace.list((yield* InstanceState.context).project)
})
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
return Workspace.status().filter((item) => ids.has(item.workspaceID))
})
return HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) =>
handlers.handle("adaptors", adaptors).handle("list", list).handle("status", status),
)
}),
)

View file

@ -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(

View file

@ -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,

View file

@ -16,6 +16,9 @@ import { GlobalRoutes } from "./routes/global"
import { WorkspaceRouterMiddleware } from "./workspace"
import { InstanceMiddleware } from "./routes/instance/middleware"
import { WorkspaceRoutes } from "./routes/control/workspace"
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
import { WorkspacePaths } from "./routes/instance/httpapi/workspace"
import { Context } from "effect"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
@ -54,16 +57,24 @@ function create(opts: { cors?: string[] }) {
}
}
const workspaceApp = new Hono()
const workspaceLegacyApp = new Hono()
.use(InstanceMiddleware())
.route("/experimental/workspace", WorkspaceRoutes())
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket))
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
const context = Context.empty() as Context.Context<unknown>
workspaceApp.get(WorkspacePaths.adaptors, (c) => handler(c.req.raw, context))
workspaceApp.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
workspaceApp.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
}
workspaceApp.route("/", workspaceLegacyApp)
return {
app: app
.route("/", ControlPlaneRoutes())
.route(
"/",
new Hono()
.use(InstanceMiddleware())
.route("/experimental/workspace", WorkspaceRoutes())
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)),
)
.route("/", workspaceApp)
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
.route("/", UIRoutes()),
runtime,

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,
}),
),
}

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,
}),
),
})
@ -619,12 +617,12 @@ export const Event = {
}),
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({

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"
@ -405,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),

View file

@ -273,17 +273,18 @@ export const Event = {
}),
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,
}),
),
}

View file

@ -28,16 +28,16 @@ export type Info = Schema.Schema.Type<typeof Info>
export const Event = {
Status: BusEvent.define(
"session.status",
z.object({
sessionID: SessionID.zod,
status: Info.zod,
Schema.Struct({
sessionID: SessionID,
status: Info,
}),
),
// deprecated
Idle: BusEvent.define(
"session.idle",
z.object({
sessionID: SessionID.zod,
Schema.Struct({
sessionID: SessionID,
}),
),
}

View file

@ -22,9 +22,9 @@ 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.zod),
Schema.Struct({
sessionID: SessionID,
todos: Schema.Array(Info),
}),
),
}

View file

@ -8,51 +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, Types } from "effect"
import { Schema as EffectSchema } from "effect"
import { zodObject } from "@/util/effect-zod"
import { isRecord } from "@/util/record"
import type { DeepMutable } from "@/util/schema"
// 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: string
type: Type
version: number
aggregate: string
effectSchema: Schema
effectProperties: BusSchema
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: Types.DeepMutable<EffectSchema.Schema.Type<Def["effectSchema"]>>
data: DeepMutable<EffectSchema.Schema.Type<Def["schema"]>>
}
export type Properties<Def extends Definition = Definition> = Types.DeepMutable<
EffectSchema.Schema.Type<Def["effectProperties"]>
>
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<unknown> | unknown
function asRecord(input: unknown) {
if (isRecord(input)) return input
throw new Error(`SyncEvent.convertEvent must return an object, got: ${JSON.stringify(input)}`)
}
let convertEvent: ConvertEvent
export function reset() {
frozen = false
@ -60,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
@ -76,7 +73,7 @@ export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; co
// 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
@ -96,21 +93,17 @@ export function define<
aggregate: Agg
schema: Schema
busSchema?: BusSchema
}): Definition<Schema, BusSchema> {
}): Definition<Type, Schema, BusSchema> {
if (frozen) {
throw new Error("Error defining sync event: sync system has been frozen")
}
const effectProperties = (input.busSchema ?? input.schema) as BusSchema
const def = {
type: input.type,
version: input.version,
aggregate: input.aggregate,
effectSchema: input.schema,
effectProperties,
schema: zodObject(input.schema),
properties: zodObject(effectProperties),
schema: input.schema,
properties: (input.busSchema ?? input.schema) as BusSchema,
}
versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0))
@ -167,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.properties }, asRecord(data))
})
void result.then(publish)
} else {
void ProjectBus.publish({ type: def.type, properties: def.properties }, asRecord(result))
void publish(result)
}
GlobalBus.emit("event", {
@ -292,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}`,

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),
}
}),
)

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 = {
@ -420,9 +416,8 @@ export const BashTool = Tool.define(
},
ctx: Tool.Context,
) {
const bytes = Truncate.MAX_BYTES
const lines = Truncate.MAX_LINES
const keep = bytes * 2
const limits = yield* trunc.limits()
const keep = limits.maxBytes * 2
let full = ""
let last = ""
const list: Chunk[] = []
@ -462,7 +457,7 @@ export const BashTool = Tool.define(
sink?.write(chunk)
} else {
full += chunk
if (Buffer.byteLength(full, "utf-8") > bytes) {
if (Buffer.byteLength(full, "utf-8") > limits.maxBytes) {
return trunc.write(full).pipe(
Effect.andThen((next) =>
Effect.sync(() => {
@ -529,7 +524,7 @@ export const BashTool = Tool.define(
}
if (aborted) meta.push("User aborted the command")
const raw = list.map((item) => item.text).join("")
const end = tail(raw, lines, bytes)
const end = tail(raw, limits.maxLines, limits.maxBytes)
if (end.cut) cut = true
if (!file && end.cut) {
file = yield* trunc.write(raw)
@ -570,7 +565,7 @@ export const BashTool = Tool.define(
})
return () =>
Effect.sync(() => {
Effect.gen(function* () {
const shell = Shell.acceptable()
const name = Shell.name(shell)
const chain =
@ -579,15 +574,17 @@ export const BashTool = Tool.define(
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })
const limits = yield* trunc.limits()
return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${os}", process.platform)
.replaceAll("${shell}", name)
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
.replaceAll("${maxLines}", String(limits.maxLines))
.replaceAll("${maxBytes}", String(limits.maxBytes)),
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)

View file

@ -1,10 +1,23 @@
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.Number.check(Schema.isGreaterThanOrEqualTo(1000))
.check(Schema.isLessThanOrEqualTo(50000))
.pipe(Schema.optional, Schema.withDecodingDefault(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,21 +25,7 @@ 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.",
),
}),
parameters: Parameters,
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* ctx.ask({
@ -45,7 +44,7 @@ export const CodeSearchTool = Tool.define(
McpExa.CodeArgs,
{
query: params.query,
tokensNum: params.tokensNum || 5000,
tokensNum: params.tokensNum,
},
"30 seconds",
)

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")

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

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 = {

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",

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,

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)

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,

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),
}
}),
)

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"
@ -126,9 +128,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* () {

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) {

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),
}
}),
)

View file

@ -1,39 +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"
// Parameters are kept inline rather than derived from Todo.Info because
// Tool.define requires z.ZodObject-typed parameters for execute() inference,
// and zodObject(Todo.Info) returns ZodObject<any> — reaching into .shape would
// erase field types. Tool schemas migrate to Effect Schema as a separate slice
// per specs/effect/schema.md.
const parameters = z.object({
todos: z
.array(
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"),
}),
)
.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",
@ -55,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>
}),
)

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 {

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