Просмотр исходного кода

refactor(effect): unify service namespaces and align naming (#18093)

Kit Langton 4 недель назад
Родитель
Сommit
a800583aea
35 измененных файлов с 2034 добавлено и 2059 удалено
  1. 30 46
      packages/opencode/AGENTS.md
  2. 144 0
      packages/opencode/specs/effect-migration.md
  3. 14 19
      packages/opencode/src/account/effect.ts
  4. 7 7
      packages/opencode/src/account/index.ts
  5. 17 23
      packages/opencode/src/auth/effect.ts
  6. 4 4
      packages/opencode/src/auth/index.ts
  7. 5 5
      packages/opencode/src/cli/cmd/account.ts
  8. 31 33
      packages/opencode/src/effect/instances.ts
  9. 4 4
      packages/opencode/src/effect/runtime.ts
  10. 391 420
      packages/opencode/src/file/index.ts
  11. 76 81
      packages/opencode/src/file/time.ts
  12. 52 66
      packages/opencode/src/file/watcher.ts
  13. 71 75
      packages/opencode/src/format/index.ts
  14. 245 32
      packages/opencode/src/permission/index.ts
  15. 0 244
      packages/opencode/src/permission/service.ts
  16. 1 8
      packages/opencode/src/project/bootstrap.ts
  17. 35 36
      packages/opencode/src/project/vcs.ts
  18. 0 230
      packages/opencode/src/provider/auth-service.ts
  19. 214 17
      packages/opencode/src/provider/auth.ts
  20. 173 19
      packages/opencode/src/question/index.ts
  21. 0 172
      packages/opencode/src/question/service.ts
  22. 2 2
      packages/opencode/src/server/server.ts
  23. 98 97
      packages/opencode/src/skill/discovery.ts
  24. 195 207
      packages/opencode/src/skill/skill.ts
  25. 133 166
      packages/opencode/src/snapshot/index.ts
  26. 6 6
      packages/opencode/src/tool/truncate-effect.ts
  27. 8 6
      packages/opencode/test/account/service.test.ts
  28. 4 5
      packages/opencode/test/file/watcher.test.ts
  29. 12 11
      packages/opencode/test/format/format.test.ts
  30. 2 2
      packages/opencode/test/permission/next.test.ts
  31. 2 1
      packages/opencode/test/plugin/auth-override.test.ts
  32. 19 13
      packages/opencode/test/project/vcs.test.ts
  33. 3 0
      packages/opencode/test/server/project-init-git.test.ts
  34. 2 2
      packages/opencode/test/skill/discovery.test.ts
  35. 34 0
      packages/opencode/test/snapshot/snapshot.test.ts

+ 30 - 46
packages/opencode/AGENTS.md

@@ -9,71 +9,55 @@
 - **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
 - **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
 
-# opencode Effect guide
+# opencode Effect rules
 
-Instructions to follow when writing Effect.
+Use these rules when writing or migrating Effect code.
 
-## Schemas
+See `specs/effect-migration.md` for the compact pattern reference and examples.
 
-- Use `Schema.Class` for data types with multiple fields.
-- Use branded schemas (`Schema.brand`) for single-value types.
-
-## Services
-
-- Services use `ServiceMap.Service<ServiceName, ServiceName.Service>()("@console/<Name>")`.
-- In `Layer.effect`, always return service implementations with `ServiceName.of({ ... })`, never a plain object.
-
-## Errors
-
-- Use `Schema.TaggedErrorClass` for typed errors.
-- For defect-like causes, use `Schema.Defect` instead of `unknown`.
-- In `Effect.gen`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
-
-## Effects
+## Core
 
 - Use `Effect.gen(function* () { ... })` for composition.
-- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
-- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers.
-- **`Effect.callback`** (not `Effect.async`) for callback-based APIs. The classic `Effect.async` was renamed to `Effect.callback` in effect-smol/v4.
-
-## Time
-
+- Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
+- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers.
+- Use `Effect.callback` for callback-based APIs.
 - Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
 
-## Errors
+## Schemas and errors
+
+- Use `Schema.Class` for multi-field data.
+- Use branded schemas (`Schema.brand`) for single-value types.
+- Use `Schema.TaggedErrorClass` for typed errors.
+- Use `Schema.Defect` instead of `unknown` for defect-like causes.
+- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
 
-- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
+## Runtime vs Instances
 
-## Instance-scoped Effect services
+- Use the shared runtime for process-wide services with one lifecycle for the whole app.
+- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup.
+- If two open directories should not share one copy of the service, it belongs in `Instances`.
+- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals.
 
-Services that need per-directory lifecycle (created/destroyed per instance) go through the `Instances` LayerMap:
+## Preferred Effect services
 
-1. Define a `ServiceMap.Service` with a `static readonly layer` (see `FileWatcherService`, `QuestionService`, `PermissionService`, `ProviderAuthService`).
-2. Add it to `InstanceServices` union and `Layer.mergeAll(...)` in `src/effect/instances.ts`.
-3. Use `InstanceContext` inside the layer to read `directory` and `project` instead of `Instance.*` globals.
-4. Call from legacy code via `runPromiseInstance(MyService.use((s) => s.method()))`.
+- In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
+- Prefer `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O.
+- Prefer `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers.
+- Prefer `HttpClient.HttpClient` instead of raw `fetch`.
+- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code.
+- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
 
-### Instance.bind — ALS context for native callbacks
+## Instance.bind — ALS for native callbacks
 
-`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and returns a wrapper that restores it synchronously when called.
+`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
 
-**Use it** when passing callbacks to native C/C++ addons (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
+Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
 
-**Don't need it** for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers — Node.js ALS propagates through those automatically.
+You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
 
 ```typescript
-// Native addon callback — needs Instance.bind
 const cb = Instance.bind((err, evts) => {
   Bus.publish(MyEvent, { ... })
 })
 nativeAddon.subscribe(dir, cb)
 ```
-
-## Flag → Effect.Config migration
-
-Flags in `src/flag/flag.ts` are being migrated from static `truthy(...)` reads to `Config.boolean(...).pipe(Config.withDefault(false))` as their consumers get effectified.
-
-- Effectful flags return `Config<boolean>` and are read with `yield*` inside `Effect.gen`.
-- The default `ConfigProvider` reads from `process.env`, so env vars keep working.
-- Tests can override via `ConfigProvider.layer(ConfigProvider.fromUnknown({ ... }))`.
-- Keep all flags in `flag.ts` as the single registry — just change the implementation from `truthy()` to `Config.boolean()` when the consumer moves to Effect.

+ 144 - 0
packages/opencode/specs/effect-migration.md

@@ -0,0 +1,144 @@
+# Effect patterns
+
+Practical reference for new and migrated Effect code in `packages/opencode`.
+
+## Choose scope
+
+Use the shared runtime for process-wide services with one lifecycle for the whole app.
+
+Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
+
+- Shared runtime: config readers, stateless helpers, global clients
+- Instance-scoped: watchers, per-project caches, session state, project-bound background work
+
+Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
+
+## Service shape
+
+For a fully migrated module, use the public namespace directly:
+
+```ts
+export namespace Foo {
+  export interface Interface {
+    readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      return Service.of({
+        get: Effect.fn("Foo.get")(function* (id) {
+          return yield* ...
+        }),
+      })
+    }),
+  )
+
+  export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
+}
+```
+
+Rules:
+
+- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace
+- Export `defaultLayer` only when wiring dependencies is useful
+- Use the direct namespace form once the module is fully migrated
+
+## Temporary mixed-mode pattern
+
+Prefer a single namespace whenever possible.
+
+Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
+
+```ts
+export namespace FooEffect {
+  export interface Interface {
+    readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
+
+  export const layer = Layer.effect(...)
+}
+```
+
+Then keep the old boundary thin:
+
+```ts
+export namespace Foo {
+  export function get(id: FooID) {
+    return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
+  }
+}
+```
+
+Remove the `Effect` suffix when the boundary split is gone.
+
+## Scheduled Tasks
+
+For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
+
+## Preferred Effect services
+
+In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
+
+Prefer these first:
+
+- `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O
+- `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers
+- `HttpClient.HttpClient` instead of raw `fetch`
+- `Path.Path` instead of mixing path helpers into service code when you already need a path service
+- `Config` for effect-native configuration reads
+- `Clock` / `DateTime` for time reads inside effects
+
+## Child processes
+
+For child process work in services, yield `ChildProcessSpawner.ChildProcessSpawner` in the layer and use `ChildProcess.make(...)`.
+
+Keep shelling-out code inside the service, not in callers.
+
+## Shared leaf models
+
+Shared schema or model files can stay outside the service namespace when lower layers also depend on them.
+
+That is fine for leaf files like `schema.ts`. Keep the service surface in the owning namespace.
+
+## Migration checklist
+
+Done now:
+
+- [x] `AccountEffect` (mixed-mode)
+- [x] `AuthEffect` (mixed-mode)
+- [x] `TruncateEffect` (mixed-mode)
+- [x] `Question`
+- [x] `PermissionNext`
+- [x] `ProviderAuth`
+- [x] `FileWatcher`
+- [x] `FileTime`
+- [x] `Format`
+- [x] `Vcs`
+- [x] `Skill`
+- [x] `Discovery`
+- [x] `File`
+- [x] `Snapshot`
+
+Still open and likely worth migrating:
+
+- [ ] `Plugin`
+- [ ] `ToolRegistry`
+- [ ] `Pty`
+- [ ] `Worktree`
+- [ ] `Installation`
+- [ ] `Bus`
+- [ ] `Command`
+- [ ] `Config`
+- [ ] `Session`
+- [ ] `SessionProcessor`
+- [ ] `SessionPrompt`
+- [ ] `SessionCompaction`
+- [ ] `Provider`
+- [ ] `Project`
+- [ ] `LSP`
+- [ ] `MCP`

+ 14 - 19
packages/opencode/src/account/service.ts → packages/opencode/src/account/effect.ts

@@ -108,8 +108,8 @@ const mapAccountServiceError =
       ),
     )
 
-export namespace AccountService {
-  export interface Service {
+export namespace AccountEffect {
+  export interface Interface {
     readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
     readonly list: () => Effect.Effect<Account[], AccountError>
     readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
@@ -124,11 +124,11 @@ export namespace AccountService {
     readonly login: (url: string) => Effect.Effect<Login, AccountError>
     readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
   }
-}
 
-export class AccountService extends ServiceMap.Service<AccountService, AccountService.Service>()("@opencode/Account") {
-  static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
-    AccountService,
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
+
+  export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
+    Service,
     Effect.gen(function* () {
       const repo = yield* AccountRepo
       const http = yield* HttpClient.HttpClient
@@ -148,8 +148,6 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
           mapAccountServiceError("HTTP request failed"),
         )
 
-      // Returns a usable access token for a stored account row, refreshing and
-      // persisting it when the cached token has expired.
       const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
         const now = yield* Clock.currentTimeMillis
         if (row.token_expiry && row.token_expiry > now) return row.access_token
@@ -218,11 +216,11 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
         )
       })
 
-      const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
+      const token = Effect.fn("Account.token")((accountID: AccountID) =>
         resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
       )
 
-      const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
+      const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
         const accounts = yield* repo.list()
         const [errors, results] = yield* Effect.partition(
           accounts,
@@ -237,7 +235,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
         return results
       })
 
-      const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
+      const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
         const resolved = yield* resolveAccess(accountID)
         if (Option.isNone(resolved)) return []
 
@@ -246,7 +244,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
         return yield* fetchOrgs(account.url, accessToken)
       })
 
-      const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
+      const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
         const resolved = yield* resolveAccess(accountID)
         if (Option.isNone(resolved)) return Option.none()
 
@@ -270,7 +268,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
         return Option.some(parsed.config)
       })
 
-      const login = Effect.fn("AccountService.login")(function* (server: string) {
+      const login = Effect.fn("Account.login")(function* (server: string) {
         const response = yield* executeEffectOk(
           HttpClientRequest.post(`${server}/auth/device/code`).pipe(
             HttpClientRequest.acceptJson,
@@ -291,7 +289,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
         })
       })
 
-      const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
+      const poll = Effect.fn("Account.poll")(function* (input: Login) {
         const response = yield* executeEffectOk(
           HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
             HttpClientRequest.acceptJson,
@@ -337,7 +335,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
         return new PollSuccess({ email: account.email })
       })
 
-      return AccountService.of({
+      return Service.of({
         active: repo.active,
         list: repo.list,
         orgsByAccount,
@@ -352,8 +350,5 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
     }),
   )
 
-  static readonly defaultLayer = AccountService.layer.pipe(
-    Layer.provide(AccountRepo.layer),
-    Layer.provide(FetchHttpClient.layer),
-  )
+  export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
 }

+ 7 - 7
packages/opencode/src/account/index.ts

@@ -5,20 +5,20 @@ import {
   type AccountError,
   type AccessToken,
   AccountID,
-  AccountService,
+  AccountEffect,
   OrgID,
-} from "./service"
+} from "./effect"
 
-export { AccessToken, AccountID, OrgID } from "./service"
+export { AccessToken, AccountID, OrgID } from "./effect"
 
 import { runtime } from "@/effect/runtime"
 
-function runSync<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
-  return runtime.runSync(AccountService.use(f))
+function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
+  return runtime.runSync(AccountEffect.Service.use(f))
 }
 
-function runPromise<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
-  return runtime.runPromise(AccountService.use(f))
+function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
+  return runtime.runPromise(AccountEffect.Service.use(f))
 }
 
 export namespace Account {

+ 17 - 23
packages/opencode/src/auth/service.ts → packages/opencode/src/auth/effect.ts

@@ -28,31 +28,31 @@ export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
 export const Info = Schema.Union([Oauth, Api, WellKnown])
 export type Info = Schema.Schema.Type<typeof Info>
 
-export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
+export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
   message: Schema.String,
   cause: Schema.optional(Schema.Defect),
 }) {}
 
 const file = path.join(Global.Path.data, "auth.json")
 
-const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
+const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
 
-export namespace AuthService {
-  export interface Service {
-    readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
-    readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
-    readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
-    readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
+export namespace AuthEffect {
+  export interface Interface {
+    readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
+    readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
+    readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
+    readonly remove: (key: string) => Effect.Effect<void, AuthError>
   }
-}
 
-export class AuthService extends ServiceMap.Service<AuthService, AuthService.Service>()("@opencode/Auth") {
-  static readonly layer = Layer.effect(
-    AuthService,
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
+
+  export const layer = Layer.effect(
+    Service,
     Effect.gen(function* () {
       const decode = Schema.decodeUnknownOption(Info)
 
-      const all = Effect.fn("AuthService.all")(() =>
+      const all = Effect.fn("Auth.all")(() =>
         Effect.tryPromise({
           try: async () => {
             const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
@@ -62,11 +62,11 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
         }),
       )
 
-      const get = Effect.fn("AuthService.get")(function* (providerID: string) {
+      const get = Effect.fn("Auth.get")(function* (providerID: string) {
         return (yield* all())[providerID]
       })
 
-      const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) {
+      const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
         const norm = key.replace(/\/+$/, "")
         const data = yield* all()
         if (norm !== key) delete data[key]
@@ -77,7 +77,7 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
         })
       })
 
-      const remove = Effect.fn("AuthService.remove")(function* (key: string) {
+      const remove = Effect.fn("Auth.remove")(function* (key: string) {
         const norm = key.replace(/\/+$/, "")
         const data = yield* all()
         delete data[key]
@@ -88,14 +88,8 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
         })
       })
 
-      return AuthService.of({
-        get,
-        all,
-        set,
-        remove,
-      })
+      return Service.of({ get, all, set, remove })
     }),
   )
 
-  static readonly defaultLayer = AuthService.layer
 }

+ 4 - 4
packages/opencode/src/auth/index.ts

@@ -1,12 +1,12 @@
 import { Effect } from "effect"
 import z from "zod"
 import { runtime } from "@/effect/runtime"
-import * as S from "./service"
+import * as S from "./effect"
 
-export { OAUTH_DUMMY_KEY } from "./service"
+export { OAUTH_DUMMY_KEY } from "./effect"
 
-function runPromise<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) {
-  return runtime.runPromise(S.AuthService.use(f))
+function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) {
+  return runtime.runPromise(S.AuthEffect.Service.use(f))
 }
 
 export namespace Auth {

+ 5 - 5
packages/opencode/src/cli/cmd/account.ts

@@ -2,7 +2,7 @@ import { cmd } from "./cmd"
 import { Duration, Effect, Match, Option } from "effect"
 import { UI } from "../ui"
 import { runtime } from "@/effect/runtime"
-import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
+import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect"
 import { type AccountError } from "@/account/schema"
 import * as Prompt from "../effect/prompt"
 import open from "open"
@@ -17,7 +17,7 @@ const isActiveOrgChoice = (
 ) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
 
 const loginEffect = Effect.fn("login")(function* (url: string) {
-  const service = yield* AccountService
+  const service = yield* AccountEffect.Service
 
   yield* Prompt.intro("Log in")
   const login = yield* service.login(url)
@@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
 })
 
 const logoutEffect = Effect.fn("logout")(function* (email?: string) {
-  const service = yield* AccountService
+  const service = yield* AccountEffect.Service
   const accounts = yield* service.list()
   if (accounts.length === 0) return yield* println("Not logged in")
 
@@ -98,7 +98,7 @@ interface OrgChoice {
 }
 
 const switchEffect = Effect.fn("switch")(function* () {
-  const service = yield* AccountService
+  const service = yield* AccountEffect.Service
 
   const groups = yield* service.orgsByAccount()
   if (groups.length === 0) return yield* println("Not logged in")
@@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
 })
 
 const orgsEffect = Effect.fn("orgs")(function* () {
-  const service = yield* AccountService
+  const service = yield* AccountEffect.Service
 
   const groups = yield* service.orgsByAccount()
   if (groups.length === 0) return yield* println("No accounts found")

+ 31 - 33
packages/opencode/src/effect/instances.ts

@@ -1,31 +1,31 @@
 import { Effect, Layer, LayerMap, ServiceMap } from "effect"
-import { FileService } from "@/file"
-import { FileTimeService } from "@/file/time"
-import { FileWatcherService } from "@/file/watcher"
-import { FormatService } from "@/format"
-import { PermissionEffect } from "@/permission/service"
+import { File } from "@/file"
+import { FileTime } from "@/file/time"
+import { FileWatcher } from "@/file/watcher"
+import { Format } from "@/format"
+import { PermissionNext } from "@/permission"
 import { Instance } from "@/project/instance"
-import { VcsService } from "@/project/vcs"
-import { ProviderAuthService } from "@/provider/auth-service"
-import { QuestionService } from "@/question/service"
-import { SkillService } from "@/skill/skill"
-import { SnapshotService } from "@/snapshot"
+import { Vcs } from "@/project/vcs"
+import { ProviderAuth } from "@/provider/auth"
+import { Question } from "@/question"
+import { Skill } from "@/skill/skill"
+import { Snapshot } from "@/snapshot"
 import { InstanceContext } from "./instance-context"
 import { registerDisposer } from "./instance-registry"
 
 export { InstanceContext } from "./instance-context"
 
 export type InstanceServices =
-  | QuestionService
-  | PermissionEffect.Service
-  | ProviderAuthService
-  | FileWatcherService
-  | VcsService
-  | FileTimeService
-  | FormatService
-  | FileService
-  | SkillService
-  | SnapshotService
+  | Question.Service
+  | PermissionNext.Service
+  | ProviderAuth.Service
+  | FileWatcher.Service
+  | Vcs.Service
+  | FileTime.Service
+  | Format.Service
+  | File.Service
+  | Skill.Service
+  | Snapshot.Service
 
 // NOTE: LayerMap only passes the key (directory string) to lookup, but we need
 // the full instance context (directory, worktree, project). We read from the
@@ -36,16 +36,16 @@ export type InstanceServices =
 function lookup(_key: string) {
   const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
   return Layer.mergeAll(
-    Layer.fresh(QuestionService.layer),
-    Layer.fresh(PermissionEffect.layer),
-    Layer.fresh(ProviderAuthService.layer),
-    Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
-    Layer.fresh(VcsService.layer),
-    Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
-    Layer.fresh(FormatService.layer),
-    Layer.fresh(FileService.layer),
-    Layer.fresh(SkillService.layer),
-    Layer.fresh(SnapshotService.layer),
+    Layer.fresh(Question.layer),
+    Layer.fresh(PermissionNext.layer),
+    Layer.fresh(ProviderAuth.defaultLayer),
+    Layer.fresh(FileWatcher.layer).pipe(Layer.orDie),
+    Layer.fresh(Vcs.layer),
+    Layer.fresh(FileTime.layer).pipe(Layer.orDie),
+    Layer.fresh(Format.layer),
+    Layer.fresh(File.layer),
+    Layer.fresh(Skill.defaultLayer),
+    Layer.fresh(Snapshot.defaultLayer),
   ).pipe(Layer.provide(ctx))
 }
 
@@ -55,9 +55,7 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
   static readonly layer = Layer.effect(
     Instances,
     Effect.gen(function* () {
-      const layerMap = yield* LayerMap.make(lookup, {
-        idleTimeToLive: Infinity,
-      })
+      const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
       const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
       yield* Effect.addFinalizer(() => Effect.sync(unregister))
       return Instances.of(layerMap)

+ 4 - 4
packages/opencode/src/effect/runtime.ts

@@ -1,6 +1,6 @@
 import { Effect, Layer, ManagedRuntime } from "effect"
-import { AccountService } from "@/account/service"
-import { AuthService } from "@/auth/service"
+import { AccountEffect } from "@/account/effect"
+import { AuthEffect } from "@/auth/effect"
 import { Instances } from "@/effect/instances"
 import type { InstanceServices } from "@/effect/instances"
 import { TruncateEffect } from "@/tool/truncate-effect"
@@ -8,10 +8,10 @@ import { Instance } from "@/project/instance"
 
 export const runtime = ManagedRuntime.make(
   Layer.mergeAll(
-    AccountService.defaultLayer, //
+    AccountEffect.defaultLayer, //
     TruncateEffect.defaultLayer,
     Instances.layer,
-  ).pipe(Layer.provideMerge(AuthService.defaultLayer)),
+  ).pipe(Layer.provideMerge(AuthEffect.layer)),
 )
 
 export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {

+ 391 - 420
packages/opencode/src/file/index.ts

@@ -1,272 +1,20 @@
 import { BusEvent } from "@/bus/bus-event"
-import z from "zod"
+import { InstanceContext } from "@/effect/instance-context"
+import { runPromiseInstance } from "@/effect/runtime"
+import { git } from "@/util/git"
+import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
 import { formatPatch, structuredPatch } from "diff"
-import path from "path"
 import fs from "fs"
-import ignore from "ignore"
-import { Log } from "../util/log"
-import { Filesystem } from "../util/filesystem"
-import { Instance } from "../project/instance"
-import { Ripgrep } from "./ripgrep"
 import fuzzysort from "fuzzysort"
+import ignore from "ignore"
+import path from "path"
+import z from "zod"
 import { Global } from "../global"
-import { git } from "@/util/git"
+import { Instance } from "../project/instance"
+import { Filesystem } from "../util/filesystem"
+import { Log } from "../util/log"
 import { Protected } from "./protected"
-import { InstanceContext } from "@/effect/instance-context"
-import { Effect, Layer, ServiceMap } from "effect"
-import { runPromiseInstance } from "@/effect/runtime"
-
-const log = Log.create({ service: "file" })
-
-const binaryExtensions = new Set([
-  "exe",
-  "dll",
-  "pdb",
-  "bin",
-  "so",
-  "dylib",
-  "o",
-  "a",
-  "lib",
-  "wav",
-  "mp3",
-  "ogg",
-  "oga",
-  "ogv",
-  "ogx",
-  "flac",
-  "aac",
-  "wma",
-  "m4a",
-  "weba",
-  "mp4",
-  "avi",
-  "mov",
-  "wmv",
-  "flv",
-  "webm",
-  "mkv",
-  "zip",
-  "tar",
-  "gz",
-  "gzip",
-  "bz",
-  "bz2",
-  "bzip",
-  "bzip2",
-  "7z",
-  "rar",
-  "xz",
-  "lz",
-  "z",
-  "pdf",
-  "doc",
-  "docx",
-  "ppt",
-  "pptx",
-  "xls",
-  "xlsx",
-  "dmg",
-  "iso",
-  "img",
-  "vmdk",
-  "ttf",
-  "otf",
-  "woff",
-  "woff2",
-  "eot",
-  "sqlite",
-  "db",
-  "mdb",
-  "apk",
-  "ipa",
-  "aab",
-  "xapk",
-  "app",
-  "pkg",
-  "deb",
-  "rpm",
-  "snap",
-  "flatpak",
-  "appimage",
-  "msi",
-  "msp",
-  "jar",
-  "war",
-  "ear",
-  "class",
-  "kotlin_module",
-  "dex",
-  "vdex",
-  "odex",
-  "oat",
-  "art",
-  "wasm",
-  "wat",
-  "bc",
-  "ll",
-  "s",
-  "ko",
-  "sys",
-  "drv",
-  "efi",
-  "rom",
-  "com",
-  "cmd",
-  "ps1",
-  "sh",
-  "bash",
-  "zsh",
-  "fish",
-])
-
-const imageExtensions = new Set([
-  "png",
-  "jpg",
-  "jpeg",
-  "gif",
-  "bmp",
-  "webp",
-  "ico",
-  "tif",
-  "tiff",
-  "svg",
-  "svgz",
-  "avif",
-  "apng",
-  "jxl",
-  "heic",
-  "heif",
-  "raw",
-  "cr2",
-  "nef",
-  "arw",
-  "dng",
-  "orf",
-  "raf",
-  "pef",
-  "x3f",
-])
-
-const textExtensions = new Set([
-  "ts",
-  "tsx",
-  "mts",
-  "cts",
-  "mtsx",
-  "ctsx",
-  "js",
-  "jsx",
-  "mjs",
-  "cjs",
-  "sh",
-  "bash",
-  "zsh",
-  "fish",
-  "ps1",
-  "psm1",
-  "cmd",
-  "bat",
-  "json",
-  "jsonc",
-  "json5",
-  "yaml",
-  "yml",
-  "toml",
-  "md",
-  "mdx",
-  "txt",
-  "xml",
-  "html",
-  "htm",
-  "css",
-  "scss",
-  "sass",
-  "less",
-  "graphql",
-  "gql",
-  "sql",
-  "ini",
-  "cfg",
-  "conf",
-  "env",
-])
-
-const textNames = new Set([
-  "dockerfile",
-  "makefile",
-  ".gitignore",
-  ".gitattributes",
-  ".editorconfig",
-  ".npmrc",
-  ".nvmrc",
-  ".prettierrc",
-  ".eslintrc",
-])
-
-function isImageByExtension(filepath: string): boolean {
-  const ext = path.extname(filepath).toLowerCase().slice(1)
-  return imageExtensions.has(ext)
-}
-
-function isTextByExtension(filepath: string): boolean {
-  const ext = path.extname(filepath).toLowerCase().slice(1)
-  return textExtensions.has(ext)
-}
-
-function isTextByName(filepath: string): boolean {
-  const name = path.basename(filepath).toLowerCase()
-  return textNames.has(name)
-}
-
-function getImageMimeType(filepath: string): string {
-  const ext = path.extname(filepath).toLowerCase().slice(1)
-  const mimeTypes: Record<string, string> = {
-    png: "image/png",
-    jpg: "image/jpeg",
-    jpeg: "image/jpeg",
-    gif: "image/gif",
-    bmp: "image/bmp",
-    webp: "image/webp",
-    ico: "image/x-icon",
-    tif: "image/tiff",
-    tiff: "image/tiff",
-    svg: "image/svg+xml",
-    svgz: "image/svg+xml",
-    avif: "image/avif",
-    apng: "image/apng",
-    jxl: "image/jxl",
-    heic: "image/heic",
-    heif: "image/heif",
-  }
-  return mimeTypes[ext] || "image/" + ext
-}
-
-function isBinaryByExtension(filepath: string): boolean {
-  const ext = path.extname(filepath).toLowerCase().slice(1)
-  return binaryExtensions.has(ext)
-}
-
-function isImage(mimeType: string): boolean {
-  return mimeType.startsWith("image/")
-}
-
-function shouldEncode(mimeType: string): boolean {
-  const type = mimeType.toLowerCase()
-  log.info("shouldEncode", { type })
-  if (!type) return false
-
-  if (type.startsWith("text/")) return false
-  if (type.includes("charset=")) return false
-
-  const parts = type.split("/", 2)
-  const top = parts[0]
-
-  const tops = ["image", "audio", "video", "font", "model", "multipart"]
-  if (tops.includes(top)) return true
-
-  return false
-}
+import { Ripgrep } from "./ripgrep"
 
 export namespace File {
   export const Info = z
@@ -336,28 +84,270 @@ export namespace File {
   }
 
   export function init() {
-    return runPromiseInstance(FileService.use((s) => s.init()))
+    return runPromiseInstance(Service.use((svc) => svc.init()))
   }
 
   export async function status() {
-    return runPromiseInstance(FileService.use((s) => s.status()))
+    return runPromiseInstance(Service.use((svc) => svc.status()))
   }
 
   export async function read(file: string): Promise<Content> {
-    return runPromiseInstance(FileService.use((s) => s.read(file)))
+    return runPromiseInstance(Service.use((svc) => svc.read(file)))
   }
 
   export async function list(dir?: string) {
-    return runPromiseInstance(FileService.use((s) => s.list(dir)))
+    return runPromiseInstance(Service.use((svc) => svc.list(dir)))
   }
 
   export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
-    return runPromiseInstance(FileService.use((s) => s.search(input)))
+    return runPromiseInstance(Service.use((svc) => svc.search(input)))
+  }
+
+  const log = Log.create({ service: "file" })
+
+  const binary = new Set([
+    "exe",
+    "dll",
+    "pdb",
+    "bin",
+    "so",
+    "dylib",
+    "o",
+    "a",
+    "lib",
+    "wav",
+    "mp3",
+    "ogg",
+    "oga",
+    "ogv",
+    "ogx",
+    "flac",
+    "aac",
+    "wma",
+    "m4a",
+    "weba",
+    "mp4",
+    "avi",
+    "mov",
+    "wmv",
+    "flv",
+    "webm",
+    "mkv",
+    "zip",
+    "tar",
+    "gz",
+    "gzip",
+    "bz",
+    "bz2",
+    "bzip",
+    "bzip2",
+    "7z",
+    "rar",
+    "xz",
+    "lz",
+    "z",
+    "pdf",
+    "doc",
+    "docx",
+    "ppt",
+    "pptx",
+    "xls",
+    "xlsx",
+    "dmg",
+    "iso",
+    "img",
+    "vmdk",
+    "ttf",
+    "otf",
+    "woff",
+    "woff2",
+    "eot",
+    "sqlite",
+    "db",
+    "mdb",
+    "apk",
+    "ipa",
+    "aab",
+    "xapk",
+    "app",
+    "pkg",
+    "deb",
+    "rpm",
+    "snap",
+    "flatpak",
+    "appimage",
+    "msi",
+    "msp",
+    "jar",
+    "war",
+    "ear",
+    "class",
+    "kotlin_module",
+    "dex",
+    "vdex",
+    "odex",
+    "oat",
+    "art",
+    "wasm",
+    "wat",
+    "bc",
+    "ll",
+    "s",
+    "ko",
+    "sys",
+    "drv",
+    "efi",
+    "rom",
+    "com",
+    "cmd",
+    "ps1",
+    "sh",
+    "bash",
+    "zsh",
+    "fish",
+  ])
+
+  const image = new Set([
+    "png",
+    "jpg",
+    "jpeg",
+    "gif",
+    "bmp",
+    "webp",
+    "ico",
+    "tif",
+    "tiff",
+    "svg",
+    "svgz",
+    "avif",
+    "apng",
+    "jxl",
+    "heic",
+    "heif",
+    "raw",
+    "cr2",
+    "nef",
+    "arw",
+    "dng",
+    "orf",
+    "raf",
+    "pef",
+    "x3f",
+  ])
+
+  const text = new Set([
+    "ts",
+    "tsx",
+    "mts",
+    "cts",
+    "mtsx",
+    "ctsx",
+    "js",
+    "jsx",
+    "mjs",
+    "cjs",
+    "sh",
+    "bash",
+    "zsh",
+    "fish",
+    "ps1",
+    "psm1",
+    "cmd",
+    "bat",
+    "json",
+    "jsonc",
+    "json5",
+    "yaml",
+    "yml",
+    "toml",
+    "md",
+    "mdx",
+    "txt",
+    "xml",
+    "html",
+    "htm",
+    "css",
+    "scss",
+    "sass",
+    "less",
+    "graphql",
+    "gql",
+    "sql",
+    "ini",
+    "cfg",
+    "conf",
+    "env",
+  ])
+
+  const textName = new Set([
+    "dockerfile",
+    "makefile",
+    ".gitignore",
+    ".gitattributes",
+    ".editorconfig",
+    ".npmrc",
+    ".nvmrc",
+    ".prettierrc",
+    ".eslintrc",
+  ])
+
+  const mime: Record<string, string> = {
+    png: "image/png",
+    jpg: "image/jpeg",
+    jpeg: "image/jpeg",
+    gif: "image/gif",
+    bmp: "image/bmp",
+    webp: "image/webp",
+    ico: "image/x-icon",
+    tif: "image/tiff",
+    tiff: "image/tiff",
+    svg: "image/svg+xml",
+    svgz: "image/svg+xml",
+    avif: "image/avif",
+    apng: "image/apng",
+    jxl: "image/jxl",
+    heic: "image/heic",
+    heif: "image/heif",
+  }
+
+  type Entry = { files: string[]; dirs: string[] }
+
+  const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
+  const name = (file: string) => path.basename(file).toLowerCase()
+  const isImageByExtension = (file: string) => image.has(ext(file))
+  const isTextByExtension = (file: string) => text.has(ext(file))
+  const isTextByName = (file: string) => textName.has(name(file))
+  const isBinaryByExtension = (file: string) => binary.has(ext(file))
+  const isImage = (mimeType: string) => mimeType.startsWith("image/")
+  const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
+
+  function shouldEncode(mimeType: string) {
+    const type = mimeType.toLowerCase()
+    log.info("shouldEncode", { type })
+    if (!type) return false
+    if (type.startsWith("text/")) return false
+    if (type.includes("charset=")) return false
+    const top = type.split("/", 2)[0]
+    return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
+  }
+
+  const hidden = (item: string) => {
+    const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
+    return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
+  }
+
+  const sortHiddenLast = (items: string[], prefer: boolean) => {
+    if (prefer) return items
+    const visible: string[] = []
+    const hiddenItems: string[] = []
+    for (const item of items) {
+      if (hidden(item)) hiddenItems.push(item)
+      else visible.push(item)
+    }
+    return [...visible, ...hiddenItems]
   }
-}
 
-export namespace FileService {
-  export interface Service {
+  export interface Interface {
     readonly init: () => Effect.Effect<void>
     readonly status: () => Effect.Effect<File.Info[]>
     readonly read: (file: string) => Effect.Effect<File.Content>
@@ -369,89 +359,83 @@ export namespace FileService {
       type?: "file" | "directory"
     }) => Effect.Effect<string[]>
   }
-}
 
-export class FileService extends ServiceMap.Service<FileService, FileService.Service>()("@opencode/File") {
-  static readonly layer = Layer.effect(
-    FileService,
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
+
+  export const layer = Layer.effect(
+    Service,
     Effect.gen(function* () {
       const instance = yield* InstanceContext
-
-      // File cache state
-      type Entry = { files: string[]; dirs: string[] }
       let cache: Entry = { files: [], dirs: [] }
-      let task: Promise<void> | undefined
-
       const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
 
-      function kick() {
-        if (task) return task
-        task = (async () => {
-          // Disable scanning if in root of file system
-          if (instance.directory === path.parse(instance.directory).root) return
-          const next: Entry = { files: [], dirs: [] }
-          try {
-            if (isGlobalHome) {
-              const dirs = new Set<string>()
-              const protectedNames = Protected.names()
-
-              const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
-              const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
-              const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
-
-              const top = await fs.promises
-                .readdir(instance.directory, { withFileTypes: true })
-                .catch(() => [] as fs.Dirent[])
-
-              for (const entry of top) {
-                if (!entry.isDirectory()) continue
-                if (shouldIgnoreName(entry.name)) continue
-                dirs.add(entry.name + "/")
-
-                const base = path.join(instance.directory, entry.name)
-                const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
-                for (const child of children) {
-                  if (!child.isDirectory()) continue
-                  if (shouldIgnoreNested(child.name)) continue
-                  dirs.add(entry.name + "/" + child.name + "/")
-                }
+      const scan = Effect.fn("File.scan")(function* () {
+        if (instance.directory === path.parse(instance.directory).root) return
+        const next: Entry = { files: [], dirs: [] }
+
+        yield* Effect.promise(async () => {
+          if (isGlobalHome) {
+            const dirs = new Set<string>()
+            const protectedNames = Protected.names()
+            const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
+            const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
+            const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
+            const top = await fs.promises
+              .readdir(instance.directory, { withFileTypes: true })
+              .catch(() => [] as fs.Dirent[])
+
+            for (const entry of top) {
+              if (!entry.isDirectory()) continue
+              if (shouldIgnoreName(entry.name)) continue
+              dirs.add(entry.name + "/")
+
+              const base = path.join(instance.directory, entry.name)
+              const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
+              for (const child of children) {
+                if (!child.isDirectory()) continue
+                if (shouldIgnoreNested(child.name)) continue
+                dirs.add(entry.name + "/" + child.name + "/")
               }
+            }
 
-              next.dirs = Array.from(dirs).toSorted()
-            } else {
-              const set = new Set<string>()
-              for await (const file of Ripgrep.files({ cwd: instance.directory })) {
-                next.files.push(file)
-                let current = file
-                while (true) {
-                  const dir = path.dirname(current)
-                  if (dir === ".") break
-                  if (dir === current) break
-                  current = dir
-                  if (set.has(dir)) continue
-                  set.add(dir)
-                  next.dirs.push(dir + "/")
-                }
+            next.dirs = Array.from(dirs).toSorted()
+          } else {
+            const seen = new Set<string>()
+            for await (const file of Ripgrep.files({ cwd: instance.directory })) {
+              next.files.push(file)
+              let current = file
+              while (true) {
+                const dir = path.dirname(current)
+                if (dir === ".") break
+                if (dir === current) break
+                current = dir
+                if (seen.has(dir)) continue
+                seen.add(dir)
+                next.dirs.push(dir + "/")
               }
             }
-            cache = next
-          } finally {
-            task = undefined
           }
-        })()
-        return task
-      }
+        })
 
-      const getFiles = async () => {
-        void kick()
-        return cache
-      }
+        cache = next
+      })
 
-      const init = Effect.fn("FileService.init")(function* () {
-        yield* Effect.promise(() => kick())
+      const getFiles = () => cache
+
+      const scope = yield* Scope.Scope
+      let fiber: Fiber.Fiber<void> | undefined
+
+      const init = Effect.fn("File.init")(function* () {
+        if (!fiber) {
+          fiber = yield* scan().pipe(
+            Effect.catchCause(() => Effect.void),
+            Effect.forkIn(scope),
+          )
+        }
+        yield* Fiber.join(fiber)
       })
 
-      const status = Effect.fn("FileService.status")(function* () {
+      const status = Effect.fn("File.status")(function* () {
         if (instance.project.vcs !== "git") return []
 
         return yield* Effect.promise(async () => {
@@ -461,14 +445,13 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
             })
           ).text()
 
-          const changedFiles: File.Info[] = []
+          const changed: File.Info[] = []
 
           if (diffOutput.trim()) {
-            const lines = diffOutput.trim().split("\n")
-            for (const line of lines) {
-              const [added, removed, filepath] = line.split("\t")
-              changedFiles.push({
-                path: filepath,
+            for (const line of diffOutput.trim().split("\n")) {
+              const [added, removed, file] = line.split("\t")
+              changed.push({
+                path: file,
                 added: added === "-" ? 0 : parseInt(added, 10),
                 removed: removed === "-" ? 0 : parseInt(removed, 10),
                 status: "modified",
@@ -494,14 +477,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
           ).text()
 
           if (untrackedOutput.trim()) {
-            const untrackedFiles = untrackedOutput.trim().split("\n")
-            for (const filepath of untrackedFiles) {
+            for (const file of untrackedOutput.trim().split("\n")) {
               try {
-                const content = await Filesystem.readText(path.join(instance.directory, filepath))
-                const lines = content.split("\n").length
-                changedFiles.push({
-                  path: filepath,
-                  added: lines,
+                const content = await Filesystem.readText(path.join(instance.directory, file))
+                changed.push({
+                  path: file,
+                  added: content.split("\n").length,
                   removed: 0,
                   status: "added",
                 })
@@ -511,7 +492,6 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
             }
           }
 
-          // Get deleted files
           const deletedOutput = (
             await git(
               [
@@ -531,50 +511,51 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
           ).text()
 
           if (deletedOutput.trim()) {
-            const deletedFiles = deletedOutput.trim().split("\n")
-            for (const filepath of deletedFiles) {
-              changedFiles.push({
-                path: filepath,
+            for (const file of deletedOutput.trim().split("\n")) {
+              changed.push({
+                path: file,
                 added: 0,
-                removed: 0, // Could get original line count but would require another git command
+                removed: 0,
                 status: "deleted",
               })
             }
           }
 
-          return changedFiles.map((x) => {
-            const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path)
+          return changed.map((item) => {
+            const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
             return {
-              ...x,
+              ...item,
               path: path.relative(instance.directory, full),
             }
           })
         })
       })
 
-      const read = Effect.fn("FileService.read")(function* (file: string) {
+      const read = Effect.fn("File.read")(function* (file: string) {
         return yield* Effect.promise(async (): Promise<File.Content> => {
           using _ = log.time("read", { file })
           const full = path.join(instance.directory, file)
 
           if (!Instance.containsPath(full)) {
-            throw new Error(`Access denied: path escapes project directory`)
+            throw new Error("Access denied: path escapes project directory")
           }
 
-          // Fast path: check extension before any filesystem operations
           if (isImageByExtension(file)) {
             if (await Filesystem.exists(full)) {
               const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
-              const content = buffer.toString("base64")
-              const mimeType = getImageMimeType(file)
-              return { type: "text", content, mimeType, encoding: "base64" }
+              return {
+                type: "text",
+                content: buffer.toString("base64"),
+                mimeType: getImageMimeType(file),
+                encoding: "base64",
+              }
             }
             return { type: "text", content: "" }
           }
 
-          const text = isTextByExtension(file) || isTextByName(file)
+          const knownText = isTextByExtension(file) || isTextByName(file)
 
-          if (isBinaryByExtension(file) && !text) {
+          if (isBinaryByExtension(file) && !knownText) {
             return { type: "binary", content: "" }
           }
 
@@ -583,7 +564,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
           }
 
           const mimeType = Filesystem.mimeType(full)
-          const encode = text ? false : shouldEncode(mimeType)
+          const encode = knownText ? false : shouldEncode(mimeType)
 
           if (encode && !isImage(mimeType)) {
             return { type: "binary", content: "", mimeType }
@@ -591,8 +572,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
 
           if (encode) {
             const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
-            const content = buffer.toString("base64")
-            return { type: "text", content, mimeType, encoding: "base64" }
+            return {
+              type: "text",
+              content: buffer.toString("base64"),
+              mimeType,
+              encoding: "base64",
+            }
           }
 
           const content = (await Filesystem.readText(full).catch(() => "")).trim()
@@ -603,7 +588,9 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
             ).text()
             if (!diff.trim()) {
               diff = (
-                await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory })
+                await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
+                  cwd: instance.directory,
+                })
               ).text()
             }
             if (diff.trim()) {
@@ -612,64 +599,64 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
                 context: Infinity,
                 ignoreWhitespace: true,
               })
-              const diff = formatPatch(patch)
-              return { type: "text", content, patch, diff }
+              return {
+                type: "text",
+                content,
+                patch,
+                diff: formatPatch(patch),
+              }
             }
           }
+
           return { type: "text", content }
         })
       })
 
-      const list = Effect.fn("FileService.list")(function* (dir?: string) {
+      const list = Effect.fn("File.list")(function* (dir?: string) {
         return yield* Effect.promise(async () => {
           const exclude = [".git", ".DS_Store"]
           let ignored = (_: string) => false
           if (instance.project.vcs === "git") {
             const ig = ignore()
-            const gitignorePath = path.join(instance.project.worktree, ".gitignore")
-            if (await Filesystem.exists(gitignorePath)) {
-              ig.add(await Filesystem.readText(gitignorePath))
+            const gitignore = path.join(instance.project.worktree, ".gitignore")
+            if (await Filesystem.exists(gitignore)) {
+              ig.add(await Filesystem.readText(gitignore))
             }
-            const ignorePath = path.join(instance.project.worktree, ".ignore")
-            if (await Filesystem.exists(ignorePath)) {
-              ig.add(await Filesystem.readText(ignorePath))
+            const ignoreFile = path.join(instance.project.worktree, ".ignore")
+            if (await Filesystem.exists(ignoreFile)) {
+              ig.add(await Filesystem.readText(ignoreFile))
             }
             ignored = ig.ignores.bind(ig)
           }
-          const resolved = dir ? path.join(instance.directory, dir) : instance.directory
 
+          const resolved = dir ? path.join(instance.directory, dir) : instance.directory
           if (!Instance.containsPath(resolved)) {
-            throw new Error(`Access denied: path escapes project directory`)
+            throw new Error("Access denied: path escapes project directory")
           }
 
           const nodes: File.Node[] = []
-          for (const entry of await fs.promises
-            .readdir(resolved, {
-              withFileTypes: true,
-            })
-            .catch(() => [])) {
+          for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
             if (exclude.includes(entry.name)) continue
-            const fullPath = path.join(resolved, entry.name)
-            const relativePath = path.relative(instance.directory, fullPath)
+            const absolute = path.join(resolved, entry.name)
+            const file = path.relative(instance.directory, absolute)
             const type = entry.isDirectory() ? "directory" : "file"
             nodes.push({
               name: entry.name,
-              path: relativePath,
-              absolute: fullPath,
+              path: file,
+              absolute,
               type,
-              ignored: ignored(type === "directory" ? relativePath + "/" : relativePath),
+              ignored: ignored(type === "directory" ? file + "/" : file),
             })
           }
+
           return nodes.sort((a, b) => {
-            if (a.type !== b.type) {
-              return a.type === "directory" ? -1 : 1
-            }
+            if (a.type !== b.type) return a.type === "directory" ? -1 : 1
             return a.name.localeCompare(b.name)
           })
         })
       })
 
-      const search = Effect.fn("FileService.search")(function* (input: {
+      const search = Effect.fn("File.search")(function* (input: {
         query: string
         limit?: number
         dirs?: boolean
@@ -681,35 +668,20 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
           const kind = input.type ?? (input.dirs === false ? "file" : "all")
           log.info("search", { query, kind })
 
-          const result = await getFiles()
-
-          const hidden = (item: string) => {
-            const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
-            return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
-          }
+          const result = getFiles()
           const preferHidden = query.startsWith(".") || query.includes("/.")
-          const sortHiddenLast = (items: string[]) => {
-            if (preferHidden) return items
-            const visible: string[] = []
-            const hiddenItems: string[] = []
-            for (const item of items) {
-              const isHidden = hidden(item)
-              if (isHidden) hiddenItems.push(item)
-              if (!isHidden) visible.push(item)
-            }
-            return [...visible, ...hiddenItems]
-          }
+
           if (!query) {
             if (kind === "file") return result.files.slice(0, limit)
-            return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
+            return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
           }
 
           const items =
             kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
 
           const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
-          const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target)
-          const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted
+          const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
+          const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
 
           log.info("search", { query, kind, results: output.length })
           return output
@@ -717,8 +689,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
       })
 
       log.info("init")
-
-      return FileService.of({ init, status, read, list, search })
+      return Service.of({ init, status, read, list, search })
     }),
   )
 }

+ 76 - 81
packages/opencode/src/file/time.ts

@@ -1,115 +1,110 @@
-import { Log } from "../util/log"
-import { Flag } from "@/flag/flag"
-import { Filesystem } from "../util/filesystem"
-import { Effect, Layer, ServiceMap, Semaphore } from "effect"
+import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
 import { runPromiseInstance } from "@/effect/runtime"
+import { Flag } from "@/flag/flag"
 import type { SessionID } from "@/session/schema"
+import { Filesystem } from "../util/filesystem"
+import { Log } from "../util/log"
+
+export namespace FileTime {
+  const log = Log.create({ service: "file.time" })
 
-const log = Log.create({ service: "file.time" })
+  export type Stamp = {
+    readonly read: Date
+    readonly mtime: number | undefined
+    readonly ctime: number | undefined
+    readonly size: number | undefined
+  }
+
+  const stamp = Effect.fnUntraced(function* (file: string) {
+    const stat = Filesystem.stat(file)
+    const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
+    return {
+      read: yield* DateTime.nowAsDate,
+      mtime: stat?.mtime?.getTime(),
+      ctime: stat?.ctime?.getTime(),
+      size,
+    }
+  })
+
+  const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
+    const value = reads.get(sessionID)
+    if (value) return value
+
+    const next = new Map<string, Stamp>()
+    reads.set(sessionID, next)
+    return next
+  }
 
-export namespace FileTimeService {
-  export interface Service {
+  export interface Interface {
     readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
     readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
     readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
     readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
   }
-}
-
-type Stamp = {
-  readonly read: Date
-  readonly mtime: number | undefined
-  readonly ctime: number | undefined
-  readonly size: number | undefined
-}
-
-function stamp(file: string): Stamp {
-  const stat = Filesystem.stat(file)
-  const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
-  return {
-    read: new Date(),
-    mtime: stat?.mtime?.getTime(),
-    ctime: stat?.ctime?.getTime(),
-    size,
-  }
-}
 
-function session(reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) {
-  let value = reads.get(sessionID)
-  if (!value) {
-    value = new Map<string, Stamp>()
-    reads.set(sessionID, value)
-  }
-  return value
-}
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
 
-export class FileTimeService extends ServiceMap.Service<FileTimeService, FileTimeService.Service>()(
-  "@opencode/FileTime",
-) {
-  static readonly layer = Layer.effect(
-    FileTimeService,
+  export const layer = Layer.effect(
+    Service,
     Effect.gen(function* () {
       const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
       const reads = new Map<SessionID, Map<string, Stamp>>()
       const locks = new Map<string, Semaphore.Semaphore>()
 
-      function getLock(filepath: string) {
-        let lock = locks.get(filepath)
-        if (!lock) {
-          lock = Semaphore.makeUnsafe(1)
-          locks.set(filepath, lock)
-        }
-        return lock
+      const getLock = (filepath: string) => {
+        const lock = locks.get(filepath)
+        if (lock) return lock
+
+        const next = Semaphore.makeUnsafe(1)
+        locks.set(filepath, next)
+        return next
       }
 
-      return FileTimeService.of({
-        read: Effect.fn("FileTimeService.read")(function* (sessionID: SessionID, file: string) {
-          log.info("read", { sessionID, file })
-          session(reads, sessionID).set(file, stamp(file))
-        }),
-
-        get: Effect.fn("FileTimeService.get")(function* (sessionID: SessionID, file: string) {
-          return reads.get(sessionID)?.get(file)?.read
-        }),
-
-        assert: Effect.fn("FileTimeService.assert")(function* (sessionID: SessionID, filepath: string) {
-          if (disableCheck) return
-
-          const time = reads.get(sessionID)?.get(filepath)
-          if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
-          const next = stamp(filepath)
-          const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
-
-          if (changed) {
-            throw new Error(
-              `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
-            )
-          }
-        }),
-
-        withLock: Effect.fn("FileTimeService.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
-          const lock = getLock(filepath)
-          return yield* Effect.promise(fn).pipe(lock.withPermits(1))
-        }),
+      const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
+        log.info("read", { sessionID, file })
+        session(reads, sessionID).set(file, yield* stamp(file))
+      })
+
+      const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
+        return reads.get(sessionID)?.get(file)?.read
+      })
+
+      const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
+        if (disableCheck) return
+
+        const time = reads.get(sessionID)?.get(filepath)
+        if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
+
+        const next = yield* stamp(filepath)
+        const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
+        if (!changed) return
+
+        throw new Error(
+          `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
+        )
+      })
+
+      const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
+        return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
       })
+
+      return Service.of({ read, get, assert, withLock })
     }),
   )
-}
 
-export namespace FileTime {
   export function read(sessionID: SessionID, file: string) {
-    return runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file)))
+    return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
   }
 
   export function get(sessionID: SessionID, file: string) {
-    return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file)))
+    return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
   }
 
   export async function assert(sessionID: SessionID, filepath: string) {
-    return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath)))
+    return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath)))
   }
 
   export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
-    return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn)))
+    return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
   }
 }

+ 52 - 66
packages/opencode/src/file/watcher.ts

@@ -1,89 +1,76 @@
-import { BusEvent } from "@/bus/bus-event"
-import { Bus } from "@/bus"
-import { InstanceContext } from "@/effect/instance-context"
-import { Instance } from "@/project/instance"
-import z from "zod"
-import { Log } from "../util/log"
-import { FileIgnore } from "./ignore"
-import { Config } from "../config/config"
-import path from "path"
+import { Cause, Effect, Layer, ServiceMap } from "effect"
 // @ts-ignore
 import { createWrapper } from "@parcel/watcher/wrapper"
-import { lazy } from "@/util/lazy"
 import type ParcelWatcher from "@parcel/watcher"
 import { readdir } from "fs/promises"
+import path from "path"
+import z from "zod"
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { InstanceContext } from "@/effect/instance-context"
+import { Flag } from "@/flag/flag"
+import { Instance } from "@/project/instance"
 import { git } from "@/util/git"
+import { lazy } from "@/util/lazy"
+import { Config } from "../config/config"
+import { FileIgnore } from "./ignore"
 import { Protected } from "./protected"
-import { Flag } from "@/flag/flag"
-import { Cause, Effect, Layer, ServiceMap } from "effect"
-
-const SUBSCRIBE_TIMEOUT_MS = 10_000
+import { Log } from "../util/log"
 
 declare const OPENCODE_LIBC: string | undefined
 
-const log = Log.create({ service: "file.watcher" })
-
-const event = {
-  Updated: BusEvent.define(
-    "file.watcher.updated",
-    z.object({
-      file: z.string(),
-      event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
-    }),
-  ),
-}
-
-const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
-  try {
-    const binding = require(
-      `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
-    )
-    return createWrapper(binding) as typeof import("@parcel/watcher")
-  } catch (error) {
-    log.error("failed to load watcher binding", { error })
-    return
+export namespace FileWatcher {
+  const log = Log.create({ service: "file.watcher" })
+  const SUBSCRIBE_TIMEOUT_MS = 10_000
+
+  export const Event = {
+    Updated: BusEvent.define(
+      "file.watcher.updated",
+      z.object({
+        file: z.string(),
+        event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
+      }),
+    ),
   }
-})
 
-function getBackend() {
-  if (process.platform === "win32") return "windows"
-  if (process.platform === "darwin") return "fs-events"
-  if (process.platform === "linux") return "inotify"
-}
+  const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
+    try {
+      const binding = require(
+        `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
+      )
+      return createWrapper(binding) as typeof import("@parcel/watcher")
+    } catch (error) {
+      log.error("failed to load watcher binding", { error })
+      return
+    }
+  })
+
+  function getBackend() {
+    if (process.platform === "win32") return "windows"
+    if (process.platform === "darwin") return "fs-events"
+    if (process.platform === "linux") return "inotify"
+  }
 
-export namespace FileWatcher {
-  export const Event = event
-  /** Whether the native @parcel/watcher binding is available on this platform. */
   export const hasNativeBinding = () => !!watcher()
-}
-
-const init = Effect.fn("FileWatcherService.init")(function* () {})
 
-export namespace FileWatcherService {
-  export interface Service {
-    readonly init: () => Effect.Effect<void>
-  }
-}
+  export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
 
-export class FileWatcherService extends ServiceMap.Service<FileWatcherService, FileWatcherService.Service>()(
-  "@opencode/FileWatcher",
-) {
-  static readonly layer = Layer.effect(
-    FileWatcherService,
+  export const layer = Layer.effect(
+    Service,
     Effect.gen(function* () {
       const instance = yield* InstanceContext
-      if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return FileWatcherService.of({ init })
+      if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
 
       log.info("init", { directory: instance.directory })
 
       const backend = getBackend()
       if (!backend) {
         log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
-        return FileWatcherService.of({ init })
+        return Service.of({})
       }
 
       const w = watcher()
-      if (!w) return FileWatcherService.of({ init })
+      if (!w) return Service.of({})
 
       log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
 
@@ -93,9 +80,9 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
       const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
         if (err) return
         for (const evt of evts) {
-          if (evt.type === "create") Bus.publish(event.Updated, { file: evt.path, event: "add" })
-          if (evt.type === "update") Bus.publish(event.Updated, { file: evt.path, event: "change" })
-          if (evt.type === "delete") Bus.publish(event.Updated, { file: evt.path, event: "unlink" })
+          if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
+          if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
+          if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
         }
       })
 
@@ -108,7 +95,6 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
           Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
           Effect.catchCause((cause) => {
             log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
-            // Clean up a subscription that resolves after timeout
             pending.then((s) => s.unsubscribe()).catch(() => {})
             return Effect.void
           }),
@@ -137,11 +123,11 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
         }
       }
 
-      return FileWatcherService.of({ init })
+      return Service.of({})
     }).pipe(
       Effect.catchCause((cause) => {
         log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
-        return Effect.succeed(FileWatcherService.of({ init }))
+        return Effect.succeed(Service.of({}))
       }),
     ),
   )

+ 71 - 75
packages/opencode/src/format/index.ts

@@ -1,21 +1,20 @@
-import { Bus } from "../bus"
-import { File } from "../file"
-import { Log } from "../util/log"
+import { Effect, Layer, ServiceMap } from "effect"
+import { runPromiseInstance } from "@/effect/runtime"
+import { InstanceContext } from "@/effect/instance-context"
 import path from "path"
+import { mergeDeep } from "remeda"
 import z from "zod"
-
-import * as Formatter from "./formatter"
+import { Bus } from "../bus"
 import { Config } from "../config/config"
-import { mergeDeep } from "remeda"
+import { File } from "../file"
 import { Instance } from "../project/instance"
 import { Process } from "../util/process"
-import { InstanceContext } from "@/effect/instance-context"
-import { Effect, Layer, ServiceMap } from "effect"
-import { runPromiseInstance } from "@/effect/runtime"
-
-const log = Log.create({ service: "format" })
+import { Log } from "../util/log"
+import * as Formatter from "./formatter"
 
 export namespace Format {
+  const log = Log.create({ service: "format" })
+
   export const Status = z
     .object({
       name: z.string(),
@@ -27,25 +26,14 @@ export namespace Format {
     })
   export type Status = z.infer<typeof Status>
 
-  export async function init() {
-    return runPromiseInstance(FormatService.use((s) => s.init()))
-  }
-
-  export async function status() {
-    return runPromiseInstance(FormatService.use((s) => s.status()))
+  export interface Interface {
+    readonly status: () => Effect.Effect<Status[]>
   }
-}
 
-export namespace FormatService {
-  export interface Service {
-    readonly init: () => Effect.Effect<void>
-    readonly status: () => Effect.Effect<Format.Status[]>
-  }
-}
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
 
-export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") {
-  static readonly layer = Layer.effect(
-    FormatService,
+  export const layer = Layer.effect(
+    Service,
     Effect.gen(function* () {
       const instance = yield* InstanceContext
 
@@ -63,17 +51,19 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
             delete formatters[name]
             continue
           }
-          const result = mergeDeep(formatters[name] ?? {}, {
+          const info = mergeDeep(formatters[name] ?? {}, {
             command: [],
             extensions: [],
             ...item,
-          }) as Formatter.Info
+          })
 
-          if (result.command.length === 0) continue
+          if (info.command.length === 0) continue
 
-          result.enabled = async () => true
-          result.name = name
-          formatters[name] = result
+          formatters[name] = {
+            ...info,
+            name,
+            enabled: async () => true,
+          }
         }
       } else {
         log.info("all formatters are disabled")
@@ -100,50 +90,52 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
         return result
       }
 
-      const unsubscribe = Bus.subscribe(
-        File.Event.Edited,
-        Instance.bind(async (payload) => {
-          const file = payload.properties.file
-          log.info("formatting", { file })
-          const ext = path.extname(file)
-
-          for (const item of await getFormatter(ext)) {
-            log.info("running", { command: item.command })
-            try {
-              const proc = Process.spawn(
-                item.command.map((x) => x.replace("$FILE", file)),
-                {
-                  cwd: instance.directory,
-                  env: { ...process.env, ...item.environment },
-                  stdout: "ignore",
-                  stderr: "ignore",
-                },
-              )
-              const exit = await proc.exited
-              if (exit !== 0)
-                log.error("failed", {
-                  command: item.command,
-                  ...item.environment,
-                })
-            } catch (error) {
-              log.error("failed to format file", {
-                error,
-                command: item.command,
-                ...item.environment,
-                file,
-              })
-            }
-          }
-        }),
+      yield* Effect.acquireRelease(
+        Effect.sync(() =>
+          Bus.subscribe(
+            File.Event.Edited,
+            Instance.bind(async (payload) => {
+              const file = payload.properties.file
+              log.info("formatting", { file })
+              const ext = path.extname(file)
+
+              for (const item of await getFormatter(ext)) {
+                log.info("running", { command: item.command })
+                try {
+                  const proc = Process.spawn(
+                    item.command.map((x) => x.replace("$FILE", file)),
+                    {
+                      cwd: instance.directory,
+                      env: { ...process.env, ...item.environment },
+                      stdout: "ignore",
+                      stderr: "ignore",
+                    },
+                  )
+                  const exit = await proc.exited
+                  if (exit !== 0) {
+                    log.error("failed", {
+                      command: item.command,
+                      ...item.environment,
+                    })
+                  }
+                } catch (error) {
+                  log.error("failed to format file", {
+                    error,
+                    command: item.command,
+                    ...item.environment,
+                    file,
+                  })
+                }
+              }
+            }),
+          ),
+        ),
+        (unsubscribe) => Effect.sync(unsubscribe),
       )
-
-      yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
       log.info("init")
 
-      const init = Effect.fn("FormatService.init")(function* () {})
-
-      const status = Effect.fn("FormatService.status")(function* () {
-        const result: Format.Status[] = []
+      const status = Effect.fn("Format.status")(function* () {
+        const result: Status[] = []
         for (const formatter of Object.values(formatters)) {
           const isOn = yield* Effect.promise(() => isEnabled(formatter))
           result.push({
@@ -155,7 +147,11 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
         return result
       })
 
-      return FormatService.of({ init, status })
+      return Service.of({ status })
     }),
   )
+
+  export async function status() {
+    return runPromiseInstance(Service.use((s) => s.status()))
+  }
 }

+ 245 - 32
packages/opencode/src/permission/index.ts

@@ -1,11 +1,251 @@
 import { runPromiseInstance } from "@/effect/runtime"
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
 import { Config } from "@/config/config"
+import { InstanceContext } from "@/effect/instance-context"
+import { ProjectID } from "@/project/schema"
+import { MessageID, SessionID } from "@/session/schema"
+import { PermissionTable } from "@/session/session.sql"
+import { Database, eq } from "@/storage/db"
 import { fn } from "@/util/fn"
+import { Log } from "@/util/log"
 import { Wildcard } from "@/util/wildcard"
+import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
 import os from "os"
-import { PermissionEffect as S } from "./service"
+import z from "zod"
+import { PermissionID } from "./schema"
 
 export namespace PermissionNext {
+  const log = Log.create({ service: "permission" })
+
+  export const Action = z.enum(["allow", "deny", "ask"]).meta({
+    ref: "PermissionAction",
+  })
+  export type Action = z.infer<typeof Action>
+
+  export const Rule = z
+    .object({
+      permission: z.string(),
+      pattern: z.string(),
+      action: Action,
+    })
+    .meta({
+      ref: "PermissionRule",
+    })
+  export type Rule = z.infer<typeof Rule>
+
+  export const Ruleset = Rule.array().meta({
+    ref: "PermissionRuleset",
+  })
+  export type Ruleset = z.infer<typeof Ruleset>
+
+  export const Request = z
+    .object({
+      id: PermissionID.zod,
+      sessionID: SessionID.zod,
+      permission: z.string(),
+      patterns: z.string().array(),
+      metadata: z.record(z.string(), z.any()),
+      always: z.string().array(),
+      tool: z
+        .object({
+          messageID: MessageID.zod,
+          callID: z.string(),
+        })
+        .optional(),
+    })
+    .meta({
+      ref: "PermissionRequest",
+    })
+  export type Request = z.infer<typeof Request>
+
+  export const Reply = z.enum(["once", "always", "reject"])
+  export type Reply = z.infer<typeof Reply>
+
+  export const Approval = z.object({
+    projectID: ProjectID.zod,
+    patterns: z.string().array(),
+  })
+
+  export const Event = {
+    Asked: BusEvent.define("permission.asked", Request),
+    Replied: BusEvent.define(
+      "permission.replied",
+      z.object({
+        sessionID: SessionID.zod,
+        requestID: PermissionID.zod,
+        reply: Reply,
+      }),
+    ),
+  }
+
+  export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
+    override get message() {
+      return "The user rejected permission to use this specific tool call."
+    }
+  }
+
+  export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
+    feedback: Schema.String,
+  }) {
+    override get message() {
+      return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
+    }
+  }
+
+  export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
+    ruleset: Schema.Any,
+  }) {
+    override get message() {
+      return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
+    }
+  }
+
+  export type Error = DeniedError | RejectedError | CorrectedError
+
+  export const AskInput = Request.partial({ id: true }).extend({
+    ruleset: Ruleset,
+  })
+
+  export const ReplyInput = z.object({
+    requestID: PermissionID.zod,
+    reply: Reply,
+    message: z.string().optional(),
+  })
+
+  export interface Interface {
+    readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
+    readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
+    readonly list: () => Effect.Effect<Request[]>
+  }
+
+  interface PendingEntry {
+    info: Request
+    deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
+  }
+
+  export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
+    const rules = rulesets.flat()
+    log.info("evaluate", { permission, pattern, ruleset: rules })
+    const match = rules.findLast(
+      (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
+    )
+    return match ?? { action: "ask", permission, pattern: "*" }
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const { project } = yield* InstanceContext
+      const row = Database.use((db) =>
+        db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
+      )
+      const pending = new Map<PermissionID, PendingEntry>()
+      const approved: Ruleset = row?.data ?? []
+
+      const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
+        const { ruleset, ...request } = input
+        let needsAsk = false
+
+        for (const pattern of request.patterns) {
+          const rule = evaluate(request.permission, pattern, ruleset, approved)
+          log.info("evaluated", { permission: request.permission, pattern, action: rule })
+          if (rule.action === "deny") {
+            return yield* new DeniedError({
+              ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
+            })
+          }
+          if (rule.action === "allow") continue
+          needsAsk = true
+        }
+
+        if (!needsAsk) return
+
+        const id = request.id ?? PermissionID.ascending()
+        const info: Request = {
+          id,
+          ...request,
+        }
+        log.info("asking", { id, permission: info.permission, patterns: info.patterns })
+
+        const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
+        pending.set(id, { info, deferred })
+        void Bus.publish(Event.Asked, info)
+        return yield* Effect.ensuring(
+          Deferred.await(deferred),
+          Effect.sync(() => {
+            pending.delete(id)
+          }),
+        )
+      })
+
+      const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
+        const existing = pending.get(input.requestID)
+        if (!existing) return
+
+        pending.delete(input.requestID)
+        void Bus.publish(Event.Replied, {
+          sessionID: existing.info.sessionID,
+          requestID: existing.info.id,
+          reply: input.reply,
+        })
+
+        if (input.reply === "reject") {
+          yield* Deferred.fail(
+            existing.deferred,
+            input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
+          )
+
+          for (const [id, item] of pending.entries()) {
+            if (item.info.sessionID !== existing.info.sessionID) continue
+            pending.delete(id)
+            void Bus.publish(Event.Replied, {
+              sessionID: item.info.sessionID,
+              requestID: item.info.id,
+              reply: "reject",
+            })
+            yield* Deferred.fail(item.deferred, new RejectedError())
+          }
+          return
+        }
+
+        yield* Deferred.succeed(existing.deferred, undefined)
+        if (input.reply === "once") return
+
+        for (const pattern of existing.info.always) {
+          approved.push({
+            permission: existing.info.permission,
+            pattern,
+            action: "allow",
+          })
+        }
+
+        for (const [id, item] of pending.entries()) {
+          if (item.info.sessionID !== existing.info.sessionID) continue
+          const ok = item.info.patterns.every(
+            (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
+          )
+          if (!ok) continue
+          pending.delete(id)
+          void Bus.publish(Event.Replied, {
+            sessionID: item.info.sessionID,
+            requestID: item.info.id,
+            reply: "always",
+          })
+          yield* Deferred.succeed(item.deferred, undefined)
+        }
+      })
+
+      const list = Effect.fn("Permission.list")(function* () {
+        return Array.from(pending.values(), (item) => item.info)
+      })
+
+      return Service.of({ ask, reply, list })
+    }),
+  )
+
   function expand(pattern: string): string {
     if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
     if (pattern === "~") return os.homedir()
@@ -14,32 +254,11 @@ export namespace PermissionNext {
     return pattern
   }
 
-  export const Action = S.Action
-  export type Action = S.Action
-  export const Rule = S.Rule
-  export type Rule = S.Rule
-  export const Ruleset = S.Ruleset
-  export type Ruleset = S.Ruleset
-  export const Request = S.Request
-  export type Request = S.Request
-  export const Reply = S.Reply
-  export type Reply = S.Reply
-  export const Approval = S.Approval
-  export const Event = S.Event
-  export const Service = S.Service
-  export const RejectedError = S.RejectedError
-  export const CorrectedError = S.CorrectedError
-  export const DeniedError = S.DeniedError
-
   export function fromConfig(permission: Config.Permission) {
     const ruleset: Ruleset = []
     for (const [key, value] of Object.entries(permission)) {
       if (typeof value === "string") {
-        ruleset.push({
-          permission: key,
-          action: value,
-          pattern: "*",
-        })
+        ruleset.push({ permission: key, action: value, pattern: "*" })
         continue
       }
       ruleset.push(
@@ -53,18 +272,12 @@ export namespace PermissionNext {
     return rulesets.flat()
   }
 
-  export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((service) => service.ask(input))))
+  export const ask = fn(AskInput, async (input) => runPromiseInstance(Service.use((svc) => svc.ask(input))))
 
-  export const reply = fn(S.ReplyInput, async (input) =>
-    runPromiseInstance(S.Service.use((service) => service.reply(input))),
-  )
+  export const reply = fn(ReplyInput, async (input) => runPromiseInstance(Service.use((svc) => svc.reply(input))))
 
   export async function list() {
-    return runPromiseInstance(S.Service.use((service) => service.list()))
-  }
-
-  export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
-    return S.evaluate(permission, pattern, ...rulesets)
+    return runPromiseInstance(Service.use((svc) => svc.list()))
   }
 
   const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]

+ 0 - 244
packages/opencode/src/permission/service.ts

@@ -1,244 +0,0 @@
-import { Bus } from "@/bus"
-import { BusEvent } from "@/bus/bus-event"
-import { InstanceContext } from "@/effect/instance-context"
-import { ProjectID } from "@/project/schema"
-import { MessageID, SessionID } from "@/session/schema"
-import { PermissionTable } from "@/session/session.sql"
-import { Database, eq } from "@/storage/db"
-import { Log } from "@/util/log"
-import { Wildcard } from "@/util/wildcard"
-import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
-import z from "zod"
-import { PermissionID } from "./schema"
-
-export namespace PermissionEffect {
-  const log = Log.create({ service: "permission" })
-
-  export const Action = z.enum(["allow", "deny", "ask"]).meta({
-    ref: "PermissionAction",
-  })
-  export type Action = z.infer<typeof Action>
-
-  export const Rule = z
-    .object({
-      permission: z.string(),
-      pattern: z.string(),
-      action: Action,
-    })
-    .meta({
-      ref: "PermissionRule",
-    })
-  export type Rule = z.infer<typeof Rule>
-
-  export const Ruleset = Rule.array().meta({
-    ref: "PermissionRuleset",
-  })
-  export type Ruleset = z.infer<typeof Ruleset>
-
-  export const Request = z
-    .object({
-      id: PermissionID.zod,
-      sessionID: SessionID.zod,
-      permission: z.string(),
-      patterns: z.string().array(),
-      metadata: z.record(z.string(), z.any()),
-      always: z.string().array(),
-      tool: z
-        .object({
-          messageID: MessageID.zod,
-          callID: z.string(),
-        })
-        .optional(),
-    })
-    .meta({
-      ref: "PermissionRequest",
-    })
-  export type Request = z.infer<typeof Request>
-
-  export const Reply = z.enum(["once", "always", "reject"])
-  export type Reply = z.infer<typeof Reply>
-
-  export const Approval = z.object({
-    projectID: ProjectID.zod,
-    patterns: z.string().array(),
-  })
-
-  export const Event = {
-    Asked: BusEvent.define("permission.asked", Request),
-    Replied: BusEvent.define(
-      "permission.replied",
-      z.object({
-        sessionID: SessionID.zod,
-        requestID: PermissionID.zod,
-        reply: Reply,
-      }),
-    ),
-  }
-
-  export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
-    override get message() {
-      return "The user rejected permission to use this specific tool call."
-    }
-  }
-
-  export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
-    feedback: Schema.String,
-  }) {
-    override get message() {
-      return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
-    }
-  }
-
-  export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
-    ruleset: Schema.Any,
-  }) {
-    override get message() {
-      return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
-    }
-  }
-
-  export type Error = DeniedError | RejectedError | CorrectedError
-
-  export const AskInput = Request.partial({ id: true }).extend({
-    ruleset: Ruleset,
-  })
-
-  export const ReplyInput = z.object({
-    requestID: PermissionID.zod,
-    reply: Reply,
-    message: z.string().optional(),
-  })
-
-  export interface Api {
-    readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
-    readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
-    readonly list: () => Effect.Effect<Request[]>
-  }
-
-  interface PendingEntry {
-    info: Request
-    deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
-  }
-
-  export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
-    const rules = rulesets.flat()
-    log.info("evaluate", { permission, pattern, ruleset: rules })
-    const match = rules.findLast(
-      (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
-    )
-    return match ?? { action: "ask", permission, pattern: "*" }
-  }
-
-  export class Service extends ServiceMap.Service<Service, Api>()("@opencode/PermissionNext") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const { project } = yield* InstanceContext
-      const row = Database.use((db) =>
-        db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
-      )
-      const pending = new Map<PermissionID, PendingEntry>()
-      const approved: Ruleset = row?.data ?? []
-
-      const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer<typeof AskInput>) {
-        const { ruleset, ...request } = input
-        let needsAsk = false
-
-        for (const pattern of request.patterns) {
-          const rule = evaluate(request.permission, pattern, ruleset, approved)
-          log.info("evaluated", { permission: request.permission, pattern, action: rule })
-          if (rule.action === "deny") {
-            return yield* new DeniedError({
-              ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
-            })
-          }
-          if (rule.action === "allow") continue
-          needsAsk = true
-        }
-
-        if (!needsAsk) return
-
-        const id = request.id ?? PermissionID.ascending()
-        const info: Request = {
-          id,
-          ...request,
-        }
-        log.info("asking", { id, permission: info.permission, patterns: info.patterns })
-
-        const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
-        pending.set(id, { info, deferred })
-        void Bus.publish(Event.Asked, info)
-        return yield* Effect.ensuring(
-          Deferred.await(deferred),
-          Effect.sync(() => {
-            pending.delete(id)
-          }),
-        )
-      })
-
-      const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer<typeof ReplyInput>) {
-        const existing = pending.get(input.requestID)
-        if (!existing) return
-
-        pending.delete(input.requestID)
-        void Bus.publish(Event.Replied, {
-          sessionID: existing.info.sessionID,
-          requestID: existing.info.id,
-          reply: input.reply,
-        })
-
-        if (input.reply === "reject") {
-          yield* Deferred.fail(
-            existing.deferred,
-            input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
-          )
-
-          for (const [id, item] of pending.entries()) {
-            if (item.info.sessionID !== existing.info.sessionID) continue
-            pending.delete(id)
-            void Bus.publish(Event.Replied, {
-              sessionID: item.info.sessionID,
-              requestID: item.info.id,
-              reply: "reject",
-            })
-            yield* Deferred.fail(item.deferred, new RejectedError())
-          }
-          return
-        }
-
-        yield* Deferred.succeed(existing.deferred, undefined)
-        if (input.reply === "once") return
-
-        for (const pattern of existing.info.always) {
-          approved.push({
-            permission: existing.info.permission,
-            pattern,
-            action: "allow",
-          })
-        }
-
-        for (const [id, item] of pending.entries()) {
-          if (item.info.sessionID !== existing.info.sessionID) continue
-          const ok = item.info.patterns.every(
-            (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
-          )
-          if (!ok) continue
-          pending.delete(id)
-          void Bus.publish(Event.Replied, {
-            sessionID: item.info.sessionID,
-            requestID: item.info.id,
-            reply: "always",
-          })
-          yield* Deferred.succeed(item.deferred, undefined)
-        }
-      })
-
-      const list = Effect.fn("PermissionService.list")(function* () {
-        return Array.from(pending.values(), (item) => item.info)
-      })
-
-      return Service.of({ ask, reply, list })
-    }),
-  )
-}

+ 1 - 8
packages/opencode/src/project/bootstrap.ts

@@ -1,30 +1,23 @@
 import { Plugin } from "../plugin"
-import { Format } from "../format"
 import { LSP } from "../lsp"
-import { FileWatcherService } from "../file/watcher"
 import { File } from "../file"
 import { Project } from "./project"
 import { Bus } from "../bus"
 import { Command } from "../command"
 import { Instance } from "./instance"
-import { VcsService } from "./vcs"
 import { Log } from "@/util/log"
 import { ShareNext } from "@/share/share-next"
-import { runPromiseInstance } from "@/effect/runtime"
 
 export async function InstanceBootstrap() {
   Log.Default.info("bootstrapping", { directory: Instance.directory })
   await Plugin.init()
   ShareNext.init()
-  await Format.init()
   await LSP.init()
-  await runPromiseInstance(FileWatcherService.use((service) => service.init()))
   File.init()
-  await runPromiseInstance(VcsService.use((s) => s.init()))
 
   Bus.subscribe(Command.Event.Executed, async (payload) => {
     if (payload.properties.name === Command.Default.INIT) {
-      await Project.setInitialized(Instance.project.id)
+      Project.setInitialized(Instance.project.id)
     }
   })
 }

+ 35 - 36
packages/opencode/src/project/vcs.ts

@@ -1,16 +1,16 @@
-import { BusEvent } from "@/bus/bus-event"
+import { Effect, Layer, ServiceMap } from "effect"
 import { Bus } from "@/bus"
-import z from "zod"
-import { Log } from "@/util/log"
-import { Instance } from "./instance"
+import { BusEvent } from "@/bus/bus-event"
 import { InstanceContext } from "@/effect/instance-context"
 import { FileWatcher } from "@/file/watcher"
+import { Log } from "@/util/log"
 import { git } from "@/util/git"
-import { Effect, Layer, ServiceMap } from "effect"
-
-const log = Log.create({ service: "vcs" })
+import { Instance } from "./instance"
+import z from "zod"
 
 export namespace Vcs {
+  const log = Log.create({ service: "vcs" })
+
   export const Event = {
     BranchUpdated: BusEvent.define(
       "vcs.branch.updated",
@@ -28,24 +28,21 @@ export namespace Vcs {
       ref: "VcsInfo",
     })
   export type Info = z.infer<typeof Info>
-}
 
-export namespace VcsService {
-  export interface Service {
-    readonly init: () => Effect.Effect<void>
+  export interface Interface {
     readonly branch: () => Effect.Effect<string | undefined>
   }
-}
 
-export class VcsService extends ServiceMap.Service<VcsService, VcsService.Service>()("@opencode/Vcs") {
-  static readonly layer = Layer.effect(
-    VcsService,
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
+
+  export const layer = Layer.effect(
+    Service,
     Effect.gen(function* () {
       const instance = yield* InstanceContext
-      let current: string | undefined
+      let currentBranch: string | undefined
 
       if (instance.project.vcs === "git") {
-        const currentBranch = async () => {
+        const getCurrentBranch = async () => {
           const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
             cwd: instance.project.worktree,
           })
@@ -54,29 +51,31 @@ export class VcsService extends ServiceMap.Service<VcsService, VcsService.Servic
           return text || undefined
         }
 
-        current = yield* Effect.promise(() => currentBranch())
-        log.info("initialized", { branch: current })
+        currentBranch = yield* Effect.promise(() => getCurrentBranch())
+        log.info("initialized", { branch: currentBranch })
 
-        const unsubscribe = Bus.subscribe(
-          FileWatcher.Event.Updated,
-          Instance.bind(async (evt) => {
-            if (!evt.properties.file.endsWith("HEAD")) return
-            const next = await currentBranch()
-            if (next !== current) {
-              log.info("branch changed", { from: current, to: next })
-              current = next
-              Bus.publish(Vcs.Event.BranchUpdated, { branch: next })
-            }
-          }),
+        yield* Effect.acquireRelease(
+          Effect.sync(() =>
+            Bus.subscribe(
+              FileWatcher.Event.Updated,
+              Instance.bind(async (evt) => {
+                if (!evt.properties.file.endsWith("HEAD")) return
+                const next = await getCurrentBranch()
+                if (next !== currentBranch) {
+                  log.info("branch changed", { from: currentBranch, to: next })
+                  currentBranch = next
+                  Bus.publish(Event.BranchUpdated, { branch: next })
+                }
+              }),
+            ),
+          ),
+          (unsubscribe) => Effect.sync(unsubscribe),
         )
-
-        yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
       }
 
-      return VcsService.of({
-        init: Effect.fn("VcsService.init")(function* () {}),
-        branch: Effect.fn("VcsService.branch")(function* () {
-          return current
+      return Service.of({
+        branch: Effect.fn("Vcs.branch")(function* () {
+          return currentBranch
         }),
       })
     }),

+ 0 - 230
packages/opencode/src/provider/auth-service.ts

@@ -1,230 +0,0 @@
-import type { AuthOuathResult } from "@opencode-ai/plugin"
-import { NamedError } from "@opencode-ai/util/error"
-import * as Auth from "@/auth/service"
-import { ProviderID } from "./schema"
-import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
-import { filter, fromEntries, map, pipe } from "remeda"
-import z from "zod"
-
-export const Method = z
-  .object({
-    type: z.union([z.literal("oauth"), z.literal("api")]),
-    label: z.string(),
-    prompts: z
-      .array(
-        z.union([
-          z.object({
-            type: z.literal("text"),
-            key: z.string(),
-            message: z.string(),
-            placeholder: z.string().optional(),
-            when: z
-              .object({
-                key: z.string(),
-                op: z.union([z.literal("eq"), z.literal("neq")]),
-                value: z.string(),
-              })
-              .optional(),
-          }),
-          z.object({
-            type: z.literal("select"),
-            key: z.string(),
-            message: z.string(),
-            options: z.array(
-              z.object({
-                label: z.string(),
-                value: z.string(),
-                hint: z.string().optional(),
-              }),
-            ),
-            when: z
-              .object({
-                key: z.string(),
-                op: z.union([z.literal("eq"), z.literal("neq")]),
-                value: z.string(),
-              })
-              .optional(),
-          }),
-        ]),
-      )
-      .optional(),
-  })
-  .meta({
-    ref: "ProviderAuthMethod",
-  })
-export type Method = z.infer<typeof Method>
-
-export const Authorization = z
-  .object({
-    url: z.string(),
-    method: z.union([z.literal("auto"), z.literal("code")]),
-    instructions: z.string(),
-  })
-  .meta({
-    ref: "ProviderAuthAuthorization",
-  })
-export type Authorization = z.infer<typeof Authorization>
-
-export const OauthMissing = NamedError.create(
-  "ProviderAuthOauthMissing",
-  z.object({
-    providerID: ProviderID.zod,
-  }),
-)
-
-export const OauthCodeMissing = NamedError.create(
-  "ProviderAuthOauthCodeMissing",
-  z.object({
-    providerID: ProviderID.zod,
-  }),
-)
-
-export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
-
-export const ValidationFailed = NamedError.create(
-  "ProviderAuthValidationFailed",
-  z.object({
-    field: z.string(),
-    message: z.string(),
-  }),
-)
-
-export type ProviderAuthError =
-  | Auth.AuthServiceError
-  | InstanceType<typeof OauthMissing>
-  | InstanceType<typeof OauthCodeMissing>
-  | InstanceType<typeof OauthCallbackFailed>
-  | InstanceType<typeof ValidationFailed>
-
-export namespace ProviderAuthService {
-  export interface Service {
-    readonly methods: () => Effect.Effect<Record<string, Method[]>>
-    readonly authorize: (input: {
-      providerID: ProviderID
-      method: number
-      inputs?: Record<string, string>
-    }) => Effect.Effect<Authorization | undefined, ProviderAuthError>
-    readonly callback: (input: {
-      providerID: ProviderID
-      method: number
-      code?: string
-    }) => Effect.Effect<void, ProviderAuthError>
-  }
-}
-
-export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()(
-  "@opencode/ProviderAuth",
-) {
-  static readonly layer = Layer.effect(
-    ProviderAuthService,
-    Effect.gen(function* () {
-      const auth = yield* Auth.AuthService
-      const hooks = yield* Effect.promise(async () => {
-        const mod = await import("../plugin")
-        return pipe(
-          await mod.Plugin.list(),
-          filter((x) => x.auth?.provider !== undefined),
-          map((x) => [x.auth!.provider, x.auth!] as const),
-          fromEntries(),
-        )
-      })
-      const pending = new Map<ProviderID, AuthOuathResult>()
-
-      const methods = Effect.fn("ProviderAuthService.methods")(function* () {
-        return Record.map(hooks, (item) =>
-          item.methods.map(
-            (method): Method => ({
-              type: method.type,
-              label: method.label,
-              prompts: method.prompts?.map((prompt) => {
-                if (prompt.type === "select") {
-                  return {
-                    type: "select" as const,
-                    key: prompt.key,
-                    message: prompt.message,
-                    options: prompt.options,
-                    when: prompt.when,
-                  }
-                }
-                return {
-                  type: "text" as const,
-                  key: prompt.key,
-                  message: prompt.message,
-                  placeholder: prompt.placeholder,
-                  when: prompt.when,
-                }
-              }),
-            }),
-          ),
-        )
-      })
-
-      const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
-        providerID: ProviderID
-        method: number
-        inputs?: Record<string, string>
-      }) {
-        const method = hooks[input.providerID].methods[input.method]
-        if (method.type !== "oauth") return
-
-        if (method.prompts && input.inputs) {
-          for (const prompt of method.prompts) {
-            if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
-              const error = prompt.validate(input.inputs[prompt.key])
-              if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
-            }
-          }
-        }
-
-        const result = yield* Effect.promise(() => method.authorize(input.inputs))
-        pending.set(input.providerID, result)
-        return {
-          url: result.url,
-          method: result.method,
-          instructions: result.instructions,
-        }
-      })
-
-      const callback = Effect.fn("ProviderAuthService.callback")(function* (input: {
-        providerID: ProviderID
-        method: number
-        code?: string
-      }) {
-        const match = pending.get(input.providerID)
-        if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
-        if (match.method === "code" && !input.code)
-          return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
-
-        const result = yield* Effect.promise(() =>
-          match.method === "code" ? match.callback(input.code!) : match.callback(),
-        )
-        if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
-
-        if ("key" in result) {
-          yield* auth.set(input.providerID, {
-            type: "api",
-            key: result.key,
-          })
-        }
-
-        if ("refresh" in result) {
-          yield* auth.set(input.providerID, {
-            type: "oauth",
-            access: result.access,
-            refresh: result.refresh,
-            expires: result.expires,
-            ...(result.accountId ? { accountId: result.accountId } : {}),
-          })
-        }
-      })
-
-      return ProviderAuthService.of({
-        methods,
-        authorize,
-        callback,
-      })
-    }),
-  )
-
-  static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer))
-}

+ 214 - 17
packages/opencode/src/provider/auth.ts

@@ -1,20 +1,223 @@
-import z from "zod"
-
+import type { AuthOuathResult } from "@opencode-ai/plugin"
+import { NamedError } from "@opencode-ai/util/error"
+import * as Auth from "@/auth/effect"
 import { runPromiseInstance } from "@/effect/runtime"
 import { fn } from "@/util/fn"
-import * as S from "./auth-service"
 import { ProviderID } from "./schema"
+import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
+import z from "zod"
 
 export namespace ProviderAuth {
-  export const Method = S.Method
-  export type Method = S.Method
+  export const Method = z
+    .object({
+      type: z.union([z.literal("oauth"), z.literal("api")]),
+      label: z.string(),
+      prompts: z
+        .array(
+          z.union([
+            z.object({
+              type: z.literal("text"),
+              key: z.string(),
+              message: z.string(),
+              placeholder: z.string().optional(),
+              when: z
+                .object({
+                  key: z.string(),
+                  op: z.union([z.literal("eq"), z.literal("neq")]),
+                  value: z.string(),
+                })
+                .optional(),
+            }),
+            z.object({
+              type: z.literal("select"),
+              key: z.string(),
+              message: z.string(),
+              options: z.array(
+                z.object({
+                  label: z.string(),
+                  value: z.string(),
+                  hint: z.string().optional(),
+                }),
+              ),
+              when: z
+                .object({
+                  key: z.string(),
+                  op: z.union([z.literal("eq"), z.literal("neq")]),
+                  value: z.string(),
+                })
+                .optional(),
+            }),
+          ]),
+        )
+        .optional(),
+    })
+    .meta({
+      ref: "ProviderAuthMethod",
+    })
+  export type Method = z.infer<typeof Method>
 
-  export async function methods() {
-    return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods()))
+  export const Authorization = z
+    .object({
+      url: z.string(),
+      method: z.union([z.literal("auto"), z.literal("code")]),
+      instructions: z.string(),
+    })
+    .meta({
+      ref: "ProviderAuthAuthorization",
+    })
+  export type Authorization = z.infer<typeof Authorization>
+
+  export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
+
+  export const OauthCodeMissing = NamedError.create(
+    "ProviderAuthOauthCodeMissing",
+    z.object({ providerID: ProviderID.zod }),
+  )
+
+  export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
+
+  export const ValidationFailed = NamedError.create(
+    "ProviderAuthValidationFailed",
+    z.object({
+      field: z.string(),
+      message: z.string(),
+    }),
+  )
+
+  export type Error =
+    | Auth.AuthError
+    | InstanceType<typeof OauthMissing>
+    | InstanceType<typeof OauthCodeMissing>
+    | InstanceType<typeof OauthCallbackFailed>
+    | InstanceType<typeof ValidationFailed>
+
+  export interface Interface {
+    readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
+    readonly authorize: (input: {
+      providerID: ProviderID
+      method: number
+      inputs?: Record<string, string>
+    }) => Effect.Effect<Authorization | undefined, Error>
+    readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
   }
 
-  export const Authorization = S.Authorization
-  export type Authorization = S.Authorization
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const auth = yield* Auth.AuthEffect.Service
+      const hooks = yield* Effect.promise(async () => {
+        const mod = await import("../plugin")
+        const plugins = await mod.Plugin.list()
+        return Record.fromEntries(
+          Arr.filterMap(plugins, (x) =>
+            x.auth?.provider !== undefined
+              ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
+              : Result.failVoid,
+          ),
+        )
+      })
+      const pending = new Map<ProviderID, AuthOuathResult>()
+
+      const methods = Effect.fn("ProviderAuth.methods")(function* () {
+        return Record.map(hooks, (item) =>
+          item.methods.map(
+            (method): Method => ({
+              type: method.type,
+              label: method.label,
+              prompts: method.prompts?.map((prompt) => {
+                if (prompt.type === "select") {
+                  return {
+                    type: "select" as const,
+                    key: prompt.key,
+                    message: prompt.message,
+                    options: prompt.options,
+                    when: prompt.when,
+                  }
+                }
+                return {
+                  type: "text" as const,
+                  key: prompt.key,
+                  message: prompt.message,
+                  placeholder: prompt.placeholder,
+                  when: prompt.when,
+                }
+              }),
+            }),
+          ),
+        )
+      })
+
+      const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
+        providerID: ProviderID
+        method: number
+        inputs?: Record<string, string>
+      }) {
+        const method = hooks[input.providerID].methods[input.method]
+        if (method.type !== "oauth") return
+
+        if (method.prompts && input.inputs) {
+          for (const prompt of method.prompts) {
+            if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
+              const error = prompt.validate(input.inputs[prompt.key])
+              if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
+            }
+          }
+        }
+
+        const result = yield* Effect.promise(() => method.authorize(input.inputs))
+        pending.set(input.providerID, result)
+        return {
+          url: result.url,
+          method: result.method,
+          instructions: result.instructions,
+        }
+      })
+
+      const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
+        providerID: ProviderID
+        method: number
+        code?: string
+      }) {
+        const match = pending.get(input.providerID)
+        if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
+        if (match.method === "code" && !input.code) {
+          return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
+        }
+
+        const result = yield* Effect.promise(() =>
+          match.method === "code" ? match.callback(input.code!) : match.callback(),
+        )
+        if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
+
+        if ("key" in result) {
+          yield* auth.set(input.providerID, {
+            type: "api",
+            key: result.key,
+          })
+        }
+
+        if ("refresh" in result) {
+          yield* auth.set(input.providerID, {
+            type: "oauth",
+            access: result.access,
+            refresh: result.refresh,
+            expires: result.expires,
+            ...(result.accountId ? { accountId: result.accountId } : {}),
+          })
+        }
+      })
+
+      return Service.of({ methods, authorize, callback })
+    }),
+  )
+
+  export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer))
+
+  export async function methods() {
+    return runPromiseInstance(Service.use((svc) => svc.methods()))
+  }
 
   export const authorize = fn(
     z.object({
@@ -22,8 +225,7 @@ export namespace ProviderAuth {
       method: z.number(),
       inputs: z.record(z.string(), z.string()).optional(),
     }),
-    async (input): Promise<Authorization | undefined> =>
-      runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))),
+    async (input): Promise<Authorization | undefined> => runPromiseInstance(Service.use((svc) => svc.authorize(input))),
   )
 
   export const callback = fn(
@@ -32,11 +234,6 @@ export namespace ProviderAuth {
       method: z.number(),
       code: z.string().optional(),
     }),
