Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline 868a303b31 fix(core): move branch watch into Vcs
LocationWatcher only existed to republish git/hg metadata as
filesystem.changed for Vcs. Nothing else consumed those events.
Vcs now owns the Parcel watch, same as skills and config.
2026-08-10 17:46:45 -05:00
Aiden Cline e5b576ce3a fix(core): restore parcel watch for git HEAD
Bun fs.watch misses git's atomic HEAD.lock rename, so checkout never
refreshed the cached branch. Watch the git dir with Parcel like dev,
keeping only HEAD and HEAD.lock.
2026-08-10 17:35:50 -05:00
128 changed files with 1988 additions and 3741 deletions
+10 -13
View File
@@ -75,7 +75,7 @@ jobs:
build-cli:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
if: github.repository == 'anomalyco/opencode'
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' && github.ref_name != 'beta'
if: github.ref_name != 'v2'
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' && github.ref_name != 'beta'
if: github.ref_name != 'v2'
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' && github.ref_name != 'beta'
if: github.ref_name != 'v2'
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' && github.ref_name != 'beta'
if: github.repository == 'anomalyco/opencode'
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' && github.ref_name != 'beta'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
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' }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
RUST_TARGET: ${{ matrix.settings.target }}
- name: Build
run: bun run build
@@ -393,7 +393,6 @@ 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
@@ -497,31 +496,29 @@ jobs:
registry-url: "https://registry.npmjs.org"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2' && github.ref_name != 'beta'
if: github.ref_name != 'v2'
with:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2' && github.ref_name != 'beta'
if: github.ref_name != 'v2'
with:
name: opencode-cli-windows
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2' && github.ref_name != 'beta'
if: github.ref_name != 'v2'
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,7 +439,6 @@
"@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:",
@@ -69,63 +69,6 @@ describe("v2 session reducer", () => {
})
})
test("prefers durable selection predecessors and derives them for older events", () => {
const source: SessionMessageInfo[] = [
{ id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } },
{
id: "msg_previous_model",
type: "model-switched",
model: { id: "old", providerID: "provider" },
time: { created: 1 },
},
]
const reducer = createV2SessionReducer()
const agent = reducer.reduce(
source,
event({
...base,
id: "evt_agent",
type: "session.agent.selected",
data: { sessionID: "ses_1", agent: "plan", previous: "review" },
}),
)
const model = reducer.reduce(
source,
event({
...base,
id: "evt_model",
type: "session.model.selected",
data: {
sessionID: "ses_1",
model: { id: "new", providerID: "provider" },
previous: { id: "durable", providerID: "provider" },
},
}),
)
const legacyAgent = reducer.reduce(
source,
event({
...base,
id: "evt_legacy_agent",
type: "session.agent.selected",
data: { sessionID: "ses_1", agent: "plan" },
}),
)
expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" })
expect(model?.messages.at(-1)).toMatchObject({
type: "model-switched",
model: { id: "new" },
previous: { id: "durable" },
})
expect(legacyAgent?.messages.at(-1)).toMatchObject({
type: "agent-switched",
agent: "plan",
previous: "build",
})
})
test("folds tool, retry, and completion events", () => {
const reducer = createV2SessionReducer()
let messages: SessionMessageInfo[] = []
@@ -61,12 +61,6 @@ export function createV2SessionReducer() {
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
previous:
event.data.previous ??
source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "agent-switched" | "assistant" }> =>
item.type === "agent-switched" || item.type === "assistant",
)?.agent,
time: { created: event.created },
})
case "session.model.selected":
@@ -75,12 +69,10 @@ export function createV2SessionReducer() {
type: "model-switched",
metadata: event.metadata,
model: event.data.model,
previous:
event.data.previous ??
source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
item.type === "model-switched" || item.type === "assistant",
)?.model,
previous: source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
item.type === "model-switched" || item.type === "assistant",
)?.model,
time: { created: event.created },
})
case "session.synthetic":
+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]
if (current && current.title === next.title && current.directory === next.directory) return
console.debug("[tabs] update persisted session info", { key, sessionID: session.id, current, next })
console.log({ tab, session, current })
if (current?.title === next.title && current.directory === next.directory) return
setInfo(key, next)
},
select: navigateTab,
@@ -4,7 +4,6 @@ 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* () {
@@ -17,7 +16,6 @@ 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))
@@ -41,7 +39,6 @@ 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 },
}),
)
}),
+7 -2
View File
@@ -1,5 +1,6 @@
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"
@@ -128,9 +129,13 @@ 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
const paths = input.paths ?? {
home: Global.Path.home,
state: Global.Path.state,
log: Global.Path.log,
}
const diagnostics = {
pid: process.pid,
cwd: input.directory,
+1 -2
View File
@@ -22,7 +22,6 @@ export type MiniCommandInput = {
demo?: boolean
tuiConfig?: MiniFrontendInput["tuiConfig"]
config?: MiniFrontendInput["config"]
paths: { home: string; state: string; log: string }
}
type Model = MiniFrontendInput["model"]
@@ -105,7 +104,7 @@ export async function runMini(input: MiniCommandInput) {
}))
const frontend = await frontendTask
return frontend.runMiniFrontend({
host: createMiniHost({ terminal, directory, paths: input.paths }),
host: createMiniHost({ terminal, directory }),
sdk,
directory,
target: resolveTarget,
+1 -2
View File
@@ -39,8 +39,7 @@ export const run = Effect.fnUntraced(function* (options: Options) {
})
const processEffect = Effect.fnUntraced(function* (options: Options) {
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
return yield* Effect.scoped(
Effect.gen(function* () {
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
+2 -10
View File
@@ -339,11 +339,7 @@ export type Endpoint5_31Output =
readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly previous?: Agent.ID | undefined
}
readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID }
}
| {
readonly id: Event.ID
@@ -352,11 +348,7 @@ export type Endpoint5_31Output =
readonly type: "session.model.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly model: Model.Ref
readonly previous?: Model.Ref | undefined
}
readonly data: { readonly sessionID: Session.ID; readonly model: Model.Ref }
}
| {
readonly id: Event.ID
@@ -41,7 +41,6 @@ export type SessionMessageAgentSelected = {
time: { created: number }
type: "agent-switched"
agent: string
previous?: string
}
export type PromptBase64 = string
@@ -436,7 +435,7 @@ export type SessionAgentSelected = {
type: "session.agent.selected"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; agent: string; previous?: string }
data: { sessionID: string; agent: string }
}
export type SessionModelSelected = {
@@ -446,7 +445,7 @@ export type SessionModelSelected = {
type: "session.model.selected"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; model: ModelRef; previous?: ModelRef }
data: { sessionID: string; model: ModelRef }
}
export type SessionMoved = {
@@ -2536,7 +2535,6 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
readonly previous?: string
}
| {
readonly id: string
@@ -2788,7 +2786,6 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
readonly previous?: string
}
| {
readonly id: string
@@ -3040,7 +3037,6 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
readonly previous?: string
}
| {
readonly id: string
+5 -4
View File
@@ -194,10 +194,11 @@ async function formatTypescript(input: string) {
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration"
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}"`).join("\n")}
export const migrations = [
${names.map((_, index) => ` m${index.toString().padStart(2, "0")},`).join("\n")}
] satisfies DatabaseMigration.Migration[]
export const migrations: DatabaseMigration.Migration[] = (
await Promise.all([
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
])
).map((module) => module.default)
`
}
+1 -16
View File
@@ -508,23 +508,8 @@ 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 {
+2 -11
View File
@@ -11,7 +11,6 @@ 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
@@ -62,7 +61,6 @@ 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() }),
@@ -113,7 +111,6 @@ export const layer = (options?: ShellSelect.Options) =>
location,
processes,
shell: options,
bin: global.bin,
})
const prompt = (yield* mcp.prompts()).find(
@@ -167,7 +164,6 @@ function evaluateTemplate(
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options
readonly bin: string
},
) {
return Effect.gen(function* () {
@@ -201,16 +197,11 @@ 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,
services.bin,
)
const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"), services.shell)
const outputs = yield* Effect.forEach(
matches,
(match) => {
@@ -271,7 +262,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, Global.node],
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node],
})
}
@@ -1,122 +0,0 @@
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)]
}
@@ -1,57 +0,0 @@
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,
},
}
}
+22 -149
View File
@@ -1,191 +1,64 @@
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, FiberMap, PubSub, Semaphore, Stream } from "effect"
import { Effect, 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 { SkillDiscovery } from "../../skill/discovery"
import { SkillFile } from "./skill-file"
type Source = Skill.DirectorySource | Skill.UrlSource
import { Global } from "@opencode-ai/util/global"
import { Location } from "../../location"
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 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 loaded = { entries: yield* config.entries() }
yield* ctx.skill.transform((draft) => {
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]) {
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
draft.source(
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
}
for (const directory of directories) {
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")) }))
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")),
}),
)
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
add(Skill.UrlSource.make({ type: "url", url: item }))
draft.source(Skill.UrlSource.make({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
add(
draft.source(
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()),
),
),
-216
View File
@@ -1,216 +0,0 @@
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, Schema, 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)),
)
})
})
const sourceDirectories = ["plugin", "plugins"] as const
const Package = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
module: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.Unknown),
})
const decodePackage = Schema.decodeUnknownOption(Package)
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.scan(`{${sourceDirectories.join(",")}}/*.{ts,js}`, {
cwd: directory,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
const children = yield* fs
.scan(`{${sourceDirectories.join(",")}}/*`, {
cwd: directory,
absolute: true,
include: "all",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
const directories = yield* Effect.filter(children.sort(), fs.isDir)
const packages = yield* Effect.forEach(directories, (child) => discoverPackage(fs, child))
return [...files.sort(), ...packages.filter((target): target is string => typeof target === "string")].map(
(target): Operation => ({ type: "add", target, options: {} }),
)
})
}
function discoverPackage(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const manifest = yield* fs
.readJson(path.join(directory, "package.json"))
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
const configured = Option.isSome(manifest)
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(
(entry): entry is string => typeof entry === "string",
)
: []
const target = yield* Effect.findFirst(
[...configured, "index.ts", "index.js"].map((entry) => path.resolve(directory, entry)),
fs.isFile,
)
return Option.getOrUndefined(target)
})
}
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)),
)
}
+7 -10
View File
@@ -40,19 +40,16 @@ const databaseLayer = Layer.effect(
)
export function layer(options: Options = { path: ":memory:" }) {
return Layer.unwrap(
Effect.gen(function* () {
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
const global = yield* Global.Service
return provide(join(global.data, filename))
}),
)
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))
})
}
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
return makeGlobalNode({ service: Service, layer: layer(options), deps: [] })
}
export const node = configured({ path: ":memory:" })
+45 -84
View File
@@ -1,86 +1,47 @@
import type { DatabaseMigration } from "./migration"
import m00 from "./migration/20260127222353_familiar_lady_ursula"
import m01 from "./migration/20260211171708_add_project_commands"
import m02 from "./migration/20260213144116_wakeful_the_professor"
import m03 from "./migration/20260225215848_workspace"
import m04 from "./migration/20260227213759_add_session_workspace_id"
import m05 from "./migration/20260228203230_blue_harpoon"
import m06 from "./migration/20260303231226_add_workspace_fields"
import m07 from "./migration/20260309230000_move_org_to_state"
import m08 from "./migration/20260312043431_session_message_cursor"
import m09 from "./migration/20260323234822_events"
import m10 from "./migration/20260410174513_workspace-name"
import m11 from "./migration/20260413175956_chief_energizer"
import m12 from "./migration/20260423070820_add_icon_url_override"
import m13 from "./migration/20260427172553_slow_nightmare"
import m14 from "./migration/20260428004200_add_session_path"
import m15 from "./migration/20260501142318_next_venus"
import m16 from "./migration/20260504145000_add_sync_owner"
import m17 from "./migration/20260507164347_add_workspace_time"
import m18 from "./migration/20260510033149_session_usage"
import m19 from "./migration/20260511000411_data_migration_state"
import m20 from "./migration/20260511173437_session-metadata"
import m21 from "./migration/20260601010001_normalize_storage_paths"
import m22 from "./migration/20260601202201_amazing_prowler"
import m23 from "./migration/20260602002951_lowly_union_jack"
import m24 from "./migration/20260602182828_add_project_directories"
import m25 from "./migration/20260603001617_session_message_projection_indexes"
import m26 from "./migration/20260603040000_session_message_projection_order"
import m27 from "./migration/20260603141458_session_input_inbox"
import m28 from "./migration/20260603160727_jittery_ezekiel_stane"
import m29 from "./migration/20260604172448_event_sourced_session_input"
import m30 from "./migration/20260605003541_add_session_context_snapshot"
import m31 from "./migration/20260605042240_add_context_epoch_agent"
import m32 from "./migration/20260611035744_credential"
import m33 from "./migration/20260611192811_lush_chimera"
import m34 from "./migration/20260612174303_project_dir_strategy"
import m35 from "./migration/20260622142730_simplify_session_context_epoch"
import m36 from "./migration/20260622170816_reset_v2_session_state"
import m37 from "./migration/20260622202450_simplify_session_input"
import m38 from "./migration/20260804233008_loose_psylocke"
import m39 from "./migration/20260805200742_import_legacy_credentials"
import m40 from "./migration/20260808023530_workspace_domain"
export const migrations = [
m00,
m01,
m02,
m03,
m04,
m05,
m06,
m07,
m08,
m09,
m10,
m11,
m12,
m13,
m14,
m15,
m16,
m17,
m18,
m19,
m20,
m21,
m22,
m23,
m24,
m25,
m26,
m27,
m28,
m29,
m30,
m31,
m32,
m33,
m34,
m35,
m36,
m37,
m38,
m39,
m40,
] satisfies DatabaseMigration.Migration[]
export const migrations: DatabaseMigration.Migration[] = (
await Promise.all([
import("./migration/20260127222353_familiar_lady_ursula"),
import("./migration/20260211171708_add_project_commands"),
import("./migration/20260213144116_wakeful_the_professor"),
import("./migration/20260225215848_workspace"),
import("./migration/20260227213759_add_session_workspace_id"),
import("./migration/20260228203230_blue_harpoon"),
import("./migration/20260303231226_add_workspace_fields"),
import("./migration/20260309230000_move_org_to_state"),
import("./migration/20260312043431_session_message_cursor"),
import("./migration/20260323234822_events"),
import("./migration/20260410174513_workspace-name"),
import("./migration/20260413175956_chief_energizer"),
import("./migration/20260423070820_add_icon_url_override"),
import("./migration/20260427172553_slow_nightmare"),
import("./migration/20260428004200_add_session_path"),
import("./migration/20260501142318_next_venus"),
import("./migration/20260504145000_add_sync_owner"),
import("./migration/20260507164347_add_workspace_time"),
import("./migration/20260510033149_session_usage"),
import("./migration/20260511000411_data_migration_state"),
import("./migration/20260511173437_session-metadata"),
import("./migration/20260601010001_normalize_storage_paths"),
import("./migration/20260601202201_amazing_prowler"),
import("./migration/20260602002951_lowly_union_jack"),
import("./migration/20260602182828_add_project_directories"),
import("./migration/20260603001617_session_message_projection_indexes"),
import("./migration/20260603040000_session_message_projection_order"),
import("./migration/20260603141458_session_input_inbox"),
import("./migration/20260603160727_jittery_ezekiel_stane"),
import("./migration/20260604172448_event_sourced_session_input"),
import("./migration/20260605003541_add_session_context_snapshot"),
import("./migration/20260605042240_add_context_epoch_agent"),
import("./migration/20260611035744_credential"),
import("./migration/20260611192811_lush_chimera"),
import("./migration/20260612174303_project_dir_strategy"),
import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"),
import("./migration/20260808023530_workspace_domain"),
])
).map((module) => module.default)
+1 -2
View File
@@ -5,7 +5,6 @@ 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]
@@ -14,7 +13,7 @@ const lock = Semaphore.makeUnsafe(1)
export type Migration = {
id: string
foreignKeys?: boolean
up: (tx: Transaction) => Effect.Effect<void, unknown, Global.Service>
up: (tx: Transaction) => Effect.Effect<void, unknown>
}
export function apply(db: Database) {
@@ -1,4 +1,3 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import { sql } from "drizzle-orm"
import { Effect, Option, Schema } from "effect"
@@ -34,10 +33,7 @@ const wellKnownSourcesKey = "wellknown:sources"
const migration: DatabaseMigration.Migration = {
id: "20260805200742_import_legacy_credentials",
up(tx) {
return Effect.gen(function* () {
const global = yield* Global.Service
return yield* importLegacyCredentials(tx, path.join(global.data, "auth.json"))
})
return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json"))
},
}
@@ -45,9 +41,9 @@ export default migration
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
return Effect.gen(function* () {
const content = yield* Effect.promise(() => readFile(filepath, "utf8").catch(() => undefined))
if (content === undefined) return
const input = Option.getOrUndefined(decodeJson(content))
const file = Bun.file(filepath)
if (!(yield* Effect.promise(() => file.exists()))) return
const input = Option.getOrUndefined(decodeJson(yield* Effect.promise(() => file.text())))
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return yield* Effect.fail(new Error("Legacy credential file must contain an object"))
}
+6 -7
View File
@@ -467,11 +467,10 @@ function updateProgress(progress: Progress) {
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
}
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.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 }
@@ -479,7 +478,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.data).root}, ${now}, ${now}, '[]')
VALUES (${Project.ID.global}, ${path.parse(Global.Path.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
@@ -493,7 +492,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const sourceTotal = yield* countNextSessions(nextPath(options))
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 =
@@ -503,7 +502,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, global.data), (completed) => {
yield* importNextDatabase(db, nextPath(options), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
@@ -622,10 +621,10 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
)
}
function nextPath(options: Options, data: string) {
function nextPath(options: Options) {
if (options.nextDatabasePath) return options.nextDatabasePath
if (process.env.OPENCODE_DB === ":memory:") return undefined
return path.join(data, "opencode-next.db")
return path.join(Global.Path.data, "opencode-next.db")
}
function openNextDatabase(sourcePath: string) {
@@ -1,71 +0,0 @@
export * as LocationWatcher from "./location-watcher"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Stream } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Document } from "@opencode-ai/schema/config"
import path from "path"
import { Config } from "../config"
import { Bus } from "../bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { Watcher } from "./watcher"
export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationWatcher") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const configService = yield* Config.Service
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
bus.publish(FileSystem.Event.Changed, {
file: update.path,
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
})
yield* Effect.gen(function* () {
const config = (yield* configService.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
if (location.vcs?.type === "hg") {
const store = location.vcs.store
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
if (!config.includes(".hg") && !config.includes(vcs)) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "branch"), type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
}).pipe(
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
Effect.forkScoped,
)
return Service.of({})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, Bus.node],
})
+1 -4
View File
@@ -7,7 +7,6 @@ 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"
@@ -26,7 +25,6 @@ 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[] = []
@@ -44,7 +42,6 @@ const layer = Layer.effect(
fs,
npm,
processes,
bin: global.bin,
})
formatters = builtIns
if (configured === true) return
@@ -125,5 +122,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
})
+26 -34
View File
@@ -18,10 +18,8 @@ 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[]) =>
@@ -39,7 +37,7 @@ export function make(input: {
name: "gofmt",
extensions: [".go"],
enabled: Effect.sync(() => {
const match = findExecutable("gofmt")
const match = which("gofmt")
return match ? [match, "-w", "$FILE"] : disabled
}),
}
@@ -48,7 +46,7 @@ export function make(input: {
name: "mix",
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
enabled: Effect.sync(() => {
const match = findExecutable("mix")
const match = which("mix")
return match ? [match, "format", "$FILE"] : disabled
}),
}
@@ -151,7 +149,7 @@ export function make(input: {
name: "zig",
extensions: [".zig", ".zon"],
enabled: Effect.sync(() => {
const match = findExecutable("zig")
const match = which("zig")
return match ? [match, "fmt", "$FILE"] : disabled
}),
}
@@ -161,7 +159,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 = findExecutable("clang-format")
const match = which("clang-format")
return match ? [match, "-i", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
@@ -170,7 +168,7 @@ export function make(input: {
name: "ktlint",
extensions: [".kt", ".kts"],
enabled: Effect.sync(() => {
const match = findExecutable("ktlint")
const match = which("ktlint")
return match ? [match, "-F", "$FILE"] : disabled
}),
}
@@ -179,18 +177,17 @@ export function make(input: {
name: "ruff",
extensions: [".py", ".pyi"],
enabled: Effect.gen(function* () {
const bin = findExecutable("ruff")
if (!bin) return disabled
if (!which("ruff")) 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 [bin, "format", "$FILE"]
return ["ruff", "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 [bin, "format", "$FILE"]
if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"]
}
return disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
@@ -200,7 +197,7 @@ export function make(input: {
name: "air",
extensions: [".R"],
enabled: Effect.gen(function* () {
const bin = findExecutable("air")
const bin = which("air")
if (!bin) return disabled
const output = yield* commandOutput([bin, "--help"])
if (output._tag === "None" || output.value.exitCode !== 0) return disabled
@@ -213,34 +210,34 @@ export function make(input: {
name: "uv",
extensions: [".py", ".pyi"],
enabled: Effect.gen(function* () {
const bin = findExecutable("uv")
const bin = which("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"], 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 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 ocamlformat: Info = {
name: "ocamlformat",
extensions: [".ml", ".mli"],
enabled: Effect.gen(function* () {
if (!(yield* findUp(".ocamlformat")).length) return disabled
const match = findExecutable("ocamlformat")
const match = which("ocamlformat")
return match ? [match, "-i", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
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 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 pint: Info = {
name: "pint",
@@ -256,9 +253,9 @@ export function make(input: {
}).pipe(Effect.orElseSucceed(() => disabled)),
}
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)
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"])
return [
gofmt,
@@ -290,17 +287,12 @@ export function make(input: {
] satisfies Info[]
}
function executable(
name: string,
extensions: readonly string[],
args: string[],
findExecutable: (name: string) => string | null,
): Info {
function executable(name: string, extensions: readonly string[], args: string[]): Info {
return {
name,
extensions,
enabled: Effect.sync(() => {
const match = findExecutable(name)
const match = which(name)
return match ? [match, ...args] : false
}),
}
+56 -64
View File
@@ -1,13 +1,15 @@
export * as InstructionDiscovery from "./instruction-discovery"
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 { 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 { State } from "./state"
import { Instructions } from "./instructions/index"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
export class File extends Schema.Class<File>("InstructionDiscovery.File")({
class File extends Schema.Class<File>("InstructionDiscovery.File")({
path: AbsolutePath,
content: Schema.String,
}) {}
@@ -15,30 +17,7 @@ export class File extends Schema.Class<File>("InstructionDiscovery.File")({
const Files = Schema.Array(File)
const key = Instructions.Key.make("core/instructions")
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>
export interface Interface {
readonly load: () => Effect.Effect<Instructions.List>
}
@@ -53,26 +32,9 @@ export const layer = (options?: Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
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 fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
Instructions.make<ReadonlyArray<File>>({
@@ -87,22 +49,52 @@ export const layer = (options?: Options) =>
},
})
const list = Effect.fn("InstructionDiscovery.list")(function* () {
const current = state.get()
if (!current.available) return Instructions.unavailable
return Array.from(current.files.values())
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)
})
return Service.of({
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)
}),
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))),
),
})
}),
)
@@ -111,7 +103,7 @@ export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node],
deps: [FSUtil.node, Global.node, Location.node],
})
}
-3
View File
@@ -15,7 +15,6 @@ import { FileSystemSearch } from "./filesystem/search"
import { Generate } from "./generate"
import { Form } from "./form"
import { Image } from "./image"
import { LocationWatcher } from "./filesystem/location-watcher"
import { Integration } from "./integration"
import { Location } from "./location"
import { LocationMutation } from "./location-mutation"
@@ -99,8 +98,6 @@ const locationServiceNodes = [
Snapshot.node,
SessionRunnerLLM.node,
Vcs.node,
// Start repository watches only after boot-critical filesystem and Git work.
LocationWatcher.node,
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
export const locationServices = LayerNode.group<typeof locationServiceNodes>(locationServiceNodes)
+54 -70
View File
@@ -1,8 +1,11 @@
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } 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"
@@ -10,7 +13,6 @@ 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
@@ -535,18 +537,6 @@ 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,
@@ -554,7 +544,6 @@ 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({
@@ -565,28 +554,21 @@ export const layer = (options?: Options) =>
),
)
const source = options?.url || defaultSource
const source = options?.url || "https://models.opencode.ai"
const fetch = options?.fetch ?? true
const userAgent = App.useragent(app)
const key = cacheKey(source)
const filepath = path.join(
Global.Path.cache,
source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`,
)
const ttl = Duration.minutes(5)
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 lockKey = `models-dev:${filepath}`
const fresh = Effect.fnUntraced(function* () {
const cached = yield* loadFromCache()
if (!cached) return false
return Date.now() - cached.updatedAt < Duration.toMillis(ttl)
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 fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
@@ -598,12 +580,15 @@ export const layer = (options?: Options) =>
)
})
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 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 loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
@@ -611,36 +596,33 @@ export const layer = (options?: Options) =>
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
// Best-effort: a cache-write failure must never kill catalog
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
// passed it in Aug 2026); a boot without a cache hit just refetches.
yield* kv.set(key, { updatedAt: Date.now(), body: text }).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
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 catalog
return text
})
const populate = Effect.gen(function* () {
const fromFile = yield* loadFromFile
if (fromFile) return normalize(fromFile)
const cached = options?.file ? undefined : yield* loadFromCache()
if (cached) return normalize(cached.catalog)
const fromDisk = yield* loadFromDisk
if (fromDisk) return normalize(fromDisk)
const bundled = yield* loadSnapshot
if (bundled) return normalize(bundled)
if (!fetch) return []
const catalog = yield* lock.withPermit(
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
const text = yield* Effect.scoped(
Effect.gen(function* () {
const stored = options?.file ? undefined : yield* loadFromCache()
if (stored) return stored.catalog
yield* Flock.effect(lockKey)
return yield* fetchAndWrite()
}),
)
return normalize(catalog)
return normalize(JSON.parse(text) as Record<string, SourceProvider>)
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
@@ -648,19 +630,21 @@ export const layer = (options?: Options) =>
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
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 (!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,
)
})
if (fetch && !process.argv.includes("--get-yargs-completions")) {
@@ -676,7 +660,7 @@ export function configured(options?: Options) {
return makeGlobalNode({
service: Service,
layer: layer(options),
deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
deps: [FSUtil.node, Bus.node, App.node, httpClient],
})
}
+7 -19
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 { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
import type { 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,29 +15,19 @@ export interface Domains {
readonly tool: ToolHooks
}
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>
type Callback<Event> = (event: Event) => Effect.Effect<void>
export interface Interface {
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
domain: Domain,
name: Name,
callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
callback: Callback<Domains[Domain][Name]>,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
domain: Domain,
name: Name,
event: Domains[Domain][Name],
) => Effect.Effect<Domains[Domain][Name], Failures[Domain][Name]>
) => Effect.Effect<Domains[Domain][Name]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginHooks") {}
@@ -66,9 +56,7 @@ 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, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
event,
])
const result: Effect.Effect<void> = Reflect.apply(callback, undefined, [event])
yield* result
}
return event
+2 -4
View File
@@ -290,10 +290,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
transform: (callback) =>
skill.transform((draft) => {
callback({
list: () => mutable(draft.list()),
add: (value) => draft.add(Schema.decodeUnknownSync(Skill.Info)(value)),
update: draft.update,
remove: draft.remove,
source: (source) => draft.source(Schema.decodeUnknownSync(Skill.Source)(source)),
list: draft.list,
})
}),
},
-51
View File
@@ -1,8 +1,6 @@
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"
@@ -12,7 +10,6 @@ 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"
@@ -27,7 +24,6 @@ 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"
@@ -41,8 +37,6 @@ 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"
@@ -85,7 +79,6 @@ 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
@@ -102,9 +95,7 @@ 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),
@@ -121,7 +112,6 @@ 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),
@@ -138,9 +128,7 @@ 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),
)
})
@@ -149,44 +137,6 @@ 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 = [
@@ -214,7 +164,6 @@ const pre = [
] as const satisfies readonly InternalPlugin[]
const post = [
ConfigInstructionPlugin.Plugin,
ConfigReferencePlugin.Plugin,
ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin,
-1
View File
@@ -36,7 +36,6 @@ export const ModelsDevPlugin = define({
draft.integrationID = Integration.ID.make(provider.info.id)
})
for (const model of provider.models) {
if (model.status === "deprecated") continue
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
}
}
+21 -15
View File
@@ -28,23 +28,29 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const reportContent = yield* reportContentWithDiagnostics(ctx.app)
yield* ctx.skill.transform((draft) => {
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("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("report"),
name: Skill.Name.make("Report"),
description: REPORT_DESCRIPTION,
slash: true,
location: AbsolutePath.make("/builtin/report.md"),
content: reportContent,
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,
}),
}),
)
})
+208 -21
View File
@@ -1,17 +1,48 @@
export * as PluginSupervisor from "./supervisor"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
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 path from "path"
import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source"
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 { 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"
@@ -32,10 +63,65 @@ 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 ConfigPluginSource.Operation[],
operations: readonly Operation[],
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
@@ -82,9 +168,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
]
})
const load = Effect.fn("PluginSupervisor.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
) {
const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Operation, { type: "add" }>) {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
@@ -108,6 +192,31 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
} 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>
@@ -115,29 +224,72 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const registry = yield* Plugin.Service
const sdk = yield* SdkPlugins.Service
const sources = yield* ConfigPluginSource.Service
const config = yield* Config.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 operations = yield* sources.operations()
const entries = yield* config.entries()
const operations = yield* scan(entries)
yield* watchConfiguredSources(entries, 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(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
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(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() =>
Effect.gen(function* () {
@@ -163,13 +315,48 @@ export const layer = Layer.effect(
}),
)
const nodeDeps = [
Plugin.node,
SdkPlugins.node,
ConfigPluginSource.node,
Bus.node,
Npm.node,
PluginInternal.requirements,
] as const
const nodeLayer = layer as Layer.Layer<Service, never, PluginInternal.Requirements>
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
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 }
+2 -9
View File
@@ -9,7 +9,6 @@ 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
@@ -97,7 +96,6 @@ 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>()
@@ -167,8 +165,7 @@ 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, global.bin)
const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options)
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
@@ -318,11 +315,7 @@ 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, Global.node],
})
return makeLocationNode({ service: Service, layer: layer(options), deps: [Bus.node, Location.node, Config.node] })
}
export const node = configured()
+7 -11
View File
@@ -34,8 +34,6 @@ 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" }))
@@ -55,12 +53,10 @@ export namespace RipgrepBinary {
config: (typeof PLATFORM)[keyof typeof PLATFORM],
target: string,
) {
const dir = yield* fs.makeTempDirectoryScoped({ directory: global.bin, prefix: "ripgrep-" })
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
if (config.extension === "zip") {
const shell =
(yield* Effect.sync(() => findExecutable("powershell.exe") ?? findExecutable("pwsh.exe"))) ??
"powershell.exe"
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
const result = yield* run(shell, [
"-NoProfile",
"-NonInteractive",
@@ -95,10 +91,10 @@ export namespace RipgrepBinary {
return Service.of({
filepath: yield* Effect.cached(
Effect.gen(function* () {
const system = yield* Effect.sync(() => findExecutable(process.platform === "win32" ? "rg.exe" : "rg"))
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
const target = path.join(global.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
const target = path.join(Global.Path.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
@@ -107,10 +103,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.bin, filename)
const archive = path.join(Global.Path.bin, filename)
yield* Effect.logInfo("downloading ripgrep", { url })
yield* fs.ensureDir(global.bin).pipe(Effect.orDie)
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
const bytes = yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
@@ -131,6 +127,6 @@ export namespace RipgrepBinary {
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Global.node, httpClient, CrossSpawnSpawner.node],
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
})
}
+1 -3
View File
@@ -716,11 +716,10 @@ const layer = Layer.effect(
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
const session = yield* result.get(input.sessionID)
yield* result.get(input.sessionID)
yield* bus.publish(SessionEvent.AgentSelected, {
sessionID: input.sessionID,
agent: input.agent,
previous: session.agent,
})
}),
switchModel: Effect.fn("Session.switchModel")(function* (input) {
@@ -734,7 +733,6 @@ const layer = Layer.effect(
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID: input.sessionID,
model: input.model,
previous: session.model,
})
}),
rename: Effect.fn("Session.rename")(function* (input) {
+10 -15
View File
@@ -4,7 +4,6 @@ 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: (
@@ -60,23 +59,19 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.created": () => Effect.void,
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return Effect.gen(function* () {
const previous = event.data.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 },
}),
)
})
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 },
}),
)
},
"session.model.selected": (event) => {
return Effect.gen(function* () {
const previous = event.data.previous ?? (yield* adapter.getModel())
const previous = yield* adapter.getModel()
yield* adapter.appendMessage(
SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
+6 -21
View File
@@ -5,7 +5,6 @@ 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"
@@ -231,17 +230,6 @@ 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 })
@@ -410,15 +398,12 @@ const layer = Layer.effectDiscard(
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.AgentSelected, (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)
}),
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))),
)
yield* bus.project(SessionEvent.ModelSelected, (event) =>
Effect.gen(function* () {
@@ -59,25 +59,6 @@ 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, Set<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 payloads = seen.get(metadata) ?? new Set<string>()
if (payloads.has(file.data)) return []
payloads.add(file.data)
seen.set(metadata, payloads)
return attachmentContent(file)
})
}
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const providerMetadata = (
@@ -205,7 +186,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)]),
...userAttachmentContent(message.files ?? []),
...(message.files ?? []).flatMap(attachmentContent),
]
if (content.length === 0) return []
return [
+1 -3
View File
@@ -143,9 +143,7 @@ export const layer = (options?: ShellSelect.Options) =>
})
const resolve = () =>
config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options, global.bin)))
config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
+36 -51
View File
@@ -34,19 +34,15 @@ function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
function findExecutable(name: string, bin?: string) {
return which(name, undefined, bin)
}
function full(file: string, options?: Options, bin?: string) {
function full(file: string, options?: Options) {
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, bin) || shell
if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options) || shell
return shell
}
if (name(shell) === "bash") return gitbash(options, bin) || findExecutable(shell, bin) || shell
return findExecutable(shell, bin) || shell
if (name(shell) === "bash") return gitbash(options) || which(shell) || shell
return which(shell) || shell
}
function meta(file: string) {
@@ -61,26 +57,21 @@ function rooted(file: string) {
return path.isAbsolute(FSUtil.windowsPath(file))
}
function resolve(file: string, options?: Options, bin?: string) {
const shell = full(file, options, bin)
function resolve(file: string, options?: Options) {
const shell = full(file, options)
if (rooted(shell)) {
if (stat(shell)?.isFile()) return shell
return
}
return findExecutable(shell, bin) ?? undefined
return which(shell) ?? undefined
}
function win(options?: Options, bin?: string) {
function win(options?: Options) {
return Array.from(
new Set(
[
findExecutable("pwsh", bin),
findExecutable("powershell", bin),
gitbash(options, bin),
process.env.COMSPEC || "cmd.exe",
]
[which("pwsh"), which("powershell"), gitbash(options), process.env.COMSPEC || "cmd.exe"]
.filter((item): item is string => Boolean(item))
.map((file) => full(file, options, bin)),
.map((file) => full(file, options)),
),
)
}
@@ -91,27 +82,27 @@ async function unix() {
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }, bin?: string) {
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }) {
if (file && (!opts?.acceptable || ok(file))) {
const shell = resolve(file, options, bin)
const shell = resolve(file, options)
if (shell) return shell
}
if (process.platform === "win32") return win(options, bin)[0]
return fallback(bin)
if (process.platform === "win32") return win(options)[0]
return fallback()
}
export function gitbash(options?: Options, bin?: string) {
export function gitbash(options?: Options) {
if (process.platform !== "win32") return
if (options?.gitbash) return options.gitbash
const git = findExecutable("git", bin)
const git = which("git")
if (!git) return
const file = path.join(git, "..", "..", "bin", "bash.exe")
if (stat(file)?.size) return file
}
function fallback(bin?: string) {
function fallback() {
if (process.platform === "darwin") return "/bin/zsh"
const bash = findExecutable("bash", bin)
const bash = which("bash")
if (bash) return bash
return "/bin/sh"
}
@@ -129,12 +120,12 @@ export function ps(file: string) {
return meta(file)?.ps === true
}
function info(file: string, options?: Options, bin?: string): Item {
const item = full(file, options, bin)
function info(file: string, options?: Options): Item {
const item = full(file, options)
const n = name(item)
return {
path: item,
name: resolve(n, options, bin) ? n : item,
name: resolve(n, options) ? n : item,
acceptable: ok(item),
}
}
@@ -148,36 +139,30 @@ export function args(file: string, command: string) {
return ["-c", command]
}
let defaultPreferred: { bin?: string; value: string } | undefined
let defaultAcceptable: { bin?: string; value: string } | undefined
let defaultPreferred: string | undefined
let defaultAcceptable: string | undefined
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
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
}
preferred.reset = () => {
defaultPreferred = undefined
}
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
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
}
acceptable.reset = () => {
defaultAcceptable = undefined
}
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))
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))
}
+192 -23
View File
@@ -2,12 +2,17 @@ export * as Skill from "./skill"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, Layer, Types } from "effect"
import { Context, Effect, FiberMap, Layer, PubSub, Schema, Semaphore, Stream, 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
@@ -52,18 +57,38 @@ 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 = {
skills: Map<ID, Types.DeepMutable<Info>>
sources: Types.DeepMutable<Source>[]
}
export type Draft = {
list: () => readonly Types.DeepMutable<Info>[]
add: (skill: Info) => void
update: (id: string, update: (skill: Types.DeepMutable<Info>) => void) => void
remove: (id: string) => void
source: (source: Source) => void
list: () => readonly Source[]
}
export interface Interface extends State.Transformable<Draft> {
readonly sources: () => Effect.Effect<Source[]>
readonly list: () => Effect.Effect<Info[]>
}
@@ -72,35 +97,179 @@ 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: () => ({ skills: new Map() }),
initial: () => ({ sources: [] }),
draft: (draft) => ({
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))
source: (source) => {
if (draft.sources.some((item) => Source.equals(item, source))) return
draft.sources.push(source as Types.DeepMutable<Source>)
},
list: () => draft.sources as Source[],
}),
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
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())
}),
)
})
return Service.of({
transform: state.transform,
reload: state.reload,
list: Effect.fn("Skill.list")(function* () {
return Array.from(state.get().skills.values())
sources: Effect.fn("Skill.sources")(function* () {
return state.get().sources
}),
list,
})
}),
)
@@ -108,5 +277,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node],
deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node],
})
+3 -2
View File
@@ -1,9 +1,10 @@
import whichPkg from "which"
import path from "path"
import { Global } from "@opencode-ai/util/global"
export function which(cmd: string, env?: NodeJS.ProcessEnv, bin?: string) {
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
const full = base && bin ? base + path.delimiter + bin : base || bin
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
const result = whichPkg.sync(cmd, {
nothrow: true,
path: full,
+28 -20
View File
@@ -3,7 +3,6 @@ export * as Vcs from "./vcs"
import path from "path"
import { Context, Effect, Layer, Stream } from "effect"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -11,6 +10,8 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { AppProcess } from "@opencode-ai/util/process"
import { Bus } from "./bus"
import { Git } from "./git"
import { Watcher } from "./filesystem/watcher"
import { VcsGit } from "./vcs/git"
import { VcsHg } from "./vcs/hg"
@@ -44,29 +45,36 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const bus = yield* Bus.Service
const git = yield* Git.Service
const watcher = yield* Watcher.Service
const impl = adapter(proc, fs, location)
const vcs = location.vcs
const state = { info: impl ? yield* impl.info() : ({ branch: {} } satisfies Info) }
if (vcs && impl) {
const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store)))
const isBranchMetadata =
vcs.type === "git"
? (file: string) => path.basename(file) === "HEAD" && FSUtil.contains(store, file)
: (file: string) => path.resolve(file) === path.join(store, "branch")
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.filter((event) => isBranchMetadata(event.data.file)),
Stream.runForEach((event) =>
Effect.gen(function* () {
const next = yield* impl.info()
const changed = state.info.branch.current !== next.branch.current
state.info = next
if (!changed) return
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.gen(function* () {
const discovered = vcs.type === "git" ? (yield* git.repo.discover(location.directory))?.gitDirectory : undefined
const target = discovered ?? vcs.store
const dir = yield* fs.realPath(target).pipe(Effect.catch(() => Effect.succeed(target)))
const keep = vcs.type === "git" ? ["HEAD", "HEAD.lock"] : ["branch"]
const ignore = (yield* fs.readDirectoryEntries(dir).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (keep.includes(entry.name) ? [] : [entry.name]),
)
const updates = yield* watcher.subscribe({ path: dir, type: "directory", ignore })
yield* updates.pipe(
Stream.filter((update) => keep.includes(path.basename(update.path))),
Stream.runForEach((update) =>
Effect.gen(function* () {
const next = yield* impl.info()
const changed = state.info.branch.current !== next.branch.current
state.info = next
if (!changed) return
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: update.path } })),
),
Effect.forkScoped({ startImmediately: true }),
)
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to watch vcs metadata", { cause })))
}
return Service.of({
@@ -88,5 +96,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer: layer,
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node],
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node, Git.node, Watcher.node],
})
-67
View File
@@ -275,73 +275,6 @@ 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
@@ -1,6 +0,0 @@
---
name: first
description: First skill
---
# first
@@ -1,6 +0,0 @@
---
name: second
description: Second skill
---
# second
+1 -102
View File
@@ -5,10 +5,8 @@ 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"
@@ -21,19 +19,10 @@ 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]), [
[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],
]),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])),
)
describe("PluginSupervisor config", () => {
@@ -168,92 +157,6 @@ describe("PluginSupervisor config", () => {
),
)
it.live("loads auto-discovered plugin packages from package metadata", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("package-metadata")
}),
false,
async (directory) => {
const plugin = path.join(directory, ".opencode", "plugins", "package-metadata")
await fs.mkdir(plugin, { recursive: true })
await fs.writeFile(path.join(plugin, "package.json"), JSON.stringify({ exports: "./entry.ts" }))
await fs.writeFile(path.join(plugin, "entry.ts"), discoveredPlugin("package-metadata"))
},
),
)
it.live("loads auto-discovered plugin packages from index fallback", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("index-fallback")
}),
false,
async (directory) => {
const plugin = path.join(directory, ".opencode", "plugins", "index-fallback")
await fs.mkdir(plugin, { recursive: true })
await fs.writeFile(path.join(plugin, "index.js"), discoveredPlugin("index-fallback"))
},
),
)
it.live("prefers package metadata over index fallback", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
expect(ids).toContain("metadata-precedence")
expect(ids).not.toContain("module-collision")
expect(ids).not.toContain("main-collision")
expect(ids).not.toContain("index-collision")
}),
false,
async (directory) => {
const plugin = path.join(directory, ".opencode", "plugins", "collision")
await fs.mkdir(plugin, { recursive: true })
await fs.writeFile(
path.join(plugin, "package.json"),
JSON.stringify({ exports: "./entry.js", module: "./module.js", main: "./main.js" }),
)
await fs.writeFile(path.join(plugin, "entry.js"), discoveredPlugin("metadata-precedence"))
await fs.writeFile(path.join(plugin, "module.js"), discoveredPlugin("module-collision"))
await fs.writeFile(path.join(plugin, "main.js"), discoveredPlugin("main-collision"))
await fs.writeFile(path.join(plugin, "index.js"), discoveredPlugin("index-collision"))
},
),
)
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,
@@ -452,7 +355,3 @@ export default Plugin.define({
})
`
}
function discoveredPlugin(id: string) {
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
}
+10 -4
View File
@@ -46,7 +46,9 @@ 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.list()).some((skill) => skill.id === "first")).toBe(true)
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
).toBe(true)
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
expect(yield* catalog.provider.get(Provider.ID.make("first"))).toBeDefined()
@@ -67,8 +69,12 @@ describe("config plugin reloads", () => {
}),
)
expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(false)
expect((yield* skills.list()).some((skill) => skill.id === "second")).toBe(true)
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)
}).pipe(
Effect.provide(Config.testLayer([config("first")])),
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
@@ -83,7 +89,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: [path.join(import.meta.dir, "fixture", "skills", `${name}-source`)],
skills: [`/skills/${name}`],
references: { [name]: `/references/${name}` },
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
}),
+62 -354
View File
@@ -1,379 +1,87 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { Effect, Layer, Schema, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import {
AgentsDirectory,
ClaudeDirectory,
Directory as ConfigDirectory,
Document,
type Entry,
Info,
} from "@opencode-ai/schema/config"
import { AgentsDirectory, ClaudeDirectory, Directory, Document, 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 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 it = testEffect(Layer.empty)
const decode = Schema.decodeUnknownSync(Info)
function write(directory: string, name: string, description: string) {
return fs.writeFile(
path.join(directory, name, "SKILL.md"),
`---
name: ${name}
description: ${description}
---
# ${name}`,
)
}
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`,
),
).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 }))))
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 }
})
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" })))
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/"],
}),
}),
]),
),
)
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)
expect(sources).toEqual([
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.claude", "skills")),
}),
),
),
)
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)
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.agents", "skills")),
}),
),
),
)
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")])
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),
}),
),
),
)
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")
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.opencode", "skills")),
}),
),
),
)
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" },
])
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")),
}),
),
),
)
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")])
}),
),
),
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/opt/skills") }),
Skill.UrlSource.make({ type: "url", url: "https://example.test/skills/" }),
])
}),
)
})
+5 -31
View File
@@ -11,19 +11,11 @@ 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 legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import { Global } from "@opencode-ai/util/global"
import { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
const run = <A, E>(
effect: Effect.Effect<A, E, SqlClient | Global.Service>,
global = Global.make({ data: path.join(process.cwd(), ".test-data") }),
) =>
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
Effect.runPromise(
effect.pipe(
Effect.provideService(Global.Service, global),
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
Effect.scoped,
),
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
)
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
@@ -39,7 +31,7 @@ describe("DatabaseMigration", () => {
Effect.scoped(Layer.build(layer)),
),
{ concurrency: "unbounded" },
).pipe(Effect.provideService(Global.Service, Global.make({ data: tmp.path }))),
),
)
})
@@ -135,8 +127,7 @@ describe("DatabaseMigration", () => {
VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now})
`)
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
yield* db.transaction((tx) => importLegacyCredentials(tx, source))
expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual(
[
@@ -168,28 +159,11 @@ 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* () {
+7 -232
View File
@@ -1,28 +1,15 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Deferred, Effect, Fiber, Layer, Schedule, Stream } from "effect"
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 { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const describeNative = process.env.CI ? describe.skip : describe
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
const configLayer = Config.testLayer()
const it = testEffect(AppNodeBuilder.build(FSUtil.node))
describe("Watcher.testLayer", () => {
it.effect("records subscriptions and broadcasts emitted updates through the service", () =>
@@ -40,7 +27,6 @@ describe("Watcher.testLayer", () => {
yield* test.emit({ type: "update", path: "/root/file.md" })
expect(Array.from(yield* Fiber.join(received))).toEqual([{ type: "update", path: "/root/file.md" }])
// subscriptions() reports acquired watches, so paths come back resolved.
expect(yield* test.subscriptions()).toEqual([{ path: path.resolve("/root"), type: "directory" }])
}).pipe(Effect.provide(Watcher.testLayer)),
)
@@ -126,167 +112,20 @@ describe("Watcher lifecycle", () => {
expect(counts.unsubscribes).toBe(0)
return consumer
}).pipe(withNative(native))
// Closing the layer scope tears the native subscription down while the
// consumer still holds a reference; the consumer's own release as its
// stream ends must not tear it down a second time.
yield* Fiber.join(consumer)
expect(counts.unsubscribes).toBe(1)
})
})
})
function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
)
const built = AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
])
return Effect.provide(built)
}
function withTmp<A, E, R>(
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
options?: {
vcs?: "git" | "hg"
init?: (directory: string) => Promise<void>
watcher?: Layer.Layer<Watcher.Service>
},
) {
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(async () => {
const tmp = await tmpdir()
if (options?.vcs === "hg") {
await fs.mkdir(path.join(tmp.path, ".hg"))
return { tmp, vcs: { type: "hg" as const, store: AbsolutePath.make(path.join(tmp.path, ".hg")) } }
}
if (options?.vcs !== "git") return { tmp, vcs: undefined }
await $`git init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
await $`git config user.name Test`.cwd(tmp.path).quiet()
await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
await options.init?.(tmp.path)
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
}),
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("LocationWatcher subscriptions", () => {
it.live("watches only exact Git branch metadata", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
}),
)
return withTmp(
(directory) =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
)
yield* Effect.sleep("10 millis")
expect(subscriptions).toEqual([{ path: path.join(directory, ".git", "HEAD"), type: "file" }])
}),
{ vcs: "git", watcher },
)
})
it.live("watches only exact Hg branch metadata", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
}),
)
return withTmp(
(directory) =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
)
yield* Effect.sleep("10 millis")
expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }])
}),
{ vcs: "hg", watcher },
)
})
})
function wait(check: (event: WatcherEvent) => boolean) {
return Effect.gen(function* () {
const bus = yield* Bus.Service
const deferred = yield* Deferred.make<WatcherEvent>()
const fiber = yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.runForEach((event) => {
if (!check(event.data)) return Effect.void
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
}),
Effect.forkScoped,
)
yield* Effect.yieldNow
return { deferred, fiber }
})
}
function maybeNextUpdate<E>(
check: (event: WatcherEvent) => boolean,
trigger: Effect.Effect<void, E>,
timeout: Duration.Input = "5 seconds",
) {
return Effect.acquireUseRelease(
wait(check),
({ deferred }) => trigger.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeoutOption(timeout)),
({ fiber }) => Fiber.interrupt(fiber),
)
}
function nextUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
return Effect.gen(function* () {
const result = yield* maybeNextUpdate(check, trigger)
if (Option.isSome(result)) return result.value
return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
})
}
function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: () => Effect.Effect<void, E>) {
return Effect.gen(function* () {
while (true) {
const result = yield* maybeNextUpdate(check, trigger(), "250 millis")
if (Option.isSome(result)) return result.value
}
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
}),
)
}
function ready(file: string, eventFile = file) {
return Effect.gen(function* () {
const fs = yield* FSUtil.Service
const content = (yield* fs.readFileStringSafe(file)) ?? `ready-${Math.random()}`
yield* eventuallyUpdate(
(event) => event.file === eventFile,
() => fs.writeFileString(file, content),
).pipe(Effect.asVoid)
})
}
describeNative("LocationWatcher", () => {
describeNative("Watcher", () => {
it.live("limits file watches to the exact target", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -333,68 +172,4 @@ describeNative("LocationWatcher", () => {
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
),
)
it.live("publishes .git/HEAD events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const head = path.join(directory, ".git", "HEAD")
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* ready(head)
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
).toEqual({ file: head, event: "change" })
}),
{ vcs: "git" },
),
)
const describeSymlink = process.platform !== "win32" ? describe : describe.skip
describeSymlink("symlinked .git", () => {
it.live("publishes .git/HEAD events through a symlinked .git directory", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const afs = yield* FSUtil.Service
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
const head = path.join(directory, ".git", "HEAD")
yield* ready(head, path.join(actual, "HEAD"))
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
yield* nextUpdate(
(event) => event.file === path.join(actual, "HEAD"),
afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
),
).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
}),
{
vcs: "git",
init: async (directory) => {
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
await fs.rename(path.join(directory, ".git"), actual)
await fs.symlink(actual, path.join(directory, ".git"))
},
},
),
)
})
it.live("publishes .hg/branch events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
yield* ready(branch)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
}),
{ vcs: "hg" },
),
)
})
-27
View File
@@ -1,27 +0,0 @@
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
@@ -0,0 +1,16 @@
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)
})
})
+176 -261
View File
@@ -1,124 +1,49 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { Effect, Layer } from "effect"
import fs from "fs/promises"
import path from "path"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
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 { LayerNode } from "@opencode-ai/util/effect/layer-node"
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 { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { readInitial, readUpdate, state } from "./lib/instructions"
import { testEffect } from "./lib/effect"
import { host } from "./plugin/host"
import { readInitial, readUpdate, state } from "./lib/instructions"
const it = testEffect(Layer.empty)
const instructionLayer = (input: {
config?: string
config: string
locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service>
project?: boolean
}) => {
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)
})
}
}) =>
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] : []),
])
describe("InstructionDiscovery", () => {
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", () =>
it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
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* () {
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")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(directory, { recursive: true })
@@ -128,15 +53,25 @@ describe("ConfigInstructionPlugin.Plugin", () => {
await fs.writeFile(packageFile, "package")
})
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())
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)
expect(initialized.text).toBe(
[
`Instructions from: ${globalFile}\nglobal`,
@@ -147,14 +82,13 @@ describe("ConfigInstructionPlugin.Plugin", () => {
expect(initialized.text).not.toContain("outside")
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
yield* emitAndWait({ type: "update", path: packageFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toContain(
expect((yield* readUpdate(yield* load, initialized)).text).toContain(
`Instructions from: ${packageFile}\nchanged`,
)
yield* Effect.promise(() => fs.rm(packageFile))
yield* emitAndWait({ type: "delete", path: packageFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
const partial = yield* readUpdate(yield* load, initialized)
expect(partial.text).toBe(
[
"These instructions replace all previously loaded ambient instructions.",
`Instructions from: ${globalFile}\nglobal`,
@@ -162,30 +96,12 @@ describe("ConfigInstructionPlugin.Plugin", () => {
].join("\n\n"),
)
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(
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
expect((yield* readUpdate(yield* 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) },
),
),
),
}),
),
)
}),
}),
),
),
)
@@ -198,150 +114,115 @@ describe("ConfigInstructionPlugin.Plugin", () => {
Effect.gen(function* () {
const file = path.join(tmp.path, "AGENTS.md")
yield* Effect.promise(() => fs.writeFile(file, ""))
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.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",
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) })),
),
}),
),
)
}).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) },
),
),
),
}),
),
)
}),
expect((yield* readInitial(context)).text).toBe(`Instructions from: ${file}\n`)
}),
),
),
)
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("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" })) }),
),
}),
),
)
})
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)))
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") })),
),
}),
),
),
).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", () =>
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 observed: { values: { targets: string[]; start: string; stop?: string }[] } = { values: [] }
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(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", () =>
Effect.gen(function* () {
let observed: { targets: string[]; start: string; stop?: string } | undefined
const observingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({
...fs,
up: (options) => Effect.sync(() => (observed.values.push(options), [])),
up: (options) =>
Effect.sync(() => {
observed = options
return []
}),
}),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
yield* start().pipe(
yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
config: "/global",
filesystemLayer: observingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
@@ -352,11 +233,31 @@ describe("ConfigInstructionPlugin.Plugin", () => {
}),
),
)
yield* start().pipe(
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()),
Effect.provide(
instructionLayer({
filesystemLayer: observingFS,
config: "/global",
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") })),
@@ -364,10 +265,25 @@ describe("ConfigInstructionPlugin.Plugin", () => {
}),
),
)
yield* start().pipe(
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()),
Effect.provide(
instructionLayer({
filesystemLayer: observingFS,
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))),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
@@ -381,8 +297,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
),
)
const repo = path.resolve("/repo")
expect(observed.values).toEqual([{ targets: ["AGENTS.md"], start: repo, stop: repo }])
expect(scanned).toBe(false)
}),
)
})
@@ -1,5 +1,4 @@
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"
@@ -17,7 +16,6 @@ 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,
@@ -31,7 +29,7 @@ const locationLayer = Layer.succeed(
const it = testEffect(
AppNodeBuilder.build(InstructionBuiltIns.node, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: temporary, tmp: temporary })],
[Global.node, Global.layerWith({ config: "/global", tmp: "/temporary" })],
]),
)
@@ -51,7 +49,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)}`,
+2 -10
View File
@@ -9,7 +9,6 @@ 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"
@@ -22,7 +21,6 @@ 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"
@@ -30,15 +28,9 @@ 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]), [
[Global.node, tempGlobalLayer],
]),
)
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node])))
const itWithSdk = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])),
)
describe("LocationServiceMap", () => {
+53 -91
View File
@@ -1,17 +1,19 @@
import { describe, expect, test } from "bun:test"
import { describe, expect, beforeEach, afterAll, 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 { KV } from "@opencode-ai/core/kv"
import { Global } from "@opencode-ai/util/global"
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 cacheKey = "models-dev:catalog"
const cacheFile = path.join(Global.Path.cache, "models.json")
test("normalizes permissive interleaved values to compatibility", () => {
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
@@ -162,18 +164,7 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
}),
)
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 }) =>
const buildLayer = (state: Ref.Ref<MockState>, 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.
@@ -181,29 +172,31 @@ const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: Models
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured(options)],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeMockKV(cache)],
]),
)
// Mirrors production KV backends whose writes die as defects (e.g. Durable
// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
const makeFailingWriteKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: () => Effect.die(new Error('Failed query: insert into "kv"')),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
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 })
})
const provided = <A, E>(state: Ref.Ref<MockState>, cache: MockCache, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
eff.pipe(Effect.provide(buildLayer(state, cache)))
afterAll(async () => {
await rm(cacheFile, { force: true })
})
const initialState: MockState = {
body: JSON.stringify(fixture),
@@ -212,14 +205,12 @@ const initialState: MockState = {
}
describe("ModelsDev Service", () => {
it.live("get() returns normalized snapshots from KV when a cache entry exists", () =>
it.live("get() returns normalized snapshots from disk when cache file exists", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
yield* writeCache(fixture)
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
cache,
ModelsDev.Service.use((s) => s.get()),
)
expect(result).toEqual(fixtureSnapshot)
@@ -228,13 +219,11 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns empty catalog when KV is empty, fetch disabled, and no bundled snapshot is injected", () =>
it.live("get() returns empty catalog when disk 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([])
@@ -243,34 +232,14 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
it.live("get() recovers from a corrupted cache file by fetching a fresh catalog", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCacheText(cache, "{")
yield* writeCacheText("{")
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true }))
const context = yield* Layer.build(buildLayer(state, { fetch: true }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
)
it.live("get() still populates the catalog when the KV cache write fails", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeFailingWriteKV(cache)],
]),
)
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.has(cacheKey)).toBe(false)
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2))
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
@@ -278,10 +247,9 @@ 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, cache, { url: "", fetch: true })),
Effect.provide(buildLayer(state, { url: "", fetch: true })),
)
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json")
}),
@@ -289,31 +257,32 @@ describe("ModelsDev Service", () => {
it.live("get() is single-flight under concurrent calls", () =>
Effect.gen(function* () {
const cache = makeCache()
yield* writeCache(fixture)
const state = yield* Ref.make(initialState)
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 })))
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",
})
}),
)
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 KV writes are ignored until invalidate)", () =>
it.live("get() caches across calls (later disk writes are ignored until invalidate)", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
yield* writeCache(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()
writeCache(cache, fixture2)
// mutate disk between calls — cache should mask the change
yield* writeCache(fixture2)
const b = yield* svc.get()
return { a, b }
}),
@@ -325,12 +294,10 @@ describe("ModelsDev Service", () => {
it.live("refresh(true) fetches via HttpClient and updates the cache", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
yield* writeCache(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()
@@ -341,7 +308,6 @@ 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")
@@ -349,14 +315,13 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
it.live("refresh(false) skips fetch when on-disk file is fresh", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 1000)
// Fresh: mtime within the 5-minute TTL.
yield* writeCache(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)
@@ -364,14 +329,13 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) fetches when the KV entry is stale", () =>
it.live("refresh(false) fetches when on-disk file is stale", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
// Stale: mtime 10 minutes ago, beyond the 5-minute TTL.
yield* writeCache(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)
@@ -386,12 +350,10 @@ describe("ModelsDev Service", () => {
it.live("refresh swallows HTTP errors and leaves cache intact", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
yield* writeCache(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,5 +1,4 @@
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"
@@ -396,51 +395,4 @@ 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([])
}),
)
})
+1 -5
View File
@@ -19,11 +19,9 @@ 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 } from "effect"
import { Effect, Layer, Stream } from "effect"
import { tempLocationLayer } from "../fixture/location"
const npmLayer = Layer.succeed(
@@ -55,10 +53,8 @@ export const PluginTestLayer = AppNodeBuilder.build(
PluginHooks.node,
Reference.node,
Skill.node,
SkillDiscovery.node,
PluginHooks.node,
Tool.node,
Watcher.node,
WebSearch.node,
]),
[
@@ -215,66 +215,6 @@ describe("ModelsDevPlugin", () => {
}),
)
it.effect("omits deprecated models from the catalog", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("acme")
const activeID = Model.ID.make("current")
const deprecatedID = Model.ID.make("legacy")
const model = {
modelID: activeID,
providerID,
name: "Current",
capabilities: { tools: true, input: [], output: [] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 32_000 },
} satisfies Omit<Model.Info, "id">
const snapshots = [
{
info: {
id: providerID,
name: "Acme",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
},
environment: [],
models: [
{ id: activeID, ...model },
{
id: deprecatedID,
...model,
modelID: deprecatedID,
name: "Legacy",
status: "deprecated" as const,
},
],
},
] satisfies readonly ModelsDev.Snapshot[]
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
).pipe(
Effect.provideService(
ModelsDev.Service,
ModelsDev.Service.of({
get: () => Effect.succeed(snapshots),
refresh: () => Effect.void,
}),
),
)
expect(yield* catalog.model.get(providerID, activeID)).toBeDefined()
expect(yield* catalog.model.get(providerID, deprecatedID)).toBeUndefined()
}),
)
it.effect("registers key methods for providers with environment variables", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+6 -16
View File
@@ -543,10 +543,11 @@ 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, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
[Database.node, targetDatabase],
[Bus.node, Bus.configured({ persist: true })],
],
)
@@ -647,17 +648,14 @@ 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, agent: Agent.ID.make("build") })
const created = yield* session.create({ location })
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") })
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
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", previous: "build" } }])
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
{ type: "agent-switched", agent: "plan", previous: "build" },
])
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
}),
)
@@ -678,12 +676,7 @@ describe("Session.create", () => {
it.effect("switches the selected model through the durable Session event", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const previous = Model.Ref.make({
id: Model.ID.make("haiku"),
providerID: Provider.ID.anthropic,
variant: Model.VariantID.make("default"),
})
const created = yield* session.create({ location, model: previous })
const created = yield* session.create({ location })
const model = Model.Ref.make({
id: Model.ID.make("sonnet"),
providerID: Provider.ID.anthropic,
@@ -697,10 +690,7 @@ describe("Session.create", () => {
yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect),
)
expect(bus).toMatchObject([{ type: "session.model.selected" }])
expect(bus[0]?.data).toEqual({ sessionID: created.id, model, previous })
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
{ type: "model-switched", model, previous },
])
expect(bus[0]?.data).toEqual({ sessionID: created.id, model })
}),
)
+1 -4
View File
@@ -99,10 +99,7 @@ const builtins = Layer.mock(InstructionBuiltIns.Service, {
}),
),
})
const discovery = Layer.mock(InstructionDiscovery.Service, {
project: true,
load: () => Effect.succeed(Instructions.empty),
})
const discovery = Layer.mock(InstructionDiscovery.Service, { 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,7 +358,6 @@ describe("SessionProjector", () => {
directory: "/project",
title: "test",
version: "test",
agent: "plan",
model: previousModel,
})
.run()
@@ -460,10 +459,6 @@ 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,103 +373,6 @@ 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,10 +83,7 @@ const models = Layer.mock(SessionRunnerModel.Service)({
),
})
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, {
project: true,
load: () => Effect.succeed(Instructions.empty),
})
const instructionContext = Layer.mock(InstructionDiscovery.Service, { 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),
+1 -4
View File
@@ -312,10 +312,7 @@ const systemContext = Layer.mock(InstructionBuiltIns.Service, {
}),
),
})
const instructionContext = Layer.mock(InstructionDiscovery.Service, {
project: true,
load: () => Effect.succeed(Instructions.empty),
})
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const skillInstructions = Layer.mock(SkillInstructions.Service, {
load: (agent) =>
Effect.succeed(
+409 -73
View File
@@ -1,102 +1,438 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Stream } from "effect"
import { Deferred, Effect, Fiber, Layer, 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 it = testEffect(AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node])))
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 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 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 }
})
}
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.effect("registers values with last-write-wins precedence", () =>
it.live("publishes updates when skill sources change", () =>
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")])
})
expect(yield* skill.list()).toEqual([info("review", "Second"), info("deploy", "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),
)
}),
)
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")
})
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")
})
expect(yield* skill.list()).toEqual([info("review", "Updated")])
}),
)
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) },
])
})
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.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())[0]?.description).toBe("Updated")
yield* updated.dispose
expect((yield* skill.list())[0]?.description).toBe("Initial")
expect(original.description).toBe("Initial")
}),
),
),
)
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
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)])
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)
}),
)
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" })
}),
)
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" })
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([])
}),
)
const agent = yield* agents.get(Agent.ID.make("reviewer"))
expect(Skill.available([info("deploy", "Deploy")], agent!)).toEqual([])
}),
),
),
)
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`,
)
})
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",
},
])
}),
),
),
)
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.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" },
])
}),
),
),
)
})
-2
View File
@@ -33,7 +33,6 @@ 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"
@@ -139,7 +138,6 @@ const layer = AppNodeBuilder.build(
[
[SessionExecution.node, executionNode],
[Permission.node, permission],
[Global.node, tempGlobalLayer],
],
)
+1
View File
@@ -81,6 +81,7 @@ describe("SkillTool", () => {
Skill.Service.of({
transform: (_transform) => Effect.die("unused"),
reload: () => Effect.die("unused"),
sources: () => Effect.die("unused"),
list: () => Effect.succeed(current),
}),
)
+1 -6
View File
@@ -4,7 +4,6 @@ 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"
@@ -27,7 +26,6 @@ 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"
@@ -102,10 +100,7 @@ const layer = AppNodeBuilder.build(
PluginRuntime.providerNode,
LocationServiceMap.node,
]),
[
[SessionExecution.node, executionNode],
[Global.node, tempGlobalLayer],
],
[[SessionExecution.node, executionNode]],
)
const it = testEffect(layer)
+4 -10
View File
@@ -18,14 +18,9 @@ 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 | Global.Service>) =>
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient | Scope.Scope>) =>
Effect.runPromise(
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 })),
),
),
Effect.scoped(effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })))),
)
const session = (
@@ -777,7 +772,7 @@ describe("V1Migration database workflow", () => {
`)
})
const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Global.Service | Scope.Scope>) =>
const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Scope.Scope>) =>
run(
Effect.gen(function* () {
const db = yield* makeDb
@@ -940,7 +935,6 @@ 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)`,
)
@@ -950,7 +944,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.data).root,
worktree: path.parse(Global.Path.data).root,
})
expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({
value: '{"phase":"completed"}',
+4 -9
View File
@@ -8,7 +8,6 @@ import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
@@ -42,7 +41,9 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
const withHg = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
withTmp((directory) =>
Effect.promise(() => hg(directory, "init")).pipe(Effect.andThen(f(directory).pipe(provide(directory)))),
Effect.promise(() => hg(directory, "init")).pipe(
Effect.andThen(f(directory).pipe(provide(directory))),
),
)
async function hg(directory: string, ...args: string[]) {
@@ -124,13 +125,7 @@ describeHg("Vcs mercurial", () => {
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
yield* bus.publish(FileSystem.Event.Changed, {
file: path.join(directory, ".hg", "branch"),
event: "change",
})
expect(yield* Fiber.join(updated)).toMatchObject({
expect(yield* Fiber.join(updated).pipe(Effect.timeout("5 seconds"))).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
+128 -40
View File
@@ -8,27 +8,66 @@ import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
const describeNative = process.env.CI ? describe.skip : describe
const locationLayer = (directory: string, git?: boolean) =>
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
),
),
)
const provide = (directory: string, input: { git?: boolean } = {}) =>
Effect.provide(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [[Location.node, locationLayer(directory, input.git)]]),
)
function fakeWatcher() {
const subscriptions: Watcher.WatchInput[] = []
const active = new Set<(update: Watcher.Update) => void>()
const native = Watcher.Native.of({
subscribe: (input) =>
Effect.sync(() => {
subscriptions.push(
input.type === "file"
? { path: input.target, type: "file" }
: input.ignore.length > 0
? { path: input.target, type: "directory", ignore: input.ignore }
: { path: input.target, type: "directory" },
)
active.add(input.publish)
return {
unsubscribe: () => {
active.delete(input.publish)
return Promise.resolve()
},
}
}),
})
return {
subscriptions: () => [...subscriptions],
emit: (update: Watcher.Update) => {
for (const publish of active) publish(update)
},
layer: Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))),
}
}
const provideFake = (directory: string, fake: ReturnType<typeof fakeWatcher>, git = true) =>
Effect.provide(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
),
),
),
],
[Location.node, locationLayer(directory, git)],
[Watcher.node, fake.layer],
]),
)
@@ -93,35 +132,84 @@ describe("Vcs", () => {
),
)
it.live("caches branch info and publishes HEAD changes", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
}),
),
it.live("watches git branch metadata", () =>
withTmp((directory) => {
const fake = fakeWatcher()
return Effect.promise(() => initRepo(directory)).pipe(
Effect.andThen(
Effect.gen(function* () {
yield* Vcs.Service
expect(fake.subscriptions()).toHaveLength(1)
const git = fake.subscriptions()[0]
if (git?.type !== "directory") throw new Error("expected a directory watch")
expect(git.path).toBe(path.join(directory, ".git"))
expect(git.ignore ?? []).not.toContain("HEAD")
expect(git.ignore ?? []).toContain("objects")
}).pipe(provideFake(directory, fake)),
),
)
}),
)
it.live("caches branch info and publishes HEAD changes", () =>
withTmp((directory) => {
const fake = fakeWatcher()
return Effect.promise(async () => {
await initRepo(directory)
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
fake.emit({ type: "update", path: path.join(directory, ".git", "index.lock") })
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
fake.emit({ type: "update", path: path.join(directory, ".git", "HEAD.lock") })
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toMatchObject({ branch: { current: "feature" } })
}).pipe(provideFake(directory, fake)),
),
)
}),
)
describeNative("native watches", () => {
it.live("publishes branch updates on git checkout", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
expect(yield* Fiber.join(updated).pipe(Effect.timeout("5 seconds"))).toMatchObject({
_tag: "Some",
value: { data: { branch: "feature" } },
})
expect(yield* vcs.info()).toMatchObject({ branch: { current: "feature" } })
}),
),
{ timeout: 15_000 },
)
})
it.live("diffs the working copy against HEAD with patches", () =>
withGit((directory) =>
Effect.gen(function* () {
@@ -57,35 +57,35 @@ test("keeps a hidden prod launcher for old Linux pins", async () => {
expect(desktop).toContain("NoDisplay=true")
})
for (const channel of ["dev", "beta"] as const) {
test(`bundles the CLI outside the ${channel} app archive`, async () => {
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 () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = channel
const module = await import(`./electron-builder.config.ts?cli-resource=${channel}`)
const module = await import(`./electron-builder.config.ts?no-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.files).toContain("!resources/opencode-cli*")
expect(config.extraResources).toContainEqual({
expect(config.extraResources).not.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 !== "prod"
...(channel === "dev"
? [
{
from: "resources/",
-1
View File
@@ -37,7 +37,6 @@
"@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 { CLI_TARGET } from "./utils"
import { RUST_TARGET } from "./utils"
if (!CLI_TARGET) throw new Error("OPENCODE_CLI_TARGET not defined")
if (!RUST_TARGET) throw new Error("RUST_TARGET not defined")
const BUNDLE_DIR = "dist"
const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles")
-1
View File
@@ -8,4 +8,3 @@ 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<{ target: string; package: string; os: string; cpu: string }> = [
export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [
{
target: "aarch64-apple-darwin",
rustTarget: "aarch64-apple-darwin",
package: "@opencode-ai/cli-darwin-arm64",
os: "darwin",
cpu: "arm64",
},
{
target: "x86_64-apple-darwin",
rustTarget: "x86_64-apple-darwin",
package: "@opencode-ai/cli-darwin-x64-baseline",
os: "darwin",
cpu: "x64",
},
{
target: "aarch64-pc-windows-msvc",
rustTarget: "aarch64-pc-windows-msvc",
package: "@opencode-ai/cli-windows-arm64",
os: "win32",
cpu: "arm64",
},
{
target: "x86_64-pc-windows-msvc",
rustTarget: "x86_64-pc-windows-msvc",
package: "@opencode-ai/cli-windows-x64-baseline",
os: "win32",
cpu: "x64",
},
{
target: "x86_64-unknown-linux-gnu",
rustTarget: "x86_64-unknown-linux-gnu",
package: "@opencode-ai/cli-linux-x64-baseline",
os: "linux",
cpu: "x64",
},
{
target: "aarch64-unknown-linux-gnu",
rustTarget: "aarch64-unknown-linux-gnu",
package: "@opencode-ai/cli-linux-arm64",
os: "linux",
cpu: "arm64",
},
]
export const CLI_TARGET = Bun.env.OPENCODE_CLI_TARGET
export const RUST_TARGET = Bun.env.RUST_TARGET
function nativeTarget() {
const { platform, arch } = process
@@ -62,19 +62,19 @@ function nativeTarget() {
throw new Error(`Unsupported platform: ${platform}/${arch}`)
}
export function getCurrentCli(target = CLI_TARGET ?? nativeTarget()) {
const binaryConfig = CLI_BINARIES.find((item) => item.target === target)
export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) {
const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target)
if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`)
return binaryConfig
}
export async function downloadCliToResources(version = CLI_VERSION) {
export async function downloadCliToResources() {
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}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${CLI_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(version = CLI_VERSION) {
}
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
console.log(`Copied ${cli.package}@${version} to ${dest}`)
console.log(`Copied ${cli.package} to ${dest}`)
}
export function windowsify(path: string) {
+48 -23
View File
@@ -1,4 +1,3 @@
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"
@@ -9,34 +8,52 @@ 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) {
export async function startBackgroundCli(logger: Logger, shellStateHome?: string) {
const bundled = app.isPackaged
? join(process.resourcesPath, executableName())
: join(root, "../../resources", executableName())
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = parseVersion(await run(bundled, ["--version"], logger))
const version = await run(bundled, ["--version"], logger)
const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled
const service = await Service.ensure({
version,
command: [binary, "serve", "--service"],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
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,
})
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
logger.log("v2 CLI background service ready", {
username: service.auth.username,
version,
...endpoint(service.url),
existing: Boolean(found),
username: "opencode",
...endpoint(url),
})
return {
url: service.url,
username: service.auth.username,
password: service.auth.password,
url,
username: "opencode",
password,
}
}
@@ -60,13 +77,21 @@ async function installCli(source: string, version: string, logger: Logger) {
return destination
}
async function run(binary: string, args: string[], logger: Logger) {
async function run(
binary: string,
args: string[],
logger: Logger,
options: { redact?: boolean; stateHome?: string } = {},
) {
logger.log("v2 CLI command started", { binary, args })
return execFileAsync(binary, args, { windowsHide: true }).then(
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(
(result) => {
const stdout = result.stdout.trim()
const stderr = result.stderr.trim()
logger.log("v2 CLI command completed", { args, stdout, stderr })
logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr })
return stdout
},
(error: unknown) => {
@@ -74,7 +99,7 @@ async function run(binary: string, args: string[], logger: Logger) {
logger.error("v2 CLI command failed", {
args,
error: error instanceof Error ? error.message : String(error),
stdout: output.stdout?.trim() ?? "",
stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""),
stderr: output.stderr?.trim() ?? "",
})
throw error
@@ -82,11 +107,11 @@ async function run(binary: string, args: string[], logger: Logger) {
)
}
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 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 endpoint(url: string | undefined) {
+2 -2
View File
@@ -181,7 +181,7 @@ const main = Effect.gen(function* () {
return
}
preferAppEnv()
const shellEnv = preferAppEnv(app.getPath("userData"))
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))
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME))
yield* Deferred.succeed(serverReady, {
url: sidecar.url,
username: sidecar.username,
+3 -2
View File
@@ -17,16 +17,17 @@ export function setDefaultServerUrl(url: string | null) {
getStore().delete(DEFAULT_SERVER_URL_KEY)
}
export function preferAppEnv() {
export function preferAppEnv(userDataPath: string) {
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> {
+7 -8
View File
@@ -77,21 +77,20 @@ export function spatialPathSpans(points: readonly DiagramPoint[]): SpatialSpan[]
.sort(([left], [right]) => left - right)
.flatMap(([y, xs]) => {
const sorted = [...xs].sort((left, right) => left - right)
const [first, ...rest] = sorted
if (first === undefined) return []
const spans: SpatialSpan[] = []
let start = first
let end = first
for (const x of rest) {
if (x === end + 1) {
let start = sorted[0]
let end = start
if (start === undefined) return spans
for (const x of sorted.slice(1)) {
if (x === end! + 1) {
end = x
continue
}
spans.push(normalizedSpan(y, start, end))
spans.push(normalizedSpan(y, start, end!))
start = x
end = x
}
spans.push(normalizedSpan(y, start, end))
spans.push(normalizedSpan(y, start, end!))
return spans
})
}
+4 -8
View File
@@ -44,10 +44,6 @@ export interface MermaidMarkdownRendererOptions {
muted?: ColorInput
warning?: ColorInput
background?: ColorInput
request?: ColorInput
response?: ColorInput
note?: ColorInput
noteBackground?: ColorInput
}
}
@@ -135,12 +131,12 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
participant: color(colors.primary),
lifeline: color(colors.muted),
group: color(colors.secondary),
request: color(colors.request ?? colors.primary),
response: color(colors.response ?? colors.primary),
request: color(colors.primary),
response: color(colors.primary),
fragment: color(colors.secondary),
fragmentLabelBg: color(colors.background),
note: color(colors.note ?? colors.warning),
noteBg: color(colors.noteBackground ?? colors.background),
note: color(colors.warning),
noteBg: color(colors.background),
}),
),
height: size.height,
-9
View File
@@ -1,6 +1,5 @@
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]
@@ -32,15 +31,11 @@ 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,
})
@@ -50,9 +45,5 @@ 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,8 +5,6 @@ export interface OpenCodeDiagramPaletteInput {
readonly text: RGBA
readonly subdued: RGBA
readonly info: RGBA
readonly success: RGBA
readonly warning: RGBA
readonly background: RGBA
}
@@ -18,9 +16,5 @@ 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,8 +12,6 @@ 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,
}),
})),
+69 -63
View File
@@ -51,15 +51,16 @@ sequenceDiagram
`)
expectDiagram(output).toEqualDiagram(`
Browser Server
GET /
401 WWW-Auth
Browser Server
GET /
401 WWW-Auth
`)
})
@@ -139,13 +140,13 @@ sequenceDiagram
`)
const lines = output.split("\n")
const browserCenter = lines[0]!.indexOf("w")
const serverCenter = lines[0]!.indexOf("v")
const browserCenter = lines[1]!.indexOf("w")
const serverCenter = lines[1]!.indexOf("v")
expect(lines[1]?.[browserCenter]).toBe("┬")
expect(lines[2]?.[browserCenter]).toBe("│")
expect(lines[1]?.[serverCenter]).toBe("┬")
expect(lines[2]?.[serverCenter]).toBe("│")
expect(lines[2]?.[browserCenter]).toBe("┬")
expect(lines[3]?.[browserCenter]).toBe("│")
expect(lines[2]?.[serverCenter]).toBe("┬")
expect(lines[3]?.[serverCenter]).toBe("│")
})
test("ramps participant frames into neutral lifelines", () => {
@@ -276,8 +277,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", () => {
@@ -313,27 +314,28 @@ 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
(
"
`)
})
@@ -385,7 +387,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)
@@ -555,7 +557,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(3)
expect(fragmentMessageRow.match(/│/g)?.length).toBe(2)
})
test("keeps long notes inside groups and nested fragment frames intact", () => {
@@ -598,7 +600,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)
})
@@ -653,17 +655,18 @@ 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
"
`)
})
@@ -703,17 +706,18 @@ sequenceDiagram
`)
expect(output).toMatchInlineSnapshot(`
"Service
Check Permissions
"
"
Service
Check Permissions
"
`)
})
test("renders note badges in their reserved rows", () => {
test("frames notes in their reserved rows", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
Browser->>Server: one
@@ -725,9 +729,11 @@ sequenceDiagram
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
expect(noteRow).toBeGreaterThan(0)
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
expect(lines[noteRow]).toContain(" phase ")
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
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(nextMessageRow).toBe(noteRow + 2)
})
+36 -6
View File
@@ -191,9 +191,29 @@ function renderSelfMessage(
}
function renderNote(grid: SequenceGrid, placement: Extract<SequenceStepPlacement, { type: "note" }>): void {
placement.textLines.forEach((line, index) =>
setText(grid, placement.textX, placement.textY + index, line, "noteBadge"),
)
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")
}
}
export function drawSequenceDiagramGrid(
@@ -216,12 +236,22 @@ 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("left-aligns message label blocks inside their arrow span", () => {
test("centers message label blocks over their arrow span", () => {
const plan = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
participant A
@@ -214,6 +214,8 @@ ${Array.from(
A->>B: short<br/>a much longer line`),
)
const message = plan.steps.find((step) => step.type === "message")!
expect(message.labelX).toBe(message.leftX + 2)
const labelWidth = Math.max(...message.labelLines.map(diagramTextWidth))
expect(message.labelX * 2 + labelWidth).toBe(message.leftX + message.rightX)
})
})
+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(3, width)
return compact ? width : Math.max(5, width + 4)
}
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 = leftX + 2
const labelLeftX = Math.floor((leftX + rightX - labelWidth) / 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
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight)
const participantHeaderY = participantHeaderTopY + (compact ? 0 : 1)
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight + 1)
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 = inlineLabel ? Math.floor((leftX + rightX - renderedLabelWidth) / 2) : leftX + 2
const labelX = Math.floor((leftX + rightX - renderedLabelWidth) / 2)
steps.push({
type: "message",
message: step.message,
+2 -4
View File
@@ -4,11 +4,9 @@ export interface Registration {
readonly dispose: Effect.Effect<void>
}
export type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <
Name extends keyof Spec,
>(
export type Hooks<Spec> = <Name extends keyof Spec>(
name: Name,
callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
callback: (input: Spec[Name]) => Effect.Effect<void>,
) => Effect.Effect<Registration, never, Scope.Scope>
export type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>
+3 -5
View File
@@ -1,13 +1,11 @@
import type { SkillApi } from "@opencode-ai/client/effect/api"
import { Skill } from "@opencode-ai/schema/skill"
import type { Effect, Types } from "effect"
import type { Effect } from "effect"
import type { Transform } from "./registration.js"
export interface SkillDraft {
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
source(source: Skill.Source): void
list(): readonly Skill.Source[]
}
export interface SkillDomain extends SkillApi<unknown> {
+1 -7
View File
@@ -38,13 +38,7 @@ 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, ToolFailures>
readonly hook: Hooks<ToolHooks>
}
+2 -5
View File
@@ -1,13 +1,10 @@
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 {
list(): readonly DeepMutable<Skill.Info>[]
add(skill: Skill.Info): void
update(id: string, update: (skill: DeepMutable<Skill.Info>) => void): void
remove(id: string): void
source(source: Skill.Source): void
list(): readonly Skill.Source[]
}
export interface SkillDomain extends SkillApi {
-9
View File
@@ -12733,9 +12733,6 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["id", "time", "type", "agent"],
@@ -14373,9 +14370,6 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14444,9 +14438,6 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],
-2
View File
@@ -69,7 +69,6 @@ export const AgentSelected = Event.durable({
schema: {
...Base,
agent: Agent.ID,
previous: Agent.ID.pipe(optional),
},
})
export type AgentSelected = typeof AgentSelected.Type
@@ -80,7 +79,6 @@ export const ModelSelected = Event.durable({
schema: {
...Base,
model: Model.Ref,
previous: Model.Ref.pipe(optional),
},
})
export type ModelSelected = typeof ModelSelected.Type

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