feat(core): reload config on filesystem changes

This commit is contained in:
Dax Raad 2026-07-03 20:34:29 -04:00
commit c9b24ef027
48 changed files with 596 additions and 510 deletions

View file

@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
const refresh: string[] = [] const refresh: string[] = []
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "file.watcher.updated", type: "filesystem.changed",
properties: { properties: {
file: "src/new.ts", file: "src/new.ts",
event: "add", event: "add",
@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "file.watcher.updated", type: "filesystem.changed",
properties: { properties: {
file: "src/open.ts", file: "src/open.ts",
event: "change", event: "change",
@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "file.watcher.updated", type: "filesystem.changed",
properties: { properties: {
file: "src", file: "src",
event: "change", event: "change",
@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "file.watcher.updated", type: "filesystem.changed",
properties: { properties: {
file: "src/file.ts", file: "src/file.ts",
event: "change", event: "change",
@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher( invalidateFromWatcher(
{ {
type: "file.watcher.updated", type: "filesystem.changed",
properties: { properties: {
file: ".git/index.lock", file: ".git/index.lock",
event: "change", event: "change",

View file

@ -16,7 +16,7 @@ type WatcherOps = {
} }
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "file.watcher.updated") return if (event.type !== "filesystem.changed") return
const props = const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined const rawPath = typeof props?.file === "string" ? props.file : undefined

View file

@ -820,7 +820,7 @@ export default function Page() {
) )
const stopVcs = sdk().event.listen((evt) => { const stopVcs = sdk().event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return if (evt.details.type !== "filesystem.changed") return
const props = const props =
typeof evt.details.properties === "object" && evt.details.properties typeof evt.details.properties === "object" && evt.details.properties
? (evt.details.properties as Record<string, unknown>) ? (evt.details.properties as Record<string, unknown>)

View file

@ -8,6 +8,7 @@ export type {
AppApi, AppApi,
CatalogApi, CatalogApi,
CommandApi, CommandApi,
EventApi,
IntegrationApi, IntegrationApi,
ModelApi, ModelApi,
PluginApi, PluginApi,

View file

@ -1,6 +1,7 @@
import type { import type {
AgentApi as EffectAgentApi, AgentApi as EffectAgentApi,
CommandApi as EffectCommandApi, CommandApi as EffectCommandApi,
EventApi as EffectEventApi,
IntegrationApi as EffectIntegrationApi, IntegrationApi as EffectIntegrationApi,
ModelApi as EffectModelApi, ModelApi as EffectModelApi,
PluginApi as EffectPluginApi, PluginApi as EffectPluginApi,
@ -25,6 +26,7 @@ type PromisifyApi<Api> = {
export type AgentApi = PromisifyApi<EffectAgentApi<unknown>> export type AgentApi = PromisifyApi<EffectAgentApi<unknown>>
export type CommandApi = PromisifyApi<EffectCommandApi<unknown>> export type CommandApi = PromisifyApi<EffectCommandApi<unknown>>
export type EventApi = PromisifyApi<EffectEventApi<unknown>>
export type IntegrationApi = PromisifyApi<EffectIntegrationApi<unknown>> export type IntegrationApi = PromisifyApi<EffectIntegrationApi<unknown>>
export type ModelApi = PromisifyApi<EffectModelApi<unknown>> export type ModelApi = PromisifyApi<EffectModelApi<unknown>>
export type PluginApi = PromisifyApi<EffectPluginApi<unknown>> export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>

View file

@ -4923,9 +4923,9 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "file.edited" readonly type: "filesystem.changed"
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly file: string } readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" }
} }
| { | {
readonly id: string readonly id: string
@ -4991,7 +4991,7 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "skill.updated" readonly type: "config.updated"
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {} readonly data: {}
} }
@ -4999,9 +4999,9 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "file.watcher.updated" readonly type: "skill.updated"
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" } readonly data: {}
} }
| { | {
readonly id: string readonly id: string

View file

@ -3,6 +3,7 @@ export type {
AgentApi, AgentApi,
CatalogApi, CatalogApi,
CommandApi, CommandApi,
EventApi,
IntegrationApi, IntegrationApi,
ModelApi, ModelApi,
PluginApi, PluginApi,

View file

@ -1,12 +1,11 @@
export * as Catalog from "./catalog" export * as Catalog from "./catalog"
import { makeLocationNode } from "./effect/app-node" import { makeLocationNode } from "./effect/app-node"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect" import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog" import { Catalog } from "@opencode-ai/schema/catalog"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state" import { State } from "./state"
import { Integration } from "./integration" import { Integration } from "./integration"
@ -17,8 +16,6 @@ export type ProviderRecord = {
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = Catalog.Event export const Event = Catalog.Event
type Data = { type Data = {
@ -65,7 +62,6 @@ const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const policy = yield* Policy.Service
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => { const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
@ -159,13 +155,6 @@ const layer = Layer.effect(
return result return result
}, },
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) { finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
if (policy.hasStatements()) {
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
catalog.provider.remove(record.provider.id)
}
}
}
yield* events.publish(Event.Updated, {}) yield* events.publish(Event.Updated, {})
}), }),
}) })
@ -294,4 +283,4 @@ const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] })

View file

@ -3,18 +3,19 @@ export * as Config from "./config"
import { makeLocationNode } from "./effect/app-node" import { makeLocationNode } from "./effect/app-node"
import path from "path" import path from "path"
import { type ParseError, parse } from "jsonc-parser" import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect" import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Permission } from "@opencode-ai/schema/permission" import { Permission } from "@opencode-ai/schema/permission"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { EventV2 } from "./event"
import { Watcher } from "./filesystem/watcher"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { Global } from "./global" import { Global } from "./global"
import { Location } from "./location" import { Location } from "./location"
import { Policy } from "./policy"
import { AbsolutePath } from "./schema" import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent" import { ConfigAgent } from "./config/agent"
import { ConfigAttachments } from "./config/attachments" import { ConfigAttachments } from "./config/attachments"
import { ConfigCompaction } from "./config/compaction" import { ConfigCompaction } from "./config/compaction"
import { ConfigCommand } from "./config/command" import { ConfigCommand } from "./config/command"
import { ConfigExperimental } from "./config/experimental"
import { ConfigFormatter } from "./config/formatter" import { ConfigFormatter } from "./config/formatter"
import { ConfigLSP } from "./config/lsp" import { ConfigLSP } from "./config/lsp"
import { ConfigMCP } from "./config/mcp" import { ConfigMCP } from "./config/mcp"
@ -102,7 +103,6 @@ export class Info extends Schema.Class<Info>("Config.Info")({
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({ plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered external plugin packages to load", description: "Ordered external plugin packages to load",
}), }),
experimental: ConfigExperimental.Experimental.pipe(Schema.optional),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
}) {} }) {}
@ -138,7 +138,8 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const global = yield* Global.Service const global = yield* Global.Service
const location = yield* Location.Service const location = yield* Location.Service
const policy = yield* Policy.Service const watcher = yield* Watcher.Service
const events = yield* EventV2.Service
const names = ["opencode.json", "opencode.jsonc"] const names = ["opencode.json", "opencode.jsonc"]
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
@ -170,45 +171,78 @@ const layer = Layer.effect(
] ]
}) })
const globalDirectory = AbsolutePath.make(global.config) const discover = Effect.fn("Config.discover")(function* () {
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) const globalDirectory = AbsolutePath.make(global.config)
// Read configuration once when this location opens. Later calls reuse these const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
// values until the location is reopened. const discovered = locationIsGlobal
const discovered = locationIsGlobal ? []
? [] : yield* fs
: yield* fs .up({
.up({ targets: [".opencode", ...names.toReversed()],
targets: [".opencode", ...names.toReversed()], start: location.directory,
start: location.directory, stop: location.project.directory,
stop: location.project.directory, })
}) .pipe(Effect.orDie)
.pipe(Effect.orDie) const directories = [
const directories = [ globalDirectory,
globalDirectory, ...discovered
...discovered .filter((item) => path.basename(item) === ".opencode")
.filter((item) => path.basename(item) === ".opencode") .toReversed()
.toReversed() .map((directory) => AbsolutePath.make(directory)),
.map((directory) => AbsolutePath.make(directory)), ]
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed()
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
Effect.orDie,
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
return {
entries: [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()],
directories,
files: directPaths,
}
})
const initial = yield* discover()
let configs = initial.entries
const updates = yield* PubSub.unbounded<Watcher.Update>()
const subscriptions = new Map<string, Effect.Effect<unknown>>()
const targets = (snapshot: typeof initial) => [
...snapshot.directories.map((path) => ({ path, type: "directory" as const })),
...snapshot.files
.filter((file) => !snapshot.directories.some((directory) => FSUtil.contains(directory, file)))
.map((path) => ({ path, type: "file" as const })),
] ]
// A config closer to the opened directory should win over one higher up. const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) {
// Search starts nearby, so reverse the results before applying them. const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target]))
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() for (const [key, stop] of subscriptions) {
const direct = yield* Effect.forEach(directPaths, loadFile).pipe( if (next.has(key)) continue
Effect.orDie, yield* stop
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), subscriptions.delete(key)
) }
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) for (const [key, target] of next) {
// Apply general settings first and more specific settings last: if (subscriptions.has(key)) continue
// global config, project files, then `.opencode` files. const fiber = yield* watcher.subscribe(target).pipe(
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] Stream.runForEach((update) => PubSub.publish(updates, update)),
// Rules use the opposite order so a user-global rule can override a Effect.forkScoped({ startImmediately: true }),
// repository rule. Statement order inside each file stays unchanged. )
yield* policy.load( subscriptions.set(key, Fiber.interrupt(fiber))
configs }
.filter((config): config is Document => config.type === "document") })
.toReversed()
.flatMap((config) => config.info.experimental?.policies ?? []), yield* Stream.fromPubSub(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach((update) =>
Effect.gen(function* () {
const next = yield* discover()
configs = next.entries
yield* reconcile(next)
yield* events.publish(ConfigSchema.Event.Updated, {})
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))),
),
Effect.forkScoped({ startImmediately: true }),
) )
yield* reconcile(initial)
return Service.of({ return Service.of({
entries: Effect.fn("Config.entries")(function* () { entries: Effect.fn("Config.entries")(function* () {
@ -221,5 +255,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, layer,
deps: [FSUtil.node, Global.node, Location.node, Policy.node], deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node],
}) })

View file

@ -1,20 +0,0 @@
export * as ConfigExperimental from "./experimental"
import { Schema } from "effect"
import { Catalog } from "../catalog"
import { Policy } from "../policy"
// Each core domain exports the policy actions it supports. Adding an action to
// this union makes it valid in authored config while keeping Policy generic.
export const PolicyAction = Schema.Union([Catalog.PolicyActions])
class PolicyConfig extends Schema.Class<PolicyConfig>("ConfigV2.Experimental.Policy")({
...Policy.Info.fields,
action: PolicyAction,
}) {}
export { PolicyConfig as Policy }
export class Experimental extends Schema.Class<Experimental>("ConfigV2.Experimental")({
policies: PolicyConfig.pipe(Schema.Array, Schema.optional),
}) {}

View file

@ -2,7 +2,7 @@ export * as ConfigCommandPlugin from "./command"
import { define } from "../../plugin/internal" import { define } from "../../plugin/internal"
import path from "path" import path from "path"
import { Effect, Option, Schema } from "effect" import { Effect, Option, Schema, Stream } from "effect"
import { CommandV2 } from "../../command" import { CommandV2 } from "../../command"
import { Config } from "../../config" import { Config } from "../../config"
import { FSUtil } from "../../fs-util" import { FSUtil } from "../../fs-util"
@ -17,16 +17,19 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service const config = yield* Config.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) return yield* Effect.forEach(yield* config.entries(), (entry) => {
return loadDirectory(fs, entry.path).pipe( if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
Effect.map((commands) => [ return loadDirectory(fs, entry.path).pipe(
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, Effect.map((commands) => [
]), { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
) ]),
}).pipe(Effect.map((documents) => documents.flat())) )
}).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: yield* load() }
yield* ctx.command.transform((draft) => { yield* ctx.command.transform((draft) => {
for (const document of documents) { for (const document of loaded.documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) { for (const [name, command] of Object.entries(document.commands ?? {})) {
draft.update(name, (item) => { draft.update(name, (item) => {
item.template = command.template item.template = command.template
@ -44,6 +47,16 @@ export const Plugin = define({
} }
} }
}) })
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
load().pipe(
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
Effect.andThen(ctx.command.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}), }),
}) })

View file

@ -0,0 +1,83 @@
export * as LocationWatcher from "./location-watcher"
import { makeLocationNode } from "../effect/app-node"
import { Context, Effect, Layer, Stream } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import os from "os"
import path from "path"
import { Config } from "../config"
import { EventV2 } from "../event"
import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { Watcher } from "./watcher"
import { Ignore } from "./ignore"
import { Protected } from "./protected"
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const relative = path.relative(dir, item)
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
})
}
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 events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const configService = yield* Config.Service
const config = (yield* configService.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
events.publish(FileSystem.Event.Changed, {
file: update.path,
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
})
if (path.resolve(location.directory) !== path.resolve(os.homedir())) {
yield* watcher
.subscribe({
path: location.directory,
type: "directory",
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
})
.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
} else {
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
}
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 ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
yield* watcher
.subscribe({ path: vcs, type: "directory", ignore })
.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
}
}
return Service.of({})
}).pipe(
Effect.catchCause((cause) =>
Effect.logError("failed to init location watcher service", { cause }).pipe(Effect.as(Service.of({}))),
),
),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
})

View file

@ -3,26 +3,20 @@ export * as Watcher from "./watcher"
// @ts-ignore // @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper" import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher" import type ParcelWatcher from "@parcel/watcher"
import { makeLocationNode } from "../effect/app-node" import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Cause, Context, Effect, Layer } from "effect" import { makeGlobalNode } from "../effect/app-node"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" import { Cause, Context, Effect, Layer, PubSub, Scope, Stream } from "effect"
import os from "os" import { KeyedMutex } from "../effect/keyed-mutex"
import path from "path"
import { Config } from "../config"
import { EventV2 } from "../event"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import { Ignore } from "./ignore" import { watch as watchFileSystem } from "node:fs"
import { Protected } from "./protected" import path from "path"
declare const OPENCODE_LIBC: string | undefined declare const OPENCODE_LIBC: string | undefined
const SUBSCRIBE_TIMEOUT_MS = 10_000 const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = FileSystemWatcher.Event export const Event = { Updated: FileSystem.Event.Changed }
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try { try {
@ -42,108 +36,132 @@ function getBackend() {
if (process.platform === "linux") return "inotify" if (process.platform === "linux") return "inotify"
} }
function protecteds(dir: string) { export const hasNativeBinding = () => !!watcher()
return Protected.paths().filter((item) => { export type Update = ParcelWatcher.Event
const relative = path.relative(dir, item)
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) export type WatchInput =
}) | { readonly path: string; readonly type: "file" }
| { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] }
export interface Interface {
readonly subscribe: (input: WatchInput) => Stream.Stream<Update>
} }
export const hasNativeBinding = () => !!watcher() export class Service extends Context.Service<Service, Interface>()("@opencode/Watcher") {}
export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileWatcher") {}
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
if (Flag.OPENCODE_DISABLE_FILEWATCHER) return Service.of({})
const backend = getBackend() const backend = getBackend()
const location = yield* Location.Service const native = watcher()
if (path.resolve(location.directory) === path.resolve(os.homedir())) { if (Flag.OPENCODE_DISABLE_FILEWATCHER) {
yield* Effect.logInfo("watcher skipped home directory", { directory: location.directory }) return Service.of({ subscribe: () => Stream.empty })
return Service.of({})
}
if (!backend) {
yield* Effect.logError("watcher backend not supported", {
directory: location.directory,
platform: process.platform,
})
return Service.of({})
} }
const w = watcher() type Entry = {
if (!w) return Service.of({}) readonly pubsub: PubSub.PubSub<Update>
readonly subscription: { readonly unsubscribe: () => Promise<void> }
yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend }) refs: number
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const subscriptions: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))),
)
const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => {
if (_error) runFork(Effect.logError("watcher callback failed", { error: _error }))
for (const update of updates) {
if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" }))
if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" }))
if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" }))
}
} }
const entries = new Map<string, Entry>()
const locks = KeyedMutex.makeUnsafe<string>()
const subscribe = (directory: string, ignore: string[]) => { const acquire = Effect.fn("Watcher.acquire")(function* (input: WatchInput) {
const pending = w.subscribe(directory, callback, { ignore, backend }) const scope = yield* Scope.Scope
return Effect.promise(() => pending).pipe( const target = path.resolve(input.path)
Effect.tap((subscription) => const directory = input.type === "file" ? path.dirname(target) : target
Effect.sync(() => subscriptions.push(subscription)).pipe( const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
Effect.andThen(Effect.logInfo("watcher subscribed", { directory, backend, ignores: ignore.length })), const id = JSON.stringify([input.type, target, ignore])
), const pubsub = yield* locks.withLock(id)(
), Effect.gen(function* () {
Effect.timeout(SUBSCRIBE_TIMEOUT_MS), const existing = entries.get(id)
Effect.catchCause((cause) => { if (existing) {
pending.then((subscription) => subscription.unsubscribe()).catch(() => {}) existing.refs++
return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) }) return existing.pubsub
}
const pubsub = yield* PubSub.unbounded<Update>()
const subscription = yield* input.type === "file"
? Effect.sync(() => {
const subscription = watchFileSystem(directory, { recursive: false }, (_event, file) => {
if (file && path.resolve(directory, file.toString()) !== target) return
PubSub.publishUnsafe(pubsub, {
path: target,
type: "update",
} satisfies Update)
})
subscription.on("error", (error) =>
Effect.runFork(Effect.logError("watcher callback failed", { path: target, error })),
)
return { unsubscribe: () => Promise.resolve(subscription.close()) }
})
: subscribeDirectory(native, backend, directory, ignore, pubsub)
if (subscription) {
entries.set(id, { pubsub, subscription, refs: 1 })
yield* Effect.logInfo("watcher started", {
path: target,
type: input.type,
backend: input.type === "file" ? "node" : backend,
ignores: ignore.length,
})
return pubsub
}
yield* PubSub.shutdown(pubsub)
return pubsub
}), }),
) )
}
const configService = yield* Config.Service yield* Scope.addFinalizer(
const config = (yield* configService.entries()) scope,
.filter((entry): entry is Config.Document => entry.type === "document") locks.withLock(id)(
.flatMap((item) => item.info.watcher?.ignore ?? []) Effect.gen(function* () {
yield* Effect.forkScoped( const entry = entries.get(id)
subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]), if (!entry) return
) entry.refs--
if (entry.refs > 0) return
if (location.vcs?.type === "git") { entries.delete(id)
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory yield* Effect.promise(() => entry.subscription.unsubscribe()).pipe(Effect.ignore)
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined yield* PubSub.shutdown(entry.pubsub)
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) { yield* Effect.logInfo("watcher stopped", { path: target, type: input.type })
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap( }),
(entry) => (entry.name === "HEAD" ? [] : [entry.name]), ),
)
yield* Effect.forkScoped(subscribe(vcs, ignore))
}
}
return Service.of({})
}).pipe(
Effect.catchCause((cause) => {
return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe(
Effect.as(Service.of({})),
) )
}), return pubsub
), })
const subscribe = (input: WatchInput) =>
Stream.unwrap(acquire(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))
return Service.of({ subscribe })
}),
) )
export const node = makeLocationNode({ export const node = makeGlobalNode({ service: Service, layer, deps: [] })
service: Service,
layer, function subscribeDirectory(
deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node], native: typeof import("@parcel/watcher") | undefined,
}) backend: ParcelWatcher.BackendType | undefined,
directory: string,
ignore: string[],
pubsub: PubSub.PubSub<Update>,
) {
if (!native || !backend) {
return Effect.logError("watcher backend not supported", { directory, platform: process.platform }).pipe(
Effect.as(undefined),
)
}
const callback: ParcelWatcher.SubscribeCallback = (error, updates) => {
if (error) Effect.runFork(Effect.logError("watcher callback failed", { error }))
for (const update of updates) PubSub.publishUnsafe(pubsub, update)
}
const pending = native.subscribe(directory, callback, { ignore, backend })
return Effect.promise(() => pending).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.logError("failed to subscribe", {
directory,
cause: Cause.pretty(cause),
}).pipe(Effect.as(undefined))
}),
)
}

View file

@ -11,7 +11,7 @@ import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search" import { FileSystemSearch } from "./filesystem/search"
import { Generate } from "./generate" import { Generate } from "./generate"
import { Form } from "./form" import { Form } from "./form"
import { Watcher } from "./filesystem/watcher" import { LocationWatcher } from "./filesystem/location-watcher"
import { Image } from "./image" import { Image } from "./image"
import { Integration } from "./integration" import { Integration } from "./integration"
import { Location } from "./location" import { Location } from "./location"
@ -21,7 +21,6 @@ import { MCP } from "./mcp/index"
import { PermissionV2 } from "./permission" import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin" import { PluginV2 } from "./plugin"
import { PluginInternal } from "./plugin/internal" import { PluginInternal } from "./plugin/internal"
import { Policy } from "./policy"
import { ProjectCopy } from "./project/copy" import { ProjectCopy } from "./project/copy"
import { Pty } from "./pty" import { Pty } from "./pty"
import { QuestionV2 } from "./question" import { QuestionV2 } from "./question"
@ -50,7 +49,6 @@ export { LocationServiceMap } from "./location-service-map"
const locationServiceNodes = [ const locationServiceNodes = [
Location.node, Location.node,
Policy.node,
Config.node, Config.node,
AgentV2.node, AgentV2.node,
CommandV2.node, CommandV2.node,
@ -64,7 +62,7 @@ const locationServiceNodes = [
ProjectCopy.refreshNode, ProjectCopy.refreshNode,
FileSystemSearch.node, FileSystemSearch.node,
FileSystem.node, FileSystem.node,
Watcher.node, LocationWatcher.node,
Pty.node, Pty.node,
Shell.node, Shell.node,
SkillV2.node, SkillV2.node,

View file

@ -1,12 +1,14 @@
export * as PluginHost from "./host" export * as PluginHost from "./host"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema } from "effect" import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Effect, Schema, Stream } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { AISDK } from "../aisdk" import { AISDK } from "../aisdk"
import { Catalog } from "../catalog" import { Catalog } from "../catalog"
import { CommandV2 } from "../command" import { CommandV2 } from "../command"
import { Credential } from "../credential" import { Credential } from "../credential"
import { EventV2 } from "../event"
import { Integration } from "../integration" import { Integration } from "../integration"
import { Location } from "../location" import { Location } from "../location"
import { ModelV2 } from "../model" import { ModelV2 } from "../model"
@ -21,12 +23,14 @@ import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace" import { WorkspaceV2 } from "../workspace"
const mutable = <T>(value: T) => value as DeepMutable<T> const mutable = <T>(value: T) => value as DeepMutable<T>
const isEvent = Schema.is(Schema.Union(EventManifest.ServerDefinitions))
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service const commands = yield* CommandV2.Service
const events = yield* EventV2.Service
const integration = yield* Integration.Service const integration = yield* Integration.Service
const location = yield* Location.Service const location = yield* Location.Service
const reference = yield* Reference.Service const reference = yield* Reference.Service
@ -155,6 +159,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
callback(draft) callback(draft)
}), }),
}, },
event: {
subscribe: () => events.live().pipe(Stream.filter(isEvent)),
},
integration: { integration: {
list: () => response(integration.list()), list: () => response(integration.list()),
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))), get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),

View file

@ -2,7 +2,7 @@ export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/v2/effect" import { define } from "@opencode-ai/plugin/v2/effect"
import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise" import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise"
import { Effect, Scope } from "effect" import { Effect, Scope, Stream } from "effect"
type HostRegistration = { readonly dispose: Effect.Effect<void> } type HostRegistration = { readonly dispose: Effect.Effect<void> }
type Registration = { readonly dispose: () => Promise<void> } type Registration = { readonly dispose: () => Promise<void> }
@ -73,6 +73,9 @@ export function fromPromise(plugin: Plugin) {
transform: transform(host.command), transform: transform(host.command),
reload: () => run(host.command.reload()), reload: () => run(host.command.reload()),
}, },
event: {
subscribe: () => Stream.toAsyncIterable(host.event.subscribe()),
},
integration: { integration: {
list: (input) => run(host.integration.list(input)), list: (input) => run(host.integration.list(input)),
get: (input) => run(host.integration.get(input)), get: (input) => run(host.integration.get(input)),

View file

@ -1,48 +0,0 @@
export * as Policy from "./policy"
import { makeLocationNode } from "./effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Wildcard } from "./util/wildcard"
import { Location } from "./location"
const PolicyEffect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" })
export { PolicyEffect as Effect }
export type Effect = typeof PolicyEffect.Type
export class Info extends Schema.Class<Info>("Policy.Info")({
action: Schema.String,
effect: PolicyEffect,
resource: Schema.String,
}) {}
export interface Interface {
readonly load: (statements: Info[]) => Effect.Effect<void>
readonly evaluate: (action: string, resource: string, fallback: Effect) => Effect.Effect<Effect>
readonly hasStatements: () => boolean
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Policy") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
let statements: Info[] = []
yield* Location.Service
return Service.of({
load: Effect.fn("Policy.load")(function* (input) {
statements = input
}),
hasStatements: () => statements.length > 0,
evaluate: Effect.fn("Policy.evaluate")(function* (action, resource, fallback) {
return (
statements.findLast(
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),
)?.effect ?? fallback
)
}),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })

View file

@ -3,7 +3,7 @@ export * as SkillV2 from "./skill"
import { makeLocationNode } from "./effect/app-node" import { makeLocationNode } from "./effect/app-node"
import path from "path" import path from "path"
import { Context, Effect, Layer, Schema, Stream, Types } from "effect" import { Context, Effect, Layer, Schema, Stream, Types } from "effect"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Skill } from "@opencode-ai/schema/skill" import { Skill } from "@opencode-ai/schema/skill"
import { AgentV2 } from "./agent" import { AgentV2 } from "./agent"
import { ConfigMarkdown } from "./config/markdown" import { ConfigMarkdown } from "./config/markdown"
@ -153,7 +153,7 @@ const layer = Layer.effect(
yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid) yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid)
}) })
yield* events.subscribe(FileSystemWatcher.Event.Updated).pipe( yield* events.subscribe(FileSystem.Event.Changed).pipe(
Stream.runForEach((event) => invalidate(event.data.file)), Stream.runForEach((event) => invalidate(event.data.file)),
Effect.forkScoped({ startImmediately: true }), Effect.forkScoped({ startImmediately: true }),
) )

View file

@ -2,7 +2,6 @@ export * as ConfigV1 from "./config"
import { Schema } from "effect" import { Schema } from "effect"
import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema" import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema"
import { ConfigExperimental } from "../../config/experimental"
import { ConfigReference } from "../../config/reference" import { ConfigReference } from "../../config/reference"
import { ConfigAgentV1 } from "./agent" import { ConfigAgentV1 } from "./agent"
import { ConfigAttachmentV1 } from "./attachment" import { ConfigAttachmentV1 } from "./attachment"
@ -179,9 +178,6 @@ export const Info = Schema.Struct({
mcp_timeout: Schema.optional(PositiveInt).annotate({ mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests", description: "Timeout in milliseconds for model context protocol (MCP) requests",
}), }),
policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({
description: "Policy statements applied to supported resources, such as provider access",
}),
}), }),
), ),
}).annotate({ identifier: "Config" }) }).annotate({ identifier: "Config" })

View file

@ -78,7 +78,6 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
plugins: info.plugin?.map((plugin) => plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }, typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
), ),
experimental: info.experimental?.policies && { policies: info.experimental.policies },
providers: providers(info.provider), providers: providers(info.provider),
} }
} }

View file

@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { Policy } from "@opencode-ai/core/policy"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location" import { location } from "./fixture/location"
@ -24,7 +23,7 @@ const locationLayer = Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make("test") })), Location.Service.of(location({ directory: AbsolutePath.make("test") })),
) )
const catalogLayer = AppNodeBuilder.build( const catalogLayer = AppNodeBuilder.build(
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]), LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node]),
[[Location.node, locationLayer]], [[Location.node, locationLayer]],
) )
const it = testEffect(catalogLayer) const it = testEffect(catalogLayer)
@ -333,21 +332,4 @@ describe("CatalogV2", () => {
expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
}), }),
) )
it.effect("removes providers denied by policy after loading", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const policy = yield* Policy.Service
const providerID = ProviderV2.ID.make("blocked")
yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })])
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
})
expect(yield* catalog.provider.all()).toEqual([])
expect(yield* catalog.model.all()).toEqual([])
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
}),
)
}) })

View file

