mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 22:33:34 +00:00
feat(core): reload config on filesystem changes
This commit is contained in:
parent
b04d8d53e6
commit
c9b24ef027
48 changed files with 612 additions and 526 deletions
|
|
@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
|
|||
const refresh: string[] = []
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/new.ts",
|
||||
event: "add",
|
||||
|
|
@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
|
|||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/open.ts",
|
||||
event: "change",
|
||||
|
|
@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
|
|||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src",
|
||||
event: "change",
|
||||
|
|
@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
|
|||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/file.ts",
|
||||
event: "change",
|
||||
|
|
@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
|
|||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: ".git/index.lock",
|
||||
event: "change",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ type WatcherOps = {
|
|||
}
|
||||
|
||||
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||
if (event.type !== "file.watcher.updated") return
|
||||
if (event.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
||||
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
||||
|
|
|
|||
|
|
@ -820,7 +820,7 @@ export default function Page() {
|
|||
)
|
||||
|
||||
const stopVcs = sdk().event.listen((evt) => {
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
if (evt.details.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
? (evt.details.properties as Record<string, unknown>)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export type {
|
|||
AppApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
EventApi,
|
||||
IntegrationApi,
|
||||
ModelApi,
|
||||
PluginApi,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type {
|
||||
AgentApi as EffectAgentApi,
|
||||
CommandApi as EffectCommandApi,
|
||||
EventApi as EffectEventApi,
|
||||
IntegrationApi as EffectIntegrationApi,
|
||||
ModelApi as EffectModelApi,
|
||||
PluginApi as EffectPluginApi,
|
||||
|
|
@ -25,6 +26,7 @@ type PromisifyApi<Api> = {
|
|||
|
||||
export type AgentApi = PromisifyApi<EffectAgentApi<unknown>>
|
||||
export type CommandApi = PromisifyApi<EffectCommandApi<unknown>>
|
||||
export type EventApi = PromisifyApi<EffectEventApi<unknown>>
|
||||
export type IntegrationApi = PromisifyApi<EffectIntegrationApi<unknown>>
|
||||
export type ModelApi = PromisifyApi<EffectModelApi<unknown>>
|
||||
export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
|
||||
|
|
|
|||
|
|
@ -4923,9 +4923,9 @@ export type EventSubscribeOutput =
|
|||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "file.edited"
|
||||
readonly type: "filesystem.changed"
|
||||
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
|
||||
|
|
@ -4991,7 +4991,7 @@ export type EventSubscribeOutput =
|
|||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "skill.updated"
|
||||
readonly type: "config.updated"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {}
|
||||
}
|
||||
|
|
@ -4999,9 +4999,9 @@ export type EventSubscribeOutput =
|
|||
readonly id: string
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "file.watcher.updated"
|
||||
readonly type: "skill.updated"
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" }
|
||||
readonly data: {}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export type {
|
|||
AgentApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
EventApi,
|
||||
IntegrationApi,
|
||||
ModelApi,
|
||||
PluginApi,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
export * as Catalog from "./catalog"
|
||||
|
||||
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 { ModelV2 } from "./model"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { EventV2 } from "./event"
|
||||
import { Policy } from "./policy"
|
||||
import { State } from "./state"
|
||||
import { Integration } from "./integration"
|
||||
|
||||
|
|
@ -17,8 +16,6 @@ export type ProviderRecord = {
|
|||
|
||||
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
|
||||
|
||||
export const PolicyActions = Schema.Literals(["provider.use"])
|
||||
|
||||
export const Event = Catalog.Event
|
||||
|
||||
type Data = {
|
||||
|
|
@ -65,7 +62,6 @@ const layer = Layer.effect(
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const policy = yield* Policy.Service
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
|
||||
|
|
@ -159,13 +155,6 @@ const layer = Layer.effect(
|
|||
return result
|
||||
},
|
||||
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, {})
|
||||
}),
|
||||
})
|
||||
|
|
@ -294,4 +283,4 @@ const layer = Layer.effect(
|
|||
|
||||
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] })
|
||||
|
|
|
|||
|
|
@ -3,18 +3,19 @@ export * as Config from "./config"
|
|||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
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 { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { EventV2 } from "./event"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { Policy } from "./policy"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigAgent } from "./config/agent"
|
||||
import { ConfigAttachments } from "./config/attachments"
|
||||
import { ConfigCompaction } from "./config/compaction"
|
||||
import { ConfigCommand } from "./config/command"
|
||||
import { ConfigExperimental } from "./config/experimental"
|
||||
import { ConfigFormatter } from "./config/formatter"
|
||||
import { ConfigLSP } from "./config/lsp"
|
||||
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({
|
||||
description: "Ordered external plugin packages to load",
|
||||
}),
|
||||
experimental: ConfigExperimental.Experimental.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 global = yield* Global.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 decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||
|
|
@ -170,45 +171,78 @@ const layer = Layer.effect(
|
|||
]
|
||||
})
|
||||
|
||||
const globalDirectory = AbsolutePath.make(global.config)
|
||||
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
|
||||
// Read configuration once when this location opens. Later calls reuse these
|
||||
// values until the location is reopened.
|
||||
const discovered = locationIsGlobal
|
||||
? []
|
||||
: yield* fs
|
||||
.up({
|
||||
targets: [".opencode", ...names.toReversed()],
|
||||
start: location.directory,
|
||||
stop: location.project.directory,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
const directories = [
|
||||
globalDirectory,
|
||||
...discovered
|
||||
.filter((item) => path.basename(item) === ".opencode")
|
||||
.toReversed()
|
||||
.map((directory) => AbsolutePath.make(directory)),
|
||||
const discover = Effect.fn("Config.discover")(function* () {
|
||||
const globalDirectory = AbsolutePath.make(global.config)
|
||||
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
|
||||
const discovered = locationIsGlobal
|
||||
? []
|
||||
: yield* fs
|
||||
.up({
|
||||
targets: [".opencode", ...names.toReversed()],
|
||||
start: location.directory,
|
||||
stop: location.project.directory,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
const directories = [
|
||||
globalDirectory,
|
||||
...discovered
|
||||
.filter((item) => path.basename(item) === ".opencode")
|
||||
.toReversed()
|
||||
.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.
|
||||
// Search starts nearby, so reverse the results before applying them.
|
||||
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)
|
||||
// Apply general settings first and more specific settings last:
|
||||
// global config, project files, then `.opencode` files.
|
||||
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
|
||||
// Rules use the opposite order so a user-global rule can override a
|
||||
// repository rule. Statement order inside each file stays unchanged.
|
||||
yield* policy.load(
|
||||
configs
|
||||
.filter((config): config is Document => config.type === "document")
|
||||
.toReversed()
|
||||
.flatMap((config) => config.info.experimental?.policies ?? []),
|
||||
const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) {
|
||||
const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target]))
|
||||
for (const [key, stop] of subscriptions) {
|
||||
if (next.has(key)) continue
|
||||
yield* stop
|
||||
subscriptions.delete(key)
|
||||
}
|
||||
for (const [key, target] of next) {
|
||||
if (subscriptions.has(key)) continue
|
||||
const fiber = yield* watcher.subscribe(target).pipe(
|
||||
Stream.runForEach((update) => PubSub.publish(updates, update)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
subscriptions.set(key, Fiber.interrupt(fiber))
|
||||
}
|
||||
})
|
||||
|
||||
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({
|
||||
entries: Effect.fn("Config.entries")(function* () {
|
||||
|
|
@ -221,5 +255,5 @@ const layer = Layer.effect(
|
|||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Global.node, Location.node, Policy.node],
|
||||
deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}) {}
|
||||
|
|
@ -2,7 +2,7 @@ export * as ConfigCommandPlugin from "./command"
|
|||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { CommandV2 } from "../../command"
|
||||
import { Config } from "../../config"
|
||||
import { FSUtil } from "../../fs-util"
|
||||
|
|
@ -17,16 +17,19 @@ export const Plugin = define({
|
|||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
||||
return loadDirectory(fs, entry.path).pipe(
|
||||
Effect.map((commands) => [
|
||||
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
|
||||
]),
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
||||
return loadDirectory(fs, entry.path).pipe(
|
||||
Effect.map((commands) => [
|
||||
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
|
||||
]),
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: yield* load() }
|
||||
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 ?? {})) {
|
||||
draft.update(name, (item) => {
|
||||
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 }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
|
|||
83
packages/core/src/filesystem/location-watcher.ts
Normal file
83
packages/core/src/filesystem/location-watcher.ts
Normal 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],
|
||||
})
|
||||
|
|
@ -3,26 +3,20 @@ export * as Watcher from "./watcher"
|
|||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Cause, Context, Effect, Layer } from "effect"
|
||||
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Config } from "../config"
|
||||
import { EventV2 } from "../event"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { Cause, Context, Effect, Layer, PubSub, Scope, Stream } from "effect"
|
||||
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Ignore } from "./ignore"
|
||||
import { Protected } from "./protected"
|
||||
import { watch as watchFileSystem } from "node:fs"
|
||||
import path from "path"
|
||||
|
||||
declare const OPENCODE_LIBC: string | undefined
|
||||
|
||||
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 => {
|
||||
try {
|
||||
|
|
@ -42,108 +36,132 @@ function getBackend() {
|
|||
if (process.platform === "linux") return "inotify"
|
||||
}
|
||||
|
||||
function protecteds(dir: string) {
|
||||
return Protected.paths().filter((item) => {
|
||||
const relative = path.relative(dir, item)
|
||||
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
})
|
||||
export const hasNativeBinding = () => !!watcher()
|
||||
export type Update = ParcelWatcher.Event
|
||||
|
||||
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 interface Interface {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileWatcher") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Watcher") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
if (Flag.OPENCODE_DISABLE_FILEWATCHER) return Service.of({})
|
||||
|
||||
const backend = getBackend()
|
||||
const location = yield* Location.Service
|
||||
if (path.resolve(location.directory) === path.resolve(os.homedir())) {
|
||||
yield* Effect.logInfo("watcher skipped home directory", { directory: location.directory })
|
||||
return Service.of({})
|
||||
}
|
||||
if (!backend) {
|
||||
yield* Effect.logError("watcher backend not supported", {
|
||||
directory: location.directory,
|
||||
platform: process.platform,
|
||||
})
|
||||
return Service.of({})
|
||||
const native = watcher()
|
||||
if (Flag.OPENCODE_DISABLE_FILEWATCHER) {
|
||||
return Service.of({ subscribe: () => Stream.empty })
|
||||
}
|
||||
|
||||
const w = watcher()
|
||||
if (!w) return Service.of({})
|
||||
|
||||
yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend })
|
||||
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" }))
|
||||
}
|
||||
type Entry = {
|
||||
readonly pubsub: PubSub.PubSub<Update>
|
||||
readonly subscription: { readonly unsubscribe: () => Promise<void> }
|
||||
refs: number
|
||||
}
|
||||
const entries = new Map<string, Entry>()
|
||||
const locks = KeyedMutex.makeUnsafe<string>()
|
||||
|
||||
const subscribe = (directory: string, ignore: string[]) => {
|
||||
const pending = w.subscribe(directory, callback, { ignore, backend })
|
||||
return Effect.promise(() => pending).pipe(
|
||||
Effect.tap((subscription) =>
|
||||
Effect.sync(() => subscriptions.push(subscription)).pipe(
|
||||
Effect.andThen(Effect.logInfo("watcher subscribed", { directory, backend, ignores: ignore.length })),
|
||||
),
|
||||
),
|
||||
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) })
|
||||
const acquire = Effect.fn("Watcher.acquire")(function* (input: WatchInput) {
|
||||
const scope = yield* Scope.Scope
|
||||
const target = path.resolve(input.path)
|
||||
const directory = input.type === "file" ? path.dirname(target) : target
|
||||
const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
|
||||
const id = JSON.stringify([input.type, target, ignore])
|
||||
const pubsub = yield* locks.withLock(id)(
|
||||
Effect.gen(function* () {
|
||||
const existing = entries.get(id)
|
||||
if (existing) {
|
||||
existing.refs++
|
||||
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
|
||||
const config = (yield* configService.entries())
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||
yield* Effect.forkScoped(
|
||||
subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(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* 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({})),
|
||||
yield* Scope.addFinalizer(
|
||||
scope,
|
||||
locks.withLock(id)(
|
||||
Effect.gen(function* () {
|
||||
const entry = entries.get(id)
|
||||
if (!entry) return
|
||||
entry.refs--
|
||||
if (entry.refs > 0) return
|
||||
entries.delete(id)
|
||||
yield* Effect.promise(() => entry.subscription.unsubscribe()).pipe(Effect.ignore)
|
||||
yield* PubSub.shutdown(entry.pubsub)
|
||||
yield* Effect.logInfo("watcher stopped", { path: target, type: input.type })
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
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({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
|
||||
})
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
||||
function subscribeDirectory(
|
||||
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))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { FileSystem } from "./filesystem"
|
|||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Generate } from "./generate"
|
||||
import { Form } from "./form"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher"
|
||||
import { Image } from "./image"
|
||||
import { Integration } from "./integration"
|
||||
import { Location } from "./location"
|
||||
|
|
@ -21,7 +21,6 @@ import { MCP } from "./mcp/index"
|
|||
import { PermissionV2 } from "./permission"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { PluginInternal } from "./plugin/internal"
|
||||
import { Policy } from "./policy"
|
||||
import { ProjectCopy } from "./project/copy"
|
||||
import { Pty } from "./pty"
|
||||
import { QuestionV2 } from "./question"
|
||||
|
|
@ -50,7 +49,6 @@ export { LocationServiceMap } from "./location-service-map"
|
|||
|
||||
const locationServiceNodes = [
|
||||
Location.node,
|
||||
Policy.node,
|
||||
Config.node,
|
||||
AgentV2.node,
|
||||
CommandV2.node,
|
||||
|
|
@ -64,7 +62,7 @@ const locationServiceNodes = [
|
|||
ProjectCopy.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
Watcher.node,
|
||||
LocationWatcher.node,
|
||||
Pty.node,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
export * as PluginHost from "./host"
|
||||
|
||||
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 { AISDK } from "../aisdk"
|
||||
import { Catalog } from "../catalog"
|
||||
import { CommandV2 } from "../command"
|
||||
import { Credential } from "../credential"
|
||||
import { EventV2 } from "../event"
|
||||
import { Integration } from "../integration"
|
||||
import { Location } from "../location"
|
||||
import { ModelV2 } from "../model"
|
||||
|
|
@ -21,12 +23,14 @@ import { ToolHooks } from "../tool/hooks"
|
|||
import { WorkspaceV2 } from "../workspace"
|
||||
|
||||
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) {
|
||||
const agents = yield* AgentV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const integration = yield* Integration.Service
|
||||
const location = yield* Location.Service
|
||||
const reference = yield* Reference.Service
|
||||
|
|
@ -155,6 +159,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
callback(draft)
|
||||
}),
|
||||
},
|
||||
event: {
|
||||
subscribe: () => events.live().pipe(Stream.filter(isEvent)),
|
||||
},
|
||||
integration: {
|
||||
list: () => response(integration.list()),
|
||||
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export * as PluginPromise from "./promise"
|
|||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
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 Registration = { readonly dispose: () => Promise<void> }
|
||||
|
|
@ -73,6 +73,9 @@ export function fromPromise(plugin: Plugin) {
|
|||
transform: transform(host.command),
|
||||
reload: () => run(host.command.reload()),
|
||||
},
|
||||
event: {
|
||||
subscribe: () => Stream.toAsyncIterable(host.event.subscribe()),
|
||||
},
|
||||
integration: {
|
||||
list: (input) => run(host.integration.list(input)),
|
||||
get: (input) => run(host.integration.get(input)),
|
||||
|
|
|
|||
|
|
@ -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] })
|
||||
|
|
@ -3,7 +3,7 @@ export * as SkillV2 from "./skill"
|
|||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
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 { AgentV2 } from "./agent"
|
||||
import { ConfigMarkdown } from "./config/markdown"
|
||||
|
|
@ -153,7 +153,7 @@ const layer = Layer.effect(
|
|||
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)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ export * as ConfigV1 from "./config"
|
|||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema"
|
||||
import { ConfigExperimental } from "../../config/experimental"
|
||||
import { ConfigReference } from "../../config/reference"
|
||||
import { ConfigAgentV1 } from "./agent"
|
||||
import { ConfigAttachmentV1 } from "./attachment"
|
||||
|
|
@ -179,9 +178,6 @@ export const Info = Schema.Struct({
|
|||
mcp_timeout: Schema.optional(PositiveInt).annotate({
|
||||
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" })
|
||||
|
|
|
|||
|
|
@ -78,7 +78,6 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
|||
plugins: info.plugin?.map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
),
|
||||
experimental: info.experimental?.policies && { policies: info.experimental.policies },
|
||||
providers: providers(info.provider),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
|
|
@ -24,7 +23,7 @@ const locationLayer = Layer.succeed(
|
|||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
||||
)
|
||||
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]],
|
||||
)
|
||||
const it = testEffect(catalogLayer)
|
||||
|
|
@ -333,21 +332,4 @@ describe("CatalogV2", () => {
|
|||
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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
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 { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
|
@ -19,7 +21,7 @@ import { testEffect } from "../lib/effect"
|
|||
import { host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([CommandV2.node, EventV2.node, FSUtil.node]), [
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Config.node, emptyConfigLayer],
|
||||
[Location.node, testLocationLayer],
|
||||
|
|
@ -53,6 +55,9 @@ Review files`,
|
|||
})
|
||||
|
||||
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(
|
||||
host({
|
||||
command: {
|
||||
|
|
@ -60,6 +65,7 @@ Review files`,
|
|||
transform: command.transform,
|
||||
reload: command.reload,
|
||||
},
|
||||
event: { subscribe: () => Stream.fromPubSub(updates) },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
|
|
@ -93,6 +99,15 @@ Review files`,
|
|||
CommandV2.Info.make({ name: "empty", template: "" }),
|
||||
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")
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
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 { Config } from "@opencode-ai/core/config"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
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 { Location } from "@opencode-ai/core/location"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
|
|
@ -26,6 +28,7 @@ function testLayer(
|
|||
globalDirectory = path.join(directory, "global"),
|
||||
projectDirectory = directory,
|
||||
vcs?: Project.Vcs,
|
||||
watcher?: Layer.Layer<Watcher.Service>,
|
||||
) {
|
||||
const locationLayer = Layer.succeed(
|
||||
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],
|
||||
[Global.node, Global.layerWith({ config: globalDirectory })],
|
||||
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
|
||||
])
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +56,52 @@ const provider = {
|
|||
}
|
||||
|
||||
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", () =>
|
||||
Effect.sync(() => {
|
||||
const entries = [
|
||||
|
|
@ -274,7 +324,6 @@ describe("Config", () => {
|
|||
const file = path.join(tmp.path, "opencode.json")
|
||||
const contents = JSON.stringify({
|
||||
shell: "/bin/zsh",
|
||||
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
|
||||
providers: { local: provider },
|
||||
})
|
||||
yield* Effect.promise(() => fs.writeFile(file, contents))
|
||||
|
|
@ -285,11 +334,6 @@ describe("Config", () => {
|
|||
|
||||
expect(documents[0]?.info.$schema).toBeUndefined()
|
||||
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)
|
||||
}).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", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
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 { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
|
|
@ -34,7 +36,7 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
|
|||
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
|
||||
)
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(Watcher.node, [
|
||||
AppNodeBuilder.build(LocationWatcher.node, [
|
||||
[Config.node, configLayer],
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
|
|
@ -66,7 +68,7 @@ function wait(check: (event: WatcherEvent) => boolean) {
|
|||
return Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
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) => {
|
||||
if (!check(event.data)) return Effect.void
|
||||
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", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
|
|
|
|||
|
|
@ -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.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
|
||||
).pipe(
|
||||
Effect.flatMap(([blocked, allowed]) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(blocked.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "test" }] },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const update = (directory: string) =>
|
||||
const update = (directory: string, providerID: ProviderV2.ID) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Reference.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
|
||||
// Tool plugins register during the forked PluginInternal boot; wait for
|
||||
// every expected tool rather than relying on batch ordering.
|
||||
|
|
@ -103,8 +94,11 @@ describe("LocationServiceMap", () => {
|
|||
),
|
||||
)
|
||||
|
||||
const blockedState = yield* update(blocked.path)
|
||||
expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false)
|
||||
const blockedID = ProviderV2.ID.make("blocked-location")
|
||||
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([
|
||||
"edit",
|
||||
"glob",
|
||||
|
|
@ -119,8 +113,9 @@ describe("LocationServiceMap", () => {
|
|||
"websearch",
|
||||
"write",
|
||||
])
|
||||
const allowedState = yield* update(allowed.path)
|
||||
expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true)
|
||||
const allowedState = yield* update(allowed.path, allowedID)
|
||||
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([
|
||||
"edit",
|
||||
"glob",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
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 { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
|
|
@ -14,6 +17,24 @@ import { PluginTestLayer } from "./plugin/fixture"
|
|||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Integration } from "@opencode-ai/core/integration"
|
|||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
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">>
|
||||
|
||||
|
|
@ -39,6 +39,9 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
transform: () => Effect.die("unused command.transform"),
|
||||
reload: () => Effect.die("unused command.reload"),
|
||||
},
|
||||
event: overrides.event ?? {
|
||||
subscribe: () => Stream.empty,
|
||||
},
|
||||
integration: overrides.integration ?? {
|
||||
list: () => Effect.die("unused integration.list"),
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -10,7 +10,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
|||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
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 { testEffect } from "./lib/effect"
|
||||
|
||||
|
|
@ -206,7 +206,7 @@ metadata:
|
|||
waitForSkillUpdate(),
|
||||
({ deferred }) =>
|
||||
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")),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { LSP } from "@/lsp/lsp"
|
|||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import DESCRIPTION from "./apply_patch.txt"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FileSystemV1 } from "@opencode-ai/schema/filesystem-v1"
|
||||
import { Format } from "../format"
|
||||
import * as Bom from "@/util/bom"
|
||||
|
||||
|
|
@ -253,7 +254,7 @@ export const ApplyPatchTool = Tool.define(
|
|||
if (yield* format.file(edited)) {
|
||||
yield* Bom.syncFile(afs, edited, change.bom)
|
||||
}
|
||||
yield* events.publish(FileSystem.Event.Edited, { file: edited })
|
||||
yield* events.publish(FileSystemV1.Event.Edited, { file: edited })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { LSP } from "@/lsp/lsp"
|
|||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import DESCRIPTION from "./edit.txt"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FileSystemV1 } from "@opencode-ai/schema/filesystem-v1"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Format } from "../format"
|
||||
|
|
@ -112,7 +113,7 @@ export const EditTool = Tool.define(
|
|||
if (yield* format.file(filePath)) {
|
||||
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, {
|
||||
file: filePath,
|
||||
event: "add",
|
||||
|
|
@ -156,7 +157,7 @@ export const EditTool = Tool.define(
|
|||
if (yield* format.file(filePath)) {
|
||||
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, {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { createTwoFilesPatch } from "diff"
|
|||
import DESCRIPTION from "./write.txt"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FileSystemV1 } from "@opencode-ai/schema/filesystem-v1"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Format } from "../format"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
|
|
@ -65,7 +66,7 @@ export const WriteTool = Tool.define(
|
|||
if (yield* format.file(filepath)) {
|
||||
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, {
|
||||
file: filepath,
|
||||
event: exists ? "change" : "add",
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ describe("v2 location HttpApi", () => {
|
|||
expect(
|
||||
Schema.decodeUnknownSync(Event)({
|
||||
id: "evt_test",
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
location: { directory: "/tmp/project" },
|
||||
data: {},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { AgentHooks } from "./agent.js"
|
|||
import type { AISDKHooks } from "./aisdk.js"
|
||||
import type { CatalogHooks } from "./catalog.js"
|
||||
import type { CommandHooks } from "./command.js"
|
||||
import type { EventHooks } from "./event.js"
|
||||
import type { IntegrationHooks } from "./integration.js"
|
||||
import type { PluginDomain } from "./plugin.js"
|
||||
import type { ReferenceHooks } from "./reference.js"
|
||||
|
|
@ -16,6 +17,7 @@ export interface PluginContext {
|
|||
readonly aisdk: AISDKHooks
|
||||
readonly catalog: CatalogHooks
|
||||
readonly command: CommandHooks
|
||||
readonly event: EventHooks
|
||||
readonly integration: IntegrationHooks
|
||||
readonly plugin: PluginDomain
|
||||
readonly reference: ReferenceHooks
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
import type { Event as SDKEvent } from "@opencode-ai/sdk/v2/types"
|
||||
import type { Stream } from "effect"
|
||||
import type { EventApi } from "@opencode-ai/client/effect/api"
|
||||
|
||||
export type EventMap = {
|
||||
[Item in SDKEvent as Item["type"]]: Item
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
subscribe<Type extends keyof EventMap>(type: Type): Stream.Stream<EventMap[Type]>
|
||||
}
|
||||
export interface EventHooks extends Pick<EventApi<unknown>, "subscribe"> {}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export type { AgentDraft, AgentHooks } from "./agent.js"
|
|||
export type { AISDKHooks } from "./aisdk.js"
|
||||
export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js"
|
||||
export type { CommandDraft, CommandHooks } from "./command.js"
|
||||
export type { EventHooks } from "./event.js"
|
||||
export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
|
||||
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
|
||||
export type { SkillDraft, SkillHooks } from "./skill.js"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { AgentHooks } from "./agent.js"
|
|||
import type { AISDKHooks } from "./aisdk.js"
|
||||
import type { CatalogHooks } from "./catalog.js"
|
||||
import type { CommandHooks } from "./command.js"
|
||||
import type { EventHooks } from "./event.js"
|
||||
import type { IntegrationHooks } from "./integration.js"
|
||||
import type { PluginDomain } from "./plugin.js"
|
||||
import type { ReferenceHooks } from "./reference.js"
|
||||
|
|
@ -15,6 +16,7 @@ export interface PluginContext {
|
|||
readonly aisdk: AISDKHooks
|
||||
readonly catalog: CatalogHooks
|
||||
readonly command: CommandHooks
|
||||
readonly event: EventHooks
|
||||
readonly integration: IntegrationHooks
|
||||
readonly plugin: PluginDomain
|
||||
readonly reference: ReferenceHooks
|
||||
|
|
|
|||
3
packages/plugin/src/v2/promise/event.ts
Normal file
3
packages/plugin/src/v2/promise/event.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import type { EventApi } from "@opencode-ai/client/promise/api"
|
||||
|
||||
export interface EventHooks extends Pick<EventApi, "subscribe"> {}
|
||||
|
|
@ -6,6 +6,7 @@ export type { AgentDraft, AgentHooks } from "./agent.js"
|
|||
export type { AISDKHooks } from "./aisdk.js"
|
||||
export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js"
|
||||
export type { CommandDraft, CommandHooks } from "./command.js"
|
||||
export type { EventHooks } from "./event.js"
|
||||
export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
|
||||
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
|
||||
export type { SessionHooks } from "./runtime.js"
|
||||
|
|
|
|||
10
packages/schema/src/config.ts
Normal file
10
packages/schema/src/config.ts
Normal 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) }
|
||||
|
|
@ -3,10 +3,11 @@ export * as EventManifest from "./event-manifest.js"
|
|||
import { Agent } from "./agent.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Config } from "./config.js"
|
||||
import { Durable } from "./durable-event-manifest.js"
|
||||
import { Event } from "./event.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import { FileSystemWatcher } from "./filesystem-watcher.js"
|
||||
import { FileSystemV1 } from "./filesystem-v1.js"
|
||||
import { Form } from "./form.js"
|
||||
import { InstallationEvent } from "./installation-event.js"
|
||||
import { Integration } from "./integration.js"
|
||||
|
|
@ -60,8 +61,8 @@ const featureDefinitions = Event.inventory(
|
|||
...Plugin.Event.Definitions,
|
||||
...ProjectDirectories.Event.Definitions,
|
||||
...Command.Event.Definitions,
|
||||
...Config.Event.Definitions,
|
||||
...Skill.Event.Definitions,
|
||||
...FileSystemWatcher.Event.Definitions,
|
||||
...Pty.Event.Definitions,
|
||||
...Shell.Event.Definitions,
|
||||
...Question.Event.Definitions,
|
||||
|
|
@ -97,6 +98,7 @@ export const Definitions = Event.inventory(
|
|||
...TuiEvent.Definitions,
|
||||
...McpEvent.Definitions,
|
||||
...LegacyEvent.Definitions,
|
||||
...FileSystemV1.Event.Definitions,
|
||||
...Project.Event.Definitions,
|
||||
...SessionStatusEvent.Definitions,
|
||||
...QuestionV1.Event.Definitions,
|
||||
|
|
|
|||
1
packages/schema/src/filesystem-v1.ts
Normal file
1
packages/schema/src/filesystem-v1.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./v1/filesystem.js"
|
||||
|
|
@ -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) }
|
||||
|
|
@ -5,11 +5,14 @@ import { optional } from "./schema.js"
|
|||
import { ephemeral, inventory } from "./event.js"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
|
||||
|
||||
const Edited = ephemeral({
|
||||
type: "file.edited",
|
||||
schema: { file: Schema.String },
|
||||
const Changed = ephemeral({
|
||||
type: "filesystem.changed",
|
||||
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 const Entry = Schema.Struct({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export { Agent } from "./agent.js"
|
||||
export { Command } from "./command.js"
|
||||
export { Config } from "./config.js"
|
||||
export { Connection } from "./connection.js"
|
||||
export { Credential } from "./credential.js"
|
||||
export { Event } from "./event.js"
|
||||
|
|
|
|||
11
packages/schema/src/v1/filesystem.ts
Normal file
11
packages/schema/src/v1/filesystem.ts
Normal 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) }
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
Agent,
|
||||
Config,
|
||||
FileSystem,
|
||||
Form,
|
||||
Integration,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
Workspace,
|
||||
} from "../src/index.js"
|
||||
import { EventManifest } from "../src/event-manifest.js"
|
||||
import { FileSystemV1 } from "../src/filesystem-v1.js"
|
||||
import { IdeEvent } from "../src/ide-event.js"
|
||||
import { McpEvent } from "../src/mcp-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(Agent.Event.Definitions).toEqual([Agent.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(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied])
|
||||
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
|
||||
|
|
|
|||
|
|
@ -58,15 +58,15 @@ export type Event =
|
|||
| EventSessionError
|
||||
| EventInstallationUpdated
|
||||
| EventInstallationUpdateAvailable
|
||||
| EventFileEdited
|
||||
| EventFilesystemChanged
|
||||
| EventReferenceUpdated
|
||||
| EventPermissionV2Asked
|
||||
| EventPermissionV2Replied
|
||||
| EventPluginAdded
|
||||
| EventProjectDirectoriesUpdated
|
||||
| EventCommandUpdated
|
||||
| EventConfigUpdated
|
||||
| EventSkillUpdated
|
||||
| EventFileWatcherUpdated
|
||||
| EventPtyCreated
|
||||
| EventPtyUpdated
|
||||
| EventPtyExited
|
||||
|
|
@ -91,6 +91,7 @@ export type Event =
|
|||
| EventMcpToolsChanged
|
||||
| EventMcpStatusChanged
|
||||
| EventCommandExecuted
|
||||
| EventFileEdited
|
||||
| EventProjectUpdated
|
||||
| EventSessionStatus
|
||||
| EventSessionIdle
|
||||
|
|
@ -1280,9 +1281,10 @@ export type GlobalEvent = {
|
|||
}
|
||||
| {
|
||||
id: string
|
||||
type: "file.edited"
|
||||
type: "filesystem.changed"
|
||||
properties: {
|
||||
file: string
|
||||
event: "add" | "change" | "unlink"
|
||||
}
|
||||
}
|
||||
| {
|
||||
|
|
@ -1339,17 +1341,16 @@ export type GlobalEvent = {
|
|||
}
|
||||
| {
|
||||
id: string
|
||||
type: "skill.updated"
|
||||
type: "config.updated"
|
||||
properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "file.watcher.updated"
|
||||
type: "skill.updated"
|
||||
properties: {
|
||||
file: string
|
||||
event: "add" | "change" | "unlink"
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
| {
|
||||
|
|
@ -1576,6 +1577,13 @@ export type GlobalEvent = {
|
|||
messageID: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "file.edited"
|
||||
properties: {
|
||||
file: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "project.updated"
|
||||
|
|
@ -2123,7 +2131,6 @@ export type Config = {
|
|||
primary_tools?: Array<string>
|
||||
continue_loop_on_deny?: boolean
|
||||
mcp_timeout?: number
|
||||
policies?: Array<ConfigV2ExperimentalPolicy>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3059,15 +3066,15 @@ export type V2Event =
|
|||
| SessionError
|
||||
| InstallationUpdated
|
||||
| InstallationUpdateAvailable
|
||||
| FileEdited
|
||||
| FilesystemChanged
|
||||
| ReferenceUpdated
|
||||
| PermissionV2Asked
|
||||
| PermissionV2Replied
|
||||
| PluginAdded
|
||||
| ProjectDirectoriesUpdated
|
||||
| CommandUpdated
|
||||
| ConfigUpdated
|
||||
| SkillUpdated
|
||||
| FileWatcherUpdated
|
||||
| PtyCreated
|
||||
| PtyUpdated
|
||||
| PtyExited
|
||||
|
|
@ -3092,6 +3099,7 @@ export type V2Event =
|
|||
| McpToolsChanged
|
||||
| McpStatusChanged
|
||||
| CommandExecuted
|
||||
| FileEdited
|
||||
| ProjectUpdated
|
||||
| SessionStatus2
|
||||
| SessionIdle
|
||||
|
|
@ -4167,14 +4175,6 @@ export type ConfigV2ReferenceLocal = {
|
|||
hidden?: boolean
|
||||
}
|
||||
|
||||
export type PolicyEffect = "allow" | "deny"
|
||||
|
||||
export type ConfigV2ExperimentalPolicy = {
|
||||
action: "provider.use"
|
||||
effect: PolicyEffect
|
||||
resource: string
|
||||
}
|
||||
|
||||
export type ProjectDirectory = {
|
||||
directory: string
|
||||
strategy?: string
|
||||
|
|
@ -5880,16 +5880,17 @@ export type InstallationUpdateAvailable = {
|
|||
}
|
||||
}
|
||||
|
||||
export type FileEdited = {
|
||||
export type FilesystemChanged = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "file.edited"
|
||||
type: "filesystem.changed"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
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 = {
|
||||
id: string
|
||||
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 = {
|
||||
id: string
|
||||
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 = {
|
||||
id: string
|
||||
created: number
|
||||
|
|
@ -7209,11 +7222,12 @@ export type EventInstallationUpdateAvailable = {
|
|||
}
|
||||
}
|
||||
|
||||
export type EventFileEdited = {
|
||||
export type EventFilesystemChanged = {
|
||||
id: string
|
||||
type: "file.edited"
|
||||
type: "filesystem.changed"
|
||||
properties: {
|
||||
file: string
|
||||
event: "add" | "change" | "unlink"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -7275,20 +7289,19 @@ export type EventCommandUpdated = {
|
|||
}
|
||||
}
|
||||
|
||||
export type EventSkillUpdated = {
|
||||
export type EventConfigUpdated = {
|
||||
id: string
|
||||
type: "skill.updated"
|
||||
type: "config.updated"
|
||||
properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileWatcherUpdated = {
|
||||
export type EventSkillUpdated = {
|
||||
id: string
|
||||
type: "file.watcher.updated"
|
||||
type: "skill.updated"
|
||||
properties: {
|
||||
file: string
|
||||
event: "add" | "change" | "unlink"
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -7508,6 +7521,14 @@ export type EventCommandExecuted = {
|
|||
}
|
||||
}
|
||||
|
||||
export type EventFileEdited = {
|
||||
id: string
|
||||
type: "file.edited"
|
||||
properties: {
|
||||
file: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventProjectUpdated = {
|
||||
id: string
|
||||
type: "project.updated"
|
||||
|
|
@ -10279,16 +10300,17 @@ export type SessionCompactionDelta2 = {
|
|||
}
|
||||
}
|
||||
|
||||
export type FileEdited2 = {
|
||||
export type FilesystemChanged2 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "file.edited"
|
||||
type: "filesystem.changed"
|
||||
location?: LocationRef2
|
||||
data: {
|
||||
file: string
|
||||
event: "add" | "change" | "unlink"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -10384,6 +10406,21 @@ export type CommandUpdated2 = {
|
|||
| 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 = {
|
||||
id: string
|
||||
created: number
|
||||
|
|
@ -10399,20 +10436,6 @@ export type SkillUpdated2 = {
|
|||
| 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 = {
|
||||
id: string
|
||||
title: string
|
||||
|
|
@ -11162,15 +11185,15 @@ export type V2EventV2 =
|
|||
| SessionRevertStaged2
|
||||
| SessionRevertCleared2
|
||||
| SessionRevertCommitted2
|
||||
| FileEdited2
|
||||
| FilesystemChanged2
|
||||
| ReferenceUpdated2
|
||||
| PermissionV2Asked2
|
||||
| PermissionV2Replied2
|
||||
| PluginAdded2
|
||||
| ProjectDirectoriesUpdated2
|
||||
| CommandUpdated2
|
||||
| ConfigUpdated2
|
||||
| SkillUpdated2
|
||||
| FileWatcherUpdated2
|
||||
| PtyCreated2
|
||||
| PtyUpdated2
|
||||
| PtyExited2
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue