Compare commits

...

20 Commits

Author SHA1 Message Date
opencode-agent[bot] 9d61eef141 fix: keep read path validation model-visible 2026-06-07 13:09:27 +00:00
Luke Parker e82542b802 fix(desktop): disable hidden agent cycling (#31207) 2026-06-07 07:54:13 +00:00
Luke Parker 2006259a02 fix(desktop): style home session scrollbar (#31202) 2026-06-07 06:19:49 +00:00
opencode-agent[bot] 472b33113e chore: generate 2026-06-07 05:58:27 +00:00
Luke Parker 218147271c feat(desktop): open attachments in active project (#31192) 2026-06-07 05:57:07 +00:00
Luke Parker a29deb1ee9 fix(app): refresh directory MCP status (#31194) 2026-06-07 05:53:23 +00:00
opencode-agent[bot] f24049736e chore: update nix node_modules hashes 2026-06-07 05:41:48 +00:00
opencode-agent[bot] 21a644fef5 chore: generate 2026-06-07 05:25:52 +00:00
Dax 106f8e94d6 refactor(tui): extract standalone package (#31193) 2026-06-07 05:24:27 +00:00
opencode-agent[bot] 7a2c49e762 chore: generate 2026-06-07 05:22:22 +00:00
Luke Parker 9b4d5b0395 feat(desktop): make updates persistent and responsive (#31191) 2026-06-07 05:20:56 +00:00
Robert Douglass f20655bef4 fix(tui): sort connect providers alphabetically (#30891)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-07 00:17:38 -05:00
opencode-agent[bot] 31c099be43 chore: update nix node_modules hashes 2026-06-07 03:41:56 +00:00
opencode-agent[bot] 155e1f20d6 chore: generate 2026-06-07 03:28:52 +00:00
Dax fe0c4f8c74 refactor(server): canonicalize service API (#31049) 2026-06-07 03:27:28 +00:00
Kit Langton 53ff1b57c9 fix(core): preserve session failure causes 2026-06-06 23:26:21 -04:00
Kit Langton 1025540fcc test(core): cover managed output read permissions (#31166) 2026-06-07 01:58:41 +00:00
Kit Langton eb9a683b40 fix(core): harden unified tool runtime (#31171) 2026-06-06 21:55:34 -04:00
Kit Langton 48c26fa039 fix(test): release Windows search handles (#31172) 2026-06-07 01:41:54 +00:00
Kit Langton 10d1e04e9b fix(core): isolate image normalization (#31165) 2026-06-06 21:24:54 -04:00
483 changed files with 9330 additions and 6061 deletions
+46 -7
View File
@@ -94,8 +94,12 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"solid-js": "catalog:",
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
@@ -531,7 +535,7 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
@@ -553,7 +557,6 @@
"ai-gateway-provider": "3.1.2",
"bonjour-service": "1.3.0",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"cross-spawn": "catalog:",
"decimal.js": "10.5.0",
"diff": "catalog:",
@@ -787,6 +790,34 @@
"vite": "catalog:",
},
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/scheduled": "1.5.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"open": "10.1.2",
"opentui-spinner": "catalog:",
"remeda": "catalog:",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.16.2",
@@ -1776,6 +1807,8 @@
"@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],
"@opencode-ai/tui": ["@opencode-ai/tui@workspace:packages/tui"],
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
@@ -3336,7 +3369,7 @@
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@@ -5722,6 +5755,8 @@
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@opencode-ai/tui/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@@ -5730,8 +5765,6 @@
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
@@ -5920,6 +5953,8 @@
"dmg-license/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
@@ -5978,10 +6013,12 @@
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"happy-dom/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"html-minifier-terser/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
@@ -6388,6 +6425,8 @@
"@jsx-email/cli/vite/rollup": ["rollup@3.30.0", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA=="],
"@jsx-email/doiuse-email/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-5DhbOm/gs2mfjmNYdZHkr0ZopgSC2HcGN9/r1noGqhc=",
"aarch64-linux": "sha256-0dIKcqKmhrPhRpabnfM20wnqt/AkoCWDMgG9cQZ8P3o=",
"aarch64-darwin": "sha256-Sx3G63vORj69u2AOMDfpk6NU7fMgxsbYBh3jnGohNXI=",
"x86_64-darwin": "sha256-4g2ydNayqNqWlBeQt90rUI6bY5dMvXP4OmQ6QWaJbuI="
"x86_64-linux": "sha256-yZeq16sWAtsAHZO3pbsr90t3+8PlOueRym2J/Kgpj1U=",
"aarch64-linux": "sha256-m54/gm6R8MyQXBh68K8McAfZ9nLLK08nYQwMvAsTs8o=",
"aarch64-darwin": "sha256-pbtcF4ZCUqKO/SVLxv4wskl+O1LlT2RNsUV5d4s9+WY=",
"x86_64-darwin": "sha256-o3ucdbFNy2y9Hrb594Y9AtpRBUPglhqZLtTyiqMECR8="
}
}
+1
View File
@@ -6,6 +6,7 @@
"exports": {
".": "./src/index.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
-1
View File
@@ -70,7 +70,6 @@ function UiI18nBridge(props: ParentProps) {
declare global {
interface Window {
__OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
}
api?: {
@@ -1,13 +1,10 @@
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { Component, createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language"
import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { useMcpToggle } from "@/context/mcp"
const statusLabels = {
connected: "mcp.status.connected",
@@ -19,10 +16,7 @@ const statusLabels = {
export const DialogSelectMcp: Component = () => {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()
const queryClient = useQueryClient()
const queryOptions = useQueryOptions()
const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
@@ -30,21 +24,7 @@ export const DialogSelectMcp: Component = () => {
.sort((a, b) => a.name.localeCompare(b.name)),
)
const toggle = useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
return
}
if (status?.status === "needs_auth") {
await sdk.client.mcp.auth.authenticate({ name })
return
}
await sdk.client.mcp.connect({ name })
},
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
}))
const toggle = useMcpToggle()
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length)
@@ -0,0 +1,7 @@
import { ServerConnection } from "@/context/server"
import type { Platform } from "@/context/platform"
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
if (platform === "desktop" && ServerConnection.local(server)) return "native" as const
return "server" as const
}
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test"
import { directoryPickerKind } from "./directory-picker-policy"
const local = {
type: "sidecar",
variant: "base",
http: { url: "http://localhost:4096" },
} as const
const remote = {
type: "ssh",
host: "example.test",
http: { url: "http://localhost:4096" },
} as const
describe("directoryPickerKind", () => {
test("uses the native picker only for local desktop projects", () => {
expect(directoryPickerKind("desktop", local)).toBe("native")
expect(directoryPickerKind("desktop", remote)).toBe("server")
expect(directoryPickerKind("web", local)).toBe("server")
})
})
@@ -0,0 +1,29 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { DialogSelectDirectory } from "./dialog-select-directory"
import { directoryPickerKind } from "./directory-picker-policy"
type DirectoryPickerInput = {
server: ServerConnection.Any
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
}
export function useDirectoryPicker() {
const platform = usePlatform()
const dialog = useDialog()
return (input: DirectoryPickerInput) => {
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)
return
}
dialog.show(
() => <DialogSelectDirectory {...input} />,
() => input.onSelect(null),
)
}
}
+22 -13
View File
@@ -57,7 +57,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
import {
canNavigateHistoryAtCursor,
navigatePromptHistory,
@@ -73,6 +73,8 @@ import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder"
import { useDirectoryPicker } from "./directory-picker"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/server-sync"
@@ -140,6 +142,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const settings = useSettings()
const { params, tabs, view } = useSessionLayout()
let editorRef!: HTMLDivElement
@@ -468,7 +471,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const pick = () => {
if (server.isLocal()) {
fileInputRef?.click()
pickAttachmentFiles({
picker: platform.openAttachmentPickerDialog,
directory: () => sdk.directory,
fallback: () => fileInputRef?.click(),
onFile: addAttachment,
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
})
return
}
void import("@/components/dialog-select-file").then((module) =>
@@ -1094,7 +1108,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true
}
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef,
isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type),
@@ -1385,7 +1399,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
server.projects.touch(worktree)
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = async () => {
const addProject = () => {
const conn = server.current
if (!conn) return
const select = (result: string | string[] | null) => {
@@ -1393,15 +1407,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!directory) return
selectProject(directory)
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
return
}
void import("@/components/dialog-select-directory").then((x) => {
dialog.show(
() => <x.DialogSelectDirectory onSelect={select} server={conn} />,
() => select(null),
)
pickDirectory({
server: conn,
title: language.t("command.project.open"),
onSelect: select,
})
}
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { attachmentMime } from "./files"
import { attachmentMime, pickAttachmentFiles } from "./files"
import { pasteMode } from "./paste"
describe("attachmentMime", () => {
@@ -24,6 +24,70 @@ describe("attachmentMime", () => {
})
})
describe("pickAttachmentFiles", () => {
test("reads the current project directory for every native picker invocation", async () => {
const paths: string[] = []
const files: File[] = []
const file = new File(["hello"], "hello.txt", { type: "text/plain" })
let directory = "C:\\Projects\\LoremIpsum"
const picker = async (options?: { defaultPath?: string }, onFile?: (file: File) => Promise<unknown>) => {
paths.push(options?.defaultPath ?? "")
await onFile?.(file)
}
pickAttachmentFiles({
picker,
directory: () => directory,
fallback: () => undefined,
onFile: async (selected) => files.push(selected),
onError: () => undefined,
})
await Promise.resolve()
directory = "C:\\Projects\\DolorSit"
pickAttachmentFiles({
picker,
directory: () => directory,
fallback: () => undefined,
onFile: async (selected) => files.push(selected),
onError: () => undefined,
})
await Promise.resolve()
expect(files).toEqual([file, file])
expect(paths).toEqual(["C:\\Projects\\LoremIpsum", "C:\\Projects\\DolorSit"])
})
test("uses the browser file input when no native picker exists", async () => {
let fallback = 0
pickAttachmentFiles({
directory: () => "/projects/consectetur-adipiscing",
fallback: () => {
fallback += 1
},
onFile: async () => undefined,
onError: () => undefined,
})
expect(fallback).toBe(1)
})
test("reports native picker failures without rejecting", async () => {
const error = new Error("picker unavailable")
const errors: unknown[] = []
const handled = Promise.withResolvers<void>()
pickAttachmentFiles({
picker: async () => Promise.reject(error),
directory: () => "C:\\Projects\\LoremIpsum",
fallback: () => undefined,
onFile: async () => undefined,
onError: (cause) => {
errors.push(cause)
handled.resolve()
},
})
await handled.promise
expect(errors).toEqual([error])
})
})
describe("pasteMode", () => {
test("uses native paste for short single-line text", () => {
expect(pasteMode("hello world")).toBe("native")
@@ -2,6 +2,38 @@ import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-pick
export { ACCEPTED_FILE_TYPES }
type AttachmentPicker = (
options: {
defaultPath?: string
multiple?: boolean
accept?: string[]
},
onFile: (file: File) => Promise<unknown>,
) => Promise<void>
export function pickAttachmentFiles(input: {
picker?: AttachmentPicker
directory: () => string
fallback: () => void
onFile: (file: File) => Promise<unknown>
onError: (error: unknown) => void
}) {
if (!input.picker) {
input.fallback()
return
}
void input
.picker(
{
defaultPath: input.directory(),
multiple: true,
accept: ACCEPTED_FILE_TYPES,
},
input.onFile,
)
.catch(input.onError)
}
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([
["gif", "image/gif"],
@@ -1,5 +1,4 @@
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
@@ -8,13 +7,13 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action"
import {
monoDefault,
monoFontFamily,
@@ -91,9 +90,7 @@ export const SettingsGeneral: Component = () => {
const params = useParams()
const settings = useSettings()
const [store, setStore] = createStore({
checking: false,
})
const updater = useUpdaterAction()
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
@@ -123,58 +120,6 @@ export const SettingsGeneral: Component = () => {
}
const desktop = createMemo(() => platform.platform === "desktop")
const check = () => {
if (!platform.checkUpdate) return
setStore("checking", true)
void platform
.checkUpdate()
.then((result) => {
if (!result.updateAvailable) {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("settings.updates.toast.latest.title"),
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
})
return
}
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,
},
]
showToast({
persistent: true,
icon: "download",
title: language.t("toast.update.title"),
description: language.t("toast.update.description", { version: result.version ?? "" }),
actions,
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => setStore("checking", false))
}
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
@@ -723,19 +668,6 @@ export const SettingsGeneral: Component = () => {
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
@@ -752,10 +684,8 @@ export const SettingsGeneral: Component = () => {
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
{store.checking
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
{language.t(updater.action().label)}
</Button>
</SettingsRow>
</SettingsList>
@@ -1,5 +1,4 @@
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
@@ -8,13 +7,13 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
import {
monoDefault,
monoFontFamily,
@@ -93,9 +92,7 @@ export const SettingsGeneralV2: Component = () => {
const params = useParams()
const settings = useSettings()
const [store, setStore] = createStore({
checking: false,
})
const updater = useUpdaterAction()
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
@@ -125,58 +122,6 @@ export const SettingsGeneralV2: Component = () => {
}
const desktop = createMemo(() => platform.platform === "desktop")
const check = () => {
if (!platform.checkUpdate) return
setStore("checking", true)
void platform
.checkUpdate()
.then((result) => {
if (!result.updateAvailable) {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("settings.updates.toast.latest.title"),
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
})
return
}
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,
},
]
showToast({
persistent: true,
icon: "download",
title: language.t("toast.update.title"),
description: language.t("toast.update.description", { version: result.version ?? "" }),
actions,
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => setStore("checking", false))
}
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
@@ -740,19 +685,6 @@ export const SettingsGeneralV2: Component = () => {
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
@@ -769,10 +701,8 @@ export const SettingsGeneralV2: Component = () => {
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<ButtonV2 size="normal" variant="neutral" disabled={store.checking || !platform.checkUpdate} onClick={check}>
{store.checking
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
{language.t(updater.action().label)}
</ButtonV2>
</SettingsRowV2>
</SettingsListV2>
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
@@ -11,14 +10,12 @@ import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health"
import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
import { useMcpToggle } from "@/context/mcp"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -99,37 +96,6 @@ const useDefaultServerKey = (
}
}
const useMcpToggleMutation = () => {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()
const queryClient = useQueryClient()
const queryOptions = useQueryOptions()
return useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
return
}
if (status?.status === "needs_auth") {
await sdk.client.mcp.auth.authenticate({ name })
return
}
await sdk.client.mcp.connect({ name })
},
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
onError: (err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
},
}))
}
type ServerStatusState = {
servers: () => ServerStatusItem[]
defaultKey: () => ServerConnection.Key | undefined
@@ -311,7 +277,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogRun += 1
})
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
const toggleMcp = useMcpToggleMutation()
const toggleMcp = useMcpToggle()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test"
import { updaterAction } from "./updater-action"
describe("updaterAction", () => {
test("disables update actions when the platform has no updater", () => {
expect(updaterAction(undefined)).toEqual({ label: "settings.updates.action.checkNow" })
})
test("projects updater transitions into one settings action", () => {
expect(updaterAction({ status: "idle" })).toEqual({
label: "settings.updates.action.checkNow",
run: "check",
})
expect(updaterAction({ status: "checking" })).toEqual({ label: "settings.updates.action.checking" })
expect(updaterAction({ status: "downloading", version: "2.0.0" })).toEqual({
label: "settings.updates.action.downloading",
})
expect(updaterAction({ status: "ready", version: "2.0.0" })).toEqual({
label: "toast.update.action.installRestart",
run: "install",
})
expect(updaterAction({ status: "installing", version: "2.0.0" })).toEqual({
label: "settings.updates.action.installing",
})
})
})
@@ -0,0 +1,51 @@
import { createMemo } from "solid-js"
import type { UpdaterState } from "@/updater"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { showToast } from "@/utils/toast"
export function updaterAction(state: UpdaterState | undefined) {
if (!state) return { label: "settings.updates.action.checkNow" as const }
switch (state.status) {
case "checking":
return { label: "settings.updates.action.checking" as const }
case "downloading":
return { label: "settings.updates.action.downloading" as const }
case "ready":
return { label: "toast.update.action.installRestart" as const, run: "install" as const }
case "installing":
return { label: "settings.updates.action.installing" as const }
case "disabled":
return { label: "settings.updates.action.checkNow" as const }
default:
return { label: "settings.updates.action.checkNow" as const, run: "check" as const }
}
}
export function useUpdaterAction() {
const platform = usePlatform()
const language = useLanguage()
const action = createMemo(() => updaterAction(platform.updater?.state()))
return {
action,
async run() {
const run = action().run
if (run === "install") return platform.updater?.install()
if (run !== "check") return
const state = await platform.updater?.check()
if (state?.status === "up-to-date") {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("settings.updates.toast.latest.title"),
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
})
}
if (state?.status === "error") {
showToast({ title: language.t("common.requestFailed"), description: state.message })
}
},
}
}
@@ -609,6 +609,9 @@ export const createDirSyncContext = (
)
},
},
mcp: {
toggle: (name: string) => serverSync.mcp.toggle(directory, name),
},
absolute,
get directory() {
return current()[0].path.directory
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test"
import { toggleMcp } from "./mcp"
describe("toggleMcp", () => {
test("runs the status action before refreshing the owning query", async () => {
const calls: string[] = []
const input = (status: "connected" | "needs_auth" | "disabled") => ({
status,
connect: async () => {
calls.push("connect")
},
disconnect: async () => {
calls.push("disconnect")
},
authenticate: async () => {
calls.push("authenticate")
},
refresh: async () => {
calls.push("refresh")
},
})
await toggleMcp(input("connected"))
expect(calls).toEqual(["disconnect", "refresh"])
calls.length = 0
await toggleMcp(input("needs_auth"))
expect(calls).toEqual(["authenticate", "refresh"])
calls.length = 0
await toggleMcp(input("disabled"))
expect(calls).toEqual(["connect", "refresh"])
})
})
@@ -0,0 +1,18 @@
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
export async function toggleMcp(input: {
status: McpStatus["status"]
connect: () => Promise<void>
disconnect: () => Promise<void>
authenticate: () => Promise<void>
refresh: () => Promise<void>
}) {
await {
connected: input.disconnect,
needs_auth: input.authenticate,
disabled: input.connect,
failed: input.connect,
needs_client_registration: input.connect,
}[input.status]()
await input.refresh()
}
+19
View File
@@ -0,0 +1,19 @@
import { useMutation } from "@tanstack/solid-query"
import { useLanguage } from "@/context/language"
import { useSync } from "@/context/sync"
import { showToast } from "@/utils/toast"
export function useMcpToggle() {
const sync = useSync()
const language = useLanguage()
return useMutation(() => ({
mutationFn: sync.mcp.toggle,
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
}))
}
+27 -20
View File
@@ -4,12 +4,18 @@ import type { Accessor } from "solid-js"
import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server"
import type { WslServersPlatform } from "../wsl/types"
import type { UpdaterPlatform } from "../updater"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
type OpenAttachmentPickerOptions = {
title?: string
multiple?: boolean
accept?: string[]
extensions?: string[]
defaultPath?: string
}
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string }
type PlatformName = "web" | "desktop"
type DesktopOS = "macos" | "windows" | "linux"
@@ -21,13 +27,7 @@ export type FatalRendererErrorLog = {
os?: DesktopOS
}
export type Platform = {
/** Platform discriminator */
platform: PlatformName
/** Desktop OS (Tauri only) */
os?: DesktopOS
type PlatformBase = {
/** App version */
version?: string
@@ -49,23 +49,20 @@ export type Platform = {
/** Send a system notification (optional deep link) */
notify(title: string, description?: string, href?: string): Promise<void>
/** Open directory picker dialog (native on Tauri, server-backed on web) */
openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
/** Open a native attachment picker and read selected files sequentially (desktop only) */
openAttachmentPickerDialog?(
opts: OpenAttachmentPickerOptions,
onFile: (file: File) => Promise<unknown>,
): Promise<void>
/** Open native file picker dialog (Tauri only) */
openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
/** Save file picker dialog (Tauri only) */
/** Open a native save file picker dialog (desktop only) */
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
/** Check for a downloadable desktop update */
checkUpdate?(): Promise<UpdateInfo>
/** Install the downloaded update using the platform restart flow */
updateAndRestart?(): Promise<void>
/** Application-global desktop updater */
updater?: UpdaterPlatform
/** Fetch override */
fetch?: typeof fetch
@@ -113,6 +110,16 @@ export type Platform = {
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
}
export type Platform = PlatformBase &
(
| { platform: "web"; os?: never }
| {
platform: "desktop"
os?: DesktopOS
openDirectoryPickerDialog(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
}
)
export type DisplayBackend = "auto" | "wayland"
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
+23
View File
@@ -36,6 +36,7 @@ import { ServerConnection, useServer } from "./server"
import { retry } from "@opencode-ai/core/util/retry"
import type { ServerScope } from "@/utils/server-scope"
import { persisted } from "@/utils/persist"
import { toggleMcp } from "./global-sync/mcp"
type GlobalStore = {
ready: boolean
@@ -481,6 +482,28 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
todo: {
set: setSessionTodo,
},
mcp: {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
const sdk = sdkFor(key)
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
await toggleMcp({
status,
connect: async () => {
await sdk.mcp.connect({ name })
},
disconnect: async () => {
await sdk.mcp.disconnect({ name })
},
authenticate: async () => {
await sdk.mcp.auth.authenticate({ name })
},
refresh: async () => {
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
},
})
},
},
}
}
-12
View File
@@ -35,9 +35,6 @@ export interface Settings {
showCustomAgents: boolean
newLayoutDesigns?: boolean
}
updates: {
startup: boolean
}
appearance: {
fontSize: number
mono: string
@@ -122,9 +119,6 @@ const defaultSettings: Settings = {
showSessionProgressBar: true,
showCustomAgents: false,
},
updates: {
startup: true,
},
appearance: {
fontSize: 14,
mono: "",
@@ -250,12 +244,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "newLayoutDesigns", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
setStartup(value: boolean) {
setStore("updates", "startup", value)
},
},
appearance: {
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
setFontSize(value: number) {
+2
View File
@@ -866,6 +866,8 @@ export const dict = {
"settings.updates.row.check.description": "Manually check for updates and install if available",
"settings.updates.action.checkNow": "Check now",
"settings.updates.action.checking": "Checking...",
"settings.updates.action.downloading": "Downloading...",
"settings.updates.action.installing": "Installing...",
"settings.updates.toast.latest.title": "You're up to date",
"settings.updates.toast.latest.description": "You're running the latest version of OpenCode.",
"sound.option.none": "None",
+1
View File
@@ -4,6 +4,7 @@ export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { useWslServers } from "./wsl/context"
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
export { type UpdaterPlatform, type UpdaterState } from "./updater"
export {
type WslDistroProbe,
type WslInstalledDistro,
+24 -26
View File
@@ -225,8 +225,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
const formattedError = () => formatError(props.error, language.t)
let recordedFatalError: Promise<void> | undefined
const [store, setStore] = createStore({
checking: false,
version: undefined as string | undefined,
actionError: undefined as string | undefined,
})
@@ -247,32 +245,25 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
})
async function checkForUpdates() {
if (!platform.checkUpdate) return
setStore("checking", true)
await platform
.checkUpdate()
.then((result) => {
setStore("actionError", undefined)
if (result.updateAvailable && result.version) setStore("version", result.version)
})
.catch((err) => {
setStore("actionError", formatError(err, language.t))
})
.finally(() => {
setStore("checking", false)
})
const state = await platform.updater?.check()
setStore("actionError", state?.status === "error" ? state.message : undefined)
}
async function installUpdate() {
if (!platform.updateAndRestart) return
await platform
.updateAndRestart()
await platform.updater
?.install()
.then(() => setStore("actionError", undefined))
.catch((err) => {
setStore("actionError", formatError(err, language.t))
})
}
const updateVersion = () => {
const state = platform.updater?.state()
if (state?.status !== "ready") return
return state.version
}
async function exportDebugLogs() {
const exportLogs = platform.exportDebugLogs
if (!exportLogs) return
@@ -327,20 +318,27 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
)
}}
</Show>
<Show when={platform.checkUpdate}>
<Show when={platform.updater}>
<Show
when={store.version}
when={updateVersion()}
fallback={
<Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
{store.checking
<Button
size="large"
variant="ghost"
onClick={checkForUpdates}
disabled={["checking", "downloading", "installing"].includes(platform.updater?.state().status ?? "")}
>
{platform.updater?.state().status === "checking"
? language.t("error.page.action.checking")
: language.t("error.page.action.checkUpdates")}
</Button>
}
>
<Button size="large" onClick={installUpdate}>
{language.t("error.page.action.updateTo", { version: store.version ?? "" })}
</Button>
{(version) => (
<Button size="large" onClick={installUpdate}>
{language.t("error.page.action.updateTo", { version: version() })}
</Button>
)}
</Show>
</Show>
</div>
+20 -30
View File
@@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
@@ -18,7 +19,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { usePlatform } from "@/context/platform"
import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { useDirectoryPicker } from "@/components/directory-picker"
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { ServerConnection, useServer } from "@/context/server"
@@ -125,6 +126,7 @@ function HomeDesign() {
const sync = useServerSync()
const layout = useLayout()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const navigate = useNavigate()
const server = useServer()
@@ -314,26 +316,19 @@ function HomeDesign() {
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
}
async function chooseProject(conn: ServerConnection.Any) {
function chooseProject(conn: ServerConnection.Any) {
function resolve(result: string | string[] | null) {
addProjects(conn, homeProjectDirectories(result))
}
const server = global.createServerCtx(conn)
if (platform.openDirectoryPickerDialog && server.isLocal) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
})
resolve(result)
return
}
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
() => resolve(null),
)
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: resolve,
})
}
function openSettings() {
@@ -390,7 +385,7 @@ function HomeDesign() {
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
<ScrollView class="mt-3 min-h-0 flex-1">
<div class="pt-3 flex flex-col gap-6">
<Show
when={!sessionLoad.isLoading}
@@ -432,7 +427,7 @@ function HomeDesign() {
</Show>
</Show>
</div>
</div>
</ScrollView>
</section>
</div>
</div>
@@ -1101,6 +1096,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
function LegacyHome() {
const sync = useServerSync()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const navigate = useNavigate()
const global = useGlobal()
@@ -1128,7 +1124,7 @@ function LegacyHome() {
navigate(`/${base64Encode(directory)}`)
}
async function chooseProject() {
function chooseProject() {
const s = server.current
if (!s) return
@@ -1142,18 +1138,12 @@ function LegacyHome() {
}
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
})
resolve(result)
} else {
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={s} />,
() => resolve(null),
)
}
pickDirectory({
server: s,
title: language.t("command.project.open"),
multiple: true,
onSelect: resolve,
})
}
return (
+14 -37
View File
@@ -14,7 +14,6 @@ import {
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useQuery } from "@tanstack/solid-query"
import { useLayout, LocalProject } from "@/context/layout"
import { useServerSync } from "@/context/server-sync"
import { Persist, persisted } from "@/utils/persist"
@@ -65,6 +64,7 @@ import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useDirectoryPicker } from "@/components/directory-picker"
import { ServerConnection, useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language"
import { pathKey } from "@/utils/path-key"
@@ -90,7 +90,6 @@ import {
} from "./layout/sidebar-workspace"
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
import { runUpdateAndRestart } from "./layout/update"
export default function Layout(props: ParentProps) {
const serverSDK = useServerSDK()
@@ -119,6 +118,7 @@ export default function Layout(props: ParentProps) {
const layout = useLayout()
const layoutReady = createMemo(() => layout.ready())
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const settings = useSettings()
const server = useServer()
const notification = useNotification()
@@ -168,28 +168,15 @@ export default function Layout(props: ParentProps) {
peeked: false,
})
const [update, setUpdate] = createStore({
installing: false,
})
const updateQuery = useQuery(() => ({
queryKey: ["desktop", "update"] as const,
enabled: () =>
!!platform.checkUpdate && !!platform.updateAndRestart && settings.ready() && settings.updates.startup(),
queryFn: () => platform.checkUpdate?.() ?? Promise.resolve({ updateAvailable: false, version: undefined }),
refetchInterval: (query) => (query.state.data?.updateAvailable ? false : 10 * 60 * 1000),
}))
const updateVersion = () => {
if (!settings.ready()) return
if (!settings.updates.startup()) return
if (!updateQuery.data?.updateAvailable) return
return updateQuery.data.version ?? ""
}
const installUpdate = () => {
runUpdateAndRestart(platform.updateAndRestart, (installing) => setUpdate("installing", installing))
const state = platform.updater?.state()
if (state?.status !== "ready") return
return state.version
}
const installUpdate = () => void platform.updater?.install()
const titlebarUpdate: TitlebarUpdate = {
version: updateVersion,
installing: () => update.installing,
installing: () => platform.updater?.state().status === "installing",
install: installUpdate,
}
@@ -1472,7 +1459,7 @@ export default function Layout(props: ParentProps) {
})
}
async function chooseProject() {
function chooseProject() {
const conn = server.current
if (!conn) return
function resolve(result: string | string[] | null) {
@@ -1486,22 +1473,12 @@ export default function Layout(props: ParentProps) {
}
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
})
resolve(result)
} else {
const run = ++dialogRun
void import("@/components/dialog-select-directory").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
() => resolve(null),
)
})
}
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: resolve,
})
}
const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
@@ -1,19 +0,0 @@
import { describe, expect, test } from "bun:test"
import { runUpdateAndRestart } from "./update"
describe("runUpdateAndRestart", () => {
test("clears the installing state when restart resolves without exiting", async () => {
const states: boolean[] = []
await new Promise<void>((resolve) => {
runUpdateAndRestart(
async () => {},
(installing) => {
states.push(installing)
if (states.length === 2) resolve()
},
)
})
expect(states).toEqual([true, false])
})
})
-10
View File
@@ -1,10 +0,0 @@
export function runUpdateAndRestart(
updateAndRestart: (() => Promise<void>) | undefined,
setInstalling: (installing: boolean) => void,
) {
if (!updateAndRestart) return
setInstalling(true)
void updateAndRestart()
.catch(() => undefined)
.finally(() => setInstalling(false))
}
@@ -547,6 +547,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
disabled: desktopV2() && !settings.general.showCustomAgents(),
onSelect: () => local.agent.move(1),
}),
agentCommand({
@@ -554,6 +555,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
disabled: desktopV2() && !settings.general.showCustomAgents(),
onSelect: () => local.agent.move(-1),
}),
]
+17
View File
@@ -0,0 +1,17 @@
import type { Accessor } from "solid-js"
export type UpdaterState =
| { status: "disabled" }
| { status: "idle" }
| { status: "checking" }
| { status: "downloading"; version: string; percent?: number }
| { status: "ready"; version: string }
| { status: "up-to-date" }
| { status: "installing"; version: string }
| { status: "error"; message: string }
export type UpdaterPlatform = {
state: Accessor<UpdaterState>
check(): Promise<UpdaterState>
install(): Promise<void>
}
+1
View File
@@ -0,0 +1 @@
preload = ["@opentui/solid/preload"]
+5 -1
View File
@@ -20,8 +20,12 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:"
"effect": "catalog:",
"solid-js": "catalog:"
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
+19 -1
View File
@@ -1,8 +1,12 @@
#!/usr/bin/env bun
import { $ } from "bun"
import fs from "fs"
import { rm } from "fs/promises"
import path from "path"
import { Script } from "@opencode-ai/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import pkg from "../package.json"
import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..")
@@ -13,7 +17,9 @@ await rm("dist", { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const sourcemapsFlag = process.argv.includes("--sourcemaps")
const plugin = createSolidTransformPlugin()
const allTargets: {
os: string
@@ -43,6 +49,12 @@ const targets = singleFlag
})
: allTargets
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker)
for (const item of targets) {
const target = [
binary,
@@ -56,8 +68,9 @@ for (const item of targets) {
const name = target.replace(binary, "cli")
console.log(`building ${name}`)
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
entrypoints: ["./src/index.ts", parserWorker],
tsconfig: "./tsconfig.json",
plugins: [plugin],
external: ["node-gyp"],
format: "esm",
minify: true,
@@ -79,6 +92,11 @@ for (const item of targets) {
OPENCODE_MODELS_DEV: modelsData,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
OTUI_TREE_SITTER_WORKER_PATH:
(item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
path.relative(dir, parserWorker).replaceAll("\\", "/") +
'"',
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
},
})
@@ -0,0 +1,13 @@
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Effect } from "effect"
import { Daemon } from "../../services/daemon"
export default Runtime.handler(Commands, () =>
Effect.gen(function* () {
const daemon = yield* Daemon.Service
const transport = yield* daemon.transport()
const { runTui } = yield* Effect.promise(() => import("../../tui"))
yield* runTui(transport)
}),
)
@@ -1,4 +1,5 @@
import { NodeHttpServer } from "@effect/platform-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Context, Layer, Option } from "effect"
import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
@@ -34,6 +35,7 @@ function bind(hostname: string, port: number, password: string) {
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
Layer.provide(PermissionSaved.defaultLayer),
),
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
}
+10 -10
View File
@@ -60,19 +60,19 @@ export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, op
}
function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
const spec: Command.Command.Any = Object.keys(node.commands).length
? (node.spec as Command.Command<string, unknown>).pipe(
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
const handler = handlers.find((handler) => handler.spec === node.spec)
const spec = handler
? node.spec.pipe(
Command.withHandler((input) =>
Effect.gen(function* () {
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
}),
),
)
: node.spec
const handler = handlers.find((handler) => handler.spec === node.spec)
if (!handler) return spec as ProvidedCommand
if (!Object.keys(node.commands).length) return spec as ProvidedCommand
return spec.pipe(
Command.withHandler((input) =>
Effect.gen(function* () {
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
}),
),
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
) as ProvidedCommand
}
+1
View File
@@ -8,6 +8,7 @@ import { Runtime } from "./framework/runtime"
import { Daemon } from "./services/daemon"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
debug: {
agents: () => import("./commands/handlers/debug/agents"),
},
+22 -10
View File
@@ -5,10 +5,12 @@ import { ServerAuth } from "@opencode-ai/server/auth"
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
import { HttpServer } from "effect/unstable/http"
import { randomBytes, randomUUID } from "crypto"
import { spawn } from "node:child_process"
import path from "path"
export interface Interface {
readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
readonly transport: () => Effect.Effect<{ url: string; headers: RequestInit["headers"] }, unknown>
readonly start: () => Effect.Effect<string, Error>
readonly status: () => Effect.Effect<string | undefined>
readonly stop: () => Effect.Effect<void, unknown>
@@ -108,16 +110,21 @@ export const layer = Layer.effect(
const start = Effect.fn("cli.daemon.start")(function* () {
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
if (found?.version === InstallationVersion) return found.url
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
if (found?.version === InstallationVersion && compiled) return found.url
if (found) yield* stopProcess(found).pipe(Effect.ignore)
yield* Effect.sync(() => {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
}).unref()
const entrypoint = compiled ? undefined : process.argv[1]
if (!compiled && entrypoint === undefined)
return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
yield* Effect.try({
try: () => {
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
detached: true,
stdio: "ignore",
}).unref()
},
catch: (cause) => new Error("Failed to start server", { cause }),
})
return yield* compatible().pipe(
@@ -127,8 +134,13 @@ export const layer = Layer.effect(
)
})
const transport = Effect.fn("cli.daemon.transport")(function* () {
return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
})
const client = Effect.fn("cli.daemon.client")(function* () {
return yield* createClient(yield* start())
const connection = yield* transport()
return createOpencodeClient({ baseUrl: connection.url, headers: connection.headers })
})
const status = Effect.fn("cli.daemon.status")(function* () {
@@ -173,7 +185,7 @@ export const layer = Layer.effect(
)
})
return Service.of({ client, start, status, stop, password, register })
return Service.of({ client, transport, start, status, stop, password, register })
}),
)
+36
View File
@@ -0,0 +1,36 @@
import { run } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config"
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
const config = TuiConfig.resolve({}, { terminalSuspend: false })
return run({
...transport,
args: {},
config,
fetch: gracefulFetch,
pluginHost: {
async start() {},
async dispose() {},
},
}).pipe(Effect.provide(Global.defaultLayer))
}
const legacyDefaults: Record<string, unknown> = {
"/config/providers": { providers: [], default: {} },
"/provider": { all: [], default: {}, connected: [] },
"/agent": [],
"/config": {},
}
const gracefulFetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await fetch(input, init)
if (response.status !== 404) return response
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
if (fallback === undefined) return response
return Response.json(fallback)
},
{ preconnect: fetch.preconnect },
)
+2
View File
@@ -2,6 +2,8 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
+2 -5
View File
@@ -63,7 +63,7 @@ export type Editor = {
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
readonly update: State.Interface<Data, Editor>["update"]
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly default: () => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
@@ -113,10 +113,7 @@ export const layer = Layer.effect(
return Service.of({
transform: state.transform,
update: Effect.fn("AgentV2.update")(function* (update) {
const transform = yield* state.transform()
yield* transform(update)
}),
update: state.update,
get: Effect.fn("AgentV2.get")(function* (id) {
return state.get().agents.get(id)
}),
+1 -1
View File
@@ -203,7 +203,7 @@ export const layer = Layer.effect(
event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
),
Stream.runForEach((event) =>
state.update((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
),
Effect.forkIn(scope, { startImmediately: true }),
)
+2
View File
@@ -27,6 +27,7 @@ export type Editor = {
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
@@ -54,6 +55,7 @@ export const layer = Layer.effect(
})
return Service.of({
update: state.update,
transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) {
return state.get().commands.get(name)
@@ -6,6 +6,7 @@ import { Git } from "../git"
import { Location } from "../location"
import { ProjectV2 } from "../project"
import { SessionV2 } from "../session"
import { SessionExecution } from "../session/execution"
import { SessionEvent } from "../session/event"
import { SessionSchema } from "../session/schema"
import { AbsolutePath, RelativePath } from "../schema"
@@ -124,5 +125,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(SessionExecution.noopLayer),
Layer.provide(SessionV2.defaultLayer),
)
+14 -5
View File
@@ -35,6 +35,13 @@ export class BinaryFileError extends Error {
}
}
export class PathValidationError extends Error {
constructor(message: string) {
super(message)
this.name = "PathValidationError"
}
}
const BINARY_EXTENSIONS = new Set([
".zip",
".tar",
@@ -265,21 +272,23 @@ export const layer = Layer.effect(
ToolOutputStore.MANAGED_DIRECTORY,
)
if (input && path.isAbsolute(input)) {
if (reference) return yield* Effect.die(new Error("Absolute paths cannot use a project reference"))
if (reference)
return yield* Effect.die(new PathValidationError("Absolute paths cannot use a project reference"))
if (path.dirname(input) !== managed || !path.basename(input).startsWith("tool_"))
return yield* Effect.die(new Error("Absolute path is not managed tool output"))
return yield* Effect.die(new PathValidationError("Absolute path is not managed tool output"))
const real = yield* fs.realPath(input).pipe(Effect.orDie)
const managedRoot = yield* fs.realPath(managed).pipe(Effect.orDie)
if (path.dirname(real) !== managedRoot || !path.basename(real).startsWith("tool_"))
return yield* Effect.die(new Error("Path escapes managed tool output"))
return yield* Effect.die(new PathValidationError("Path escapes managed tool output"))
return { absolute: input, real, directory: managed, root: managedRoot }
}
const selected = yield* select(reference)
const absolute = path.resolve(selected.directory, input ?? ".")
if (!FSUtil.contains(selected.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
return yield* Effect.die(new PathValidationError("Path escapes the location"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
if (!FSUtil.contains(selected.root, real))
return yield* Effect.die(new PathValidationError("Path escapes the location"))
return { absolute, real, ...selected }
})
const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) {
+4
View File
@@ -237,6 +237,10 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
// and does not await the scan; the native background scan starts as soon as
// the picker exists. The `wait` gate dedupes concurrent creation.
const acquire = Effect.fn("Search.acquire")(function* (cwd: string) {
// The opencode test runtime owns an isolated XDG tree that Windows must
// remove before process exit, so use ripgrep instead of native FFF there.
if (process.env.OPENCODE_TEST_HOME) return undefined
const available = yield* fffSync("check availability", () => Fff.available()).pipe(
Effect.catch((error) => {
log.warn("fff availability check failed", { error })
+72
View File
@@ -0,0 +1,72 @@
export * as Image from "./image"
import { Context, Effect, Layer, Schema } from "effect"
import { Config } from "./config"
import { FileSystem } from "./filesystem"
export class ResizerUnavailableError extends Schema.TaggedErrorClass<ResizerUnavailableError>()(
"Image.ResizerUnavailableError",
{},
) {}
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("Image.DecodeError", {
resource: Schema.String,
}) {
override get message() {
return `Image could not be decoded: ${this.resource}`
}
}
export class SizeError extends Schema.TaggedErrorClass<SizeError>()("Image.SizeError", {
resource: Schema.String,
width: Schema.Number,
height: Schema.Number,
bytes: Schema.Number,
maxWidth: Schema.Number,
maxHeight: Schema.Number,
maxBytes: Schema.Number,
}) {
override get message() {
return `Image ${this.resource} is ${this.width}x${this.height} with base64 size ${this.bytes}, exceeding configured limits ${this.maxWidth}x${this.maxHeight}/${this.maxBytes} bytes`
}
}
export interface Interface {
readonly normalize: (
resource: string,
content: FileSystem.BinaryContent,
) => Effect.Effect<FileSystem.BinaryContent, ResizerUnavailableError | DecodeError | SizeError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const loadAdapter = yield* Effect.cached(
Effect.tryPromise({
try: () => import("./image/photon"),
catch: () => new ResizerUnavailableError(),
}).pipe(Effect.flatMap((adapter) => adapter.make)),
)
const normalize = Effect.fn("Image.normalize")(function* (resource: string, content: FileSystem.BinaryContent) {
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
),
)
const normalize = yield* loadAdapter
return yield* normalize(resource, content, {
autoResize: image.auto_resize ?? true,
maxWidth: image.max_width ?? 2_000,
maxHeight: image.max_height ?? 2_000,
maxBase64Bytes: image.max_base64_bytes ?? 5 * 1024 * 1024,
})
})
return Service.of({ normalize })
}),
)
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
+94
View File
@@ -0,0 +1,94 @@
// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm.
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
import { Effect } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { FileSystem } from "../filesystem"
import { DecodeError, ResizerUnavailableError, SizeError } from "../image"
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
export const make = Effect.gen(function* () {
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
const loadPhoton = yield* Effect.cached(
Effect.tryPromise({
try: () => import("@silvia-odwyer/photon-node"),
catch: () => new ResizerUnavailableError(),
}),
)
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
content: FileSystem.BinaryContent,
limits: {
readonly autoResize: boolean
readonly maxWidth: number
readonly maxHeight: number
readonly maxBase64Bytes: number
},
) {
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(content.content, "base64")),
catch: () => new DecodeError({ resource }),
})
try {
const width = decoded.get_width()
const height = decoded.get_height()
const bytes = Buffer.byteLength(content.content, "utf-8")
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes) return content
if (!limits.autoResize)
return yield* new SizeError({
resource,
width,
height,
bytes,
maxWidth: limits.maxWidth,
maxHeight: limits.maxHeight,
maxBytes: limits.maxBase64Bytes,
})
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
const previous = acc.at(-1) ?? {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
const next =
acc.length === 0
? previous
: {
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
}
return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next]
}, [])
for (const size of sizes) {
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
try {
const encoders: Array<readonly [mime: string, encode: () => Uint8Array]> = [
["image/png", () => resized.get_bytes()],
...JPEG_QUALITIES.map((quality) => ["image/jpeg", () => resized.get_bytes_jpeg(quality)] as const),
]
for (const [mime, encode] of encoders) {
const candidate = Buffer.from(encode()).toString("base64")
if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes)
return new FileSystem.BinaryContent({ type: "binary", content: candidate, encoding: "base64", mime })
}
} finally {
resized.free()
}
}
return yield* new SizeError({
resource,
width,
height,
bytes,
maxWidth: limits.maxWidth,
maxHeight: limits.maxHeight,
maxBytes: limits.maxBase64Bytes,
})
} finally {
decoded.free()
}
})
})
+1 -1
View File
@@ -70,7 +70,7 @@ export const layer = Layer.effectDiscard(
return files.filter((file): file is File => file !== undefined)
})
yield* registry.contribute({
yield* registry.register({
key,
load: observe().pipe(
Effect.map((files) =>
+15 -3
View File
@@ -28,6 +28,7 @@ import { Pty } from "./pty"
import { SkillV2 } from "./skill"
import { SkillGuidance } from "./skill/guidance"
import { BuiltInTools } from "./tool/builtins"
import { Image } from "./image"
import { ToolRegistry } from "./tool/registry"
import { ApplicationTools } from "./tool/application-tools"
import { ToolOutputStore } from "./tool-output-store"
@@ -71,6 +72,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(base),
)
const services = Layer.mergeAll(base, resources, permissionsAndTools)
const image = Image.layer.pipe(Layer.provide(services))
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
@@ -83,6 +85,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(resources),
Layer.provide(todos),
Layer.provide(questions),
Layer.provide(image),
)
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
const runner = SessionRunnerLLM.defaultLayer.pipe(
@@ -90,9 +93,18 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(model),
Layer.provide(skillGuidance),
)
return Layer.mergeAll(services, mutation, searches, resources, todos, questions, model, runner, builtInTools).pipe(
Layer.fresh,
)
return Layer.mergeAll(
services,
image,
mutation,
searches,
resources,
todos,
questions,
model,
runner,
builtInTools,
).pipe(Layer.fresh)
},
idleTimeToLive: "60 minutes",
dependencies: [
+1 -1
View File
@@ -3,7 +3,7 @@ export { Agent } from "./agent"
export { Model } from "./model"
export { OpenCode } from "./opencode"
export { Session } from "./session"
export * as Tool from "./tool"
export { Tool } from "./tool"
export { Location } from "./location"
export { Prompt } from "../session/prompt"
export { AbsolutePath } from "../schema"
+1 -1
View File
@@ -4,7 +4,7 @@ import { Effect, Scope } from "effect"
import type { AnyTool, RegistrationError } from "../tool/tool"
export { Failure, RegistrationError, make } from "../tool/tool"
export type { AnyTool, Content, Context } from "../tool/tool"
export type { AnyTool, Content, Context, Definition } from "../tool/tool"
export interface Interface {
/**
+8 -18
View File
@@ -25,6 +25,7 @@ import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution"
import { logFailure } from "./session/logging"
import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input"
@@ -177,10 +178,7 @@ export const layer = Layer.effect(
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to wake Session").pipe(
Effect.annotateLogs("sessionID", admitted.sessionID),
Effect.annotateLogs("cause", cause),
),
: logFailure("Failed to wake Session", admitted.sessionID, cause),
),
Effect.ignore,
Effect.forkIn(scope, { startImmediately: true }),
@@ -427,20 +425,12 @@ export const layer = Layer.effect(
}),
)
const DefaultDatabase = Database.defaultLayer
const DefaultEvents = EventV2.layer.pipe(Layer.provide(DefaultDatabase))
const DefaultProjector = SessionProjector.layer.pipe(Layer.provide(DefaultEvents), Layer.provide(DefaultDatabase))
const DefaultStore = SessionStore.layer.pipe(Layer.provide(DefaultDatabase))
export const defaultLayer = layer.pipe(
Layer.provide(
Layer.mergeAll(
DefaultDatabase,
DefaultEvents,
DefaultProjector,
DefaultStore,
SessionExecution.noopLayer,
ProjectV2.defaultLayer,
),
),
Layer.provide(SessionExecution.noopLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.orDie,
)
+4 -5
View File
@@ -5,6 +5,7 @@ import { SessionRunner } from "../runner"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionExecution } from "../execution"
import { logFailure } from "../logging"
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
export const layer = Layer.effect(
@@ -20,11 +21,7 @@ export const layer = Layer.effect(
Effect.provide(locations.get(session.location)),
)
}),
onFailure: (sessionID, cause) =>
Effect.logError("Failed to drain Session").pipe(
Effect.annotateLogs("sessionID", sessionID),
Effect.annotateLogs("cause", cause),
),
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
})
return SessionExecution.Service.of({
@@ -34,3 +31,5 @@ export const layer = Layer.effect(
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))
+8
View File
@@ -0,0 +1,8 @@
import { Cause, Effect } from "effect"
import { SessionSchema } from "./schema"
export const logFailure = (
message: "Failed to drain Session" | "Failed to wake Session",
sessionID: SessionSchema.ID,
cause: Cause.Cause<unknown>,
) => Effect.logError(message, cause).pipe(Effect.annotateLogs("sessionID", sessionID))
+2 -5
View File
@@ -2,6 +2,7 @@ export * as SessionRunCoordinator from "./run-coordinator"
import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect"
import { SessionRunner } from "./runner"
import { logFailure } from "./logging"
import { SessionSchema } from "./schema"
export type Mode = "run" | "wake"
@@ -275,11 +276,7 @@ export const layer = Layer.effect(
Effect.flatMap((runner) =>
make<SessionSchema.ID, void, SessionRunner.RunError>({
drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }),
onFailure: (sessionID, cause) =>
Effect.logError("Failed to drain Session").pipe(
Effect.annotateLogs("sessionID", sessionID),
Effect.annotateLogs("cause", cause),
),
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
}),
),
Effect.map(Service.of),
+5
View File
@@ -320,6 +320,11 @@ export const layer = Layer.effect(
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
}
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
const failure = Cause.squash(settled.cause)
const message = failure instanceof Error ? failure.message : String(failure)
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
}
if (publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !publisher.hasProviderError())
+2
View File
@@ -58,3 +58,5 @@ export const layer = Layer.effect(
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
+36 -19
View File
@@ -4,7 +4,7 @@ import { Effect, Scope, Semaphore } from "effect"
import type { Draft, Objectish } from "immer"
/**
* A replayable contribution applied to an editor during rebuild.
* A replayable transform applied to an editor during rebuild.
*
* Transforms are intentionally synchronous and mutation-shaped: domain editors
* hide the draft representation while preserving concise plugin/config code.
@@ -39,15 +39,17 @@ export interface Interface<State extends Objectish, Editor> {
* registration order. Closing the owning Scope removes the slot and rebuilds.
*/
readonly transform: () => Effect.Effect<(transform: Transform<Editor>) => Effect.Effect<void>, never, Scope.Scope>
/** Registers and applies a replayable transform in the current Scope. */
readonly update: (update: Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
/**
* Mutates the current materialized state directly.
* Mutates the current materialized state directly, once.
*
* This is not replayable contribution state: a later rebuild starts again
* This is not replayable transform state: a later rebuild starts again
* from `initial()` plus active transforms, so direct edits must be reserved
* for current-state adjustments that are intentionally outside the transform
* fold.
*/
readonly update: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
readonly mutate: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
}
export function create<State extends Objectish, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
@@ -61,35 +63,50 @@ export function create<State extends Objectish, Editor>(options: Options<State,
state = next
})
const rebuild = Effect.fn("State.rebuild")(function* () {
const rebuild = Effect.fnUntraced(function* () {
const next = options.initial()
const api = options.editor(next as Draft<State>)
for (const transform of transforms)
yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {}))
yield* commit(next)
}, semaphore.withPermit)
})
return {
const result: Interface<State, Editor> = {
get: () => state,
transform: Effect.fn("State.transform")(function* () {
const transform = { update: (_editor: Editor) => {} }
transforms = [...transforms, transform]
const scope = yield* Scope.Scope
yield* Scope.addFinalizer(
scope,
Effect.sync(() => {
transforms = transforms.filter((item) => item !== transform)
}).pipe(Effect.andThen(rebuild())),
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const transform = { update: (_editor: Editor) => {} }
transforms = [...transforms, transform]
yield* Scope.addFinalizer(
scope,
semaphore.withPermit(
Effect.sync(() => {
transforms = transforms.filter((item) => item !== transform)
}).pipe(Effect.andThen(rebuild())),
),
)
return (update: Transform<Editor>) =>
Effect.uninterruptible(
semaphore.withPermit(
Effect.sync(() => {
transform.update = update
}).pipe(Effect.andThen(rebuild())),
),
)
}),
)
return Effect.fnUntraced(function* (update: Transform<Editor>) {
transform.update = update
yield* rebuild()
})
}),
update: Effect.fn("State.update")(function* (update, reason) {
update: Effect.fn("State.update")(function* (update) {
const transform = yield* result.transform()
yield* transform(update)
}),
mutate: Effect.fn("State.mutate")(function* (update, reason) {
const api = options.editor(state as Draft<State>)
yield* update(api)
if (options.finalize) yield* options.finalize(api, reason)
}, semaphore.withPermit),
}
return result
}
+1 -1
View File
@@ -36,7 +36,7 @@ const builtIns = Layer.effectDiscard(
}),
])
yield* registry.contribute({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
}),
)
+12 -12
View File
@@ -3,13 +3,13 @@ export * as SystemContextRegistry from "./registry"
import { Context, Effect, Layer, Ref, Scope } from "effect"
import { SystemContext } from "./index"
export interface Contribution {
export interface Entry {
readonly key: SystemContext.Key
readonly load: Effect.Effect<SystemContext.SystemContext>
}
export interface Interface {
readonly contribute: (contribution: Contribution) => Effect.Effect<void, never, Scope.Scope>
readonly register: (entry: Entry) => Effect.Effect<void, never, Scope.Scope>
readonly load: () => Effect.Effect<SystemContext.SystemContext>
}
@@ -18,27 +18,27 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const contributions = yield* Ref.make<ReadonlyArray<Contribution>>([])
const entries = yield* Ref.make<ReadonlyArray<Entry>>([])
return Service.of({
contribute: Effect.fn("SystemContextRegistry.contribute")(function* (contribution) {
register: Effect.fn("SystemContextRegistry.register")(function* (entry) {
yield* Effect.acquireRelease(
Ref.modify(contributions, (current) => {
if (current.some((item) => item.key === contribution.key)) return [false, current]
return [true, [...current, contribution]]
Ref.modify(entries, (current) => {
if (current.some((item) => item.key === entry.key)) return [false, current]
return [true, [...current, entry]]
}).pipe(
Effect.flatMap((added) =>
added ? Effect.void : Effect.die(`Duplicate system context contribution key: ${contribution.key}`),
added ? Effect.void : Effect.die(`Duplicate system context entry key: ${entry.key}`),
),
Effect.as(contribution),
Effect.as(entry),
),
(entry) => Ref.update(contributions, (current) => current.filter((item) => item !== entry)),
(entry) => Ref.update(entries, (current) => current.filter((item) => item !== entry)),
)
}),
load: Effect.fn("SystemContextRegistry.load")(function* () {
const current = (yield* Ref.get(contributions)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
const current = (yield* Ref.get(entries)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
return SystemContext.combine(
yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }),
yield* Effect.forEach(current, (entry) => entry.load, { concurrency: "unbounded" }),
)
}),
})
+17 -28
View File
@@ -11,7 +11,6 @@ import type { ToolOutput } from "@opencode-ai/llm"
export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024
export const MAX_INLINE_MEDIA_BYTES = 5 * 1024 * 1024
export const RETENTION = Duration.days(7)
export const MANAGED_DIRECTORY = "tool-output"
@@ -32,13 +31,7 @@ export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolO
cause: Schema.Defect,
}) {}
export class MediaLimitError extends Schema.TaggedErrorClass<MediaLimitError>()("ToolOutputStore.MediaLimitError", {
mime: Schema.String,
bytes: Schema.Int,
limit: Schema.Int,
}) {}
export type Error = StorageError | MediaLimitError
export type Error = StorageError
export interface Interface {
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
@@ -139,37 +132,33 @@ export const layer = Layer.effect(
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
const outputLimits = yield* limits()
const media = input.output.content.filter((item) => item.type === "file")
let mediaBytes = 0
for (const item of media) {
if (item.source.type !== "data") continue
mediaBytes += Buffer.byteLength(item.source.data, "utf-8")
if (mediaBytes > MAX_INLINE_MEDIA_BYTES)
return yield* new MediaLimitError({ mime: item.mime, bytes: mediaBytes, limit: MAX_INLINE_MEDIA_BYTES })
}
const contextual = {
structured: media.length > 0 ? {} : input.output.structured,
content: input.output.content.filter((item) => item.type === "text"),
}
const encoded = yield* Effect.try({
try: () => JSON.stringify(contextual, null, 2),
catch: (cause) => new StorageError({ operation: "encode", cause }),
})
if (lineCount(encoded) <= outputLimits.maxLines && Buffer.byteLength(encoded, "utf-8") <= outputLimits.maxBytes)
const text = input.output.content.filter((item) => item.type === "text")
const contextual =
input.output.content.length === 0
? yield* Effect.try({
try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured),
catch: (cause) => new StorageError({ operation: "encode", cause }),
})
: text.map((item) => item.text).join("")
if (
lineCount(contextual) <= outputLimits.maxLines &&
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
)
return {
output: { structured: contextual.structured, content: input.output.content },
output: input.output,
outputPaths: [],
}
const outputPath = yield* write(encoded)
const outputPath = yield* write(contextual)
const marker = `... output truncated; full content saved to ${outputPath} ...`
return {
output: {
structured: {},
structured: input.output.structured,
content: [
{
type: "text" as const,
text: boundedPreview(encoded, marker, outputLimits.maxLines, outputLimits.maxBytes),
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
},
...media,
],
+1
View File
@@ -56,3 +56,4 @@ Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOut
- Plugin boot has not been redesigned to register canonical tools through `Tools.Service`; do not redesign it as part of leaf migrations.
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
- The public Session result shape currently exposes managed `outputPaths`; full storage encapsulation requires a future opaque managed-output reference design.
+6 -6
View File
@@ -12,7 +12,7 @@ import { Tools } from "./tools"
export const name = "apply_patch"
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
patchText: Schema.String.annotate({
description: "The full patch text describing add, update, and delete operations",
}),
@@ -24,10 +24,10 @@ export const Applied = Schema.Struct({
target: Schema.String,
})
export const Success = Schema.Struct({ applied: Schema.Array(Applied) })
export type Success = typeof Success.Type
export const Output = Schema.Struct({ applied: Schema.Array(Applied) })
export type Output = typeof Output.Type
export const toModelOutput = (output: Success) =>
export const toModelOutput = (output: Output) =>
[
"Applied patch sequentially:",
...output.applied.map(
@@ -57,8 +57,8 @@ export const layer = Layer.effectDiscard(
Tool.make({
description:
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
+6 -6
View File
@@ -18,7 +18,7 @@ export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
command: Schema.String.annotate({ description: "Shell command string to execute" }),
workdir: Schema.String.pipe(Schema.optional).annotate({
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
@@ -33,7 +33,7 @@ export const Parameters = Schema.Struct({
}),
})
const Success = Schema.Struct({
const Output = Schema.Struct({
command: Schema.String,
cwd: Schema.String,
exitCode: Schema.Number.pipe(Schema.optional),
@@ -46,7 +46,7 @@ const Success = Schema.Struct({
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
})
type Success = typeof Success.Type
type Output = typeof Output.Type
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
@@ -62,7 +62,7 @@ const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => {
return undefined
}
const modelOutput = (output: Success) => {
const modelOutput = (output: Output) => {
const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: ""
@@ -117,8 +117,8 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })],
execute: (input, context) =>
Effect.gen(function* () {
+2 -2
View File
@@ -15,7 +15,7 @@ import { WebSearchTool } from "./websearch"
import { WriteTool } from "./write"
/**
* Composes only the shipped Location-scoped built-in tool contributions.
* Composes only the shipped Location-scoped built-in tool transforms.
* Each tool retains its implementation and focused tests independently. Dynamic
* MCP and plugin tools later use separate scoped canonical registrations, while
* provider/model filtering belongs to a future materialization phase rather
@@ -25,7 +25,7 @@ import { WriteTool } from "./write"
* TODO: Port the remaining launch-follow-up leaves deliberately: edit fuzzy
* parity, task, LSP,
* repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin
* contributions separate from this static built-in list.
* transforms separate from this static built-in list.
*/
export const locationLayer = Layer.mergeAll(
ApplyPatchTool.layer,
+7 -7
View File
@@ -18,7 +18,7 @@ import { Tools } from "./tools"
export const name = "edit"
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
path: Schema.String.annotate({
description:
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
@@ -30,14 +30,14 @@ export const Parameters = Schema.Struct({
}),
})
export const Success = Schema.Struct({
export const Output = Schema.Struct({
operation: Schema.Literal("write"),
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
replacements: Schema.Number,
})
export type Success = typeof Success.Type
export type Output = typeof Output.Type
const normalizeLineEndings = (text: string) => text.replaceAll("\r\n", "\n")
const detectLineEnding = (text: string): "\n" | "\r\n" => (text.includes("\r\n") ? "\r\n" : "\n")
@@ -70,7 +70,7 @@ const previewLines = (value: string, prefix: "+" | "-") => {
return shown
}
export const toModelOutput = (output: Success, oldString: string, newString: string) =>
export const toModelOutput = (output: Output, oldString: string, newString: string) =>
[
`Edited file successfully: ${output.resource}`,
`Replacements: ${output.replacements}`,
@@ -101,8 +101,8 @@ export const layer = Layer.effectDiscard(
Tool.make({
description:
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
toolText({ type: "text", text: toModelOutput(output, input.oldString, input.newString) }),
],
@@ -188,7 +188,7 @@ export const layer = Layer.effectDiscard(
content: joinBom(next.text, source.bom || next.bom),
}),
)
return { ...result, replacements } satisfies Success
return { ...result, replacements } satisfies Output
})
},
}),
+2 -2
View File
@@ -10,7 +10,7 @@ import { Tools } from "./tools"
export const name = "glob"
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
path: LocationSearch.FilesInput.fields.path.annotate({
description: "Relative directory to search. Defaults to the active Location.",
@@ -56,7 +56,7 @@ export const layer = Layer.effectDiscard(
[name]: Tool.make({
description:
"Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Parameters,
input: Input,
output: LocationSearch.FilesResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) =>
+4 -4
View File
@@ -11,7 +11,7 @@ import { Tools } from "./tools"
export const name = "grep"
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
pattern: LocationSearch.GrepInput.fields.pattern.annotate({
description: "Regex pattern to search for in file contents",
}),
@@ -29,10 +29,10 @@ export const Parameters = Schema.Struct({
}),
})
type Success = typeof LocationSearch.GrepResult.Encoded
type Output = typeof LocationSearch.GrepResult.Encoded
/** Format raw Location search matches into the familiar concise model output. */
export const toModelOutput = (output: Success) => {
export const toModelOutput = (output: Output) => {
const lines = output.items.length === 0 ? ["No files found"] : [`Found ${output.items.length} matches`]
let current = ""
for (const match of output.items) {
@@ -71,7 +71,7 @@ export const layer = Layer.effectDiscard(
[name]: Tool.make({
description:
"Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Parameters,
input: Input,
output: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) =>
+5 -5
View File
@@ -20,14 +20,14 @@ Usage notes:
- Answers are returned as arrays of labels; set \`multiple: true\` to allow selecting more than one
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
})
export const Success = Schema.Struct({
export const Output = Schema.Struct({
answers: Schema.Array(QuestionV2.Answer),
})
export type Success = typeof Success.Type
export type Output = typeof Output.Type
export const toModelOutput = (
questions: ReadonlyArray<QuestionV2.Prompt>,
@@ -52,8 +52,8 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description,
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
toolText({ type: "text", text: toModelOutput(input.questions, output.answers) }),
],
+19 -136
View File
@@ -1,47 +1,15 @@
export * as ReadTool from "./read"
import { ToolFailure } from "@opencode-ai/llm"
// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm.
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
import { Effect, Layer, Schema } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { Config } from "../config"
import { FileSystem } from "../filesystem"
import { Image } from "../image"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "read"
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024
const MAX_IMAGE_WIDTH = 2_000
const MAX_IMAGE_HEIGHT = 2_000
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
class ImageDecodeError extends Error {
constructor(readonly resource: string) {
super(`Image could not be decoded: ${resource}`)
this.name = "ImageDecodeError"
}
}
class ImageSizeError extends Error {
constructor(
readonly resource: string,
readonly width: number,
readonly height: number,
readonly bytes: number,
readonly maxWidth: number,
readonly maxHeight: number,
readonly maxBytes: number,
) {
super(
`Image ${resource} is ${width}x${height} with base64 size ${bytes}, exceeding configured limits ${maxWidth}x${maxHeight}/${maxBytes} bytes`,
)
this.name = "ImageSizeError"
}
}
const LocationInput = Schema.Struct({
...FileSystem.ReadInput.fields,
offset: FileSystem.ListPageInput.fields.offset.annotate({
@@ -52,20 +20,14 @@ const LocationInput = Schema.Struct({
}),
})
const Input = LocationInput
const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
const Output = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const filesystem = yield* FileSystem.Service
const config = yield* Config.Service
const image = yield* Image.Service
const permission = yield* PermissionV2.Service
const loadPhoton = yield* Effect.cached(
Effect.sync(() => {
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
}).pipe(Effect.andThen(() => Effect.promise(() => import("@silvia-odwyer/photon-node")))),
)
yield* tools
.register({
@@ -73,7 +35,7 @@ export const layer = Layer.effectDiscard(
description:
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
input: Input,
output: Success,
output: Output,
toModelOutput: ({ input, output }) => {
if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
return [
@@ -98,108 +60,29 @@ export const layer = Layer.effectDiscard(
limit: input.limit,
})
if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
const mime = content.mime
const base64 = content.content
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
),
)
const limits = {
autoResize: image.auto_resize ?? true,
maxWidth: image.max_width ?? MAX_IMAGE_WIDTH,
maxHeight: image.max_height ?? MAX_IMAGE_HEIGHT,
maxBase64Bytes: image.max_base64_bytes ?? MAX_IMAGE_BASE64_BYTES,
}
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")),
catch: () => new ImageDecodeError(resolved.resource),
})
try {
const width = decoded.get_width()
const height = decoded.get_height()
const bytes = Buffer.byteLength(base64, "utf-8")
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes)
return new FileSystem.BinaryContent({ type: "binary", content: base64, encoding: "base64", mime })
if (!limits.autoResize)
return yield* Effect.fail(
new ImageSizeError(
resolved.resource,
width,
height,
bytes,
limits.maxWidth,
limits.maxHeight,
limits.maxBase64Bytes,
),
)
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
const previous = acc.at(-1) ?? {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
const next =
acc.length === 0
? previous
: {
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
}
return acc.some((item) => item.width === next.width && item.height === next.height)
? acc
: [...acc, next]
}, [])
for (const size of sizes) {
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
try {
const candidate = [
{ content: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
...JPEG_QUALITIES.map((quality) => ({
content: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
mime: "image/jpeg",
})),
].find((item) => Buffer.byteLength(item.content, "utf-8") <= limits.maxBase64Bytes)
if (candidate)
return new FileSystem.BinaryContent({
type: "binary",
content: candidate.content,
encoding: "base64",
mime: candidate.mime,
})
} finally {
resized.free()
}
}
return yield* Effect.fail(
new ImageSizeError(
resolved.resource,
width,
height,
bytes,
limits.maxWidth,
limits.maxHeight,
limits.maxBase64Bytes,
),
)
} finally {
decoded.free()
}
return yield* image
.normalize(resolved.resource, content)
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
}
if (content.type === "binary")
return yield* Effect.fail(new FileSystem.BinaryFileError(resolved.resource))
return content
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof FileSystem.PathValidationError
? Effect.fail(new ToolFailure({ message: defect.message }))
: Effect.die(defect),
),
Effect.mapError((error) => {
const message =
error instanceof FileSystem.BinaryFileError ||
error instanceof FileSystem.MediaIngestLimitError ||
error instanceof ImageDecodeError ||
error instanceof ImageSizeError
error instanceof ToolFailure
? error.message
: `Unable to read ${input.path}`
: error instanceof FileSystem.BinaryFileError ||
error instanceof FileSystem.MediaIngestLimitError ||
error instanceof Image.DecodeError ||
error instanceof Image.SizeError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message })
}),
)
+23 -21
View File
@@ -1,6 +1,6 @@
export * as ToolRegistry from "./registry"
import { Tool as LlmTool, ToolOutput, type ToolCall, type ToolSettlement } from "@opencode-ai/llm"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolSettlement } from "@opencode-ai/llm"
import { Context, Effect, Layer, Scope } from "effect"
import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission"
@@ -9,7 +9,7 @@ import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { ApplicationTools } from "./application-tools"
import { Tool } from "./tool"
import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool"
import { Tools } from "./tools"
export type ExecuteInput = {
@@ -22,13 +22,11 @@ export type ExecuteInput = {
export interface Interface {
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
/** Internal registration capability exposed publicly only through Tools.Service. */
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
}
export interface Materialization {
readonly definitions: ReadonlyArray<ReturnType<typeof LlmTool.toDefinitions>[number]>
readonly definitions: ReadonlyArray<ToolDefinition>
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
}
@@ -43,7 +41,7 @@ const registryLayer = Layer.effect(
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const resources = yield* ToolOutputStore.Service
type Registration = { readonly identity: object; readonly tool: Tool.AnyTool }
type Registration = { readonly identity: object; readonly tool: AnyTool }
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
@@ -58,7 +56,7 @@ const registryLayer = Layer.effect(
}
if (advertised && registration.identity !== advertised)
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
const pending = yield* Tool.settle(registration.tool, input.call, {
const pending = yield* settle(registration.tool, input.call, {
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
@@ -84,17 +82,21 @@ const registryLayer = Layer.effect(
register: Effect.fn("ToolRegistry.register")(function* (tools) {
const entries = Object.entries(tools)
if (entries.length === 0) return
yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true })
const token = {}
for (const [name, tool] of entries)
local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const [name] of entries) {
const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? []
if (registrations.length > 0) local.set(name, registrations)
else local.delete(name)
}
yield* Effect.forEach(entries, ([name]) => validateName(name), { discard: true })
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
for (const [name, tool] of entries)
local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const [name] of entries) {
const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? []
if (registrations.length > 0) local.set(name, registrations)
else local.delete(name)
}
}),
)
}),
)
}),
@@ -105,9 +107,9 @@ const registryLayer = Layer.effect(
if (registration) registrations.set(name, registration)
}
for (const [name, registration] of registrations)
if (whollyDisabled(Tool.permission(registration.tool, name), permissions)) registrations.delete(name)
if (whollyDisabled(permission(registration.tool, name), permissions)) registrations.delete(name)
return {
definitions: Array.from(registrations, ([name, registration]) => Tool.definition(name, registration.tool)),
definitions: Array.from(registrations, ([name, registration]) => definition(name, registration.tool)),
settle: (input) => {
const registration = registrations.get(input.call.name)
if (registration) return settleWith(input, registration.identity)
+4 -4
View File
@@ -14,11 +14,11 @@ import { Tools } from "./tools"
export const name = "skill"
const FILE_LIMIT = 10
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }),
})
export const Success = Schema.Struct({
export const Output = Schema.Struct({
name: Schema.String,
directory: Schema.String,
output: Schema.String,
@@ -66,8 +66,8 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description,
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
execute: (input, context) =>
Effect.gen(function* () {
+6 -6
View File
@@ -9,16 +9,16 @@ import { Tools } from "./tools"
export const name = "todowrite"
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
todos: Schema.Array(SessionTodo.Info).annotate({ description: "The updated todo list" }),
})
export const Success = Schema.Struct({
export const Output = Schema.Struct({
todos: Schema.Array(SessionTodo.Info),
})
export type Success = typeof Success.Type
export type Output = typeof Output.Type
export const toModelOutput = (output: Success) => JSON.stringify(output.todos, null, 2)
export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2)
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@@ -31,8 +31,8 @@ export const layer = Layer.effectDiscard(
[name]: Tool.make({
description:
"Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) =>
Effect.gen(function* () {
+22 -13
View File
@@ -1,7 +1,7 @@
export * as Tool from "./tool"
import { Tool as LlmTool, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import { ToolDefinition, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm"
import { Effect, JsonSchema, Schema } from "effect"
import type { AgentV2 } from "../agent"
import type { SessionMessage } from "../session/message"
import type { SessionSchema } from "../session/schema"
@@ -17,14 +17,14 @@ export type SchemaType<A> = Schema.Codec<A, any, never, never>
declare const TypeId: unique symbol
export interface Tool<Input extends SchemaType<any>, Output extends SchemaType<any>> {
export interface Definition<Input extends SchemaType<any>, Output extends SchemaType<any>> {
readonly [TypeId]: {
readonly _Input: Input
readonly _Output: Output
}
}
export type AnyTool = Tool<any, any>
export type AnyTool = Definition<any, any>
export const Failure = ToolFailure
export type Failure = ToolFailure
@@ -53,7 +53,7 @@ type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
type Runtime = {
readonly permission?: string
readonly definition: (name: string) => ReturnType<typeof LlmTool.toDefinitions>[number]
readonly definition: (name: string) => ToolDefinition
readonly settle: (call: ToolCall, context: Context) => Effect.Effect<ToolOutput, ToolFailure>
}
@@ -61,16 +61,19 @@ const runtimes = new WeakMap<AnyTool, Runtime>()
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
config: Config<Input, Output>,
): Tool<Input, Output> {
const tool = Object.freeze({}) as Tool<Input, Output>
const definitions = new Map<string, ReturnType<typeof LlmTool.toDefinitions>[number]>()
): Definition<Input, Output> {
const tool = Object.freeze({}) as Definition<Input, Output>
const definitions = new Map<string, ToolDefinition>()
runtimes.set(tool, {
definition: (name) => {
const cached = definitions.get(name)
if (cached) return cached
const definition = LlmTool.toDefinitions({
[name]: LlmTool.make({ description: config.description, parameters: config.input, success: config.output }),
})[0]
const definition = new ToolDefinition({
name,
description: config.description,
inputSchema: toJsonSchema(config.input),
outputSchema: toJsonSchema(config.output),
})
definitions.set(name, definition)
return definition
},
@@ -117,10 +120,10 @@ export const validateName = (name: string) =>
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
export const withPermission = <Input extends SchemaType<any>, Output extends SchemaType<any>>(
tool: Tool<Input, Output>,
tool: Definition<Input, Output>,
permission: string,
) => {
const decorated = Object.freeze({}) as Tool<Input, Output>
const decorated = Object.freeze({}) as Definition<Input, Output>
runtimes.set(decorated, { ...runtimeOf(tool), permission })
return decorated
}
@@ -134,3 +137,9 @@ function runtimeOf(tool: AnyTool) {
if (!runtime) throw new TypeError("Invalid Core Tool value")
return runtime
}
function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema {
const document = Schema.toJsonSchemaDocument(schema)
if (Object.keys(document.definitions).length === 0) return document.schema
return { ...document.schema, $defs: document.definitions }
}
+7 -7
View File
@@ -16,11 +16,11 @@ export const MAX_TIMEOUT_SECONDS = 120
export const description = `Fetch content from an HTTP or HTTPS URL and return it as text, markdown, or HTML. Markdown is the default.
Use a more targeted tool when one is available. This tool is read-only. Large text results are truncated and saved to a managed file that ordinary Read, Grep, and Bash tools can inspect.`
Use a more targeted tool when one is available. This tool is read-only. Large text results may be replaced with a preview while the complete output is retained in managed storage.`
const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS))
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
url: Schema.String.annotate({ description: "The HTTP or HTTPS URL to fetch content from" }),
format: Schema.Literals(["text", "markdown", "html"])
.annotate({ description: "The format to return the content in. Defaults to markdown." })
@@ -30,14 +30,14 @@ export const Parameters = Schema.Struct({
}),
})
const Success = Schema.Struct({
const Output = Schema.Struct({
url: Schema.String,
contentType: Schema.String,
format: Parameters.fields.format,
format: Input.fields.format,
output: Schema.String,
})
type Format = (typeof Parameters.Type)["format"]
type Format = (typeof Input.Type)["format"]
const acceptHeader = (format: Format) => {
switch (format) {
@@ -134,8 +134,8 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description,
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
execute: (input, context) =>
Effect.gen(function* () {
+4 -4
View File
@@ -33,7 +33,7 @@ Optional controls support result count, live crawling ('fallback' or 'preferred'
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
query: Schema.String.annotate({ description: "Websearch query" }),
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`,
@@ -176,7 +176,7 @@ const callMcp = <F extends Schema.Struct.Fields>(
)
})
const Success = Schema.Struct({
const Output = Schema.Struct({
provider: Provider,
text: Schema.String,
})
@@ -192,8 +192,8 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description,
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })],
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)
+6 -6
View File
@@ -18,7 +18,7 @@ import { Tools } from "./tools"
export const name = "write"
// TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
path: Schema.String.annotate({
description:
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
@@ -26,15 +26,15 @@ export const Parameters = Schema.Struct({
content: Schema.String.annotate({ description: "Content to write to the file" }),
})
export const Success = Schema.Struct({
export const Output = Schema.Struct({
operation: Schema.Literal("write"),
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
})
export type Success = typeof Success.Type
export type Output = typeof Output.Type
export const toModelOutput = (output: Success) =>
export const toModelOutput = (output: Output) =>
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
/** Deferred V2 write UX integrations remain visible at the model-facing seam. */
@@ -56,8 +56,8 @@ export const layer = Layer.effectDiscard(
Tool.make({
description:
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) =>
Effect.gen(function* () {
+1 -1
View File
@@ -59,7 +59,7 @@ describe("AgentV2", () => {
}),
)
it.effect("removes a transform contribution when its scope closes", () =>
it.effect("removes a transform when its scope closes", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("scoped")
+35 -12
View File
@@ -9,7 +9,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Tools } from "@opencode-ai/core/tool/tools"
import { Effect, Exit, Layer, Schema, Scope } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect"
const permission = Layer.mock(PermissionV2.Service, {
@@ -136,7 +136,7 @@ describe("ApplicationTools", () => {
],
},
output: {
structured: {},
structured: { answer: "HELLO" },
content: [
{ type: "text", text: "HELLO" },
{ type: "file", source: { type: "data", data: "aGVsbG8=" }, mime: "image/png", name: "result.png" },
@@ -147,7 +147,7 @@ describe("ApplicationTools", () => {
}),
)
it.effect("removes an application tool when its attachment scope closes", () =>
it.effect("removes an application tool when its registration scope closes", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
@@ -165,11 +165,11 @@ describe("ApplicationTools", () => {
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const attachmentScope = yield* Scope.make()
yield* applications.register({ contextual: contextual([]) }).pipe(Scope.provide(attachmentScope))
const registrationScope = yield* Scope.make()
yield* applications.register({ contextual: contextual([]) }).pipe(Scope.provide(registrationScope))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
yield* Scope.close(attachmentScope, Exit.void)
yield* Scope.close(registrationScope, Exit.void)
expect(
yield* settleTool(registry, {
sessionID,
@@ -181,7 +181,7 @@ describe("ApplicationTools", () => {
}),
)
it.effect("does not leak an attachment into an already closed scope", () =>
it.effect("does not leak a registration into an already closed scope", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
@@ -194,13 +194,36 @@ describe("ApplicationTools", () => {
}),
)
it.effect("captures the attached record before later State rebuilds", () =>
it.effect("preserves an interrupted application registration until its scope closes", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const attached = { stable: contextual([]) }
yield* applications.register(attached)
Object.assign(attached, { late: contextual([]) })
const scope = yield* Scope.make()
const registered = yield* Deferred.make<void>()
const fiber = yield* applications
.register({ interrupted: contextual([]) })
.pipe(
Effect.andThen(Deferred.succeed(registered, undefined)),
Effect.andThen(Effect.never),
Scope.provide(scope),
Effect.forkChild,
)
yield* Deferred.await(registered)
yield* Fiber.interrupt(fiber)
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["interrupted"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(registry)).toEqual([])
}),
)
it.effect("captures the registered record before later State rebuilds", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const registered = { stable: contextual([]) }
yield* applications.register(registered)
Object.assign(registered, { late: contextual([]) })
yield* Effect.scoped(applications.register({ temporary: contextual([]) }))
@@ -208,7 +231,7 @@ describe("ApplicationTools", () => {
}),
)
it.effect("settles with the current same-name application tool and restores earlier attachments", () =>
it.effect("settles with the current same-name application tool and restores earlier registrations", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
@@ -67,6 +67,11 @@ describe("FileSystem", () => {
expect(yield* service.read({ path: output })).toMatchObject({ type: "text", content: "failure here" })
expect((yield* service.resolveRoot({ path: output })).real).toBe(output)
expect(
yield* service
.resolveReadPath({ path: unrelated })
.pipe(Effect.catchDefect((defect) => Effect.succeed(defect))),
).toBeInstanceOf(FileSystem.PathValidationError)
expect(yield* Effect.exit(service.read({ path: unrelated }))).toMatchObject({ _tag: "Failure" })
expect(yield* Effect.exit(service.read({ path: managed }))).toMatchObject({ _tag: "Failure" })
}).pipe(provide(worktree, inertReferences, FSUtil.defaultLayer, data))
+15
View File
@@ -161,6 +161,21 @@ describe("PermissionV2", () => {
}),
)
it.effect("allows managed output reads without granting external directory access", () =>
Effect.gen(function* () {
yield* setup([
{ action: "*", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
])
const service = yield* PermissionV2.Service
expect(yield* service.ask(assertion({ resources: ["tool_123"] }))).toMatchObject({ effect: "allow" })
expect(
yield* service.ask(assertion({ action: "external_directory", resources: ["/tmp/tool-output/*"] })),
).toMatchObject({ effect: "deny" })
}),
)
it.effect("uses build permissions when the Session agent is omitted", () =>
Effect.gen(function* () {
yield* setup()
+1 -1
View File
@@ -45,7 +45,7 @@ describe("PluginV2", () => {
}),
)
it.effect("serializes same-ID additions and leaves one removable contribution", () =>
it.effect("serializes same-ID additions and leaves one removable attachment", () =>
Effect.gen(function* () {
const values = state()
const layerScope = yield* Scope.fork(yield* Scope.Scope)
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from "bun:test"
import { Tool } from "@opencode-ai/core/public"
import { Effect } from "effect"
describe("public Tool API", () => {
it("keeps the public registration capability narrow", () => {
const tools = {
register: () => Effect.void,
} satisfies Tool.Interface
expect(Object.keys(tools)).toEqual(["register"])
})
})
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Logger } from "effect"
import { logFailure } from "@opencode-ai/core/session/logging"
import { SessionSchema } from "@opencode-ai/core/session/schema"
describe("Session logging", () => {
for (const message of ["Failed to drain Session", "Failed to wake Session"] as const) {
test(`renders the cause for ${message}`, async () => {
const entries: Array<ReturnType<typeof Logger.formatStructured.log>> = []
const logger = Logger.formatStructured.pipe(
Logger.map((entry): void => {
entries.push(entry)
}),
)
await logFailure(
message,
SessionSchema.ID.make("session-123"),
Cause.fail({ _tag: "SessionFailure", detail: { code: "nested-code" } }),
).pipe(Effect.provide(Logger.layer([logger])), Effect.runPromise)
expect(entries).toHaveLength(1)
expect(entries[0]?.message).toBe(message)
expect(entries[0]?.annotations).toEqual({ sessionID: "session-123" })
expect(entries[0]?.cause).toContain("SessionFailure")
expect(entries[0]?.cause).toContain("nested-code")
expect(entries[0]?.cause).not.toContain("[Object")
})
}
})
@@ -7,7 +7,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Scope } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { testEffect } from "./lib/effect"
const bounds: ToolOutputStore.BoundInput[] = []
@@ -29,6 +29,7 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
})
const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore))
const it = testEffect(registry)
const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry))
const identity = {
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_registry"),
@@ -125,6 +126,28 @@ describe("ToolRegistry", () => {
}),
)
it.effect("preserves an interrupted registration until its scope closes", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
const registered = yield* Deferred.make<void>()
const fiber = yield* service
.register({ echo: make() })
.pipe(
Effect.andThen(Deferred.succeed(registered, undefined)),
Effect.andThen(Effect.never),
Scope.provide(scope),
Effect.forkChild,
)
yield* Deferred.await(registered)
yield* Fiber.interrupt(fiber)
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(service)).toEqual([])
}),
)
it.effect("returns model errors without swallowing interruption or defects", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
@@ -237,6 +260,72 @@ describe("ToolRegistry", () => {
}),
)
it.effect("enforces transformed codecs at execution and projection boundaries", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const executed: string[] = []
const Transformed = Schema.Boolean.pipe(
Schema.decodeTo(Schema.String, {
decode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
encode: SchemaGetter.transform((value) => value === "yes"),
}),
)
yield* service.register({
transformed: Tool.make({
description: "Transform values",
input: Schema.Struct({ value: Transformed }),
output: Schema.Struct({ value: Transformed }),
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
}),
})
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } },
}),
).toEqual({ type: "text", value: "true" })
expect(executed).toEqual(["yes"])
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "invalid-input", name: "transformed", input: { value: "yes" } },
}),
).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") })
expect(executed).toEqual(["yes"])
yield* service.register({
invalid_output: Tool.make({
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({
value: Schema.Boolean.pipe(
Schema.decodeTo(Schema.String, {
decode: SchemaGetter.transform((value) => String(value)),
encode: SchemaGetter.transformOrFail((value) =>
value === "valid"
? Effect.succeed(true)
: Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
),
}),
),
}),
execute: () => Effect.succeed({ value: "invalid" }),
}),
})
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "invalid-output", name: "invalid_output", input: {} },
}),
).toMatchObject({ type: "error", value: expect.stringContaining("invalid value for its output schema") })
}),
)
it.effect("executes the unchanged registration advertised for a provider turn", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
@@ -293,6 +382,38 @@ describe("ToolRegistry", () => {
}),
)
integrated.effect("rejects an application call after a Location override is registered", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const service = yield* ToolRegistry.Service
yield* applications.register({ echo: make() })
const materialized = yield* service.materialize()
yield* service.register({ echo: make() })
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
}),
)
integrated.effect("rejects a Location call after removal reveals an application registration", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const service = yield* ToolRegistry.Service
yield* applications.register({ echo: make() })
const scope = yield* Scope.make()
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
const materialized = yield* service.materialize()
yield* Scope.close(scope, Exit.void)
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
}),
)
it.effect("keeps captured execution running after registration mutation", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
+17 -1
View File
@@ -173,7 +173,7 @@ const skillBaselines = new Map<AgentV2.ID, string>()
const systemContext = Layer.effectDiscard(
SystemContextRegistry.Service.pipe(
Effect.flatMap((registry) =>
registry.contribute({
registry.register({
key: systemContextKey,
load: Effect.sync(() =>
SystemContext.combine(
@@ -2955,6 +2955,22 @@ describe("SessionRunnerLLM", () => {
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe("unexpected tool defect")
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call defect" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-defect",
state: {
status: "error",
error: { type: "unknown", message: "Tool execution failed: unexpected tool defect" },
},
},
],
},
])
}),
)
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect } from "bun:test"
import { State } from "@opencode-ai/core/state"
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
describe("State", () => {
it.effect("commits a transform atomically when its updater is interrupted", () =>
Effect.gen(function* () {
const rebuilding = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let block = true
const state = State.create({
initial: () => ({ values: [] as string[] }),
editor: (draft) => ({ add: (value: string) => draft.values.push(value) }),
finalize: () =>
block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void,
})
const scope = yield* Scope.make()
const update = yield* state.transform().pipe(Scope.provide(scope))
const fiber = yield* update((editor) => editor.add("registered")).pipe(Effect.forkChild)
yield* Deferred.await(rebuilding)
const interruption = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild)
block = false
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(interruption)
expect(state.get().values).toEqual(["registered"])
yield* Scope.close(scope, Exit.void)
expect(state.get().values).toEqual([])
}),
)
})
@@ -4,7 +4,7 @@ import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { testEffect } from "../lib/effect"
const contribution = (key: string, text: string, sourceKey = key) => ({
const entry = (key: string, text: string, sourceKey = key) => ({
key: SystemContext.Key.make(key),
load: Effect.succeed(
SystemContext.make({
@@ -20,7 +20,7 @@ const contribution = (key: string, text: string, sourceKey = key) => ({
const it = testEffect(SystemContextRegistry.layer)
describe("SystemContextRegistry", () => {
it.effect("loads empty system context when there are no contributions", () =>
it.effect("loads empty system context when there are no entries", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
@@ -28,21 +28,21 @@ describe("SystemContextRegistry", () => {
}),
)
it.effect("loads scoped contributions in stable key order", () =>
it.effect("loads scoped entries in stable key order", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
yield* registry.contribute(contribution("test/second", "second"))
yield* registry.contribute(contribution("test/first", "first"))
yield* registry.register(entry("test/second", "second"))
yield* registry.register(entry("test/first", "first"))
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond")
}),
)
it.effect("re-evaluates contribution producers on each load", () =>
it.effect("re-evaluates entry producers on each load", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
let loads = 0
yield* registry.contribute({
yield* registry.register({
key: SystemContext.Key.make("test/dynamic"),
load: Effect.sync(() => {
loads++
@@ -57,11 +57,11 @@ describe("SystemContextRegistry", () => {
}),
)
it.effect("propagates contribution producer failures", () =>
it.effect("propagates entry producer failures", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
const failure = new Error("contribution failed")
yield* registry.contribute({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
const failure = new Error("entry failed")
yield* registry.register({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
const exit = yield* registry.load().pipe(Effect.exit)
@@ -70,11 +70,11 @@ describe("SystemContextRegistry", () => {
}),
)
it.effect("rejects duplicate source keys from separate contributions", () =>
it.effect("rejects duplicate source keys from separate entries", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
yield* registry.contribute(contribution("test/first", "first", "test/duplicate"))
yield* registry.contribute(contribution("test/second", "second", "test/duplicate"))
yield* registry.register(entry("test/first", "first", "test/duplicate"))
yield* registry.register(entry("test/second", "second", "test/duplicate"))
const exit = yield* registry.load().pipe(Effect.exit)
@@ -86,23 +86,23 @@ describe("SystemContextRegistry", () => {
}),
)
it.effect("rejects duplicate contribution keys", () =>
it.effect("rejects duplicate entry keys", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
yield* registry.contribute(contribution("test/duplicate", "first"))
yield* registry.register(entry("test/duplicate", "first"))
const exit = yield* registry.contribute(contribution("test/duplicate", "second", "test/other")).pipe(Effect.exit)
const exit = yield* registry.register(entry("test/duplicate", "second", "test/other")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context contribution key")
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context entry key")
}),
)
it.effect("removes a contribution when its owning scope closes", () =>
it.effect("removes an entry when its owning scope closes", () =>
Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service
const scope = yield* Scope.make()
yield* registry.contribute(contribution("test/scoped", "scoped")).pipe(Scope.provide(scope))
yield* registry.register(entry("test/scoped", "scoped")).pipe(Scope.provide(scope))
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("scoped")
+1 -1
View File
@@ -115,7 +115,7 @@ const withTool = <A, E, R>(
}).pipe(Effect.provide(Layer.mergeAll(registry, bash)))
}
const call = (input: typeof BashTool.Parameters.Type, id = "call-bash") => ({
const call = (input: typeof BashTool.Input.Type, id = "call-bash") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "bash", input },
+1 -1
View File
@@ -93,7 +93,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, edit)))
}
const call = (input: typeof EditTool.Parameters.Type, id = "call-edit") => ({
const call = (input: typeof EditTool.Input.Type, id = "call-edit") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "edit", input },
+1 -1
View File
@@ -91,7 +91,7 @@ const reset = () => {
result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
}
const call = (input: typeof GlobTool.Parameters.Type, id = "call-glob") => ({
const call = (input: typeof GlobTool.Input.Type, id = "call-glob") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "glob", input },
+1 -1
View File
@@ -151,7 +151,7 @@ function provideLive(directory: string, projectReferences = references({})) {
}
describe("GrepTool", () => {
it.effect("registers the grep contribution", () =>
it.effect("registers grep", () =>
Effect.gen(function* () {
reset()
expect(yield* toolDefinitions(yield* ToolRegistry.Service)).toMatchObject([{ name: "grep" }])
+39 -66
View File
@@ -43,7 +43,7 @@ const withStore = <A, E, R>(
const it = testEffect(Layer.empty)
describe("ToolOutputStore", () => {
it.live("bounds aggregate text and structured output with one managed file", () =>
it.live("bounds the provider-facing text channel with one managed file", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const first = "HEAD-" + "x".repeat(30_000)
@@ -59,15 +59,9 @@ describe("ToolOutputStore", () => {
],
},
})
expect(result.output.structured).toEqual({})
expect(result.output.structured).toEqual({ kind: "report" })
expect(result.outputPaths).toHaveLength(1)
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual({
structured: { kind: "report" },
content: [
{ type: "text", text: first },
{ type: "text", text: second },
],
})
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(first + second)
if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
}),
@@ -79,18 +73,18 @@ describe("ToolOutputStore", () => {
Effect.gen(function* () {
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
const result = yield* store.bound({ sessionID, toolCallID: "call-json", output: { structured, content: [] } })
expect(result.output.structured).toEqual({})
expect(result.output.structured).toEqual(structured)
expect(result.outputPaths).toHaveLength(1)
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual({ structured, content: [] })
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
expect(result.output.content).toHaveLength(1)
}),
),
)
it.live("preserves oversized inline media without duplicating its data", () =>
it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const data = "a".repeat(ToolOutputStore.MAX_BYTES)
const data = "a".repeat(6 * 1024 * 1024)
const result = yield* store.bound({
sessionID,
toolCallID: "call-file",
@@ -100,7 +94,7 @@ describe("ToolOutputStore", () => {
},
})
expect(result.outputPaths).toEqual([])
expect(result.output.structured).toEqual({})
expect(result.output.structured).toEqual({ caption: "pixel" })
expect(result.output.content).toHaveLength(1)
expect(result.output.content[0]).toEqual({
type: "file",
@@ -112,51 +106,38 @@ describe("ToolOutputStore", () => {
),
)
it.live("rejects inline media beyond the settlement media limit", () =>
withStore(({ store }) =>
it.live("preserves structured metadata and native media when bounding text", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const exit = yield* store
.bound({
sessionID,
toolCallID: "call-file-too-large",
output: {
structured: {},
content: [
{
type: "file",
source: { type: "data", data: "a".repeat(ToolOutputStore.MAX_INLINE_MEDIA_BYTES + 1) },
mime: "image/png",
},
],
},
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.MediaLimitError")
const text = "x".repeat(ToolOutputStore.MAX_BYTES + 1)
const media = {
type: "file" as const,
source: { type: "data" as const, data: "aGVsbG8=" },
mime: "image/png",
name: "pixel.png",
}
const result = yield* store.bound({
sessionID,
toolCallID: "call-text-and-media",
output: { structured: { caption: "pixel" }, content: [{ type: "text", text }, media] },
})
expect(result.output.structured).toEqual({ caption: "pixel" })
expect(result.output.content[1]).toEqual(media)
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(text)
}),
),
)
it.live("rejects inline media whose aggregate size exceeds the settlement limit", () =>
it.live("does not double-count structured data duplicated in projected text", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const exit = yield* store
.bound({
sessionID,
toolCallID: "call-files-too-large",
output: {
structured: {},
content: [
{ type: "file", source: { type: "data", data: "a".repeat(3 * 1024 * 1024) }, mime: "image/png" },
{ type: "file", source: { type: "data", data: "b".repeat(3 * 1024 * 1024) }, mime: "image/png" },
],
},
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.MediaLimitError")
const text = "x".repeat(30_000)
const output = { structured: { output: text }, content: [{ type: "text" as const, text }] }
expect(yield* store.bound({ sessionID, toolCallID: "call-duplicated", output })).toEqual({
output,
outputPaths: [],
})
}),
),
)
@@ -179,22 +160,14 @@ describe("ToolOutputStore", () => {
),
)
it.live("fails operationally when output cannot be encoded for bounding", () =>
it.live("does not encode ignored structured metadata when projected content exists", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const exit = yield* store
.bound({
sessionID,
toolCallID: "call-unencodable",
output: {
structured: { value: 1n },
content: [{ type: "text", text: "readable text" }],
},
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.StorageError")
const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] }
expect(yield* store.bound({ sessionID, toolCallID: "call-unencodable", output })).toEqual({
output,
outputPaths: [],
})
}),
),
)
+61 -3
View File
@@ -3,6 +3,7 @@ import { Effect, Exit, Layer } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Image } from "@opencode-ai/core/image"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
@@ -75,13 +76,29 @@ const permission = Layer.succeed(
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
const image = Image.layer.pipe(Layer.provide(config))
const unavailableImage = Layer.succeed(
Image.Service,
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
)
const read = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(permission),
Layer.provide(config),
Layer.provide(image),
)
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, image, read))
const unavailableRead = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(permission),
Layer.provide(config),
Layer.provide(unavailableImage),
)
const itWithoutResizer = testEffect(
Layer.mergeAll(registry, filesystem, permission, config, unavailableImage, unavailableRead),
)
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, read))
const sessionID = SessionV2.ID.make("ses_read_tool_test")
describe("ReadTool", () => {
@@ -146,7 +163,7 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
})
expect(settled.output?.structured).toEqual({})
expect(settled.output?.structured).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" })
expect(settled.output?.content).toMatchObject([
{ type: "text", text: "Image read successfully" },
{ type: "file", mime: "image/png", source: { type: "data", data: png } },
@@ -177,7 +194,7 @@ describe("ReadTool", () => {
})
expect(settled.outputPaths).toBeUndefined()
expect(settled.output?.structured).toEqual({})
expect(settled.output?.structured).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" })
expect(settled.result).toEqual({
type: "content",
value: [
@@ -188,6 +205,30 @@ describe("ReadTool", () => {
}),
)
itWithoutResizer.effect("returns the original image when the resizer is unavailable", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = new FileSystem.BinaryContent({
type: "binary",
content: png,
encoding: "base64",
mime: "image/png",
})
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image-fallback", name: "read", input: { path: "pixel.png" } },
}),
).toMatchObject({
type: "content",
value: [{ type: "text" }, { type: "media", mediaType: "image/png", data: png }],
})
}),
)
it.effect("rejects invalid image data returned by the filesystem", () =>
Effect.gen(function* () {
readResult = new FileSystem.BinaryContent({
@@ -449,6 +490,23 @@ describe("ReadTool", () => {
}),
)
it.effect("returns path validation failures as model-visible tool output", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
resolveFailure = new FileSystem.PathValidationError("Absolute path is not managed tool output")
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-unmanaged", name: "read", input: { path: "/tmp/private.txt" } },
}),
).toEqual({ type: "error", value: "Absolute path is not managed tool output" })
expect(readCalls).toEqual([])
}),
)
it.effect("forwards pagination and returns bounded text pages with continuation", () =>
Effect.gen(function* () {
readResult = new FileSystem.TextPage({
+3 -3
View File
@@ -51,7 +51,7 @@ const reset = () => {
respond = () => Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
}
const call = (input: typeof WebFetchTool.Parameters.Type, id = "call-webfetch") => ({
const call = (input: typeof WebFetchTool.Input.Type, id = "call-webfetch") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "webfetch", input },
@@ -59,7 +59,7 @@ const call = (input: typeof WebFetchTool.Parameters.Type, id = "call-webfetch")
describe("WebFetchTool helpers", () => {
test("defaults format and rejects invalid timeout controls", () => {
const decode = Schema.decodeUnknownSync(WebFetchTool.Parameters)
const decode = Schema.decodeUnknownSync(WebFetchTool.Input)
expect(decode({ url: "https://example.com" })).toEqual({ url: "https://example.com", format: "markdown" })
expect(() => decode({ url: "https://example.com", timeout: 0 })).toThrow()
expect(() => decode({ url: "https://example.com", timeout: WebFetchTool.MAX_TIMEOUT_SECONDS + 1 })).toThrow()
@@ -72,7 +72,7 @@ describe("WebFetchTool helpers", () => {
})
})
describe("WebFetchTool contribution", () => {
describe("WebFetchTool registration", () => {
it.effect("registers and fetches an ordinary hostname HTTP URL without rewriting it", () =>
Effect.gen(function* () {
reset()
+2 -2
View File
@@ -18,7 +18,7 @@ const payload = (text: string) =>
describe("WebSearchTool provider selection", () => {
test("rejects out-of-range numeric controls", () => {
const decode = Schema.decodeUnknownSync(WebSearchTool.Parameters)
const decode = Schema.decodeUnknownSync(WebSearchTool.Input)
expect(() => decode({ query: "x", numResults: 0 })).toThrow()
expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
@@ -122,7 +122,7 @@ const websearch = WebSearchTool.layer.pipe(
)
const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, websearch))
describe("WebSearchTool contribution", () => {
describe("WebSearchTool registration", () => {
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
Effect.gen(function* () {
requests.length = 0
+1 -1
View File
@@ -76,7 +76,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, write)))
}
const call = (input: typeof WriteTool.Parameters.Type, id = "call-write") => ({
const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "write", input },

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