Переглянути джерело

feat(core): add location-scoped config loading

Dax Raad 2 місяців тому
батько
коміт
9e556b0f6c

+ 1 - 0
bun.lock

@@ -256,6 +256,7 @@
         "glob": "13.0.5",
         "glob": "13.0.5",
         "google-auth-library": "10.5.0",
         "google-auth-library": "10.5.0",
         "immer": "11.1.4",
         "immer": "11.1.4",
+        "jsonc-parser": "3.3.1",
         "mime-types": "3.0.2",
         "mime-types": "3.0.2",
         "minimatch": "10.2.5",
         "minimatch": "10.2.5",
         "npm-package-arg": "13.0.2",
         "npm-package-arg": "13.0.2",

+ 1 - 0
packages/core/package.json

@@ -63,6 +63,7 @@
     "glob": "13.0.5",
     "glob": "13.0.5",
     "google-auth-library": "10.5.0",
     "google-auth-library": "10.5.0",
     "immer": "11.1.4",
     "immer": "11.1.4",
+    "jsonc-parser": "3.3.1",
     "mime-types": "3.0.2",
     "mime-types": "3.0.2",
     "minimatch": "10.2.5",
     "minimatch": "10.2.5",
     "npm-package-arg": "13.0.2",
     "npm-package-arg": "13.0.2",

+ 0 - 147
packages/core/src/agent.ts

@@ -1,147 +0,0 @@
-export * as AgentV2 from "./agent"
-
-import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect"
-import { produce, type Draft } from "immer"
-import { ModelV2 } from "./model"
-import { PermissionV2 } from "./permission"
-import { PluginV2 } from "./plugin"
-import { ProviderV2 } from "./provider"
-
-export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
-export type ID = typeof ID.Type
-
-export const Mode = Schema.Literals(["subagent", "primary", "all"]).annotate({ identifier: "AgentV2.Mode" })
-export type Mode = typeof Mode.Type
-
-export const Info = Schema.Struct({
-  name: ID,
-  description: Schema.optional(Schema.String),
-  mode: Mode,
-  hidden: Schema.Boolean.pipe(Schema.optional),
-  color: Schema.String.pipe(Schema.optional),
-  permission: PermissionV2.Ruleset,
-  model: ModelV2.Ref.pipe(Schema.optional),
-  system: Schema.String.pipe(Schema.optional),
-  options: ProviderV2.Options.pipe(Schema.optional),
-  steps: Schema.Int.pipe(Schema.optional),
-}).annotate({ identifier: "AgentV2.Info" })
-export type Info = typeof Info.Type
-
-export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("AgentV2.NotFound", {
-  agent: ID,
-}) {}
-
-export class InvalidDefaultError extends Schema.TaggedErrorClass<InvalidDefaultError>()("AgentV2.InvalidDefault", {
-  agent: ID,
-  reason: Schema.Literals(["missing", "subagent", "hidden"]),
-}) {}
-
-export class NoDefaultError extends Schema.TaggedErrorClass<NoDefaultError>()("AgentV2.NoDefault", {}) {}
-
-export interface Interface {
-  readonly get: (agent: ID) => Effect.Effect<Info, NotFoundError>
-  readonly list: () => Effect.Effect<Info[]>
-  readonly update: (agent: ID, fn: (agent: Draft<Info>) => void) => Effect.Effect<void>
-  readonly remove: (agent: ID) => Effect.Effect<void>
-  readonly defaultInfo: () => Effect.Effect<Info, InvalidDefaultError | NoDefaultError>
-  readonly defaultAgent: () => Effect.Effect<ID, InvalidDefaultError | NoDefaultError>
-  readonly setDefault: (agent: ID) => Effect.Effect<void, NotFoundError>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
-
-export const layer = Layer.effect(
-  Service,
-  Effect.gen(function* () {
-    const plugin = yield* PluginV2.Service
-    let agents = HashMap.empty<ID, Info>()
-    let defaultAgent: ID | undefined
-
-    const result: Interface = {
-      get: Effect.fn("AgentV2.get")(function* (agent) {
-        const match = HashMap.get(agents, agent)
-        if (!match.valueOrUndefined) return yield* new NotFoundError({ agent })
-        return match.value
-      }),
-
-      list: Effect.fn("AgentV2.list")(function* () {
-        return pipe(
-          HashMap.toValues(agents),
-          Array.sortWith((agent) => agent.name, Order.String),
-        )
-      }),
-
-      update: Effect.fnUntraced(function* (agent, fn) {
-        const next = produce(
-          HashMap.get(agents, agent).pipe(
-            Option.getOrElse(
-              () =>
-                ({
-                  name: agent,
-                  mode: "all",
-                  permission: [],
-                  options: {
-                    headers: {},
-                    body: {},
-                    aisdk: {
-                      provider: {},
-                      request: {},
-                    },
-                  },
-                }) satisfies Info,
-            ),
-          ),
-          fn,
-        )
-        const updated = yield* plugin.trigger("agent.update", {}, { agent: next, cancel: false })
-        if (updated.cancel) return
-        agents = HashMap.set(agents, agent, { ...updated.agent, name: agent })
-      }),
-
-      remove: Effect.fn("AgentV2.remove")(function* (agent) {
-        const existing = Option.getOrUndefined(HashMap.get(agents, agent))
-        if (!existing) return
-        if ((yield* plugin.trigger("agent.remove", { agent: existing }, { cancel: false })).cancel) return
-        agents = HashMap.remove(agents, agent)
-        if (defaultAgent === agent) defaultAgent = undefined
-      }),
-
-      defaultInfo: Effect.fn("AgentV2.defaultInfo")(function* () {
-        const updated = yield* plugin.trigger("agent.default", {}, { agent: defaultAgent })
-        const selected = updated.agent
-        if (selected) {
-          const agent = yield* result
-            .get(selected)
-            .pipe(
-              Effect.catchTag("AgentV2.NotFound", () =>
-                Effect.fail(new InvalidDefaultError({ agent: selected, reason: "missing" })),
-              ),
-            )
-          if (agent.mode === "subagent") return yield* new InvalidDefaultError({ agent: selected, reason: "subagent" })
-          if (agent.hidden === true) return yield* new InvalidDefaultError({ agent: selected, reason: "hidden" })
-          return agent
-        }
-
-        const visible = pipe(
-          yield* result.list(),
-          Array.findFirst((agent) => agent.mode !== "subagent" && agent.hidden !== true),
-        )
-        if (Option.isSome(visible)) return visible.value
-        return yield* new NoDefaultError()
-      }),
-
-      defaultAgent: Effect.fn("AgentV2.defaultAgent")(function* () {
-        return (yield* result.defaultInfo()).name
-      }),
-
-      setDefault: Effect.fn("AgentV2.setDefault")(function* (agent) {
-        yield* result.get(agent)
-        defaultAgent = agent
-      }),
-    }
-
-    return Service.of(result)
-  }),
-)
-
-export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))

+ 89 - 0
packages/core/src/config/config.ts

@@ -0,0 +1,89 @@
+export * as Config from "./config"
+
+import path from "path"
+import { type ParseError, parse } from "jsonc-parser"
+import { Context, Effect, Layer, Option, Schema } from "effect"
+import { AppFileSystem } from "../filesystem"
+import { Global } from "../global"
+import { Location } from "../location"
+import { AbsolutePath } from "../schema"
+import { ConfigV2 } from "./schema"
+
+export interface Interface {
+  /** Returns supplemental config directories from lowest to highest priority. */
+  readonly directories: () => Effect.Effect<AbsolutePath[]>
+  /** Loads location config files from lowest to highest priority. */
+  readonly get: () => Effect.Effect<ConfigV2.Loaded[]>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Config") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const fs = yield* AppFileSystem.Service
+    const global = yield* Global.Service
+    const location = yield* Location.Service
+    const names = ["config.json", "opencode.json", "opencode.jsonc"]
+
+    const loadFile = Effect.fnUntraced(function* (filepath: string) {
+      const text = yield* fs.readFileStringSafe(filepath)
+      if (!text) return
+
+      const errors: ParseError[] = []
+      const input: unknown = parse(text, errors, { allowTrailingComma: true })
+      if (errors.length) return
+
+      const info = Option.getOrUndefined(Schema.decodeUnknownOption(ConfigV2.Info)(input, { errors: "all" }))
+      if (!info) return
+      return new ConfigV2.Loaded({ source: new ConfigV2.FileSource({ type: "file", path: filepath }), info })
+    })
+
+    const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
+      return yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
+        Effect.map((configs) => configs.filter((config): config is ConfigV2.Loaded => config !== undefined)),
+      )
+    })
+
+    const globalDirectory = AbsolutePath.make(global.config)
+    const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
+    // Read configuration once when this location opens. Later calls reuse these
+    // values until the location is reopened.
+    const directories = locationIsGlobal
+      ? [globalDirectory]
+      : [
+          globalDirectory,
+          ...(yield* fs
+            .up({ targets: [".opencode"], start: location.directory, stop: location.project.directory })
+            .pipe(Effect.orDie))
+            .toReversed()
+            .map((directory) => AbsolutePath.make(directory)),
+        ]
+    // A config closer to the opened directory should win over one higher up.
+    // Search starts nearby, so reverse the results before applying them.
+    const directPaths = locationIsGlobal
+      ? []
+      : (yield* fs
+          .up({ targets: names.toReversed(), start: location.directory, stop: location.project.directory })
+          .pipe(Effect.orDie)).toReversed()
+    const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
+      Effect.orDie,
+      Effect.map((configs) => configs.filter((config): config is ConfigV2.Loaded => config !== undefined)),
+    )
+    const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
+    // Apply general settings first and more specific settings last:
+    // global config, project files, then `.opencode` files.
+    const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
+
+    return Service.of({
+      directories: Effect.fn("Config.directories")(function* () {
+        return directories
+      }),
+      get: Effect.fn("Config.get")(function* () {
+        return configs
+      }),
+    })
+  }),
+)
+
+export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer))

+ 121 - 0
packages/core/src/config/provider.ts

@@ -0,0 +1,121 @@
+export * as ConfigProvider from "./provider"
+
+import { Effect, Schema } from "effect"
+import { Catalog } from "../catalog"
+import { Config } from "./config"
+import { ProviderV2 } from "../provider"
+import { ModelV2 } from "../model"
+import { PluginV2 } from "../plugin"
+
+class Model extends Schema.Class<Model>("ConfigV2.Model")({
+  apiID: ModelV2.ID.pipe(Schema.optional),
+  family: ModelV2.Family.pipe(Schema.optional),
+  name: Schema.String.pipe(Schema.optional),
+  endpoint: ProviderV2.Endpoint.pipe(Schema.optional),
+  capabilities: ModelV2.Capabilities.pipe(Schema.optional),
+  options: Schema.Struct({
+    ...ProviderV2.Options.fields,
+    variant: Schema.String.pipe(Schema.optional),
+  }).pipe(Schema.optional),
+  variants: Schema.Struct({
+    id: ModelV2.VariantID,
+    ...ProviderV2.Options.fields,
+  }).pipe(Schema.Array, Schema.optional),
+  cost: ModelV2.Cost.pipe(Schema.Array).pipe(Schema.optional),
+  enabled: Schema.Boolean.pipe(Schema.optional),
+  limit: Schema.Struct({
+    context: Schema.Int,
+    input: Schema.Int.pipe(Schema.optional),
+    output: Schema.Int,
+  }).pipe(Schema.optional),
+}) {}
+
+export class Info extends Schema.Class<Info>("ConfigV2.Provider")({
+  name: Schema.String.pipe(Schema.optional),
+  endpoint: ProviderV2.Endpoint.pipe(Schema.optional),
+  options: ProviderV2.Options.pipe(Schema.optional),
+  models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
+}) {}
+
+export const Plugin = PluginV2.define({
+  id: PluginV2.ID.make("config-provider"),
+  effect: Effect.gen(function* () {
+    const catalog = yield* Catalog.Service
+    const config = yield* Config.Service
+    const load = yield* catalog.loader()
+    const files = yield* config.get()
+
+    yield* load((catalog) => {
+      for (const file of files) {
+        for (const [id, item] of Object.entries(file.info.providers ?? {})) {
+          const providerID = ProviderV2.ID.make(id)
+          catalog.provider.update(providerID, (provider) => {
+            if (item.name !== undefined) provider.name = item.name
+            provider.enabled = { via: "custom", data: {} }
+            if (item.endpoint !== undefined) provider.endpoint = { ...item.endpoint }
+            if (item.options !== undefined) {
+              Object.assign(provider.options.headers, item.options.headers)
+              Object.assign(provider.options.body, item.options.body)
+              Object.assign(provider.options.aisdk.provider, item.options.aisdk.provider)
+              Object.assign(provider.options.aisdk.request, item.options.aisdk.request)
+            }
+          })
+
+          for (const [id, config] of Object.entries(item.models ?? {})) {
+            catalog.model.update(providerID, ModelV2.ID.make(id), (model) => {
+              if (config.apiID !== undefined) model.apiID = config.apiID
+              if (config.family !== undefined) model.family = config.family
+              if (config.name !== undefined) model.name = config.name
+              if (config.endpoint !== undefined) model.endpoint = { ...config.endpoint }
+              if (config.capabilities !== undefined) {
+                model.capabilities = {
+                  tools: config.capabilities.tools,
+                  input: [...config.capabilities.input],
+                  output: [...config.capabilities.output],
+                }
+              }
+              if (config.options !== undefined) {
+                Object.assign(model.options.headers, config.options.headers)
+                Object.assign(model.options.body, config.options.body)
+                Object.assign(model.options.aisdk.provider, config.options.aisdk.provider)
+                Object.assign(model.options.aisdk.request, config.options.aisdk.request)
+                if (config.options.variant !== undefined) model.options.variant = config.options.variant
+              }
+              if (config.variants !== undefined) {
+                for (const variant of config.variants) {
+                  let existing = model.variants.find((item) => item.id === variant.id)
+                  if (!existing) {
+                    existing = {
+                      id: variant.id,
+                      headers: {},
+                      body: {},
+                      aisdk: {
+                        provider: {},
+                        request: {},
+                      },
+                    }
+                    model.variants.push(existing)
+                  }
+                  Object.assign(existing.headers, variant.headers)
+                  Object.assign(existing.body, variant.body)
+                  Object.assign(existing.aisdk.provider, variant.aisdk.provider)
+                  Object.assign(existing.aisdk.request, variant.aisdk.request)
+                }
+              }
+              if (config.cost !== undefined) {
+                model.cost = config.cost.map((cost) => ({
+                  tier: cost.tier && { ...cost.tier },
+                  input: cost.input,
+                  output: cost.output,
+                  cache: { ...cost.cache },
+                }))
+              }
+              if (config.enabled !== undefined) model.enabled = config.enabled
+              if (config.limit !== undefined) model.limit = { ...config.limit }
+            })
+          }
+        }
+      }
+    })
+  }),
+})

+ 28 - 0
packages/core/src/config/schema.ts

@@ -0,0 +1,28 @@
+export * as ConfigV2 from "./schema"
+
+import { Schema } from "effect"
+import { ConfigProvider } from "./provider"
+
+export class Info extends Schema.Class<Info>("ConfigV2.Info")({
+  $schema: Schema.optional(Schema.String).annotate({
+    description: "JSON schema reference for configuration validation",
+  }),
+  providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
+}) {}
+
+export class FileSource extends Schema.Class<FileSource>("ConfigV2.FileSource")({
+  type: Schema.Literal("file"),
+  path: Schema.String,
+}) {}
+
+export class MemorySource extends Schema.Class<MemorySource>("ConfigV2.MemorySource")({
+  type: Schema.Literal("memory"),
+}) {}
+
+export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type"))
+export type Source = typeof Source.Type
+
+export class Loaded extends Schema.Class<Loaded>("ConfigV2.Loaded")({
+  source: Source,
+  info: Info,
+}) {}

+ 1 - 1
packages/core/src/event.ts

@@ -128,7 +128,7 @@ export const layer = Layer.effect(
           ...(options?.metadata ? { metadata: options.metadata } : {}),
           ...(options?.metadata ? { metadata: options.metadata } : {}),
           type: definition.type,
           type: definition.type,
           ...(definition.version === undefined ? {} : { version: definition.version }),
           ...(definition.version === undefined ? {} : { version: definition.version }),
-          ...(location ? { location } : {}),
+          ...(location ? { location: { directory: location.directory, workspaceID: location.workspaceID } } : {}),
           data,
           data,
         } as Payload<D>
         } as Payload<D>
         return yield* publishEvent(event)
         return yield* publishEvent(event)

+ 6 - 4
packages/core/src/location-layer.ts

@@ -4,10 +4,12 @@ import { Catalog } from "./catalog"
 import { PluginBoot } from "./plugin/boot"
 import { PluginBoot } from "./plugin/boot"
 
 
 export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
 export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
-  lookup: (ref: Location.Ref) =>
-    Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(
-      Layer.provide([Layer.succeed(Location.Service, Location.Service.of(ref))]),
-    ),
+  lookup: (ref: Location.Ref) => {
+    const result = Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(
+      Layer.provideMerge(Location.defaultLayer(ref)),
+    )
+    return result
+  },
   idleTimeToLive: "5 minutes",
   idleTimeToLive: "5 minutes",
   dependencies: [],
   dependencies: [],
 }) {}
 }) {}

+ 32 - 3
packages/core/src/location.ts

@@ -1,11 +1,40 @@
-import { Context, Schema } from "effect"
+import { Context, Effect, Layer, Schema } from "effect"
+import { Project } from "./project"
+import { AbsolutePath } from "./schema"
 
 
 export * as Location from "./location"
 export * as Location from "./location"
 
 
 export const Ref = Schema.Struct({
 export const Ref = Schema.Struct({
-  directory: Schema.String,
+  directory: AbsolutePath,
   workspaceID: Schema.optional(Schema.String),
   workspaceID: Schema.optional(Schema.String),
 }).annotate({ identifier: "Location.Ref" })
 }).annotate({ identifier: "Location.Ref" })
 export type Ref = typeof Ref.Type
 export type Ref = typeof Ref.Type
 
 
-export class Service extends Context.Service<Service, Ref>()("@opencode/Location") {}
+export interface Interface {
+  readonly directory: AbsolutePath
+  readonly workspaceID?: string
+  readonly project: {
+    readonly id: Project.ID
+    readonly directory: AbsolutePath
+  }
+  readonly vcs?: Project.Vcs
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
+
+export const layer = (ref: Ref) =>
+  Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const project = yield* Project.Service
+      const resolved = yield* project.resolve(ref.directory)
+      return Service.of({
+        directory: ref.directory,
+        workspaceID: ref.workspaceID,
+        project: { id: resolved.id, directory: resolved.directory },
+        vcs: resolved.vcs,
+      })
+    }),
+  )
+
+export const defaultLayer = (ref: Ref) => layer(ref).pipe(Layer.provide(Project.defaultLayer))

+ 0 - 22
packages/core/src/plugin.ts

@@ -4,7 +4,6 @@ import { createDraft, finishDraft, type Draft } from "immer"
 import type { LanguageModelV3 } from "@ai-sdk/provider"
 import type { LanguageModelV3 } from "@ai-sdk/provider"
 import { Context, Effect, Exit, Layer, PubSub, Schema, Scope, Stream } from "effect"
 import { Context, Effect, Exit, Layer, PubSub, Schema, Scope, Stream } from "effect"
 import type { ModelV2 } from "./model"
 import type { ModelV2 } from "./model"
-import type { AgentV2 } from "./agent"
 import type { Catalog } from "./catalog"
 import type { Catalog } from "./catalog"
 
 
 export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
 export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
@@ -43,27 +42,6 @@ type HookSpec = {
       sdk?: any
       sdk?: any
     }
     }
   }
   }
-  "agent.update": {
-    input: {}
-    output: {
-      agent: AgentV2.Info
-      cancel: boolean
-    }
-  }
-  "agent.remove": {
-    input: {
-      agent: AgentV2.Info
-    }
-    output: {
-      cancel: boolean
-    }
-  }
-  "agent.default": {
-    input: {}
-    output: {
-      agent?: AgentV2.ID
-    }
-  }
 }
 }
 
 
 export type Hooks = {
 export type Hooks = {

+ 7 - 5
packages/core/src/plugin/boot.ts

@@ -2,8 +2,9 @@ export * as PluginBoot from "./boot"
 
 
 import { Context, Deferred, Effect, Layer } from "effect"
 import { Context, Deferred, Effect, Layer } from "effect"
 import { AccountV2 } from "../account"
 import { AccountV2 } from "../account"
-import { AgentV2 } from "../agent"
 import { Catalog } from "../catalog"
 import { Catalog } from "../catalog"
+import { Config } from "../config/config"
+import { ConfigProvider } from "../config/provider"
 import { EventV2 } from "../event"
 import { EventV2 } from "../event"
 import { Npm } from "../npm"
 import { Npm } from "../npm"
 import { PluginV2 } from "../plugin"
 import { PluginV2 } from "../plugin"
@@ -15,7 +16,7 @@ import { ProviderPlugins } from "./provider"
 type Plugin = {
 type Plugin = {
   id: PluginV2.ID
   id: PluginV2.ID
   effect: PluginV2.Effect<
   effect: PluginV2.Effect<
-    Catalog.Service | AgentV2.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service
+    Catalog.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service | Config.Service
   >
   >
 }
 }
 
 
@@ -28,10 +29,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
 export const layer = Layer.effect(
 export const layer = Layer.effect(
   Service,
   Service,
   Effect.gen(function* () {
   Effect.gen(function* () {
-    const agent = yield* AgentV2.Service
     const catalog = yield* Catalog.Service
     const catalog = yield* Catalog.Service
     const plugin = yield* PluginV2.Service
     const plugin = yield* PluginV2.Service
     const accounts = yield* AccountV2.Service
     const accounts = yield* AccountV2.Service
+    const config = yield* Config.Service
     const npm = yield* Npm.Service
     const npm = yield* Npm.Service
     const events = yield* EventV2.Service
     const events = yield* EventV2.Service
     const done = yield* Deferred.make<void>()
     const done = yield* Deferred.make<void>()
@@ -41,8 +42,8 @@ export const layer = Layer.effect(
         id: input.id,
         id: input.id,
         effect: input.effect.pipe(
         effect: input.effect.pipe(
           Effect.provideService(Catalog.Service, catalog),
           Effect.provideService(Catalog.Service, catalog),
-          Effect.provideService(AgentV2.Service, agent),
           Effect.provideService(AccountV2.Service, accounts),
           Effect.provideService(AccountV2.Service, accounts),
+          Effect.provideService(Config.Service, config),
           Effect.provideService(Npm.Service, npm),
           Effect.provideService(Npm.Service, npm),
           Effect.provideService(EventV2.Service, events),
           Effect.provideService(EventV2.Service, events),
           Effect.provideService(PluginV2.Service, plugin),
           Effect.provideService(PluginV2.Service, plugin),
@@ -57,6 +58,7 @@ export const layer = Layer.effect(
         yield* add(item)
         yield* add(item)
       }
       }
       yield* add(ModelsDevPlugin)
       yield* add(ModelsDevPlugin)
+      yield* add(ConfigProvider.Plugin)
     }).pipe(Effect.withSpan("PluginBoot.boot"))
     }).pipe(Effect.withSpan("PluginBoot.boot"))
 
 
     yield* boot.pipe(
     yield* boot.pipe(
@@ -72,10 +74,10 @@ export const layer = Layer.effect(
 )
 )
 
 
 export const defaultLayer = layer.pipe(
 export const defaultLayer = layer.pipe(
-  Layer.provide(AgentV2.defaultLayer),
   Layer.provide(Catalog.defaultLayer),
   Layer.provide(Catalog.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(PluginV2.defaultLayer),
   Layer.provide(PluginV2.defaultLayer),
   Layer.provide(AccountV2.defaultLayer),
   Layer.provide(AccountV2.defaultLayer),
+  Layer.provide(Config.defaultLayer),
   Layer.provide(Npm.defaultLayer),
   Layer.provide(Npm.defaultLayer),
 )
 )

+ 1 - 2
packages/core/src/project.ts

@@ -25,7 +25,6 @@ export type Vcs = typeof Vcs.Type
 
 
 export class Info extends Schema.Class<Info>("Project.Info")({
 export class Info extends Schema.Class<Info>("Project.Info")({
   id: ID,
   id: ID,
-  vcs: Schema.optional(Vcs),
 }) {}
 }) {}
 
 
 export interface Interface {
 export interface Interface {
@@ -105,7 +104,7 @@ export const layer = Layer.effect(
 
 
     const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
     const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
       const repo = yield* git.find(input)
       const repo = yield* git.find(input)
-      if (!repo) return { id: ID.global, directory: input, vcs: undefined }
+      if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
 
 
       const previous = yield* cached(repo.store)
       const previous = yield* cached(repo.store)
       const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
       const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))

+ 6 - 1
packages/core/test/catalog.test.ts

@@ -6,9 +6,14 @@ import { Location } from "@opencode-ai/core/location"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { ProviderV2 } from "@opencode-ai/core/provider"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { location } from "./fixture/location"
 import { testEffect } from "./lib/effect"
 import { testEffect } from "./lib/effect"
 
 
-const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
+const locationLayer = Layer.succeed(
+  Location.Service,
+  Location.Service.of(location({ directory: AbsolutePath.make("test") })),
+)
 const it = testEffect(
 const it = testEffect(
   Catalog.layer.pipe(
   Catalog.layer.pipe(
     Layer.provideMerge(EventV2.defaultLayer),
     Layer.provideMerge(EventV2.defaultLayer),

+ 208 - 0
packages/core/test/config/config.test.ts

@@ -0,0 +1,208 @@
+import path from "path"
+import fs from "fs/promises"
+import { describe, expect } from "bun:test"
+import { Effect, Layer } from "effect"
+import { Config } from "@opencode-ai/core/config/config"
+import { ConfigProvider } from "@opencode-ai/core/config/provider"
+import { ConfigV2 } from "@opencode-ai/core/config/schema"
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { Global } from "@opencode-ai/core/global"
+import { Location } from "@opencode-ai/core/location"
+import { Project } from "@opencode-ai/core/project"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { location } from "../fixture/location"
+import { tmpdir } from "../fixture/tmpdir"
+import { testEffect } from "../lib/effect"
+
+const it = testEffect(Layer.empty)
+
+function testLayer(
+  directory: string,
+  globalDirectory = path.join(directory, "global"),
+  projectDirectory = directory,
+  vcs?: Project.Vcs,
+) {
+  return Config.layer.pipe(
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(Global.layerWith({ config: globalDirectory })),
+    Layer.provide(
+      Layer.succeed(
+        Location.Service,
+        Location.Service.of(
+          location(
+            { directory: AbsolutePath.make(directory) },
+            { projectDirectory: AbsolutePath.make(projectDirectory), vcs },
+          ),
+        ),
+      ),
+    ),
+  )
+}
+
+const provider = {
+  endpoint: { type: "unknown" },
+  options: {
+    headers: {},
+    body: {},
+    aisdk: {
+      provider: {},
+      request: {},
+    },
+  },
+  models: {},
+}
+
+describe("Config", () => {
+  it.live("returns an empty configuration when directory files do not exist", () =>
+    Effect.acquireRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ).pipe(
+      Effect.flatMap((tmp) =>
+        Effect.gen(function* () {
+          const config = yield* Config.Service
+          const documents = yield* config.get()
+
+          expect(documents).toEqual([])
+        }).pipe(Effect.provide(testLayer(tmp.path))),
+      ),
+    ),
+  )
+
+  it.live("loads JSON and JSONC files from lowest to highest priority", () =>
+    Effect.acquireRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ).pipe(
+      Effect.flatMap((tmp) =>
+        Effect.gen(function* () {
+          yield* Effect.promise(() =>
+            Promise.all([
+              fs.writeFile(
+                path.join(tmp.path, "config.json"),
+                JSON.stringify({ $schema: "base", providers: { base: provider } }),
+              ),
+              fs.writeFile(
+                path.join(tmp.path, "opencode.json"),
+                JSON.stringify({ $schema: "middle", providers: { middle: provider } }),
+              ),
+              fs.writeFile(
+                path.join(tmp.path, "opencode.jsonc"),
+                `{
+                  // Later global files override scalar fields while retaining providers.
+                  "$schema": "last",
+                  "providers": { "last": ${JSON.stringify(provider)} },
+                }`,
+              ),
+            ]),
+          )
+          return yield* Effect.gen(function* () {
+            const config = yield* Config.Service
+            const documents = yield* config.get()
+
+            expect(documents).toHaveLength(3)
+            expect(documents.map((document) => document.source.type)).toEqual(["file", "file", "file"])
+            expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
+            expect(documents[0]).toBeInstanceOf(ConfigV2.Loaded)
+            expect(documents[0]?.source).toBeInstanceOf(ConfigV2.FileSource)
+            expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe(
+              path.join(tmp.path, "config.json"),
+            )
+            expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
+
+            yield* Effect.promise(() =>
+              fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
+            )
+            expect((yield* config.get()).map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
+          }).pipe(Effect.provide(testLayer(tmp.path)))
+        }),
+      ),
+    ),
+  )
+
+  it.live("ignores invalid files while loading valid config values", () =>
+    Effect.acquireRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ).pipe(
+      Effect.flatMap((tmp) =>
+        Effect.gen(function* () {
+          yield* Effect.promise(() =>
+            Promise.all([
+              fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })),
+              fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"),
+              fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })),
+            ]),
+          )
+          return yield* Effect.gen(function* () {
+            const config = yield* Config.Service
+            const documents = yield* config.get()
+
+            expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
+          }).pipe(Effect.provide(testLayer(tmp.path)))
+        }),
+      ),
+    ),
+  )
+
+  it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
+    Effect.acquireRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ).pipe(
+      Effect.flatMap((tmp) => {
+        const global = path.join(tmp.path, "global")
+        const root = path.join(tmp.path, "repo")
+        const parent = path.join(root, "packages")
+        const directory = path.join(parent, "app")
+        return Effect.gen(function* () {
+          yield* Effect.promise(async () => {
+            await fs.mkdir(global, { recursive: true })
+            await fs.mkdir(directory, { recursive: true })
+            await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
+            await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
+            await Promise.all([
+              fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
+              fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
+              fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
+              fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
+              fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })),
+              fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
+              fs.writeFile(
+                path.join(directory, ".opencode", "opencode.jsonc"),
+                JSON.stringify({ $schema: "directory-dot" }),
+              ),
+            ])
+          })
+
+          return yield* Effect.gen(function* () {
+            const config = yield* Config.Service
+            const directories = yield* config.directories()
+            const documents = yield* config.get()
+
+            expect(directories).toEqual([
+              AbsolutePath.make(global),
+              AbsolutePath.make(path.join(root, ".opencode")),
+              AbsolutePath.make(path.join(directory, ".opencode")),
+            ])
+            expect(documents.map((document) => document.info.$schema)).toEqual([
+              "global",
+              "root",
+              "parent",
+              "directory",
+              "root-dot",
+              "directory-dot",
+            ])
+          }).pipe(
+            Effect.provide(
+              testLayer(directory, global, root, {
+                type: "git",
+                store: AbsolutePath.make(path.join(root, ".git")),
+              }),
+            ),
+          )
+        })
+      }),
+    ),
+  )
+})

+ 138 - 0
packages/core/test/config/provider.test.ts

@@ -0,0 +1,138 @@
+import { describe, expect } from "bun:test"
+import { Effect, Schema } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
+import { Config } from "@opencode-ai/core/config/config"
+import { ConfigProvider } from "@opencode-ai/core/config/provider"
+import { ConfigV2 } from "@opencode-ai/core/config/schema"
+import { ModelV2 } from "@opencode-ai/core/model"
+import { PluginV2 } from "@opencode-ai/core/plugin"
+import { ProviderV2 } from "@opencode-ai/core/provider"
+import { it } from "../plugin/provider-helper"
+
+function options(headers: Record<string, string>, variant?: string) {
+  return {
+    headers,
+    body: {},
+    aisdk: {
+      provider: {},
+      request: {},
+    },
+    variant,
+  }
+}
+
+const decode = Schema.decodeUnknownSync(ConfigV2.Info)
+
+describe("ConfigProvider.Plugin", () => {
+  it.effect("loads configured providers and applies later model overrides", () =>
+    Effect.gen(function* () {
+      const catalog = yield* Catalog.Service
+      const plugin = yield* PluginV2.Service
+      const providerID = ProviderV2.ID.make("custom")
+      const modelID = ModelV2.ID.make("chat")
+      const config = Config.Service.of({
+        directories: () => Effect.succeed([]),
+        get: () =>
+          Effect.succeed([
+            new ConfigV2.Loaded({
+              source: new ConfigV2.MemorySource({ type: "memory" }),
+              info: decode({
+                providers: {
+                  custom: {
+                    name: "Configured",
+                    endpoint: { type: "unknown" },
+                    options: options({ first: "first", shared: "first" }),
+                    models: {
+                      chat: {
+                        name: "First",
+                        capabilities: { tools: true, input: ["text"], output: ["text"] },
+                        enabled: false,
+                        limit: { context: 100, output: 50 },
+                        options: options({ first: "first", shared: "first" }, "retained"),
+                        variants: [
+                          {
+                            id: "fast",
+                            headers: { first: "first", shared: "first" },
+                            body: {},
+                            aisdk: { provider: {}, request: {} },
+                          },
+                        ],
+                      },
+                    },
+                  },
+                },
+              }),
+            }),
+            new ConfigV2.Loaded({
+              source: new ConfigV2.MemorySource({ type: "memory" }),
+              info: decode({
+                providers: {
+                  custom: {
+                    endpoint: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
+                    options: options({ last: "last", shared: "last" }),
+                    models: {
+                      chat: {
+                        apiID: "api-chat",
+                        name: "Last",
+                        options: options({ last: "last", shared: "last" }),
+                        variants: [
+                          {
+                            id: "fast",
+                            headers: { last: "last", shared: "last" },
+                            body: {},
+                            aisdk: { provider: {}, request: {} },
+                          },
+                          {
+                            id: "slow",
+                            headers: { slow: "slow" },
+                            body: {},
+                            aisdk: { provider: {}, request: {} },
+                          },
+                        ],
+                      },
+                    },
+                  },
+                },
+              }),
+            }),
+            new ConfigV2.Loaded({
+              source: new ConfigV2.MemorySource({ type: "memory" }),
+              info: decode({
+                providers: {
+                  custom: { name: "Renamed" },
+                },
+              }),
+            }),
+          ]),
+      })
+
+      yield* plugin.add({
+        ...ConfigProvider.Plugin,
+        effect: ConfigProvider.Plugin.effect.pipe(
+          Effect.provideService(Config.Service, config),
+          Effect.provideService(Catalog.Service, catalog),
+        ),
+      })
+
+      const provider = yield* catalog.provider.get(providerID)
+      const model = yield* catalog.model.get(providerID, modelID)
+      expect(provider.name).toBe("Renamed")
+      expect(provider.enabled).toEqual({ via: "custom", data: {} })
+      expect(provider.endpoint).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
+      expect(provider.options.headers).toEqual({ first: "first", shared: "last", last: "last" })
+      expect(model.apiID).toBe(ModelV2.ID.make("api-chat"))
+      expect(model.name).toBe("Last")
+      expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
+      expect(model.enabled).toBe(false)
+      expect(model.limit).toEqual({ context: 100, output: 50 })
+      expect(model.options.headers).toEqual({ first: "first", shared: "last", last: "last" })
+      expect(model.options.variant).toBe("retained")
+      expect(model.variants.map((variant) => variant.id)).toEqual([
+        ModelV2.VariantID.make("fast"),
+        ModelV2.VariantID.make("slow"),
+      ])
+      expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
+      expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
+    }),
+  )
+})

+ 4 - 2
packages/core/test/event.test.ts

@@ -2,11 +2,13 @@ import { describe, expect } from "bun:test"
 import { Effect, Fiber, Layer, Schema, Stream } from "effect"
 import { Effect, Fiber, Layer, Schema, Stream } from "effect"
 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 { AbsolutePath } from "@opencode-ai/core/schema"
+import { location } from "./fixture/location"
 import { testEffect } from "./lib/effect"
 import { testEffect } from "./lib/effect"
 
 
 const locationLayer = Layer.succeed(
 const locationLayer = Layer.succeed(
   Location.Service,
   Location.Service,
-  Location.Service.of({ directory: "project", workspaceID: "workspace" }),
+  Location.Service.of(location({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })),
 )
 )
 const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer)))
 const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer)))
 const itWithoutLocation = testEffect(EventV2.layer)
 const itWithoutLocation = testEffect(EventV2.layer)
@@ -46,7 +48,7 @@ describe("EventV2", () => {
       expect(event.type).toBe("test.message")
       expect(event.type).toBe("test.message")
       expect(event).not.toHaveProperty("version")
       expect(event).not.toHaveProperty("version")
       expect(event.data).toEqual({ text: "hello" })
       expect(event.data).toEqual({ text: "hello" })
-      expect(event.location).toEqual({ directory: "project", workspaceID: "workspace" })
+      expect(event.location).toEqual({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })
     }),
     }),
   )
   )
 
 

+ 12 - 0
packages/core/test/fixture/location.ts

@@ -0,0 +1,12 @@
+import { Location } from "@opencode-ai/core/location"
+import { Project } from "@opencode-ai/core/project"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+
+export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
+  return {
+    directory: ref.directory,
+    workspaceID: ref.workspaceID,
+    project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
+    vcs: input.vcs,
+  } satisfies Location.Interface
+}

+ 38 - 0
packages/core/test/location.test.ts

@@ -0,0 +1,38 @@
+import { describe, expect } from "bun:test"
+import { Effect, Layer } from "effect"
+import { Location } from "@opencode-ai/core/location"
+import { Project } from "@opencode-ai/core/project"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { testEffect } from "./lib/effect"
+
+const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID: "workspace" }
+const projectLayer = Layer.succeed(
+  Project.Service,
+  Project.Service.of({
+    resolve: () =>
+      Effect.succeed({
+        id: Project.ID.make("project"),
+        directory: AbsolutePath.make("/repo"),
+        vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
+      }),
+    commit: () => Effect.void,
+  }),
+)
+const it = testEffect(Location.layer(ref).pipe(Layer.provide(projectLayer)))
+
+describe("Location", () => {
+  it.effect("resolves the current project and vcs information", () =>
+    Effect.gen(function* () {
+      const location = yield* Location.Service
+
+      expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app"))
+      expect(location.workspaceID).toBe("workspace")
+      expect(location.project.id).toBe(Project.ID.make("project"))
+      expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
+      expect(location.vcs).toEqual({
+        type: "git",
+        store: AbsolutePath.make("/repo/.git"),
+      })
+    }),
+  )
+})

+ 5 - 1
packages/core/test/plugin/provider-azure.test.ts

@@ -8,6 +8,8 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
 import { AccountPlugin } from "@opencode-ai/core/plugin/account"
 import { AccountPlugin } from "@opencode-ai/core/plugin/account"
 import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
 import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { ProviderV2 } from "@opencode-ai/core/provider"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { location } from "../fixture/location"
 import { testEffect } from "../lib/effect"
 import { testEffect } from "../lib/effect"
 import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
 import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
 
 
@@ -16,7 +18,9 @@ const itWithAccount = testEffect(
     Layer.provideMerge(PluginV2.defaultLayer),
     Layer.provideMerge(PluginV2.defaultLayer),
     Layer.provideMerge(AccountV2.defaultLayer),
     Layer.provideMerge(AccountV2.defaultLayer),
     Layer.provideMerge(EventV2.defaultLayer),
     Layer.provideMerge(EventV2.defaultLayer),
-    Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
+    Layer.provideMerge(
+      Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
+    ),
     Layer.provideMerge(npmLayer),
     Layer.provideMerge(npmLayer),
   ),
   ),
 )
 )

+ 5 - 1
packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts

@@ -9,6 +9,8 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
 import { AccountPlugin } from "@opencode-ai/core/plugin/account"
 import { AccountPlugin } from "@opencode-ai/core/plugin/account"
 import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
 import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { ProviderV2 } from "@opencode-ai/core/provider"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { location } from "../fixture/location"
 import { testEffect } from "../lib/effect"
 import { testEffect } from "../lib/effect"
 import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper"
 import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper"
 
 
@@ -17,7 +19,9 @@ const itWithAccount = testEffect(
     Layer.provideMerge(PluginV2.defaultLayer),
     Layer.provideMerge(PluginV2.defaultLayer),
     Layer.provideMerge(AccountV2.defaultLayer),
     Layer.provideMerge(AccountV2.defaultLayer),
     Layer.provideMerge(EventV2.defaultLayer),
     Layer.provideMerge(EventV2.defaultLayer),
-    Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
+    Layer.provideMerge(
+      Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
+    ),
     Layer.provideMerge(npmLayer),
     Layer.provideMerge(npmLayer),
   ),
   ),
 )
 )

+ 5 - 1
packages/core/test/plugin/provider-gitlab.test.ts

@@ -8,6 +8,8 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
 import { AccountPlugin } from "@opencode-ai/core/plugin/account"
 import { AccountPlugin } from "@opencode-ai/core/plugin/account"
 import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
 import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { ProviderV2 } from "@opencode-ai/core/provider"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { location } from "../fixture/location"
 import { testEffect } from "../lib/effect"
 import { testEffect } from "../lib/effect"
 import { it, model, npmLayer, withEnv } from "./provider-helper"
 import { it, model, npmLayer, withEnv } from "./provider-helper"
 
 
@@ -31,7 +33,9 @@ const itWithAccount = testEffect(
     Layer.provideMerge(PluginV2.defaultLayer),
     Layer.provideMerge(PluginV2.defaultLayer),
     Layer.provideMerge(AccountV2.defaultLayer),
     Layer.provideMerge(AccountV2.defaultLayer),
     Layer.provideMerge(EventV2.defaultLayer),
     Layer.provideMerge(EventV2.defaultLayer),
-    Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
+    Layer.provideMerge(
+      Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
+    ),
     Layer.provideMerge(npmLayer),
     Layer.provideMerge(npmLayer),
   ),
   ),
 )
 )

+ 6 - 1
packages/core/test/plugin/provider-helper.ts

@@ -8,10 +8,15 @@ import { Location } from "@opencode-ai/core/location"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { ProviderV2 } from "@opencode-ai/core/provider"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { location } from "../fixture/location"
 import { testEffect } from "../lib/effect"
 import { testEffect } from "../lib/effect"
 
 
 export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
 export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
-const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
+const locationLayer = Layer.succeed(
+  Location.Service,
+  Location.Service.of(location({ directory: AbsolutePath.make("test") })),
+)
 
 
 export const npmLayer = Layer.succeed(
 export const npmLayer = Layer.succeed(
   Npm.Service,
   Npm.Service,

+ 6 - 1
packages/core/test/plugin/provider-opencode.test.ts

@@ -6,10 +6,15 @@ import { ModelV2 } from "@opencode-ai/core/model"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
 import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { ProviderV2 } from "@opencode-ai/core/provider"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { location } from "../fixture/location"
 import { it, model, provider, withEnv } from "./provider-helper"
 import { it, model, provider, withEnv } from "./provider-helper"
 
 
 const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
 const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
-const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
+const locationLayer = Layer.succeed(
+  Location.Service,
+  Location.Service.of(location({ directory: AbsolutePath.make("test") })),
+)
 
 
 describe("OpencodePlugin", () => {
 describe("OpencodePlugin", () => {
   it.effect("uses a public key and disables paid models without credentials", () =>
   it.effect("uses a public key and disables paid models without credentials", () =>

+ 1 - 1
packages/core/test/project.test.ts

@@ -49,7 +49,7 @@ describe("ProjectV2.resolve", () => {
       const result = yield* project.resolve(abs(tmp.path))
       const result = yield* project.resolve(abs(tmp.path))
 
 
       expect(result.id).toBe(Project.ID.make("global"))
       expect(result.id).toBe(Project.ID.make("global"))
-      expect(path.resolve(result.directory)).toBe(path.resolve(tmp.path))
+      expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
       expect(result.previous).toBeUndefined()
       expect(result.previous).toBeUndefined()
       expect(result.vcs).toBeUndefined()
       expect(result.vcs).toBeUndefined()
     }),
     }),

+ 2 - 1
packages/opencode/src/cli/cmd/debug/v2.ts

@@ -3,6 +3,7 @@ import { Effect, Option } from "effect"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { LocationServiceMap } from "@opencode-ai/core/location-layer"
 import { LocationServiceMap } from "@opencode-ai/core/location-layer"
 import { PluginBoot } from "@opencode-ai/core/plugin/boot"
 import { PluginBoot } from "@opencode-ai/core/plugin/boot"
+import { AbsolutePath } from "@opencode-ai/core/schema"
 import { effectCmd } from "../../effect-cmd"
 import { effectCmd } from "../../effect-cmd"
 
 
 export const V2Command = effectCmd({
 export const V2Command = effectCmd({
@@ -37,7 +38,7 @@ export const V2Command = effectCmd({
       Effect.withSpan("Cli.debug.v2"),
       Effect.withSpan("Cli.debug.v2"),
       Effect.provide(
       Effect.provide(
         LocationServiceMap.get({
         LocationServiceMap.get({
-          directory: process.cwd(),
+          directory: AbsolutePath.make(process.cwd()),
         }),
         }),
       ),
       ),
       Effect.provide(LocationServiceMap.layer),
       Effect.provide(LocationServiceMap.layer),

+ 4 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts

@@ -1,6 +1,7 @@
 import { Catalog } from "@opencode-ai/core/catalog"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { Location } from "@opencode-ai/core/location"
 import { Location } from "@opencode-ai/core/location"
 import { LocationServiceMap } from "@opencode-ai/core/location-layer"
 import { LocationServiceMap } from "@opencode-ai/core/location-layer"
+import { AbsolutePath } from "@opencode-ai/core/schema"
 import { PluginBoot } from "@opencode-ai/core/plugin/boot"
 import { PluginBoot } from "@opencode-ai/core/plugin/boot"
 import { Effect, Layer, Schema } from "effect"
 import { Effect, Layer, Schema } from "effect"
 import { HttpServerRequest } from "effect/unstable/http"
 import { HttpServerRequest } from "effect/unstable/http"
@@ -40,7 +41,9 @@ export class V2LocationMiddleware extends HttpApiMiddleware.Service<
 function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
 function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
   const query = new URL(request.url, "http://localhost").searchParams
   const query = new URL(request.url, "http://localhost").searchParams
   return {
   return {
-    directory: query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
+    directory: AbsolutePath.make(
+      query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
+    ),
     workspaceID: query.get("location[workspace]") || request.headers["x-opencode-workspace"],
     workspaceID: query.get("location[workspace]") || request.headers["x-opencode-workspace"],
   }
   }
 }
 }