-    async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))),
+    async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))),
   )
-
-  export import OauthMissing = S.OauthMissing
-  export import OauthCodeMissing = S.OauthCodeMissing
-  export import OauthCallbackFailed = S.OauthCallbackFailed
-  export import ValidationFailed = S.ValidationFailed
 }

+ 173 - 19
packages/opencode/src/question/index.ts

@@ -1,39 +1,193 @@
+import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
 import { runPromiseInstance } from "@/effect/runtime"
-import * as S from "./service"
-import type { QuestionID } from "./schema"
-import type { SessionID, MessageID } from "@/session/schema"
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { SessionID, MessageID } from "@/session/schema"
+import { Log } from "@/util/log"
+import z from "zod"
+import { QuestionID } from "./schema"
+
+const log = Log.create({ service: "question" })
 
 export namespace Question {
-  export const Option = S.Option
-  export type Option = S.Option
-  export const Info = S.Info
-  export type Info = S.Info
-  export const Request = S.Request
-  export type Request = S.Request
-  export const Answer = S.Answer
-  export type Answer = S.Answer
-  export const Reply = S.Reply
-  export type Reply = S.Reply
-  export const Event = S.Event
-  export const RejectedError = S.RejectedError
+  // Schemas
+
+  export const Option = z
+    .object({
+      label: z.string().describe("Display text (1-5 words, concise)"),
+      description: z.string().describe("Explanation of choice"),
+    })
+    .meta({ ref: "QuestionOption" })
+  export type Option = z.infer<typeof Option>
+
+  export const Info = z
+    .object({
+      question: z.string().describe("Complete question"),
+      header: z.string().describe("Very short label (max 30 chars)"),
+      options: z.array(Option).describe("Available choices"),
+      multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
+      custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
+    })
+    .meta({ ref: "QuestionInfo" })
+  export type Info = z.infer<typeof Info>
+
+  export const Request = z
+    .object({
+      id: QuestionID.zod,
+      sessionID: SessionID.zod,
+      questions: z.array(Info).describe("Questions to ask"),
+      tool: z
+        .object({
+          messageID: MessageID.zod,
+          callID: z.string(),
+        })
+        .optional(),
+    })
+    .meta({ ref: "QuestionRequest" })
+  export type Request = z.infer<typeof Request>
+
+  export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
+  export type Answer = z.infer<typeof Answer>
+
+  export const Reply = z.object({
+    answers: z
+      .array(Answer)
+      .describe("User answers in order of questions (each answer is an array of selected labels)"),
+  })
+  export type Reply = z.infer<typeof Reply>
+
+  export const Event = {
+    Asked: BusEvent.define("question.asked", Request),
+    Replied: BusEvent.define(
+      "question.replied",
+      z.object({
+        sessionID: SessionID.zod,
+        requestID: QuestionID.zod,
+        answers: z.array(Answer),
+      }),
+    ),
+    Rejected: BusEvent.define(
+      "question.rejected",
+      z.object({
+        sessionID: SessionID.zod,
+        requestID: QuestionID.zod,
+      }),
+    ),
+  }
+
+  export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
+    override get message() {
+      return "The user dismissed this question"
+    }
+  }
+
+  interface PendingEntry {
+    info: Request
+    deferred: Deferred.Deferred<Answer[], RejectedError>
+  }
+
+  // Service
+
+  export interface Interface {
+    readonly ask: (input: {
+      sessionID: SessionID
+      questions: Info[]
+      tool?: { messageID: MessageID; callID: string }
+    }) => Effect.Effect<Answer[], RejectedError>
+    readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
+    readonly reject: (requestID: QuestionID) => Effect.Effect<void>
+    readonly list: () => Effect.Effect<Request[]>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const pending = new Map<QuestionID, PendingEntry>()
+
+      const ask = Effect.fn("Question.ask")(function* (input: {
+        sessionID: SessionID
+        questions: Info[]
+        tool?: { messageID: MessageID; callID: string }
+      }) {
+        const id = QuestionID.ascending()
+        log.info("asking", { id, questions: input.questions.length })
+
+        const deferred = yield* Deferred.make<Answer[], RejectedError>()
+        const info: Request = {
+          id,
+          sessionID: input.sessionID,
+          questions: input.questions,
+          tool: input.tool,
+        }
+        pending.set(id, { info, deferred })
+        Bus.publish(Event.Asked, info)
+
+        return yield* Effect.ensuring(
+          Deferred.await(deferred),
+          Effect.sync(() => {
+            pending.delete(id)
+          }),
+        )
+      })
+
+      const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
+        const existing = pending.get(input.requestID)
+        if (!existing) {
+          log.warn("reply for unknown request", { requestID: input.requestID })
+          return
+        }
+        pending.delete(input.requestID)
+        log.info("replied", { requestID: input.requestID, answers: input.answers })
+        Bus.publish(Event.Replied, {
+          sessionID: existing.info.sessionID,
+          requestID: existing.info.id,
+          answers: input.answers,
+        })
+        yield* Deferred.succeed(existing.deferred, input.answers)
+      })
+
+      const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
+        const existing = pending.get(requestID)
+        if (!existing) {
+          log.warn("reject for unknown request", { requestID })
+          return
+        }
+        pending.delete(requestID)
+        log.info("rejected", { requestID })
+        Bus.publish(Event.Rejected, {
+          sessionID: existing.info.sessionID,
+          requestID: existing.info.id,
+        })
+        yield* Deferred.fail(existing.deferred, new RejectedError())
+      })
+
+      const list = Effect.fn("Question.list")(function* () {
+        return Array.from(pending.values(), (x) => x.info)
+      })
+
+      return Service.of({ ask, reply, reject, list })
+    }),
+  )
 
   export async function ask(input: {
     sessionID: SessionID
     questions: Info[]
     tool?: { messageID: MessageID; callID: string }
   }): Promise<Answer[]> {
-    return runPromiseInstance(S.QuestionService.use((service) => service.ask(input)))
+    return runPromiseInstance(Service.use((svc) => svc.ask(input)))
   }
 
   export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
-    return runPromiseInstance(S.QuestionService.use((service) => service.reply(input)))
+    return runPromiseInstance(Service.use((svc) => svc.reply(input)))
   }
 
   export async function reject(requestID: QuestionID): Promise<void> {
-    return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID)))
+    return runPromiseInstance(Service.use((svc) => svc.reject(requestID)))
   }
 
   export async function list(): Promise<Request[]> {
-    return runPromiseInstance(S.QuestionService.use((service) => service.list()))
+    return runPromiseInstance(Service.use((svc) => svc.list()))
   }
 }

+ 0 - 172
packages/opencode/src/question/service.ts

@@ -1,172 +0,0 @@
-import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
-import { Bus } from "@/bus"
-import { BusEvent } from "@/bus/bus-event"
-import { SessionID, MessageID } from "@/session/schema"
-import { Log } from "@/util/log"
-import z from "zod"
-import { QuestionID } from "./schema"
-
-const log = Log.create({ service: "question" })
-
-// --- Zod schemas (re-exported by facade) ---
-
-export const Option = z
-  .object({
-    label: z.string().describe("Display text (1-5 words, concise)"),
-    description: z.string().describe("Explanation of choice"),
-  })
-  .meta({ ref: "QuestionOption" })
-export type Option = z.infer<typeof Option>
-
-export const Info = z
-  .object({
-    question: z.string().describe("Complete question"),
-    header: z.string().describe("Very short label (max 30 chars)"),
-    options: z.array(Option).describe("Available choices"),
-    multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
-    custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
-  })
-  .meta({ ref: "QuestionInfo" })
-export type Info = z.infer<typeof Info>
-
-export const Request = z
-  .object({
-    id: QuestionID.zod,
-    sessionID: SessionID.zod,
-    questions: z.array(Info).describe("Questions to ask"),
-    tool: z
-      .object({
-        messageID: MessageID.zod,
-        callID: z.string(),
-      })
-      .optional(),
-  })
-  .meta({ ref: "QuestionRequest" })
-export type Request = z.infer<typeof Request>
-
-export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
-export type Answer = z.infer<typeof Answer>
-
-export const Reply = z.object({
-  answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
-})
-export type Reply = z.infer<typeof Reply>
-
-export const Event = {
-  Asked: BusEvent.define("question.asked", Request),
-  Replied: BusEvent.define(
-    "question.replied",
-    z.object({
-      sessionID: SessionID.zod,
-      requestID: QuestionID.zod,
-      answers: z.array(Answer),
-    }),
-  ),
-  Rejected: BusEvent.define(
-    "question.rejected",
-    z.object({
-      sessionID: SessionID.zod,
-      requestID: QuestionID.zod,
-    }),
-  ),
-}
-
-export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
-  override get message() {
-    return "The user dismissed this question"
-  }
-}
-
-// --- Effect service ---
-
-interface PendingEntry {
-  info: Request
-  deferred: Deferred.Deferred<Answer[], RejectedError>
-}
-
-export namespace QuestionService {
-  export interface Service {
-    readonly ask: (input: {
-      sessionID: SessionID
-      questions: Info[]
-      tool?: { messageID: MessageID; callID: string }
-    }) => Effect.Effect<Answer[], RejectedError>
-    readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
-    readonly reject: (requestID: QuestionID) => Effect.Effect<void>
-    readonly list: () => Effect.Effect<Request[]>
-  }
-}
-
-export class QuestionService extends ServiceMap.Service<QuestionService, QuestionService.Service>()(
-  "@opencode/Question",
-) {
-  static readonly layer = Layer.effect(
-    QuestionService,
-    Effect.gen(function* () {
-      const pending = new Map<QuestionID, PendingEntry>()
-
-      const ask = Effect.fn("QuestionService.ask")(function* (input: {
-        sessionID: SessionID
-        questions: Info[]
-        tool?: { messageID: MessageID; callID: string }
-      }) {
-        const id = QuestionID.ascending()
-        log.info("asking", { id, questions: input.questions.length })
-
-        const deferred = yield* Deferred.make<Answer[], RejectedError>()
-        const info: Request = {
-          id,
-          sessionID: input.sessionID,
-          questions: input.questions,
-          tool: input.tool,
-        }
-        pending.set(id, { info, deferred })
-        Bus.publish(Event.Asked, info)
-
-        return yield* Effect.ensuring(
-          Deferred.await(deferred),
-          Effect.sync(() => {
-            pending.delete(id)
-          }),
-        )
-      })
-
-      const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
-        const existing = pending.get(input.requestID)
-        if (!existing) {
-          log.warn("reply for unknown request", { requestID: input.requestID })
-          return
-        }
-        pending.delete(input.requestID)
-        log.info("replied", { requestID: input.requestID, answers: input.answers })
-        Bus.publish(Event.Replied, {
-          sessionID: existing.info.sessionID,
-          requestID: existing.info.id,
-          answers: input.answers,
-        })
-        yield* Deferred.succeed(existing.deferred, input.answers)
-      })
-
-      const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) {
-        const existing = pending.get(requestID)
-        if (!existing) {
-          log.warn("reject for unknown request", { requestID })
-          return
-        }
-        pending.delete(requestID)
-        log.info("rejected", { requestID })
-        Bus.publish(Event.Rejected, {
-          sessionID: existing.info.sessionID,
-          requestID: existing.info.id,
-        })
-        yield* Deferred.fail(existing.deferred, new RejectedError())
-      })
-
-      const list = Effect.fn("QuestionService.list")(function* () {
-        return Array.from(pending.values(), (x) => x.info)
-      })
-
-      return QuestionService.of({ ask, reply, reject, list })
-    }),
-  )
-}

+ 2 - 2
packages/opencode/src/server/server.ts

@@ -14,7 +14,7 @@ import { LSP } from "../lsp"
 import { Format } from "../format"
 import { TuiRoutes } from "./routes/tui"
 import { Instance } from "../project/instance"
-import { Vcs, VcsService } from "../project/vcs"
+import { Vcs } from "../project/vcs"
 import { runPromiseInstance } from "@/effect/runtime"
 import { Agent } from "../agent/agent"
 import { Skill } from "../skill/skill"
@@ -332,7 +332,7 @@ export namespace Server {
           },
         }),
         async (c) => {
-          const branch = await runPromiseInstance(VcsService.use((s) => s.branch()))
+          const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
           return c.json({
             branch,
           })

+ 98 - 97
packages/opencode/src/skill/discovery.ts

@@ -1,116 +1,117 @@
 import { NodeFileSystem, NodePath } from "@effect/platform-node"
 import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
 import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+import { withTransientReadRetry } from "@/util/effect-http-client"
 import { Global } from "../global"
 import { Log } from "../util/log"
-import { withTransientReadRetry } from "@/util/effect-http-client"
 
-class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
-  name: Schema.String,
-  files: Schema.Array(Schema.String),
-}) {}
+export namespace Discovery {
+  const skillConcurrency = 4
+  const fileConcurrency = 8
 
-class Index extends Schema.Class<Index>("Index")({
-  skills: Schema.Array(IndexSkill),
-}) {}
+  class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
+    name: Schema.String,
+    files: Schema.Array(Schema.String),
+  }) {}
 
