feat(core): reload config on filesystem changes
This commit is contained in:
parent
b04d8d53e6
commit
c9b24ef027
48 changed files with 596 additions and 510 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export type {
|
||||||
AppApi,
|
AppApi,
|
||||||
CatalogApi,
|
CatalogApi,
|
||||||
CommandApi,
|
CommandApi,
|
||||||
|
EventApi,
|
||||||
IntegrationApi,
|
IntegrationApi,
|
||||||
ModelApi,
|
ModelApi,
|
||||||
PluginApi,
|
PluginApi,
|
||||||
|
|
|
||||||
|
|
@ -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>>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export type {
|
||||||
AgentApi,
|
AgentApi,
|
||||||
CatalogApi,
|
CatalogApi,
|
||||||
CommandApi,
|
CommandApi,
|
||||||
|
EventApi,
|
||||||
IntegrationApi,
|
IntegrationApi,
|
||||||
ModelApi,
|
ModelApi,
|
||||||
PluginApi,
|
PluginApi,
|
||||||
|
|
|
||||||
|
|
@ -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] })
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 }),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
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
|
// @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))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))),
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 }),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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" })
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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 { 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),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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: {},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]>
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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 { 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"
|
||||||
|
|
|
||||||
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 { 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,
|
||||||
|
|
|
||||||
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 { 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({
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
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 { 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])
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue