Compare commits

...

22 Commits

Author SHA1 Message Date
Filip 4a6f5635f9 fix(core): adopt consolidated migration aliases
The V1-to-V2 consolidation replaced a shipped migration chain with a new ID, causing upgraded databases to replay already-applied schema changes. Treat the terminal pre-consolidation migration as an alias and record the canonical migration without rerunning its SQL.
2026-08-11 12:43:37 +00:00
opencode-agent[bot] 306204bad8 fix(app): handle untitled session tab info (#41700)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-11 18:02:10 +10:00
Aiden Cline 47c8d85904 feat(plugin): allow tool hooks to fail with tool errors (#41668) 2026-08-11 00:36:59 -05:00
Aiden Cline c401076b6f fix(core): preserve AI SDK tool media (#41672) 2026-08-11 00:33:33 -05:00
Luke Parker 1aef4de853 chore(desktop): skip unused beta CLI builds (#41673) 2026-08-11 15:07:21 +10:00
opencode-agent[bot] 9c94634515 fix(tui): use active model for compaction (#41608)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-10 23:50:51 -05:00
Luke Parker c2b754bb03 feat(desktop): publish v2 beta desktop (#41626) 2026-08-11 14:33:31 +10:00
Aiden Cline 2372edd5eb feat(tui): show previous agent in switch notices (#41661) 2026-08-10 23:09:26 -05:00
Kit Langton a5f7f8d3b5 fix(tui): deduplicate repeated image attachments (#41651) 2026-08-10 23:39:38 -04:00
Aiden Cline 4df276b9e8 feat(session): persist previous agent on switch (#41621) 2026-08-10 22:35:16 -05:00
Kit Langton 518af92c5b refactor(core): move the models.dev catalog cache from disk to KV (#41649) 2026-08-10 22:40:41 -04:00
Kit Langton 5d8c48718b refactor(core): move instruction discovery to the config side (#41629) 2026-08-10 22:32:58 -04:00
Kit Langton 41c5853739 refactor: route Global path consumers through the service (#41632) 2026-08-10 21:37:09 -04:00
Kit Langton 8d989f6829 fix(tui): collapse execute child details (#41624) 2026-08-10 21:20:51 -04:00
Kit Langton 753fa4cd60 fix(core): isolate config plugin tests from ambient user config (#41631) 2026-08-11 01:16:08 +00:00
opencode-agent[bot] d99a268ea5 chore: generate 2026-08-11 00:39:33 +00:00
Kit Langton 1113adfd5e refactor(core): skill service stores values, config plugin owns the filesystem (#41622) 2026-08-10 20:38:08 -04:00
Kit Langton 6afff5a55f fix(util): no filesystem side effects at global module load (#41619) 2026-08-10 20:38:04 -04:00
Kit Langton 71f5e4189d refactor(core): move plugin discovery and watching to the config side (#41618) 2026-08-10 20:15:08 -04:00
Kit Langton b49bad9a86 fix(merman): remove sequence lifeline fade (#41623) 2026-08-10 19:48:28 -04:00
Kit Langton 2580f880a8 feat(merman): refine sequence diagram styling (#41617) 2026-08-10 19:12:46 -04:00
Kit Langton 9d34029cd9 fix(core): runtime-neutral legacy credential import (#41607) 2026-08-10 18:35:52 -04:00
112 changed files with 2991 additions and 1753 deletions
+13 -10
View File
@@ -75,7 +75,7 @@ jobs:
build-cli:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
with:
@@ -91,7 +91,7 @@ jobs:
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build legacy CLI
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
@@ -109,7 +109,7 @@ jobs:
GH_TOKEN: ${{ steps.committer.outputs.token }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
with:
name: opencode-cli
path: |
@@ -117,7 +117,7 @@ jobs:
packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
@@ -132,7 +132,7 @@ jobs:
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
strategy:
fail-fast: false
matrix:
@@ -184,7 +184,7 @@ jobs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -377,7 +377,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
RUST_TARGET: ${{ matrix.settings.target }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
- name: Build
run: bun run build
@@ -393,6 +393,7 @@ jobs:
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
- name: Package
if: needs.version.outputs.release
@@ -496,29 +497,31 @@ jobs:
registry-url: "https://registry.npmjs.org"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
with:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
with:
name: opencode-cli-windows
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
if: github.ref_name != 'v2' && github.ref_name != 'beta'
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with:
name: opencode-preview-cli
path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with:
pattern: opencode-node-cli-*
path: packages/cli/dist/node
+1
View File
@@ -439,6 +439,7 @@
"@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
+2 -2
View File
@@ -351,8 +351,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const key = tabKey(tab)
const next = { title: session.title, directory: session.location.directory }
const current = info[key]
console.log({ tab, session, current })
if (current?.title === next.title && current.directory === next.directory) return
if (current && current.title === next.title && current.directory === next.directory) return
console.debug("[tabs] update persisted session info", { key, sessionID: session.id, current, next })
setInfo(key, next)
},
select: navigateTab,
@@ -4,6 +4,7 @@ import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
import { Config } from "../../config"
import { resolve } from "@opencode-ai/tui/config"
import { Global } from "@opencode-ai/util/global"
export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () {
@@ -16,6 +17,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
mismatch: "replace",
})
const config = yield* Config.Service
const global = yield* Global.Service
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
const fileSystem = yield* FileSystem.FileSystem
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
@@ -39,6 +41,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
config: {
update: (update) => runServicePromise(config.update(update)),
},
paths: { home: global.home, state: global.state, log: global.log },
}),
)
}),
+2 -7
View File
@@ -1,6 +1,5 @@
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
import { Global } from "@opencode-ai/util/global"
import fs from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"
@@ -129,13 +128,9 @@ export async function usingInteractiveStdin<T>(
export function createMiniHost(input: {
terminal: InteractiveStdin
directory: string
paths?: { home: string; state: string; log: string }
paths: { home: string; state: string; log: string }
}): MiniHost {
const paths = input.paths ?? {
home: Global.Path.home,
state: Global.Path.state,
log: Global.Path.log,
}
const paths = input.paths
const diagnostics = {
pid: process.pid,
cwd: input.directory,
+2 -1
View File
@@ -22,6 +22,7 @@ export type MiniCommandInput = {
demo?: boolean
tuiConfig?: MiniFrontendInput["tuiConfig"]
config?: MiniFrontendInput["config"]
paths: { home: string; state: string; log: string }
}
type Model = MiniFrontendInput["model"]
@@ -104,7 +105,7 @@ export async function runMini(input: MiniCommandInput) {
}))
const frontend = await frontendTask
return frontend.runMiniFrontend({
host: createMiniHost({ terminal, directory }),
host: createMiniHost({ terminal, directory, paths: input.paths }),
sdk,
directory,
target: resolveTarget,
+2 -1
View File
@@ -39,7 +39,8 @@ export const run = Effect.fnUntraced(function* (options: Options) {
})
const processEffect = Effect.fnUntraced(function* (options: Options) {
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
return yield* Effect.scoped(
Effect.gen(function* () {
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
@@ -41,6 +41,7 @@ export type SessionMessageAgentSelected = {
time: { created: number }
type: "agent-switched"
agent: string
previous?: string
}
export type PromptBase64 = string
@@ -2535,6 +2536,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
readonly previous?: string
}
| {
readonly id: string
@@ -2786,6 +2788,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
readonly previous?: string
}
| {
readonly id: string
@@ -3037,6 +3040,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
readonly previous?: string
}
| {
readonly id: string
+16 -1
View File
@@ -508,8 +508,23 @@ function toolOutput(result: ToolResultValue) {
case "text":
case "error":
return { type: "text" as const, value: messageValue(result.value) }
case "content":
return {
type: "content" as const,
value: result.value.map((item) => {
if (item.type === "text") return { type: "text" as const, text: item.text }
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1]
const image = item.mime.toLowerCase().startsWith("image/")
if (data !== undefined)
return image
? { type: "image-data" as const, data, mediaType: item.mime }
: { type: "file-data" as const, data, mediaType: item.mime, filename: item.name }
return image ? { type: "image-url" as const, url: item.uri } : { type: "file-url" as const, url: item.uri }
}),
}
case "json":
return { type: "json" as const, value: jsonValue(result.value) }
}
return { type: "json" as const, value: jsonValue(result.value) }
}
function tool(input: ToolDefinition): LanguageModelV3FunctionTool {
+11 -2
View File
@@ -11,6 +11,7 @@ import { ChildProcess } from "effect/unstable/process"
import { Config } from "./config"
import { Location } from "./location"
import { ShellSelect } from "./shell/select"
import { Global } from "@opencode-ai/util/global"
export const Info = Command.Info
export type Info = Command.Info
@@ -61,6 +62,7 @@ export const layer = (options?: ShellSelect.Options) =>
const processes = yield* AppProcess.Service
const config = yield* Config.Service
const location = yield* Location.Service
const global = yield* Global.Service
const state = State.create<Data, Draft>({
name: "command",
initial: () => ({ commands: new Map() }),
@@ -111,6 +113,7 @@ export const layer = (options?: ShellSelect.Options) =>
location,
processes,
shell: options,
bin: global.bin,
})
const prompt = (yield* mcp.prompts()).find(
@@ -164,6 +167,7 @@ function evaluateTemplate(
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options
readonly bin: string
},
) {
return Effect.gen(function* () {
@@ -197,11 +201,16 @@ const evaluateShell = Effect.fnUntraced(function* (
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options
readonly bin: string
},
) {
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"), services.shell)
const shell = ShellSelect.preferred(
Config.latest(yield* services.config.entries(), "shell"),
services.shell,
services.bin,
)
const outputs = yield* Effect.forEach(
matches,
(match) => {
@@ -262,7 +271,7 @@ export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node],
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node, Global.node],
})
}
@@ -0,0 +1,122 @@
export * as ConfigInstructionPlugin from "./instruction"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { dirname, join } from "path"
import { Effect, PubSub, Semaphore, Stream } from "effect"
import { Watcher } from "../../filesystem/watcher"
import { InstructionDiscovery } from "../../instruction-discovery"
import { Instructions } from "../../instructions/index"
import { Location } from "../../location"
import { AbsolutePath } from "../../schema"
type Loaded =
| { readonly type: "available"; readonly files: InstructionDiscovery.File[] }
| { readonly type: "unavailable" }
export const Plugin = define({
id: "opencode.config.instruction",
effect: Effect.fn(function* () {
const discovery = yield* InstructionDiscovery.Service
yield* Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const start = yield* fs.resolve(location.directory)
const stop = yield* fs.resolve(location.project.directory)
const project = discovery.project && FSUtil.contains(stop, start)
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
const publish = (update: Watcher.Update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)
const candidates = [
globalFile,
...(project ? ancestorDirectories(start, stop).map((directory) => join(directory, "AGENTS.md")) : []),
]
for (const path of new Set(candidates)) {
const updates = yield* watcher.subscribe({ path, type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
}
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (path: string) {
const content = yield* fs.readFileStringSafe(path)
if (content !== undefined) return new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
yield* Effect.logDebug("instruction file skipped", { path, reason: "unavailable" })
})
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
const file = yield* read(globalFile)
return file ? [file] : []
})
const projectSource = Effect.fn("ConfigInstructionPlugin.projectSource")(function* () {
if (!project) return []
const discovered = new Set(
yield* Effect.forEach(yield* fs.up({ targets: ["AGENTS.md"], start, stop }), fs.resolve),
)
const files = yield* Effect.forEach(discovered, read, { concurrency: "unbounded" })
if (files.some((file) => file === undefined)) return Instructions.unavailable
return files.filter((file): file is InstructionDiscovery.File => file !== undefined)
})
const isolate = <A, E, R>(source: string, effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load instruction source", { source, cause }).pipe(
Effect.as(Instructions.unavailable),
),
),
)
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(function* (file?: string) {
yield* lock.withPermit(
Effect.gen(function* () {
const sources = yield* Effect.all({
global: isolate("global", globalSource()),
project: isolate("project", projectSource()),
})
loaded.current =
Array.isArray(sources.global) && Array.isArray(sources.project)
? { type: "available", files: [...sources.global, ...sources.project] }
: { type: "unavailable" }
if (!file) return
yield* Effect.logDebug("instructions rescanned", {
file,
instructions:
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
})
}),
)
})
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
yield* discovery.transform((draft) => {
if (loaded.current.type === "unavailable") {
draft.unavailable()
return
}
for (const file of loaded.current.files) draft.add(file)
})
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to activate instruction source", { cause }).pipe(
Effect.andThen(discovery.transform((draft) => draft.unavailable())),
Effect.asVoid,
),
),
)
}),
})
function ancestorDirectories(start: string, stop: string): string[] {
if (start === stop) return [start]
return [start, ...ancestorDirectories(dirname(start), stop)]
}
@@ -0,0 +1,57 @@
export * as SkillFile from "./skill-file"
import path from "path"
import { Result, Schema, type SchemaIssue, SchemaParser } from "effect"
import { ConfigMarkdown } from "../markdown"
import { AbsolutePath } from "../../schema"
import { Skill } from "../../skill"
const Frontmatter = Schema.Struct({
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
slash: Schema.Boolean.pipe(Schema.optional),
metadata: Schema.Unknown.pipe(Schema.optional),
})
const decodeFrontmatter = SchemaParser.decodeUnknownResult(Frontmatter)
export type ParseResult =
| { readonly _tag: "Parsed"; readonly skill: Skill.Info }
| { readonly _tag: "Skipped"; readonly reason: "markdown" }
| { readonly _tag: "Skipped"; readonly reason: "frontmatter"; readonly issue: SchemaIssue.Issue }
const metadataBoolean = (metadata: unknown, key: string) => {
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
return undefined
}
const value = Reflect.get(metadata, key)
if (typeof value === "boolean") return value
if (typeof value !== "string") return undefined
const normalized = value.trim().toLowerCase()
if (normalized === "true") return true
if (normalized === "false") return false
return undefined
}
export function parse(directory: string, filepath: string, content: string): ParseResult {
const markdown = ConfigMarkdown.parseOption(content)
if (!markdown) return { _tag: "Skipped", reason: "markdown" }
const decoded = decodeFrontmatter(markdown.data)
if (Result.isFailure(decoded)) return { _tag: "Skipped", reason: "frontmatter", issue: decoded.failure }
const frontmatter = decoded.success
const id =
path.dirname(filepath) === directory ? path.basename(filepath, ".md") : path.basename(path.dirname(filepath))
const slash = metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash
const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
return {
_tag: "Parsed",
skill: {
id: Skill.ID.make(id),
name: Skill.Name.make(frontmatter.name ?? id),
...(frontmatter.description === undefined ? {} : { description: frontmatter.description }),
...(slash === undefined ? {} : { slash }),
...(autoinvoke === undefined ? {} : { autoinvoke }),
location: AbsolutePath.make(filepath),
content: markdown.content,
},
}
}
+149 -22
View File
@@ -1,64 +1,191 @@
export * as ConfigSkillPlugin from "./skill"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Entry } from "@opencode-ai/schema/config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import path from "path"
import { Effect, Stream } from "effect"
import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
import { Config } from "../../config"
import { Watcher } from "../../filesystem/watcher"
import { Location } from "../../location"
import { AbsolutePath } from "../../schema"
import { Skill } from "../../skill"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../../location"
import { SkillDiscovery } from "../../skill/discovery"
import { SkillFile } from "./skill-file"
type Source = Skill.DirectorySource | Skill.UrlSource
export const Plugin = define({
id: "opencode.config.skill",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.skill.transform((draft) => {
const watcher = yield* Watcher.Service
const loaded: { entries: Entry[]; skills: Skill.Info[] } = {
entries: yield* config.entries(),
skills: [],
}
const watches = yield* FiberMap.make<string>()
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe({ path: target, type })
yield* FiberMap.run(
watches,
`${type}:${target}`,
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
{ onlyIfMissing: true, startImmediately: true },
)
})
function firstMissing(target: string): Effect.Effect<string | undefined> {
const parent = path.dirname(target)
if (parent === target) return Effect.succeed(undefined)
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
}
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn(
"ConfigSkillPlugin.watchDirectory",
)(function* (directory: string) {
const target = path.resolve(directory)
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (resolved) {
yield* watch(resolved, "directory")
if (resolved !== target) yield* watch(target, "file")
return resolved === target ? [target] : [target, resolved]
}
const missing = yield* firstMissing(target)
if (missing) yield* watch(missing, "file")
if (
yield* fs.realPath(directory).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
) {
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
return yield* watchDirectory(directory)
}
return [target]
})
const sources = () => {
const result: Source[] = []
const add = (source: Source) => {
if (result.some((item) => Skill.Source.equals(item, source))) return
result.push(source)
}
const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : []))
const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : []))
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of [...claude, ...agents]) {
draft.source(
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
}
for (const directory of directories) {
draft.source(
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
draft.source(
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }))
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
draft.source(Skill.UrlSource.make({ type: "url", url: item }))
add(Skill.UrlSource.make({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
draft.source(
add(
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
}
return result
}
const load = Effect.fn("ConfigSkillPlugin.load")(function* (source: Source) {
const directories =
source.type === "directory"
? [source.path]
: yield* discovery.pull(source.url).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load skill source", {
source: Skill.Source.key(source),
cause,
}).pipe(Effect.as([] as AbsolutePath[])),
),
)
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
const skills: Skill.Info[] = []
for (const directory of directories) {
const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const filepath of files.toSorted()) {
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(path.dirname(resolved), "directory")
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!content) continue
const parsed = SkillFile.parse(directory, filepath, content)
if (parsed._tag === "Skipped") {
yield* Effect.logDebug("skill file skipped", {
filepath,
reason: parsed.reason,
...(parsed.reason === "frontmatter" ? { issue: parsed.issue } : {}),
})
continue
}
skills.push(parsed.skill)
}
}
yield* Effect.logDebug("skill source loaded", {
source: Skill.Source.key(source),
type: source.type,
directories,
skills: skills.map((skill) => skill.id),
})
return skills
})
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(function* (file?: string) {
yield* lock.withPermit(
Effect.gen(function* () {
yield* FiberMap.clear(watches)
const skills = new Map<Skill.ID, Skill.Info>()
const current = sources()
for (const source of current) {
for (const skill of yield* load(source)) skills.set(skill.id, skill)
}
loaded.skills = Array.from(skills.values())
if (file) {
yield* Effect.logInfo("skills rescanned", {
file,
sources: current.map(Skill.Source.key),
skills: loaded.skills.map((skill) => skill.id),
})
}
}),
)
})
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
yield* ctx.skill.transform((draft) => {
for (const skill of loaded.skills) draft.add(skill)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(refresh()),
Effect.andThen(ctx.skill.reload()),
),
),
+179
View File
@@ -0,0 +1,179 @@
export * as ConfigPluginSource from "./source"
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "../../config"
import { Watcher } from "../../filesystem/watcher"
import { Location } from "../../location"
export type Operation =
| {
readonly type: "add"
readonly target: string
readonly options: Record<string, unknown>
readonly mtime?: number
}
| {
readonly type: "remove"
readonly target: string
}
export interface Interface {
readonly operations: () => Effect.Effect<readonly Operation[], never, Scope.Scope>
readonly changes: () => Stream.Stream<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigPluginSource") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const watcher = yield* Watcher.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
// Configured local plugin files can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
// Watches start on first sighting and are never torn down individually:
// a stale watch after a config edit costs one deduped fs handle and a
// no-op activation, and every watch dies with this layer's scope.
const watchConfiguredSources = Effect.fn("ConfigPluginSource.watchConfiguredSources")(function* (
entries: readonly Entry[],
operations: readonly Operation[],
) {
for (const operation of operations) {
if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue
if (watched.has(operation.target)) continue
// The config change feed already covers {plugin,plugins} directories.
if (isPluginSource(entries, operation.target)) continue
// Directory targets can't hot-reload (their stat mtime ignores edits
// inside), so don't watch what can't trigger anything.
if (yield* fs.isDir(operation.target)) continue
watched.add(operation.target)
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
yield* updates.pipe(
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
Effect.catchCause((cause) =>
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
),
Effect.forkScoped({ startImmediately: true }),
)
}
})
return Service.of({
operations: Effect.fn("ConfigPluginSource.operations")(function* () {
const entries = yield* config.entries()
const operations = yield* scan(fs, location, entries)
yield* watchConfiguredSources(entries, operations)
return operations
}),
changes: () =>
Stream.merge(
config.changes().pipe(
Stream.filterEffect((update) =>
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
),
Stream.map(() => undefined),
),
Stream.fromPubSub(configuredChanges),
),
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Watcher.node, Location.node],
})
export const empty = makeLocationNode({
service: Service,
layer: Layer.succeed(
Service,
Service.of({
operations: () => Effect.succeed([]),
changes: () => Stream.never,
}),
),
deps: [],
})
function parse(input: ConfigPlugin.Plugin): Operation {
if (typeof input !== "string") {
return { type: "add", target: input.package, options: input.options ?? {} }
}
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
return { type: "remove", target: input.slice(1) }
}
const scan = Effect.fn("ConfigPluginSource.scan")(function* (
fs: FSUtil.Interface,
location: Location.Interface,
entries: readonly Entry[],
) {
const discovered = yield* Effect.forEach(
entries.filter((entry): entry is Directory => entry.type === "directory"),
(entry) => discoverDirectory(fs, entry.path),
).pipe(Effect.map((items) => items.flat()))
const configured = entries
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) =>
(entry.info.plugins ?? []).map(parse).map((operation) => {
if (operation.type === "remove") return operation
const directory = entry.path ? path.dirname(entry.path) : location.directory
const target = operation.target.startsWith("file://")
? fileURLToPath(operation.target)
: operation.target.startsWith("./") || operation.target.startsWith("../")
? path.resolve(directory, operation.target)
: operation.target
return { ...operation, target }
}),
)
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.catch(() => Effect.succeed(operation)),
)
})
})
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.scan("{plugin,plugins}/*.{ts,js}", {
cwd: directory,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
})
}
const sourceDirectories = ["plugin", "plugins"] as const
function isPluginSource(entries: readonly Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
)
}
+10 -7
View File
@@ -40,16 +40,19 @@ const databaseLayer = Layer.effect(
)
export function layer(options: Options = { path: ":memory:" }) {
return Layer.suspend(() => {
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
return provide(join(Global.Path.data, filename))
})
return Layer.unwrap(
Effect.gen(function* () {
const global = yield* Global.Service
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
return provide(join(global.data, filename))
}),
)
}
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [] })
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
}
export const node = configured({ path: ":memory:" })
+11 -1
View File
@@ -5,6 +5,7 @@ import { Effect, Semaphore } from "effect"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { migrations } from "./migration.gen"
import schema from "./schema.gen"
import { Global } from "@opencode-ai/util/global"
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
@@ -12,8 +13,9 @@ const lock = Semaphore.makeUnsafe(1)
export type Migration = {
id: string
aliases?: string[]
foreignKeys?: boolean
up: (tx: Transaction) => Effect.Effect<void, unknown>
up: (tx: Transaction) => Effect.Effect<void, unknown, Global.Service>
}
export function apply(db: Database) {
@@ -76,6 +78,14 @@ export function applyOnly(db: Database, input: Migration[]) {
for (const migration of input) {
if (completed.has(migration.id)) continue
const alias = migration.aliases?.find((id) => completed.has(id))
if (alias) {
yield* db.run(
sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`,
)
yield* Effect.logInfo("database migration adopted", { migration: migration.id, alias })
continue
}
const started = Date.now()
yield* Effect.logInfo("database migration started", { migration: migration.id })
const apply = db.transaction((tx) =>
@@ -3,6 +3,7 @@ import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
id: "20260804233008_loose_psylocke",
aliases: ["20260730195856_optional_session_title"],
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import { sql } from "drizzle-orm"
import { Effect, Option, Schema } from "effect"
@@ -33,7 +34,10 @@ const wellKnownSourcesKey = "wellknown:sources"
const migration: DatabaseMigration.Migration = {
id: "20260805200742_import_legacy_credentials",
up(tx) {
return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json"))
return Effect.gen(function* () {
const global = yield* Global.Service
return yield* importLegacyCredentials(tx, path.join(global.data, "auth.json"))
})
},
}
@@ -41,9 +45,9 @@ export default migration
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
return Effect.gen(function* () {
const file = Bun.file(filepath)
if (!(yield* Effect.promise(() => file.exists()))) return
const input = Option.getOrUndefined(decodeJson(yield* Effect.promise(() => file.text())))
const content = yield* Effect.promise(() => readFile(filepath, "utf8").catch(() => undefined))
if (content === undefined) return
const input = Option.getOrUndefined(decodeJson(content))
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return yield* Effect.fail(new Error("Legacy credential file must contain an object"))
}
+7 -6
View File
@@ -467,10 +467,11 @@ function updateProgress(progress: Progress) {
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
}
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service> {
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
return lock.withPermit(
Effect.gen(function* () {
const { db } = yield* Database.Service
const global = yield* Global.Service
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
@@ -478,7 +479,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(Global.Path.data).root}, ${now}, ${now}, '[]')
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
@@ -492,7 +493,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options))
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
@@ -502,7 +503,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options), (completed) => {
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
@@ -621,10 +622,10 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
)
}
function nextPath(options: Options) {
function nextPath(options: Options, data: string) {
if (options.nextDatabasePath) return options.nextDatabasePath
if (process.env.OPENCODE_DB === ":memory:") return undefined
return path.join(Global.Path.data, "opencode-next.db")
return path.join(data, "opencode-next.db")
}
function openNextDatabase(sourcePath: string) {
+4 -1
View File
@@ -7,6 +7,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { Global } from "@opencode-ai/util/global"
import { Config } from "./config"
import { Location } from "./location"
import { make, type Info } from "./formatter/builtins"
@@ -25,6 +26,7 @@ const layer = Layer.effect(
const location = yield* Location.Service
const npm = yield* Npm.Service
const processes = yield* AppProcess.Service
const global = yield* Global.Service
const commands = new Map<string, string[] | false>()
let formatters: Info[] = []
@@ -42,6 +44,7 @@ const layer = Layer.effect(
fs,
npm,
processes,
bin: global.bin,
})
formatters = builtIns
if (configured === true) return
@@ -122,5 +125,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
})
+34 -26
View File
@@ -18,8 +18,10 @@ export function make(input: {
readonly fs: FSUtil.Interface
readonly npm: Npm.Interface
readonly processes: AppProcess.Interface
readonly bin: string
}) {
const disabled = false as const
const findExecutable = (name: string) => which(name, undefined, input.bin)
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
const commandOutput = (command: string[]) =>
@@ -37,7 +39,7 @@ export function make(input: {
name: "gofmt",
extensions: [".go"],
enabled: Effect.sync(() => {
const match = which("gofmt")
const match = findExecutable("gofmt")
return match ? [match, "-w", "$FILE"] : disabled
}),
}
@@ -46,7 +48,7 @@ export function make(input: {
name: "mix",
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
enabled: Effect.sync(() => {
const match = which("mix")
const match = findExecutable("mix")
return match ? [match, "format", "$FILE"] : disabled
}),
}
@@ -149,7 +151,7 @@ export function make(input: {
name: "zig",
extensions: [".zig", ".zon"],
enabled: Effect.sync(() => {
const match = which("zig")
const match = findExecutable("zig")
return match ? [match, "fmt", "$FILE"] : disabled
}),
}
@@ -159,7 +161,7 @@ export function make(input: {
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
enabled: Effect.gen(function* () {
if (!(yield* findUp(".clang-format")).length) return disabled
const match = which("clang-format")
const match = findExecutable("clang-format")
return match ? [match, "-i", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
@@ -168,7 +170,7 @@ export function make(input: {
name: "ktlint",
extensions: [".kt", ".kts"],
enabled: Effect.sync(() => {
const match = which("ktlint")
const match = findExecutable("ktlint")
return match ? [match, "-F", "$FILE"] : disabled
}),
}
@@ -177,17 +179,18 @@ export function make(input: {
name: "ruff",
extensions: [".py", ".pyi"],
enabled: Effect.gen(function* () {
if (!which("ruff")) return disabled
const bin = findExecutable("ruff")
if (!bin) return disabled
for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) {
const found = yield* findUp(config)
if (!found.length) continue
if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) {
return ["ruff", "format", "$FILE"]
return [bin, "format", "$FILE"]
}
}
for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) {
const found = yield* findUp(dependency)
if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"]
if (found.length && (yield* readText(found[0])).includes("ruff")) return [bin, "format", "$FILE"]
}
return disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
@@ -197,7 +200,7 @@ export function make(input: {
name: "air",
extensions: [".R"],
enabled: Effect.gen(function* () {
const bin = which("air")
const bin = findExecutable("air")
if (!bin) return disabled
const output = yield* commandOutput([bin, "--help"])
if (output._tag === "None" || output.value.exitCode !== 0) return disabled
@@ -210,34 +213,34 @@ export function make(input: {
name: "uv",
extensions: [".py", ".pyi"],
enabled: Effect.gen(function* () {
const bin = which("uv")
const bin = findExecutable("uv")
if (!bin) return disabled
const output = yield* commandOutput([bin, "format", "--help"])
return output._tag === "Some" && output.value.exitCode === 0 ? [bin, "format", "--", "$FILE"] : disabled
}),
}
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"])
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"])
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"])
const dart = executable("dart", [".dart"], ["format", "$FILE"])
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"], findExecutable)
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"], findExecutable)
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"], findExecutable)
const dart = executable("dart", [".dart"], ["format", "$FILE"], findExecutable)
const ocamlformat: Info = {
name: "ocamlformat",
extensions: [".ml", ".mli"],
enabled: Effect.gen(function* () {
if (!(yield* findUp(".ocamlformat")).length) return disabled
const match = which("ocamlformat")
const match = findExecutable("ocamlformat")
return match ? [match, "-i", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"])
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"])
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"])
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"])
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"])
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"])
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"], findExecutable)
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"], findExecutable)
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"], findExecutable)
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"], findExecutable)
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"], findExecutable)
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"], findExecutable)
const pint: Info = {
name: "pint",
@@ -253,9 +256,9 @@ export function make(input: {
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"])
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"])
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"])
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"], findExecutable)
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"], findExecutable)
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"], findExecutable)
return [
gofmt,
@@ -287,12 +290,17 @@ export function make(input: {
] satisfies Info[]
}
function executable(name: string, extensions: readonly string[], args: string[]): Info {
function executable(
name: string,
extensions: readonly string[],
args: string[],
findExecutable: (name: string) => string | null,
): Info {
return {
name,
extensions,
enabled: Effect.sync(() => {
const match = which(name)
const match = findExecutable(name)
return match ? [match, ...args] : false
}),
}
+64 -56
View File
@@ -1,15 +1,13 @@
export * as InstructionDiscovery from "./instruction-discovery"
import { Array, Context, Effect, Layer, Schema } from "effect"
import { isAbsolute, join, relative, sep } from "path"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { Instructions } from "./instructions/index"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus"
import { Instructions } from "./instructions/index"
import { AbsolutePath } from "./schema"
import { State } from "./state"
class File extends Schema.Class<File>("InstructionDiscovery.File")({
export class File extends Schema.Class<File>("InstructionDiscovery.File")({
path: AbsolutePath,
content: Schema.String,
}) {}
@@ -17,7 +15,30 @@ class File extends Schema.Class<File>("InstructionDiscovery.File")({
const Files = Schema.Array(File)
const key = Instructions.Key.make("core/instructions")
export interface Interface {
export const Event = {
Updated: Bus.ephemeral({ type: "instruction-discovery.updated", schema: {} }),
}
export type Data = {
files: Map<AbsolutePath, Types.DeepMutable<File>>
available: boolean
}
export type Draft = {
list: () => readonly Types.DeepMutable<File>[]
// Map insertion order is render order: config adds global then nearest-to-farthest project files;
// sibling contributors interleave by transform registration order.
add: (file: File) => void
update: (path: string, update: (file: Types.DeepMutable<File>) => void) => void
remove: (path: string) => void
unavailable: () => void
}
export interface Interface extends State.Transformable<Draft> {
// Discovery policy lives here because internal plugins have no per-composition options channel.
// Move it into plugin config once plugins can consume their own options.
readonly project: boolean
readonly list: () => Effect.Effect<File[] | Instructions.Unavailable>
readonly load: () => Effect.Effect<Instructions.List>
}
@@ -32,9 +53,26 @@ export const layer = (options?: Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const bus = yield* Bus.Service
const state = State.create<Data, Draft>({
name: "instruction-discovery",
initial: () => ({ files: new Map(), available: true }),
draft: (draft) => ({
list: () => Array.from(draft.files.values()),
add: (file) => draft.files.set(file.path, new File(file) as Types.DeepMutable<File>),
update: (path, update) => {
const current = draft.files.get(AbsolutePath.make(path))
if (!current) return
update(current)
current.path = AbsolutePath.make(path)
},
remove: (path) => draft.files.delete(AbsolutePath.make(path)),
unavailable: () => {
draft.available = false
},
}),
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
Instructions.make<ReadonlyArray<File>>({
@@ -49,52 +87,22 @@ export const layer = (options?: Options) =>
},
})
const observe = Effect.fn("InstructionDiscovery.observe")(function* () {
const start = yield* fs.resolve(location.directory)
const stop = yield* fs.resolve(location.project.directory)
const fromProject = relative(stop, start)
const insideProject =
fromProject === "" ||
(fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject))
const discovered = new Set(
yield* Effect.forEach(
options?.project === false || !insideProject
? []
: yield* fs.up({
targets: ["AGENTS.md"],
start,
stop,
}),
fs.resolve,
),
)
const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered])
const files = yield* Effect.forEach(
paths,
(path) =>
fs
.readFileStringSafe(path)
.pipe(
Effect.map((content) =>
content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }),
),
),
{ concurrency: "unbounded" },
)
if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
return Instructions.unavailable
return files.filter((file): file is File => file !== undefined)
const list = Effect.fn("InstructionDiscovery.list")(function* () {
const current = state.get()
if (!current.available) return Instructions.unavailable
return Array.from(current.files.values())
})
return Service.of({
load: () =>
observe().pipe(
Effect.map((files) =>
Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files),
),
Effect.catch(() => Effect.succeed(source(Instructions.unavailable))),
Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))),
),
project: options?.project !== false,
transform: state.transform,
reload: state.reload,
list,
load: Effect.fn("InstructionDiscovery.load")(function* () {
const files = yield* list()
if (!Array.isArray(files)) return source(files)
return source(files.length === 0 ? Instructions.removed : files)
}),
})
}),
)
@@ -103,7 +111,7 @@ export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [FSUtil.node, Global.node, Location.node],
deps: [Bus.node],
})
}
+63 -56
View File
@@ -1,11 +1,8 @@
import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money"
import { App } from "./app"
import { Global } from "@opencode-ai/util/global"
import { Flock } from "@opencode-ai/util/flock"
import { Hash } from "@opencode-ai/util/hash"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bus } from "./bus"
@@ -13,6 +10,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Model } from "./model"
import { Provider } from "./provider"
import { KV } from "./kv"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -537,6 +535,18 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
const Cache = Schema.Struct({
updatedAt: Schema.Number,
body: CatalogJson,
})
const defaultSource = "https://models.opencode.ai"
function cacheKey(source: string) {
if (source === defaultSource) return "models-dev:catalog"
return `models-dev:catalog:${Hash.fast(source)}`
}
export const layer = (options?: Options) =>
Layer.effect(
Service,
@@ -544,6 +554,7 @@ export const layer = (options?: Options) =>
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const app = yield* App.Metadata
const kv = yield* KV.Service
const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
@@ -554,21 +565,28 @@ export const layer = (options?: Options) =>
),
)
const source = options?.url || "https://models.opencode.ai"
const source = options?.url || defaultSource
const fetch = options?.fetch ?? true
const userAgent = App.useragent(app)
const filepath = path.join(
Global.Path.cache,
source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`,
)
const key = cacheKey(source)
const ttl = Duration.minutes(5)
const lockKey = `models-dev:${filepath}`
const lock = Semaphore.makeUnsafe(1)
const loadFromCache = Effect.fnUntraced(function* () {
const value = yield* kv.get(key)
const cached = Schema.decodeUnknownOption(Cache)(value)
if (Option.isSome(cached))
return {
catalog: cached.value.body as Record<string, SourceProvider>,
updatedAt: cached.value.updatedAt,
}
if (value !== undefined) yield* kv.remove(key)
})
const fresh = Effect.fnUntraced(function* () {
const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!stat) return false
const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime()
return Date.now() - mtime < Duration.toMillis(ttl)
const cached = yield* loadFromCache()
if (!cached) return false
return Date.now() - cached.updatedAt < Duration.toMillis(ttl)
})
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
@@ -580,15 +598,12 @@ export const layer = (options?: Options) =>
)
})
const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.catch((error) => {
if (options?.file === undefined && error._tag === "FileSystemError" && error.method === "readJson") {
return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined))
}
return Effect.succeed(undefined)
}),
)
const loadFromFile = options?.file
? fs.readJson(options.file).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.catch(() => Effect.succeed(undefined)),
)
: Effect.succeed(undefined)
const loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
@@ -596,33 +611,27 @@ export const layer = (options?: Options) =>
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
yield* fs.writeWithDirs(tempfile, text).pipe(
Effect.andThen(fs.rename(tempfile, filepath)),
Effect.catch((error) =>
Effect.gen(function* () {
yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore)
return yield* Effect.fail(error)
}),
),
)
return text
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
yield* kv.set(key, { updatedAt: Date.now(), body: text })
return catalog
})
const populate = Effect.gen(function* () {
const fromDisk = yield* loadFromDisk
if (fromDisk) return normalize(fromDisk)
const fromFile = yield* loadFromFile
if (fromFile) return normalize(fromFile)
const cached = options?.file ? undefined : yield* loadFromCache()
if (cached) return normalize(cached.catalog)
const bundled = yield* loadSnapshot
if (bundled) return normalize(bundled)
if (!fetch) return []
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
const text = yield* Effect.scoped(
const catalog = yield* lock.withPermit(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
const stored = options?.file ? undefined : yield* loadFromCache()
if (stored) return stored.catalog
return yield* fetchAndWrite()
}),
)
return normalize(JSON.parse(text) as Record<string, SourceProvider>)
return normalize(catalog)
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
@@ -630,21 +639,19 @@ export const layer = (options?: Options) =>
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
if (!force && (yield* fresh())) return
yield* Effect.scoped(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
// Re-check under the lock: another process may have refreshed between
// our outer check and lock acquisition.
if (!force && (yield* fresh())) return
yield* fetchAndWrite()
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
}),
).pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.ignore,
)
yield* lock
.withPermit(
Effect.gen(function* () {
if (!force && (yield* fresh())) return
yield* fetchAndWrite()
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
}),
)
.pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.ignore,
)
})
if (fetch && !process.argv.includes("--get-yargs-completions")) {
@@ -660,7 +667,7 @@ export function configured(options?: Options) {
return makeGlobalNode({
service: Service,
layer: layer(options),
deps: [FSUtil.node, Bus.node, App.node, httpClient],
deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
})
}
+19 -7
View File
@@ -3,7 +3,7 @@ export * as PluginHooks from "./hooks"
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { State } from "../state"
@@ -15,19 +15,29 @@ export interface Domains {
readonly tool: ToolHooks
}
type Callback<Event> = (event: Event) => Effect.Effect<void>
type NoFailures<Spec> = { readonly [Name in keyof Spec]: never }
// Failure channel for each hook event. Only tool execute.before may fail: a Tool.Error rejects the call before it runs.
interface Failures extends Record<keyof Domains, unknown> {
readonly aisdk: NoFailures<AISDKHooks>
readonly session: NoFailures<SessionHooks>
readonly shell: NoFailures<ShellHooks>
readonly tool: ToolFailures
}
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
export interface Interface {
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
domain: Domain,
name: Name,
callback: Callback<Domains[Domain][Name]>,
callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
domain: Domain,
name: Name,
event: Domains[Domain][Name],
) => Effect.Effect<Domains[Domain][Name]>
) => Effect.Effect<Domains[Domain][Name], Failures[Domain][Name]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginHooks") {}
@@ -56,7 +66,9 @@ const layer = Layer.effect(
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
for (const callback of callbacks.get(key(domain, name)) ?? []) {
const result: Effect.Effect<void> = Reflect.apply(callback, undefined, [event])
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
event,
])
yield* result
}
return event
+4 -2
View File
@@ -290,8 +290,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
transform: (callback) =>
skill.transform((draft) => {
callback({
source: (source) => draft.source(Schema.decodeUnknownSync(Skill.Source)(source)),
list: draft.list,
list: () => mutable(draft.list()),
add: (value) => draft.add(Schema.decodeUnknownSync(Skill.Info)(value)),
update: draft.update,
remove: draft.remove,
})
}),
},
+51
View File
@@ -1,6 +1,8 @@
export * as PluginInternal from "./internal"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Context, Effect, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Agent } from "../agent"
@@ -10,6 +12,7 @@ import { Config } from "../config"
import { Credential } from "../credential"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigInstructionPlugin } from "../config/plugin/instruction"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigPolicyPlugin } from "../config/plugin/policy"
import { ConfigReferencePlugin } from "../config/plugin/reference"
@@ -24,6 +27,7 @@ import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "../image"
import { InstructionDiscovery } from "../instruction-discovery"
import { Integration } from "../integration"
import { KV } from "../kv"
import { Location } from "../location"
@@ -37,6 +41,8 @@ import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { Shell } from "../shell"
import { Skill } from "../skill"
import { SkillDiscovery } from "../skill/discovery"
import { Watcher } from "../filesystem/watcher"
import { PatchTool } from "../tool/plugin/patch"
import { EditTool } from "../tool/plugin/edit"
import { GlobTool } from "../tool/plugin/glob"
@@ -79,6 +85,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const global = yield* Global.Service
const http = yield* HttpClient.HttpClient
const image = yield* Image.Service
const instructionDiscovery = yield* InstructionDiscovery.Service
const integration = yield* Integration.Service
const kv = yield* KV.Service
const location = yield* Location.Service
@@ -95,7 +102,9 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service
const skill = yield* Skill.Service
const skillDiscovery = yield* SkillDiscovery.Service
const tools = yield* Tool.Service
const watcher = yield* Watcher.Service
const wellknown = yield* WellKnown.Service
return Context.mergeAll(
Context.make(Agent.Service, agent),
@@ -112,6 +121,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Global.Service, global),
Context.make(HttpClient.HttpClient, http),
Context.make(Image.Service, image),
Context.make(InstructionDiscovery.Service, instructionDiscovery),
Context.make(Integration.Service, integration),
Context.make(KV.Service, kv),
Context.make(Location.Service, location),
@@ -128,7 +138,9 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell),
Context.make(Skill.Service, skill),
Context.make(SkillDiscovery.Service, skillDiscovery),
Context.make(Tool.Service, tools),
Context.make(Watcher.Service, watcher),
Context.make(WellKnown.Service, wellknown),
)
})
@@ -137,6 +149,44 @@ type ContextServices<A> = A extends Context.Context<infer R> ? R : never
export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>>
export const requirements = LayerNode.group([
Agent.node,
Catalog.node,
Command.node,
Config.node,
Credential.node,
Bus.node,
Environment.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
FSUtil.node,
Global.node,
httpClient,
Image.node,
InstructionDiscovery.node,
Integration.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Npm.node,
Permission.node,
PluginRuntime.node,
Form.node,
ReadToolFileSystem.node,
Reference.node,
WebSearch.node,
Ripgrep.node,
SessionInstructions.node,
Shell.node,
Skill.node,
SkillDiscovery.node,
Tool.node,
Watcher.node,
WellKnown.node,
])
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
@@ -164,6 +214,7 @@ const pre = [
] as const satisfies readonly InternalPlugin[]
const post = [
ConfigInstructionPlugin.Plugin,
ConfigReferencePlugin.Plugin,
ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin,
+15 -21
View File
@@ -28,29 +28,23 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const reportContent = yield* reportContentWithDiagnostics(ctx.app)
yield* ctx.skill.transform((draft) => {
draft.source(
Skill.EmbeddedSource.make({
type: "embedded",
skill: Skill.Info.make({
id: Skill.ID.make("opencode"),
name: Skill.Name.make("OpenCode"),
description: OpencodeDescription,
location: AbsolutePath.make("/builtin/opencode.md"),
content: OpencodeContent,
}),
draft.add(
Skill.Info.make({
id: Skill.ID.make("opencode"),
name: Skill.Name.make("OpenCode"),
description: OpencodeDescription,
location: AbsolutePath.make("/builtin/opencode.md"),
content: OpencodeContent,
}),
)
draft.source(
Skill.EmbeddedSource.make({
type: "embedded",
skill: Skill.Info.make({
id: Skill.ID.make("report"),
name: Skill.Name.make("Report"),
description: REPORT_DESCRIPTION,
slash: true,
location: AbsolutePath.make("/builtin/report.md"),
content: reportContent,
}),
draft.add(
Skill.Info.make({
id: Skill.ID.make("report"),
name: Skill.Name.make("Report"),
description: REPORT_DESCRIPTION,
slash: true,
location: AbsolutePath.make("/builtin/report.md"),
content: reportContent,
}),
)
})
+21 -208
View File
@@ -1,48 +1,17 @@
export * as PluginSupervisor from "./supervisor"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Directory, Document, Event, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/config"
import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Agent } from "../agent"
import { Catalog } from "../catalog"
import { Command } from "../command"
import { Config } from "../config"
import { Credential } from "../credential"
import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Bus } from "../bus"
import { Environment } from "../environment"
import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter"
import { FileSystem } from "../filesystem"
import { Watcher } from "../filesystem/watcher"
import { Form } from "../form"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "../image"
import { Integration } from "../integration"
import { KV } from "../kv"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
import { Npm } from "@opencode-ai/util/npm"
import { Permission } from "../permission"
import { Plugin } from "../plugin"
import { PluginPromise } from "../plugin/promise"
import { Reference } from "../reference"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { Shell } from "../shell"
import { Skill } from "../skill"
import { ReadToolFileSystem } from "../tool/read-filesystem"
import { Tool } from "../tool"
import { WebSearch } from "../websearch"
import { WellKnown } from "../wellknown"
import { PluginInternal } from "./internal"
import { PluginRuntime } from "./runtime"
import { SdkPlugins } from "./sdk"
import { importModule } from "@opencode-ai/util/runtime-import"
@@ -63,65 +32,10 @@ const PluginModule = Schema.Struct({
]),
})
type Operation =
| {
readonly type: "add"
readonly target: string
readonly options: Record<string, unknown>
readonly mtime?: number
}
| {
readonly type: "remove"
readonly target: string
}
function parse(input: ConfigPlugin.Plugin): Operation {
if (typeof input !== "string") {
return { type: "add", target: input.package, options: input.options ?? {} }
}
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
return { type: "remove", target: input.slice(1) }
}
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Entry[]) {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const discovered = yield* Effect.forEach(
entries.filter((entry): entry is Directory => entry.type === "directory"),
(entry) => discoverDirectory(fs, entry.path),
).pipe(Effect.map((items) => items.flat()))
const configured = entries
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) =>
(entry.info.plugins ?? []).map(parse).map((operation) => {
if (operation.type === "remove") return operation
const directory = entry.path ? path.dirname(entry.path) : location.directory
const target = operation.target.startsWith("file://")
? fileURLToPath(operation.target)
: operation.target.startsWith("./") || operation.target.startsWith("../")
? path.resolve(directory, operation.target)
: operation.target
return { ...operation, target }
}),
)
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.catch(() => Effect.succeed(operation)),
)
})
})
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
operations: readonly Operation[],
operations: readonly ConfigPluginSource.Operation[],
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
@@ -168,7 +82,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
]
})
const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Operation, { type: "add" }>) {
const load = Effect.fn("PluginSupervisor.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
) {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
@@ -192,31 +108,6 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Op
} satisfies Plugin.Versioned
})
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.scan("{plugin,plugins}/*.{ts,js}", {
cwd: directory,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
})
}
const sourceDirectories = ["plugin", "plugins"] as const
function isPluginSource(entries: readonly Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
)
}
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
@@ -224,72 +115,29 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const registry = yield* Plugin.Service
const sdk = yield* SdkPlugins.Service
const config = yield* Config.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const watcher = yield* Watcher.Service
const fs = yield* FSUtil.Service
const ready = { current: yield* Deferred.make<void>() }
let observed = 0
// Configured local plugin files can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
// Watches start on first sighting and are never torn down individually:
// a stale watch after a config edit costs one deduped fs handle and a
// no-op activation, and every watch dies with this layer's scope.
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
const watchConfiguredSources = Effect.fn("PluginSupervisor.watchConfiguredSources")(function* (
entries: readonly Entry[],
operations: readonly Operation[],
) {
for (const operation of operations) {
if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue
if (watched.has(operation.target)) continue
// The config change feed already covers {plugin,plugins} directories.
if (isPluginSource(entries, operation.target)) continue
// Directory targets can't hot-reload (their stat mtime ignores edits
// inside), so don't watch what can't trigger anything.
if (yield* fs.isDir(operation.target)) continue
watched.add(operation.target)
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
yield* updates.pipe(
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
Effect.catchCause((cause) =>
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
),
Effect.forkScoped({ startImmediately: true }),
)
}
})
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed SDK plugins in boot order.
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
const entries = yield* config.entries()
const operations = yield* scan(entries)
yield* watchConfiguredSources(entries, operations)
const operations = yield* sources.operations()
// Apply config operations and load enabled package plugins into one ordered generation.
const plugins = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(plugins)
})
const updates = Stream.merge(
config.changes().pipe(
Stream.filterEffect((update) =>
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
),
Stream.merge(Stream.fromPubSub(configuredChanges)),
),
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
).pipe(
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() =>
Effect.gen(function* () {
@@ -315,48 +163,13 @@ const layer = Layer.effect(
}),
)
const nodeLayer = layer as Layer.Layer<Service, never, PluginInternal.Requirements>
const nodeDeps = [
Plugin.node,
SdkPlugins.node,
ConfigPluginSource.node,
Bus.node,
Npm.node,
PluginInternal.requirements,
] as const
export const node = makeLocationNode({
service: Service,
layer: nodeLayer,
deps: [
Plugin.node,
SdkPlugins.node,
Agent.node,
Catalog.node,
Command.node,
Config.node,
Credential.node,
Bus.node,
Environment.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
FSUtil.node,
Global.node,
httpClient,
Image.node,
Integration.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Npm.node,
Permission.node,
PluginRuntime.node,
Form.node,
ReadToolFileSystem.node,
Reference.node,
Ripgrep.node,
SessionInstructions.node,
Shell.node,
Skill.node,
Tool.node,
Watcher.node,
WebSearch.node,
WellKnown.node,
],
})
export { layer }
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
+9 -2
View File
@@ -9,6 +9,7 @@ import { Bus } from "./bus"
import { Location } from "./location"
import { PtyID } from "./pty/schema"
import { ShellSelect } from "./shell/select"
import { Global } from "@opencode-ai/util/global"
import { lazy } from "./util/lazy"
const BUFFER_LIMIT = 1024 * 1024 * 2
@@ -96,6 +97,7 @@ export const layer = (options?: ShellSelect.Options) =>
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<PtyID, Active>()
@@ -165,7 +167,8 @@ export const layer = (options?: ShellSelect.Options) =>
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options)
const command =
input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options, global.bin)
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
@@ -315,7 +318,11 @@ export const layer = (options?: ShellSelect.Options) =>
)
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [Bus.node, Location.node, Config.node] })
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node],
})
}
export const node = configured()
+11 -7
View File
@@ -34,6 +34,8 @@ export namespace RipgrepBinary {
const fs = yield* FSUtil.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const spawner = yield* ChildProcessSpawner
const global = yield* Global.Service
const findExecutable = (name: string) => which(name, undefined, global.bin)
const run = Effect.fnUntraced(function* (command: string, args: string[]) {
const handle = yield* spawner.spawn(ChildProcess.make(command, args, { extendEnv: true, stdin: "ignore" }))
@@ -53,10 +55,12 @@ export namespace RipgrepBinary {
config: (typeof PLATFORM)[keyof typeof PLATFORM],
target: string,
) {
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
const dir = yield* fs.makeTempDirectoryScoped({ directory: global.bin, prefix: "ripgrep-" })
if (config.extension === "zip") {
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
const shell =
(yield* Effect.sync(() => findExecutable("powershell.exe") ?? findExecutable("pwsh.exe"))) ??
"powershell.exe"
const result = yield* run(shell, [
"-NoProfile",
"-NonInteractive",
@@ -91,10 +95,10 @@ export namespace RipgrepBinary {
return Service.of({
filepath: yield* Effect.cached(
Effect.gen(function* () {
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
const system = yield* Effect.sync(() => findExecutable(process.platform === "win32" ? "rg.exe" : "rg"))
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
const target = path.join(global.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
@@ -103,10 +107,10 @@ export namespace RipgrepBinary {
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
const archive = path.join(Global.Path.bin, filename)
const archive = path.join(global.bin, filename)
yield* Effect.logInfo("downloading ripgrep", { url })
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
yield* fs.ensureDir(global.bin).pipe(Effect.orDie)
const bytes = yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
@@ -127,6 +131,6 @@ export namespace RipgrepBinary {
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
deps: [FSUtil.node, Global.node, httpClient, CrossSpawnSpawner.node],
})
}
+14 -9
View File
@@ -4,6 +4,7 @@ import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
export interface Adapter {
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getAssistant: (
@@ -59,15 +60,19 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.created": () => Effect.void,
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return adapter.appendMessage(
SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
time: { created: event.created },
}),
)
return Effect.gen(function* () {
const previous = yield* adapter.getAgent()
yield* adapter.appendMessage(
SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
previous,
time: { created: event.created },
}),
)
})
},
"session.model.selected": (event) => {
return Effect.gen(function* () {
+21 -6
View File
@@ -5,6 +5,7 @@ import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import { Database } from "../database/database"
import { Bus } from "../bus"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Agent } from "../agent"
import { Model } from "../model"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
@@ -230,6 +231,17 @@ function run(db: DatabaseService, event: MessageEvent) {
}
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
const adapter: SessionMessageUpdater.Adapter = {
getAgent() {
return db
.select({ agent: SessionTable.agent })
.from(SessionTable)
.where(eq(SessionTable.id, event.data.sessionID))
.get()
.pipe(
Effect.orDie,
Effect.map((row) => (row?.agent ? Agent.ID.make(row.agent) : undefined)),
)
},
getModel() {
return db
.select({ model: SessionTable.model })
@@ -398,12 +410,15 @@ const layer = Layer.effectDiscard(
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.AgentSelected, (event) =>
db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
Effect.gen(function* () {
yield* run(db, event)
yield* db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.ModelSelected, (event) =>
Effect.gen(function* () {
@@ -59,6 +59,25 @@ const attachmentContent = (file: FileAttachment): ContentPart[] => {
return []
}
const userAttachmentContent = (files: readonly FileAttachment[]) => {
const eligible = files.filter(
(file) => imageMimes.has(file.mime) && file.source.type === "inline" && file.mention?.text,
)
if (eligible.length < 2) return files.flatMap(attachmentContent)
const seen = new Map<string, string[]>()
return files.flatMap((file) => {
if (!imageMimes.has(file.mime) || file.source.type !== "inline" || !file.mention?.text)
return attachmentContent(file)
const metadata = JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text])
const matches = seen.get(metadata)
if (matches?.includes(file.data)) return []
if (matches) matches.push(file.data)
if (!matches) seen.set(metadata, [file.data])
return attachmentContent(file)
})
}
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const providerMetadata = (
@@ -186,7 +205,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
const content = [
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
...(message.text === "" ? [] : [Message.text(message.text)]),
...(message.files ?? []).flatMap(attachmentContent),
...userAttachmentContent(message.files ?? []),
]
if (content.length === 0) return []
return [
+3 -1
View File
@@ -143,7 +143,9 @@ export const layer = (options?: ShellSelect.Options) =>
})
const resolve = () =>
config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options, global.bin)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
+51 -36
View File
@@ -34,15 +34,19 @@ function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
function full(file: string, options?: Options) {
function findExecutable(name: string, bin?: string) {
return which(name, undefined, bin)
}
function full(file: string, options?: Options, bin?: string) {
if (process.platform !== "win32") return file
const shell = FSUtil.windowsPath(file)
if (path.win32.dirname(shell) !== ".") {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options) || shell
if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options, bin) || shell
return shell
}
if (name(shell) === "bash") return gitbash(options) || which(shell) || shell
return which(shell) || shell
if (name(shell) === "bash") return gitbash(options, bin) || findExecutable(shell, bin) || shell
return findExecutable(shell, bin) || shell
}
function meta(file: string) {
@@ -57,21 +61,26 @@ function rooted(file: string) {
return path.isAbsolute(FSUtil.windowsPath(file))
}
function resolve(file: string, options?: Options) {
const shell = full(file, options)
function resolve(file: string, options?: Options, bin?: string) {
const shell = full(file, options, bin)
if (rooted(shell)) {
if (stat(shell)?.isFile()) return shell
return
}
return which(shell) ?? undefined
return findExecutable(shell, bin) ?? undefined
}
function win(options?: Options) {
function win(options?: Options, bin?: string) {
return Array.from(
new Set(
[which("pwsh"), which("powershell"), gitbash(options), process.env.COMSPEC || "cmd.exe"]
[
findExecutable("pwsh", bin),
findExecutable("powershell", bin),
gitbash(options, bin),
process.env.COMSPEC || "cmd.exe",
]
.filter((item): item is string => Boolean(item))
.map((file) => full(file, options)),
.map((file) => full(file, options, bin)),
),
)
}
@@ -82,27 +91,27 @@ async function unix() {
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }) {
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }, bin?: string) {
if (file && (!opts?.acceptable || ok(file))) {
const shell = resolve(file, options)
const shell = resolve(file, options, bin)
if (shell) return shell
}
if (process.platform === "win32") return win(options)[0]
return fallback()
if (process.platform === "win32") return win(options, bin)[0]
return fallback(bin)
}
export function gitbash(options?: Options) {
export function gitbash(options?: Options, bin?: string) {
if (process.platform !== "win32") return
if (options?.gitbash) return options.gitbash
const git = which("git")
const git = findExecutable("git", bin)
if (!git) return
const file = path.join(git, "..", "..", "bin", "bash.exe")
if (stat(file)?.size) return file
}
function fallback() {
function fallback(bin?: string) {
if (process.platform === "darwin") return "/bin/zsh"
const bash = which("bash")
const bash = findExecutable("bash", bin)
if (bash) return bash
return "/bin/sh"
}
@@ -120,12 +129,12 @@ export function ps(file: string) {
return meta(file)?.ps === true
}
function info(file: string, options?: Options): Item {
const item = full(file, options)
function info(file: string, options?: Options, bin?: string): Item {
const item = full(file, options, bin)
const n = name(item)
return {
path: item,
name: resolve(n, options) ? n : item,
name: resolve(n, options, bin) ? n : item,
acceptable: ok(item),
}
}
@@ -139,30 +148,36 @@ export function args(file: string, command: string) {
return ["-c", command]
}
let defaultPreferred: string | undefined
let defaultAcceptable: string | undefined
let defaultPreferred: { bin?: string; value: string } | undefined
let defaultAcceptable: { bin?: string; value: string } | undefined
export function preferred(configShell?: string, options?: Options) {
if (configShell) return select(configShell, options)
if (options?.gitbash) return select(process.env.SHELL, options)
defaultPreferred ??= select(process.env.SHELL)
return defaultPreferred
export function preferred(configShell?: string, options?: Options, bin?: string) {
if (configShell) return select(configShell, options, undefined, bin)
if (options?.gitbash) return select(process.env.SHELL, options, undefined, bin)
const cached = defaultPreferred
if (cached && cached.bin === bin) return cached.value
const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin)
defaultPreferred = { bin, value }
return value
}
preferred.reset = () => {
defaultPreferred = undefined
}
export function acceptable(configShell?: string, options?: Options) {
if (configShell) return select(configShell, options, { acceptable: true })
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true })
defaultAcceptable ??= select(process.env.SHELL, undefined, { acceptable: true })
return defaultAcceptable
export function acceptable(configShell?: string, options?: Options, bin?: string) {
if (configShell) return select(configShell, options, { acceptable: true }, bin)
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }, bin)
const cached = defaultAcceptable
if (cached && cached.bin === bin) return cached.value
const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin)
defaultAcceptable = { bin, value }
return value
}
acceptable.reset = () => {
defaultAcceptable = undefined
}
export async function list(options?: Options): Promise<Item[]> {
const shells = process.platform === "win32" ? win(options) : await unix()
return shells.filter((shell) => resolve(shell, options)).map((shell) => info(shell, options))
export async function list(options?: Options, bin?: string): Promise<Item[]> {
const shells = process.platform === "win32" ? win(options, bin) : await unix()
return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
}
+23 -192
View File
@@ -2,17 +2,12 @@ export * as Skill from "./skill"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, FiberMap, Layer, PubSub, Schema, Semaphore, Stream, Types } from "effect"
import { Context, Effect, Layer, Types } from "effect"
import { Skill } from "@opencode-ai/schema/skill"
import { Agent } from "./agent"
import { ConfigMarkdown } from "./config/markdown"
import { Bus } from "./bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Permission } from "./permission"
import { AbsolutePath } from "./schema"
import { SkillDiscovery } from "./skill/discovery"
import { State } from "./state"
import { Watcher } from "./filesystem/watcher"
export const DirectorySource = Skill.DirectorySource
export type DirectorySource = Skill.DirectorySource
@@ -57,38 +52,18 @@ export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
].join("\n")
}
const Frontmatter = Schema.Struct({
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
slash: Schema.Boolean.pipe(Schema.optional),
metadata: Schema.Unknown.pipe(Schema.optional),
})
const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter)
const metadataBoolean = (metadata: unknown, key: string) => {
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
return undefined
}
const value = (metadata as { readonly [key: string]: unknown })[key]
if (typeof value === "boolean") return value
if (typeof value !== "string") return undefined
const normalized = value.trim().toLowerCase()
if (normalized === "true") return true
if (normalized === "false") return false
return undefined
}
export type Data = {
sources: Types.DeepMutable<Source>[]
skills: Map<ID, Types.DeepMutable<Info>>
}
export type Draft = {
source: (source: Source) => void
list: () => readonly Source[]
list: () => readonly Types.DeepMutable<Info>[]
add: (skill: Info) => void
update: (id: string, update: (skill: Types.DeepMutable<Info>) => void) => void
remove: (id: string) => void
}
export interface Interface extends State.Transformable<Draft> {
readonly sources: () => Effect.Effect<Source[]>
readonly list: () => Effect.Effect<Info[]>
}
@@ -97,179 +72,35 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sk
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const watcher = yield* Watcher.Service
const cache = new Map<string, { skills: Info[]; paths: readonly string[] }>()
const watches = yield* FiberMap.make<string>()
const lock = Semaphore.makeUnsafe(1)
const changes = yield* PubSub.unbounded<string>()
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
const changed = yield* lock.withPermit(
Effect.gen(function* () {
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
loaded.paths.some((item) => FSUtil.overlaps(item, file)),
)
if (invalidated.length === 0) return false
cache.clear()
yield* FiberMap.clear(watches)
yield* Effect.logInfo("skill cache invalidated", {
file,
sources: invalidated.map(([key]) => key),
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
})
return true
}),
)
if (!changed) return
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
})
yield* Stream.fromPubSub(changes).pipe(Stream.runForEach(invalidate), Effect.forkScoped({ startImmediately: true }))
const watch = Effect.fn("Skill.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe(
type === "file" ? { path: target, type: "file" } : { path: target, type: "directory" },
)
yield* FiberMap.run(
watches,
`${type}:${target}`,
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
{
onlyIfMissing: true,
startImmediately: true,
},
)
})
function firstMissing(target: string): Effect.Effect<string | undefined> {
const parent = path.dirname(target)
if (parent === target) return Effect.succeed(undefined)
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
}
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn("Skill.watchDirectory")(function* (
directory: string,
) {
const target = path.resolve(directory)
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (resolved) {
yield* watch(resolved, "directory")
if (resolved !== target) {
yield* watch(target, "file")
}
return resolved === target ? [target] : [target, resolved]
}
const missing = yield* firstMissing(target)
if (missing) yield* watch(missing, "file")
if (
yield* fs.realPath(directory).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
) {
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
return yield* watchDirectory(directory)
}
return [target]
})
const state = State.create<Data, Draft>({
name: "skill",
initial: () => ({ sources: [] }),
initial: () => ({ skills: new Map() }),
draft: (draft) => ({
source: (source) => {
if (draft.sources.some((item) => Source.equals(item, source))) return
draft.sources.push(source as Types.DeepMutable<Source>)
list: () => Array.from(draft.skills.values()),
add: (skill) => {
draft.skills.set(skill.id, { ...skill } as Types.DeepMutable<Info>)
},
update: (id, update) => {
const current = draft.skills.get(ID.make(id))
if (!current) return
update(current)
current.id = ID.make(id)
},
remove: (id) => {
draft.skills.delete(ID.make(id))
},
list: () => draft.sources as Source[],
}),
finalize: () =>
lock
.withPermit(FiberMap.clear(watches).pipe(Effect.andThen(Effect.sync(() => cache.clear())), Effect.asVoid))
.pipe(Effect.andThen(bus.publish(Skill.Event.Updated, {})), Effect.asVoid),
})
const load = Effect.fn("Skill.load")(function* (source: Source) {
const skills: Info[] = []
if (source.type === "embedded") {
yield* Effect.logDebug("skill source loaded", {
source: Source.key(source),
type: source.type,
directories: [],
skills: [source.skill.id],
})
return { skills: [source.skill], paths: [] }
}
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
const paths = [...roots]
for (const directory of directories) {
const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const filepath of files.toSorted()) {
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
if (!roots.some((root) => FSUtil.contains(root, resolved))) {
const external = path.dirname(resolved)
paths.push(external)
yield* watch(external, "directory")
}
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!content) continue
const markdown = ConfigMarkdown.parseOption(content)
if (!markdown) continue
const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
if (!frontmatter) continue
const id =
path.dirname(filepath) === directory
? path.basename(filepath, ".md")
: path.basename(path.dirname(filepath))
skills.push({
id: ID.make(id),
name: Name.make(frontmatter.name ?? id),
description: frontmatter.description,
slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash,
autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"),
location: AbsolutePath.make(filepath),
content: markdown.content,
})
}
}
yield* Effect.logDebug("skill source loaded", {
source: Source.key(source),
type: source.type,
directories,
skills: skills.map((skill) => skill.id),
})
return { skills, paths }
})
const list = Effect.fn("Skill.list")(function* () {
return yield* lock.withPermit(
Effect.gen(function* () {
const skills = new Map<ID, Info>()
for (const source of state.get().sources) {
const key = Source.key(source)
const loaded = cache.get(key) ?? (yield* load(source))
cache.set(key, loaded)
for (const skill of loaded.skills) skills.set(skill.id, skill)
}
return Array.from(skills.values())
}),
)
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
})
return Service.of({
transform: state.transform,
reload: state.reload,
sources: Effect.fn("Skill.sources")(function* () {
return state.get().sources
list: Effect.fn("Skill.list")(function* () {
return Array.from(state.get().skills.values())
}),
list,
})
}),
)
@@ -277,5 +108,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node],
deps: [Bus.node],
})
+2 -3
View File
@@ -1,10 +1,9 @@
import whichPkg from "which"
import path from "path"
import { Global } from "@opencode-ai/util/global"
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
export function which(cmd: string, env?: NodeJS.ProcessEnv, bin?: string) {
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
const full = base && bin ? base + path.delimiter + bin : base || bin
const result = whichPkg.sync(cmd, {
nothrow: true,
path: full,
+67
View File
@@ -275,6 +275,73 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
}),
)
it.effect("preserves tool result content in AI SDK prompts", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model(model("test-ai-sdk"))
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
messages: [
Message.tool({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "attachments" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "pixel.png" },
{
type: "file",
uri: "data:application/pdf;charset=utf-8;base64,JVBERg==",
mime: "application/pdf",
name: "document.pdf",
},
{ type: "file", uri: "data:audio/mpeg;base64,SUQz", mime: "audio/mpeg", name: "clip.mp3" },
{ type: "file", uri: "https://example.com/pixel.png", mime: "image/png" },
{ type: "file", uri: "https://example.com/document.pdf", mime: "application/pdf" },
],
},
}),
],
}),
)
expect(prepared.body.prompt).toEqual([
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_1",
toolName: "read",
output: {
type: "content",
value: [
{ type: "text", text: "attachments" },
{ type: "image-data", data: "AAAA", mediaType: "image/png" },
{
type: "file-data",
data: "JVBERg==",
mediaType: "application/pdf",
filename: "document.pdf",
},
{ type: "file-data", data: "SUQz", mediaType: "audio/mpeg", filename: "clip.mp3" },
{ type: "image-url", url: "https://example.com/pixel.png" },
{ type: "file-url", url: "https://example.com/document.pdf" },
],
},
},
],
},
])
}),
)
it.effect("emits malformed AI SDK tool input without executing it", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
@@ -0,0 +1,6 @@
---
name: first
description: First skill
---
# first
@@ -0,0 +1,6 @@
---
name: second
description: Second skill
---
# second
+35 -1
View File
@@ -5,8 +5,10 @@ import { describe, expect } from "bun:test"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
@@ -19,10 +21,19 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Logger, Stream } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
)
const staticIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[ConfigPluginSource.node, ConfigPluginSource.empty],
[Global.node, tempGlobalLayer],
]),
)
describe("PluginSupervisor config", () => {
@@ -157,6 +168,29 @@ describe("PluginSupervisor config", () => {
),
)
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void }))
yield* withLocation(
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
expect(ids).toContain("opencode.agent")
expect(ids).toContain("static-sdk")
expect(ids).not.toContain("config-promise-plugin")
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
}),
true,
)
}),
)
it.live("reloads an auto-discovered plugin when its file changes", () =>
withLocation(
undefined,
+4 -10
View File
@@ -46,9 +46,7 @@ describe("config plugin reloads", () => {
expect((yield* agents.get(Agent.ID.make("first")))?.description).toBe("First agent")
expect((yield* commands.get("first"))?.description).toBe("First command")
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
).toBe(true)
expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(true)
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
expect(yield* catalog.provider.get(Provider.ID.make("first"))).toBeDefined()
@@ -69,12 +67,8 @@ describe("config plugin reloads", () => {
}),
)
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
).toBe(false)
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
).toBe(true)
expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(false)
expect((yield* skills.list()).some((skill) => skill.id === "second")).toBe(true)
}).pipe(
Effect.provide(Config.testLayer([config("first")])),
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
@@ -89,7 +83,7 @@ function config(name: string) {
info: decode({
agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } },
commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } },
skills: [`/skills/${name}`],
skills: [path.join(import.meta.dir, "fixture", "skills", `${name}-source`)],
references: { [name]: `/references/${name}` },
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
}),
+360 -68
View File
@@ -1,87 +1,379 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema, Stream } from "effect"
import { Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { AgentsDirectory, ClaudeDirectory, Directory, Document, Info } from "@opencode-ai/schema/config"
import {
AgentsDirectory,
ClaudeDirectory,
Directory as ConfigDirectory,
Document,
type Entry,
Info,
} from "@opencode-ai/schema/config"
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
import { SkillFile } from "@opencode-ai/core/config/plugin/skill-file"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { tmpdir } from "../fixture/tmpdir"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const it = testEffect(Layer.empty)
const urls = new Map<string, AbsolutePath[]>()
const failedUrls = new Set<string>()
let pulls = 0
const discoveryLayer = Layer.succeed(
SkillDiscovery.Service,
SkillDiscovery.Service.of({
pull: (url) => {
pulls++
if (failedUrls.has(url)) return Effect.die(`failed to pull ${url}`)
return Effect.succeed(urls.get(url) ?? [])
},
}),
)
const watcherLayer = Watcher.testLayer
const it = testEffect(
Layer.mergeAll(
AppNodeBuilder.build(LayerNode.group([Skill.node, Bus.node, FSUtil.node])),
discoveryLayer,
watcherLayer,
),
)
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigSkillPlugin.Plugin", () => {
it.effect("registers configured skill directories and URLs", () =>
Effect.gen(function* () {
const directory = AbsolutePath.make("/repo/packages/app")
const sources: Skill.Source[] = []
const transform = Effect.fnUntraced(function* (update: (draft: Skill.Draft) => void | Effect.Effect<void>) {
const result = update({
source: (source) => {
sources.push(source)
},
list: () => sources,
})
if (Effect.isEffect(result)) yield* result
const dispose = Effect.sync(() => {
sources.length = 0
})
yield* Effect.addFinalizer(() => dispose)
return { dispose }
})
function write(directory: string, name: string, description: string) {
return fs.writeFile(
path.join(directory, name, "SKILL.md"),
`---
name: ${name}
description: ${description}
---
# ${name}`,
)
}
yield* ConfigSkillPlugin.Plugin.effect(
host({
skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void },
}),
).pipe(
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })),
Effect.provideService(Location.Service, Location.Service.of(location({ directory }))),
Effect.provide(
Config.testLayer([
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }),
new Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
new Document({
type: "document",
info: decode({
skills: ["./skills", "~/shared-skills", "/opt/skills", "https://example.test/skills/"],
}),
}),
]),
const startEntries = Effect.fnUntraced(function* (entries: Entry[], directory: string, home = directory) {
const service = yield* Skill.Service
yield* ConfigSkillPlugin.Plugin.effect(
host({
skill: {
list: () => Effect.die("unused skill.list"),
transform: service.transform,
reload: service.reload,
},
}),
).pipe(
Effect.provide(Config.testLayer(entries)),
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
Effect.provideService(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
)
return service
})
const start = (skills: string[], directory: string) =>
startEntries(
[
new Document({
type: "document",
info: decode({ skills }),
}),
],
directory,
)
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
const bus = yield* Bus.Service
const deferred = yield* Deferred.make<void>()
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
Effect.forkScoped,
)
yield* Effect.yieldNow
yield* watcher.emit(update)
yield* Deferred.await(deferred).pipe(Effect.timeout("2 seconds"))
yield* Fiber.interrupt(fiber)
})
}
describe("SkillFile.parse", () => {
it.effect("parses root and nested skill ids and metadata flags", () =>
Effect.sync(() => {
const directory = "/repo/skills"
expect(
SkillFile.parse(
directory,
"/repo/skills/manual/SKILL.md",
`---
name: Manual
description: Manual only
metadata:
opencode/slash: "true"
opencode/autoinvoke: false
---
# manual`,
),
)
expect(sources).toEqual([
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.claude", "skills")),
}),
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.agents", "skills")),
}),
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),
}),
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.opencode", "skills")),
}),
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/home/test", "shared-skills")),
}),
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/opt/skills") }),
Skill.UrlSource.make({ type: "url", url: "https://example.test/skills/" }),
])
).toEqual({
_tag: "Parsed",
skill: {
id: Skill.ID.make("manual"),
name: Skill.Name.make("Manual"),
description: "Manual only",
slash: true,
autoinvoke: false,
location: AbsolutePath.make("/repo/skills/manual/SKILL.md"),
content: "# manual",
},
})
expect(SkillFile.parse(directory, "/repo/skills/foo.md", "---\nslash: true\n---\n# foo")).toMatchObject({
_tag: "Parsed",
skill: { id: Skill.ID.make("foo") },
})
expect(
SkillFile.parse(directory, "/repo/skills/broken.md", "---\ndescription: foo: bar\nmetadata: [\n---\n# broken"),
).toEqual({ _tag: "Skipped", reason: "markdown" })
expect(SkillFile.parse(directory, "/repo/skills/broken.md", "---\nslash: nope\n---\n# broken")).toMatchObject({
_tag: "Skipped",
reason: "frontmatter",
issue: expect.anything(),
})
}),
)
})
describe("ConfigSkillPlugin.Plugin", () => {
it.live("maps config entry types to skill directories", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const claude = path.join(tmp.path, "claude")
const agents = path.join(tmp.path, "agents")
const opencode = path.join(tmp.path, "opencode")
const home = path.join(tmp.path, "home")
const directory = path.join(tmp.path, "project")
const expected = [
path.join(claude, "skills"),
path.join(agents, "skills"),
path.join(opencode, "skill"),
path.join(opencode, "skills"),
path.join(home, "shared"),
path.join(directory, "relative"),
]
yield* Effect.promise(() => Promise.all(expected.map((item) => fs.mkdir(item, { recursive: true }))))
yield* startEntries(
[
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(claude) }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make(agents) }),
new ConfigDirectory({ type: "directory", path: AbsolutePath.make(opencode) }),
new Document({ type: "document", info: decode({ skills: ["~/shared", "./relative"] }) }),
],
directory,
home,
)
const watcher = yield* Watcher.Test
expect(yield* watcher.subscriptions()).toEqual(expected.map((item) => ({ path: item, type: "directory" })))
}),
),
),
)
it.live("loads directory and URL sources with later-source precedence", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "review"), { recursive: true })
await fs.mkdir(path.join(second, "review"), { recursive: true })
await write(first, "review", "First")
await write(second, "review", "Second")
})
pulls = 0
urls.set("https://example.test/skills/", [AbsolutePath.make(second)])
const skill = yield* start([first, "https://example.test/skills/"], tmp.path)
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Second")
expect(pulls).toBe(1)
}),
),
),
)
it.live("keeps directory skills when a URL source fails", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
await write(tmp.path, "review", "Available")
})
const url = "https://unreachable.example.test/skills/"
failedUrls.add(url)
const skill = yield* start([tmp.path, url], tmp.path)
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Available")
failedUrls.delete(url)
}),
),
),
)
it.live("rescans directory sources when watched files change", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Initial")
})
const skill = yield* start([tmp.path], tmp.path)
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial")
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated"))
yield* emitAndWait({ type: "update", path: deploy })
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
await write(tmp.path, "review", "Review")
})
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "review", "SKILL.md") })
expect((yield* skill.list()).map((item) => item.id)).toEqual([
Skill.ID.make("deploy"),
Skill.ID.make("review"),
])
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
yield* emitAndWait({ type: "delete", path: path.join(tmp.path, "review", "SKILL.md") })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
}),
),
),
)
it.live("watches canonical directories behind symlinked skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const target = path.join(tmp.path, "target", "bro")
const file = path.join(target, "SKILL.md")
yield* Effect.promise(async () => {
await fs.mkdir(source, { recursive: true })
await fs.mkdir(target, { recursive: true })
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
await fs.symlink(target, path.join(source, "bro"), process.platform === "win32" ? "junction" : undefined)
})
const skill = yield* start([source], tmp.path)
const watcher = yield* Watcher.Test
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
expect(yield* watcher.subscriptions()).toContainEqual({ path: target, type: "directory" })
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
yield* emitAndWait({ type: "update", path: file })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
}),
),
),
)
it.live("reloads symlinked sources when their target changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "bro"), { recursive: true })
await fs.mkdir(path.join(second, "bro"), { recursive: true })
await write(first, "bro", "First")
await write(second, "bro", "Second")
await fs.symlink(first, source, process.platform === "win32" ? "junction" : undefined)
})
const skill = yield* start([source], tmp.path)
const watcher = yield* Watcher.Test
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
])
yield* Effect.promise(async () => {
await fs.unlink(source)
await fs.symlink(second, source, process.platform === "win32" ? "junction" : undefined)
})
yield* emitAndWait({ type: "update", path: source })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
{ path: second, type: "directory" },
{ path: source, type: "file" },
])
}),
),
),
)
it.live("follows missing source directories as their parents appear", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "generated", "skills")
const skill = yield* start([source], tmp.path)
const watcher = yield* Watcher.Test
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
yield* Effect.promise(async () => {
await fs.mkdir(path.join(source, "deploy"), { recursive: true })
await write(source, "deploy", "Deploy")
})
yield* emitAndWait({ type: "create", path: source })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
}),
),
),
)
})
+54 -5
View File
@@ -11,11 +11,20 @@ import { migrations } from "@opencode-ai/core/database/migration.gen"
import { Database } from "@opencode-ai/core/database/database"
import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import consolidatedV2Migration from "@opencode-ai/core/database/migration/20260804233008_loose_psylocke"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
const run = <A, E>(
effect: Effect.Effect<A, E, SqlClient | Global.Service>,
global = Global.make({ data: path.join(process.cwd(), ".test-data") }),
) =>
Effect.runPromise(
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
effect.pipe(
Effect.provideService(Global.Service, global),
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
Effect.scoped,
),
)
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
@@ -31,7 +40,7 @@ describe("DatabaseMigration", () => {
Effect.scoped(Layer.build(layer)),
),
{ concurrency: "unbounded" },
),
).pipe(Effect.provideService(Global.Service, Global.make({ data: tmp.path }))),
)
})
@@ -106,6 +115,28 @@ describe("DatabaseMigration", () => {
)
})
test("adopts the consolidated V2 migration after the superseded chain", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, created integer DEFAULT 0 NOT NULL)`)
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
yield* db.run(sql`
INSERT INTO migration (id, time_completed)
VALUES ('20260730195856_optional_session_title', 1)
`)
yield* DatabaseMigration.applyOnly(db, [consolidatedV2Migration])
expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${consolidatedV2Migration.id}`)).toEqual({
id: consolidatedV2Migration.id,
})
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'kv'`)).toBeUndefined()
}),
)
})
test("imports legacy JSON credentials without changing the source file or existing credentials", async () => {
await using tmp = await tmpdir()
const source = path.join(tmp.path, "auth.json")
@@ -127,7 +158,8 @@ describe("DatabaseMigration", () => {
VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now})
`)
yield* db.transaction((tx) => importLegacyCredentials(tx, source))
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual(
[
@@ -159,11 +191,28 @@ describe("DatabaseMigration", () => {
value: JSON.stringify(["https://example.com"]),
})
}),
Global.make({ data: tmp.path }),
)
expect(await Bun.file(source).text()).toBe(content)
})
test("skips legacy credential import when the source file is absent", async () => {
await using tmp = await tmpdir()
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
expect(yield* db.all(sql`SELECT id FROM credential`)).toEqual([])
}),
Global.make({ data: tmp.path }),
)
})
test("rolls back a failed migration without recording it", async () => {
await run(
Effect.gen(function* () {
+27
View File
@@ -0,0 +1,27 @@
import path from "path"
import { Global } from "@opencode-ai/util/global"
import { Effect, Layer } from "effect"
import { tmpdir } from "./tmpdir"
export const tempGlobalLayer = Layer.unwrap(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.map((tmp) => {
const data = path.join(tmp.path, "data")
const cache = path.join(tmp.path, "cache")
return Global.layerWith({
home: path.join(tmp.path, "home"),
data,
cache,
config: path.join(tmp.path, "config"),
state: path.join(tmp.path, "state"),
tmp: path.join(tmp.path, "tmp"),
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
repos: path.join(data, "repos"),
})
}),
),
)
-16
View File
@@ -1,16 +0,0 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Global } from "@opencode-ai/util/global"
describe("global paths", () => {
test("tmp path is the canonical system temp directory", async () => {
expect(Global.Path.tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode")))
expect(Global.make().tmp).toBe(Global.Path.tmp)
})
test("tmp path is created on module load", async () => {
expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true)
})
})
+260 -175
View File
@@ -1,49 +1,124 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import fs from "fs/promises"
import path from "path"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Bus } from "@opencode-ai/core/bus"
import { ConfigInstructionPlugin } from "@opencode-ai/core/config/plugin/instruction"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tempGlobalLayer } from "./fixture/global"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { readInitial, readUpdate, state } from "./lib/instructions"
import { testEffect } from "./lib/effect"
import { host } from "./plugin/host"
const it = testEffect(Layer.empty)
const instructionLayer = (input: {
config: string
config?: string
locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service>
project?: boolean
}) =>
AppNodeBuilder.build(InstructionDiscovery.node, [
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[Global.node, Global.layerWith({ config: input.config })],
[Location.node, input.locationServiceLayer],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
])
}) => {
const watcher = Watcher.testLayer
return Layer.mergeAll(
AppNodeBuilder.build(
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
[
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[Global.node, input.config ? Global.layerWith({ config: input.config }) : tempGlobalLayer],
[Location.node, input.locationServiceLayer],
[Watcher.node, watcher],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
],
),
watcher,
)
}
const start = Effect.fnUntraced(function* () {
yield* ConfigInstructionPlugin.Plugin.effect(host())
return yield* InstructionDiscovery.Service
})
const file = (path: string, content: string) =>
new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
const bus = yield* Bus.Service
const updated = yield* Deferred.make<void>()
const fiber = yield* bus.subscribe(InstructionDiscovery.Event.Updated).pipe(
Stream.runForEach(() => Deferred.succeed(updated, undefined).pipe(Effect.asVoid)),
Effect.forkScoped,
)
yield* Effect.yieldNow
yield* watcher.emit(update)
yield* Deferred.await(updated).pipe(Effect.timeout("2 seconds"))
yield* Fiber.interrupt(fiber)
})
}
describe("InstructionDiscovery", () => {
it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
it.effect("stores ordered values with last-write-wins precedence", () =>
Effect.gen(function* () {
const discovery = yield* InstructionDiscovery.Service
yield* discovery.transform((draft) => {
draft.add(file("/repo/AGENTS.md", "first"))
draft.add(file("/repo/packages/AGENTS.md", "package"))
draft.add(file("/repo/AGENTS.md", "last"))
draft.update("/repo/packages/AGENTS.md", (current) => {
current.content = "updated"
current.path = AbsolutePath.make("/ignored")
})
draft.remove("/missing")
})
expect(yield* discovery.list()).toEqual([
file("/repo/AGENTS.md", "last"),
file("/repo/packages/AGENTS.md", "updated"),
])
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
)
it.effect("preserves admitted values while the source is unavailable", () =>
Effect.gen(function* () {
const discovery = yield* InstructionDiscovery.Service
yield* discovery.transform((draft) => draft.unavailable())
expect(
(yield* readUpdate(
yield* discovery.load(),
state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
)).changed,
).toBe(false)
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
)
})
describe("ConfigInstructionPlugin.Plugin", () => {
it.live("loads global and upward project files and rescans them on change", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const directory = path.join(project, "packages", "core")
const outside = path.join(tmp.path, "AGENTS.md")
const globalFile = path.join(global, "AGENTS.md")
const projectFile = path.join(project, "AGENTS.md")
const packageFile = path.join(directory, "AGENTS.md")
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const directory = path.join(project, "packages", "core")
const outside = path.join(tmp.path, "AGENTS.md")
const globalFile = path.join(global, "AGENTS.md")
const projectFile = path.join(project, "AGENTS.md")
const packageFile = path.join(directory, "AGENTS.md")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(directory, { recursive: true })
@@ -53,25 +128,15 @@ describe("InstructionDiscovery", () => {
await fs.writeFile(packageFile, "package")
})
const load = InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
config: global,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
)
const initialized = yield* readInitial(yield* load)
const discovery = yield* start()
const watcher = yield* Watcher.Test
expect(yield* watcher.subscriptions()).toEqual([
{ path: globalFile, type: "file" },
{ path: packageFile, type: "file" },
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
{ path: projectFile, type: "file" },
])
const initialized = yield* readInitial(yield* discovery.load())
expect(initialized.text).toBe(
[
`Instructions from: ${globalFile}\nglobal`,
@@ -82,13 +147,14 @@ describe("InstructionDiscovery", () => {
expect(initialized.text).not.toContain("outside")
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
expect((yield* readUpdate(yield* load, initialized)).text).toContain(
yield* emitAndWait({ type: "update", path: packageFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toContain(
`Instructions from: ${packageFile}\nchanged`,
)
yield* Effect.promise(() => fs.rm(packageFile))
const partial = yield* readUpdate(yield* load, initialized)
expect(partial.text).toBe(
yield* emitAndWait({ type: "delete", path: packageFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
[
"These instructions replace all previously loaded ambient instructions.",
`Instructions from: ${globalFile}\nglobal`,
@@ -96,12 +162,30 @@ describe("InstructionDiscovery", () => {
].join("\n\n"),
)
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
expect((yield* readUpdate(yield* load, initialized)).text).toBe(
yield* Effect.promise(() => fs.rm(globalFile))
yield* emitAndWait({ type: "delete", path: globalFile })
yield* Effect.promise(() => fs.rm(projectFile))
yield* emitAndWait({ type: "delete", path: projectFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
"Previously loaded instructions no longer apply.",
)
}),
),
}).pipe(
Effect.provide(
instructionLayer({
config: global,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
)
}),
),
)
@@ -114,115 +198,150 @@ describe("InstructionDiscovery", () => {
Effect.gen(function* () {
const file = path.join(tmp.path, "AGENTS.md")
yield* Effect.promise(() => fs.writeFile(file, ""))
const context = yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
config: path.join(tmp.path, "global"),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
),
}),
),
)
expect((yield* readInitial(context)).text).toBe(`Instructions from: ${file}\n`)
}),
const discovery = yield* start()
expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${file}\n`)
}).pipe(
Effect.provide(
instructionLayer({
config: path.join(tmp.path, "global"),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
),
}),
),
),
),
),
)
it.effect("preserves admitted instructions while observation is unavailable", () =>
Effect.gen(function* () {
const failingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const context = yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
config: "/global",
filesystemLayer: failingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
it.live("discovers a newly created instruction file in an intermediate directory", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const project = path.join(tmp.path, "project")
const intermediate = path.join(project, "packages", "AGENTS.md")
const directory = path.join(project, "packages", "core")
const projectFile = path.join(project, "AGENTS.md")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
yield* Effect.promise(() => fs.writeFile(projectFile, "project"))
const discovery = yield* start()
expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${projectFile}\nproject`)
yield* Effect.promise(() => fs.writeFile(intermediate, "intermediate"))
yield* emitAndWait({ type: "create", path: intermediate })
expect((yield* readInitial(yield* discovery.load())).text).toBe(
[`Instructions from: ${intermediate}\nintermediate`, `Instructions from: ${projectFile}\nproject`].join(
"\n\n",
),
}),
),
)
expect(
(yield* readUpdate(context, state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] })))
.changed,
).toBe(false)
}),
)
it.effect("preserves admitted instructions when a discovered file disappears before read", () =>
Effect.gen(function* () {
const file = AbsolutePath.make("/repo/AGENTS.md")
const racingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({
...fs,
up: () => Effect.succeed([file]),
readFileStringSafe: () => Effect.succeed(undefined),
)
}).pipe(
Effect.provide(
instructionLayer({
config: path.join(tmp.path, "global"),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const context = yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
config: "/global",
filesystemLayer: racingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
}),
),
)
expect(
(yield* readUpdate(context, state({ "core/instructions": [{ path: file, content: "old" }] }))).changed,
).toBe(false)
}),
)
}),
),
)
it.effect("canonicalizes upward discovery boundaries", () =>
it.effect("isolates source failure without failing activation", () => {
const failingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
return Effect.gen(function* () {
const discovery = yield* start()
expect(
(yield* readUpdate(
yield* discovery.load(),
state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
)).changed,
).toBe(false)
}).pipe(
Effect.provide(
instructionLayer({
filesystemLayer: failingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
}),
),
)
})
it.effect("marks a discovered file that disappears before read as unavailable", () => {
const discovered = AbsolutePath.make("/repo/AGENTS.md")
const racingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({
...fs,
up: () => Effect.succeed([discovered]),
readFileStringSafe: () => Effect.succeed(undefined),
}),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
return Effect.gen(function* () {
const discovery = yield* start()
expect(
(yield* readUpdate(
yield* discovery.load(),
state({ "core/instructions": [{ path: discovered, content: "old" }] }),
)).changed,
).toBe(false)
}).pipe(
Effect.provide(
instructionLayer({
filesystemLayer: racingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
}),
),
)
})
it.effect("canonicalizes boundaries and honors project opt-out", () =>
Effect.gen(function* () {
let observed: { targets: string[]; start: string; stop?: string } | undefined
const observed: { values: { targets: string[]; start: string; stop?: string }[] } = { values: [] }
const observingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({
...fs,
up: (options) =>
Effect.sync(() => {
observed = options
return []
}),
up: (options) => Effect.sync(() => (observed.values.push(options), [])),
}),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
yield* start().pipe(
Effect.provide(
instructionLayer({
config: "/global",
filesystemLayer: observingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
@@ -233,31 +352,11 @@ describe("InstructionDiscovery", () => {
}),
),
)
expect(observed).toEqual({
targets: ["AGENTS.md"],
start: FSUtil.resolve("/repo"),
stop: FSUtil.resolve("/repo"),
})
}),
)
it.effect("honors the project instruction opt-out", () =>
Effect.gen(function* () {
let scanned = false
yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
yield* start().pipe(
Effect.provide(
instructionLayer({
config: "/global",
filesystemLayer: observingFS,
project: false,
filesystemLayer: Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
@@ -265,25 +364,10 @@ describe("InstructionDiscovery", () => {
}),
),
)
expect(scanned).toBe(false)
}),
)
it.effect("does not discover project instructions outside the canonical project root", () =>
Effect.gen(function* () {
let scanned = false
yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
yield* start().pipe(
Effect.provide(
instructionLayer({
config: "/global",
filesystemLayer: Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
filesystemLayer: observingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
@@ -297,7 +381,8 @@ describe("InstructionDiscovery", () => {
),
)
expect(scanned).toBe(false)
const repo = path.resolve("/repo")
expect(observed.values).toEqual([{ targets: ["AGENTS.md"], start: repo, stop: repo }])
}),
)
})
@@ -1,4 +1,5 @@
import { describe, expect } from "bun:test"
import os from "os"
import { Effect, Layer } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -16,6 +17,7 @@ const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const sessionID = SessionSchema.ID.make("ses_builtin_test")
const temporary = os.tmpdir()
const localDate = (time: number) => new Date(time).toDateString()
const locationLayer = Layer.succeed(
Location.Service,
@@ -29,7 +31,7 @@ const locationLayer = Layer.succeed(
const it = testEffect(
AppNodeBuilder.build(InstructionBuiltIns.node, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global", tmp: "/temporary" })],
[Global.node, Global.layerWith({ config: temporary, tmp: temporary })],
]),
)
@@ -49,7 +51,7 @@ describe("InstructionBuiltIns", () => {
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
" Use /temporary for temporary work outside the workspace; it already exists and is pre-approved for external directory access.",
` Use ${temporary} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
+10 -2
View File
@@ -9,6 +9,7 @@ import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
@@ -21,6 +22,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { toolDefinitions, waitForTool } from "./lib/tool"
import { Database } from "../src/database/database"
@@ -28,9 +30,15 @@ import { Bus } from "../src/bus"
import { Reference } from "../src/reference"
import { Tool } from "../src/tool"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node])))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
)
const itWithSdk = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
)
describe("LocationServiceMap", () => {
+65 -55
View File
@@ -1,19 +1,17 @@
import { describe, expect, beforeEach, afterAll, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { KV } from "@opencode-ai/core/kv"
import { Model } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@opencode-ai/core/provider"
import { it } from "./lib/effect"
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
const cacheFile = path.join(Global.Path.cache, "models.json")
const cacheKey = "models-dev:catalog"
test("normalizes permissive interleaved values to compatibility", () => {
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
@@ -164,7 +162,18 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
}),
)
const buildLayer = (state: Ref.Ref<MockState>, options: ModelsDev.Options = { fetch: false }) =>
interface MockCache {
readonly values: Map<string, KV.Value>
}
const makeMockKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
})
const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: ModelsDev.Options = { fetch: false }) =>
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
// and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run.
@@ -172,31 +181,20 @@ const buildLayer = (state: Ref.Ref<MockState>, options: ModelsDev.Options = { fe
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured(options)],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeMockKV(cache)],
]),
)
const writeCacheText = (text: string, mtimeMs?: number) =>
Effect.promise(async () => {
await mkdir(Global.Path.cache, { recursive: true })
await writeFile(cacheFile, text)
if (mtimeMs !== undefined) {
const t = mtimeMs / 1000
await utimes(cacheFile, t, t)
}
})
const makeCache = (): MockCache => ({ values: new Map() })
const writeCache = (data: object, mtimeMs?: number) => writeCacheText(JSON.stringify(data), mtimeMs)
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
cache.values.set(cacheKey, { updatedAt, body: text })
const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
eff.pipe(Effect.provide(buildLayer(state)))
const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
writeCacheText(cache, JSON.stringify(data), updatedAt)
beforeEach(async () => {
await rm(cacheFile, { force: true })
})
afterAll(async () => {
await rm(cacheFile, { force: true })
})
const provided = <A, E>(state: Ref.Ref<MockState>, cache: MockCache, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
eff.pipe(Effect.provide(buildLayer(state, cache)))
const initialState: MockState = {
body: JSON.stringify(fixture),
@@ -205,12 +203,14 @@ const initialState: MockState = {
}
describe("ModelsDev Service", () => {
it.live("get() returns normalized snapshots from disk when cache file exists", () =>
it.live("get() returns normalized snapshots from KV when a cache entry exists", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
cache,
ModelsDev.Service.use((s) => s.get()),
)
expect(result).toEqual(fixtureSnapshot)
@@ -219,11 +219,13 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns empty catalog when disk empty, fetch disabled, and no bundled snapshot is injected", () =>
it.live("get() returns empty catalog when KV is empty, fetch disabled, and no bundled snapshot is injected", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
cache,
ModelsDev.Service.use((s) => s.get()),
)
expect(result).toEqual([])
@@ -232,14 +234,15 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() recovers from a corrupted cache file by fetching a fresh catalog", () =>
it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
Effect.gen(function* () {
yield* writeCacheText("{")
const cache = makeCache()
writeCacheText(cache, "{")
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const context = yield* Layer.build(buildLayer(state, { fetch: true }))
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2))
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
@@ -247,9 +250,10 @@ describe("ModelsDev Service", () => {
it.live("uses the default models URL when the configured URL is empty", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
yield* ModelsDev.Service.use((service) => service.get()).pipe(
Effect.provide(buildLayer(state, { url: "", fetch: true })),
Effect.provide(buildLayer(state, cache, { url: "", fetch: true })),
)
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json")
}),
@@ -257,32 +261,31 @@ describe("ModelsDev Service", () => {
it.live("get() is single-flight under concurrent calls", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
const state = yield* Ref.make(initialState)
const results = yield* provided(
state,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
concurrency: "unbounded",
})
}),
)
const results = yield* Effect.gen(function* () {
const svc = yield* ModelsDev.Service
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
concurrency: "unbounded",
})
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: true })))
for (const result of results) expect(result).toEqual(fixtureSnapshot)
expect((yield* Ref.get(state)).calls.length).toBe(1)
}),
)
it.live("get() caches across calls (later disk writes are ignored until invalidate)", () =>
it.live("get() caches across calls (later KV writes are ignored until invalidate)", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make(initialState)
const first = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const a = yield* svc.get()
// mutate disk between calls — cache should mask the change
yield* writeCache(fixture2)
writeCache(cache, fixture2)
const b = yield* svc.get()
return { a, b }
}),
@@ -294,10 +297,12 @@ describe("ModelsDev Service", () => {
it.live("refresh(true) fetches via HttpClient and updates the cache", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const result = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const before = yield* svc.get()
@@ -308,6 +313,7 @@ describe("ModelsDev Service", () => {
)
expect(result.before).toEqual(fixtureSnapshot)
expect(result.after).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json")
@@ -315,13 +321,14 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) skips fetch when on-disk file is fresh", () =>
it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
Effect.gen(function* () {
// Fresh: mtime within the 5-minute TTL.
yield* writeCache(fixture, Date.now() - 1000)
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 1000)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
yield* provided(
state,
cache,
ModelsDev.Service.use((s) => s.refresh(false)),
)
const final = yield* Ref.get(state)
@@ -329,13 +336,14 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) fetches when on-disk file is stale", () =>
it.live("refresh(false) fetches when the KV entry is stale", () =>
Effect.gen(function* () {
// Stale: mtime 10 minutes ago, beyond the 5-minute TTL.
yield* writeCache(fixture, Date.now() - 10 * 60 * 1000)
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const after = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
yield* svc.refresh(false)
@@ -350,10 +358,12 @@ describe("ModelsDev Service", () => {
it.live("refresh swallows HTTP errors and leaves cache intact", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
const result = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
yield* svc.refresh(true)
+48
View File
@@ -1,4 +1,5 @@
import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
@@ -395,4 +396,51 @@ describe("Plugin", () => {
})
}),
)
it.effect("rejects tool execution when an execute.before hook fails", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
const plugin = EffectPlugin.define({
id: "tool-hook-reject",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool
.transform((draft) =>
draft.add({
name: "echo",
options: { codemode: false },
description: "Echo",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) =>
Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
}),
)
.pipe(Effect.orDie)
yield* ctx.tool
.hook("execute.before", () => new ToolFailure({ message: "write disabled" }))
.pipe(Effect.asVoid)
}),
})
yield* plugins.activate([versioned(plugin)])
const toolSet = yield* registry.snapshot()
const failure = yield* toolSet
.execute({
sessionID: Session.ID.make("ses_hook_reject"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_hook_reject"),
call: { type: "tool-call", id: "call-hook-reject", name: "echo", input: { text: "original" } },
})
.pipe(Effect.flip)
expect(failure).toMatchObject({ _tag: "Tool.Error", message: "write disabled" })
expect(executed).toEqual([])
}),
)
})
+5 -1
View File
@@ -19,9 +19,11 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Reference } from "@opencode-ai/core/reference"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Tool } from "@opencode-ai/core/tool"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Effect, Layer, Stream } from "effect"
import { Effect, Layer } from "effect"
import { tempLocationLayer } from "../fixture/location"
const npmLayer = Layer.succeed(
@@ -53,8 +55,10 @@ export const PluginTestLayer = AppNodeBuilder.build(
PluginHooks.node,
Reference.node,
Skill.node,
SkillDiscovery.node,
PluginHooks.node,
Tool.node,
Watcher.node,
WebSearch.node,
]),
[
+5 -3
View File
@@ -543,11 +543,10 @@ describe("Session.create", () => {
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const targetDatabase = Database.layer({ path: path.join(tmp.path, "target.sqlite") })
const targetLayer = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
[
[Database.node, targetDatabase],
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
[Bus.node, Bus.configured({ persist: true })],
],
)
@@ -648,7 +647,7 @@ describe("Session.create", () => {
it.effect("switches the selected agent through the durable Session event", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
const created = yield* session.create({ location, agent: Agent.ID.make("build") })
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") })
@@ -656,6 +655,9 @@ describe("Session.create", () => {
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
{ type: "agent-switched", agent: "plan", previous: "build" },
])
}),
)
+4 -1
View File
@@ -99,7 +99,10 @@ const builtins = Layer.mock(InstructionBuiltIns.Service, {
}),
),
})
const discovery = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const discovery = Layer.mock(InstructionDiscovery.Service, {
project: true,
load: () => Effect.succeed(Instructions.empty),
})
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
@@ -358,6 +358,7 @@ describe("SessionProjector", () => {
directory: "/project",
title: "test",
version: "test",
agent: "plan",
model: previousModel,
})
.run()
@@ -459,6 +460,10 @@ describe("SessionProjector", () => {
text: "synthetic context",
metadata: { source: "projector-test" },
})
expect(messages.find((message) => message.type === "agent-switched")).toMatchObject({
agent: build,
previous: "plan",
})
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
expect(messages.find((message) => message.type === "shell")).toMatchObject({
command: "pwd",
@@ -373,6 +373,103 @@ Recent work
])
})
test("deduplicates provider media while preserving durable attachment references", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-duplicate-image"),
type: "user",
text: "[Image 1] [Image 1] [Image 2]",
files: [
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 10, end: 19, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
description: "alternate use",
mention: { start: 20, end: 29, text: "[Image 2]" },
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "[Image 1] [Image 1] [Image 2]" },
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
{
type: "media",
mediaType: "image/png",
data,
filename: "image.png",
metadata: { description: "alternate use" },
},
])
})
test("preserves provider media with distinct labels or URI sources", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-distinct-images"),
type: "user",
text: "[Image 1] [Image 2]",
files: [
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 10, end: 19, text: "[Image 2]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "file:///project/image.png" },
name: "image.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content.filter((part) => part.type === "media")).toHaveLength(4)
})
test("replays durable tool media into canonical tool messages without structured base64", () => {
const messages = toLLMMessages(
[
@@ -83,7 +83,10 @@ const models = Layer.mock(SessionRunnerModel.Service)({
),
})
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, {
project: true,
load: () => Effect.succeed(Instructions.empty),
})
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
load: () => Effect.succeed(Instructions.empty),
+4 -1
View File
@@ -312,7 +312,10 @@ const systemContext = Layer.mock(InstructionBuiltIns.Service, {
}),
),
})
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, {
project: true,
load: () => Effect.succeed(Instructions.empty),
})
const skillInstructions = Layer.mock(SkillInstructions.Service, {
load: (agent) =>
Effect.succeed(
+71 -407
View File
@@ -1,438 +1,102 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { Deferred, Effect, Fiber, Stream } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const urls = new Map<string, AbsolutePath[]>()
let pulls = 0
const discovery = Layer.succeed(
SkillDiscovery.Service,
SkillDiscovery.Service.of({
pull: (url) => {
pulls++
return Effect.succeed(urls.get(url) ?? [])
},
}),
)
const watcherLayer = Watcher.testLayer
const it = testEffect(
Layer.mergeAll(
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [
[SkillDiscovery.node, discovery],
[Watcher.node, watcherLayer],
]),
watcherLayer,
),
)
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node])))
function write(directory: string, name: string, description: string) {
return fs.writeFile(
path.join(directory, name, "SKILL.md"),
`---
name: ${name}
description: ${description}
---
# ${name}`,
)
}
function waitForSkillUpdate() {
return Effect.gen(function* () {
const bus = yield* Bus.Service
const deferred = yield* Deferred.make<void>()
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
Effect.forkScoped,
)
yield* Effect.yieldNow
return { deferred, fiber }
const info = (id: string, description: string) =>
Skill.Info.make({
id: Skill.ID.make(id),
name: Skill.Name.make(id),
description,
location: AbsolutePath.make(`/skills/${id}/SKILL.md`),
content: `# ${id}`,
})
}
function expectSubscription(check: (input: Watcher.WatchInput) => boolean) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
expect((yield* watcher.subscriptions()).some(check)).toBe(true)
})
}
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
yield* Effect.acquireUseRelease(
waitForSkillUpdate(),
({ deferred }) => watcher.emit(update).pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber),
)
})
}
describe("Skill", () => {
it.live("publishes updates when skill sources change", () =>
it.effect("registers values with last-write-wins precedence", () =>
Effect.gen(function* () {
const skill = yield* Skill.Service
yield* skill.transform((draft) => {
draft.add(info("review", "First"))
draft.add(info("deploy", "Deploy"))
draft.add(info("review", "Second"))
expect(draft.list().map((item) => item.id)).toEqual([Skill.ID.make("review"), Skill.ID.make("deploy")])
})
yield* Effect.acquireUseRelease(
waitForSkillUpdate(),
({ deferred }) =>
skill
.transform((editor) =>
editor.source({ type: "directory", path: AbsolutePath.make("/tmp/opencode-skills") }),
)
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber),
)
expect(yield* skill.list()).toEqual([info("review", "Second"), info("deploy", "Deploy")])
}),
)
it.live("registers sources and resolves later source precedence", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "review"), { recursive: true })
await fs.mkdir(path.join(second, "review"), { recursive: true })
await write(first, "review", "First")
await write(second, "review", "Second")
await fs.writeFile(path.join(first, "foo.md"), "---\nslash: true\n---\n# foo")
})
it.effect("updates and removes registered values", () =>
Effect.gen(function* () {
const skill = yield* Skill.Service
yield* skill.transform((draft) => {
draft.add(info("review", "Initial"))
draft.update("review", (value) => {
value.description = "Updated"
value.id = Skill.ID.make("ignored")
})
draft.update("missing", () => Effect.die("unreachable"))
draft.add(info("deploy", "Deploy"))
draft.remove("deploy")
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => {
editor.source({ type: "directory", path: AbsolutePath.make(first) })
editor.source({ type: "directory", path: AbsolutePath.make(first) })
editor.source({ type: "directory", path: AbsolutePath.make(second) })
expect(editor.list()).toEqual([
{ type: "directory", path: AbsolutePath.make(first) },
{ type: "directory", path: AbsolutePath.make(second) },
])
})
expect(yield* skill.sources()).toEqual([
{ type: "directory", path: AbsolutePath.make(first) },
{ type: "directory", path: AbsolutePath.make(second) },
])
expect(yield* skill.list()).toEqual([
Skill.Info.make({
id: Skill.ID.make("foo"),
name: Skill.Name.make("foo"),
slash: true,
location: AbsolutePath.make(path.join(first, "foo.md")),
content: "# foo",
}),
{
id: Skill.ID.make("review"),
name: Skill.Name.make("review"),
description: "Second",
location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
content: "# review",
},
])
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: second, type: "directory" },
])
yield* Effect.promise(() => write(second, "review", "Updated Second"))
yield* emitAndWait({ type: "update", path: path.join(second, "review", "SKILL.md") })
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Updated Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: second, type: "directory" },
{ path: first, type: "directory" },
{ path: second, type: "directory" },
])
}),
),
),
expect(yield* skill.list()).toEqual([info("review", "Updated")])
}),
)
it.live("loads URL sources and filters skills for agents", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Deploy production")
})
pulls = 0
urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)])
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("reviewer"), (agent) => {
agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
}),
)
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" }))
expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
expect(pulls).toBe(1)
expect(Skill.available(yield* skill.list(), (yield* agents.get(Agent.ID.make("reviewer")))!)).toEqual([])
it.effect("restores earlier values when an updating transform is disposed", () =>
Effect.gen(function* () {
const skill = yield* Skill.Service
const original = info("review", "Initial")
yield* skill.transform((draft) => draft.add(original))
const updated = yield* skill.transform((draft) =>
draft.update("review", (value) => {
value.description = "Updated"
}),
),
),
)
expect((yield* skill.list())[0]?.description).toBe("Updated")
yield* updated.dispose
expect((yield* skill.list())[0]?.description).toBe("Initial")
expect(original.description).toBe("Initial")
}),
)
it.live("parses opencode metadata flags from skill frontmatter", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "manual"), { recursive: true })
await fs.writeFile(
path.join(tmp.path, "manual", "SKILL.md"),
`---
name: manual
description: Manual only
metadata:
opencode/slash: true
opencode/autoinvoke: false
---
# manual`,
)
})
it.live("publishes updates after committed values are visible", () =>
Effect.gen(function* () {
const skill = yield* Skill.Service
const bus = yield* Bus.Service
const updated = yield* Deferred.make<Skill.Info[]>()
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
Stream.runForEach(() => skill.list().pipe(Effect.flatMap((values) => Deferred.succeed(updated, values)))),
Effect.forkScoped,
)
yield* Effect.yieldNow
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect(yield* skill.list()).toEqual([
{
id: Skill.ID.make("manual"),
name: Skill.Name.make("manual"),
description: "Manual only",
slash: true,
autoinvoke: false,
location: AbsolutePath.make(path.join(tmp.path, "manual", "SKILL.md")),
content: "# manual",
},
])
}),
),
),
yield* skill.transform((draft) => draft.add(info("review", "Visible")))
expect(yield* Deferred.await(updated).pipe(Effect.timeout("1 second"))).toEqual([info("review", "Visible")])
yield* Fiber.interrupt(fiber)
}),
)
it.live("clears cached skills when sources reload", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Initial deploy")
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
const bus = yield* Bus.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial deploy")
expect(yield* watcher.subscriptions()).toEqual([{ path: tmp.path, type: "directory" }])
let refreshed: Skill.Info[] = []
const unsubscribe = yield* bus.listen((event) => {
if (event.type !== Skill.Event.Updated.type) return Effect.void
return skill.list().pipe(
Effect.tap((items) => Effect.sync(() => (refreshed = items))),
Effect.asVoid,
)
})
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
yield* skill.reload().pipe(Effect.timeout("1 second"))
yield* unsubscribe
expect(refreshed.find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
expect(yield* watcher.subscriptions()).toEqual([
{ path: tmp.path, type: "directory" },
{ path: tmp.path, type: "directory" },
])
it.effect("filters values by agent permissions", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("reviewer"), (agent) => {
agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
}),
),
),
)
it.live("reloads project sources created after their missing parent", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "generated", "skills")
const file = path.join(source, "deploy", "SKILL.md")
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(tmp.path, "generated"), type: "file" },
{ path: source, type: "file" },
])
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(file), { recursive: true })
await write(source, "deploy", "Deploy production")
})
yield* emitAndWait({ type: "create", path: source })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(tmp.path, "generated"), type: "file" },
{ path: source, type: "file" },
{ path: source, type: "directory" },
])
}),
),
),
)
it.live("watches directory sources for added and changed skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Initial deploy")
})
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
yield* emitAndWait({ type: "update", path: deploy })
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
await write(tmp.path, "review", "Review changes")
})
const review = path.join(tmp.path, "review", "SKILL.md")
yield* emitAndWait({ type: "create", path: review })
expect((yield* skill.list()).map((item) => item.id)).toEqual([
Skill.ID.make("deploy"),
Skill.ID.make("review"),
])
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
yield* emitAndWait({ type: "delete", path: review })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
}),
),
),
)
it.live("watches canonical directories behind symlinked skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const target = path.join(tmp.path, "target", "bro")
const file = path.join(target, "SKILL.md")
yield* Effect.promise(async () => {
await fs.mkdir(source, { recursive: true })
await fs.mkdir(target, { recursive: true })
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
await fs.symlink(target, path.join(source, "bro"))
})
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
yield* expectSubscription((input) => input.type === "directory" && input.path === target)
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
yield* emitAndWait({ type: "update", path: file })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
}),
),
),
)
it.live("invalidates symlinked sources when their target changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "bro"), { recursive: true })
await fs.mkdir(path.join(second, "bro"), { recursive: true })
await write(first, "bro", "First")
await write(second, "bro", "Second")
await fs.symlink(first, source)
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
])
yield* Effect.promise(async () => {
await fs.unlink(source)
await fs.symlink(second, source)
})
yield* emitAndWait({ type: "update", path: source })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
{ path: second, type: "directory" },
{ path: source, type: "file" },
])
}),
),
),
)
const agent = yield* agents.get(Agent.ID.make("reviewer"))
expect(Skill.available([info("deploy", "Deploy")], agent!)).toEqual([])
}),
)
})
+2
View File
@@ -33,6 +33,7 @@ import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, toolDefinitions, waitForTool } from "./lib/tool"
@@ -138,6 +139,7 @@ const layer = AppNodeBuilder.build(
[
[SessionExecution.node, executionNode],
[Permission.node, permission],
[Global.node, tempGlobalLayer],
],
)
-1
View File
@@ -81,7 +81,6 @@ describe("SkillTool", () => {
Skill.Service.of({
transform: (_transform) => Effect.die("unused"),
reload: () => Effect.die("unused"),
sources: () => Effect.die("unused"),
list: () => Effect.succeed(current),
}),
)
+6 -1
View File
@@ -4,6 +4,7 @@ import path from "path"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Database } from "@opencode-ai/core/database/database"
import { Bus } from "@opencode-ai/core/bus"
@@ -26,6 +27,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { executeTool, toolIdentity, waitForTool } from "./lib/tool"
@@ -100,7 +102,10 @@ const layer = AppNodeBuilder.build(
PluginRuntime.providerNode,
LocationServiceMap.node,
]),
[[SessionExecution.node, executionNode]],
[
[SessionExecution.node, executionNode],
[Global.node, tempGlobalLayer],
],
)
const it = testEffect(layer)
+10 -4
View File
@@ -18,9 +18,14 @@ import { tmpdir } from "./fixture/tmpdir"
import path from "path"
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient | Scope.Scope>) =>
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient | Scope.Scope | Global.Service>) =>
Effect.runPromise(
Effect.scoped(effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })))),
Effect.scoped(
effect.pipe(
Effect.provideService(Global.Service, Global.make({ data: path.join(process.cwd(), ".test-data") })),
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
),
),
)
const session = (
@@ -772,7 +777,7 @@ describe("V1Migration database workflow", () => {
`)
})
const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Scope.Scope>) =>
const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Global.Service | Scope.Scope>) =>
run(
Effect.gen(function* () {
const db = yield* makeDb
@@ -935,6 +940,7 @@ describe("V1Migration database workflow", () => {
await database(
Effect.gen(function* () {
const { db } = yield* Database.Service
const global = yield* Global.Service
yield* db.run(
sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '1', 1, 2)`,
)
@@ -944,7 +950,7 @@ describe("V1Migration database workflow", () => {
project_id: "global",
})
expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({
worktree: path.parse(Global.Path.data).root,
worktree: path.parse(global.data).root,
})
expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({
value: '{"phase":"completed"}',
@@ -57,35 +57,35 @@ test("keeps a hidden prod launcher for old Linux pins", async () => {
expect(desktop).toContain("NoDisplay=true")
})
test("bundles the CLI outside the dev app archive", async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = "dev"
const module = await import("./electron-builder.config.ts?cli-resource")
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.files).toContain("!resources/opencode-cli*")
expect(config.extraResources).toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
})
})
for (const channel of ["beta", "prod"] as const) {
test(`does not bundle the CLI in ${channel} builds`, async () => {
for (const channel of ["dev", "beta"] as const) {
test(`bundles the CLI outside the ${channel} app archive`, async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = channel
const module = await import(`./electron-builder.config.ts?no-cli-resource=${channel}`)
const module = await import(`./electron-builder.config.ts?cli-resource=${channel}`)
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.extraResources).not.toContainEqual({
expect(config.files).toContain("!resources/opencode-cli*")
expect(config.extraResources).toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
})
})
}
test("does not bundle the CLI in prod builds", async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = "prod"
const module = await import("./electron-builder.config.ts?no-cli-resource=prod")
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.extraResources).not.toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
})
})
+1 -1
View File
@@ -57,7 +57,7 @@ const getBase = (appId: string): Configuration => ({
},
files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"],
extraResources: [
...(channel === "dev"
...(channel !== "prod"
? [
{
from: "resources/",
+1
View File
@@ -37,6 +37,7 @@
"@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
+2 -2
View File
@@ -1,9 +1,9 @@
import { $ } from "bun"
import * as path from "node:path"
import { RUST_TARGET } from "./utils"
import { CLI_TARGET } from "./utils"
if (!RUST_TARGET) throw new Error("RUST_TARGET not defined")
if (!CLI_TARGET) throw new Error("OPENCODE_CLI_TARGET not defined")
const BUNDLE_DIR = "dist"
const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles")
+1
View File
@@ -8,3 +8,4 @@ await $`bun ./scripts/copy-icons.ts ${channel}`
await $`bun ./scripts/copy-metainfo.ts ${channel}`
if (channel === "dev") await downloadCliToResources()
if (channel === "beta") await downloadCliToResources("next")
+13 -13
View File
@@ -13,46 +13,46 @@ export function resolveChannel(): Channel {
return "dev"
}
export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [
export const CLI_BINARIES: Array<{ target: string; package: string; os: string; cpu: string }> = [
{
rustTarget: "aarch64-apple-darwin",
target: "aarch64-apple-darwin",
package: "@opencode-ai/cli-darwin-arm64",
os: "darwin",
cpu: "arm64",
},
{
rustTarget: "x86_64-apple-darwin",
target: "x86_64-apple-darwin",
package: "@opencode-ai/cli-darwin-x64-baseline",
os: "darwin",
cpu: "x64",
},
{
rustTarget: "aarch64-pc-windows-msvc",
target: "aarch64-pc-windows-msvc",
package: "@opencode-ai/cli-windows-arm64",
os: "win32",
cpu: "arm64",
},
{
rustTarget: "x86_64-pc-windows-msvc",
target: "x86_64-pc-windows-msvc",
package: "@opencode-ai/cli-windows-x64-baseline",
os: "win32",
cpu: "x64",
},
{
rustTarget: "x86_64-unknown-linux-gnu",
target: "x86_64-unknown-linux-gnu",
package: "@opencode-ai/cli-linux-x64-baseline",
os: "linux",
cpu: "x64",
},
{
rustTarget: "aarch64-unknown-linux-gnu",
target: "aarch64-unknown-linux-gnu",
package: "@opencode-ai/cli-linux-arm64",
os: "linux",
cpu: "arm64",
},
]
export const RUST_TARGET = Bun.env.RUST_TARGET
export const CLI_TARGET = Bun.env.OPENCODE_CLI_TARGET
function nativeTarget() {
const { platform, arch } = process
@@ -62,19 +62,19 @@ function nativeTarget() {
throw new Error(`Unsupported platform: ${platform}/${arch}`)
}
export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) {
const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target)
export function getCurrentCli(target = CLI_TARGET ?? nativeTarget()) {
const binaryConfig = CLI_BINARIES.find((item) => item.target === target)
if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`)
return binaryConfig
}
export async function downloadCliToResources() {
export async function downloadCliToResources(version = CLI_VERSION) {
const cli = getCurrentCli()
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
const dest = windowsify("resources/opencode-cli")
try {
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${CLI_VERSION}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
await copyFile(
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"),
dest,
@@ -88,7 +88,7 @@ export async function downloadCliToResources() {
}
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
console.log(`Copied ${cli.package} to ${dest}`)
console.log(`Copied ${cli.package}@${version} to ${dest}`)
}
export function windowsify(path: string) {
+23 -48
View File
@@ -1,3 +1,4 @@
import { Service } from "@opencode-ai/client/service"
import { execFile } from "node:child_process"
import { existsSync } from "node:fs"
import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises"
@@ -8,52 +9,34 @@ import { app } from "electron"
const execFileAsync = promisify(execFile)
const root = dirname(fileURLToPath(import.meta.url))
const stateHome = process.env.XDG_STATE_HOME
const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"]
type Logger = {
log(message: string, meta?: Record<string, unknown>): void
error(message: string, meta?: Record<string, unknown>): void
}
export async function startBackgroundCli(logger: Logger, shellStateHome?: string) {
export async function startBackgroundCli(logger: Logger) {
const bundled = app.isPackaged
? join(process.resourcesPath, executableName())
: join(root, "../../resources", executableName())
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = await run(bundled, ["--version"], logger)
const version = parseVersion(await run(bundled, ["--version"], logger))
const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled
const candidates = [
...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]),
].filter((candidate) => candidate === undefined || existsSync(candidate))
const discovered = await Promise.all(
candidates.map(async (candidate) => ({
stateHome: candidate,
url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })),
})),
)
const found = discovered.find((candidate) => candidate.url !== undefined)
logger.log("v2 CLI background instance checked", {
detected: Boolean(found),
...endpoint(found?.url),
})
const daemonStateHome = found?.stateHome ?? stateHome
const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome })
const password = await run(binary, ["service", "get", "password"], logger, {
redact: true,
stateHome: daemonStateHome,
const service = await Service.ensure({
version,
command: [binary, "serve", "--service"],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
logger.log("v2 CLI background service ready", {
existing: Boolean(found),
username: "opencode",
...endpoint(url),
username: service.auth.username,
version,
...endpoint(service.url),
})
return {
url,
username: "opencode",
password,
url: service.url,
username: service.auth.username,
password: service.auth.password,
}
}
@@ -77,21 +60,13 @@ async function installCli(source: string, version: string, logger: Logger) {
return destination
}
async function run(
binary: string,
args: string[],
logger: Logger,
options: { redact?: boolean; stateHome?: string } = {},
) {
async function run(binary: string, args: string[], logger: Logger) {
logger.log("v2 CLI command started", { binary, args })
const env = { ...process.env }
if (options.stateHome === undefined) delete env.XDG_STATE_HOME
else env.XDG_STATE_HOME = options.stateHome
return execFileAsync(binary, args, { env, windowsHide: true }).then(
return execFileAsync(binary, args, { windowsHide: true }).then(
(result) => {
const stdout = result.stdout.trim()
const stderr = result.stderr.trim()
logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr })
logger.log("v2 CLI command completed", { args, stdout, stderr })
return stdout
},
(error: unknown) => {
@@ -99,7 +74,7 @@ async function run(
logger.error("v2 CLI command failed", {
args,
error: error instanceof Error ? error.message : String(error),
stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""),
stdout: output.stdout?.trim() ?? "",
stderr: output.stderr?.trim() ?? "",
})
throw error
@@ -107,11 +82,11 @@ async function run(
)
}
function serviceUrl(status: string) {
if (URL.canParse(status)) return status
if (!status.startsWith("running ")) return
const url = status.slice("running ".length).trim()
return URL.canParse(url) ? url : undefined
function parseVersion(output: string) {
const marker = output.lastIndexOf(" v")
const version = marker === -1 ? output : output.slice(marker + 2)
if (!version) throw new Error("V2 CLI did not provide a version")
return version
}
function endpoint(url: string | undefined) {
+2 -2
View File
@@ -181,7 +181,7 @@ const main = Effect.gen(function* () {
return
}
const shellEnv = preferAppEnv(app.getPath("userData"))
preferAppEnv()
app.on("second-instance", (_event: Event, argv: string[]) => {
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
@@ -310,7 +310,7 @@ const main = Effect.gen(function* () {
useEnvProxy()
logger.log("starting v2 background service")
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME))
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger))
yield* Deferred.succeed(serverReady, {
url: sidecar.url,
username: sidecar.username,
+2 -3
View File
@@ -17,17 +17,16 @@ export function setDefaultServerUrl(url: string | null) {
getStore().delete(DEFAULT_SERVER_URL_KEY)
}
export function preferAppEnv(userDataPath: string) {
export function preferAppEnv() {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null
if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME
Object.assign(process.env, {
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
})
return shellEnv
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
+8 -4
View File
@@ -44,6 +44,10 @@ export interface MermaidMarkdownRendererOptions {
muted?: ColorInput
warning?: ColorInput
background?: ColorInput
request?: ColorInput
response?: ColorInput
note?: ColorInput
noteBackground?: ColorInput
}
}
@@ -131,12 +135,12 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
participant: color(colors.primary),
lifeline: color(colors.muted),
group: color(colors.secondary),
request: color(colors.primary),
response: color(colors.primary),
request: color(colors.request ?? colors.primary),
response: color(colors.response ?? colors.primary),
fragment: color(colors.secondary),
fragmentLabelBg: color(colors.background),
note: color(colors.warning),
noteBg: color(colors.background),
note: color(colors.note ?? colors.warning),
noteBg: color(colors.noteBackground ?? colors.background),
}),
),
height: size.height,
+9
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { blendColor } from "./core/color/style.js"
import { createOpenCodeDiagramPalette } from "./palette.js"
type Rgb = readonly [number, number, number]
@@ -31,11 +32,15 @@ describe("OpenCode diagram palette", () => {
}>)("derives a controlled neutral ladder for a $name", ({ text, subdued, secondary, muted }) => {
const primary = rgb(text)
const info = RGBA.fromInts(40, 120, 220)
const success = RGBA.fromInts(80, 180, 120)
const warning = RGBA.fromInts(220, 160, 80)
const background = RGBA.fromInts(10, 20, 30)
const palette = createOpenCodeDiagramPalette({
text: primary,
subdued: rgb(subdued),
info,
success,
warning,
background,
})
@@ -45,5 +50,9 @@ describe("OpenCode diagram palette", () => {
expect(palette.muted.equals(rgb(muted))).toBe(true)
expect(palette.warning).toBe(info)
expect(palette.background).toBe(background)
expect(palette.request).toBe(success)
expect(palette.response).toBe(warning)
expect(palette.note).toBe(primary)
expect(palette.noteBackground.equals(blendColor(background, rgb(subdued), 0.25))).toBe(true)
})
})
+6
View File
@@ -5,6 +5,8 @@ export interface OpenCodeDiagramPaletteInput {
readonly text: RGBA
readonly subdued: RGBA
readonly info: RGBA
readonly success: RGBA
readonly warning: RGBA
readonly background: RGBA
}
@@ -16,5 +18,9 @@ export function createOpenCodeDiagramPalette(input: OpenCodeDiagramPaletteInput)
muted: blendColor(input.text, input.subdued, 0.7),
warning: input.info,
background: input.background,
request: input.success,
response: input.warning,
note: input.text,
noteBackground: blendColor(input.background, input.subdued, 0.25),
}
}
+2
View File
@@ -12,6 +12,8 @@ export default Plugin.define({
text: context.theme.text.default,
subdued: context.theme.text.subdued,
info: context.theme.text.feedback.info.default,
success: context.theme.text.feedback.success.default,
warning: context.theme.text.feedback.warning.default,
background: context.theme.background.default,
}),
})),
+63 -69
View File
@@ -51,16 +51,15 @@ sequenceDiagram
`)
expectDiagram(output).toEqualDiagram(`
Browser Server
GET /
401 WWW-Auth
Browser Server
GET /
401 WWW-Auth
`)
})
@@ -140,13 +139,13 @@ sequenceDiagram
`)
const lines = output.split("\n")
const browserCenter = lines[1]!.indexOf("w")
const serverCenter = lines[1]!.indexOf("v")
const browserCenter = lines[0]!.indexOf("w")
const serverCenter = lines[0]!.indexOf("v")
expect(lines[2]?.[browserCenter]).toBe("┬")
expect(lines[3]?.[browserCenter]).toBe("│")
expect(lines[2]?.[serverCenter]).toBe("┬")
expect(lines[3]?.[serverCenter]).toBe("│")
expect(lines[1]?.[browserCenter]).toBe("┬")
expect(lines[2]?.[browserCenter]).toBe("│")
expect(lines[1]?.[serverCenter]).toBe("┬")
expect(lines[2]?.[serverCenter]).toBe("│")
})
test("ramps participant frames into neutral lifelines", () => {
@@ -277,8 +276,8 @@ sequenceDiagram
A->>B: hello`)
expect(output).not.toContain("<br")
expect(output).toContain("First line")
expect(output).toContain("Second line")
expect(output).toContain("First line")
expect(output).toContain("Second line")
})
test("parses Mermaid arrow head variants", () => {
@@ -314,28 +313,27 @@ sequenceDiagram
`)
expect(output).toMatchInlineSnapshot(`
"
A B
open solid
>
open dashed
<
failed solid
failed dashed
async solid
)
async dashed
(
"
" A B
open solid
>
open dashed
<
failed solid
failed dashed
async solid
)
async dashed
(
"
`)
})
@@ -387,7 +385,7 @@ sequenceDiagram
end
`)
const lines = output.split("\n")
const participantCenter = lines.find((line) => line.includes("│ A │"))!.indexOf("A")
const participantCenter = lines.find((line) => line.includes(" A"))!.indexOf("A")
const fragmentStart = lines.find((line) => line.includes("alt: ok"))!.indexOf("╭")
expect(fragmentStart).toBeLessThan(participantCenter)
@@ -557,7 +555,7 @@ sequenceDiagram
const fragmentMessageRow = fragment.split("\n").find((line) => line.includes("this non adjacent message"))!
expect(groupMessageRow.trimEnd().endsWith("│")).toBe(true)
expect(fragmentMessageRow).toContain("this non adjacent message is deliberately much wider than the frame")
expect(fragmentMessageRow.match(/│/g)?.length).toBe(2)
expect(fragmentMessageRow.match(/│/g)?.length).toBe(3)
})
test("keeps long notes inside groups and nested fragment frames intact", () => {
@@ -600,7 +598,7 @@ sequenceDiagram
const groupBorderRight = output.split("\n")[0]!.lastIndexOf("╮")
const lines = output.split("\n")
const externalLabelRow = lines.findIndex((line) => line.includes("External"))
const externalHeaderLeft = lines[externalLabelRow - 1]!.lastIndexOf("")
const externalHeaderLeft = lines[externalLabelRow + 1]!.lastIndexOf("")
expect(externalHeaderLeft).toBeGreaterThan(groupBorderRight)
})
@@ -655,18 +653,17 @@ sequenceDiagram
`)
expect(output).toMatchInlineSnapshot(`
" Backend
Browser API Cache DB
GET /users/42
get user:42
"
" Backend
Browser API Cache DB
GET /users/42
get user:42
"
`)
})
@@ -706,18 +703,17 @@ sequenceDiagram
`)
expect(output).toMatchInlineSnapshot(`
"
Service
Check Permissions
"
"Service
Check Permissions
"
`)
})
test("frames notes in their reserved rows", () => {
test("renders note badges in their reserved rows", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
Browser->>Server: one
@@ -729,11 +725,9 @@ sequenceDiagram
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
expect(noteRow).toBeGreaterThan(0)
expect(lines[noteRow - 1]).toContain("")
expect(lines[noteRow - 1]).toContain("")
expect(lines[noteRow]).toContain("│ phase │")
expect(lines[noteRow + 1]).toContain("╰")
expect(lines[noteRow + 1]).toContain("╯")
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
expect(lines[noteRow]).toContain(" phase ")
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
expect(nextMessageRow).toBe(noteRow + 2)
})
+6 -36
View File
@@ -191,29 +191,9 @@ function renderSelfMessage(
}
function renderNote(grid: SequenceGrid, placement: Extract<SequenceStepPlacement, { type: "note" }>): void {
const width = Math.max(...placement.textLines.map(diagramTextWidth))
const left = placement.textX
const right = left + width - 1
const top = placement.textY - 1
const bottom = placement.textY + placement.textLines.length
for (let x = left + 1; x < right; x++) {
setCell(grid, x, top, SEQUENCE_BORDER.horizontal, "note")
setCell(grid, x, bottom, SEQUENCE_BORDER.horizontal, "note")
}
for (let y = top + 1; y < bottom; y++) {
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
}
setCell(grid, left, top, SEQUENCE_BORDER.topLeft, "note")
setCell(grid, right, top, SEQUENCE_BORDER.topRight, "note")
setCell(grid, left, bottom, SEQUENCE_BORDER.bottomLeft, "note")
setCell(grid, right, bottom, SEQUENCE_BORDER.bottomRight, "note")
placement.textLines.forEach((line, index) => setText(grid, left, placement.textY + index, line, "noteBadge"))
for (let y = placement.textY; y < bottom; y++) {
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
}
placement.textLines.forEach((line, index) =>
setText(grid, placement.textX, placement.textY + index, line, "noteBadge"),
)
}
export function drawSequenceDiagramGrid(
@@ -236,22 +216,12 @@ export function drawSequenceDiagramGrid(
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
)
} else {
labelLines.forEach((line, index) =>
setText(grid, centeredStart(center, line), participantHeaderTopY + index, line, "participant"),
)
for (let x = headerLeftX; x <= headerRightX; x++) {
setCell(grid, x, participantHeaderTopY, SEQUENCE_BORDER.horizontal, "participant")
setCell(grid, x, participantRuleY, SEQUENCE_BORDER.horizontal, "participant")
}
setCell(grid, headerLeftX, participantHeaderTopY, SEQUENCE_BORDER.topLeft, "participant")
setCell(grid, headerRightX, participantHeaderTopY, SEQUENCE_BORDER.topRight, "participant")
for (let y = participantHeaderY; y < participantRuleY; y++) {
setCell(grid, headerLeftX, y, SEQUENCE_BORDER.vertical, "participant")
setCell(grid, headerRightX, y, SEQUENCE_BORDER.vertical, "participant")
}
setCell(grid, headerLeftX, participantRuleY, SEQUENCE_BORDER.bottomLeft, "participant")
setCell(grid, headerRightX, participantRuleY, SEQUENCE_BORDER.bottomRight, "participant")
labelLines.forEach((line, index) =>
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
)
setCell(grid, center, participantRuleY, SEQUENCE_BORDER.topT, "participant")
}
@@ -206,7 +206,7 @@ ${Array.from(
expect(explicit.activations).toEqual(shorthand.activations)
})
test("centers message label blocks over their arrow span", () => {
test("left-aligns message label blocks inside their arrow span", () => {
const plan = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
participant A
@@ -214,8 +214,6 @@ ${Array.from(
A->>B: short<br/>a much longer line`),
)
const message = plan.steps.find((step) => step.type === "message")!
const labelWidth = Math.max(...message.labelLines.map(diagramTextWidth))
expect(message.labelX * 2 + labelWidth).toBe(message.leftX + message.rightX)
expect(message.labelX).toBe(message.leftX + 2)
})
})
+5 -5
View File
@@ -142,7 +142,7 @@ function messageLabelText(message: SequenceMessage): string {
function participantHeaderWidth(label: string, compact: boolean): number {
const width = labelLinesWidth(mermaidLabelLines(label))
return compact ? width : Math.max(5, width + 4)
return compact ? width : Math.max(3, width)
}
function fragmentLabelText(fragment: SequenceFragment): string {
@@ -247,7 +247,7 @@ function getStepContentBounds(
const leftX = Math.min(fromX, toX)
const rightX = Math.max(fromX, toX)
const labelWidth = messageWidth(step.message)
const labelLeftX = Math.floor((leftX + rightX - labelWidth) / 2)
const labelLeftX = leftX + 2
return { leftX: Math.min(leftX, labelLeftX), rightX: Math.max(rightX, labelLeftX + labelWidth - 1) }
}
if (step.type !== "note") return undefined
@@ -525,8 +525,8 @@ export function createSequencePlacementPlan(
...diagram.participants.map((participant) => mermaidLabelLines(participant.label).length),
)
const participantHeaderTopY = hasGroups ? 1 : 0
const participantHeaderY = participantHeaderTopY + (compact ? 0 : 1)
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight + 1)
const participantHeaderY = participantHeaderTopY
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight)
const lifelineStartY = participantRuleY + 1
const stepStartY = lifelineStartY + 1
const width = Math.max(contentBounds.rightX + 1, ...groups.map((group) => group.rightX + 1), fragments.rightX + 1)
@@ -650,7 +650,7 @@ export function createSequencePlacementPlan(
const inlineLabel = inlineMessageLabel(step.message, labelLines, fromX, toX, compact)
const arrowY = inlineLabel ? stepY : stepY + labelLines.length
const renderedLabelWidth = inlineLabel ? visualLength(inlineLabel) : labelLinesWidth(labelLines)
const labelX = Math.floor((leftX + rightX - renderedLabelWidth) / 2)
const labelX = inlineLabel ? Math.floor((leftX + rightX - renderedLabelWidth) / 2) : leftX + 2
steps.push({
type: "message",
message: step.message,
+4 -2
View File
@@ -4,9 +4,11 @@ export interface Registration {
readonly dispose: Effect.Effect<void>
}
export type Hooks<Spec> = <Name extends keyof Spec>(
export type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <
Name extends keyof Spec,
>(
name: Name,
callback: (input: Spec[Name]) => Effect.Effect<void>,
callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
) => Effect.Effect<Registration, never, Scope.Scope>
export type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>
+5 -3
View File
@@ -1,11 +1,13 @@
import type { SkillApi } from "@opencode-ai/client/effect/api"
import { Skill } from "@opencode-ai/schema/skill"
import type { Effect } from "effect"
import type { Effect, Types } from "effect"
import type { Transform } from "./registration.js"
export interface SkillDraft {
source(source: Skill.Source): void
list(): readonly Skill.Source[]
list(): readonly Types.DeepMutable<Skill.Info>[]
add(skill: Skill.Info): void
update(id: string, update: (skill: Types.DeepMutable<Skill.Info>) => void): void
remove(id: string): void
}
export interface SkillDomain extends SkillApi<unknown> {
+7 -1
View File
@@ -38,7 +38,13 @@ export interface ToolHooks {
)
}
// Only execute.before may fail: a Tool.Error rejects the call before the tool runs.
export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
readonly "execute.before": Tool.Error
readonly "execute.after": never
}
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly hook: Hooks<ToolHooks>
readonly hook: Hooks<ToolHooks, ToolFailures>
}
+5 -2
View File
@@ -1,10 +1,13 @@
import type { SkillApi } from "@opencode-ai/client/promise/api"
import type { Skill } from "@opencode-ai/schema/skill"
import type { Transform } from "./registration.js"
import type { DeepMutable } from "./types.js"
export interface SkillDraft {
source(source: Skill.Source): void
list(): readonly Skill.Source[]
list(): readonly DeepMutable<Skill.Info>[]
add(skill: Skill.Info): void
update(id: string, update: (skill: DeepMutable<Skill.Info>) => void): void
remove(id: string): void
}
export interface SkillDomain extends SkillApi {
+3
View File
@@ -12733,6 +12733,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["id", "time", "type", "agent"],
+1
View File
@@ -42,6 +42,7 @@ export const AgentSelected = Schema.Struct({
...Base,
type: Schema.tag("agent-switched"),
agent: Agent.ID,
previous: Agent.ID.pipe(optional),
}).annotate({ identifier: "Session.Message.AgentSelected" })
export interface ModelSelected extends Schema.Schema.Type<typeof ModelSelected> {}
+1
View File
@@ -46,6 +46,7 @@ import type { ServerOptions } from "./options"
import { modalWorkspaceDriver, provider as modalProvider } from "./workspace/modal-workspace"
const applicationServices = LayerNode.group([
Global.node,
Database.node,
Bus.node,
EventLogger.node,
+5 -2
View File
@@ -72,7 +72,7 @@ import { DialogOpen } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { sessionTabsFitVertically } from "./ui/layout"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
import { Session } from "./routes/session"
import { PromptHistoryProvider } from "./prompt/history"
@@ -372,7 +372,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode={mode}>
<ThemeProvider
mode={mode}
source={createThemeSource(global.config)}
>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
+21 -12
View File
@@ -60,6 +60,11 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import {
deduplicatePromptImages,
preserveMentionlessPromptAttachments,
promptAttachmentLabel,
} from "../../prompt/attachment"
import { DialogImagePreview } from "../dialog-image-preview"
export type PromptProps = {
@@ -331,7 +336,7 @@ export function Prompt(props: PromptProps) {
}
const imageAttachments = createMemo(() =>
(store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")),
(deduplicatePromptImages(store.prompt.files) ?? []).filter((file) => file.uri.startsWith("data:image/")),
)
const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2)
@@ -736,6 +741,7 @@ export function Prompt(props: PromptProps) {
setStore(
produce((draft) => {
const newMap = new Map<number, PromptPartRef>()
const fileExtmarks = new Map<number, NonNullable<PromptInfo["files"]>[number]>()
const files: NonNullable<PromptInfo["files"]> = []
const agents: NonNullable<PromptInfo["agents"]> = []
const skills: NonNullable<PromptInfo["skills"]> = []
@@ -749,9 +755,8 @@ export function Prompt(props: PromptProps) {
if (!part?.mention) continue
part.mention.start = extmark.start
part.mention.end = extmark.end
const index = files.length
files.push(part)
newMap.set(extmark.id, { type: "file", index })
fileExtmarks.set(extmark.id, part)
continue
}
if (ref.type === "agent") {
@@ -783,8 +788,19 @@ export function Prompt(props: PromptProps) {
newMap.set(extmark.id, { type: "pasted", index })
}
const nextFiles = preserveMentionlessPromptAttachments(draft.prompt.files, files)
const fileIndices = new Map(nextFiles.map((file, index) => [file, index]))
for (const [extmark, file] of fileExtmarks) {
const index = fileIndices.get(file)
if (index !== undefined) newMap.set(extmark, { type: "file", index })
}
draft.extmarkToPart = newMap
draft.prompt.files = files
if (
nextFiles.length !== draft.prompt.files?.length ||
nextFiles.some((file, index) => file !== draft.prompt.files?.[index])
)
draft.prompt.files = nextFiles
draft.prompt.agents = agents
draft.prompt.skills = skills
draft.prompt.pasted = pasted
@@ -1138,7 +1154,6 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
if (store.mode === "shell") {
move.startSubmit()
void client.api.session.shell({
@@ -1376,13 +1391,7 @@ export function Prompt(props: PromptProps) {
function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
const pdf = file.uri.startsWith("data:application/pdf;")
const count = pdf
? (store.prompt.files?.filter(
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
).length ?? 0)
: imageAttachments().length
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
const virtualText = promptAttachmentLabel(store.prompt.files, { uri: file.uri, name: file.filename })
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
+4 -1
View File
@@ -386,7 +386,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
}))
break
case "session.agent.selected":
case "session.agent.selected": {
const previous = store.session.info[event.data.sessionID]?.agent
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
message.update(event.data.sessionID, (draft, index) => {
@@ -394,10 +395,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
id: messageIDFromEvent(event.id),
type: "agent-switched",
agent: event.data.agent,
previous,
time: { created: event.created },
})
})
break
}
case "session.model.selected":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "model", event.data.model)
+5 -6
View File
@@ -28,7 +28,6 @@ import { createEffect, createMemo, onCleanup, onMount, type Accessor, type Paren
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useConfig } from "../config"
import { Global } from "@opencode-ai/util/global"
import { DevTools } from "../devtools"
import { configDirectories } from "../util/config-directories"
@@ -69,15 +68,15 @@ export type ThemeSource = Readonly<{
subscribeRefresh?(refresh: () => void): () => void
}>
const themeSource: ThemeSource = {
export const createThemeSource = (config: string): ThemeSource => ({
async discover() {
return discoverThemes(configDirectories(Global.Path.config, process.cwd()))
return discoverThemes(configDirectories(config, process.cwd()))
},
subscribeRefresh(refresh) {
process.on("SIGUSR2", refresh)
return () => process.off("SIGUSR2", refresh)
},
}
})
export { discoverThemes } from "../theme/discovery"
@@ -139,11 +138,11 @@ subscribeThemes((themes) => setStore("themes", themes))
const themeContext = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light"; source?: ThemeSource }): ThemeContextValue => {
init: (props: { mode: "dark" | "light"; source: ThemeSource }): ThemeContextValue => {
const renderer = useRenderer()
const configState = useConfig()
const config = configState.data
const themes = props.source ?? themeSource
const themes = props.source
const pick = (value: unknown) => {
if (value === "dark" || value === "light") return value
return
+96
View File
@@ -0,0 +1,96 @@
import type { PromptInput } from "@opencode-ai/schema"
type PromptFile = PromptInput.FileAttachment
type PromptFileIdentity = Pick<PromptFile, "uri" | "name" | "description">
type ProjectedFile = Readonly<{
data: string
mime: string
source: { type: string }
name?: string
description?: string
mention?: { text: string }
}>
function attachmentKind(uri: string) {
if (uri.startsWith("data:image/")) return "Image"
if (uri.startsWith("data:application/pdf;")) return "PDF"
return undefined
}
function attachmentMetadata(file: PromptFileIdentity) {
return JSON.stringify([file.name ?? null, file.description ?? null])
}
function deduplicateByIdentity<T>(
items: readonly T[],
identity: (item: T) => { metadata: string; payload: string } | undefined,
) {
const seen = new Map<string, string[]>()
return items.filter((item) => {
const key = identity(item)
if (!key) return true
const matches = seen.get(key.metadata)
if (matches?.includes(key.payload)) return false
if (matches) matches.push(key.payload)
if (!matches) seen.set(key.metadata, [key.payload])
return true
})
}
export function deduplicatePromptImages(files: readonly PromptFile[] | undefined) {
if (!files || files.length < 2) return files
return deduplicateByIdentity(files, (file) =>
file.uri.startsWith("data:image/") && file.mention?.text
? {
metadata: JSON.stringify([attachmentMetadata(file), file.mention.text]),
payload: file.uri,
}
: undefined,
)
}
export function preserveMentionlessPromptAttachments(
files: readonly PromptFile[] | undefined,
mentioned: PromptFile[],
) {
if (!files) return mentioned
const tracked = mentioned.values()
return files.flatMap((file) => {
if (!file.mention?.text) return [file]
const next = tracked.next()
return next.done ? [] : [next.value]
})
}
export function deduplicateVisibleImages<T extends ProjectedFile>(files: readonly T[]) {
return deduplicateByIdentity(files, (file) =>
file.mime.startsWith("image/") && file.source.type === "inline" && file.mention?.text
? {
metadata: JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text]),
payload: file.data,
}
: undefined,
)
}
export function promptAttachmentLabel(files: readonly PromptFile[] | undefined, file: PromptFileIdentity) {
const kind = attachmentKind(file.uri)
if (!kind) throw new Error(`Unsupported inline attachment: ${file.uri}`)
const metadata = attachmentMetadata(file)
const existing =
kind === "Image"
? files?.find(
(candidate) =>
candidate.uri === file.uri && attachmentMetadata(candidate) === metadata && candidate.mention?.text,
)?.mention?.text
: undefined
if (existing) return existing
const pattern = new RegExp(`^\\[${kind} (\\d+)\\]$`)
const count =
files?.reduce((highest, candidate) => {
const match = candidate.mention?.text.match(pattern)
return match ? Math.max(highest, Number(match[1])) : highest
}, 0) ?? 0
return `[${kind} ${count + 1}]`
}
+81 -13
View File
@@ -5,6 +5,7 @@ import {
createMemo,
createSignal,
For,
Index,
Match,
on,
onCleanup,
@@ -12,6 +13,7 @@ import {
Show,
Switch,
useContext,
type Accessor,
} from "solid-js"
import path from "node:path"
import { EOL, tmpdir } from "node:os"
@@ -68,6 +70,7 @@ import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
import { projectedPromptInput } from "../../prompt/codec"
import { deduplicateVisibleImages } from "../../prompt/attachment"
import { useEpilogue } from "../../context/epilogue"
import { normalizePath } from "../../util/path"
import { PermissionPrompt } from "./permission"
@@ -647,8 +650,18 @@ export function Session() {
slash: {
name: "compact",
},
run: () => {
void client.api.session.compact({ sessionID: route.sessionID })
run: async () => {
const selection = local.model.current()
if (selection)
await client.api.session.switchModel({
sessionID: route.sessionID,
model: {
providerID: selection.providerID,
id: selection.modelID,
variant: local.model.variant.current(),
},
})
await client.api.session.compact({ sessionID: route.sessionID })
dialog.clear()
},
},
@@ -1649,7 +1662,12 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
const ctx = use()
const theme = useTheme()
const text = () => {
if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}`
if (props.message.type === "agent-switched") {
const agent = Locale.titlecase(props.message.agent)
if (props.message.previous && props.message.previous !== props.message.agent)
return `Switched agent from ${Locale.titlecase(props.message.previous)} to ${agent}`
return `Switched agent to ${agent}`
}
if (props.message.type === "model-switched")
return switchLabel(props.message.model, ctx.models(), props.message.previous)
return ""
@@ -1897,7 +1915,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
const ctx = use()
const data = useData()
const local = useLocal()
const files = createMemo(() => props.message.files ?? [])
const files = createMemo(() => deduplicateVisibleImages(props.message.files ?? []))
const skills = createMemo(() => props.message.skills ?? [])
const images = createMemo(() =>
files().flatMap((file) =>
@@ -3019,6 +3037,63 @@ function executeCalls(value: unknown): ExecuteCall[] {
})
}
export function executeCallSummary(call: ExecuteCall) {
const args = primitiveInputSummary(call.input ?? {}).replace(/\s+/g, " ")
return `${call.tool}${call.status === "error" ? " (failed)" : ""}${args ? ` ${args}` : ""}`
}
function ExecuteCallView(props: { call: Accessor<ExecuteCall> }) {
const theme = useTheme()
const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false)
const input = createMemo(() => Object.entries(props.call().input ?? {}))
const expandable = createMemo(() => input().length > 0)
const title = createMemo(() => `${props.call().tool}${props.call().status === "error" ? " (failed)" : ""}`)
return (
<box
paddingLeft={3 + INLINE_TOOL_ICON_WIDTH}
onMouseOver={() => expandable() && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (!expandable() || renderer.getSelection()?.getSelectedText()) return
setExpanded((value) => !value)
}}
>
<text
wrapMode="none"
truncate
fg={
props.call().status === "error"
? theme.text.feedback.error.default
: hover()
? theme.text.default
: theme.text.subdued
}
>
{expanded() ? title() : executeCallSummary(props.call())}
</text>
<Show when={expanded()}>
<box paddingLeft={2}>
<For each={input()}>
{([key, value]) => (
<box flexDirection="row">
<text flexShrink={0} fg={theme.text.subdued}>
{key}:{" "}
</text>
<text flexGrow={1} wrapMode="word" fg={theme.text.default}>
{typeof value === "string" ? value : JSON.stringify(value, null, 2)}
</text>
</box>
)}
</For>
</box>
</Show>
</box>
)
}
// The `execute` tool streams child tool calls through metadata, not a child session like Task.
function Execute(props: ToolProps) {
const ctx = use()
@@ -3029,14 +3104,6 @@ function Execute(props: ToolProps) {
const hasRuntimeError = createMemo(() => props.metadata.error === true || props.part.state.status === "error")
const outputPreview = createMemo(() => collapseToolOutput(output(), 4, 4 * Math.max(20, ctx.width - 6)).output)
const showOutput = createMemo(() => output() && hasRuntimeError())
const content = createMemo(() => {
const lines = ["execute"]
for (const call of calls()) {
const args = primitiveInputSummary(call.input ?? {})
lines.push(`${call.tool}${args ? ` ${args}` : ""}${call.status === "error" ? " (failed)" : ""}`)
}
return lines.join("\n")
})
return (
<>
@@ -3048,8 +3115,9 @@ function Execute(props: ToolProps) {
complete={true}
part={props.part}
>
{content()}
execute
</InlineTool>
<Index each={calls()}>{(call) => <ExecuteCallView call={call} />}</Index>
<Show when={showOutput()}>
<box paddingLeft={3}>
<For each={outputPreview().split("\n")}>
@@ -10,6 +10,7 @@ import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { emptyThemeSource } from "../../fixture/fixture"
test("searches settings globally and opens the matching setting", async () => {
let current: Info = {}
@@ -53,7 +54,7 @@ test("searches settings globally and opens the matching setting", async () => {
<TestTuiContexts>
<ConfigProvider config={resolve(current, { terminalSuspend: true })} service={service}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Fixture />
+2 -1
View File
@@ -16,6 +16,7 @@ import { ThemeProvider } from "../../../src/context/theme"
import { Composer } from "../../../src/routes/session/composer"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
import { emptyThemeSource } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -1979,7 +1980,7 @@ test("keeps shell state scoped to location", async () => {
return (
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_shared" }}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
</ThemeProvider>
</Keymap.Provider>
@@ -16,6 +16,7 @@ import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client"
import { emptyThemeSource } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -226,7 +227,7 @@ async function renderOpen(
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<DialogProvider>
<Probe />
</DialogProvider>
@@ -5,7 +5,7 @@ import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { onCleanup } from "solid-js"
import { tmpdir } from "../../fixture/fixture"
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import type { TuiKeybind } from "../../../src/config/keybind"
import { TestTuiContexts } from "../../fixture/tui-environment"
@@ -58,7 +58,7 @@ async function mountPrompt(input: {
>
<ConfigProvider config={resolvedConfig}>
<Keymap.Provider>
<ThemeProvider mode="dark">
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Prompt />
@@ -9,7 +9,7 @@ import { dialogWidth } from "../../../src/ui/dialog"
import { dialogSelectContentWidth, type DialogSelectOption } from "../../../src/ui/dialog-select"
import { truncateFilePath } from "../../../src/ui/file-path"
import { stringWidth } from "../../../src/util/string-width"
import { tmpdir } from "../../fixture/fixture"
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -62,7 +62,7 @@ async function renderSelect(
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<ConfigProvider config={config}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Select />
@@ -138,7 +138,7 @@ async function mountSelect(
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<ConfigProvider config={config}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Fixture />
@@ -4,6 +4,7 @@ import { testRender } from "@opentui/solid"
import type { JSX } from "solid-js"
import { onMount, type ParentProps } from "solid-js"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { emptyThemeSource } from "../../fixture/fixture"
import { ThemeProvider, useThemes } from "../../../src/context/theme"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { ConfigProvider } from "../../../src/config"
@@ -149,7 +150,7 @@ function withTheme(component: () => JSX.Element, onReady = () => {}) {
return (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<ThemeProvider mode="dark">
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Ready onReady={onReady}>{component()}</Ready>
</ThemeProvider>
</ConfigProvider>
@@ -12,6 +12,7 @@ import type {
Slot,
} from "@opencode-ai/plugin/tui/context"
import { ThemeProvider, useThemes } from "../../../src/context/theme"
import { emptyThemeSource } from "../../fixture/fixture"
import { ConfigProvider } from "../../../src/config"
import { TuiKeybind } from "../../../src/config/keybind"
import { Keymap } from "../../../src/context/keymap"
@@ -224,7 +225,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
<ConfigProvider config={config}>
<Keymap.Provider>
<ToastProvider>
<ThemeProvider mode="dark">
<ThemeProvider mode="dark" source={emptyThemeSource}>
<DialogProvider>
<Content />
</DialogProvider>

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