-const skillConcurrency = 4
-const fileConcurrency = 8
+  class Index extends Schema.Class<Index>("Index")({
+    skills: Schema.Array(IndexSkill),
+  }) {}
 
-export namespace DiscoveryService {
-  export interface Service {
+  export interface Interface {
     readonly pull: (url: string) => Effect.Effect<string[]>
   }
-}
 
-export class DiscoveryService extends ServiceMap.Service<DiscoveryService, DiscoveryService.Service>()(
-  "@opencode/SkillDiscovery",
-) {
-  static readonly layer = Layer.effect(
-    DiscoveryService,
-    Effect.gen(function* () {
-      const log = Log.create({ service: "skill-discovery" })
-      const fs = yield* FileSystem.FileSystem
-      const path = yield* Path.Path
-      const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
-      const cache = path.join(Global.Path.cache, "skills")
-
-      const download = Effect.fn("DiscoveryService.download")(function* (url: string, dest: string) {
-        if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
-
-        return yield* HttpClientRequest.get(url).pipe(
-          http.execute,
-          Effect.flatMap((res) => res.arrayBuffer),
-          Effect.flatMap((body) =>
-            fs
-              .makeDirectory(path.dirname(dest), { recursive: true })
-              .pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
-          ),
-          Effect.as(true),
-          Effect.catch((err) =>
-            Effect.sync(() => {
-              log.error("failed to download", { url, err })
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
+
+  export const layer: Layer.Layer<Service, never, FileSystem.FileSystem | Path.Path | HttpClient.HttpClient> =
+    Layer.effect(
+      Service,
+      Effect.gen(function* () {
+        const log = Log.create({ service: "skill-discovery" })
+        const fs = yield* FileSystem.FileSystem
+        const path = yield* Path.Path
+        const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
+        const cache = path.join(Global.Path.cache, "skills")
+
+        const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
+          if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
+
+          return yield* HttpClientRequest.get(url).pipe(
+            http.execute,
+            Effect.flatMap((res) => res.arrayBuffer),
+            Effect.flatMap((body) =>
+              fs
+                .makeDirectory(path.dirname(dest), { recursive: true })
+                .pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
+            ),
+            Effect.as(true),
+            Effect.catch((err) =>
+              Effect.sync(() => {
+                log.error("failed to download", { url, err })
+                return false
+              }),
+            ),
+          )
+        })
+
+        const pull = Effect.fn("Discovery.pull")(function* (url: string) {
+          const base = url.endsWith("/") ? url : `${url}/`
+          const index = new URL("index.json", base).href
+          const host = base.slice(0, -1)
+
+          log.info("fetching index", { url: index })
+
+          const data = yield* HttpClientRequest.get(index).pipe(
+            HttpClientRequest.acceptJson,
+            http.execute,
+            Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
+            Effect.catch((err) =>
+              Effect.sync(() => {
+                log.error("failed to fetch index", { url: index, err })
+                return null
+              }),
+            ),
+          )
+
+          if (!data) return []
+
+          const list = data.skills.filter((skill) => {
+            if (!skill.files.includes("SKILL.md")) {
+              log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
               return false
-            }),
-          ),
-        )
-      })
-
-      const pull: DiscoveryService.Service["pull"] = Effect.fn("DiscoveryService.pull")(function* (url: string) {
-        const base = url.endsWith("/") ? url : `${url}/`
-        const index = new URL("index.json", base).href
-        const host = base.slice(0, -1)
-
-        log.info("fetching index", { url: index })
-
-        const data = yield* HttpClientRequest.get(index).pipe(
-          HttpClientRequest.acceptJson,
-          http.execute,
-          Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
-          Effect.catch((err) =>
-            Effect.sync(() => {
-              log.error("failed to fetch index", { url: index, err })
-              return null
-            }),
-          ),
-        )
-
-        if (!data) return []
-
-        const list = data.skills.filter((skill) => {
-          if (!skill.files.includes("SKILL.md")) {
-            log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
-            return false
-          }
-          return true
+            }
+            return true
+          })
+
+          const dirs = yield* Effect.forEach(
+            list,
+            (skill) =>
+              Effect.gen(function* () {
+                const root = path.join(cache, skill.name)
+
+                yield* Effect.forEach(
+                  skill.files,
+                  (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
+                  {
+                    concurrency: fileConcurrency,
+                  },
+                )
+
+                const md = path.join(root, "SKILL.md")
+                return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
+              }),
+            { concurrency: skillConcurrency },
+          )
+
+          return dirs.filter((dir): dir is string => dir !== null)
         })
 
-        const dirs = yield* Effect.forEach(
-          list,
-          (skill) =>
-            Effect.gen(function* () {
-              const root = path.join(cache, skill.name)
-
-              yield* Effect.forEach(
-                skill.files,
-                (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
-                { concurrency: fileConcurrency },
-              )
-
-              const md = path.join(root, "SKILL.md")
-              return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
-            }),
-          { concurrency: skillConcurrency },
-        )
-
-        return dirs.filter((dir): dir is string => dir !== null)
-      })
-
-      return DiscoveryService.of({ pull })
-    }),
-  )
+        return Service.of({ pull })
+      }),
+    )
 
-  static readonly defaultLayer = DiscoveryService.layer.pipe(
+  export const defaultLayer: Layer.Layer<Service> = layer.pipe(
     Layer.provide(FetchHttpClient.layer),
     Layer.provide(NodeFileSystem.layer),
     Layer.provide(NodePath.layer),

+ 195 - 207
packages/opencode/src/skill/skill.ts

@@ -1,34 +1,30 @@
-import z from "zod"
-import path from "path"
 import os from "os"
-import { Config } from "../config/config"
-import { Instance } from "../project/instance"
-import { NamedError } from "@opencode-ai/util/error"
-import { ConfigMarkdown } from "../config/markdown"
-import { Log } from "../util/log"
-import { Global } from "@/global"
-import { Filesystem } from "@/util/filesystem"
-import { Flag } from "@/flag/flag"
-import { Bus } from "@/bus"
-import { DiscoveryService } from "./discovery"
-import { Glob } from "../util/glob"
+import path from "path"
 import { pathToFileURL } from "url"
+import z from "zod"
+import { Effect, Layer, ServiceMap } from "effect"
+import { NamedError } from "@opencode-ai/util/error"
 import type { Agent } from "@/agent/agent"
-import { PermissionNext } from "@/permission"
+import { Bus } from "@/bus"
 import { InstanceContext } from "@/effect/instance-context"
-import { Effect, Layer, ServiceMap } from "effect"
 import { runPromiseInstance } from "@/effect/runtime"
-
-const log = Log.create({ service: "skill" })
-
-// External skill directories to search for (project-level and global)
-// These follow the directory layout used by Claude Code and other agents.
-const EXTERNAL_DIRS = [".claude", ".agents"]
-const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
-const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
-const SKILL_PATTERN = "**/SKILL.md"
+import { Flag } from "@/flag/flag"
+import { Global } from "@/global"
+import { PermissionNext } from "@/permission"
+import { Filesystem } from "@/util/filesystem"
+import { Config } from "../config/config"
+import { ConfigMarkdown } from "../config/markdown"
+import { Glob } from "../util/glob"
+import { Log } from "../util/log"
+import { Discovery } from "./discovery"
 
 export namespace Skill {
+  const log = Log.create({ service: "skill" })
+  const EXTERNAL_DIRS = [".claude", ".agents"]
+  const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
+  const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
+  const SKILL_PATTERN = "**/SKILL.md"
+
   export const Info = z.object({
     name: z.string(),
     description: z.string(),
@@ -55,213 +51,205 @@ export namespace Skill {
     }),
   )
 
+  type State = {
+    skills: Record<string, Info>
+    dirs: Set<string>
+    task?: Promise<void>
+  }
+
+  type Cache = State & {
+    ensure: () => Promise<void>
+  }
+
+  export interface Interface {
+    readonly get: (name: string) => Effect.Effect<Info | undefined>
+    readonly all: () => Effect.Effect<Info[]>
+    readonly dirs: () => Effect.Effect<string[]>
+    readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
+  }
+
+  const add = async (state: State, match: string) => {
+    const md = await ConfigMarkdown.parse(match).catch(async (err) => {
+      const message = ConfigMarkdown.FrontmatterError.isInstance(err)
+        ? err.data.message
+        : `Failed to parse skill ${match}`
+      const { Session } = await import("@/session")
+      Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
+      log.error("failed to load skill", { skill: match, err })
+      return undefined
+    })
+
+    if (!md) return
+
+    const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
+    if (!parsed.success) return
+
+    if (state.skills[parsed.data.name]) {
+      log.warn("duplicate skill name", {
+        name: parsed.data.name,
+        existing: state.skills[parsed.data.name].location,
+        duplicate: match,
+      })
+    }
+
+    state.dirs.add(path.dirname(match))
+    state.skills[parsed.data.name] = {
+      name: parsed.data.name,
+      description: parsed.data.description,
+      location: match,
+      content: md.content,
+    }
+  }
+
+  const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
+    return Glob.scan(pattern, {
+      cwd: root,
+      absolute: true,
+      include: "file",
+      symlink: true,
+      dot: opts?.dot,
+    })
+      .then((matches) => Promise.all(matches.map((match) => add(state, match))))
+      .catch((error) => {
+        if (!opts?.scope) throw error
+        log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
+      })
+  }
+
+  // TODO: Migrate to Effect
+  const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
+    const state: State = {
+      skills: {},
+      dirs: new Set<string>(),
+    }
+
+    const load = async () => {
+      if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
+        for (const dir of EXTERNAL_DIRS) {
+          const root = path.join(Global.Path.home, dir)
+          if (!(await Filesystem.isDir(root))) continue
+          await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
+        }
+
+        for await (const root of Filesystem.up({
+          targets: EXTERNAL_DIRS,
+          start: instance.directory,
+          stop: instance.project.worktree,
+        })) {
+          await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
+        }
+      }
+
+      for (const dir of await Config.directories()) {
+        await scan(state, dir, OPENCODE_SKILL_PATTERN)
+      }
+
+      const cfg = await Config.get()
+      for (const item of cfg.skills?.paths ?? []) {
+        const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
+        const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
+        if (!(await Filesystem.isDir(dir))) {
+          log.warn("skill path not found", { path: dir })
+          continue
+        }
+
+        await scan(state, dir, SKILL_PATTERN)
+      }
+
+      for (const url of cfg.skills?.urls ?? []) {
+        for (const dir of await Effect.runPromise(discovery.pull(url))) {
+          state.dirs.add(dir)
+          await scan(state, dir, SKILL_PATTERN)
+        }
+      }
+
+      log.info("init", { count: Object.keys(state.skills).length })
+    }
+
+    const ensure = () => {
+      if (state.task) return state.task
+      state.task = load().catch((err) => {
+        state.task = undefined
+        throw err
+      })
+      return state.task
+    }
+
+    return { ...state, ensure }
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
+
+  export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const instance = yield* InstanceContext
+      const discovery = yield* Discovery.Service
+      const state = create(instance, discovery)
+
+      const get = Effect.fn("Skill.get")(function* (name: string) {
+        yield* Effect.promise(() => state.ensure())
+        return state.skills[name]
+      })
+
+      const all = Effect.fn("Skill.all")(function* () {
+        yield* Effect.promise(() => state.ensure())
+        return Object.values(state.skills)
+      })
+
+      const dirs = Effect.fn("Skill.dirs")(function* () {
+        yield* Effect.promise(() => state.ensure())
+        return Array.from(state.dirs)
+      })
+
+      const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
+        yield* Effect.promise(() => state.ensure())
+        const list = Object.values(state.skills)
+        if (!agent) return list
+        return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
+      })
+
+      return Service.of({ get, all, dirs, available })
+    }),
+  )
+
+  export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
+    Layer.provide(Discovery.defaultLayer),
+  )
+
   export async function get(name: string) {
-    return runPromiseInstance(SkillService.use((s) => s.get(name)))
+    return runPromiseInstance(Service.use((skill) => skill.get(name)))
   }
 
   export async function all() {
-    return runPromiseInstance(SkillService.use((s) => s.all()))
+    return runPromiseInstance(Service.use((skill) => skill.all()))
   }
 
   export async function dirs() {
-    return runPromiseInstance(SkillService.use((s) => s.dirs()))
+    return runPromiseInstance(Service.use((skill) => skill.dirs()))
   }
 
   export async function available(agent?: Agent.Info) {
-    return runPromiseInstance(SkillService.use((s) => s.available(agent)))
+    return runPromiseInstance(Service.use((skill) => skill.available(agent)))
   }
 
   export function fmt(list: Info[], opts: { verbose: boolean }) {
-    if (list.length === 0) {
-      return "No skills are currently available."
-    }
+    if (list.length === 0) return "No skills are currently available."
+
     if (opts.verbose) {
       return [
         "<available_skills>",
         ...list.flatMap((skill) => [
-          `  <skill>`,
+          "  <skill>",
           `    <name>${skill.name}</name>`,
           `    <description>${skill.description}</description>`,
           `    <location>${pathToFileURL(skill.location).href}</location>`,
-          `  </skill>`,
+          "  </skill>",
         ]),
         "</available_skills>",
       ].join("\n")
     }
-    return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
-  }
-}
 
-export namespace SkillService {
-  export interface Service {
-    readonly get: (name: string) => Effect.Effect<Skill.Info | undefined>
-    readonly all: () => Effect.Effect<Skill.Info[]>
-    readonly dirs: () => Effect.Effect<string[]>
-    readonly available: (agent?: Agent.Info) => Effect.Effect<Skill.Info[]>
+    return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
   }
 }
-
-export class SkillService extends ServiceMap.Service<SkillService, SkillService.Service>()("@opencode/Skill") {
-  static readonly layer = Layer.effect(
-    SkillService,
-    Effect.gen(function* () {
-      const instance = yield* InstanceContext
-      const discovery = yield* DiscoveryService
-
-      const skills: Record<string, Skill.Info> = {}
-      const skillDirs = new Set<string>()
-      let task: Promise<void> | undefined
-
-      const addSkill = async (match: string) => {
-        const md = await ConfigMarkdown.parse(match).catch(async (err) => {
-          const message = ConfigMarkdown.FrontmatterError.isInstance(err)
-            ? err.data.message
-            : `Failed to parse skill ${match}`
-          const { Session } = await import("@/session")
-          Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
-          log.error("failed to load skill", { skill: match, err })
-          return undefined
-        })
-
-        if (!md) return
-
-        const parsed = Skill.Info.pick({ name: true, description: true }).safeParse(md.data)
-        if (!parsed.success) return
-
-        // Warn on duplicate skill names
-        if (skills[parsed.data.name]) {
-          log.warn("duplicate skill name", {
-            name: parsed.data.name,
-            existing: skills[parsed.data.name].location,
-            duplicate: match,
-          })
-        }
-
-        skillDirs.add(path.dirname(match))
-
-        skills[parsed.data.name] = {
-          name: parsed.data.name,
-          description: parsed.data.description,
-          location: match,
-          content: md.content,
-        }
-      }
-
-      const scanExternal = async (root: string, scope: "global" | "project") => {
-        return Glob.scan(EXTERNAL_SKILL_PATTERN, {
-          cwd: root,
-          absolute: true,
-          include: "file",
-          dot: true,
-          symlink: true,
-        })
-          .then((matches) => Promise.all(matches.map(addSkill)))
-          .catch((error) => {
-            log.error(`failed to scan ${scope} skills`, { dir: root, error })
-          })
-      }
-
-      function ensureScanned() {
-        if (task) return task
-        task = (async () => {
-          // Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
-          // Load global (home) first, then project-level (so project-level overwrites)
-          if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
-            for (const dir of EXTERNAL_DIRS) {
-              const root = path.join(Global.Path.home, dir)
-              if (!(await Filesystem.isDir(root))) continue
-              await scanExternal(root, "global")
-            }
-
-            for await (const root of Filesystem.up({
-              targets: EXTERNAL_DIRS,
-              start: instance.directory,
-              stop: instance.project.worktree,
-            })) {
-              await scanExternal(root, "project")
-            }
-          }
-
-          // Scan .opencode/skill/ directories
-          for (const dir of await Config.directories()) {
-            const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, {
-              cwd: dir,
-              absolute: true,
-              include: "file",
-              symlink: true,
-            })
-            for (const match of matches) {
-              await addSkill(match)
-            }
-          }
-
-          // Scan additional skill paths from config
-          const config = await Config.get()
-          for (const skillPath of config.skills?.paths ?? []) {
-            const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath
-            const resolved = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
-            if (!(await Filesystem.isDir(resolved))) {
-              log.warn("skill path not found", { path: resolved })
-              continue
-            }
-            const matches = await Glob.scan(SKILL_PATTERN, {
-              cwd: resolved,
-              absolute: true,
-              include: "file",
-              symlink: true,
-            })
-            for (const match of matches) {
-              await addSkill(match)
-            }
-          }
-
-          // Download and load skills from URLs
-          for (const url of config.skills?.urls ?? []) {
-            const list = await Effect.runPromise(discovery.pull(url))
-            for (const dir of list) {
-              skillDirs.add(dir)
-              const matches = await Glob.scan(SKILL_PATTERN, {
-                cwd: dir,
-                absolute: true,
-                include: "file",
-                symlink: true,
-              })
-              for (const match of matches) {
-                await addSkill(match)
-              }
-            }
-          }
-
-          log.info("init", { count: Object.keys(skills).length })
-        })().catch((err) => {
-          task = undefined
-          throw err
-        })
-        return task
-      }
-
-      return SkillService.of({
-        get: Effect.fn("SkillService.get")(function* (name: string) {
-          yield* Effect.promise(() => ensureScanned())
-          return skills[name]
-        }),
-        all: Effect.fn("SkillService.all")(function* () {
-          yield* Effect.promise(() => ensureScanned())
-          return Object.values(skills)
-        }),
-        dirs: Effect.fn("SkillService.dirs")(function* () {
-          yield* Effect.promise(() => ensureScanned())
-          return Array.from(skillDirs)
-        }),
-        available: Effect.fn("SkillService.available")(function* (agent?: Agent.Info) {
-          yield* Effect.promise(() => ensureScanned())
-          const list = Object.values(skills)
-          if (!agent) return list
-          return list.filter(
-            (skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny",
-          )
-        }),
-      })
-    }),
-  ).pipe(Layer.provide(DiscoveryService.defaultLayer))
-}

+ 133 - 166
packages/opencode/src/snapshot/index.ts

@@ -9,20 +9,6 @@ import { Config } from "../config/config"
 import { Global } from "../global"
 import { Log } from "../util/log"
 
-const log = Log.create({ service: "snapshot" })
-const PRUNE = "7.days"
-
-// Common git config flags shared across snapshot operations
-const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
-const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE]
-const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"]
-
-interface GitResult {
-  readonly code: ChildProcessSpawner.ExitCode
-  readonly text: string
-  readonly stderr: string
-}
-
 export namespace Snapshot {
   export const Patch = z.object({
     hash: z.string(),
@@ -44,43 +30,47 @@ export namespace Snapshot {
     })
   export type FileDiff = z.infer<typeof FileDiff>
 
-  // Promise facade — existing callers use these
-  export function init() {
-    void runPromiseInstance(SnapshotService.use((s) => s.init()))
-  }
-
   export async function cleanup() {
-    return runPromiseInstance(SnapshotService.use((s) => s.cleanup()))
+    return runPromiseInstance(Service.use((svc) => svc.cleanup()))
   }
 
   export async function track() {
-    return runPromiseInstance(SnapshotService.use((s) => s.track()))
+    return runPromiseInstance(Service.use((svc) => svc.track()))
   }
 
   export async function patch(hash: string) {
-    return runPromiseInstance(SnapshotService.use((s) => s.patch(hash)))
+    return runPromiseInstance(Service.use((svc) => svc.patch(hash)))
   }
 
   export async function restore(snapshot: string) {
-    return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot)))
+    return runPromiseInstance(Service.use((svc) => svc.restore(snapshot)))
   }
 
   export async function revert(patches: Patch[]) {
-    return runPromiseInstance(SnapshotService.use((s) => s.revert(patches)))
+    return runPromiseInstance(Service.use((svc) => svc.revert(patches)))
   }
 
   export async function diff(hash: string) {
-    return runPromiseInstance(SnapshotService.use((s) => s.diff(hash)))
+    return runPromiseInstance(Service.use((svc) => svc.diff(hash)))
   }
 
   export async function diffFull(from: string, to: string) {
-    return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to)))
+    return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to)))
+  }
+
+  const log = Log.create({ service: "snapshot" })
+  const prune = "7.days"
+  const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
+  const cfg = ["-c", "core.autocrlf=false", ...core]
+  const quote = [...cfg, "-c", "core.quotepath=false"]
+
+  interface GitResult {
+    readonly code: ChildProcessSpawner.ExitCode
+    readonly text: string
+    readonly stderr: string
   }
-}
 
-export namespace SnapshotService {
-  export interface Service {
-    readonly init: () => Effect.Effect<void>
+  export interface Interface {
     readonly cleanup: () => Effect.Effect<void>
     readonly track: () => Effect.Effect<string | undefined>
     readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
@@ -89,99 +79,92 @@ export namespace SnapshotService {
     readonly diff: (hash: string) => Effect.Effect<string>
     readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
   }
-}
 
-export class SnapshotService extends ServiceMap.Service<SnapshotService, SnapshotService.Service>()(
-  "@opencode/Snapshot",
-) {
-  static readonly layer = Layer.effect(
-    SnapshotService,
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
+
+  export const layer: Layer.Layer<
+    Service,
+    never,
+    InstanceContext | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
+  > = Layer.effect(
+    Service,
     Effect.gen(function* () {
       const ctx = yield* InstanceContext
-      const fileSystem = yield* FileSystem.FileSystem
+      const fs = yield* FileSystem.FileSystem
       const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
-      const { directory, worktree, project } = ctx
-      const isGit = project.vcs === "git"
-      const snapshotGit = path.join(Global.Path.data, "snapshot", project.id)
+      const directory = ctx.directory
+      const worktree = ctx.worktree
+      const project = ctx.project
+      const gitdir = path.join(Global.Path.data, "snapshot", project.id)
 
-      const gitArgs = (cmd: string[]) => ["--git-dir", snapshotGit, "--work-tree", worktree, ...cmd]
+      const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
 
-      // Run git with nothrow semantics — always returns a result, never fails
-      const git = (args: string[], opts?: { cwd?: string; env?: Record<string, string> }): Effect.Effect<GitResult> =>
-        Effect.gen(function* () {
-          const command = ChildProcess.make("git", args, {
+      const git = Effect.fnUntraced(
+        function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
+          const proc = ChildProcess.make("git", cmd, {
             cwd: opts?.cwd,
             env: opts?.env,
             extendEnv: true,
           })
-          const handle = yield* spawner.spawn(command)
+          const handle = yield* spawner.spawn(proc)
           const [text, stderr] = yield* Effect.all(
             [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
             { concurrency: 2 },
           )
           const code = yield* handle.exitCode
-          return { code, text, stderr }
-        }).pipe(
-          Effect.scoped,
-          Effect.catch((err) =>
-            Effect.succeed({
-              code: ChildProcessSpawner.ExitCode(1),
-              text: "",
-              stderr: String(err),
-            }),
-          ),
-        )
-
-      // FileSystem helpers — orDie converts PlatformError to defects
-      const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie)
-      const mkdir = (p: string) => fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie)
-      const writeFile = (p: string, content: string) => fileSystem.writeFileString(p, content).pipe(Effect.orDie)
-      const readFile = (p: string) => fileSystem.readFileString(p).pipe(Effect.catch(() => Effect.succeed("")))
-      const removeFile = (p: string) => fileSystem.remove(p).pipe(Effect.catch(() => Effect.void))
+          return { code, text, stderr } satisfies GitResult
+        },
+        Effect.scoped,
+        Effect.catch((err) =>
+          Effect.succeed({
+            code: ChildProcessSpawner.ExitCode(1),
+            text: "",
+            stderr: String(err),
+          }),
+        ),
+      )
 
-      // --- internal Effect helpers ---
+      const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
+      const mkdir = (dir: string) => fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
+      const write = (file: string, text: string) => fs.writeFileString(file, text).pipe(Effect.orDie)
+      const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
+      const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
 
-      const isEnabled = Effect.gen(function* () {
-        if (!isGit) return false
-        const cfg = yield* Effect.promise(() => Config.get())
-        return cfg.snapshot !== false
+      const enabled = Effect.fnUntraced(function* () {
+        if (project.vcs !== "git") return false
+        return (yield* Effect.promise(() => Config.get())).snapshot !== false
       })
 
-      const excludesPath = Effect.gen(function* () {
+      const excludes = Effect.fnUntraced(function* () {
         const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
           cwd: worktree,
         })
         const file = result.text.trim()
-        if (!file) return undefined
-        if (!(yield* exists(file))) return undefined
+        if (!file) return
+        if (!(yield* exists(file))) return
         return file
       })
 
-      const syncExclude = Effect.gen(function* () {
-        const file = yield* excludesPath
-        const target = path.join(snapshotGit, "info", "exclude")
-        yield* mkdir(path.join(snapshotGit, "info"))
+      const sync = Effect.fnUntraced(function* () {
+        const file = yield* excludes()
+        const target = path.join(gitdir, "info", "exclude")
+        yield* mkdir(path.join(gitdir, "info"))
         if (!file) {
-          yield* writeFile(target, "")
+          yield* write(target, "")
           return
         }
-        const text = yield* readFile(file)
-        yield* writeFile(target, text)
+        yield* write(target, yield* read(file))
       })
 
-      const add = Effect.gen(function* () {
-        yield* syncExclude
-        yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory })
+      const add = Effect.fnUntraced(function* () {
+        yield* sync()
+        yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
       })
 
-      // --- service methods ---
-
-      const cleanup = Effect.fn("SnapshotService.cleanup")(function* () {
-        if (!(yield* isEnabled)) return
-        if (!(yield* exists(snapshotGit))) return
-        const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), {
-          cwd: directory,
-        })
+      const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
+        if (!(yield* enabled())) return
+        if (!(yield* exists(gitdir))) return
+        const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
         if (result.code !== 0) {
           log.warn("cleanup failed", {
             exitCode: result.code,
@@ -189,58 +172,55 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
           })
           return
         }
-        log.info("cleanup", { prune: PRUNE })
+        log.info("cleanup", { prune })
       })
 
-      const track = Effect.fn("SnapshotService.track")(function* () {
-        if (!(yield* isEnabled)) return undefined
-        const existed = yield* exists(snapshotGit)
-        yield* mkdir(snapshotGit)
+      const track = Effect.fn("Snapshot.track")(function* () {
+        if (!(yield* enabled())) return
+        const existed = yield* exists(gitdir)
+        yield* mkdir(gitdir)
         if (!existed) {
           yield* git(["init"], {
-            env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree },
+            env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
           })
-          yield* git(["--git-dir", snapshotGit, "config", "core.autocrlf", "false"])
-          yield* git(["--git-dir", snapshotGit, "config", "core.longpaths", "true"])
-          yield* git(["--git-dir", snapshotGit, "config", "core.symlinks", "true"])
-          yield* git(["--git-dir", snapshotGit, "config", "core.fsmonitor", "false"])
+          yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
+          yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
+          yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
+          yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
           log.info("initialized")
         }
-        yield* add
-        const result = yield* git(gitArgs(["write-tree"]), { cwd: directory })
+        yield* add()
+        const result = yield* git(args(["write-tree"]), { cwd: directory })
         const hash = result.text.trim()
-        log.info("tracking", { hash, cwd: directory, git: snapshotGit })
+        log.info("tracking", { hash, cwd: directory, git: gitdir })
         return hash
       })
 
-      const patch = Effect.fn("SnapshotService.patch")(function* (hash: string) {
-        yield* add
-        const result = yield* git(
-          [...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
-          { cwd: directory },
-        )
-
+      const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
+        yield* add()
+        const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
+          cwd: directory,
+        })
         if (result.code !== 0) {
           log.warn("failed to get diff", { hash, exitCode: result.code })
-          return { hash, files: [] } as Snapshot.Patch
+          return { hash, files: [] }
         }
-
         return {
           hash,
           files: result.text
             .trim()
             .split("\n")
-            .map((x: string) => x.trim())
+            .map((x) => x.trim())
             .filter(Boolean)
-            .map((x: string) => path.join(worktree, x).replaceAll("\\", "/")),
-        } as Snapshot.Patch
+            .map((x) => path.join(worktree, x).replaceAll("\\", "/")),
+        }
       })
 
-      const restore = Effect.fn("SnapshotService.restore")(function* (snapshot: string) {
+      const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
         log.info("restore", { commit: snapshot })
-        const result = yield* git([...GIT_CORE, ...gitArgs(["read-tree", snapshot])], { cwd: worktree })
+        const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
         if (result.code === 0) {
-          const checkout = yield* git([...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], { cwd: worktree })
+          const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
           if (checkout.code === 0) return
           log.error("failed to restore snapshot", {
             snapshot,
@@ -256,38 +236,33 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
         })
       })
 
-      const revert = Effect.fn("SnapshotService.revert")(function* (patches: Snapshot.Patch[]) {
+      const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
         const seen = new Set<string>()
         for (const item of patches) {
           for (const file of item.files) {
             if (seen.has(file)) continue
+            seen.add(file)
             log.info("reverting", { file, hash: item.hash })
-            const result = yield* git([...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], {
-              cwd: worktree,
-            })
+            const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree })
             if (result.code !== 0) {
-              const relativePath = path.relative(worktree, file)
-              const checkTree = yield* git([...GIT_CORE, ...gitArgs(["ls-tree", item.hash, "--", relativePath])], {
-                cwd: worktree,
-              })
-              if (checkTree.code === 0 && checkTree.text.trim()) {
+              const rel = path.relative(worktree, file)
+              const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree })
+              if (tree.code === 0 && tree.text.trim()) {
                 log.info("file existed in snapshot but checkout failed, keeping", { file })
               } else {
                 log.info("file did not exist in snapshot, deleting", { file })
-                yield* removeFile(file)
+                yield* remove(file)
               }
             }
-            seen.add(file)
           }
         }
       })
 
-      const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) {
-        yield* add
-        const result = yield* git([...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."])], {
+      const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
+        yield* add()
+        const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
           cwd: worktree,
         })
-
         if (result.code !== 0) {
           log.warn("failed to get diff", {
             hash,
@@ -296,19 +271,15 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
           })
           return ""
         }
-
         return result.text.trim()
       })
 
-      const diffFull = Effect.fn("SnapshotService.diffFull")(function* (from: string, to: string) {
+      const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
         const result: Snapshot.FileDiff[] = []
         const status = new Map<string, "added" | "deleted" | "modified">()
 
         const statuses = yield* git(
-          [
-            ...GIT_CFG_QUOTE,
-            ...gitArgs(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
-          ],
+          [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
           { cwd: directory },
         )
 
@@ -316,64 +287,60 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
           if (!line) continue
           const [code, file] = line.split("\t")
           if (!code || !file) continue
-          const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
-          status.set(file, kind)
+          status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
         }
 
         const numstat = yield* git(
-          [...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
-          { cwd: directory },
+          [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
+          {
+            cwd: directory,
+          },
         )
 
         for (const line of numstat.text.trim().split("\n")) {
           if (!line) continue
-          const [additions, deletions, file] = line.split("\t")
-          const isBinaryFile = additions === "-" && deletions === "-"
-          const [before, after] = isBinaryFile
+          const [adds, dels, file] = line.split("\t")
+          if (!file) continue
+          const binary = adds === "-" && dels === "-"
+          const [before, after] = binary
             ? ["", ""]
             : yield* Effect.all(
                 [
-                  git([...GIT_CFG, ...gitArgs(["show", `${from}:${file}`])]).pipe(Effect.map((r) => r.text)),
-                  git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(Effect.map((r) => r.text)),
+                  git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
+                  git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
                 ],
                 { concurrency: 2 },
               )
-          const added = isBinaryFile ? 0 : parseInt(additions!)
-          const deleted = isBinaryFile ? 0 : parseInt(deletions!)
+          const additions = binary ? 0 : parseInt(adds)
+          const deletions = binary ? 0 : parseInt(dels)
           result.push({
-            file: file!,
+            file,
             before,
             after,
-            additions: Number.isFinite(added) ? added : 0,
-            deletions: Number.isFinite(deleted) ? deleted : 0,
-            status: status.get(file!) ?? "modified",
+            additions: Number.isFinite(additions) ? additions : 0,
+            deletions: Number.isFinite(deletions) ? deletions : 0,
+            status: status.get(file) ?? "modified",
           })
         }
+
         return result
       })
 
-      // Start hourly cleanup fiber — scoped to instance lifetime
       yield* cleanup().pipe(
         Effect.catchCause((cause) => {
           log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
           return Effect.void
         }),
         Effect.repeat(Schedule.spaced(Duration.hours(1))),
+        Effect.delay(Duration.minutes(1)),
         Effect.forkScoped,
       )
 
-      return SnapshotService.of({
-        init: Effect.fn("SnapshotService.init")(function* () {}),
-        cleanup,
-        track,
-        patch,
-        restore,
-        revert,
-        diff,
-        diffFull,
-      })
+      return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
     }),
-  ).pipe(
+  )
+
+  export const defaultLayer = layer.pipe(
     Layer.provide(NodeChildProcessSpawner.layer),
     Layer.provide(NodeFileSystem.layer),
     Layer.provide(NodePath.layer),

+ 6 - 6
packages/opencode/src/tool/truncate-effect.ts

@@ -2,7 +2,7 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node"
 import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
 import path from "path"
 import type { Agent } from "../agent/agent"
-import { PermissionEffect } from "../permission/service"
+import { PermissionNext } from "../permission"
 import { Identifier } from "../id/id"
 import { Log } from "../util/log"
 import { ToolID } from "./schema"
@@ -27,10 +27,10 @@ export namespace TruncateEffect {
 
   function hasTaskTool(agent?: Agent.Info) {
     if (!agent?.permission) return false
-    return PermissionEffect.evaluate("task", "*", agent.permission).action !== "deny"
+    return PermissionNext.evaluate("task", "*", agent.permission).action !== "deny"
   }
 
-  export interface Api {
+  export interface Interface {
     readonly cleanup: () => Effect.Effect<void>
     /**
      * Returns output unchanged when it fits within the limits, otherwise writes the full text
@@ -39,14 +39,14 @@ export namespace TruncateEffect {
     readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
   }
 
-  export class Service extends ServiceMap.Service<Service, Api>()("@opencode/Truncate") {}
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
 
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
       const fs = yield* FileSystem.FileSystem
 
-      const cleanup = Effect.fn("TruncateEffect.cleanup")(function* () {
+      const cleanup = Effect.fn("Truncate.cleanup")(function* () {
         const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
         const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
           Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
@@ -58,7 +58,7 @@ export namespace TruncateEffect {
         }
       })
 
-      const output = Effect.fn("TruncateEffect.output")(function* (
+      const output = Effect.fn("Truncate.output")(function* (
         text: string,
         options: Options = {},
         agent?: Agent.Info,

+ 8 - 6
packages/opencode/test/account/service.test.ts

@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
 import { HttpClient, HttpClientResponse } from "effect/unstable/http"
 
 import { AccountRepo } from "../../src/account/repo"
-import { AccountService } from "../../src/account/service"
+import { AccountEffect } from "../../src/account/effect"
 import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
 import { Database } from "../../src/storage/db"
 import { testEffect } from "../lib/effect"
@@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard(
 const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
 
 const live = (client: HttpClient.HttpClient) =>
-  AccountService.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
+  AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
 
 const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
   HttpClientResponse.fromWeb(
@@ -77,7 +77,7 @@ it.effect("orgsByAccount groups orgs per account", () =>
       }),
     )
 
-    const rows = yield* AccountService.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
+    const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
 
     expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
       [AccountID.make("user-1"), [OrgID.make("org-1")]],
@@ -115,7 +115,7 @@ it.effect("token refresh persists the new token", () =>
       ),
     )
 
-    const token = yield* AccountService.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
+    const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
 
     expect(Option.getOrThrow(token)).toBeDefined()
     expect(String(Option.getOrThrow(token))).toBe("at_new")
@@ -158,7 +158,9 @@ it.effect("config sends the selected org header", () =>
       }),
     )
 
-    const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
+    const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(
+      Effect.provide(live(client)),
+    )
 
     expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
     expect(seen).toEqual({
@@ -196,7 +198,7 @@ it.effect("poll stores the account and first org on success", () =>
       ),
     )
 
-    const res = yield* AccountService.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
+    const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
 
     expect(res._tag).toBe("PollSuccess")
     if (res._tag === "PollSuccess") {

+ 4 - 5
packages/opencode/test/file/watcher.test.ts

@@ -5,7 +5,7 @@ import path from "path"
 import { Deferred, Effect, Fiber, Option } from "effect"
 import { tmpdir } from "../fixture/fixture"
 import { watcherConfigLayer, withServices } from "../fixture/instance"
-import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
+import { FileWatcher } from "../../src/file/watcher"
 import { Instance } from "../../src/project/instance"
 import { GlobalBus } from "../../src/bus/global"
 
@@ -19,13 +19,12 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
 type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
 type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
 
-/** Run `body` with a live FileWatcherService. */
+/** Run `body` with a live FileWatcher service. */
 function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
   return withServices(
     directory,
-    FileWatcherService.layer,
+    FileWatcher.layer,
     async (rt) => {
-      await rt.runPromise(FileWatcherService.use((s) => s.init()))
       await Effect.runPromise(ready(directory))
       await Effect.runPromise(body)
     },
@@ -138,7 +137,7 @@ function ready(directory: string) {
 // Tests
 // ---------------------------------------------------------------------------
 
-describeWatcher("FileWatcherService", () => {
+describeWatcher("FileWatcher", () => {
   afterEach(() => Instance.disposeAll())
 
   test("publishes root create, update, and delete events", async () => {

+ 12 - 11
packages/opencode/test/format/format.test.ts

@@ -1,17 +1,18 @@
+import { Effect } from "effect"
 import { afterEach, describe, expect, test } from "bun:test"
 import { tmpdir } from "../fixture/fixture"
 import { withServices } from "../fixture/instance"
-import { FormatService } from "../../src/format"
+import { Format } from "../../src/format"
 import { Instance } from "../../src/project/instance"
 
-describe("FormatService", () => {
+describe("Format", () => {
   afterEach(() => Instance.disposeAll())
 
   test("status() returns built-in formatters when no config overrides", async () => {
     await using tmp = await tmpdir()
 
-    await withServices(tmp.path, FormatService.layer, async (rt) => {
-      const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
+    await withServices(tmp.path, Format.layer, async (rt) => {
+      const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
       expect(Array.isArray(statuses)).toBe(true)
       expect(statuses.length).toBeGreaterThan(0)
 
@@ -32,8 +33,8 @@ describe("FormatService", () => {
       config: { formatter: false },
     })
 
-    await withServices(tmp.path, FormatService.layer, async (rt) => {
-      const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
+    await withServices(tmp.path, Format.layer, async (rt) => {
+      const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
       expect(statuses).toEqual([])
     })
   })
@@ -47,18 +48,18 @@ describe("FormatService", () => {
       },
     })
 
-    await withServices(tmp.path, FormatService.layer, async (rt) => {
-      const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
+    await withServices(tmp.path, Format.layer, async (rt) => {
+      const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
       const gofmt = statuses.find((s) => s.name === "gofmt")
       expect(gofmt).toBeUndefined()
     })
   })
 
-  test("init() completes without error", async () => {
+  test("service initializes without error", async () => {
     await using tmp = await tmpdir()
 
-    await withServices(tmp.path, FormatService.layer, async (rt) => {
-      await rt.runPromise(FormatService.use((s) => s.init()))
+    await withServices(tmp.path, Format.layer, async (rt) => {
+      await rt.runPromise(Format.Service.use(() => Effect.void))
     })
   })
 })

+ 2 - 2
packages/opencode/test/permission/next.test.ts

@@ -5,7 +5,7 @@ import { Bus } from "../../src/bus"
 import { runtime } from "../../src/effect/runtime"
 import { Instances } from "../../src/effect/instances"
 import { PermissionNext } from "../../src/permission"
-import * as S from "../../src/permission/service"
+import { PermissionNext as S } from "../../src/permission"
 import { PermissionID } from "../../src/permission/schema"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
@@ -1005,7 +1005,7 @@ test("ask - abort should clear pending request", async () => {
     fn: async () => {
       const ctl = new AbortController()
       const ask = runtime.runPromise(
-        S.PermissionEffect.Service.use((svc) =>
+        S.Service.use((svc) =>
           svc.ask({
             sessionID: SessionID.make("session_test"),
             permission: "bash",

+ 2 - 1
packages/opencode/test/plugin/auth-override.test.ts

@@ -4,6 +4,7 @@ import fs from "fs/promises"
 import { tmpdir } from "../fixture/fixture"
 import { Instance } from "../../src/project/instance"
 import { ProviderAuth } from "../../src/provider/auth"
+import { ProviderID } from "../../src/provider/schema"
 
 describe("plugin.auth-override", () => {
   test("user plugin overrides built-in github-copilot auth", async () => {
@@ -34,7 +35,7 @@ describe("plugin.auth-override", () => {
       directory: tmp.path,
       fn: async () => {
         const methods = await ProviderAuth.methods()
-        const copilot = methods["github-copilot"]
+        const copilot = methods[ProviderID.make("github-copilot")]
         expect(copilot).toBeDefined()
         expect(copilot.length).toBe(1)
         expect(copilot[0].label).toBe("Test Override Auth")

+ 19 - 13
packages/opencode/test/project/vcs.test.ts

@@ -2,13 +2,13 @@ import { $ } from "bun"
 import { afterEach, describe, expect, test } from "bun:test"
 import fs from "fs/promises"
 import path from "path"
-import { Layer, ManagedRuntime } from "effect"
+import { Effect, Layer, ManagedRuntime } from "effect"
 import { tmpdir } from "../fixture/fixture"
 import { watcherConfigLayer, withServices } from "../fixture/instance"
-import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
+import { FileWatcher } from "../../src/file/watcher"
 import { Instance } from "../../src/project/instance"
 import { GlobalBus } from "../../src/bus/global"
-import { Vcs, VcsService } from "../../src/project/vcs"
+import { Vcs } from "../../src/project/vcs"
 
 // Skip in CI — native @parcel/watcher binding needed
 const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@@ -19,15 +19,15 @@ const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe
 
 function withVcs(
   directory: string,
-  body: (rt: ManagedRuntime.ManagedRuntime<FileWatcherService | VcsService, never>) => Promise<void>,
+  body: (rt: ManagedRuntime.ManagedRuntime<FileWatcher.Service | Vcs.Service, never>) => Promise<void>,
 ) {
   return withServices(
     directory,
-    Layer.merge(FileWatcherService.layer, VcsService.layer),
+    Layer.merge(FileWatcher.layer, Vcs.layer),
     async (rt) => {
-      await rt.runPromise(FileWatcherService.use((s) => s.init()))
-      await rt.runPromise(VcsService.use((s) => s.init()))
-      await Bun.sleep(200)
+      await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
+      await rt.runPromise(Vcs.Service.use(() => Effect.void))
+      await Bun.sleep(500)
       await body(rt)
     },
     { provide: [watcherConfigLayer] },
@@ -36,10 +36,14 @@ function withVcs(
 
 type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
 
-/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus */
-function nextBranchUpdate(directory: string, timeout = 5000) {
+/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */
+function nextBranchUpdate(directory: string, timeout = 10_000) {
   return new Promise<string | undefined>((resolve, reject) => {
+    let settled = false
+
     const timer = setTimeout(() => {
+      if (settled) return
+      settled = true
       GlobalBus.off("event", on)
       reject(new Error("timed out waiting for BranchUpdated event"))
     }, timeout)
@@ -47,6 +51,8 @@ function nextBranchUpdate(directory: string, timeout = 5000) {
     function on(evt: BranchEvent) {
       if (evt.directory !== directory) return
       if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return
+      if (settled) return
+      settled = true
       clearTimeout(timer)
       GlobalBus.off("event", on)
       resolve(evt.payload.properties.branch)
@@ -67,7 +73,7 @@ describeVcs("Vcs", () => {
     await using tmp = await tmpdir({ git: true })
 
     await withVcs(tmp.path, async (rt) => {
-      const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
+      const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
       expect(branch).toBeDefined()
       expect(typeof branch).toBe("string")
     })
@@ -77,7 +83,7 @@ describeVcs("Vcs", () => {
     await using tmp = await tmpdir()
 
     await withVcs(tmp.path, async (rt) => {
-      const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
+      const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
       expect(branch).toBeUndefined()
     })
   })
@@ -110,7 +116,7 @@ describeVcs("Vcs", () => {
       await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
 
       await pending
-      const current = await rt.runPromise(VcsService.use((s) => s.branch()))
+      const current = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
       expect(current).toBe(branch)
     })
   })

+ 3 - 0
packages/opencode/test/server/project-init-git.test.ts

@@ -68,6 +68,7 @@ describe("project.initGit endpoint", () => {
         },
       })
     } finally {
+      await Instance.disposeAll()
       reloadSpy.mockRestore()
       GlobalBus.off("event", fn)
     }
@@ -111,7 +112,9 @@ describe("project.initGit endpoint", () => {
         vcs: "git",
         worktree: tmp.path,
       })
+
     } finally {
+      await Instance.disposeAll()
       reloadSpy.mockRestore()
       GlobalBus.off("event", fn)
     }

+ 2 - 2
packages/opencode/test/skill/discovery.test.ts

@@ -1,6 +1,6 @@
 import { describe, test, expect, beforeAll, afterAll } from "bun:test"
 import { Effect } from "effect"
-import { DiscoveryService } from "../../src/skill/discovery"
+import { Discovery } from "../../src/skill/discovery"
 import { Global } from "../../src/global"
 import { Filesystem } from "../../src/util/filesystem"
 import { rm } from "fs/promises"
@@ -48,7 +48,7 @@ afterAll(async () => {
 
 describe("Discovery.pull", () => {
   const pull = (url: string) =>
-    Effect.runPromise(DiscoveryService.use((s) => s.pull(url)).pipe(Effect.provide(DiscoveryService.defaultLayer)))
+    Effect.runPromise(Discovery.Service.use((s) => s.pull(url)).pipe(Effect.provide(Discovery.defaultLayer)))
 
   test("downloads skills from cloudflare url", async () => {
     const dirs = await pull(CLOUDFLARE_SKILLS_URL)

+ 34 - 0
packages/opencode/test/snapshot/snapshot.test.ts

@@ -1178,3 +1178,37 @@ test("diffFull with whitespace changes", async () => {
     },
   })
 })
+
+test("revert with overlapping files across patches uses first patch hash", async () => {
+  await using tmp = await bootstrap()
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      // Write initial content and snapshot
+      await Filesystem.write(`${tmp.path}/shared.txt`, "v1")
+      const snap1 = await Snapshot.track()
+      expect(snap1).toBeTruthy()
+
+      // Modify and snapshot again
+      await Filesystem.write(`${tmp.path}/shared.txt`, "v2")
+      const snap2 = await Snapshot.track()
+      expect(snap2).toBeTruthy()
+
+      // Modify once more so both patches include shared.txt
+      await Filesystem.write(`${tmp.path}/shared.txt`, "v3")
+
+      const patch1 = await Snapshot.patch(snap1!)
+      const patch2 = await Snapshot.patch(snap2!)
+
+      // Both patches should include shared.txt
+      expect(patch1.files).toContain(fwd(tmp.path, "shared.txt"))
+      expect(patch2.files).toContain(fwd(tmp.path, "shared.txt"))
+
+      // Revert with patch1 first — should use snap1's hash (restoring "v1")
+      await Snapshot.revert([patch1, patch2])
+
+      const content = await fs.readFile(`${tmp.path}/shared.txt`, "utf-8")
+      expect(content).toBe("v1")
+    },
+  })
+})