@ -1,13 +1,15 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect" import { Effect, PubSub, Schema, Stream } from "effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { CommandV2 } from "@opencode-ai/core/command" import { CommandV2 } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index" import { MCP } from "@opencode-ai/core/mcp/index"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
@ -19,7 +21,7 @@ import { testEffect } from "../lib/effect"
import { host } from "../plugin/host" import { host } from "../plugin/host"
const it = testEffect( const it = testEffect(
AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]), [ AppNodeBuilder.build(LayerNode.group([CommandV2.node, EventV2.node, FSUtil.node]), [
[MCP.node, emptyMcpLayer], [MCP.node, emptyMcpLayer],
[Config.node, emptyConfigLayer], [Config.node, emptyConfigLayer],
[Location.node, testLocationLayer], [Location.node, testLocationLayer],
@ -53,6 +55,9 @@ Review files`,
}) })
const command = yield* CommandV2.Service const command = yield* CommandV2.Service
const events = yield* EventV2.Service
const update = yield* events.publish(ConfigSchema.Event.Updated, {})
const updates = yield* PubSub.unbounded<typeof update>()
yield* ConfigCommandPlugin.Plugin.effect( yield* ConfigCommandPlugin.Plugin.effect(
host({ host({
command: { command: {
@ -60,6 +65,7 @@ Review files`,
transform: command.transform, transform: command.transform,
reload: command.reload, reload: command.reload,
}, },
event: { subscribe: () => Stream.fromPubSub(updates) },
}), }),
).pipe( ).pipe(
Effect.provideService( Effect.provideService(
@ -93,6 +99,15 @@ Review files`,
CommandV2.Info.make({ name: "empty", template: "" }), CommandV2.Info.make({ name: "empty", template: "" }),
CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }), CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }),
]) ])
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again"))
yield* Effect.sleep("10 millis")
yield* PubSub.publish(updates, update)
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* command.get("review"))?.template === "Review again") break
yield* Effect.sleep("10 millis")
}
expect((yield* command.get("review"))?.template).toBe("Review again")
}), }),
), ),
), ),

View file

@ -1,18 +1,20 @@
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect" import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing" import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { ConfigProvider } from "@opencode-ai/core/config/provider" import { ConfigProvider } from "@opencode-ai/core/config/provider"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { EventV2 } from "@opencode-ai/core/event"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location" import { location } from "../fixture/location"
@ -26,6 +28,7 @@ function testLayer(
globalDirectory = path.join(directory, "global"), globalDirectory = path.join(directory, "global"),
projectDirectory = directory, projectDirectory = directory,
vcs?: Project.Vcs, vcs?: Project.Vcs,
watcher?: Layer.Layer<Watcher.Service>,
) { ) {
const locationLayer = Layer.succeed( const locationLayer = Layer.succeed(
Location.Service, Location.Service,
@ -36,9 +39,10 @@ function testLayer(
), ),
), ),
) )
return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [ return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
[Location.node, locationLayer], [Location.node, locationLayer],
[Global.node, Global.layerWith({ config: globalDirectory })], [Global.node, Global.layerWith({ config: globalDirectory })],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
]) ])
} }
@ -52,6 +56,52 @@ const provider = {
} }
describe("Config", () => { describe("Config", () => {
it.live("reloads external config and publishes directory updates", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const file = path.join(global, "opencode.json")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(file, JSON.stringify({ shell: "first" }))
})
const updates = yield* PubSub.unbounded<Watcher.Update>()
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: () => Stream.fromPubSub(updates),
}),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const events = yield* EventV2.Service
const changed = yield* events
.subscribe(ConfigSchema.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.sleep("10 millis")
yield* PubSub.publish(updates, {
type: "update",
path: path.join(global, "commands", "review.md"),
} satisfies Watcher.Update)
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
yield* PubSub.publish(updates, { type: "update", path: file } satisfies Watcher.Update)
expect(yield* Fiber.join(changed)).toHaveLength(1)
expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
}).pipe(Effect.provide(testLayer(project, global, project, undefined, watcher)))
}),
),
),
)
it.effect("returns the latest defined scalar from priority-ordered documents", () => it.effect("returns the latest defined scalar from priority-ordered documents", () =>
Effect.sync(() => { Effect.sync(() => {
const entries = [ const entries = [
@ -274,7 +324,6 @@ describe("Config", () => {
const file = path.join(tmp.path, "opencode.json") const file = path.join(tmp.path, "opencode.json")
const contents = JSON.stringify({ const contents = JSON.stringify({
shell: "/bin/zsh", shell: "/bin/zsh",
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
providers: { local: provider }, providers: { local: provider },
}) })
yield* Effect.promise(() => fs.writeFile(file, contents)) yield* Effect.promise(() => fs.writeFile(file, contents))
@ -285,11 +334,6 @@ describe("Config", () => {
expect(documents[0]?.info.$schema).toBeUndefined() expect(documents[0]?.info.$schema).toBeUndefined()
expect(documents[0]?.info.shell).toBe("/bin/zsh") expect(documents[0]?.info.shell).toBe("/bin/zsh")
expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({
effect: "deny",
action: "provider.use",
resource: "openai",
})
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents) expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
}).pipe(Effect.provide(testLayer(tmp.path))) }).pipe(Effect.provide(testLayer(tmp.path)))
}), }),
@ -723,40 +767,6 @@ describe("Config", () => {
), ),
) )
it.live("loads policy statements in reverse config order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.writeFile(
path.join(global, "opencode.json"),
JSON.stringify({
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
}),
)
await fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] },
}),
)
})
return yield* Effect.gen(function* () {
const policy = yield* Policy.Service
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}).pipe(Effect.provide(testLayer(tmp.path, global)))
})
}),
),
)
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () => it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),

View file

@ -8,7 +8,9 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location" import { location } from "../fixture/location"
@ -34,7 +36,7 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })), Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
) )
return Effect.provide( return Effect.provide(
AppNodeBuilder.build(Watcher.node, [ AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer], [Config.node, configLayer],
[Location.node, locationLayer], [Location.node, locationLayer],
]), ]),
@ -66,7 +68,7 @@ function wait(check: (event: WatcherEvent) => boolean) {
return Effect.gen(function* () { return Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const deferred = yield* Deferred.make<WatcherEvent>() const deferred = yield* Deferred.make<WatcherEvent>()
const fiber = yield* events.subscribe(Watcher.Event.Updated).pipe( const fiber = yield* events.subscribe(FileSystem.Event.Changed).pipe(
Stream.runForEach((event) => { Stream.runForEach((event) => {
if (!check(event.data)) return Effect.void if (!check(event.data)) return Effect.void
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid) return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
@ -136,7 +138,27 @@ function ready(directory: string) {
}) })
} }
describeWatcher("Watcher", () => { describeWatcher("LocationWatcher", () => {
it.live("limits file watches to the exact target", () =>
withTmp((directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const watcher = yield* Watcher.Service
const target = path.join(directory, "opencode.json")
const sibling = path.join(directory, "other.json")
const update = yield* watcher
.subscribe({ path: target, type: "file" })
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.yieldNow
yield* fs.writeFileString(sibling, "sibling")
yield* fs.writeFileString(target, "target")
expect((yield* Fiber.join(update)).valueOrUndefined?.path).toBe(target)
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
),
)
it.live("publishes root create, update, and delete events", () => it.live("publishes root create, update, and delete events", () =>
withTmp( withTmp(
(directory) => (directory) =>

View file

@ -51,27 +51,18 @@ describe("LocationServiceMap", () => {
), ),
) )
it.live("isolates location state while sharing location policy with catalog", () => it.live("isolates catalog state by location", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)), (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
).pipe( ).pipe(
Effect.flatMap(([blocked, allowed]) => Effect.flatMap(([blocked, allowed]) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(() => const update = (directory: string, providerID: ProviderV2.ID) =>
fs.writeFile(
path.join(blocked.path, "opencode.json"),
JSON.stringify({
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "test" }] },
}),
),
)
const update = (directory: string) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Reference.Service yield* Reference.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
// Tool plugins register during the forked PluginInternal boot; wait for // Tool plugins register during the forked PluginInternal boot; wait for
// every expected tool rather than relying on batch ordering. // every expected tool rather than relying on batch ordering.
@ -103,8 +94,11 @@ describe("LocationServiceMap", () => {
), ),
) )
const blockedState = yield* update(blocked.path) const blockedID = ProviderV2.ID.make("blocked-location")
expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false) const allowedID = ProviderV2.ID.make("allowed-location")
const blockedState = yield* update(blocked.path, blockedID)
expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true)
expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([ expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
"edit", "edit",
"glob", "glob",
@ -119,8 +113,9 @@ describe("LocationServiceMap", () => {
"websearch", "websearch",
"write", "write",
]) ])
const allowedState = yield* update(allowed.path) const allowedState = yield* update(allowed.path, allowedID)
expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true) expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true)
expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false)
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([ expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
"edit", "edit",
"glob", "glob",

View file

@ -1,8 +1,11 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Schema } from "effect" import { Effect, Exit, Fiber, Schema, Stream } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { define } from "@opencode-ai/plugin/v2/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { EventV2 } from "@opencode-ai/core/event"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool/tool" import { Tool } from "@opencode-ai/core/tool/tool"
@ -14,6 +17,24 @@ import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer) const it = testEffect(PluginTestLayer)
describe("PluginV2", () => { describe("PluginV2", () => {
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const events = yield* EventV2.Service
const host = yield* PluginHost.make(plugins)
const received = yield* host.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("10 millis")
yield* events.publish(ConfigSchema.Event.Updated, {})
expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated")
}),
)
it.effect("waits for a plugin and returns immediately once active", () => it.effect("waits for a plugin and returns immediately once active", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service

View file

@ -6,7 +6,7 @@ import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types"
import { Effect } from "effect" import { Effect, Stream } from "effect"
type Overrides = Partial<Omit<PluginContext, "options">> type Overrides = Partial<Omit<PluginContext, "options">>
@ -39,6 +39,9 @@ export function host(overrides: Overrides = {}): PluginContext {
transform: () => Effect.die("unused command.transform"), transform: () => Effect.die("unused command.transform"),
reload: () => Effect.die("unused command.reload"), reload: () => Effect.die("unused command.reload"),
}, },
event: overrides.event ?? {
subscribe: () => Stream.empty,
},
integration: overrides.integration ?? { integration: overrides.integration ?? {
list: () => Effect.die("unused integration.list"), list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"), get: () => Effect.die("unused integration.get"),

View file

@ -1,85 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(Policy.node, [
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
],
]),
)
describe("Policy", () => {
it.effect("returns the caller's fallback when no statement matches", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
expect(yield* policy.evaluate("provider.use", "anthropic", "deny")).toBe("deny")
}),
)
it.effect("evaluates wildcard provider rules in written order", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "deny",
action: "provider.*",
resource: "*",
}),
new Policy.Info({
effect: "allow",
action: "provider.use",
resource: "anthropic",
}),
])
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}),
)
it.effect("matches action and resource independently", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "deny",
action: "provider.*",
resource: "company-*",
}),
])
expect(yield* policy.evaluate("provider.use", "company-stable", "allow")).toBe("deny")
expect(yield* policy.evaluate("plugin.load", "company-stable", "allow")).toBe("allow")
}),
)
it.effect("uses the last matching loaded statement", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "allow",
action: "provider.use",
resource: "openai",
}),
new Policy.Info({
effect: "deny",
action: "provider.use",
resource: "openai",
}),
])
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}),
)
})

View file

@ -10,7 +10,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SkillV2 } from "@opencode-ai/core/skill" import { SkillV2 } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" import { FileSystem } from "@opencode-ai/schema/filesystem"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@ -206,7 +206,7 @@ metadata:
waitForSkillUpdate(), waitForSkillUpdate(),
({ deferred }) => ({ deferred }) =>
events events
.publish(FileSystemWatcher.Event.Updated, { file, event: "change" }) .publish(FileSystem.Event.Changed, { file, event: "change" })
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")), .pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber), ({ fiber }) => Fiber.interrupt(fiber),
) )

View file

@ -12,6 +12,7 @@ import { LSP } from "@/lsp/lsp"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import DESCRIPTION from "./apply_patch.txt" import DESCRIPTION from "./apply_patch.txt"
import { FileSystem } from "@opencode-ai/core/filesystem" import { FileSystem } from "@opencode-ai/core/filesystem"
import { FileSystemV1 } from "@opencode-ai/schema/filesystem-v1"
import { Format } from "../format" import { Format } from "../format"
import * as Bom from "@/util/bom" import * as Bom from "@/util/bom"
@ -253,7 +254,7 @@ export const ApplyPatchTool = Tool.define(
if (yield* format.file(edited)) { if (yield* format.file(edited)) {
yield* Bom.syncFile(afs, edited, change.bom) yield* Bom.syncFile(afs, edited, change.bom)
} }
yield* events.publish(FileSystem.Event.Edited, { file: edited }) yield* events.publish(FileSystemV1.Event.Edited, { file: edited })
} }
} }

View file

@ -10,6 +10,7 @@ import { LSP } from "@/lsp/lsp"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
import DESCRIPTION from "./edit.txt" import DESCRIPTION from "./edit.txt"
import { FileSystem } from "@opencode-ai/core/filesystem" import { FileSystem } from "@opencode-ai/core/filesystem"
import { FileSystemV1 } from "@opencode-ai/schema/filesystem-v1"
import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { Format } from "../format" import { Format } from "../format"
@ -112,7 +113,7 @@ export const EditTool = Tool.define(
if (yield* format.file(filePath)) { if (yield* format.file(filePath)) {
contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
} }
yield* events.publish(FileSystem.Event.Edited, { file: filePath }) yield* events.publish(FileSystemV1.Event.Edited, { file: filePath })
yield* events.publish(Watcher.Event.Updated, { yield* events.publish(Watcher.Event.Updated, {
file: filePath, file: filePath,
event: "add", event: "add",
@ -156,7 +157,7 @@ export const EditTool = Tool.define(
if (yield* format.file(filePath)) { if (yield* format.file(filePath)) {
contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
} }
yield* events.publish(FileSystem.Event.Edited, { file: filePath }) yield* events.publish(FileSystemV1.Event.Edited, { file: filePath })
yield* events.publish(Watcher.Event.Updated, { yield* events.publish(Watcher.Event.Updated, {
file: filePath, file: filePath,
event: "change", event: "change",

View file

@ -7,6 +7,7 @@ import { createTwoFilesPatch } from "diff"
import DESCRIPTION from "./write.txt" import DESCRIPTION from "./write.txt"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { FileSystem } from "@opencode-ai/core/filesystem" import { FileSystem } from "@opencode-ai/core/filesystem"
import { FileSystemV1 } from "@opencode-ai/schema/filesystem-v1"
import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Format } from "../format" import { Format } from "../format"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
@ -65,7 +66,7 @@ export const WriteTool = Tool.define(
if (yield* format.file(filepath)) { if (yield* format.file(filepath)) {
yield* Bom.syncFile(fs, filepath, desiredBom) yield* Bom.syncFile(fs, filepath, desiredBom)
} }
yield* events.publish(FileSystem.Event.Edited, { file: filepath }) yield* events.publish(FileSystemV1.Event.Edited, { file: filepath })
yield* events.publish(Watcher.Event.Updated, { yield* events.publish(Watcher.Event.Updated, {
file: filepath, file: filepath,
event: exists ? "change" : "add", event: exists ? "change" : "add",

View file

@ -82,7 +82,7 @@ describe("v2 location HttpApi", () => {
expect( expect(
Schema.decodeUnknownSync(Event)({ Schema.decodeUnknownSync(Event)({
id: "evt_test", id: "evt_test",
type: "file.watcher.updated", type: "filesystem.changed",
location: { directory: "/tmp/project" }, location: { directory: "/tmp/project" },
data: {}, data: {},
}), }),

View file

@ -3,6 +3,7 @@ import type { AgentHooks } from "./agent.js"
import type { AISDKHooks } from "./aisdk.js" import type { AISDKHooks } from "./aisdk.js"
import type { CatalogHooks } from "./catalog.js" import type { CatalogHooks } from "./catalog.js"
import type { CommandHooks } from "./command.js" import type { CommandHooks } from "./command.js"
import type { EventHooks } from "./event.js"
import type { IntegrationHooks } from "./integration.js" import type { IntegrationHooks } from "./integration.js"
import type { PluginDomain } from "./plugin.js" import type { PluginDomain } from "./plugin.js"
import type { ReferenceHooks } from "./reference.js" import type { ReferenceHooks } from "./reference.js"
@ -16,6 +17,7 @@ export interface PluginContext {
readonly aisdk: AISDKHooks readonly aisdk: AISDKHooks
readonly catalog: CatalogHooks readonly catalog: CatalogHooks
readonly command: CommandHooks readonly command: CommandHooks
readonly event: EventHooks
readonly integration: IntegrationHooks readonly integration: IntegrationHooks
readonly plugin: PluginDomain readonly plugin: PluginDomain
readonly reference: ReferenceHooks readonly reference: ReferenceHooks

View file

@ -1,10 +1,3 @@
import type { Event as SDKEvent } from "@opencode-ai/sdk/v2/types" import type { EventApi } from "@opencode-ai/client/effect/api"
import type { Stream } from "effect"
export type EventMap = { export interface EventHooks extends Pick<EventApi<unknown>, "subscribe"> {}
[Item in SDKEvent as Item["type"]]: Item
}
export interface Event {
subscribe<Type extends keyof EventMap>(type: Type): Stream.Stream<EventMap[Type]>
}

View file

@ -5,6 +5,7 @@ export type { AgentDraft, AgentHooks } from "./agent.js"
export type { AISDKHooks } from "./aisdk.js" export type { AISDKHooks } from "./aisdk.js"
export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js"
export type { CommandDraft, CommandHooks } from "./command.js" export type { CommandDraft, CommandHooks } from "./command.js"
export type { EventHooks } from "./event.js"
export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
export type { SkillDraft, SkillHooks } from "./skill.js" export type { SkillDraft, SkillHooks } from "./skill.js"

View file

@ -3,6 +3,7 @@ import type { AgentHooks } from "./agent.js"
import type { AISDKHooks } from "./aisdk.js" import type { AISDKHooks } from "./aisdk.js"
import type { CatalogHooks } from "./catalog.js" import type { CatalogHooks } from "./catalog.js"
import type { CommandHooks } from "./command.js" import type { CommandHooks } from "./command.js"
import type { EventHooks } from "./event.js"
import type { IntegrationHooks } from "./integration.js" import type { IntegrationHooks } from "./integration.js"
import type { PluginDomain } from "./plugin.js" import type { PluginDomain } from "./plugin.js"
import type { ReferenceHooks } from "./reference.js" import type { ReferenceHooks } from "./reference.js"
@ -15,6 +16,7 @@ export interface PluginContext {
readonly aisdk: AISDKHooks readonly aisdk: AISDKHooks
readonly catalog: CatalogHooks readonly catalog: CatalogHooks
readonly command: CommandHooks readonly command: CommandHooks
readonly event: EventHooks
readonly integration: IntegrationHooks readonly integration: IntegrationHooks
readonly plugin: PluginDomain readonly plugin: PluginDomain
readonly reference: ReferenceHooks readonly reference: ReferenceHooks

View file

@ -0,0 +1,3 @@
import type { EventApi } from "@opencode-ai/client/promise/api"
export interface EventHooks extends Pick<EventApi, "subscribe"> {}

View file

@ -6,6 +6,7 @@ export type { AgentDraft, AgentHooks } from "./agent.js"
export type { AISDKHooks } from "./aisdk.js" export type { AISDKHooks } from "./aisdk.js"
export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js"
export type { CommandDraft, CommandHooks } from "./command.js" export type { CommandDraft, CommandHooks } from "./command.js"
export type { EventHooks } from "./event.js"
export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
export type { SessionHooks } from "./runtime.js" export type { SessionHooks } from "./runtime.js"

View file

@ -0,0 +1,10 @@
export * as Config from "./config.js"
import { ephemeral, inventory } from "./event.js"
const Updated = ephemeral({
type: "config.updated",
schema: {},
})
export const Event = { Updated, Definitions: inventory(Updated) }

View file

@ -3,10 +3,11 @@ export * as EventManifest from "./event-manifest.js"
import { Agent } from "./agent.js" import { Agent } from "./agent.js"
import { Catalog } from "./catalog.js" import { Catalog } from "./catalog.js"
import { Command } from "./command.js" import { Command } from "./command.js"
import { Config } from "./config.js"
import { Durable } from "./durable-event-manifest.js" import { Durable } from "./durable-event-manifest.js"
import { Event } from "./event.js" import { Event } from "./event.js"
import { FileSystem } from "./filesystem.js" import { FileSystem } from "./filesystem.js"
import { FileSystemWatcher } from "./filesystem-watcher.js" import { FileSystemV1 } from "./filesystem-v1.js"
import { Form } from "./form.js" import { Form } from "./form.js"
import { InstallationEvent } from "./installation-event.js" import { InstallationEvent } from "./installation-event.js"
import { Integration } from "./integration.js" import { Integration } from "./integration.js"
@ -60,8 +61,8 @@ const featureDefinitions = Event.inventory(
...Plugin.Event.Definitions, ...Plugin.Event.Definitions,
...ProjectDirectories.Event.Definitions, ...ProjectDirectories.Event.Definitions,
...Command.Event.Definitions, ...Command.Event.Definitions,
...Config.Event.Definitions,
...Skill.Event.Definitions, ...Skill.Event.Definitions,
...FileSystemWatcher.Event.Definitions,
...Pty.Event.Definitions, ...Pty.Event.Definitions,
...Shell.Event.Definitions, ...Shell.Event.Definitions,
...Question.Event.Definitions, ...Question.Event.Definitions,
@ -97,6 +98,7 @@ export const Definitions = Event.inventory(
...TuiEvent.Definitions, ...TuiEvent.Definitions,
...McpEvent.Definitions, ...McpEvent.Definitions,
...LegacyEvent.Definitions, ...LegacyEvent.Definitions,
...FileSystemV1.Event.Definitions,
...Project.Event.Definitions, ...Project.Event.Definitions,
...SessionStatusEvent.Definitions, ...SessionStatusEvent.Definitions,
...QuestionV1.Event.Definitions, ...QuestionV1.Event.Definitions,

View file

@ -0,0 +1 @@
export * from "./v1/filesystem.js"

View file

@ -1,13 +0,0 @@
export * as FileSystemWatcher from "./filesystem-watcher.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
const Updated = ephemeral({
type: "file.watcher.updated",
schema: {
file: Schema.String,
event: Schema.Literals(["add", "change", "unlink"]),
},
})
export const Event = { Updated, Definitions: inventory(Updated) }

View file

@ -5,11 +5,14 @@ import { optional } from "./schema.js"
import { ephemeral, inventory } from "./event.js" import { ephemeral, inventory } from "./event.js"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
const Edited = ephemeral({ const Changed = ephemeral({
type: "file.edited", type: "filesystem.changed",
schema: { file: Schema.String }, schema: {
file: Schema.String,
event: Schema.Literals(["add", "change", "unlink"]),
},
}) })
export const Event = { Edited, Definitions: inventory(Edited) } export const Event = { Changed, Definitions: inventory(Changed) }
export interface Entry extends Schema.Schema.Type<typeof Entry> {} export interface Entry extends Schema.Schema.Type<typeof Entry> {}
export const Entry = Schema.Struct({ export const Entry = Schema.Struct({

View file

@ -1,5 +1,6 @@
export { Agent } from "./agent.js" export { Agent } from "./agent.js"
export { Command } from "./command.js" export { Command } from "./command.js"
export { Config } from "./config.js"
export { Connection } from "./connection.js" export { Connection } from "./connection.js"
export { Credential } from "./credential.js" export { Credential } from "./credential.js"
export { Event } from "./event.js" export { Event } from "./event.js"

View file

@ -0,0 +1,11 @@
export * as FileSystemV1 from "./filesystem.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "../event.js"
const Edited = ephemeral({
type: "file.edited",
schema: { file: Schema.String },
})
export const Event = { Edited, Definitions: inventory(Edited) }

View file

@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { import {
Agent, Agent,
Config,
FileSystem, FileSystem,
Form, Form,
Integration, Integration,
@ -11,6 +12,7 @@ import {
Workspace, Workspace,
} from "../src/index.js" } from "../src/index.js"
import { EventManifest } from "../src/event-manifest.js" import { EventManifest } from "../src/event-manifest.js"
import { FileSystemV1 } from "../src/filesystem-v1.js"
import { IdeEvent } from "../src/ide-event.js" import { IdeEvent } from "../src/ide-event.js"
import { McpEvent } from "../src/mcp-event.js" import { McpEvent } from "../src/mcp-event.js"
import { SessionEvent } from "../src/session-event.js" import { SessionEvent } from "../src/session-event.js"
@ -59,7 +61,9 @@ describe("public event manifest", () => {
expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated) expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated)
expect(Agent.Event.Definitions).toEqual([Agent.Event.Updated]) expect(Agent.Event.Definitions).toEqual([Agent.Event.Updated])
expect(Project.Event.Definitions).toEqual([Project.Event.Updated]) expect(Project.Event.Definitions).toEqual([Project.Event.Updated])
expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Edited]) expect(Config.Event.Definitions).toEqual([Config.Event.Updated])
expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Changed])
expect(FileSystemV1.Event.Definitions).toEqual([FileSystemV1.Event.Edited])
expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated]) expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated])
expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied]) expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied])
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled]) expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])

View file

@ -58,15 +58,15 @@ export type Event =
| EventSessionError | EventSessionError
| EventInstallationUpdated | EventInstallationUpdated
| EventInstallationUpdateAvailable | EventInstallationUpdateAvailable
| EventFileEdited | EventFilesystemChanged
| EventReferenceUpdated | EventReferenceUpdated
| EventPermissionV2Asked | EventPermissionV2Asked
| EventPermissionV2Replied | EventPermissionV2Replied
| EventPluginAdded | EventPluginAdded
| EventProjectDirectoriesUpdated | EventProjectDirectoriesUpdated
| EventCommandUpdated | EventCommandUpdated
| EventConfigUpdated
| EventSkillUpdated | EventSkillUpdated
| EventFileWatcherUpdated
| EventPtyCreated | EventPtyCreated
| EventPtyUpdated | EventPtyUpdated
| EventPtyExited | EventPtyExited
@ -91,6 +91,7 @@ export type Event =
| EventMcpToolsChanged | EventMcpToolsChanged
| EventMcpStatusChanged | EventMcpStatusChanged
| EventCommandExecuted | EventCommandExecuted
| EventFileEdited
| EventProjectUpdated | EventProjectUpdated
| EventSessionStatus | EventSessionStatus
| EventSessionIdle | EventSessionIdle
@ -1280,9 +1281,10 @@ export type GlobalEvent = {
} }
| { | {
id: string id: string
type: "file.edited" type: "filesystem.changed"
properties: { properties: {
file: string file: string
event: "add" | "change" | "unlink"
} }
} }
| { | {
@ -1339,17 +1341,16 @@ export type GlobalEvent = {
} }
| { | {
id: string id: string
type: "skill.updated" type: "config.updated"
properties: { properties: {
[key: string]: unknown [key: string]: unknown
} }
} }
| { | {
id: string id: string
type: "file.watcher.updated" type: "skill.updated"
properties: { properties: {
file: string [key: string]: unknown
event: "add" | "change" | "unlink"
} }
} }
| { | {
@ -1576,6 +1577,13 @@ export type GlobalEvent = {
messageID: string messageID: string
} }
} }
| {
id: string
type: "file.edited"
properties: {
file: string
}
}
| { | {
id: string id: string
type: "project.updated" type: "project.updated"
@ -2123,7 +2131,6 @@ export type Config = {
primary_tools?: Array<string> primary_tools?: Array<string>
continue_loop_on_deny?: boolean continue_loop_on_deny?: boolean
mcp_timeout?: number mcp_timeout?: number
policies?: Array<ConfigV2ExperimentalPolicy>
} }
} }
@ -3059,15 +3066,15 @@ export type V2Event =
| SessionError | SessionError
| InstallationUpdated | InstallationUpdated
| InstallationUpdateAvailable | InstallationUpdateAvailable
| FileEdited | FilesystemChanged
| ReferenceUpdated | ReferenceUpdated
| PermissionV2Asked | PermissionV2Asked
| PermissionV2Replied | PermissionV2Replied
| PluginAdded | PluginAdded
| ProjectDirectoriesUpdated | ProjectDirectoriesUpdated
| CommandUpdated | CommandUpdated
| ConfigUpdated
| SkillUpdated | SkillUpdated
| FileWatcherUpdated
| PtyCreated | PtyCreated
| PtyUpdated | PtyUpdated
| PtyExited | PtyExited
@ -3092,6 +3099,7 @@ export type V2Event =
| McpToolsChanged | McpToolsChanged
| McpStatusChanged | McpStatusChanged
| CommandExecuted | CommandExecuted
| FileEdited
| ProjectUpdated | ProjectUpdated
| SessionStatus2 | SessionStatus2
| SessionIdle | SessionIdle
@ -4167,14 +4175,6 @@ export type ConfigV2ReferenceLocal = {
hidden?: boolean hidden?: boolean
} }
export type PolicyEffect = "allow" | "deny"
export type ConfigV2ExperimentalPolicy = {
action: "provider.use"
effect: PolicyEffect
resource: string
}
export type ProjectDirectory = { export type ProjectDirectory = {
directory: string directory: string
strategy?: string strategy?: string
@ -5880,16 +5880,17 @@ export type InstallationUpdateAvailable = {
} }
} }
export type FileEdited = { export type FilesystemChanged = {
id: string id: string
created: number created: number
metadata?: { metadata?: {
[key: string]: unknown [key: string]: unknown
} }
type: "file.edited" type: "filesystem.changed"
location?: LocationRef location?: LocationRef
data: { data: {
file: string file: string
event: "add" | "change" | "unlink"
} }
} }
@ -5981,6 +5982,19 @@ export type CommandUpdated = {
} }
} }
export type ConfigUpdated = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "config.updated"
location?: LocationRef
data: {
[key: string]: unknown
}
}
export type SkillUpdated = { export type SkillUpdated = {
id: string id: string
created: number created: number
@ -5994,20 +6008,6 @@ export type SkillUpdated = {
} }
} }
export type FileWatcherUpdated = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "file.watcher.updated"
location?: LocationRef
data: {
file: string
event: "add" | "change" | "unlink"
}
}
export type PtyCreated = { export type PtyCreated = {
id: string id: string
created: number created: number
@ -6408,6 +6408,19 @@ export type CommandExecuted = {
} }
} }
export type FileEdited = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "file.edited"
location?: LocationRef
data: {
file: string
}
}
export type ProjectUpdated = { export type ProjectUpdated = {
id: string id: string
created: number created: number
@ -7209,11 +7222,12 @@ export type EventInstallationUpdateAvailable = {
} }
} }
export type EventFileEdited = { export type EventFilesystemChanged = {
id: string id: string
type: "file.edited" type: "filesystem.changed"
properties: { properties: {
file: string file: string
event: "add" | "change" | "unlink"
} }
} }
@ -7275,20 +7289,19 @@ export type EventCommandUpdated = {
} }
} }
export type EventSkillUpdated = { export type EventConfigUpdated = {
id: string id: string
type: "skill.updated" type: "config.updated"
properties: { properties: {
[key: string]: unknown [key: string]: unknown
} }
} }
export type EventFileWatcherUpdated = { export type EventSkillUpdated = {
id: string id: string
type: "file.watcher.updated" type: "skill.updated"
properties: { properties: {
file: string [key: string]: unknown
event: "add" | "change" | "unlink"
} }
} }
@ -7508,6 +7521,14 @@ export type EventCommandExecuted = {
} }
} }
export type EventFileEdited = {
id: string
type: "file.edited"
properties: {
file: string
}
}
export type EventProjectUpdated = { export type EventProjectUpdated = {
id: string id: string
type: "project.updated" type: "project.updated"
@ -10279,16 +10300,17 @@ export type SessionCompactionDelta2 = {
} }
} }
export type FileEdited2 = { export type FilesystemChanged2 = {
id: string id: string
created: number created: number
metadata?: { metadata?: {
[key: string]: unknown [key: string]: unknown
} }
type: "file.edited" type: "filesystem.changed"
location?: LocationRef2 location?: LocationRef2
data: { data: {
file: string file: string
event: "add" | "change" | "unlink"
} }
} }
@ -10384,6 +10406,21 @@ export type CommandUpdated2 = {
| Array<unknown> | Array<unknown>
} }
export type ConfigUpdated2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "config.updated"
location?: LocationRef2
data:
| {
[key: string]: unknown
}
| Array<unknown>
}
export type SkillUpdated2 = { export type SkillUpdated2 = {
id: string id: string
created: number created: number
@ -10399,20 +10436,6 @@ export type SkillUpdated2 = {
| Array<unknown> | Array<unknown>
} }
export type FileWatcherUpdated2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "file.watcher.updated"
location?: LocationRef2
data: {
file: string
event: "add" | "change" | "unlink"
}
}
export type PtyV2 = { export type PtyV2 = {
id: string id: string
title: string title: string
@ -11162,15 +11185,15 @@ export type V2EventV2 =
| SessionRevertStaged2 | SessionRevertStaged2
| SessionRevertCleared2 | SessionRevertCleared2
| SessionRevertCommitted2 | SessionRevertCommitted2
| FileEdited2 | FilesystemChanged2
| ReferenceUpdated2 | ReferenceUpdated2
| PermissionV2Asked2 | PermissionV2Asked2
| PermissionV2Replied2 | PermissionV2Replied2
| PluginAdded2 | PluginAdded2
| ProjectDirectoriesUpdated2 | ProjectDirectoriesUpdated2
| CommandUpdated2 | CommandUpdated2
| ConfigUpdated2
| SkillUpdated2 | SkillUpdated2
| FileWatcherUpdated2
| PtyCreated2 | PtyCreated2
| PtyUpdated2 | PtyUpdated2
| PtyExited2 | PtyExited2