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

Move service state into InstanceState, flatten service facades (#18483)

Kit Langton 4 недель назад
Родитель
Сommit
38e0dc9ccd
84 измененных файлов с 4546 добавлено и 3752 удалено
  1. 0 2
      packages/opencode/script/seed-e2e.ts
  2. 53 54
      packages/opencode/specs/effect-migration.md
  3. 0 380
      packages/opencode/src/account/effect.ts
  4. 380 17
      packages/opencode/src/account/index.ts
  5. 22 22
      packages/opencode/src/agent/agent.ts
  6. 0 94
      packages/opencode/src/auth/effect.ts
  7. 94 36
      packages/opencode/src/auth/index.ts
  8. 5 6
      packages/opencode/src/cli/cmd/account.ts
  9. 6 6
      packages/opencode/src/cli/cmd/debug/agent.ts
  10. 2 2
      packages/opencode/src/cli/cmd/run.ts
  11. 1 1
      packages/opencode/src/cli/cmd/tui/context/sync.tsx
  12. 1 1
      packages/opencode/src/config/config.ts
  13. 47 0
      packages/opencode/src/effect/instance-state.ts
  14. 0 68
      packages/opencode/src/effect/instances.ts
  15. 13 0
      packages/opencode/src/effect/run-service.ts
  16. 0 25
      packages/opencode/src/effect/runtime.ts
  17. 689 17
      packages/opencode/src/file/index.ts
  18. 0 674
      packages/opencode/src/file/service.ts
  19. 0 93
      packages/opencode/src/file/time-service.ts
  20. 110 10
      packages/opencode/src/file/time.ts
  21. 96 70
      packages/opencode/src/file/watcher.ts
  22. 174 8
      packages/opencode/src/format/index.ts
  23. 0 152
      packages/opencode/src/format/service.ts
  24. 7 12
      packages/opencode/src/installation/index.ts
  25. 303 33
      packages/opencode/src/permission/index.ts
  26. 0 282
      packages/opencode/src/permission/service.ts
  27. 8 0
      packages/opencode/src/project/bootstrap.ts
  28. 4 4
      packages/opencode/src/project/instance.ts
  29. 62 34
      packages/opencode/src/project/vcs.ts
  30. 0 215
      packages/opencode/src/provider/auth-service.ts
  31. 234 32
      packages/opencode/src/provider/auth.ts
  32. 195 23
      packages/opencode/src/question/index.ts
  33. 0 172
      packages/opencode/src/question/service.ts
  34. 5 5
      packages/opencode/src/server/routes/permission.ts
  35. 4 4
      packages/opencode/src/server/routes/session.ts
  36. 3 4
      packages/opencode/src/server/server.ts
  37. 5 5
      packages/opencode/src/session/index.ts
  38. 4 4
      packages/opencode/src/session/llm.ts
  39. 1 1
      packages/opencode/src/session/message-v2.ts
  40. 4 4
      packages/opencode/src/session/processor.ts
  41. 7 7
      packages/opencode/src/session/prompt.ts
  42. 4 4
      packages/opencode/src/session/session.sql.ts
  43. 2 2
      packages/opencode/src/session/system.ts
  44. 1 1
      packages/opencode/src/share/share-next.ts
  45. 260 1
      packages/opencode/src/skill/index.ts
  46. 0 238
      packages/opencode/src/skill/service.ts
  47. 0 35
      packages/opencode/src/skill/skill.ts
  48. 369 17
      packages/opencode/src/snapshot/index.ts
  49. 0 320
      packages/opencode/src/snapshot/service.ts
  50. 1 1
      packages/opencode/src/tool/apply_patch.ts
  51. 2 2
      packages/opencode/src/tool/edit.ts
  52. 2 3
      packages/opencode/src/tool/question.ts
  53. 2 2
      packages/opencode/src/tool/task.ts
  54. 2 2
      packages/opencode/src/tool/tool.ts
  55. 0 137
      packages/opencode/src/tool/truncate-effect.ts
  56. 135 9
      packages/opencode/src/tool/truncate.ts
  57. 1 1
      packages/opencode/src/tool/write.ts
  58. 1 1
      packages/opencode/test/account/service.test.ts
  59. 21 17
      packages/opencode/test/agent/agent.test.ts
  60. 1 1
      packages/opencode/test/config/config.test.ts
  61. 384 0
      packages/opencode/test/effect/instance-state.test.ts
  62. 46 0
      packages/opencode/test/effect/run-service.test.ts
  63. 0 128
      packages/opencode/test/effect/runtime.test.ts
  64. 95 1
      packages/opencode/test/file/index.test.ts
  65. 25 1
      packages/opencode/test/file/time.test.ts
  66. 4 2
      packages/opencode/test/file/watcher.test.ts
  67. 108 1
      packages/opencode/test/format/format.test.ts
  68. 59 55
      packages/opencode/test/permission-task.test.ts
  69. 243 127
      packages/opencode/test/permission/next.test.ts
  70. 17 6
      packages/opencode/test/plugin/auth-override.test.ts
  71. 5 3
      packages/opencode/test/project/vcs.test.ts
  72. 131 0
      packages/opencode/test/question/question.test.ts
  73. 3 3
      packages/opencode/test/share/share-next.test.ts
  74. 5 1
      packages/opencode/test/skill/skill.test.ts
  75. 5 1
      packages/opencode/test/snapshot/snapshot.test.ts
  76. 21 21
      packages/opencode/test/tool/bash.test.ts
  77. 5 1
      packages/opencode/test/tool/edit.test.ts
  78. 6 6
      packages/opencode/test/tool/external-directory.test.ts
  79. 17 13
      packages/opencode/test/tool/read.test.ts
  80. 5 1
      packages/opencode/test/tool/registry.test.ts
  81. 7 3
      packages/opencode/test/tool/skill.test.ts
  82. 5 1
      packages/opencode/test/tool/task.test.ts
  83. 2 3
      packages/opencode/test/tool/truncation.test.ts
  84. 5 1
      packages/opencode/test/tool/write.test.ts

+ 0 - 2
packages/opencode/script/seed-e2e.ts

@@ -11,7 +11,6 @@ const seed = async () => {
   const { Instance } = await import("../src/project/instance")
   const { InstanceBootstrap } = await import("../src/project/bootstrap")
   const { Config } = await import("../src/config/config")
-  const { disposeRuntime } = await import("../src/effect/runtime")
   const { Session } = await import("../src/session")
   const { MessageID, PartID } = await import("../src/session/schema")
   const { Project } = await import("../src/project/project")
@@ -55,7 +54,6 @@ const seed = async () => {
     })
   } finally {
     await Instance.disposeAll().catch(() => {})
-    await disposeRuntime().catch(() => {})
   }
 }
 

+ 53 - 54
packages/opencode/specs/effect-migration.md

@@ -4,18 +4,18 @@ 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 `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
 
-Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
+Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
 
-- Shared runtime: config readers, stateless helpers, global clients
-- Instance-scoped: watchers, per-project caches, session state, project-bound background work
+- Global services (no per-directory state): Account, Auth, Installation, Truncate
+- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
 
-Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
+Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
 
 ## Service shape
 
-For a fully migrated module, use the public namespace directly:
+Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
 
 ```ts
 export namespace Foo {
@@ -28,53 +28,52 @@ export namespace Foo {
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
-      return Service.of({
-        get: Effect.fn("Foo.get")(function* (id) {
-          return yield* ...
-        }),
+      // For instance-scoped services:
+      const state = yield* InstanceState.make<State>(
+        Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
+      )
+
+      const get = Effect.fn("Foo.get")(function* (id: FooID) {
+        const s = yield* InstanceState.get(state)
+        // ...
       })
+
+      return Service.of({ get })
     }),
   )
 
-  export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
+  // Optional: wire dependencies
+  export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
+
+  // Per-service runtime (inside the namespace)
+  const runPromise = makeRunPromise(Service, defaultLayer)
+
+  // Async facade functions
+  export async function get(id: FooID) {
+    return runPromise((svc) => svc.get(id))
+  }
 }
 ```
 
 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
+- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
+- `runPromise` goes inside the namespace (not exported unless tests need it)
+- Facade functions are plain `async function` — no `fn()` wrappers
+- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
+- No `Layer.fresh` — InstanceState handles per-directory isolation
 
-Prefer a single namespace whenever possible.
+## Schema → Zod interop
 
-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.
+When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
 
 ```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") {}
+import { zod } from "@/util/effect-zod"
 
-  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)))
-  }
-}
+export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
 ```
 
-Remove the `Effect` suffix when the boundary split is gone.
+See `Auth.ZodInfo` for the canonical example.
 
 ## Scheduled Tasks
 
@@ -107,22 +106,23 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
 
 ## 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`
+Fully migrated (single namespace, InstanceState where needed, flattened facade):
+
+- [x] `Account` — `account/index.ts`
+- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
+- [x] `File` — `file/index.ts`
+- [x] `FileTime` — `file/time.ts`
+- [x] `FileWatcher` — `file/watcher.ts`
+- [x] `Format` — `format/index.ts`
+- [x] `Installation` — `installation/index.ts`
+- [x] `Permission` — `permission/index.ts`
+- [x] `ProviderAuth` — `provider/auth.ts`
+- [x] `Question` — `question/index.ts`
+- [x] `Skill` — `skill/index.ts`
+- [x] `Snapshot` — `snapshot/index.ts`
+- [x] `Truncate` — `tool/truncate.ts`
+- [x] `Vcs` — `project/vcs.ts`
+- [x] `Discovery` — `skill/discovery.ts`
 
 Still open and likely worth migrating:
 
@@ -130,7 +130,6 @@ Still open and likely worth migrating:
 - [ ] `ToolRegistry`
 - [ ] `Pty`
 - [ ] `Worktree`
-- [ ] `Installation`
 - [ ] `Bus`
 - [ ] `Command`
 - [ ] `Config`

+ 0 - 380
packages/opencode/src/account/effect.ts

@@ -1,380 +0,0 @@
-import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
-import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
-
-import { withTransientReadRetry } from "@/util/effect-http-client"
-import { AccountRepo, type AccountRow } from "./repo"
-import {
-  type AccountError,
-  AccessToken,
-  AccountID,
-  DeviceCode,
-  Info,
-  RefreshToken,
-  AccountServiceError,
-  Login,
-  Org,
-  OrgID,
-  PollDenied,
-  PollError,
-  PollExpired,
-  PollPending,
-  type PollResult,
-  PollSlow,
-  PollSuccess,
-  UserCode,
-} from "./schema"
-
-export {
-  AccountID,
-  type AccountError,
-  AccountRepoError,
-  AccountServiceError,
-  AccessToken,
-  RefreshToken,
-  DeviceCode,
-  UserCode,
-  Info,
-  Org,
-  OrgID,
-  Login,
-  PollSuccess,
-  PollPending,
-  PollSlow,
-  PollExpired,
-  PollDenied,
-  PollError,
-  PollResult,
-} from "./schema"
-
-export type AccountOrgs = {
-  account: Info
-  orgs: readonly Org[]
-}
-
-class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
-  config: Schema.Record(Schema.String, Schema.Json),
-}) {}
-
-const DurationFromSeconds = Schema.Number.pipe(
-  Schema.decodeTo(Schema.Duration, {
-    decode: SchemaGetter.transform((n) => Duration.seconds(n)),
-    encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
-  }),
-)
-
-class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
-  access_token: AccessToken,
-  refresh_token: RefreshToken,
-  expires_in: DurationFromSeconds,
-}) {}
-
-class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
-  device_code: DeviceCode,
-  user_code: UserCode,
-  verification_uri_complete: Schema.String,
-  expires_in: DurationFromSeconds,
-  interval: DurationFromSeconds,
-}) {}
-
-class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
-  access_token: AccessToken,
-  refresh_token: RefreshToken,
-  token_type: Schema.Literal("Bearer"),
-  expires_in: DurationFromSeconds,
-}) {}
-
-class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
-  error: Schema.String,
-  error_description: Schema.String,
-}) {
-  toPollResult(): PollResult {
-    if (this.error === "authorization_pending") return new PollPending()
-    if (this.error === "slow_down") return new PollSlow()
-    if (this.error === "expired_token") return new PollExpired()
-    if (this.error === "access_denied") return new PollDenied()
-    return new PollError({ cause: this.error })
-  }
-}
-
-const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
-
-class User extends Schema.Class<User>("User")({
-  id: AccountID,
-  email: Schema.String,
-}) {}
-
-class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
-
-class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
-  grant_type: Schema.String,
-  device_code: DeviceCode,
-  client_id: Schema.String,
-}) {}
-
-class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
-  grant_type: Schema.String,
-  refresh_token: RefreshToken,
-  client_id: Schema.String,
-}) {}
-
-const clientId = "opencode-cli"
-
-const mapAccountServiceError =
-  (message = "Account service operation failed") =>
-  <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
-    effect.pipe(
-      Effect.mapError((cause) =>
-        cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
-      ),
-    )
-
-export namespace Account {
-  export interface Interface {
-    readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
-    readonly list: () => Effect.Effect<Info[], AccountError>
-    readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
-    readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
-    readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
-    readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
-    readonly config: (
-      accountID: AccountID,
-      orgID: OrgID,
-    ) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
-    readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
-    readonly login: (url: string) => Effect.Effect<Login, AccountError>
-    readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
-  }
-
-  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
-      const httpRead = withTransientReadRetry(http)
-      const httpOk = HttpClient.filterStatusOk(http)
-      const httpReadOk = HttpClient.filterStatusOk(httpRead)
-
-      const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
-        httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
-
-      const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
-        httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
-
-      const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
-        request.pipe(
-          Effect.flatMap((req) => httpOk.execute(req)),
-          mapAccountServiceError("HTTP request failed"),
-        )
-
-      const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
-        request.pipe(
-          Effect.flatMap((req) => http.execute(req)),
-          mapAccountServiceError("HTTP request failed"),
-        )
-
-      const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
-        const now = yield* Clock.currentTimeMillis
-        if (row.token_expiry && row.token_expiry > now) return row.access_token
-
-        const response = yield* executeEffectOk(
-          HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
-            HttpClientRequest.acceptJson,
-            HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
-              new TokenRefreshRequest({
-                grant_type: "refresh_token",
-                refresh_token: row.refresh_token,
-                client_id: clientId,
-              }),
-            ),
-          ),
-        )
-
-        const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
-          mapAccountServiceError("Failed to decode response"),
-        )
-
-        const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
-
-        yield* repo.persistToken({
-          accountID: row.id,
-          accessToken: parsed.access_token,
-          refreshToken: parsed.refresh_token,
-          expiry,
-        })
-
-        return parsed.access_token
-      })
-
-      const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
-        const maybeAccount = yield* repo.getRow(accountID)
-        if (Option.isNone(maybeAccount)) return Option.none()
-
-        const account = maybeAccount.value
-        const accessToken = yield* resolveToken(account)
-        return Option.some({ account, accessToken })
-      })
-
-      const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
-        const response = yield* executeReadOk(
-          HttpClientRequest.get(`${url}/api/orgs`).pipe(
-            HttpClientRequest.acceptJson,
-            HttpClientRequest.bearerToken(accessToken),
-          ),
-        )
-
-        return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
-          mapAccountServiceError("Failed to decode response"),
-        )
-      })
-
-      const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
-        const response = yield* executeReadOk(
-          HttpClientRequest.get(`${url}/api/user`).pipe(
-            HttpClientRequest.acceptJson,
-            HttpClientRequest.bearerToken(accessToken),
-          ),
-        )
-
-        return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
-          mapAccountServiceError("Failed to decode response"),
-        )
-      })
-
-      const token = Effect.fn("Account.token")((accountID: AccountID) =>
-        resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
-      )
-
-      const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
-        const accounts = yield* repo.list()
-        const [errors, results] = yield* Effect.partition(
-          accounts,
-          (account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
-          { concurrency: 3 },
-        )
-        for (const error of errors) {
-          yield* Effect.logWarning("failed to fetch orgs for account").pipe(
-            Effect.annotateLogs({ error: String(error) }),
-          )
-        }
-        return results
-      })
-
-      const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
-        const resolved = yield* resolveAccess(accountID)
-        if (Option.isNone(resolved)) return []
-
-        const { account, accessToken } = resolved.value
-
-        return yield* fetchOrgs(account.url, accessToken)
-      })
-
-      const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
-        const resolved = yield* resolveAccess(accountID)
-        if (Option.isNone(resolved)) return Option.none()
-
-        const { account, accessToken } = resolved.value
-
-        const response = yield* executeRead(
-          HttpClientRequest.get(`${account.url}/api/config`).pipe(
-            HttpClientRequest.acceptJson,
-            HttpClientRequest.bearerToken(accessToken),
-            HttpClientRequest.setHeaders({ "x-org-id": orgID }),
-          ),
-        )
-
-        if (response.status === 404) return Option.none()
-
-        const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
-
-        const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
-          mapAccountServiceError("Failed to decode response"),
-        )
-        return Option.some(parsed.config)
-      })
-
-      const login = Effect.fn("Account.login")(function* (server: string) {
-        const response = yield* executeEffectOk(
-          HttpClientRequest.post(`${server}/auth/device/code`).pipe(
-            HttpClientRequest.acceptJson,
-            HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
-          ),
-        )
-
-        const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
-          mapAccountServiceError("Failed to decode response"),
-        )
-        return new Login({
-          code: parsed.device_code,
-          user: parsed.user_code,
-          url: `${server}${parsed.verification_uri_complete}`,
-          server,
-          expiry: parsed.expires_in,
-          interval: parsed.interval,
-        })
-      })
-
-      const poll = Effect.fn("Account.poll")(function* (input: Login) {
-        const response = yield* executeEffect(
-          HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
-            HttpClientRequest.acceptJson,
-            HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
-              new DeviceTokenRequest({
-                grant_type: "urn:ietf:params:oauth:grant-type:device_code",
-                device_code: input.code,
-                client_id: clientId,
-              }),
-            ),
-          ),
-        )
-
-        const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
-          mapAccountServiceError("Failed to decode response"),
-        )
-
-        if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
-        const accessToken = parsed.access_token
-
-        const user = fetchUser(input.server, accessToken)
-        const orgs = fetchOrgs(input.server, accessToken)
-
-        const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
-
-        // TODO: When there are multiple orgs, let the user choose
-        const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
-
-        const now = yield* Clock.currentTimeMillis
-        const expiry = now + Duration.toMillis(parsed.expires_in)
-        const refreshToken = parsed.refresh_token
-
-        yield* repo.persistAccount({
-          id: account.id,
-          email: account.email,
-          url: input.server,
-          accessToken,
-          refreshToken,
-          expiry,
-          orgID: firstOrgID,
-        })
-
-        return new PollSuccess({ email: account.email })
-      })
-
-      return Service.of({
-        active: repo.active,
-        list: repo.list,
-        orgsByAccount,
-        remove: repo.remove,
-        use: repo.use,
-        orgs,
-        config,
-        token,
-        login,
-        poll,
-      })
-    }),
-  )
-
-  export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
-}

+ 380 - 17
packages/opencode/src/account/index.ts

@@ -1,34 +1,397 @@
-import { Effect, Option } from "effect"
+import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
+import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
 
-import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
+import { makeRunPromise } from "@/effect/run-service"
+import { withTransientReadRetry } from "@/util/effect-http-client"
+import { AccountRepo, type AccountRow } from "./repo"
+import {
+  type AccountError,
+  AccessToken,
+  AccountID,
+  DeviceCode,
+  Info,
+  RefreshToken,
+  AccountServiceError,
+  Login,
+  Org,
+  OrgID,
+  PollDenied,
+  PollError,
+  PollExpired,
+  PollPending,
+  type PollResult,
+  PollSlow,
+  PollSuccess,
+  UserCode,
+} from "./schema"
 
-export { AccessToken, AccountID, OrgID } from "./effect"
+export {
+  AccountID,
+  type AccountError,
+  AccountRepoError,
+  AccountServiceError,
+  AccessToken,
+  RefreshToken,
+  DeviceCode,
+  UserCode,
+  Info,
+  Org,
+  OrgID,
+  Login,
+  PollSuccess,
+  PollPending,
+  PollSlow,
+  PollExpired,
+  PollDenied,
+  PollError,
+  PollResult,
+} from "./schema"
 
-import { runtime } from "@/effect/runtime"
-
-function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
-  return runtime.runSync(S.Service.use(f))
+export type AccountOrgs = {
+  account: Info
+  orgs: readonly Org[]
 }
 
-function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
-  return runtime.runPromise(S.Service.use(f))
+class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
+  config: Schema.Record(Schema.String, Schema.Json),
+}) {}
+
+const DurationFromSeconds = Schema.Number.pipe(
+  Schema.decodeTo(Schema.Duration, {
+    decode: SchemaGetter.transform((n) => Duration.seconds(n)),
+    encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
+  }),
+)
+
+class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
+  access_token: AccessToken,
+  refresh_token: RefreshToken,
+  expires_in: DurationFromSeconds,
+}) {}
+
+class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
+  device_code: DeviceCode,
+  user_code: UserCode,
+  verification_uri_complete: Schema.String,
+  expires_in: DurationFromSeconds,
+  interval: DurationFromSeconds,
+}) {}
+
+class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
+  access_token: AccessToken,
+  refresh_token: RefreshToken,
+  token_type: Schema.Literal("Bearer"),
+  expires_in: DurationFromSeconds,
+}) {}
+
+class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
+  error: Schema.String,
+  error_description: Schema.String,
+}) {
+  toPollResult(): PollResult {
+    if (this.error === "authorization_pending") return new PollPending()
+    if (this.error === "slow_down") return new PollSlow()
+    if (this.error === "expired_token") return new PollExpired()
+    if (this.error === "access_denied") return new PollDenied()
+    return new PollError({ cause: this.error })
+  }
 }
 
+const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
+
+class User extends Schema.Class<User>("User")({
+  id: AccountID,
+  email: Schema.String,
+}) {}
+
+class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
+
+class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
+  grant_type: Schema.String,
+  device_code: DeviceCode,
+  client_id: Schema.String,
+}) {}
+
+class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
+  grant_type: Schema.String,
+  refresh_token: RefreshToken,
+  client_id: Schema.String,
+}) {}
+
+const clientId = "opencode-cli"
+
+const mapAccountServiceError =
+  (message = "Account service operation failed") =>
+  <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
+    effect.pipe(
+      Effect.mapError((cause) =>
+        cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
+      ),
+    )
+
 export namespace Account {
-  export const Info = Model
-  export type Info = Model
+  export interface Interface {
+    readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
+    readonly list: () => Effect.Effect<Info[], AccountError>
+    readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
+    readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
+    readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
+    readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
+    readonly config: (
+      accountID: AccountID,
+      orgID: OrgID,
+    ) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
+    readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
+    readonly login: (url: string) => Effect.Effect<Login, AccountError>
+    readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
+  }
+
+  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
+      const httpRead = withTransientReadRetry(http)
+      const httpOk = HttpClient.filterStatusOk(http)
+      const httpReadOk = HttpClient.filterStatusOk(httpRead)
+
+      const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
+        httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
+
+      const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
+        httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
+
+      const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
+        request.pipe(
+          Effect.flatMap((req) => httpOk.execute(req)),
+          mapAccountServiceError("HTTP request failed"),
+        )
+
+      const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
+        request.pipe(
+          Effect.flatMap((req) => http.execute(req)),
+          mapAccountServiceError("HTTP request failed"),
+        )
+
+      const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
+        const now = yield* Clock.currentTimeMillis
+        if (row.token_expiry && row.token_expiry > now) return row.access_token
+
+        const response = yield* executeEffectOk(
+          HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
+              new TokenRefreshRequest({
+                grant_type: "refresh_token",
+                refresh_token: row.refresh_token,
+                client_id: clientId,
+              }),
+            ),
+          ),
+        )
+
+        const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
+          mapAccountServiceError("Failed to decode response"),
+        )
+
+        const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
+
+        yield* repo.persistToken({
+          accountID: row.id,
+          accessToken: parsed.access_token,
+          refreshToken: parsed.refresh_token,
+          expiry,
+        })
+
+        return parsed.access_token
+      })
+
+      const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
+        const maybeAccount = yield* repo.getRow(accountID)
+        if (Option.isNone(maybeAccount)) return Option.none()
+
+        const account = maybeAccount.value
+        const accessToken = yield* resolveToken(account)
+        return Option.some({ account, accessToken })
+      })
+
+      const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
+        const response = yield* executeReadOk(
+          HttpClientRequest.get(`${url}/api/orgs`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.bearerToken(accessToken),
+          ),
+        )
+
+        return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
+          mapAccountServiceError("Failed to decode response"),
+        )
+      })
+
+      const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
+        const response = yield* executeReadOk(
+          HttpClientRequest.get(`${url}/api/user`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.bearerToken(accessToken),
+          ),
+        )
+
+        return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
+          mapAccountServiceError("Failed to decode response"),
+        )
+      })
+
+      const token = Effect.fn("Account.token")((accountID: AccountID) =>
+        resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
+      )
+
+      const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
+        const accounts = yield* repo.list()
+        const [errors, results] = yield* Effect.partition(
+          accounts,
+          (account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
+          { concurrency: 3 },
+        )
+        for (const error of errors) {
+          yield* Effect.logWarning("failed to fetch orgs for account").pipe(
+            Effect.annotateLogs({ error: String(error) }),
+          )
+        }
+        return results
+      })
+
+      const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
+        const resolved = yield* resolveAccess(accountID)
+        if (Option.isNone(resolved)) return []
+
+        const { account, accessToken } = resolved.value
+
+        return yield* fetchOrgs(account.url, accessToken)
+      })
+
+      const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
+        const resolved = yield* resolveAccess(accountID)
+        if (Option.isNone(resolved)) return Option.none()
+
+        const { account, accessToken } = resolved.value
+
+        const response = yield* executeRead(
+          HttpClientRequest.get(`${account.url}/api/config`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.bearerToken(accessToken),
+            HttpClientRequest.setHeaders({ "x-org-id": orgID }),
+          ),
+        )
+
+        if (response.status === 404) return Option.none()
+
+        const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
+
+        const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
+          mapAccountServiceError("Failed to decode response"),
+        )
+        return Option.some(parsed.config)
+      })
+
+      const login = Effect.fn("Account.login")(function* (server: string) {
+        const response = yield* executeEffectOk(
+          HttpClientRequest.post(`${server}/auth/device/code`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
+          ),
+        )
+
+        const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
+          mapAccountServiceError("Failed to decode response"),
+        )
+        return new Login({
+          code: parsed.device_code,
+          user: parsed.user_code,
+          url: `${server}${parsed.verification_uri_complete}`,
+          server,
+          expiry: parsed.expires_in,
+          interval: parsed.interval,
+        })
+      })
+
+      const poll = Effect.fn("Account.poll")(function* (input: Login) {
+        const response = yield* executeEffect(
+          HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
+              new DeviceTokenRequest({
+                grant_type: "urn:ietf:params:oauth:grant-type:device_code",
+                device_code: input.code,
+                client_id: clientId,
+              }),
+            ),
+          ),
+        )
+
+        const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
+          mapAccountServiceError("Failed to decode response"),
+        )
+
+        if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
+        const accessToken = parsed.access_token
+
+        const user = fetchUser(input.server, accessToken)
+        const orgs = fetchOrgs(input.server, accessToken)
+
+        const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
+
+        // TODO: When there are multiple orgs, let the user choose
+        const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
+
+        const now = yield* Clock.currentTimeMillis
+        const expiry = now + Duration.toMillis(parsed.expires_in)
+        const refreshToken = parsed.refresh_token
+
+        yield* repo.persistAccount({
+          id: account.id,
+          email: account.email,
+          url: input.server,
+          accessToken,
+          refreshToken,
+          expiry,
+          orgID: firstOrgID,
+        })
+
+        return new PollSuccess({ email: account.email })
+      })
+
+      return Service.of({
+        active: repo.active,
+        list: repo.list,
+        orgsByAccount,
+        remove: repo.remove,
+        use: repo.use,
+        orgs,
+        config,
+        token,
+        login,
+        poll,
+      })
+    }),
+  )
+
+  export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
+
+  export const runPromise = makeRunPromise(Service, defaultLayer)
 
-  export function active(): Info | undefined {
-    return Option.getOrUndefined(runSync((service) => service.active()))
+  export async function active(): Promise<Info | undefined> {
+    return Option.getOrUndefined(await runPromise((service) => service.active()))
   }
 
   export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
-    const config = await runPromise((service) => service.config(accountID, orgID))
-    return Option.getOrUndefined(config)
+    const cfg = await runPromise((service) => service.config(accountID, orgID))
+    return Option.getOrUndefined(cfg)
   }
 
   export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
-    const token = await runPromise((service) => service.token(accountID))
-    return Option.getOrUndefined(token)
+    const t = await runPromise((service) => service.token(accountID))
+    return Option.getOrUndefined(t)
   }
 }

+ 22 - 22
packages/opencode/src/agent/agent.ts

@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
 import PROMPT_EXPLORE from "./prompt/explore.txt"
 import PROMPT_SUMMARY from "./prompt/summary.txt"
 import PROMPT_TITLE from "./prompt/title.txt"
-import { Permission as PermissionNext } from "@/permission/service"
+import { Permission } from "@/permission"
 import { mergeDeep, pipe, sortBy, values } from "remeda"
 import { Global } from "@/global"
 import path from "path"
@@ -32,7 +32,7 @@ export namespace Agent {
       topP: z.number().optional(),
       temperature: z.number().optional(),
       color: z.string().optional(),
-      permission: PermissionNext.Ruleset,
+      permission: Permission.Ruleset,
       model: z
         .object({
           modelID: ModelID.zod,
@@ -54,7 +54,7 @@ export namespace Agent {
 
     const skillDirs = await Skill.dirs()
     const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
-    const defaults = PermissionNext.fromConfig({
+    const defaults = Permission.fromConfig({
       "*": "allow",
       doom_loop: "ask",
       external_directory: {
@@ -72,16 +72,16 @@ export namespace Agent {
         "*.env.example": "allow",
       },
     })
-    const user = PermissionNext.fromConfig(cfg.permission ?? {})
+    const user = Permission.fromConfig(cfg.permission ?? {})
 
     const result: Record<string, Info> = {
       build: {
         name: "build",
         description: "The default agent. Executes tools based on configured permissions.",
         options: {},
-        permission: PermissionNext.merge(
+        permission: Permission.merge(
           defaults,
-          PermissionNext.fromConfig({
+          Permission.fromConfig({
             question: "allow",
             plan_enter: "allow",
           }),
@@ -94,9 +94,9 @@ export namespace Agent {
         name: "plan",
         description: "Plan mode. Disallows all edit tools.",
         options: {},
-        permission: PermissionNext.merge(
+        permission: Permission.merge(
           defaults,
-          PermissionNext.fromConfig({
+          Permission.fromConfig({
             question: "allow",
             plan_exit: "allow",
             external_directory: {
@@ -116,9 +116,9 @@ export namespace Agent {
       general: {
         name: "general",
         description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
-        permission: PermissionNext.merge(
+        permission: Permission.merge(
           defaults,
-          PermissionNext.fromConfig({
+          Permission.fromConfig({
             todoread: "deny",
             todowrite: "deny",
           }),
@@ -130,9 +130,9 @@ export namespace Agent {
       },
       explore: {
         name: "explore",
-        permission: PermissionNext.merge(
+        permission: Permission.merge(
           defaults,
-          PermissionNext.fromConfig({
+          Permission.fromConfig({
             "*": "deny",
             grep: "allow",
             glob: "allow",
@@ -161,9 +161,9 @@ export namespace Agent {
         native: true,
         hidden: true,
         prompt: PROMPT_COMPACTION,
-        permission: PermissionNext.merge(
+        permission: Permission.merge(
           defaults,
-          PermissionNext.fromConfig({
+          Permission.fromConfig({
             "*": "deny",
           }),
           user,
@@ -177,9 +177,9 @@ export namespace Agent {
         native: true,
         hidden: true,
         temperature: 0.5,
-        permission: PermissionNext.merge(
+        permission: Permission.merge(
           defaults,
-          PermissionNext.fromConfig({
+          Permission.fromConfig({
             "*": "deny",
           }),
           user,
@@ -192,9 +192,9 @@ export namespace Agent {
         options: {},
         native: true,
         hidden: true,
-        permission: PermissionNext.merge(
+        permission: Permission.merge(
           defaults,
-          PermissionNext.fromConfig({
+          Permission.fromConfig({
             "*": "deny",
           }),
           user,
@@ -213,7 +213,7 @@ export namespace Agent {
         item = result[key] = {
           name: key,
           mode: "all",
-          permission: PermissionNext.merge(defaults, user),
+          permission: Permission.merge(defaults, user),
           options: {},
           native: false,
         }
@@ -229,7 +229,7 @@ export namespace Agent {
       item.name = value.name ?? item.name
       item.steps = value.steps ?? item.steps
       item.options = mergeDeep(item.options, value.options ?? {})
-      item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
+      item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
     }
 
     // Ensure Truncate.GLOB is allowed unless explicitly configured
@@ -242,9 +242,9 @@ export namespace Agent {
       })
       if (explicit) continue
 
-      result[name].permission = PermissionNext.merge(
+      result[name].permission = Permission.merge(
         result[name].permission,
-        PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
+        Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
       )
     }
 

+ 0 - 94
packages/opencode/src/auth/effect.ts

@@ -1,94 +0,0 @@
-import path from "path"
-import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
-import { Global } from "../global"
-import { Filesystem } from "../util/filesystem"
-
-export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
-
-export class Oauth extends Schema.Class<Oauth>("OAuth")({
-  type: Schema.Literal("oauth"),
-  refresh: Schema.String,
-  access: Schema.String,
-  expires: Schema.Number,
-  accountId: Schema.optional(Schema.String),
-  enterpriseUrl: Schema.optional(Schema.String),
-}) {}
-
-export class Api extends Schema.Class<Api>("ApiAuth")({
-  type: Schema.Literal("api"),
-  key: Schema.String,
-}) {}
-
-export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
-  type: Schema.Literal("wellknown"),
-  key: Schema.String,
-  token: Schema.String,
-}) {}
-
-export const Info = Schema.Union([Oauth, Api, WellKnown])
-export type Info = Schema.Schema.Type<typeof Info>
-
-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 AuthError({ message, cause })
-
-export namespace Auth {
-  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 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("Auth.all")(() =>
-        Effect.tryPromise({
-          try: async () => {
-            const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
-            return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
-          },
-          catch: fail("Failed to read auth data"),
-        }),
-      )
-
-      const get = Effect.fn("Auth.get")(function* (providerID: string) {
-        return (yield* all())[providerID]
-      })
-
-      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]
-        delete data[norm + "/"]
-        yield* Effect.tryPromise({
-          try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
-          catch: fail("Failed to write auth data"),
-        })
-      })
-
-      const remove = Effect.fn("Auth.remove")(function* (key: string) {
-        const norm = key.replace(/\/+$/, "")
-        const data = yield* all()
-        delete data[key]
-        delete data[norm]
-        yield* Effect.tryPromise({
-          try: () => Filesystem.writeJson(file, data, 0o600),
-          catch: fail("Failed to write auth data"),
-        })
-      })
-
-      return Service.of({ get, all, set, remove })
-    }),
-  )
-}

+ 94 - 36
packages/opencode/src/auth/index.ts

@@ -1,43 +1,101 @@
-import { Effect } from "effect"
-import z from "zod"
-import { runtime } from "@/effect/runtime"
-import * as S from "./effect"
+import path from "path"
+import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
+import { makeRunPromise } from "@/effect/run-service"
+import { zod } from "@/util/effect-zod"
+import { Global } from "../global"
+import { Filesystem } from "../util/filesystem"
 
-export { OAUTH_DUMMY_KEY } from "./effect"
+export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
 
-function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
-  return runtime.runPromise(S.Auth.Service.use(f))
-}
+const file = path.join(Global.Path.data, "auth.json")
+
+const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause })
 
 export namespace Auth {
-  export const Oauth = z
-    .object({
-      type: z.literal("oauth"),
-      refresh: z.string(),
-      access: z.string(),
-      expires: z.number(),
-      accountId: z.string().optional(),
-      enterpriseUrl: z.string().optional(),
-    })
-    .meta({ ref: "OAuth" })
-
-  export const Api = z
-    .object({
-      type: z.literal("api"),
-      key: z.string(),
-    })
-    .meta({ ref: "ApiAuth" })
-
-  export const WellKnown = z
-    .object({
-      type: z.literal("wellknown"),
-      key: z.string(),
-      token: z.string(),
-    })
-    .meta({ ref: "WellKnownAuth" })
-
-  export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
-  export type Info = z.infer<typeof Info>
+  export class Oauth extends Schema.Class<Oauth>("OAuth")({
+    type: Schema.Literal("oauth"),
+    refresh: Schema.String,
+    access: Schema.String,
+    expires: Schema.Number,
+    accountId: Schema.optional(Schema.String),
+    enterpriseUrl: Schema.optional(Schema.String),
+  }) {}
+
+  export class Api extends Schema.Class<Api>("ApiAuth")({
+    type: Schema.Literal("api"),
+    key: Schema.String,
+  }) {}
+
+  export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
+    type: Schema.Literal("wellknown"),
+    key: Schema.String,
+    token: Schema.String,
+  }) {}
+
+  const _Info = Schema.Union([Oauth, Api, WellKnown])
+  export const Info = Object.assign(_Info, { zod: zod(_Info) })
+  export type Info = Schema.Schema.Type<typeof _Info>
+
+  export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
+    message: Schema.String,
+    cause: Schema.optional(Schema.Defect),
+  }) {}
+
+  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 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("Auth.all")(() =>
+        Effect.tryPromise({
+          try: async () => {
+            const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
+            return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
+          },
+          catch: fail("Failed to read auth data"),
+        }),
+      )
+
+      const get = Effect.fn("Auth.get")(function* (providerID: string) {
+        return (yield* all())[providerID]
+      })
+
+      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]
+        delete data[norm + "/"]
+        yield* Effect.tryPromise({
+          try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
+          catch: fail("Failed to write auth data"),
+        })
+      })
+
+      const remove = Effect.fn("Auth.remove")(function* (key: string) {
+        const norm = key.replace(/\/+$/, "")
+        const data = yield* all()
+        delete data[key]
+        delete data[norm]
+        yield* Effect.tryPromise({
+          try: () => Filesystem.writeJson(file, data, 0o600),
+          catch: fail("Failed to write auth data"),
+        })
+      })
+
+      return Service.of({ get, all, set, remove })
+    }),
+  )
+
+  const runPromise = makeRunPromise(Service, layer)
 
   export async function get(providerID: string) {
     return runPromise((service) => service.get(providerID))

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

@@ -1,8 +1,7 @@
 import { cmd } from "./cmd"
 import { Duration, Effect, Match, Option } from "effect"
 import { UI } from "../ui"
-import { runtime } from "@/effect/runtime"
-import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
+import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
 import { type AccountError } from "@/account/schema"
 import * as Prompt from "../effect/prompt"
 import open from "open"
@@ -160,7 +159,7 @@ export const LoginCommand = cmd({
     }),
   async handler(args) {
     UI.empty()
-    await runtime.runPromise(loginEffect(args.url))
+    await Account.runPromise((_svc) => loginEffect(args.url))
   },
 })
 
@@ -174,7 +173,7 @@ export const LogoutCommand = cmd({
     }),
   async handler(args) {
     UI.empty()
-    await runtime.runPromise(logoutEffect(args.email))
+    await Account.runPromise((_svc) => logoutEffect(args.email))
   },
 })
 
@@ -183,7 +182,7 @@ export const SwitchCommand = cmd({
   describe: false,
   async handler() {
     UI.empty()
-    await runtime.runPromise(switchEffect())
+    await Account.runPromise((_svc) => switchEffect())
   },
 })
 
@@ -192,7 +191,7 @@ export const OrgsCommand = cmd({
   describe: false,
   async handler() {
     UI.empty()
-    await runtime.runPromise(orgsEffect())
+    await Account.runPromise((_svc) => orgsEffect())
   },
 })
 

+ 6 - 6
packages/opencode/src/cli/cmd/debug/agent.ts

@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
 import { MessageID, PartID } from "../../../session/schema"
 import { ToolRegistry } from "../../../tool/registry"
 import { Instance } from "../../../project/instance"
-import { PermissionNext } from "../../../permission"
+import { Permission } from "../../../permission"
 import { iife } from "../../../util/iife"
 import { bootstrap } from "../../bootstrap"
 import { cmd } from "../cmd"
@@ -75,7 +75,7 @@ async function getAvailableTools(agent: Agent.Info) {
 }
 
 async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
-  const disabled = PermissionNext.disabled(
+  const disabled = Permission.disabled(
     availableTools.map((tool) => tool.id),
     agent.permission,
   )
@@ -145,7 +145,7 @@ async function createToolContext(agent: Agent.Info) {
   }
   await Session.updateMessage(message)
 
-  const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
+  const ruleset = Permission.merge(agent.permission, session.permission ?? [])
 
   return {
     sessionID: session.id,
@@ -155,11 +155,11 @@ async function createToolContext(agent: Agent.Info) {
     abort: new AbortController().signal,
     messages: [],
     metadata: () => {},
-    async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
+    async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
       for (const pattern of req.patterns) {
-        const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
+        const rule = Permission.evaluate(req.permission, pattern, ruleset)
         if (rule.action === "deny") {
-          throw new PermissionNext.DeniedError({ ruleset })
+          throw new Permission.DeniedError({ ruleset })
         }
       }
     },

+ 2 - 2
packages/opencode/src/cli/cmd/run.ts

@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
 import { Server } from "../../server/server"
 import { Provider } from "../../provider/provider"
 import { Agent } from "../../agent/agent"
-import { PermissionNext } from "../../permission"
+import { Permission } from "../../permission"
 import { Tool } from "../../tool/tool"
 import { GlobTool } from "../../tool/glob"
 import { GrepTool } from "../../tool/grep"
@@ -354,7 +354,7 @@ export const RunCommand = cmd({
       process.exit(1)
     }
 
-    const rules: PermissionNext.Ruleset = [
+    const rules: Permission.Ruleset = [
       {
         permission: "question",
         action: "deny",

+ 1 - 1
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -22,7 +22,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
 import { useSDK } from "@tui/context/sdk"
 import { Binary } from "@opencode-ai/util/binary"
 import { createSimpleContext } from "./helper"
-import type { Snapshot } from "@/snapshot/service"
+import type { Snapshot } from "@/snapshot"
 import { useExit } from "./exit"
 import { useArgs } from "./args"
 import { batch, onMount } from "solid-js"

+ 1 - 1
packages/opencode/src/config/config.ts

@@ -177,7 +177,7 @@ export namespace Config {
       log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
     }
 
-    const active = Account.active()
+    const active = await Account.active()
     if (active?.active_org_id) {
       try {
         const [config, token] = await Promise.all([

+ 47 - 0
packages/opencode/src/effect/instance-state.ts

@@ -0,0 +1,47 @@
+import { Effect, ScopedCache, Scope } from "effect"
+import { Instance, type Shape } from "@/project/instance"
+import { registerDisposer } from "./instance-registry"
+
+const TypeId = "~opencode/InstanceState"
+
+export interface InstanceState<A, E = never, R = never> {
+  readonly [TypeId]: typeof TypeId
+  readonly cache: ScopedCache.ScopedCache<string, A, E, R>
+}
+
+export namespace InstanceState {
+  export const make = <A, E = never, R = never>(
+    init: (ctx: Shape) => Effect.Effect<A, E, R | Scope.Scope>,
+  ): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
+    Effect.gen(function* () {
+      const cache = yield* ScopedCache.make<string, A, E, R>({
+        capacity: Number.POSITIVE_INFINITY,
+        lookup: () => init(Instance.current),
+      })
+
+      const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
+      yield* Effect.addFinalizer(() => Effect.sync(off))
+
+      return {
+        [TypeId]: TypeId,
+        cache,
+      }
+    })
+
+  export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
+    Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
+
+  export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
+    Effect.map(get(self), select)
+
+  export const useEffect = <A, E, R, B, E2, R2>(
+    self: InstanceState<A, E, R>,
+    select: (value: A) => Effect.Effect<B, E2, R2>,
+  ) => Effect.flatMap(get(self), select)
+
+  export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
+    Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
+
+  export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
+    Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
+}

+ 0 - 68
packages/opencode/src/effect/instances.ts

@@ -1,68 +0,0 @@
-import { Effect, Layer, LayerMap, ServiceMap } from "effect"
-import { File } from "@/file/service"
-import { FileTime } from "@/file/time-service"
-import { FileWatcher } from "@/file/watcher"
-import { Format } from "@/format/service"
-import { Permission } from "@/permission/service"
-import { Instance } from "@/project/instance"
-import { Vcs } from "@/project/vcs"
-import { ProviderAuth } from "@/provider/auth-service"
-import { Question } from "@/question/service"
-import { Skill } from "@/skill/service"
-import { Snapshot } from "@/snapshot/service"
-import { InstanceContext } from "./instance-context"
-import { registerDisposer } from "./instance-registry"
-
-export { InstanceContext } from "./instance-context"
-
-export type InstanceServices =
-  | Question.Service
-  | Permission.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
-// legacy Instance ALS here, which is safe because lookup is only triggered via
-// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
-// This should go away once the old Instance type is removed and lookup can load
-// the full context directly.
-function lookup(_key: string) {
-  const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
-  return Layer.mergeAll(
-    Question.layer,
-    Permission.layer,
-    ProviderAuth.defaultLayer,
-    FileWatcher.layer,
-    Vcs.layer,
-    FileTime.layer,
-    Format.layer,
-    File.layer,
-    Skill.defaultLayer,
-    Snapshot.defaultLayer,
-  ).pipe(Layer.provide(ctx))
-}
-
-export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()(
-  "opencode/Instances",
-) {
-  static readonly layer = Layer.effect(
-    Instances,
-    Effect.gen(function* () {
-      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)
-    }),
-  )
-
-  static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
-    return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
-  }
-}

+ 13 - 0
packages/opencode/src/effect/run-service.ts

@@ -0,0 +1,13 @@
+import { Effect, Layer, ManagedRuntime } from "effect"
+import * as ServiceMap from "effect/ServiceMap"
+
+export const memoMap = Layer.makeMemoMapUnsafe()
+
+export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
+  let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
+
+  return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => {
+    rt ??= ManagedRuntime.make(layer, { memoMap })
+    return rt.runPromise(service.use(fn), options)
+  }
+}

+ 0 - 25
packages/opencode/src/effect/runtime.ts

@@ -1,25 +0,0 @@
-import { Effect, Layer, ManagedRuntime } from "effect"
-import { Account } from "@/account/effect"
-import { Auth } from "@/auth/effect"
-import { Instances } from "@/effect/instances"
-import type { InstanceServices } from "@/effect/instances"
-import { Installation } from "@/installation"
-import { Truncate } from "@/tool/truncate-effect"
-import { Instance } from "@/project/instance"
-
-export const runtime = ManagedRuntime.make(
-  Layer.mergeAll(
-    Account.defaultLayer, //
-    Installation.defaultLayer,
-    Truncate.defaultLayer,
-    Instances.layer,
-  ).pipe(Layer.provideMerge(Auth.layer)),
-)
-
-export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
-  return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
-}
-
-export function disposeRuntime() {
-  return runtime.dispose()
-}

+ 689 - 17
packages/opencode/src/file/index.ts

@@ -1,40 +1,712 @@
-import { runPromiseInstance } from "@/effect/runtime"
-import { File as S } from "./service"
+import { BusEvent } from "@/bus/bus-event"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import { git } from "@/util/git"
+import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
+import { formatPatch, structuredPatch } from "diff"
+import fs from "fs"
+import fuzzysort from "fuzzysort"
+import ignore from "ignore"
+import path from "path"
+import z from "zod"
+import { Global } from "../global"
+import { Instance } from "../project/instance"
+import { Filesystem } from "../util/filesystem"
+import { Log } from "../util/log"
+import { Protected } from "./protected"
+import { Ripgrep } from "./ripgrep"
 
 export namespace File {
-  export const Info = S.Info
-  export type Info = S.Info
+  export const Info = z
+    .object({
+      path: z.string(),
+      added: z.number().int(),
+      removed: z.number().int(),
+      status: z.enum(["added", "deleted", "modified"]),
+    })
+    .meta({
+      ref: "File",
+    })
 
-  export const Node = S.Node
-  export type Node = S.Node
+  export type Info = z.infer<typeof Info>
 
-  export const Content = S.Content
-  export type Content = S.Content
+  export const Node = z
+    .object({
+      name: z.string(),
+      path: z.string(),
+      absolute: z.string(),
+      type: z.enum(["file", "directory"]),
+      ignored: z.boolean(),
+    })
+    .meta({
+      ref: "FileNode",
+    })
+  export type Node = z.infer<typeof Node>
 
-  export const Event = S.Event
+  export const Content = z
+    .object({
+      type: z.enum(["text", "binary"]),
+      content: z.string(),
+      diff: z.string().optional(),
+      patch: z
+        .object({
+          oldFileName: z.string(),
+          newFileName: z.string(),
+          oldHeader: z.string().optional(),
+          newHeader: z.string().optional(),
+          hunks: z.array(
+            z.object({
+              oldStart: z.number(),
+              oldLines: z.number(),
+              newStart: z.number(),
+              newLines: z.number(),
+              lines: z.array(z.string()),
+            }),
+          ),
+          index: z.string().optional(),
+        })
+        .optional(),
+      encoding: z.literal("base64").optional(),
+      mimeType: z.string().optional(),
+    })
+    .meta({
+      ref: "FileContent",
+    })
+  export type Content = z.infer<typeof Content>
 
-  export type Interface = S.Interface
+  export const Event = {
+    Edited: BusEvent.define(
+      "file.edited",
+      z.object({
+        file: z.string(),
+      }),
+    ),
+  }
+
+  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",
+  ])
+
+  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.debug("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]
+  }
+
+  interface State {
+    cache: Entry
+    fiber: Fiber.Fiber<void> | undefined
+  }
+
+  export interface Interface {
+    readonly init: () => Effect.Effect<void>
+    readonly status: () => Effect.Effect<File.Info[]>
+    readonly read: (file: string) => Effect.Effect<File.Content>
+    readonly list: (dir?: string) => Effect.Effect<File.Node[]>
+    readonly search: (input: {
+      query: string
+      limit?: number
+      dirs?: boolean
+      type?: "file" | "directory"
+    }) => Effect.Effect<string[]>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const state = yield* InstanceState.make<State>(
+        Effect.fn("File.state")(() =>
+          Effect.succeed({
+            cache: { files: [], dirs: [] } as Entry,
+            fiber: undefined as Fiber.Fiber<void> | undefined,
+          }),
+        ),
+      )
+
+      const scan = Effect.fn("File.scan")(function* () {
+        if (Instance.directory === path.parse(Instance.directory).root) return
+        const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
+        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 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 + "/")
+              }
+            }
+          }
+        })
+
+        const s = yield* InstanceState.get(state)
+        s.cache = next
+      })
+
+      const scope = yield* Scope.Scope
+
+      const ensure = Effect.fn("File.ensure")(function* () {
+        const s = yield* InstanceState.get(state)
+        if (!s.fiber)
+          s.fiber = yield* scan().pipe(
+            Effect.catchCause(() => Effect.void),
+            Effect.ensuring(
+              Effect.sync(() => {
+                s.fiber = undefined
+              }),
+            ),
+            Effect.forkIn(scope),
+          )
+        yield* Fiber.join(s.fiber)
+      })
+
+      const init = Effect.fn("File.init")(function* () {
+        yield* ensure()
+      })
+
+      const status = Effect.fn("File.status")(function* () {
+        if (Instance.project.vcs !== "git") return []
+
+        return yield* Effect.promise(async () => {
+          const diffOutput = (
+            await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
+              cwd: Instance.directory,
+            })
+          ).text()
+
+          const changed: File.Info[] = []
+
+          if (diffOutput.trim()) {
+            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",
+              })
+            }
+          }
+
+          const untrackedOutput = (
+            await git(
+              [
+                "-c",
+                "core.fsmonitor=false",
+                "-c",
+                "core.quotepath=false",
+                "ls-files",
+                "--others",
+                "--exclude-standard",
+              ],
+              {
+                cwd: Instance.directory,
+              },
+            )
+          ).text()
+
+          if (untrackedOutput.trim()) {
+            for (const file of untrackedOutput.trim().split("\n")) {
+              try {
+                const content = await Filesystem.readText(path.join(Instance.directory, file))
+                changed.push({
+                  path: file,
+                  added: content.split("\n").length,
+                  removed: 0,
+                  status: "added",
+                })
+              } catch {
+                continue
+              }
+            }
+          }
+
+          const deletedOutput = (
+            await git(
+              [
+                "-c",
+                "core.fsmonitor=false",
+                "-c",
+                "core.quotepath=false",
+                "diff",
+                "--name-only",
+                "--diff-filter=D",
+                "HEAD",
+              ],
+              {
+                cwd: Instance.directory,
+              },
+            )
+          ).text()
+
+          if (deletedOutput.trim()) {
+            for (const file of deletedOutput.trim().split("\n")) {
+              changed.push({
+                path: file,
+                added: 0,
+                removed: 0,
+                status: "deleted",
+              })
+            }
+          }
+
+          return changed.map((item) => {
+            const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
+            return {
+              ...item,
+              path: path.relative(Instance.directory, full),
+            }
+          })
+        })
+      })
+
+      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")
+          }
+
+          if (isImageByExtension(file)) {
+            if (await Filesystem.exists(full)) {
+              const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
+              return {
+                type: "text",
+                content: buffer.toString("base64"),
+                mimeType: getImageMimeType(file),
+                encoding: "base64",
+              }
+            }
+            return { type: "text", content: "" }
+          }
+
+          const knownText = isTextByExtension(file) || isTextByName(file)
+
+          if (isBinaryByExtension(file) && !knownText) {
+            return { type: "binary", content: "" }
+          }
+
+          if (!(await Filesystem.exists(full))) {
+            return { type: "text", content: "" }
+          }
+
+          const mimeType = Filesystem.mimeType(full)
+          const encode = knownText ? false : shouldEncode(mimeType)
+
+          if (encode && !isImage(mimeType)) {
+            return { type: "binary", content: "", mimeType }
+          }
+
+          if (encode) {
+            const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
+            return {
+              type: "text",
+              content: buffer.toString("base64"),
+              mimeType,
+              encoding: "base64",
+            }
+          }
+
+          const content = (await Filesystem.readText(full).catch(() => "")).trim()
+
+          if (Instance.project.vcs === "git") {
+            let diff = (
+              await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
+            ).text()
+            if (!diff.trim()) {
+              diff = (
+                await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
+                  cwd: Instance.directory,
+                })
+              ).text()
+            }
+            if (diff.trim()) {
+              const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
+              const patch = structuredPatch(file, file, original, content, "old", "new", {
+                context: Infinity,
+                ignoreWhitespace: true,
+              })
+              return {
+                type: "text",
+                content,
+                patch,
+                diff: formatPatch(patch),
+              }
+            }
+          }
+
+          return { type: "text", content }
+        })
+      })
+
+      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 gitignore = path.join(Instance.project.worktree, ".gitignore")
+            if (await Filesystem.exists(gitignore)) {
+              ig.add(await Filesystem.readText(gitignore))
+            }
+            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
+          if (!Instance.containsPath(resolved)) {
+            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(() => [])) {
+            if (exclude.includes(entry.name)) continue
+            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: file,
+              absolute,
+              type,
+              ignored: ignored(type === "directory" ? file + "/" : file),
+            })
+          }
+
+          return nodes.sort((a, b) => {
+            if (a.type !== b.type) return a.type === "directory" ? -1 : 1
+            return a.name.localeCompare(b.name)
+          })
+        })
+      })
+
+      const search = Effect.fn("File.search")(function* (input: {
+        query: string
+        limit?: number
+        dirs?: boolean
+        type?: "file" | "directory"
+      }) {
+        yield* ensure()
+        const { cache } = yield* InstanceState.get(state)
+
+        return yield* Effect.promise(async () => {
+          const query = input.query.trim()
+          const limit = input.limit ?? 100
+          const kind = input.type ?? (input.dirs === false ? "file" : "all")
+          log.info("search", { query, kind })
+
+          const result = cache
+          const preferHidden = query.startsWith(".") || query.includes("/.")
+
+          if (!query) {
+            if (kind === "file") return result.files.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((item) => item.target)
+          const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
+
+          log.info("search", { query, kind, results: output.length })
+          return output
+        })
+      })
+
+      log.info("init")
+      return Service.of({ init, status, read, list, search })
+    }),
+  )
 
-  export const Service = S.Service
-  export const layer = S.layer
+  const runPromise = makeRunPromise(Service, layer)
 
   export function init() {
-    return runPromiseInstance(S.Service.use((svc) => svc.init()))
+    return runPromise((svc) => svc.init())
   }
 
   export async function status() {
-    return runPromiseInstance(S.Service.use((svc) => svc.status()))
+    return runPromise((svc) => svc.status())
   }
 
   export async function read(file: string): Promise<Content> {
-    return runPromiseInstance(S.Service.use((svc) => svc.read(file)))
+    return runPromise((svc) => svc.read(file))
   }
 
   export async function list(dir?: string) {
-    return runPromiseInstance(S.Service.use((svc) => svc.list(dir)))
+    return runPromise((svc) => svc.list(dir))
   }
 
   export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
-    return runPromiseInstance(S.Service.use((svc) => svc.search(input)))
+    return runPromise((svc) => svc.search(input))
   }
 }

+ 0 - 674
packages/opencode/src/file/service.ts

@@ -1,674 +0,0 @@
-import { BusEvent } from "@/bus/bus-event"
-import { InstanceContext } from "@/effect/instance-context"
-import { git } from "@/util/git"
-import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
-import { formatPatch, structuredPatch } from "diff"
-import fs from "fs"
-import fuzzysort from "fuzzysort"
-import ignore from "ignore"
-import path from "path"
-import z from "zod"
-import { Global } from "../global"
-import { Instance } from "../project/instance"
-import { Filesystem } from "../util/filesystem"
-import { Log } from "../util/log"
-import { Protected } from "./protected"
-import { Ripgrep } from "./ripgrep"
-
-export namespace File {
-  export const Info = z
-    .object({
-      path: z.string(),
-      added: z.number().int(),
-      removed: z.number().int(),
-      status: z.enum(["added", "deleted", "modified"]),
-    })
-    .meta({
-      ref: "File",
-    })
-
-  export type Info = z.infer<typeof Info>
-
-  export const Node = z
-    .object({
-      name: z.string(),
-      path: z.string(),
-      absolute: z.string(),
-      type: z.enum(["file", "directory"]),
-      ignored: z.boolean(),
-    })
-    .meta({
-      ref: "FileNode",
-    })
-  export type Node = z.infer<typeof Node>
-
-  export const Content = z
-    .object({
-      type: z.enum(["text", "binary"]),
-      content: z.string(),
-      diff: z.string().optional(),
-      patch: z
-        .object({
-          oldFileName: z.string(),
-          newFileName: z.string(),
-          oldHeader: z.string().optional(),
-          newHeader: z.string().optional(),
-          hunks: z.array(
-            z.object({
-              oldStart: z.number(),
-              oldLines: z.number(),
-              newStart: z.number(),
-              newLines: z.number(),
-              lines: z.array(z.string()),
-            }),
-          ),
-          index: z.string().optional(),
-        })
-        .optional(),
-      encoding: z.literal("base64").optional(),
-      mimeType: z.string().optional(),
-    })
-    .meta({
-      ref: "FileContent",
-    })
-  export type Content = z.infer<typeof Content>
-
-  export const Event = {
-    Edited: BusEvent.define(
-      "file.edited",
-      z.object({
-        file: z.string(),
-      }),
-    ),
-  }
-
-  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 interface Interface {
-    readonly init: () => Effect.Effect<void>
-    readonly status: () => Effect.Effect<File.Info[]>
-    readonly read: (file: string) => Effect.Effect<File.Content>
-    readonly list: (dir?: string) => Effect.Effect<File.Node[]>
-    readonly search: (input: {
-      query: string
-      limit?: number
-      dirs?: boolean
-      type?: "file" | "directory"
-    }) => Effect.Effect<string[]>
-  }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const instance = yield* InstanceContext
-      let cache: Entry = { files: [], dirs: [] }
-      const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
-
-      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 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
-      })
-
-      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("File.status")(function* () {
-        if (instance.project.vcs !== "git") return []
-
-        return yield* Effect.promise(async () => {
-          const diffOutput = (
-            await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
-              cwd: instance.directory,
-            })
-          ).text()
-
-          const changed: File.Info[] = []
-
-          if (diffOutput.trim()) {
-            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",
-              })
-            }
-          }
-
-          const untrackedOutput = (
-            await git(
-              [
-                "-c",
-                "core.fsmonitor=false",
-                "-c",
-                "core.quotepath=false",
-                "ls-files",
-                "--others",
-                "--exclude-standard",
-              ],
-              {
-                cwd: instance.directory,
-              },
-            )
-          ).text()
-
-          if (untrackedOutput.trim()) {
-            for (const file of untrackedOutput.trim().split("\n")) {
-              try {
-                const content = await Filesystem.readText(path.join(instance.directory, file))
-                changed.push({
-                  path: file,
-                  added: content.split("\n").length,
-                  removed: 0,
-                  status: "added",
-                })
-              } catch {
-                continue
-              }
-            }
-          }
-
-          const deletedOutput = (
-            await git(
-              [
-                "-c",
-                "core.fsmonitor=false",
-                "-c",
-                "core.quotepath=false",
-                "diff",
-                "--name-only",
-                "--diff-filter=D",
-                "HEAD",
-              ],
-              {
-                cwd: instance.directory,
-              },
-            )
-          ).text()
-
-          if (deletedOutput.trim()) {
-            for (const file of deletedOutput.trim().split("\n")) {
-              changed.push({
-                path: file,
-                added: 0,
-                removed: 0,
-                status: "deleted",
-              })
-            }
-          }
-
-          return changed.map((item) => {
-            const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
-            return {
-              ...item,
-              path: path.relative(instance.directory, full),
-            }
-          })
-        })
-      })
-
-      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")
-          }
-
-          if (isImageByExtension(file)) {
-            if (await Filesystem.exists(full)) {
-              const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
-              return {
-                type: "text",
-                content: buffer.toString("base64"),
-                mimeType: getImageMimeType(file),
-                encoding: "base64",
-              }
-            }
-            return { type: "text", content: "" }
-          }
-
-          const knownText = isTextByExtension(file) || isTextByName(file)
-
-          if (isBinaryByExtension(file) && !knownText) {
-            return { type: "binary", content: "" }
-          }
-
-          if (!(await Filesystem.exists(full))) {
-            return { type: "text", content: "" }
-          }
-
-          const mimeType = Filesystem.mimeType(full)
-          const encode = knownText ? false : shouldEncode(mimeType)
-
-          if (encode && !isImage(mimeType)) {
-            return { type: "binary", content: "", mimeType }
-          }
-
-          if (encode) {
-            const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
-            return {
-              type: "text",
-              content: buffer.toString("base64"),
-              mimeType,
-              encoding: "base64",
-            }
-          }
-
-          const content = (await Filesystem.readText(full).catch(() => "")).trim()
-
-          if (instance.project.vcs === "git") {
-            let diff = (
-              await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
-            ).text()
-            if (!diff.trim()) {
-              diff = (
-                await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
-                  cwd: instance.directory,
-                })
-              ).text()
-            }
-            if (diff.trim()) {
-              const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
-              const patch = structuredPatch(file, file, original, content, "old", "new", {
-                context: Infinity,
-                ignoreWhitespace: true,
-              })
-              return {
-                type: "text",
-                content,
-                patch,
-                diff: formatPatch(patch),
-              }
-            }
-          }
-
-          return { type: "text", content }
-        })
-      })
-
-      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 gitignore = path.join(instance.project.worktree, ".gitignore")
-            if (await Filesystem.exists(gitignore)) {
-              ig.add(await Filesystem.readText(gitignore))
-            }
-            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
-          if (!Instance.containsPath(resolved)) {
-            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(() => [])) {
-            if (exclude.includes(entry.name)) continue
-            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: file,
-              absolute,
-              type,
-              ignored: ignored(type === "directory" ? file + "/" : file),
-            })
-          }
-
-          return nodes.sort((a, b) => {
-            if (a.type !== b.type) return a.type === "directory" ? -1 : 1
-            return a.name.localeCompare(b.name)
-          })
-        })
-      })
-
-      const search = Effect.fn("File.search")(function* (input: {
-        query: string
-        limit?: number
-        dirs?: boolean
-        type?: "file" | "directory"
-      }) {
-        return yield* Effect.promise(async () => {
-          const query = input.query.trim()
-          const limit = input.limit ?? 100
-          const kind = input.type ?? (input.dirs === false ? "file" : "all")
-          log.info("search", { query, kind })
-
-          const result = getFiles()
-          const preferHidden = query.startsWith(".") || query.includes("/.")
-
-          if (!query) {
-            if (kind === "file") return result.files.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((item) => item.target)
-          const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
-
-          log.info("search", { query, kind, results: output.length })
-          return output
-        })
-      })
-
-      log.info("init")
-      return Service.of({ init, status, read, list, search })
-    }),
-  ).pipe(Layer.fresh)
-}

+ 0 - 93
packages/opencode/src/file/time-service.ts

@@ -1,93 +0,0 @@
-import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
-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" })
-
-  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 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>
-  }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
-
-  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>()
-
-      const getLock = (filepath: string) => {
-        const lock = locks.get(filepath)
-        if (lock) return lock
-
-        const next = Semaphore.makeUnsafe(1)
-        locks.set(filepath, next)
-        return next
-      }
-
-      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 })
-    }),
-  ).pipe(Layer.orDie, Layer.fresh)
-}

+ 110 - 10
packages/opencode/src/file/time.ts

@@ -1,28 +1,128 @@
-import { runPromiseInstance } from "@/effect/runtime"
+import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import { Flag } from "@/flag/flag"
 import type { SessionID } from "@/session/schema"
-import { FileTime as S } from "./time-service"
+import { Filesystem } from "../util/filesystem"
+import { Log } from "../util/log"
 
 export namespace FileTime {
-  export type Stamp = S.Stamp
+  const log = Log.create({ service: "file.time" })
 
-  export type Interface = S.Interface
+  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
+  }
+
+  interface State {
+    reads: Map<SessionID, Map<string, Stamp>>
+    locks: Map<string, Semaphore.Semaphore>
+  }
+
+  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>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
+      const state = yield* InstanceState.make<State>(
+        Effect.fn("FileTime.state")(() =>
+          Effect.succeed({
+            reads: new Map<SessionID, Map<string, Stamp>>(),
+            locks: new Map<string, Semaphore.Semaphore>(),
+          }),
+        ),
+      )
+
+      const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
+        const locks = (yield* InstanceState.get(state)).locks
+        const lock = locks.get(filepath)
+        if (lock) return lock
+
+        const next = Semaphore.makeUnsafe(1)
+        locks.set(filepath, next)
+        return next
+      })
+
+      const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
+        const reads = (yield* InstanceState.get(state)).reads
+        log.info("read", { sessionID, file })
+        session(reads, sessionID).set(file, yield* stamp(file))
+      })
+
+      const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
+        const reads = (yield* InstanceState.get(state)).reads
+        return reads.get(sessionID)?.get(file)?.read
+      })
+
+      const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
+        if (disableCheck) return
+
+        const reads = (yield* InstanceState.get(state)).reads
+        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((yield* getLock(filepath)).withPermits(1))
+      })
+
+      return Service.of({ read, get, assert, withLock })
+    }),
+  ).pipe(Layer.orDie)
 
-  export const Service = S.Service
-  export const layer = S.layer
+  const runPromise = makeRunPromise(Service, layer)
 
   export function read(sessionID: SessionID, file: string) {
-    return runPromiseInstance(S.Service.use((s) => s.read(sessionID, file)))
+    return runPromise((s) => s.read(sessionID, file))
   }
 
   export function get(sessionID: SessionID, file: string) {
-    return runPromiseInstance(S.Service.use((s) => s.get(sessionID, file)))
+    return runPromise((s) => s.get(sessionID, file))
   }
 
   export async function assert(sessionID: SessionID, filepath: string) {
-    return runPromiseInstance(S.Service.use((s) => s.assert(sessionID, filepath)))
+    return runPromise((s) => s.assert(sessionID, filepath))
   }
 
   export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
-    return runPromiseInstance(S.Service.use((s) => s.withLock(filepath, fn)))
+    return runPromise((s) => s.withLock(filepath, fn))
   }
 }

+ 96 - 70
packages/opencode/src/file/watcher.ts

@@ -1,4 +1,4 @@
-import { Cause, Effect, Layer, ServiceMap } from "effect"
+import { Cause, Effect, Layer, Scope, ServiceMap } from "effect"
 // @ts-ignore
 import { createWrapper } from "@parcel/watcher/wrapper"
 import type ParcelWatcher from "@parcel/watcher"
@@ -7,7 +7,8 @@ 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 { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
 import { Flag } from "@/flag/flag"
 import { Instance } from "@/project/instance"
 import { git } from "@/util/git"
@@ -60,82 +61,107 @@ export namespace FileWatcher {
 
   export const hasNativeBinding = () => !!watcher()
 
-  export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
+  export interface Interface {
+    readonly init: () => Effect.Effect<void>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileWatcher") {}
 
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
-      const instance = yield* InstanceContext
-      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 Service.of({})
-      }
-
-      const w = watcher()
-      if (!w) return Service.of({})
-
-      log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
-
-      const subs: ParcelWatcher.AsyncSubscription[] = []
-      yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))))
-
-      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" })
-        }
-      })
-
-      const subscribe = (dir: string, ignore: string[]) => {
-        const pending = w.subscribe(dir, cb, { ignore, backend })
-        return Effect.gen(function* () {
-          const sub = yield* Effect.promise(() => pending)
-          subs.push(sub)
-        }).pipe(
-          Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
+      const state = yield* InstanceState.make(
+        Effect.fn("FileWatcher.state")(
+          function* () {
+            if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
+
+            log.info("init", { directory: Instance.directory })
+
+            const backend = getBackend()
+            if (!backend) {
+              log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
+              return
+            }
+
+            const w = watcher()
+            if (!w) return
+
+            log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
+
+            const subs: ParcelWatcher.AsyncSubscription[] = []
+            yield* Effect.addFinalizer(() =>
+              Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
+            )
+
+            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" })
+              }
+            })
+
+            const subscribe = (dir: string, ignore: string[]) => {
+              const pending = w.subscribe(dir, cb, { ignore, backend })
+              return Effect.gen(function* () {
+                const sub = yield* Effect.promise(() => pending)
+                subs.push(sub)
+              }).pipe(
+                Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
+                Effect.catchCause((cause) => {
+                  log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
+                  pending.then((s) => s.unsubscribe()).catch(() => {})
+                  return Effect.void
+                }),
+              )
+            }
+
+            const cfg = yield* Effect.promise(() => Config.get())
+            const cfgIgnores = cfg.watcher?.ignore ?? []
+
+            if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
+              yield* subscribe(Instance.directory, [
+                ...FileIgnore.PATTERNS,
+                ...cfgIgnores,
+                ...protecteds(Instance.directory),
+              ])
+            }
+
+            if (Instance.project.vcs === "git") {
+              const result = yield* Effect.promise(() =>
+                git(["rev-parse", "--git-dir"], {
+                  cwd: Instance.project.worktree,
+                }),
+              )
+              const vcsDir =
+                result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
+              if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
+                const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
+                  (entry) => entry !== "HEAD",
+                )
+                yield* subscribe(vcsDir, ignore)
+              }
+            }
+          },
           Effect.catchCause((cause) => {
-            log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
-            pending.then((s) => s.unsubscribe()).catch(() => {})
+            log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
             return Effect.void
           }),
-        )
-      }
+        ),
+      )
 
-      const cfg = yield* Effect.promise(() => Config.get())
-      const cfgIgnores = cfg.watcher?.ignore ?? []
+      return Service.of({
+        init: Effect.fn("FileWatcher.init")(function* () {
+          yield* InstanceState.get(state)
+        }),
+      })
+    }),
+  )
 
-      if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
-        yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(instance.directory)])
-      }
+  const runPromise = makeRunPromise(Service, layer)
 
-      if (instance.project.vcs === "git") {
-        const result = yield* Effect.promise(() =>
-          git(["rev-parse", "--git-dir"], {
-            cwd: instance.project.worktree,
-          }),
-        )
-        const vcsDir = result.exitCode === 0 ? path.resolve(instance.project.worktree, result.text().trim()) : undefined
-        if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
-          const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
-            (entry) => entry !== "HEAD",
-          )
-          yield* subscribe(vcsDir, ignore)
-        }
-      }
-
-      return Service.of({})
-    }).pipe(
-      Effect.catchCause((cause) => {
-        log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
-        return Effect.succeed(Service.of({}))
-      }),
-    ),
-  ).pipe(Layer.orDie, Layer.fresh)
+  export function init() {
+    return runPromise((svc) => svc.init())
+  }
 }

+ 174 - 8
packages/opencode/src/format/index.ts

@@ -1,16 +1,182 @@
-import { runPromiseInstance } from "@/effect/runtime"
-import { Format as S } from "./service"
+import { Effect, Layer, ServiceMap } from "effect"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import path from "path"
+import { mergeDeep } from "remeda"
+import z from "zod"
+import { Bus } from "../bus"
+import { Config } from "../config/config"
+import { File } from "../file"
+import { Instance } from "../project/instance"
+import { Process } from "../util/process"
+import { Log } from "../util/log"
+import * as Formatter from "./formatter"
 
 export namespace Format {
-  export const Status = S.Status
-  export type Status = S.Status
+  const log = Log.create({ service: "format" })
 
-  export type Interface = S.Interface
+  export const Status = z
+    .object({
+      name: z.string(),
+      extensions: z.string().array(),
+      enabled: z.boolean(),
+    })
+    .meta({
+      ref: "FormatterStatus",
+    })
+  export type Status = z.infer<typeof Status>
 
-  export const Service = S.Service
-  export const layer = S.layer
+  export interface Interface {
+    readonly init: () => Effect.Effect<void>
+    readonly status: () => Effect.Effect<Status[]>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const state = yield* InstanceState.make(
+        Effect.fn("Format.state")(function* (_ctx) {
+          const enabled: Record<string, boolean> = {}
+          const formatters: Record<string, Formatter.Info> = {}
+
+          const cfg = yield* Effect.promise(() => Config.get())
+
+          if (cfg.formatter !== false) {
+            for (const item of Object.values(Formatter)) {
+              formatters[item.name] = item
+            }
+            for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
+              if (item.disabled) {
+                delete formatters[name]
+                continue
+              }
+              const info = mergeDeep(formatters[name] ?? {}, {
+                command: [],
+                extensions: [],
+                ...item,
+              })
+
+              if (info.command.length === 0) continue
+
+              formatters[name] = {
+                ...info,
+                name,
+                enabled: async () => true,
+              }
+            }
+          } else {
+            log.info("all formatters are disabled")
+          }
+
+          async function isEnabled(item: Formatter.Info) {
+            let status = enabled[item.name]
+            if (status === undefined) {
+              status = await item.enabled()
+              enabled[item.name] = status
+            }
+            return status
+          }
+
+          async function getFormatter(ext: string) {
+            const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
+            const checks = await Promise.all(
+              matching.map(async (item) => {
+                log.info("checking", { name: item.name, ext })
+                const on = await isEnabled(item)
+                if (on) {
+                  log.info("enabled", { name: item.name, ext })
+                }
+                return {
+                  item,
+                  enabled: on,
+                }
+              }),
+            )
+            return checks.filter((x) => x.enabled).map((x) => x.item)
+          }
+
+          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),
+          )
+          log.info("init")
+
+          return {
+            formatters,
+            isEnabled,
+          }
+        }),
+      )
+
+      const init = Effect.fn("Format.init")(function* () {
+        yield* InstanceState.get(state)
+      })
+
+      const status = Effect.fn("Format.status")(function* () {
+        const { formatters, isEnabled } = yield* InstanceState.get(state)
+        const result: Status[] = []
+        for (const formatter of Object.values(formatters)) {
+          const isOn = yield* Effect.promise(() => isEnabled(formatter))
+          result.push({
+            name: formatter.name,
+            extensions: formatter.extensions,
+            enabled: isOn,
+          })
+        }
+        return result
+      })
+
+      return Service.of({ init, status })
+    }),
+  )
+
+  const runPromise = makeRunPromise(Service, layer)
+
+  export async function init() {
+    return runPromise((s) => s.init())
+  }
 
   export async function status() {
-    return runPromiseInstance(S.Service.use((s) => s.status()))
+    return runPromise((s) => s.status())
   }
 }

+ 0 - 152
packages/opencode/src/format/service.ts

@@ -1,152 +0,0 @@
-import { Effect, Layer, ServiceMap } from "effect"
-import { InstanceContext } from "@/effect/instance-context"
-import path from "path"
-import { mergeDeep } from "remeda"
-import z from "zod"
-import { Bus } from "../bus"
-import { Config } from "../config/config"
-import { File } from "../file/service"
-import { Instance } from "../project/instance"
-import { Process } from "../util/process"
-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(),
-      extensions: z.string().array(),
-      enabled: z.boolean(),
-    })
-    .meta({
-      ref: "FormatterStatus",
-    })
-  export type Status = z.infer<typeof Status>
-
-  export interface Interface {
-    readonly status: () => Effect.Effect<Status[]>
-  }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const instance = yield* InstanceContext
-
-      const enabled: Record<string, boolean> = {}
-      const formatters: Record<string, Formatter.Info> = {}
-
-      const cfg = yield* Effect.promise(() => Config.get())
-
-      if (cfg.formatter !== false) {
-        for (const item of Object.values(Formatter)) {
-          formatters[item.name] = item
-        }
-        for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
-          if (item.disabled) {
-            delete formatters[name]
-            continue
-          }
-          const info = mergeDeep(formatters[name] ?? {}, {
-            command: [],
-            extensions: [],
-            ...item,
-          })
-
-          if (info.command.length === 0) continue
-
-          formatters[name] = {
-            ...info,
-            name,
-            enabled: async () => true,
-          }
-        }
-      } else {
-        log.info("all formatters are disabled")
-      }
-
-      async function isEnabled(item: Formatter.Info) {
-        let status = enabled[item.name]
-        if (status === undefined) {
-          status = await item.enabled()
-          enabled[item.name] = status
-        }
-        return status
-      }
-
-      async function getFormatter(ext: string) {
-        const result = []
-        for (const item of Object.values(formatters)) {
-          log.info("checking", { name: item.name, ext })
-          if (!item.extensions.includes(ext)) continue
-          if (!(await isEnabled(item))) continue
-          log.info("enabled", { name: item.name, ext })
-          result.push(item)
-        }
-        return result
-      }
-
-      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),
-      )
-      log.info("init")
-
-      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({
-            name: formatter.name,
-            extensions: formatter.extensions,
-            enabled: isOn,
-          })
-        }
-        return result
-      })
-
-      return Service.of({ status })
-    }),
-  ).pipe(Layer.fresh)
-}

+ 7 - 12
packages/opencode/src/installation/index.ts

@@ -1,6 +1,7 @@
 import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
 import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
 import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+import { makeRunPromise } from "@/effect/run-service"
 import { withTransientReadRetry } from "@/util/effect-http-client"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import path from "path"
@@ -293,7 +294,7 @@ export namespace Installation {
               result = yield* run(["scoop", "install", `opencode@${target}`])
               break
             default:
-              throw new Error(`Unknown method: ${m}`)
+              return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
           }
           if (!result || result.code !== 0) {
             const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
@@ -329,27 +330,21 @@ export namespace Installation {
     Layer.provide(NodePath.layer),
   )
 
-  // Legacy adapters — dynamic import avoids circular dependency since
-  // foundational modules (db.ts, provider/models.ts) import Installation
-  // at load time, and runtime transitively loads those same modules.
-  async function runPromise<A>(f: (service: Interface) => Effect.Effect<A, any>) {
-    const { runtime } = await import("@/effect/runtime")
-    return runtime.runPromise(Service.use(f))
-  }
+  const runPromise = makeRunPromise(Service, defaultLayer)
 
-  export function info(): Promise<Info> {
+  export async function info(): Promise<Info> {
     return runPromise((svc) => svc.info())
   }
 
-  export function method(): Promise<Method> {
+  export async function method(): Promise<Method> {
     return runPromise((svc) => svc.method())
   }
 
-  export function latest(installMethod?: Method): Promise<string> {
+  export async function latest(installMethod?: Method): Promise<string> {
     return runPromise((svc) => svc.latest(installMethod))
   }
 
-  export function upgrade(m: Method, target: string): Promise<void> {
+  export async function upgrade(m: Method, target: string): Promise<void> {
     return runPromise((svc) => svc.upgrade(m, target))
   }
 }

+ 303 - 33
packages/opencode/src/permission/index.ts

@@ -1,52 +1,322 @@
-import { runPromiseInstance } from "@/effect/runtime"
-import { fn } from "@/util/fn"
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { Config } from "@/config/config"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import { ProjectID } from "@/project/schema"
+import { Instance } from "@/project/instance"
+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 os from "os"
 import z from "zod"
-import { Permission as S } from "./service"
+import { evaluate as evalRule } from "./evaluate"
+import { PermissionID } from "./schema"
 
-export namespace PermissionNext {
-  export const Action = S.Action
-  export type Action = S.Action
+export namespace Permission {
+  const log = Log.create({ service: "permission" })
 
-  export const Rule = S.Rule
-  export type Rule = S.Rule
+  export const Action = z.enum(["allow", "deny", "ask"]).meta({
+    ref: "PermissionAction",
+  })
+  export type Action = z.infer<typeof Action>
 
-  export const Ruleset = S.Ruleset
-  export type Ruleset = S.Ruleset
+  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 Request = S.Request
-  export type Request = S.Request
+  export const Ruleset = Rule.array().meta({
+    ref: "PermissionRuleset",
+  })
+  export type Ruleset = z.infer<typeof Ruleset>
 
-  export const Reply = S.Reply
-  export type Reply = S.Reply
+  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 Approval = S.Approval
-  export type Approval = z.infer<typeof S.Approval>
+  export const Reply = z.enum(["once", "always", "reject"])
+  export type Reply = z.infer<typeof Reply>
 
-  export const Event = S.Event
+  export const Approval = z.object({
+    projectID: ProjectID.zod,
+    patterns: z.string().array(),
+  })
 
-  export const RejectedError = S.RejectedError
-  export const CorrectedError = S.CorrectedError
-  export const DeniedError = S.DeniedError
-  export type Error = S.Error
+  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>
+  }
+
+  interface State {
+    pending: Map<PermissionID, PendingEntry>
+    approved: Ruleset
+  }
+
+  export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
+    log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
+    return evalRule(permission, pattern, ...rulesets)
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Permission") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const state = yield* InstanceState.make<State>(
+        Effect.fn("Permission.state")(function* (ctx) {
+          const row = Database.use((db) =>
+            db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
+          )
+          const state = {
+            pending: new Map<PermissionID, PendingEntry>(),
+            approved: row?.data ?? [],
+          }
+
+          yield* Effect.addFinalizer(() =>
+            Effect.gen(function* () {
+              for (const item of state.pending.values()) {
+                yield* Deferred.fail(item.deferred, new RejectedError())
+              }
+              state.pending.clear()
+            }),
+          )
+
+          return state
+        }),
+      )
 
-  export const AskInput = S.AskInput
-  export const ReplyInput = S.ReplyInput
+      const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
+        const { approved, pending } = yield* InstanceState.get(state)
+        const { ruleset, ...request } = input
+        let needsAsk = false
 
-  export type Interface = S.Interface
+        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
+        }
 
-  export const Service = S.Service
-  export const layer = S.layer
+        if (!needsAsk) return
 
-  export const evaluate = S.evaluate
-  export const fromConfig = S.fromConfig
-  export const merge = S.merge
-  export const disabled = S.disabled
+        const id = request.id ?? PermissionID.ascending()
+        const info: Request = {
+          id,
+          ...request,
+        }
+        log.info("asking", { id, permission: info.permission, patterns: info.patterns })
 
-  export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((s) => s.ask(input))))
+        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)
+          }),
+        )
+      })
 
-  export const reply = fn(S.ReplyInput, async (input) => runPromiseInstance(S.Service.use((s) => s.reply(input))))
+      const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
+        const { approved, pending } = yield* InstanceState.get(state)
+        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* () {
+        const pending = (yield* InstanceState.get(state)).pending
+        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()
+    if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
+    if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
+    return pattern
+  }
+
+  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: "*" })
+        continue
+      }
+      ruleset.push(
+        ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
+      )
+    }
+    return ruleset
+  }
+
+  export function merge(...rulesets: Ruleset[]): Ruleset {
+    return rulesets.flat()
+  }
+
+  const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
+
+  export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
+    const result = new Set<string>()
+    for (const tool of tools) {
+      const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
+      const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
+      if (!rule) continue
+      if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
+    }
+    return result
+  }
+
+  export const runPromise = makeRunPromise(Service, layer)
+
+  export async function ask(input: z.infer<typeof AskInput>) {
+    return runPromise((s) => s.ask(input))
+  }
+
+  export async function reply(input: z.infer<typeof ReplyInput>) {
+    return runPromise((s) => s.reply(input))
+  }
 
   export async function list() {
-    return runPromiseInstance(S.Service.use((s) => s.list()))
+    return runPromise((s) => s.list())
   }
 }

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

@@ -1,282 +0,0 @@
-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 { Log } from "@/util/log"
-import { Wildcard } from "@/util/wildcard"
-import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
-import os from "os"
-import z from "zod"
-import { evaluate as evalRule } from "./evaluate"
-import { PermissionID } from "./schema"
-
-export namespace Permission {
-  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 {
-    log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
-    return evalRule(permission, pattern, ...rulesets)
-  }
-
-  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 })
-    }),
-  ).pipe(Layer.fresh)
-
-  function expand(pattern: string): string {
-    if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
-    if (pattern === "~") return os.homedir()
-    if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
-    if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
-    return pattern
-  }
-
-  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: "*" })
-        continue
-      }
-      ruleset.push(
-        ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
-      )
-    }
-    return ruleset
-  }
-
-  export function merge(...rulesets: Ruleset[]): Ruleset {
-    return rulesets.flat()
-  }
-
-  const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
-
-  export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
-    const result = new Set<string>()
-    for (const tool of tools) {
-      const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
-      const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
-      if (!rule) continue
-      if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
-    }
-    return result
-  }
-}

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

@@ -1,7 +1,11 @@
 import { Plugin } from "../plugin"
+import { Format } from "../format"
 import { LSP } from "../lsp"
 import { File } from "../file"
+import { FileWatcher } from "../file/watcher"
+import { Snapshot } from "../snapshot"
 import { Project } from "./project"
+import { Vcs } from "./vcs"
 import { Bus } from "../bus"
 import { Command } from "../command"
 import { Instance } from "./instance"
@@ -12,8 +16,12 @@ export async function InstanceBootstrap() {
   Log.Default.info("bootstrapping", { directory: Instance.directory })
   await Plugin.init()
   ShareNext.init()
+  Format.init()
   await LSP.init()
   File.init()
+  FileWatcher.init()
+  Vcs.init()
+  Snapshot.init()
 
   Bus.subscribe(Command.Event.Executed, async (payload) => {
     if (payload.properties.name === Command.Default.INIT) {

+ 4 - 4
packages/opencode/src/project/instance.ts

@@ -7,13 +7,13 @@ import { Context } from "../util/context"
 import { Project } from "./project"
 import { State } from "./state"
 
-interface Context {
+export interface Shape {
   directory: string
   worktree: string
   project: Project.Info
 }
-const context = Context.create<Context>("instance")
-const cache = new Map<string, Promise<Context>>()
+const context = Context.create<Shape>("instance")
+const cache = new Map<string, Promise<Shape>>()
 
 const disposal = {
   all: undefined as Promise<void> | undefined,
@@ -52,7 +52,7 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
   })
 }
 
-function track(directory: string, next: Promise<Context>) {
+function track(directory: string, next: Promise<Shape>) {
   const task = next.catch((error) => {
     if (cache.get(directory) === task) cache.delete(directory)
     throw error

+ 62 - 34
packages/opencode/src/project/vcs.ts

@@ -1,7 +1,8 @@
 import { Effect, Layer, ServiceMap } from "effect"
 import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
-import { InstanceContext } from "@/effect/instance-context"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
 import { FileWatcher } from "@/file/watcher"
 import { Log } from "@/util/log"
 import { git } from "@/util/git"
@@ -30,54 +31,81 @@ export namespace Vcs {
   export type Info = z.infer<typeof Info>
 
   export interface Interface {
+    readonly init: () => Effect.Effect<void>
     readonly branch: () => Effect.Effect<string | undefined>
   }
 
+  interface State {
+    current: string | undefined
+  }
+
   export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
 
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
-      const instance = yield* InstanceContext
-      let currentBranch: string | undefined
+      const state = yield* InstanceState.make<State>(
+        Effect.fn("Vcs.state")((ctx) =>
+          Effect.gen(function* () {
+            if (ctx.project.vcs !== "git") {
+              return { current: undefined }
+            }
+
+            const getCurrentBranch = async () => {
+              const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
+                cwd: ctx.worktree,
+              })
+              if (result.exitCode !== 0) return undefined
+              const text = result.text().trim()
+              return text || undefined
+            }
 
-      if (instance.project.vcs === "git") {
-        const getCurrentBranch = async () => {
-          const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
-            cwd: instance.project.worktree,
-          })
-          if (result.exitCode !== 0) return undefined
-          const text = result.text().trim()
-          return text || undefined
-        }
+            const value = {
+              current: yield* Effect.promise(() => getCurrentBranch()),
+            }
+            log.info("initialized", { branch: value.current })
 
-        currentBranch = yield* Effect.promise(() => getCurrentBranch())
-        log.info("initialized", { branch: currentBranch })
+            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 !== value.current) {
+                      log.info("branch changed", { from: value.current, to: next })
+                      value.current = next
+                      Bus.publish(Event.BranchUpdated, { branch: next })
+                    }
+                  }),
+                ),
+              ),
+              (unsubscribe) => Effect.sync(unsubscribe),
+            )
 
-        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),
-        )
-      }
+            return value
+          }),
+        ),
+      )
 
       return Service.of({
+        init: Effect.fn("Vcs.init")(function* () {
+          yield* InstanceState.get(state)
+        }),
         branch: Effect.fn("Vcs.branch")(function* () {
-          return currentBranch
+          return yield* InstanceState.use(state, (x) => x.current)
         }),
       })
     }),
-  ).pipe(Layer.fresh)
+  )
+
+  const runPromise = makeRunPromise(Service, layer)
+
+  export function init() {
+    return runPromise((svc) => svc.init())
+  }
+
+  export function branch() {
+    return runPromise((svc) => svc.branch())
+  }
 }

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

@@ -1,215 +0,0 @@
-import type { AuthOuathResult } from "@opencode-ai/plugin"
-import { NamedError } from "@opencode-ai/util/error"
-import * as Auth from "@/auth/effect"
-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 = 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 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 class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const auth = yield* Auth.Auth.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 })
-    }),
-  ).pipe(Layer.fresh)
-
-  export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer))
-}

+ 234 - 32
packages/opencode/src/provider/auth.ts

@@ -1,48 +1,250 @@
-import { runPromiseInstance } from "@/effect/runtime"
-import { fn } from "@/util/fn"
+import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
+import { NamedError } from "@opencode-ai/util/error"
+import { Auth } from "@/auth"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import { Plugin } from "../plugin"
 import { ProviderID } from "./schema"
+import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
 import z from "zod"
-import { ProviderAuth as S } from "./auth-service"
 
 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 const Authorization = S.Authorization
-  export type Authorization = S.Authorization
+  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 = S.OauthMissing
-  export const OauthCodeMissing = S.OauthCodeMissing
-  export const OauthCallbackFailed = S.OauthCallbackFailed
-  export const ValidationFailed = S.ValidationFailed
-  export type Error = S.Error
+  export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
 
-  export type Interface = S.Interface
-
-  export const Service = S.Service
-  export const layer = S.layer
-  export const defaultLayer = S.defaultLayer
+  export const OauthCodeMissing = NamedError.create(
+    "ProviderAuthOauthCodeMissing",
+    z.object({ providerID: ProviderID.zod }),
+  )
 
-  export async function methods() {
-    return runPromiseInstance(S.Service.use((svc) => svc.methods()))
-  }
+  export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
 
-  export const authorize = fn(
+  export const ValidationFailed = NamedError.create(
+    "ProviderAuthValidationFailed",
     z.object({
-      providerID: ProviderID.zod,
-      method: z.number(),
-      inputs: z.record(z.string(), z.string()).optional(),
+      field: z.string(),
+      message: z.string(),
     }),
-    async (input): Promise<Authorization | undefined> =>
-      runPromiseInstance(S.Service.use((svc) => svc.authorize(input))),
   )
 
-  export const callback = fn(
-    z.object({
-      providerID: ProviderID.zod,
-      method: z.number(),
-      code: z.string().optional(),
+  export type Error =
+    | Auth.AuthError
+    | InstanceType<typeof OauthMissing>
+    | InstanceType<typeof OauthCodeMissing>
+    | InstanceType<typeof OauthCallbackFailed>
+    | InstanceType<typeof ValidationFailed>
+
+  type Hook = NonNullable<Hooks["auth"]>
+
+  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>
+  }
+
+  interface State {
+    hooks: Record<ProviderID, Hook>
+    pending: Map<ProviderID, AuthOuathResult>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const auth = yield* Auth.Service
+      const state = yield* InstanceState.make<State>(
+        Effect.fn("ProviderAuth.state")(() =>
+          Effect.promise(async () => {
+          const plugins = await Plugin.list()
+          return {
+            hooks: Record.fromEntries(
+              Arr.filterMap(plugins, (x) =>
+                x.auth?.provider !== undefined
+                  ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
+                  : Result.failVoid,
+              ),
+            ),
+            pending: new Map<ProviderID, AuthOuathResult>(),
+          }
+        })),
+      )
+
+      const methods = Effect.fn("ProviderAuth.methods")(function* () {
+        const hooks = (yield* InstanceState.get(state)).hooks
+        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 { hooks, pending } = yield* InstanceState.get(state)
+        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 pending = (yield* InstanceState.get(state)).pending
+        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 })
     }),
-    async (input) => runPromiseInstance(S.Service.use((svc) => svc.callback(input))),
   )
+
+  export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
+
+  const runPromise = makeRunPromise(Service, defaultLayer)
+
+  export async function methods() {
+    return runPromise((svc) => svc.methods())
+  }
+
+  export async function authorize(input: {
+    providerID: ProviderID
+    method: number
+    inputs?: Record<string, string>
+  }): Promise<Authorization | undefined> {
+    return runPromise((svc) => svc.authorize(input))
+  }
+
+  export async function callback(input: { providerID: ProviderID; method: number; code?: string }) {
+    return runPromise((svc) => svc.callback(input))
+  }
 }

+ 195 - 23
packages/opencode/src/question/index.ts

@@ -1,49 +1,221 @@
-import { runPromiseInstance } from "@/effect/runtime"
-import type { MessageID, SessionID } from "@/session/schema"
-import type { QuestionID } from "./schema"
-import { Question as S } from "./service"
+import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import { SessionID, MessageID } from "@/session/schema"
+import { Log } from "@/util/log"
+import z from "zod"
+import { QuestionID } from "./schema"
 
 export namespace Question {
-  export const Option = S.Option
-  export type Option = S.Option
+  const log = Log.create({ service: "question" })
 
-  export const Info = S.Info
-  export type Info = S.Info
+  // Schemas
 
-  export const Request = S.Request
-  export type Request = S.Request
+  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 Answer = S.Answer
-  export type Answer = S.Answer
+  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 Reply = S.Reply
-  export type Reply = S.Reply
+  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 Event = S.Event
-  export const RejectedError = S.RejectedError
+  export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
+  export type Answer = z.infer<typeof Answer>
 
-  export type Interface = S.Interface
+  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 Service = S.Service
-  export const layer = S.layer
+  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>
+  }
+
+  interface State {
+    pending: Map<QuestionID, PendingEntry>
+  }
+
+  // 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 state = yield* InstanceState.make<State>(
+        Effect.fn("Question.state")(function* () {
+          const state = {
+            pending: new Map<QuestionID, PendingEntry>(),
+          }
+
+          yield* Effect.addFinalizer(() =>
+            Effect.gen(function* () {
+              for (const item of state.pending.values()) {
+                yield* Deferred.fail(item.deferred, new RejectedError())
+              }
+              state.pending.clear()
+            }),
+          )
+
+          return state
+        }),
+      )
+
+      const ask = Effect.fn("Question.ask")(function* (input: {
+        sessionID: SessionID
+        questions: Info[]
+        tool?: { messageID: MessageID; callID: string }
+      }) {
+        const pending = (yield* InstanceState.get(state)).pending
+        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 pending = (yield* InstanceState.get(state)).pending
+        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 pending = (yield* InstanceState.get(state)).pending
+        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* () {
+        const pending = (yield* InstanceState.get(state)).pending
+        return Array.from(pending.values(), (x) => x.info)
+      })
+
+      return Service.of({ ask, reply, reject, list })
+    }),
+  )
+
+  const runPromise = makeRunPromise(Service, layer)
 
   export async function ask(input: {
     sessionID: SessionID
     questions: Info[]
     tool?: { messageID: MessageID; callID: string }
   }): Promise<Answer[]> {
-    return runPromiseInstance(S.Service.use((s) => s.ask(input)))
+    return runPromise((s) => s.ask(input))
   }
 
   export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
-    return runPromiseInstance(S.Service.use((s) => s.reply(input)))
+    return runPromise((s) => s.reply(input))
   }
 
   export async function reject(requestID: QuestionID) {
-    return runPromiseInstance(S.Service.use((s) => s.reject(requestID)))
+    return runPromise((s) => s.reject(requestID))
   }
 
   export async function list() {
-    return runPromiseInstance(S.Service.use((s) => s.list()))
+    return runPromise((s) => s.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" })
-
-export namespace Question {
-  // 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 })
-    }),
-  ).pipe(Layer.fresh)
-}

+ 5 - 5
packages/opencode/src/server/routes/permission.ts

@@ -1,7 +1,7 @@
 import { Hono } from "hono"
 import { describeRoute, validator, resolver } from "hono-openapi"
 import z from "zod"
-import { PermissionNext } from "@/permission"
+import { Permission } from "@/permission"
 import { PermissionID } from "@/permission/schema"
 import { errors } from "../error"
 import { lazy } from "../../util/lazy"
@@ -32,11 +32,11 @@ export const PermissionRoutes = lazy(() =>
           requestID: PermissionID.zod,
         }),
       ),
-      validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
+      validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })),
       async (c) => {
         const params = c.req.valid("param")
         const json = c.req.valid("json")
-        await PermissionNext.reply({
+        await Permission.reply({
           requestID: params.requestID,
           reply: json.reply,
           message: json.message,
@@ -55,14 +55,14 @@ export const PermissionRoutes = lazy(() =>
             description: "List of pending permissions",
             content: {
               "application/json": {
-                schema: resolver(PermissionNext.Request.array()),
+                schema: resolver(Permission.Request.array()),
               },
             },
           },
         },
       }),
       async (c) => {
-        const permissions = await PermissionNext.list()
+        const permissions = await Permission.list()
         return c.json(permissions)
       },
     ),

+ 4 - 4
packages/opencode/src/server/routes/session.ts

@@ -12,9 +12,9 @@ import { SessionStatus } from "@/session/status"
 import { SessionSummary } from "@/session/summary"
 import { Todo } from "../../session/todo"
 import { Agent } from "../../agent/agent"
-import { Snapshot } from "@/snapshot/service"
+import { Snapshot } from "@/snapshot"
 import { Log } from "../../util/log"
-import { PermissionNext } from "@/permission"
+import { Permission } from "@/permission"
 import { PermissionID } from "@/permission/schema"
 import { ModelID, ProviderID } from "@/provider/schema"
 import { errors } from "../error"
@@ -1010,10 +1010,10 @@ export const SessionRoutes = lazy(() =>
           permissionID: PermissionID.zod,
         }),
       ),
-      validator("json", z.object({ response: PermissionNext.Reply })),
+      validator("json", z.object({ response: Permission.Reply })),
       async (c) => {
         const params = c.req.valid("param")
-        PermissionNext.reply({
+        Permission.reply({
           requestID: params.permissionID,
           reply: c.req.valid("json").response,
         })

+ 3 - 4
packages/opencode/src/server/server.ts

@@ -12,9 +12,8 @@ import { Format } from "../format"
 import { TuiRoutes } from "./routes/tui"
 import { Instance } from "../project/instance"
 import { Vcs } from "../project/vcs"
-import { runPromiseInstance } from "@/effect/runtime"
 import { Agent } from "../agent/agent"
-import { Skill } from "../skill/skill"
+import { Skill } from "../skill"
 import { Auth } from "../auth"
 import { Flag } from "../flag/flag"
 import { Command } from "../command"
@@ -152,7 +151,7 @@ export namespace Server {
             providerID: ProviderID.zod,
           }),
         ),
-        validator("json", Auth.Info),
+        validator("json", Auth.Info.zod),
         async (c) => {
           const providerID = c.req.valid("param").providerID
           const info = c.req.valid("json")
@@ -331,7 +330,7 @@ export namespace Server {
           },
         }),
         async (c) => {
-          const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
+          const branch = await Vcs.branch()
           return c.json({
             branch,
           })

+ 5 - 5
packages/opencode/src/session/index.ts

@@ -20,7 +20,7 @@ import { Instance } from "../project/instance"
 import { SessionPrompt } from "./prompt"
 import { fn } from "@/util/fn"
 import { Command } from "../command"
-import { Snapshot } from "@/snapshot/service"
+import { Snapshot } from "@/snapshot"
 import { WorkspaceContext } from "../control-plane/workspace-context"
 import { ProjectID } from "../project/schema"
 import { WorkspaceID } from "../control-plane/schema"
@@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema"
 
 import type { Provider } from "@/provider/provider"
 import { ModelID, ProviderID } from "@/provider/schema"
-import { Permission as PermissionNext } from "@/permission/service"
+import { Permission } from "@/permission"
 import { Global } from "@/global"
 import type { LanguageModelV2Usage } from "@ai-sdk/provider"
 import { iife } from "@/util/iife"
@@ -148,7 +148,7 @@ export namespace Session {
         compacting: z.number().optional(),
         archived: z.number().optional(),
       }),
-      permission: PermissionNext.Ruleset.optional(),
+      permission: Permission.Ruleset.optional(),
       revert: z
         .object({
           messageID: MessageID.zod,
@@ -300,7 +300,7 @@ export namespace Session {
     parentID?: SessionID
     workspaceID?: WorkspaceID
     directory: string
-    permission?: PermissionNext.Ruleset
+    permission?: Permission.Ruleset
   }) {
     const result: Info = {
       id: SessionID.descending(input.id),
@@ -423,7 +423,7 @@ export namespace Session {
   export const setPermission = fn(
     z.object({
       sessionID: SessionID.zod,
-      permission: PermissionNext.Ruleset,
+      permission: Permission.Ruleset,
     }),
     async (input) => {
       return Database.use((db) => {

+ 4 - 4
packages/opencode/src/session/llm.ts

@@ -21,7 +21,7 @@ import type { MessageV2 } from "./message-v2"
 import { Plugin } from "@/plugin"
 import { SystemPrompt } from "./system"
 import { Flag } from "@/flag/flag"
-import { Permission as PermissionNext } from "@/permission/service"
+import { Permission } from "@/permission"
 import { Auth } from "@/auth"
 
 export namespace LLM {
@@ -33,7 +33,7 @@ export namespace LLM {
     sessionID: string
     model: Provider.Model
     agent: Agent.Info
-    permission?: PermissionNext.Ruleset
+    permission?: Permission.Ruleset
     system: string[]
     abort: AbortSignal
     messages: ModelMessage[]
@@ -286,9 +286,9 @@ export namespace LLM {
   }
 
   async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
-    const disabled = PermissionNext.disabled(
+    const disabled = Permission.disabled(
       Object.keys(input.tools),
-      PermissionNext.merge(input.agent.permission, input.permission ?? []),
+      Permission.merge(input.agent.permission, input.permission ?? []),
     )
     for (const tool of Object.keys(input.tools)) {
       if (input.user.tools?.[tool] === false || disabled.has(tool)) {

+ 1 - 1
packages/opencode/src/session/message-v2.ts

@@ -4,7 +4,7 @@ import z from "zod"
 import { NamedError } from "@opencode-ai/util/error"
 import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
 import { LSP } from "../lsp"
-import { Snapshot } from "@/snapshot/service"
+import { Snapshot } from "@/snapshot"
 import { fn } from "@/util/fn"
 import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db"
 import { MessageTable, PartTable, SessionTable } from "./session.sql"

+ 4 - 4
packages/opencode/src/session/processor.ts

@@ -12,8 +12,8 @@ import type { Provider } from "@/provider/provider"
 import { LLM } from "./llm"
 import { Config } from "@/config/config"
 import { SessionCompaction } from "./compaction"
-import { PermissionNext } from "@/permission"
-import { Question } from "@/question/service"
+import { Permission } from "@/permission"
+import { Question } from "@/question"
 import { PartID } from "./schema"
 import type { SessionID, MessageID } from "./schema"
 
@@ -163,7 +163,7 @@ export namespace SessionProcessor {
                       )
                     ) {
                       const agent = await Agent.get(input.assistantMessage.agent)
-                      await PermissionNext.ask({
+                      await Permission.ask({
                         permission: "doom_loop",
                         patterns: [value.toolName],
                         sessionID: input.assistantMessage.sessionID,
@@ -219,7 +219,7 @@ export namespace SessionProcessor {
                     })
 
                     if (
-                      value.error instanceof PermissionNext.RejectedError ||
+                      value.error instanceof Permission.RejectedError ||
                       value.error instanceof Question.RejectedError
                     ) {
                       blocked = shouldBreak

+ 7 - 7
packages/opencode/src/session/prompt.ts

@@ -41,7 +41,7 @@ import { fn } from "@/util/fn"
 import { SessionProcessor } from "./processor"
 import { TaskTool } from "@/tool/task"
 import { Tool } from "@/tool/tool"
-import { PermissionNext } from "@/permission"
+import { Permission } from "@/permission"
 import { SessionStatus } from "./status"
 import { LLM } from "./llm"
 import { iife } from "@/util/iife"
@@ -168,7 +168,7 @@ export namespace SessionPrompt {
 
     // this is backwards compatibility for allowing `tools` to be specified when
     // prompting
-    const permissions: PermissionNext.Ruleset = []
+    const permissions: Permission.Ruleset = []
     for (const [tool, enabled] of Object.entries(input.tools ?? {})) {
       permissions.push({
         permission: tool,
@@ -437,10 +437,10 @@ export namespace SessionPrompt {
             } satisfies MessageV2.ToolPart)) as MessageV2.ToolPart
           },
           async ask(req) {
-            await PermissionNext.ask({
+            await Permission.ask({
               ...req,
               sessionID: sessionID,
-              ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []),
+              ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
             })
           },
         }
@@ -781,11 +781,11 @@ export namespace SessionPrompt {
         }
       },
       async ask(req) {
-        await PermissionNext.ask({
+        await Permission.ask({
           ...req,
           sessionID: input.session.id,
           tool: { messageID: input.processor.message.id, callID: options.toolCallId },
-          ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []),
+          ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
         })
       },
     })
@@ -1271,7 +1271,7 @@ export namespace SessionPrompt {
 
         if (part.type === "agent") {
           // Check if this agent would be denied by task permission
-          const perm = PermissionNext.evaluate("task", part.name, agent.permission)
+          const perm = Permission.evaluate("task", part.name, agent.permission)
           const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
           return [
             {

+ 4 - 4
packages/opencode/src/session/session.sql.ts

@@ -1,8 +1,8 @@
 import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
 import { ProjectTable } from "../project/project.sql"
 import type { MessageV2 } from "./message-v2"
-import type { Snapshot } from "../snapshot/service"
-import type { Permission as PermissionNext } from "../permission/service"
+import type { Snapshot } from "../snapshot"
+import type { Permission } from "../permission"
 import type { ProjectID } from "../project/schema"
 import type { SessionID, MessageID, PartID } from "./schema"
 import type { WorkspaceID } from "../control-plane/schema"
@@ -31,7 +31,7 @@ export const SessionTable = sqliteTable(
     summary_files: integer(),
     summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
     revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
-    permission: text({ mode: "json" }).$type<PermissionNext.Ruleset>(),
+    permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
     ...Timestamps,
     time_compacting: integer(),
     time_archived: integer(),
@@ -99,5 +99,5 @@ export const PermissionTable = sqliteTable("permission", {
     .primaryKey()
     .references(() => ProjectTable.id, { onDelete: "cascade" }),
   ...Timestamps,
-  data: text({ mode: "json" }).notNull().$type<PermissionNext.Ruleset>(),
+  data: text({ mode: "json" }).notNull().$type<Permission.Ruleset>(),
 })

+ 2 - 2
packages/opencode/src/session/system.ts

@@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex.txt"
 import PROMPT_TRINITY from "./prompt/trinity.txt"
 import type { Provider } from "@/provider/provider"
 import type { Agent } from "@/agent/agent"
-import { Permission as PermissionNext } from "@/permission/service"
+import { Permission } from "@/permission"
 import { Skill } from "@/skill"
 
 export namespace SystemPrompt {
@@ -53,7 +53,7 @@ export namespace SystemPrompt {
   }
 
   export async function skills(agent: Agent.Info) {
-    if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return
+    if (Permission.disabled(["skill"], agent.permission).has("skill")) return
 
     const list = await Skill.available(agent)
 

+ 1 - 1
packages/opencode/src/share/share-next.ts

@@ -45,7 +45,7 @@ export namespace ShareNext {
   }> {
     const headers: Record<string, string> = {}
 
-    const active = Account.active()
+    const active = await Account.active()
     if (!active?.active_org_id) {
       const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
       return { headers, api: legacyApi, baseUrl }

+ 260 - 1
packages/opencode/src/skill/index.ts

@@ -1 +1,260 @@
-export * from "./skill"
+import os from "os"
+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 { Bus } from "@/bus"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import { Flag } from "@/flag/flag"
+import { Global } from "@/global"
+import { Permission } 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(),
+    location: z.string(),
+    content: z.string(),
+  })
+  export type Info = z.infer<typeof Info>
+
+  export const InvalidError = NamedError.create(
+    "SkillInvalidError",
+    z.object({
+      path: z.string(),
+      message: z.string().optional(),
+      issues: z.custom<z.core.$ZodIssue[]>().optional(),
+    }),
+  )
+
+  export const NameMismatchError = NamedError.create(
+    "SkillNameMismatchError",
+    z.object({
+      path: z.string(),
+      expected: z.string(),
+      actual: z.string(),
+    }),
+  )
+
+  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 = (discovery: Discovery.Interface, directory: string, worktree: string): 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: directory,
+          stop: 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(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, Discovery.Service> = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const discovery = yield* Discovery.Service
+      const state = yield* InstanceState.make(Effect.fn("Skill.state")((ctx) => Effect.sync(() => create(discovery, ctx.directory, ctx.worktree))))
+
+      const ensure = Effect.fn("Skill.ensure")(function* () {
+        const cache = yield* InstanceState.get(state)
+        yield* Effect.promise(() => cache.ensure())
+        return cache
+      })
+
+      const get = Effect.fn("Skill.get")(function* (name: string) {
+        const cache = yield* ensure()
+        return cache.skills[name]
+      })
+
+      const all = Effect.fn("Skill.all")(function* () {
+        const cache = yield* ensure()
+        return Object.values(cache.skills)
+      })
+
+      const dirs = Effect.fn("Skill.dirs")(function* () {
+        const cache = yield* ensure()
+        return Array.from(cache.dirs)
+      })
+
+      const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
+        const cache = yield* ensure()
+        const list = Object.values(cache.skills).toSorted((a, b) => a.name.localeCompare(b.name))
+        if (!agent) return list
+        return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
+      })
+
+      return Service.of({ get, all, dirs, available })
+    }),
+  )
+
+  export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Discovery.defaultLayer))
+
+  export function fmt(list: Info[], opts: { verbose: boolean }) {
+    if (list.length === 0) return "No skills are currently available."
+
+    if (opts.verbose) {
+      return [
+        "<available_skills>",
+        ...list.flatMap((skill) => [
+          "  <skill>",
+          `    <name>${skill.name}</name>`,
+          `    <description>${skill.description}</description>`,
+          `    <location>${pathToFileURL(skill.location).href}</location>`,
+          "  </skill>",
+        ]),
+        "</available_skills>",
+      ].join("\n")
+    }
+
+    return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
+  }
+
+  const runPromise = makeRunPromise(Service, defaultLayer)
+
+  export async function get(name: string) {
+    return runPromise((skill) => skill.get(name))
+  }
+
+  export async function all() {
+    return runPromise((skill) => skill.all())
+  }
+
+  export async function dirs() {
+    return runPromise((skill) => skill.dirs())
+  }
+
+  export async function available(agent?: Agent.Info) {
+    return runPromise((skill) => skill.available(agent))
+  }
+}

+ 0 - 238
packages/opencode/src/skill/service.ts

@@ -1,238 +0,0 @@
-import os from "os"
-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 { Bus } from "@/bus"
-import { InstanceContext } from "@/effect/instance-context"
-import { Flag } from "@/flag/flag"
-import { Global } from "@/global"
-import { Permission } from "@/permission/service"
-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(),
-    location: z.string(),
-    content: z.string(),
-  })
-  export type Info = z.infer<typeof Info>
-
-  export const InvalidError = NamedError.create(
-    "SkillInvalidError",
-    z.object({
-      path: z.string(),
-      message: z.string().optional(),
-      issues: z.custom<z.core.$ZodIssue[]>().optional(),
-    }),
-  )
-
-  export const NameMismatchError = NamedError.create(
-    "SkillNameMismatchError",
-    z.object({
-      path: z.string(),
-      expected: z.string(),
-      actual: z.string(),
-    }),
-  )
-
-  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).toSorted((a, b) => a.name.localeCompare(b.name))
-        if (!agent) return list
-        return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
-      })
-
-      return Service.of({ get, all, dirs, available })
-    }),
-  ).pipe(Layer.fresh)
-
-  export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
-    Layer.provide(Discovery.defaultLayer),
-  )
-
-  export function fmt(list: Info[], opts: { verbose: boolean }) {
-    if (list.length === 0) return "No skills are currently available."
-
-    if (opts.verbose) {
-      return [
-        "<available_skills>",
-        ...list.flatMap((skill) => [
-          "  <skill>",
-          `    <name>${skill.name}</name>`,
-          `    <description>${skill.description}</description>`,
-          `    <location>${pathToFileURL(skill.location).href}</location>`,
-          "  </skill>",
-        ]),
-        "</available_skills>",
-      ].join("\n")
-    }
-
-    return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
-  }
-}

+ 0 - 35
packages/opencode/src/skill/skill.ts

@@ -1,35 +0,0 @@
-import { runPromiseInstance } from "@/effect/runtime"
-import type { Agent } from "@/agent/agent"
-import { Skill as S } from "./service"
-
-export namespace Skill {
-  export const Info = S.Info
-  export type Info = S.Info
-
-  export const InvalidError = S.InvalidError
-  export const NameMismatchError = S.NameMismatchError
-
-  export type Interface = S.Interface
-
-  export const Service = S.Service
-  export const layer = S.layer
-  export const defaultLayer = S.defaultLayer
-
-  export const fmt = S.fmt
-
-  export async function get(name: string) {
-    return runPromiseInstance(S.Service.use((skill) => skill.get(name)))
-  }
-
-  export async function all() {
-    return runPromiseInstance(S.Service.use((skill) => skill.all()))
-  }
-
-  export async function dirs() {
-    return runPromiseInstance(S.Service.use((skill) => skill.dirs()))
-  }
-
-  export async function available(agent?: Agent.Info) {
-    return runPromiseInstance(S.Service.use((skill) => skill.available(agent)))
-  }
-}

+ 369 - 17
packages/opencode/src/snapshot/index.ts

@@ -1,44 +1,396 @@
-import { runPromiseInstance } from "@/effect/runtime"
-import { Snapshot as S } from "./service"
+import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
+import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import path from "path"
+import z from "zod"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import { AppFileSystem } from "@/filesystem"
+import { Config } from "../config/config"
+import { Global } from "../global"
+import { Log } from "../util/log"
 
 export namespace Snapshot {
-  export const Patch = S.Patch
-  export type Patch = S.Patch
+  export const Patch = z.object({
+    hash: z.string(),
+    files: z.string().array(),
+  })
+  export type Patch = z.infer<typeof Patch>
 
-  export const FileDiff = S.FileDiff
-  export type FileDiff = S.FileDiff
+  export const FileDiff = z
+    .object({
+      file: z.string(),
+      before: z.string(),
+      after: z.string(),
+      additions: z.number(),
+      deletions: z.number(),
+      status: z.enum(["added", "deleted", "modified"]).optional(),
+    })
+    .meta({
+      ref: "FileDiff",
+    })
+  export type FileDiff = z.infer<typeof FileDiff>
 
-  export type Interface = S.Interface
+  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"]
 
-  export const Service = S.Service
-  export const layer = S.layer
-  export const defaultLayer = S.defaultLayer
+  interface GitResult {
+    readonly code: ChildProcessSpawner.ExitCode
+    readonly text: string
+    readonly stderr: string
+  }
+
+  type State = Omit<Interface, "init">
+
+  export interface Interface {
+    readonly init: () => Effect.Effect<void>
+    readonly cleanup: () => Effect.Effect<void>
+    readonly track: () => Effect.Effect<string | undefined>
+    readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
+    readonly restore: (snapshot: string) => Effect.Effect<void>
+    readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
+    readonly diff: (hash: string) => Effect.Effect<string>
+    readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
+
+  export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner> =
+    Layer.effect(
+      Service,
+      Effect.gen(function* () {
+        const fs = yield* AppFileSystem.Service
+        const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+        const state = yield* InstanceState.make<State>(
+          Effect.fn("Snapshot.state")(function* (ctx) {
+            const state = {
+              directory: ctx.directory,
+              worktree: ctx.worktree,
+              gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id),
+              vcs: ctx.project.vcs,
+            }
+
+            const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
+
+            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(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 } satisfies GitResult
+              },
+              Effect.scoped,
+              Effect.catch((err) =>
+                Effect.succeed({
+                  code: ChildProcessSpawner.ExitCode(1),
+                  text: "",
+                  stderr: String(err),
+                }),
+              ),
+            )
+
+            const exists = (file: string) => fs.exists(file).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 enabled = Effect.fnUntraced(function* () {
+              if (state.vcs !== "git") return false
+              return (yield* Effect.promise(() => Config.get())).snapshot !== false
+            })
+
+            const excludes = Effect.fnUntraced(function* () {
+              const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
+                cwd: state.worktree,
+              })
+              const file = result.text.trim()
+              if (!file) return
+              if (!(yield* exists(file))) return
+              return file
+            })
+
+            const sync = Effect.fnUntraced(function* () {
+              const file = yield* excludes()
+              const target = path.join(state.gitdir, "info", "exclude")
+              yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
+              if (!file) {
+                yield* fs.writeFileString(target, "").pipe(Effect.orDie)
+                return
+              }
+              yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
+            })
+
+            const add = Effect.fnUntraced(function* () {
+              yield* sync()
+              yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory })
+            })
+
+            const cleanup = Effect.fnUntraced(function* () {
+              if (!(yield* enabled())) return
+              if (!(yield* exists(state.gitdir))) return
+              const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
+              if (result.code !== 0) {
+                log.warn("cleanup failed", {
+                  exitCode: result.code,
+                  stderr: result.stderr,
+                })
+                return
+              }
+              log.info("cleanup", { prune })
+            })
+
+            const track = Effect.fnUntraced(function* () {
+              if (!(yield* enabled())) return
+              const existed = yield* exists(state.gitdir)
+              yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
+              if (!existed) {
+                yield* git(["init"], {
+                  env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
+                })
+                yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
+                yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
+                yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
+                yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
+                log.info("initialized")
+              }
+              yield* add()
+              const result = yield* git(args(["write-tree"]), { cwd: state.directory })
+              const hash = result.text.trim()
+              log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
+              return hash
+            })
+
+            const patch = Effect.fnUntraced(function* (hash: string) {
+              yield* add()
+              const result = yield* git(
+                [...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
+                {
+                  cwd: state.directory,
+                },
+              )
+              if (result.code !== 0) {
+                log.warn("failed to get diff", { hash, exitCode: result.code })
+                return { hash, files: [] }
+              }
+              return {
+                hash,
+                files: result.text
+                  .trim()
+                  .split("\n")
+                  .map((x) => x.trim())
+                  .filter(Boolean)
+                  .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
+              }
+            })
+
+            const restore = Effect.fnUntraced(function* (snapshot: string) {
+              log.info("restore", { commit: snapshot })
+              const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
+              if (result.code === 0) {
+                const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: state.worktree })
+                if (checkout.code === 0) return
+                log.error("failed to restore snapshot", {
+                  snapshot,
+                  exitCode: checkout.code,
+                  stderr: checkout.stderr,
+                })
+                return
+              }
+              log.error("failed to restore snapshot", {
+                snapshot,
+                exitCode: result.code,
+                stderr: result.stderr,
+              })
+            })
+
+            const revert = Effect.fnUntraced(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([...core, ...args(["checkout", item.hash, "--", file])], {
+                    cwd: state.worktree,
+                  })
+                  if (result.code !== 0) {
+                    const rel = path.relative(state.worktree, file)
+                    const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
+                      cwd: state.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* remove(file)
+                    }
+                  }
+                }
+              }
+            })
+
+            const diff = Effect.fnUntraced(function* (hash: string) {
+              yield* add()
+              const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
+                cwd: state.worktree,
+              })
+              if (result.code !== 0) {
+                log.warn("failed to get diff", {
+                  hash,
+                  exitCode: result.code,
+                  stderr: result.stderr,
+                })
+                return ""
+              }
+              return result.text.trim()
+            })
+
+            const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
+              const result: Snapshot.FileDiff[] = []
+              const status = new Map<string, "added" | "deleted" | "modified">()
+
+              const statuses = yield* git(
+                [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
+                { cwd: state.directory },
+              )
+
+              for (const line of statuses.text.trim().split("\n")) {
+                if (!line) continue
+                const [code, file] = line.split("\t")
+                if (!code || !file) continue
+                status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
+              }
+
+              const numstat = yield* git(
+                [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
+                {
+                  cwd: state.directory,
+                },
+              )
+
+              for (const line of numstat.text.trim().split("\n")) {
+                if (!line) continue
+                const [adds, dels, file] = line.split("\t")
+                if (!file) continue
+                const binary = adds === "-" && dels === "-"
+                const [before, after] = binary
+                  ? ["", ""]
+                  : yield* Effect.all(
+                      [
+                        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 additions = binary ? 0 : parseInt(adds)
+                const deletions = binary ? 0 : parseInt(dels)
+                result.push({
+                  file,
+                  before,
+                  after,
+                  additions: Number.isFinite(additions) ? additions : 0,
+                  deletions: Number.isFinite(deletions) ? deletions : 0,
+                  status: status.get(file) ?? "modified",
+                })
+              }
+
+              return result
+            })
+
+            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 { cleanup, track, patch, restore, revert, diff, diffFull }
+          }),
+        )
+
+        return Service.of({
+          init: Effect.fn("Snapshot.init")(function* () {
+            yield* InstanceState.get(state)
+          }),
+          cleanup: Effect.fn("Snapshot.cleanup")(function* () {
+            return yield* InstanceState.useEffect(state, (s) => s.cleanup())
+          }),
+          track: Effect.fn("Snapshot.track")(function* () {
+            return yield* InstanceState.useEffect(state, (s) => s.track())
+          }),
+          patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
+            return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
+          }),
+          restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
+            return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
+          }),
+          revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
+            return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
+          }),
+          diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
+            return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
+          }),
+          diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
+            return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
+          }),
+        })
+      }),
+    )
+
+  export const defaultLayer = layer.pipe(
+    Layer.provide(NodeChildProcessSpawner.layer),
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
+    Layer.provide(NodePath.layer),
+  )
+
+  const runPromise = makeRunPromise(Service, defaultLayer)
+
+  export async function init() {
+    return runPromise((svc) => svc.init())
+  }
 
   export async function cleanup() {
-    return runPromiseInstance(S.Service.use((svc) => svc.cleanup()))
+    return runPromise((svc) => svc.cleanup())
   }
 
   export async function track() {
-    return runPromiseInstance(S.Service.use((svc) => svc.track()))
+    return runPromise((svc) => svc.track())
   }
 
   export async function patch(hash: string) {
-    return runPromiseInstance(S.Service.use((svc) => svc.patch(hash)))
+    return runPromise((svc) => svc.patch(hash))
   }
 
   export async function restore(snapshot: string) {
-    return runPromiseInstance(S.Service.use((svc) => svc.restore(snapshot)))
+    return runPromise((svc) => svc.restore(snapshot))
   }
 
   export async function revert(patches: Patch[]) {
-    return runPromiseInstance(S.Service.use((svc) => svc.revert(patches)))
+    return runPromise((svc) => svc.revert(patches))
   }
 
   export async function diff(hash: string) {
-    return runPromiseInstance(S.Service.use((svc) => svc.diff(hash)))
+    return runPromise((svc) => svc.diff(hash))
   }
 
   export async function diffFull(from: string, to: string) {
-    return runPromiseInstance(S.Service.use((svc) => svc.diffFull(from, to)))
+    return runPromise((svc) => svc.diffFull(from, to))
   }
 }

+ 0 - 320
packages/opencode/src/snapshot/service.ts

@@ -1,320 +0,0 @@
-import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
-import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
-import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
-import path from "path"
-import z from "zod"
-import { InstanceContext } from "@/effect/instance-context"
-import { AppFileSystem } from "@/filesystem"
-import { Config } from "../config/config"
-import { Global } from "../global"
-import { Log } from "../util/log"
-
-export namespace Snapshot {
-  export const Patch = z.object({
-    hash: z.string(),
-    files: z.string().array(),
-  })
-  export type Patch = z.infer<typeof Patch>
-
-  export const FileDiff = z
-    .object({
-      file: z.string(),
-      before: z.string(),
-      after: z.string(),
-      additions: z.number(),
-      deletions: z.number(),
-      status: z.enum(["added", "deleted", "modified"]).optional(),
-    })
-    .meta({
-      ref: "FileDiff",
-    })
-  export type FileDiff = z.infer<typeof FileDiff>
-
-  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 interface Interface {
-    readonly cleanup: () => Effect.Effect<void>
-    readonly track: () => Effect.Effect<string | undefined>
-    readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
-    readonly restore: (snapshot: string) => Effect.Effect<void>
-    readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
-    readonly diff: (hash: string) => Effect.Effect<string>
-    readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
-  }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
-
-  export const layer: Layer.Layer<
-    Service,
-    never,
-    InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
-  > = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const ctx = yield* InstanceContext
-      const fs = yield* AppFileSystem.Service
-      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
-      const directory = ctx.directory
-      const worktree = ctx.worktree
-      const project = ctx.project
-      const gitdir = path.join(Global.Path.data, "snapshot", project.id)
-
-      const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
-
-      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(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 } satisfies GitResult
-        },
-        Effect.scoped,
-        Effect.catch((err) =>
-          Effect.succeed({
-            code: ChildProcessSpawner.ExitCode(1),
-            text: "",
-            stderr: String(err),
-          }),
-        ),
-      )
-
-      // Snapshot-specific error handling on top of AppFileSystem
-      const exists = (file: string) => fs.exists(file).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 enabled = Effect.fnUntraced(function* () {
-        if (project.vcs !== "git") return false
-        return (yield* Effect.promise(() => Config.get())).snapshot !== false
-      })
-
-      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
-        if (!(yield* exists(file))) return
-        return file
-      })
-
-      const sync = Effect.fnUntraced(function* () {
-        const file = yield* excludes()
-        const target = path.join(gitdir, "info", "exclude")
-        yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie)
-        if (!file) {
-          yield* fs.writeFileString(target, "").pipe(Effect.orDie)
-          return
-        }
-        yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
-      })
-
-      const add = Effect.fnUntraced(function* () {
-        yield* sync()
-        yield* git([...cfg, ...args(["add", "."])], { 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,
-            stderr: result.stderr,
-          })
-          return
-        }
-        log.info("cleanup", { prune })
-      })
-
-      const track = Effect.fn("Snapshot.track")(function* () {
-        if (!(yield* enabled())) return
-        const existed = yield* exists(gitdir)
-        yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
-        if (!existed) {
-          yield* git(["init"], {
-            env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
-          })
-          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(args(["write-tree"]), { cwd: directory })
-        const hash = result.text.trim()
-        log.info("tracking", { hash, cwd: directory, git: gitdir })
-        return hash
-      })
-
-      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: [] }
-        }
-        return {
-          hash,
-          files: result.text
-            .trim()
-            .split("\n")
-            .map((x) => x.trim())
-            .filter(Boolean)
-            .map((x) => path.join(worktree, x).replaceAll("\\", "/")),
-        }
-      })
-
-      const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
-        log.info("restore", { commit: snapshot })
-        const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
-        if (result.code === 0) {
-          const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
-          if (checkout.code === 0) return
-          log.error("failed to restore snapshot", {
-            snapshot,
-            exitCode: checkout.code,
-            stderr: checkout.stderr,
-          })
-          return
-        }
-        log.error("failed to restore snapshot", {
-          snapshot,
-          exitCode: result.code,
-          stderr: result.stderr,
-        })
-      })
-
-      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([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree })
-            if (result.code !== 0) {
-              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* remove(file)
-              }
-            }
-          }
-        }
-      })
-
-      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,
-            exitCode: result.code,
-            stderr: result.stderr,
-          })
-          return ""
-        }
-        return result.text.trim()
-      })
-
-      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(
-          [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
-          { cwd: directory },
-        )
-
-        for (const line of statuses.text.trim().split("\n")) {
-          if (!line) continue
-          const [code, file] = line.split("\t")
-          if (!code || !file) continue
-          status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
-        }
-
-        const numstat = yield* git(
-          [...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 [adds, dels, file] = line.split("\t")
-          if (!file) continue
-          const binary = adds === "-" && dels === "-"
-          const [before, after] = binary
-            ? ["", ""]
-            : yield* Effect.all(
-                [
-                  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 additions = binary ? 0 : parseInt(adds)
-          const deletions = binary ? 0 : parseInt(dels)
-          result.push({
-            file,
-            before,
-            after,
-            additions: Number.isFinite(additions) ? additions : 0,
-            deletions: Number.isFinite(deletions) ? deletions : 0,
-            status: status.get(file) ?? "modified",
-          })
-        }
-
-        return result
-      })
-
-      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 Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
-    }),
-  ).pipe(Layer.fresh)
-
-  export const defaultLayer = layer.pipe(
-    Layer.provide(NodeChildProcessSpawner.layer),
-    Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
-    Layer.provide(NodePath.layer),
-  )
-}

+ 1 - 1
packages/opencode/src/tool/apply_patch.ts

@@ -12,7 +12,7 @@ import { trimDiff } from "./edit"
 import { LSP } from "../lsp"
 import { Filesystem } from "../util/filesystem"
 import DESCRIPTION from "./apply_patch.txt"
-import { File } from "../file/service"
+import { File } from "../file"
 
 const PatchParams = z.object({
   patchText: z.string().describe("The full patch text that describes all changes to be made"),

+ 2 - 2
packages/opencode/src/tool/edit.ts

@@ -9,13 +9,13 @@ import { Tool } from "./tool"
 import { LSP } from "../lsp"
 import { createTwoFilesPatch, diffLines } from "diff"
 import DESCRIPTION from "./edit.txt"
-import { File } from "../file/service"
+import { File } from "../file"
 import { FileWatcher } from "../file/watcher"
 import { Bus } from "../bus"
 import { FileTime } from "../file/time"
 import { Filesystem } from "../util/filesystem"
 import { Instance } from "../project/instance"
-import { Snapshot } from "@/snapshot/service"
+import { Snapshot } from "@/snapshot"
 import { assertExternalDirectory } from "./external-directory"
 
 const MAX_DIAGNOSTICS_PER_FILE = 20

+ 2 - 3
packages/opencode/src/tool/question.ts

@@ -1,7 +1,6 @@
 import z from "zod"
 import { Tool } from "./tool"
-import { Question } from "../question/service"
-import { Question as QuestionApi } from "../question"
+import { Question } from "../question"
 import DESCRIPTION from "./question.txt"
 
 export const QuestionTool = Tool.define("question", {
@@ -10,7 +9,7 @@ export const QuestionTool = Tool.define("question", {
     questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"),
   }),
   async execute(params, ctx) {
-    const answers = await QuestionApi.ask({
+    const answers = await Question.ask({
       sessionID: ctx.sessionID,
       questions: params.questions,
       tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,

+ 2 - 2
packages/opencode/src/tool/task.ts

@@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt"
 import { iife } from "@/util/iife"
 import { defer } from "@/util/defer"
 import { Config } from "../config/config"
-import { Permission as PermissionNext } from "@/permission/service"
+import { Permission } from "@/permission"
 
 const parameters = z.object({
   description: z.string().describe("A short (3-5 words) description of the task"),
@@ -31,7 +31,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
   // Filter agents by permissions if agent provided
   const caller = ctx?.agent
   const accessibleAgents = caller
-    ? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny")
+    ? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
     : agents
   const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
 

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

@@ -1,7 +1,7 @@
 import z from "zod"
 import type { MessageV2 } from "../session/message-v2"
 import type { Agent } from "../agent/agent"
-import type { Permission as PermissionNext } from "../permission/service"
+import type { Permission } from "../permission"
 import type { SessionID, MessageID } from "../session/schema"
 import { Truncate } from "./truncate"
 
@@ -23,7 +23,7 @@ export namespace Tool {
     extra?: { [key: string]: any }
     messages: MessageV2.WithParts[]
     metadata(input: { title?: string; metadata?: M }): void
-    ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
+    ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
   }
   export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
     id: string

+ 0 - 137
packages/opencode/src/tool/truncate-effect.ts

@@ -1,137 +0,0 @@
-import { NodePath } from "@effect/platform-node"
-import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
-import path from "path"
-import type { Agent } from "../agent/agent"
-import { AppFileSystem } from "@/filesystem"
-import { evaluate } from "@/permission/evaluate"
-import { Identifier } from "../id/id"
-import { Log } from "../util/log"
-import { ToolID } from "./schema"
-import { TRUNCATION_DIR } from "./truncation-dir"
-
-export namespace Truncate {
-  const log = Log.create({ service: "truncation" })
-  const RETENTION = Duration.days(7)
-
-  export const MAX_LINES = 2000
-  export const MAX_BYTES = 50 * 1024
-  export const DIR = TRUNCATION_DIR
-  export const GLOB = path.join(TRUNCATION_DIR, "*")
-
-  export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
-
-  export interface Options {
-    maxLines?: number
-    maxBytes?: number
-    direction?: "head" | "tail"
-  }
-
-  function hasTaskTool(agent?: Agent.Info) {
-    if (!agent?.permission) return false
-    return evaluate("task", "*", agent.permission).action !== "deny"
-  }
-
-  export interface Interface {
-    readonly cleanup: () => Effect.Effect<void>
-    /**
-     * Returns output unchanged when it fits within the limits, otherwise writes the full text
-     * to the truncation directory and returns a preview plus a hint to inspect the saved file.
-     */
-    readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
-  }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const fs = yield* AppFileSystem.Service
-
-      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_"))),
-          Effect.catch(() => Effect.succeed([])),
-        )
-        for (const entry of entries) {
-          if (Identifier.timestamp(entry) >= cutoff) continue
-          yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
-        }
-      })
-
-      const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
-        const maxLines = options.maxLines ?? MAX_LINES
-        const maxBytes = options.maxBytes ?? MAX_BYTES
-        const direction = options.direction ?? "head"
-        const lines = text.split("\n")
-        const totalBytes = Buffer.byteLength(text, "utf-8")
-
-        if (lines.length <= maxLines && totalBytes <= maxBytes) {
-          return { content: text, truncated: false } as const
-        }
-
-        const out: string[] = []
-        let i = 0
-        let bytes = 0
-        let hitBytes = false
-
-        if (direction === "head") {
-          for (i = 0; i < lines.length && i < maxLines; i++) {
-            const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
-            if (bytes + size > maxBytes) {
-              hitBytes = true
-              break
-            }
-            out.push(lines[i])
-            bytes += size
-          }
-        } else {
-          for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
-            const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
-            if (bytes + size > maxBytes) {
-              hitBytes = true
-              break
-            }
-            out.unshift(lines[i])
-            bytes += size
-          }
-        }
-
-        const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
-        const unit = hitBytes ? "bytes" : "lines"
-        const preview = out.join("\n")
-        const file = path.join(TRUNCATION_DIR, ToolID.ascending())
-
-        yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
-        yield* fs.writeFileString(file, text).pipe(Effect.orDie)
-
-        const hint = hasTaskTool(agent)
-          ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
-          : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
-
-        return {
-          content:
-            direction === "head"
-              ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
-              : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
-          truncated: true,
-          outputPath: file,
-        } as const
-      })
-
-      yield* cleanup().pipe(
-        Effect.catchCause((cause) => {
-          log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
-          return Effect.void
-        }),
-        Effect.repeat(Schedule.spaced(Duration.hours(1))),
-        Effect.delay(Duration.minutes(1)),
-        Effect.forkScoped,
-      )
-
-      return Service.of({ cleanup, output })
-    }),
-  )
-
-  export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
-}

+ 135 - 9
packages/opencode/src/tool/truncate.ts

@@ -1,18 +1,144 @@
+import { NodePath } from "@effect/platform-node"
+import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
+import path from "path"
 import type { Agent } from "../agent/agent"
-import { runtime } from "@/effect/runtime"
-import { Truncate as S } from "./truncate-effect"
+import { makeRunPromise } from "@/effect/run-service"
+import { AppFileSystem } from "@/filesystem"
+import { evaluate } from "@/permission/evaluate"
+import { Identifier } from "../id/id"
+import { Log } from "../util/log"
+import { ToolID } from "./schema"
+import { TRUNCATION_DIR } from "./truncation-dir"
 
 export namespace Truncate {
-  export const MAX_LINES = S.MAX_LINES
-  export const MAX_BYTES = S.MAX_BYTES
-  export const DIR = S.DIR
-  export const GLOB = S.GLOB
+  const log = Log.create({ service: "truncation" })
+  const RETENTION = Duration.days(7)
 
-  export type Result = S.Result
+  export const MAX_LINES = 2000
+  export const MAX_BYTES = 50 * 1024
+  export const DIR = TRUNCATION_DIR
+  export const GLOB = path.join(TRUNCATION_DIR, "*")
 
-  export type Options = S.Options
+  export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
+
+  export interface Options {
+    maxLines?: number
+    maxBytes?: number
+    direction?: "head" | "tail"
+  }
+
+  function hasTaskTool(agent?: Agent.Info) {
+    if (!agent?.permission) return false
+    return evaluate("task", "*", agent.permission).action !== "deny"
+  }
+
+  export interface Interface {
+    readonly cleanup: () => Effect.Effect<void>
+    /**
+     * Returns output unchanged when it fits within the limits, otherwise writes the full text
+     * to the truncation directory and returns a preview plus a hint to inspect the saved file.
+     */
+    readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const fs = yield* AppFileSystem.Service
+
+      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_"))),
+          Effect.catch(() => Effect.succeed([])),
+        )
+        for (const entry of entries) {
+          if (Identifier.timestamp(entry) >= cutoff) continue
+          yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
+        }
+      })
+
+      const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
+        const maxLines = options.maxLines ?? MAX_LINES
+        const maxBytes = options.maxBytes ?? MAX_BYTES
+        const direction = options.direction ?? "head"
+        const lines = text.split("\n")
+        const totalBytes = Buffer.byteLength(text, "utf-8")
+
+        if (lines.length <= maxLines && totalBytes <= maxBytes) {
+          return { content: text, truncated: false } as const
+        }
+
+        const out: string[] = []
+        let i = 0
+        let bytes = 0
+        let hitBytes = false
+
+        if (direction === "head") {
+          for (i = 0; i < lines.length && i < maxLines; i++) {
+            const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
+            if (bytes + size > maxBytes) {
+              hitBytes = true
+              break
+            }
+            out.push(lines[i])
+            bytes += size
+          }
+        } else {
+          for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
+            const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
+            if (bytes + size > maxBytes) {
+              hitBytes = true
+              break
+            }
+            out.unshift(lines[i])
+            bytes += size
+          }
+        }
+
+        const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
+        const unit = hitBytes ? "bytes" : "lines"
+        const preview = out.join("\n")
+        const file = path.join(TRUNCATION_DIR, ToolID.ascending())
+
+        yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
+        yield* fs.writeFileString(file, text).pipe(Effect.orDie)
+
+        const hint = hasTaskTool(agent)
+          ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
+          : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
+
+        return {
+          content:
+            direction === "head"
+              ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
+              : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
+          truncated: true,
+          outputPath: file,
+        } as const
+      })
+
+      yield* cleanup().pipe(
+        Effect.catchCause((cause) => {
+          log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
+          return Effect.void
+        }),
+        Effect.repeat(Schedule.spaced(Duration.hours(1))),
+        Effect.delay(Duration.minutes(1)),
+        Effect.forkScoped,
+      )
+
+      return Service.of({ cleanup, output })
+    }),
+  )
+
+  export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
+
+  const runPromise = makeRunPromise(Service, defaultLayer)
 
   export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
-    return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent)))
+    return runPromise((s) => s.output(text, options, agent))
   }
 }

+ 1 - 1
packages/opencode/src/tool/write.ts

@@ -5,7 +5,7 @@ import { LSP } from "../lsp"
 import { createTwoFilesPatch } from "diff"
 import DESCRIPTION from "./write.txt"
 import { Bus } from "../bus"
-import { File } from "../file/service"
+import { File } from "../file"
 import { FileWatcher } from "../file/watcher"
 import { FileTime } from "../file/time"
 import { Filesystem } from "../util/filesystem"

+ 1 - 1
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 { Account } from "../../src/account/effect"
+import { Account } from "../../src/account"
 import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
 import { Database } from "../../src/storage/db"
 import { testEffect } from "../lib/effect"

+ 21 - 17
packages/opencode/test/agent/agent.test.ts

@@ -1,16 +1,20 @@
-import { test, expect } from "bun:test"
+import { afterEach, test, expect } from "bun:test"
 import path from "path"
 import { tmpdir } from "../fixture/fixture"
 import { Instance } from "../../src/project/instance"
 import { Agent } from "../../src/agent/agent"
-import { PermissionNext } from "../../src/permission"
+import { Permission } from "../../src/permission"
 
 // Helper to evaluate permission for a tool with wildcard pattern
-function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
+function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
   if (!agent) return undefined
-  return PermissionNext.evaluate(permission, "*", agent.permission).action
+  return Permission.evaluate(permission, "*", agent.permission).action
 }
 
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
 test("returns default native agents when no config", async () => {
   await using tmp = await tmpdir()
   await Instance.provide({
@@ -54,7 +58,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => {
       // Wildcard is denied
       expect(evalPerm(plan, "edit")).toBe("deny")
       // But specific path is allowed
-      expect(PermissionNext.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
+      expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
     },
   })
 })
@@ -83,8 +87,8 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy
     fn: async () => {
       const explore = await Agent.get("explore")
       expect(explore).toBeDefined()
-      expect(PermissionNext.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
-      expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
+      expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
+      expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
     },
   })
 })
@@ -216,7 +220,7 @@ test("agent permission config merges with defaults", async () => {
       const build = await Agent.get("build")
       expect(build).toBeDefined()
       // Specific pattern is denied
-      expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
+      expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
       // Edit still allowed
       expect(evalPerm(build, "edit")).toBe("allow")
     },
@@ -501,9 +505,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
     directory: tmp.path,
     fn: async () => {
       const build = await Agent.get("build")
-      expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
-      expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
-      expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
+      expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
+      expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
+      expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
     },
   })
 })
@@ -525,9 +529,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
     directory: tmp.path,
     fn: async () => {
       const build = await Agent.get("build")
-      expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
-      expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
-      expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
+      expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
+      expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
+      expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
     },
   })
 })
@@ -548,8 +552,8 @@ test("explicit Truncate.GLOB deny is respected", async () => {
     directory: tmp.path,
     fn: async () => {
       const build = await Agent.get("build")
-      expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
-      expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
+      expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
+      expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
     },
   })
 })
@@ -582,7 +586,7 @@ description: Permission skill.
         const build = await Agent.get("build")
         const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
         const target = path.join(skillDir, "reference", "notes.md")
-        expect(PermissionNext.evaluate("external_directory", target, build!.permission).action).toBe("allow")
+        expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow")
       },
     })
   } finally {

+ 1 - 1
packages/opencode/test/config/config.test.ts

@@ -251,7 +251,7 @@ test("resolves env templates in account config with account token", async () =>
   const originalToken = Account.token
   const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
 
-  Account.active = mock(() => ({
+  Account.active = mock(async () => ({
     id: AccountID.make("account-1"),
     email: "[email protected]",
     url: "https://control.example.com",

+ 384 - 0
packages/opencode/test/effect/instance-state.test.ts

@@ -0,0 +1,384 @@
+import { afterEach, expect, test } from "bun:test"
+import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect"
+import { InstanceState } from "../../src/effect/instance-state"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+async function access<A, E>(state: InstanceState<A, E>, dir: string) {
+  return Instance.provide({
+    directory: dir,
+    fn: () => Effect.runPromise(InstanceState.get(state)),
+  })
+}
+
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
+test("InstanceState caches values per directory", async () => {
+  await using tmp = await tmpdir()
+  let n = 0
+
+  await Effect.runPromise(
+    Effect.scoped(
+      Effect.gen(function* () {
+        const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
+
+        const a = yield* Effect.promise(() => access(state, tmp.path))
+        const b = yield* Effect.promise(() => access(state, tmp.path))
+
+        expect(a).toBe(b)
+        expect(n).toBe(1)
+      }),
+    ),
+  )
+})
+
+test("InstanceState isolates directories", async () => {
+  await using one = await tmpdir()
+  await using two = await tmpdir()
+  let n = 0
+
+  await Effect.runPromise(
+    Effect.scoped(
+      Effect.gen(function* () {
+        const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
+
+        const a = yield* Effect.promise(() => access(state, one.path))
+        const b = yield* Effect.promise(() => access(state, two.path))
+        const c = yield* Effect.promise(() => access(state, one.path))
+
+        expect(a).toBe(c)
+        expect(a).not.toBe(b)
+        expect(n).toBe(2)
+      }),
+    ),
+  )
+})
+
+test("InstanceState invalidates on reload", async () => {
+  await using tmp = await tmpdir()
+  const seen: string[] = []
+  let n = 0
+
+  await Effect.runPromise(
+    Effect.scoped(
+      Effect.gen(function* () {
+        const state = yield* InstanceState.make(() =>
+          Effect.acquireRelease(
+            Effect.sync(() => ({ n: ++n })),
+            (value) =>
+              Effect.sync(() => {
+                seen.push(String(value.n))
+              }),
+          ),
+        )
+
+        const a = yield* Effect.promise(() => access(state, tmp.path))
+        yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
+        const b = yield* Effect.promise(() => access(state, tmp.path))
+
+        expect(a).not.toBe(b)
+        expect(seen).toEqual(["1"])
+      }),
+    ),
+  )
+})
+
+test("InstanceState invalidates on disposeAll", async () => {
+  await using one = await tmpdir()
+  await using two = await tmpdir()
+  const seen: string[] = []
+
+  await Effect.runPromise(
+    Effect.scoped(
+      Effect.gen(function* () {
+        const state = yield* InstanceState.make((ctx) =>
+          Effect.acquireRelease(
+            Effect.sync(() => ({ dir: ctx.directory })),
+            (value) =>
+              Effect.sync(() => {
+                seen.push(value.dir)
+              }),
+          ),
+        )
+
+        yield* Effect.promise(() => access(state, one.path))
+        yield* Effect.promise(() => access(state, two.path))
+        yield* Effect.promise(() => Instance.disposeAll())
+
+        expect(seen.sort()).toEqual([one.path, two.path].sort())
+      }),
+    ),
+  )
+})
+
+test("InstanceState.get reads the current directory lazily", async () => {
+  await using one = await tmpdir()
+  await using two = await tmpdir()
+
+  interface Api {
+    readonly get: () => Effect.Effect<string>
+  }
+
+  class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateLazy") {
+    static readonly layer = Layer.effect(
+      Test,
+      Effect.gen(function* () {
+        const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
+        const get = InstanceState.get(state)
+
+        return Test.of({
+          get: Effect.fn("Test.get")(function* () {
+            return yield* get
+          }),
+        })
+      }),
+    )
+  }
+
+  const rt = ManagedRuntime.make(Test.layer)
+
+  try {
+    const a = await Instance.provide({
+      directory: one.path,
+      fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+    })
+    const b = await Instance.provide({
+      directory: two.path,
+      fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+    })
+
+    expect(a).toBe(one.path)
+    expect(b).toBe(two.path)
+  } finally {
+    await rt.dispose()
+  }
+})
+
+test("InstanceState preserves directory across async boundaries", async () => {
+  await using one = await tmpdir({ git: true })
+  await using two = await tmpdir({ git: true })
+  await using three = await tmpdir({ git: true })
+
+  interface Api {
+    readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }>
+  }
+
+  class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateAsync") {
+    static readonly layer = Layer.effect(
+      Test,
+      Effect.gen(function* () {
+        const state = yield* InstanceState.make((ctx) =>
+          Effect.sync(() => ({
+            directory: ctx.directory,
+            worktree: ctx.worktree,
+            project: ctx.project.id,
+          })),
+        )
+
+        return Test.of({
+          get: Effect.fn("Test.get")(function* () {
+            yield* Effect.promise(() => Bun.sleep(1))
+            yield* Effect.sleep(Duration.millis(1))
+            for (let i = 0; i < 100; i++) {
+              yield* Effect.yieldNow
+            }
+            for (let i = 0; i < 100; i++) {
+              yield* Effect.promise(() => Promise.resolve())
+            }
+            yield* Effect.sleep(Duration.millis(2))
+            yield* Effect.promise(() => Bun.sleep(1))
+            return yield* InstanceState.get(state)
+          }),
+        })
+      }),
+    )
+  }
+
+  const rt = ManagedRuntime.make(Test.layer)
+
+  try {
+    const [a, b, c] = await Promise.all([
+      Instance.provide({
+        directory: one.path,
+        fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+      }),
+      Instance.provide({
+        directory: two.path,
+        fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+      }),
+      Instance.provide({
+        directory: three.path,
+        fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+      }),
+    ])
+
+    expect(a).toEqual({ directory: one.path, worktree: one.path, project: a.project })
+    expect(b).toEqual({ directory: two.path, worktree: two.path, project: b.project })
+    expect(c).toEqual({ directory: three.path, worktree: three.path, project: c.project })
+    expect(a.project).not.toBe(b.project)
+    expect(a.project).not.toBe(c.project)
+    expect(b.project).not.toBe(c.project)
+  } finally {
+    await rt.dispose()
+  }
+})
+
+test("InstanceState survives high-contention concurrent access", async () => {
+  const N = 20
+  const dirs = await Promise.all(Array.from({ length: N }, () => tmpdir()))
+
+  interface Api {
+    readonly get: () => Effect.Effect<string>
+  }
+
+  class Test extends ServiceMap.Service<Test, Api>()("@test/HighContention") {
+    static readonly layer = Layer.effect(
+      Test,
+      Effect.gen(function* () {
+        const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
+
+        return Test.of({
+          get: Effect.fn("Test.get")(function* () {
+            // Interleave many async hops to maximize chance of ALS corruption
+            for (let i = 0; i < 10; i++) {
+              yield* Effect.promise(() => Bun.sleep(Math.random() * 3))
+              yield* Effect.yieldNow
+              yield* Effect.promise(() => Promise.resolve())
+            }
+            return yield* InstanceState.get(state)
+          }),
+        })
+      }),
+    )
+  }
+
+  const rt = ManagedRuntime.make(Test.layer)
+
+  try {
+    const results = await Promise.all(
+      dirs.map((d) =>
+        Instance.provide({
+          directory: d.path,
+          fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+        }),
+      ),
+    )
+
+    for (let i = 0; i < N; i++) {
+      expect(results[i]).toBe(dirs[i].path)
+    }
+  } finally {
+    await rt.dispose()
+    for (const d of dirs) await d[Symbol.asyncDispose]()
+  }
+})
+
+test("InstanceState correct after interleaved init and dispose", async () => {
+  await using one = await tmpdir()
+  await using two = await tmpdir()
+
+  interface Api {
+    readonly get: () => Effect.Effect<string>
+  }
+
+  class Test extends ServiceMap.Service<Test, Api>()("@test/InterleavedDispose") {
+    static readonly layer = Layer.effect(
+      Test,
+      Effect.gen(function* () {
+        const state = yield* InstanceState.make((ctx) =>
+          Effect.promise(async () => {
+            await Bun.sleep(5) // slow init
+            return ctx.directory
+          }),
+        )
+
+        return Test.of({
+          get: Effect.fn("Test.get")(function* () {
+            return yield* InstanceState.get(state)
+          }),
+        })
+      }),
+    )
+  }
+
+  const rt = ManagedRuntime.make(Test.layer)
+
+  try {
+    // Init both directories
+    const a = await Instance.provide({
+      directory: one.path,
+      fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+    })
+    expect(a).toBe(one.path)
+
+    // Dispose one directory, access the other concurrently
+    const [, b] = await Promise.all([
+      Instance.reload({ directory: one.path }),
+      Instance.provide({
+        directory: two.path,
+        fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+      }),
+    ])
+    expect(b).toBe(two.path)
+
+    // Re-access disposed directory - should get fresh state
+    const c = await Instance.provide({
+      directory: one.path,
+      fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+    })
+    expect(c).toBe(one.path)
+  } finally {
+    await rt.dispose()
+  }
+})
+
+test("InstanceState mutation in one directory does not leak to another", async () => {
+  await using one = await tmpdir()
+  await using two = await tmpdir()
+
+  await Effect.runPromise(
+    Effect.scoped(
+      Effect.gen(function* () {
+        const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 })))
+
+        // Mutate state in directory one
+        const s1 = yield* Effect.promise(() => access(state, one.path))
+        s1.count = 42
+
+        // Access directory two — should be independent
+        const s2 = yield* Effect.promise(() => access(state, two.path))
+        expect(s2.count).toBe(0)
+
+        // Confirm directory one still has the mutation
+        const s1again = yield* Effect.promise(() => access(state, one.path))
+        expect(s1again.count).toBe(42)
+        expect(s1again).toBe(s1) // same reference
+      }),
+    ),
+  )
+})
+
+test("InstanceState dedupes concurrent lookups", async () => {
+  await using tmp = await tmpdir()
+  let n = 0
+
+  await Effect.runPromise(
+    Effect.scoped(
+      Effect.gen(function* () {
+        const state = yield* InstanceState.make(() =>
+          Effect.promise(async () => {
+            n += 1
+            await Bun.sleep(10)
+            return { n }
+          }),
+        )
+
+        const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
+        expect(a).toBe(b)
+        expect(n).toBe(1)
+      }),
+    ),
+  )
+})

+ 46 - 0
packages/opencode/test/effect/run-service.test.ts

@@ -0,0 +1,46 @@
+import { expect, test } from "bun:test"
+import { Effect, Layer, ServiceMap } from "effect"
+import { makeRunPromise } from "../../src/effect/run-service"
+
+class Shared extends ServiceMap.Service<Shared, { readonly id: number }>()("@test/Shared") {}
+
+test("makeRunPromise shares dependent layers through the shared memo map", async () => {
+  let n = 0
+
+  const shared = Layer.effect(
+    Shared,
+    Effect.sync(() => {
+      n += 1
+      return Shared.of({ id: n })
+    }),
+  )
+
+  class One extends ServiceMap.Service<One, { readonly get: () => Effect.Effect<number> }>()("@test/One") {}
+  const one = Layer.effect(
+    One,
+    Effect.gen(function* () {
+      const svc = yield* Shared
+      return One.of({
+        get: Effect.fn("One.get")(() => Effect.succeed(svc.id)),
+      })
+    }),
+  ).pipe(Layer.provide(shared))
+
+  class Two extends ServiceMap.Service<Two, { readonly get: () => Effect.Effect<number> }>()("@test/Two") {}
+  const two = Layer.effect(
+    Two,
+    Effect.gen(function* () {
+      const svc = yield* Shared
+      return Two.of({
+        get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)),
+      })
+    }),
+  ).pipe(Layer.provide(shared))
+
+  const runOne = makeRunPromise(One, one)
+  const runTwo = makeRunPromise(Two, two)
+
+  expect(await runOne((svc) => svc.get())).toBe(1)
+  expect(await runTwo((svc) => svc.get())).toBe(1)
+  expect(n).toBe(1)
+})

+ 0 - 128
packages/opencode/test/effect/runtime.test.ts

@@ -1,128 +0,0 @@
-import { afterEach, describe, expect, test } from "bun:test"
-import { Effect } from "effect"
-import { runtime, runPromiseInstance } from "../../src/effect/runtime"
-import { Auth } from "../../src/auth/effect"
-import { Instances } from "../../src/effect/instances"
-import { Instance } from "../../src/project/instance"
-import { ProviderAuth } from "../../src/provider/auth"
-import { Vcs } from "../../src/project/vcs"
-import { Question } from "../../src/question"
-import { tmpdir } from "../fixture/fixture"
-
-/**
- * Integration tests for the Effect runtime and LayerMap-based instance system.
- *
- * Each instance service layer has `.pipe(Layer.fresh)` at its definition site
- * so it is always rebuilt per directory, while shared dependencies are provided
- * outside the fresh boundary and remain memoizable.
- *
- * These tests verify the invariants using object identity (===) on the real
- * production services — not mock services or return-value checks.
- */
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-const grabInstance = (service: any) => runPromiseInstance(service.use(Effect.succeed))
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-const grabGlobal = (service: any) => runtime.runPromise(service.use(Effect.succeed))
-
-describe("effect/runtime", () => {
-  afterEach(async () => {
-    await Instance.disposeAll()
-  })
-
-  test("global services are shared across directories", async () => {
-    await using one = await tmpdir({ git: true })
-    await using two = await tmpdir({ git: true })
-
-    // Auth is a global service — it should be the exact same object
-    // regardless of which directory we're in.
-    const authOne = await Instance.provide({
-      directory: one.path,
-      fn: () => grabGlobal(Auth.Service),
-    })
-
-    const authTwo = await Instance.provide({
-      directory: two.path,
-      fn: () => grabGlobal(Auth.Service),
-    })
-
-    expect(authOne).toBe(authTwo)
-  })
-
-  test("instance services with global deps share the global (ProviderAuth → Auth)", async () => {
-    await using one = await tmpdir({ git: true })
-    await using two = await tmpdir({ git: true })
-
-    // ProviderAuth depends on Auth via defaultLayer.
-    // The instance service itself should be different per directory,
-    // but the underlying Auth should be shared.
-    const paOne = await Instance.provide({
-      directory: one.path,
-      fn: () => grabInstance(ProviderAuth.Service),
-    })
-
-    const paTwo = await Instance.provide({
-      directory: two.path,
-      fn: () => grabInstance(ProviderAuth.Service),
-    })
-
-    // Different directories → different ProviderAuth instances.
-    expect(paOne).not.toBe(paTwo)
-
-    // But the global Auth is the same object in both.
-    const authOne = await Instance.provide({
-      directory: one.path,
-      fn: () => grabGlobal(Auth.Service),
-    })
-    const authTwo = await Instance.provide({
-      directory: two.path,
-      fn: () => grabGlobal(Auth.Service),
-    })
-    expect(authOne).toBe(authTwo)
-  })
-
-  test("instance services are shared within the same directory", async () => {
-    await using tmp = await tmpdir({ git: true })
-
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        expect(await grabInstance(Vcs.Service)).toBe(await grabInstance(Vcs.Service))
-        expect(await grabInstance(Question.Service)).toBe(await grabInstance(Question.Service))
-      },
-    })
-  })
-
-  test("different directories get different service instances", async () => {
-    await using one = await tmpdir({ git: true })
-    await using two = await tmpdir({ git: true })
-
-    const vcsOne = await Instance.provide({
-      directory: one.path,
-      fn: () => grabInstance(Vcs.Service),
-    })
-
-    const vcsTwo = await Instance.provide({
-      directory: two.path,
-      fn: () => grabInstance(Vcs.Service),
-    })
-
-    expect(vcsOne).not.toBe(vcsTwo)
-  })
-
-  test("disposal rebuilds services with a new instance", async () => {
-    await using tmp = await tmpdir({ git: true })
-
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        const before = await grabInstance(Question.Service)
-
-        await runtime.runPromise(Instances.use((map) => map.invalidate(Instance.directory)))
-
-        const after = await grabInstance(Question.Service)
-        expect(after).not.toBe(before)
-      },
-    })
-  })
-})

+ 95 - 1
packages/opencode/test/file/index.test.ts

@@ -1,4 +1,4 @@
-import { describe, test, expect } from "bun:test"
+import { afterEach, describe, test, expect } from "bun:test"
 import { $ } from "bun"
 import path from "path"
 import fs from "fs/promises"
@@ -7,6 +7,10 @@ import { Instance } from "../../src/project/instance"
 import { Filesystem } from "../../src/util/filesystem"
 import { tmpdir } from "../fixture/fixture"
 
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
 describe("file/index Filesystem patterns", () => {
   describe("File.read() - text content", () => {
     test("reads text file via Filesystem.readText()", async () => {
@@ -689,6 +693,18 @@ describe("file/index Filesystem patterns", () => {
       })
     })
 
+    test("search works before explicit init", async () => {
+      await using tmp = await setupSearchableRepo()
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const result = await File.search({ query: "main", type: "file" })
+          expect(result.some((f) => f.includes("main"))).toBe(true)
+        },
+      })
+    })
+
     test("empty query returns dirs sorted with hidden last", async () => {
       await using tmp = await setupSearchableRepo()
 
@@ -785,6 +801,23 @@ describe("file/index Filesystem patterns", () => {
         },
       })
     })
+
+    test("search refreshes after init when files change", async () => {
+      await using tmp = await setupSearchableRepo()
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          await File.init()
+          expect(await File.search({ query: "fresh", type: "file" })).toEqual([])
+
+          await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8")
+
+          const result = await File.search({ query: "fresh", type: "file" })
+          expect(result).toContain("fresh.ts")
+        },
+      })
+    })
   })
 
   describe("File.read() - diff/patch", () => {
@@ -849,4 +882,65 @@ describe("file/index Filesystem patterns", () => {
       })
     })
   })
+
+  describe("InstanceState isolation", () => {
+    test("two directories get independent file caches", async () => {
+      await using one = await tmpdir({ git: true })
+      await using two = await tmpdir({ git: true })
+      await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8")
+      await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8")
+
+      await Instance.provide({
+        directory: one.path,
+        fn: async () => {
+          await File.init()
+          const results = await File.search({ query: "a.ts", type: "file" })
+          expect(results).toContain("a.ts")
+          const results2 = await File.search({ query: "b.ts", type: "file" })
+          expect(results2).not.toContain("b.ts")
+        },
+      })
+
+      await Instance.provide({
+        directory: two.path,
+        fn: async () => {
+          await File.init()
+          const results = await File.search({ query: "b.ts", type: "file" })
+          expect(results).toContain("b.ts")
+          const results2 = await File.search({ query: "a.ts", type: "file" })
+          expect(results2).not.toContain("a.ts")
+        },
+      })
+    })
+
+    test("disposal gives fresh state on next access", async () => {
+      await using tmp = await tmpdir({ git: true })
+      await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          await File.init()
+          const results = await File.search({ query: "before", type: "file" })
+          expect(results).toContain("before.ts")
+        },
+      })
+
+      await Instance.disposeAll()
+
+      await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8")
+      await fs.rm(path.join(tmp.path, "before.ts"))
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          await File.init()
+          const results = await File.search({ query: "after", type: "file" })
+          expect(results).toContain("after.ts")
+          const stale = await File.search({ query: "before", type: "file" })
+          expect(stale).not.toContain("before.ts")
+        },
+      })
+    })
+  })
 })

+ 25 - 1
packages/opencode/test/file/time.test.ts

@@ -7,7 +7,9 @@ import { SessionID } from "../../src/session/schema"
 import { Filesystem } from "../../src/util/filesystem"
 import { tmpdir } from "../fixture/fixture"
 
-afterEach(() => Instance.disposeAll())
+afterEach(async () => {
+  await Instance.disposeAll()
+})
 
 async function touch(file: string, time: number) {
   const date = new Date(time)
@@ -84,6 +86,28 @@ describe("file/time", () => {
         },
       })
     })
+
+    test("isolates reads by directory", async () => {
+      await using one = await tmpdir()
+      await using two = await tmpdir()
+      await using shared = await tmpdir()
+      const filepath = path.join(shared.path, "file.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
+
+      await Instance.provide({
+        directory: one.path,
+        fn: async () => {
+          await FileTime.read(sessionID, filepath)
+        },
+      })
+
+      await Instance.provide({
+        directory: two.path,
+        fn: async () => {
+          expect(await FileTime.get(sessionID, filepath)).toBeUndefined()
+        },
+      })
+    })
   })
 
   describe("assert()", () => {

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

@@ -25,7 +25,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
     directory,
     FileWatcher.layer,
     async (rt) => {
-      await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
+      await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
       await Effect.runPromise(ready(directory))
       await Effect.runPromise(body)
     },
@@ -136,7 +136,9 @@ function ready(directory: string) {
 // ---------------------------------------------------------------------------
 
 describeWatcher("FileWatcher", () => {
-  afterEach(() => Instance.disposeAll())
+  afterEach(async () => {
+    await Instance.disposeAll()
+  })
 
   test("publishes root create, update, and delete events", async () => {
     await using tmp = await tmpdir({ git: true })

+ 108 - 1
packages/opencode/test/format/format.test.ts

@@ -2,11 +2,16 @@ import { Effect } from "effect"
 import { afterEach, describe, expect, test } from "bun:test"
 import { tmpdir } from "../fixture/fixture"
 import { withServices } from "../fixture/instance"
+import { Bus } from "../../src/bus"
+import { File } from "../../src/file"
 import { Format } from "../../src/format"
+import * as Formatter from "../../src/format/formatter"
 import { Instance } from "../../src/project/instance"
 
 describe("Format", () => {
-  afterEach(() => Instance.disposeAll())
+  afterEach(async () => {
+    await Instance.disposeAll()
+  })
 
   test("status() returns built-in formatters when no config overrides", async () => {
     await using tmp = await tmpdir()
@@ -62,4 +67,106 @@ describe("Format", () => {
       await rt.runPromise(Format.Service.use(() => Effect.void))
     })
   })
+
+  test("status() initializes formatter state per directory", async () => {
+    await using off = await tmpdir({
+      config: { formatter: false },
+    })
+    await using on = await tmpdir()
+
+    const a = await Instance.provide({
+      directory: off.path,
+      fn: () => Format.status(),
+    })
+    const b = await Instance.provide({
+      directory: on.path,
+      fn: () => Format.status(),
+    })
+
+    expect(a).toEqual([])
+    expect(b.length).toBeGreaterThan(0)
+  })
+
+  test("runs enabled checks for matching formatters in parallel", async () => {
+    await using tmp = await tmpdir()
+
+    const file = `${tmp.path}/test.parallel`
+    await Bun.write(file, "x")
+
+    const one = {
+      extensions: Formatter.gofmt.extensions,
+      enabled: Formatter.gofmt.enabled,
+      command: Formatter.gofmt.command,
+    }
+    const two = {
+      extensions: Formatter.mix.extensions,
+      enabled: Formatter.mix.enabled,
+      command: Formatter.mix.command,
+    }
+
+    let active = 0
+    let max = 0
+
+    Formatter.gofmt.extensions = [".parallel"]
+    Formatter.mix.extensions = [".parallel"]
+    Formatter.gofmt.command = ["sh", "-c", "true"]
+    Formatter.mix.command = ["sh", "-c", "true"]
+    Formatter.gofmt.enabled = async () => {
+      active++
+      max = Math.max(max, active)
+      await Bun.sleep(20)
+      active--
+      return true
+    }
+    Formatter.mix.enabled = async () => {
+      active++
+      max = Math.max(max, active)
+      await Bun.sleep(20)
+      active--
+      return true
+    }
+
+    try {
+      await withServices(tmp.path, Format.layer, async (rt) => {
+        await rt.runPromise(Format.Service.use((s) => s.init()))
+        await Bus.publish(File.Event.Edited, { file })
+      })
+    } finally {
+      Formatter.gofmt.extensions = one.extensions
+      Formatter.gofmt.enabled = one.enabled
+      Formatter.gofmt.command = one.command
+      Formatter.mix.extensions = two.extensions
+      Formatter.mix.enabled = two.enabled
+      Formatter.mix.command = two.command
+    }
+
+    expect(max).toBe(2)
+  })
+
+  test("runs matching formatters sequentially for the same file", async () => {
+    await using tmp = await tmpdir({
+      config: {
+        formatter: {
+          first: {
+            command: ["sh", "-c", "sleep 0.05; v=$(cat \"$1\"); printf '%sA' \"$v\" > \"$1\"", "sh", "$FILE"],
+            extensions: [".seq"],
+          },
+          second: {
+            command: ["sh", "-c", "v=$(cat \"$1\"); printf '%sB' \"$v\" > \"$1\"", "sh", "$FILE"],
+            extensions: [".seq"],
+          },
+        },
+      },
+    })
+
+    const file = `${tmp.path}/test.seq`
+    await Bun.write(file, "x")
+
+    await withServices(tmp.path, Format.layer, async (rt) => {
+      await rt.runPromise(Format.Service.use((s) => s.init()))
+      await Bus.publish(File.Event.Edited, { file })
+    })
+
+    expect(await Bun.file(file).text()).toBe("xAB")
+  })
 })

+ 59 - 55
packages/opencode/test/permission-task.test.ts

@@ -1,11 +1,15 @@
-import { describe, test, expect } from "bun:test"
-import { PermissionNext } from "../src/permission"
+import { afterEach, describe, test, expect } from "bun:test"
+import { Permission } from "../src/permission"
 import { Config } from "../src/config/config"
 import { Instance } from "../src/project/instance"
 import { tmpdir } from "./fixture/fixture"
 
-describe("PermissionNext.evaluate for permission.task", () => {
-  const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
+describe("Permission.evaluate for permission.task", () => {
+  const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
     Object.entries(rules).map(([pattern, action]) => ({
       permission: "task",
       pattern,
@@ -13,42 +17,42 @@ describe("PermissionNext.evaluate for permission.task", () => {
     }))
 
   test("returns ask when no match (default)", () => {
-    expect(PermissionNext.evaluate("task", "code-reviewer", []).action).toBe("ask")
+    expect(Permission.evaluate("task", "code-reviewer", []).action).toBe("ask")
   })
 
   test("returns deny for explicit deny", () => {
     const ruleset = createRuleset({ "code-reviewer": "deny" })
-    expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+    expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
   })
 
   test("returns allow for explicit allow", () => {
     const ruleset = createRuleset({ "code-reviewer": "allow" })
-    expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
+    expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
   })
 
   test("returns ask for explicit ask", () => {
     const ruleset = createRuleset({ "code-reviewer": "ask" })
-    expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
+    expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
   })
 
   test("matches wildcard patterns with deny", () => {
     const ruleset = createRuleset({ "orchestrator-*": "deny" })
-    expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
-    expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
-    expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
+    expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
+    expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
+    expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
   })
 
   test("matches wildcard patterns with allow", () => {
     const ruleset = createRuleset({ "orchestrator-*": "allow" })
-    expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
-    expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
+    expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
+    expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
   })
 
   test("matches wildcard patterns with ask", () => {
     const ruleset = createRuleset({ "orchestrator-*": "ask" })
-    expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
+    expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
     const globalRuleset = createRuleset({ "*": "ask" })
-    expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
+    expect(Permission.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
   })
 
   test("later rules take precedence (last match wins)", () => {
@@ -56,22 +60,22 @@ describe("PermissionNext.evaluate for permission.task", () => {
       "orchestrator-*": "deny",
       "orchestrator-fast": "allow",
     })
-    expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
-    expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
+    expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
+    expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
   })
 
   test("matches global wildcard", () => {
-    expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
-    expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
-    expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
+    expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
+    expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
+    expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
   })
 })
 
-describe("PermissionNext.disabled for task tool", () => {
+describe("Permission.disabled for task tool", () => {
   // Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
   // It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
   // It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
-  const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
+  const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
     Object.entries(rules).map(([pattern, action]) => ({
       permission: "task",
       pattern,
@@ -85,7 +89,7 @@ describe("PermissionNext.disabled for task tool", () => {
       "orchestrator-*": "allow",
       "*": "deny",
     })
-    const disabled = PermissionNext.disabled(["task", "bash", "read"], ruleset)
+    const disabled = Permission.disabled(["task", "bash", "read"], ruleset)
     // The task tool IS disabled because there's a pattern: "*" with action: "deny"
     expect(disabled.has("task")).toBe(true)
   })
@@ -95,14 +99,14 @@ describe("PermissionNext.disabled for task tool", () => {
       "orchestrator-*": "ask",
       "*": "deny",
     })
-    const disabled = PermissionNext.disabled(["task"], ruleset)
+    const disabled = Permission.disabled(["task"], ruleset)
     // The task tool IS disabled because there's a pattern: "*" with action: "deny"
     expect(disabled.has("task")).toBe(true)
   })
 
   test("task tool is disabled when global deny pattern exists", () => {
     const ruleset = createRuleset({ "*": "deny" })
-    const disabled = PermissionNext.disabled(["task"], ruleset)
+    const disabled = Permission.disabled(["task"], ruleset)
     expect(disabled.has("task")).toBe(true)
   })
 
@@ -113,13 +117,13 @@ describe("PermissionNext.disabled for task tool", () => {
       "orchestrator-*": "deny",
       general: "deny",
     })
-    const disabled = PermissionNext.disabled(["task"], ruleset)
+    const disabled = Permission.disabled(["task"], ruleset)
     // The task tool is NOT disabled because no rule has pattern: "*" with action: "deny"
     expect(disabled.has("task")).toBe(false)
   })
 
   test("task tool is enabled when no task rules exist (default ask)", () => {
-    const disabled = PermissionNext.disabled(["task"], [])
+    const disabled = Permission.disabled(["task"], [])
     expect(disabled.has("task")).toBe(false)
   })
 
@@ -129,7 +133,7 @@ describe("PermissionNext.disabled for task tool", () => {
       "*": "deny",
       "orchestrator-coder": "allow",
     })
-    const disabled = PermissionNext.disabled(["task"], ruleset)
+    const disabled = Permission.disabled(["task"], ruleset)
     // The disabled() function uses findLast and checks if the last matching rule
     // has pattern: "*" and action: "deny". In this case, the last rule matching
     // "task" permission has pattern "orchestrator-coder", not "*", so not disabled
@@ -155,11 +159,11 @@ describe("permission.task with real config files", () => {
       directory: tmp.path,
       fn: async () => {
         const config = await Config.get()
-        const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+        const ruleset = Permission.fromConfig(config.permission ?? {})
         // general and orchestrator-fast should be allowed, code-reviewer denied
-        expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
-        expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
-        expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+        expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
+        expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
+        expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
       },
     })
   })
@@ -180,11 +184,11 @@ describe("permission.task with real config files", () => {
       directory: tmp.path,
       fn: async () => {
         const config = await Config.get()
-        const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+        const ruleset = Permission.fromConfig(config.permission ?? {})
         // general and code-reviewer should be ask, orchestrator-* denied
-        expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
-        expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
-        expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
+        expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
+        expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
+        expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
       },
     })
   })
@@ -205,11 +209,11 @@ describe("permission.task with real config files", () => {
       directory: tmp.path,
       fn: async () => {
         const config = await Config.get()
-        const ruleset = PermissionNext.fromConfig(config.permission ?? {})
-        expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
-        expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+        const ruleset = Permission.fromConfig(config.permission ?? {})
+        expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
+        expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
         // Unspecified agents default to "ask"
-        expect(PermissionNext.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
+        expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
       },
     })
   })
@@ -232,18 +236,18 @@ describe("permission.task with real config files", () => {
       directory: tmp.path,
       fn: async () => {
         const config = await Config.get()
-        const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+        const ruleset = Permission.fromConfig(config.permission ?? {})
 
         // Verify task permissions
-        expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
-        expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+        expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
+        expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
 
         // Verify other tool permissions
-        expect(PermissionNext.evaluate("bash", "*", ruleset).action).toBe("allow")
-        expect(PermissionNext.evaluate("edit", "*", ruleset).action).toBe("ask")
+        expect(Permission.evaluate("bash", "*", ruleset).action).toBe("allow")
+        expect(Permission.evaluate("edit", "*", ruleset).action).toBe("ask")
 
         // Verify disabled tools
-        const disabled = PermissionNext.disabled(["bash", "edit", "task"], ruleset)
+        const disabled = Permission.disabled(["bash", "edit", "task"], ruleset)
         expect(disabled.has("bash")).toBe(false)
         expect(disabled.has("edit")).toBe(false)
         // task is NOT disabled because disabled() uses findLast, and the last rule
@@ -270,16 +274,16 @@ describe("permission.task with real config files", () => {
       directory: tmp.path,
       fn: async () => {
         const config = await Config.get()
-        const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+        const ruleset = Permission.fromConfig(config.permission ?? {})
 
         // Last matching rule wins - "*" deny is last, so all agents are denied
-        expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("deny")
-        expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
-        expect(PermissionNext.evaluate("task", "unknown", ruleset).action).toBe("deny")
+        expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny")
+        expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+        expect(Permission.evaluate("task", "unknown", ruleset).action).toBe("deny")
 
         // Since "*": "deny" is the last rule, disabled() finds it with findLast
         // and sees pattern: "*" with action: "deny", so task is disabled
-        const disabled = PermissionNext.disabled(["task"], ruleset)
+        const disabled = Permission.disabled(["task"], ruleset)
         expect(disabled.has("task")).toBe(true)
       },
     })
@@ -301,17 +305,17 @@ describe("permission.task with real config files", () => {
       directory: tmp.path,
       fn: async () => {
         const config = await Config.get()
-        const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+        const ruleset = Permission.fromConfig(config.permission ?? {})
 
         // Evaluate uses findLast - "general" allow comes after "*" deny
-        expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
+        expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
         // Other agents still denied by the earlier "*" deny
-        expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+        expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
 
         // disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny"
         // In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*"
         // So the task tool is NOT disabled (even though most subagents are denied)
-        const disabled = PermissionNext.disabled(["task"], ruleset)
+        const disabled = Permission.disabled(["task"], ruleset)
         expect(disabled.has("task")).toBe(false)
       },
     })

+ 243 - 127
packages/opencode/test/permission/next.test.ts

@@ -1,11 +1,7 @@
 import { afterEach, test, expect } from "bun:test"
 import os from "os"
-import { Effect } from "effect"
 import { Bus } from "../../src/bus"
-import { runtime } from "../../src/effect/runtime"
-import { Instances } from "../../src/effect/instances"
-import { PermissionNext } from "../../src/permission"
-import { PermissionNext as S } from "../../src/permission"
+import { Permission } from "../../src/permission"
 import { PermissionID } from "../../src/permission/schema"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
@@ -16,8 +12,8 @@ afterEach(async () => {
 })
 
 async function rejectAll(message?: string) {
-  for (const req of await PermissionNext.list()) {
-    await PermissionNext.reply({
+  for (const req of await Permission.list()) {
+    await Permission.reply({
       requestID: req.id,
       reply: "reject",
       message,
@@ -27,22 +23,22 @@ async function rejectAll(message?: string) {
 
 async function waitForPending(count: number) {
   for (let i = 0; i < 20; i++) {
-    const list = await PermissionNext.list()
+    const list = await Permission.list()
     if (list.length === count) return list
     await Bun.sleep(0)
   }
-  return PermissionNext.list()
+  return Permission.list()
 }
 
 // fromConfig tests
 
 test("fromConfig - string value becomes wildcard rule", () => {
-  const result = PermissionNext.fromConfig({ bash: "allow" })
+  const result = Permission.fromConfig({ bash: "allow" })
   expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
 })
 
 test("fromConfig - object value converts to rules array", () => {
-  const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } })
+  const result = Permission.fromConfig({ bash: { "*": "allow", rm: "deny" } })
   expect(result).toEqual([
     { permission: "bash", pattern: "*", action: "allow" },
     { permission: "bash", pattern: "rm", action: "deny" },
@@ -50,7 +46,7 @@ test("fromConfig - object value converts to rules array", () => {
 })
 
 test("fromConfig - mixed string and object values", () => {
-  const result = PermissionNext.fromConfig({
+  const result = Permission.fromConfig({
     bash: { "*": "allow", rm: "deny" },
     edit: "allow",
     webfetch: "ask",
@@ -64,51 +60,51 @@ test("fromConfig - mixed string and object values", () => {
 })
 
 test("fromConfig - empty object", () => {
-  const result = PermissionNext.fromConfig({})
+  const result = Permission.fromConfig({})
   expect(result).toEqual([])
 })
 
 test("fromConfig - expands tilde to home directory", () => {
-  const result = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } })
+  const result = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
   expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
 })
 
 test("fromConfig - expands $HOME to home directory", () => {
-  const result = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
+  const result = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
   expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
 })
 
 test("fromConfig - expands $HOME without trailing slash", () => {
-  const result = PermissionNext.fromConfig({ external_directory: { $HOME: "allow" } })
+  const result = Permission.fromConfig({ external_directory: { $HOME: "allow" } })
   expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
 })
 
 test("fromConfig - does not expand tilde in middle of path", () => {
-  const result = PermissionNext.fromConfig({ external_directory: { "/some/~/path": "allow" } })
+  const result = Permission.fromConfig({ external_directory: { "/some/~/path": "allow" } })
   expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
 })
 
 test("fromConfig - expands exact tilde to home directory", () => {
-  const result = PermissionNext.fromConfig({ external_directory: { "~": "allow" } })
+  const result = Permission.fromConfig({ external_directory: { "~": "allow" } })
   expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
 })
 
 test("evaluate - matches expanded tilde pattern", () => {
-  const ruleset = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } })
-  const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
+  const ruleset = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
+  const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
   expect(result.action).toBe("allow")
 })
 
 test("evaluate - matches expanded $HOME pattern", () => {
-  const ruleset = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
-  const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
+  const ruleset = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
+  const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
   expect(result.action).toBe("allow")
 })
 
 // merge tests
 
 test("merge - simple concatenation", () => {
-  const result = PermissionNext.merge(
+  const result = Permission.merge(
     [{ permission: "bash", pattern: "*", action: "allow" }],
     [{ permission: "bash", pattern: "*", action: "deny" }],
   )
@@ -119,7 +115,7 @@ test("merge - simple concatenation", () => {
 })
 
 test("merge - adds new permission", () => {
-  const result = PermissionNext.merge(
+  const result = Permission.merge(
     [{ permission: "bash", pattern: "*", action: "allow" }],
     [{ permission: "edit", pattern: "*", action: "deny" }],
   )
@@ -130,7 +126,7 @@ test("merge - adds new permission", () => {
 })
 
 test("merge - concatenates rules for same permission", () => {
-  const result = PermissionNext.merge(
+  const result = Permission.merge(
     [{ permission: "bash", pattern: "foo", action: "ask" }],
     [{ permission: "bash", pattern: "*", action: "deny" }],
   )
@@ -141,7 +137,7 @@ test("merge - concatenates rules for same permission", () => {
 })
 
 test("merge - multiple rulesets", () => {
-  const result = PermissionNext.merge(
+  const result = Permission.merge(
     [{ permission: "bash", pattern: "*", action: "allow" }],
     [{ permission: "bash", pattern: "rm", action: "ask" }],
     [{ permission: "edit", pattern: "*", action: "allow" }],
@@ -154,12 +150,12 @@ test("merge - multiple rulesets", () => {
 })
 
 test("merge - empty ruleset does nothing", () => {
-  const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
+  const result = Permission.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
   expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
 })
 
 test("merge - preserves rule order", () => {
-  const result = PermissionNext.merge(
+  const result = Permission.merge(
     [
       { permission: "edit", pattern: "src/*", action: "allow" },
       { permission: "edit", pattern: "src/secret/*", action: "deny" },
@@ -175,40 +171,40 @@ test("merge - preserves rule order", () => {
 
 test("merge - config permission overrides default ask", () => {
   // Simulates: defaults have "*": "ask", config sets bash: "allow"
-  const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
-  const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
-  const merged = PermissionNext.merge(defaults, config)
+  const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
+  const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+  const merged = Permission.merge(defaults, config)
 
   // Config's bash allow should override default ask
-  expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("allow")
+  expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow")
   // Other permissions should still be ask (from defaults)
-  expect(PermissionNext.evaluate("edit", "foo.ts", merged).action).toBe("ask")
+  expect(Permission.evaluate("edit", "foo.ts", merged).action).toBe("ask")
 })
 
 test("merge - config ask overrides default allow", () => {
   // Simulates: defaults have bash: "allow", config sets bash: "ask"
-  const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
-  const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
-  const merged = PermissionNext.merge(defaults, config)
+  const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+  const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
+  const merged = Permission.merge(defaults, config)
 
   // Config's ask should override default allow
-  expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("ask")
+  expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask")
 })
 
 // evaluate tests
 
 test("evaluate - exact pattern match", () => {
-  const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
+  const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
   expect(result.action).toBe("deny")
 })
 
 test("evaluate - wildcard pattern match", () => {
-  const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
+  const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
   expect(result.action).toBe("allow")
 })
 
 test("evaluate - last matching rule wins", () => {
-  const result = PermissionNext.evaluate("bash", "rm", [
+  const result = Permission.evaluate("bash", "rm", [
     { permission: "bash", pattern: "*", action: "allow" },
     { permission: "bash", pattern: "rm", action: "deny" },
   ])
@@ -216,7 +212,7 @@ test("evaluate - last matching rule wins", () => {
 })
 
 test("evaluate - last matching rule wins (wildcard after specific)", () => {
-  const result = PermissionNext.evaluate("bash", "rm", [
+  const result = Permission.evaluate("bash", "rm", [
     { permission: "bash", pattern: "rm", action: "deny" },
     { permission: "bash", pattern: "*", action: "allow" },
   ])
@@ -224,14 +220,12 @@ test("evaluate - last matching rule wins (wildcard after specific)", () => {
 })
 
 test("evaluate - glob pattern match", () => {
-  const result = PermissionNext.evaluate("edit", "src/foo.ts", [
-    { permission: "edit", pattern: "src/*", action: "allow" },
-  ])
+  const result = Permission.evaluate("edit", "src/foo.ts", [{ permission: "edit", pattern: "src/*", action: "allow" }])
   expect(result.action).toBe("allow")
 })
 
 test("evaluate - last matching glob wins", () => {
-  const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
+  const result = Permission.evaluate("edit", "src/components/Button.tsx", [
     { permission: "edit", pattern: "src/*", action: "deny" },
     { permission: "edit", pattern: "src/components/*", action: "allow" },
   ])
@@ -240,7 +234,7 @@ test("evaluate - last matching glob wins", () => {
 
 test("evaluate - order matters for specificity", () => {
   // If more specific rule comes first, later wildcard overrides it
-  const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
+  const result = Permission.evaluate("edit", "src/components/Button.tsx", [
     { permission: "edit", pattern: "src/components/*", action: "allow" },
     { permission: "edit", pattern: "src/*", action: "deny" },
   ])
@@ -248,31 +242,29 @@ test("evaluate - order matters for specificity", () => {
 })
 
 test("evaluate - unknown permission returns ask", () => {
-  const result = PermissionNext.evaluate("unknown_tool", "anything", [
+  const result = Permission.evaluate("unknown_tool", "anything", [
     { permission: "bash", pattern: "*", action: "allow" },
   ])
   expect(result.action).toBe("ask")
 })
 
 test("evaluate - empty ruleset returns ask", () => {
-  const result = PermissionNext.evaluate("bash", "rm", [])
+  const result = Permission.evaluate("bash", "rm", [])
   expect(result.action).toBe("ask")
 })
 
 test("evaluate - no matching pattern returns ask", () => {
-  const result = PermissionNext.evaluate("edit", "etc/passwd", [
-    { permission: "edit", pattern: "src/*", action: "allow" },
-  ])
+  const result = Permission.evaluate("edit", "etc/passwd", [{ permission: "edit", pattern: "src/*", action: "allow" }])
   expect(result.action).toBe("ask")
 })
 
 test("evaluate - empty rules array returns ask", () => {
-  const result = PermissionNext.evaluate("bash", "rm", [])
+  const result = Permission.evaluate("bash", "rm", [])
   expect(result.action).toBe("ask")
 })
 
 test("evaluate - multiple matching patterns, last wins", () => {
-  const result = PermissionNext.evaluate("edit", "src/secret.ts", [
+  const result = Permission.evaluate("edit", "src/secret.ts", [
     { permission: "edit", pattern: "*", action: "ask" },
     { permission: "edit", pattern: "src/*", action: "allow" },
     { permission: "edit", pattern: "src/secret.ts", action: "deny" },
@@ -281,7 +273,7 @@ test("evaluate - multiple matching patterns, last wins", () => {
 })
 
 test("evaluate - non-matching patterns are skipped", () => {
-  const result = PermissionNext.evaluate("edit", "src/foo.ts", [
+  const result = Permission.evaluate("edit", "src/foo.ts", [
     { permission: "edit", pattern: "*", action: "ask" },
     { permission: "edit", pattern: "test/*", action: "deny" },
     { permission: "edit", pattern: "src/*", action: "allow" },
@@ -290,7 +282,7 @@ test("evaluate - non-matching patterns are skipped", () => {
 })
 
 test("evaluate - exact match at end wins over earlier wildcard", () => {
-  const result = PermissionNext.evaluate("bash", "/bin/rm", [
+  const result = Permission.evaluate("bash", "/bin/rm", [
     { permission: "bash", pattern: "*", action: "allow" },
     { permission: "bash", pattern: "/bin/rm", action: "deny" },
   ])
@@ -298,7 +290,7 @@ test("evaluate - exact match at end wins over earlier wildcard", () => {
 })
 
 test("evaluate - wildcard at end overrides earlier exact match", () => {
-  const result = PermissionNext.evaluate("bash", "/bin/rm", [
+  const result = Permission.evaluate("bash", "/bin/rm", [
     { permission: "bash", pattern: "/bin/rm", action: "deny" },
     { permission: "bash", pattern: "*", action: "allow" },
   ])
@@ -308,24 +300,24 @@ test("evaluate - wildcard at end overrides earlier exact match", () => {
 // wildcard permission tests
 
 test("evaluate - wildcard permission matches any permission", () => {
-  const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
+  const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
   expect(result.action).toBe("deny")
 })
 
 test("evaluate - wildcard permission with specific pattern", () => {
-  const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
+  const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
   expect(result.action).toBe("deny")
 })
 
 test("evaluate - glob permission pattern", () => {
-  const result = PermissionNext.evaluate("mcp_server_tool", "anything", [
+  const result = Permission.evaluate("mcp_server_tool", "anything", [
     { permission: "mcp_*", pattern: "*", action: "allow" },
   ])
   expect(result.action).toBe("allow")
 })
 
 test("evaluate - specific permission and wildcard permission combined", () => {
-  const result = PermissionNext.evaluate("bash", "rm", [
+  const result = Permission.evaluate("bash", "rm", [
     { permission: "*", pattern: "*", action: "deny" },
     { permission: "bash", pattern: "*", action: "allow" },
   ])
@@ -333,7 +325,7 @@ test("evaluate - specific permission and wildcard permission combined", () => {
 })
 
 test("evaluate - wildcard permission does not match when specific exists", () => {
-  const result = PermissionNext.evaluate("edit", "src/foo.ts", [
+  const result = Permission.evaluate("edit", "src/foo.ts", [
     { permission: "*", pattern: "*", action: "deny" },
     { permission: "edit", pattern: "src/*", action: "allow" },
   ])
@@ -341,7 +333,7 @@ test("evaluate - wildcard permission does not match when specific exists", () =>
 })
 
 test("evaluate - multiple matching permission patterns combine rules", () => {
-  const result = PermissionNext.evaluate("mcp_dangerous", "anything", [
+  const result = Permission.evaluate("mcp_dangerous", "anything", [
     { permission: "*", pattern: "*", action: "ask" },
     { permission: "mcp_*", pattern: "*", action: "allow" },
     { permission: "mcp_dangerous", pattern: "*", action: "deny" },
@@ -350,7 +342,7 @@ test("evaluate - multiple matching permission patterns combine rules", () => {
 })
 
 test("evaluate - wildcard permission fallback for unknown tool", () => {
-  const result = PermissionNext.evaluate("unknown_tool", "anything", [
+  const result = Permission.evaluate("unknown_tool", "anything", [
     { permission: "*", pattern: "*", action: "ask" },
     { permission: "bash", pattern: "*", action: "allow" },
   ])
@@ -359,7 +351,7 @@ test("evaluate - wildcard permission fallback for unknown tool", () => {
 
 test("evaluate - permission patterns sorted by length regardless of object order", () => {
   // specific permission listed before wildcard, but specific should still win
-  const result = PermissionNext.evaluate("bash", "rm", [
+  const result = Permission.evaluate("bash", "rm", [
     { permission: "bash", pattern: "*", action: "allow" },
     { permission: "*", pattern: "*", action: "deny" },
   ])
@@ -368,22 +360,22 @@ test("evaluate - permission patterns sorted by length regardless of object order
 })
 
 test("evaluate - merges multiple rulesets", () => {
-  const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
-  const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
+  const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+  const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
   // approved comes after config, so rm should be denied
-  const result = PermissionNext.evaluate("bash", "rm", config, approved)
+  const result = Permission.evaluate("bash", "rm", config, approved)
   expect(result.action).toBe("deny")
 })
 
 // disabled tests
 
 test("disabled - returns empty set when all tools allowed", () => {
-  const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
+  const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
   expect(result.size).toBe(0)
 })
 
 test("disabled - disables tool when denied", () => {
-  const result = PermissionNext.disabled(
+  const result = Permission.disabled(
     ["bash", "edit", "read"],
     [
       { permission: "*", pattern: "*", action: "allow" },
@@ -396,7 +388,7 @@ test("disabled - disables tool when denied", () => {
 })
 
 test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => {
-  const result = PermissionNext.disabled(
+  const result = Permission.disabled(
     ["edit", "write", "apply_patch", "multiedit", "bash"],
     [
       { permission: "*", pattern: "*", action: "allow" },
@@ -411,7 +403,7 @@ test("disabled - disables edit/write/apply_patch/multiedit when edit denied", ()
 })
 
 test("disabled - does not disable when partially denied", () => {
-  const result = PermissionNext.disabled(
+  const result = Permission.disabled(
     ["bash"],
     [
       { permission: "bash", pattern: "*", action: "allow" },
@@ -422,14 +414,14 @@ test("disabled - does not disable when partially denied", () => {
 })
 
 test("disabled - does not disable when action is ask", () => {
-  const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
+  const result = Permission.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
   expect(result.size).toBe(0)
 })
 
 test("disabled - does not disable when specific allow after wildcard deny", () => {
   // Tool is NOT disabled because a specific allow after wildcard deny means
   // there's at least some usage allowed
-  const result = PermissionNext.disabled(
+  const result = Permission.disabled(
     ["bash"],
     [
       { permission: "bash", pattern: "*", action: "deny" },
@@ -440,7 +432,7 @@ test("disabled - does not disable when specific allow after wildcard deny", () =
 })
 
 test("disabled - does not disable when wildcard allow after deny", () => {
-  const result = PermissionNext.disabled(
+  const result = Permission.disabled(
     ["bash"],
     [
       { permission: "bash", pattern: "rm *", action: "deny" },
@@ -451,7 +443,7 @@ test("disabled - does not disable when wildcard allow after deny", () => {
 })
 
 test("disabled - disables multiple tools", () => {
-  const result = PermissionNext.disabled(
+  const result = Permission.disabled(
     ["bash", "edit", "webfetch"],
     [
       { permission: "bash", pattern: "*", action: "deny" },
@@ -465,14 +457,14 @@ test("disabled - disables multiple tools", () => {
 })
 
 test("disabled - wildcard permission denies all tools", () => {
-  const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
+  const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
   expect(result.has("bash")).toBe(true)
   expect(result.has("edit")).toBe(true)
   expect(result.has("read")).toBe(true)
 })
 
 test("disabled - specific allow overrides wildcard deny", () => {
-  const result = PermissionNext.disabled(
+  const result = Permission.disabled(
     ["bash", "edit", "read"],
     [
       { permission: "*", pattern: "*", action: "deny" },
@@ -491,7 +483,7 @@ test("ask - resolves immediately when action is allow", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const result = await PermissionNext.ask({
+      const result = await Permission.ask({
         sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["ls"],
@@ -510,7 +502,7 @@ test("ask - throws RejectedError when action is deny", async () => {
     directory: tmp.path,
     fn: async () => {
       await expect(
-        PermissionNext.ask({
+        Permission.ask({
           sessionID: SessionID.make("session_test"),
           permission: "bash",
           patterns: ["rm -rf /"],
@@ -518,7 +510,7 @@ test("ask - throws RejectedError when action is deny", async () => {
           always: [],
           ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
         }),
-      ).rejects.toBeInstanceOf(PermissionNext.DeniedError)
+      ).rejects.toBeInstanceOf(Permission.DeniedError)
     },
   })
 })
@@ -528,7 +520,7 @@ test("ask - returns pending promise when action is ask", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const promise = PermissionNext.ask({
+      const promise = Permission.ask({
         sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["ls"],
@@ -550,7 +542,7 @@ test("ask - adds request to pending list", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const ask = PermissionNext.ask({
+      const ask = Permission.ask({
         sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["ls"],
@@ -563,7 +555,7 @@ test("ask - adds request to pending list", async () => {
         ruleset: [],
       })
 
-      const list = await PermissionNext.list()
+      const list = await Permission.list()
       expect(list).toHaveLength(1)
       expect(list[0]).toMatchObject({
         sessionID: SessionID.make("session_test"),
@@ -588,12 +580,12 @@ test("ask - publishes asked event", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      let seen: PermissionNext.Request | undefined
-      const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => {
+      let seen: Permission.Request | undefined
+      const unsub = Bus.subscribe(Permission.Event.Asked, (event) => {
         seen = event.properties
       })
 
-      const ask = PermissionNext.ask({
+      const ask = Permission.ask({
         sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["ls"],
@@ -606,7 +598,7 @@ test("ask - publishes asked event", async () => {
         ruleset: [],
       })
 
-      expect(await PermissionNext.list()).toHaveLength(1)
+      expect(await Permission.list()).toHaveLength(1)
       expect(seen).toBeDefined()
       expect(seen).toMatchObject({
         sessionID: SessionID.make("session_test"),
@@ -628,7 +620,7 @@ test("reply - once resolves the pending ask", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const askPromise = PermissionNext.ask({
+      const askPromise = Permission.ask({
         id: PermissionID.make("per_test1"),
         sessionID: SessionID.make("session_test"),
         permission: "bash",
@@ -640,7 +632,7 @@ test("reply - once resolves the pending ask", async () => {
 
       await waitForPending(1)
 
-      await PermissionNext.reply({
+      await Permission.reply({
         requestID: PermissionID.make("per_test1"),
         reply: "once",
       })
@@ -655,7 +647,7 @@ test("reply - reject throws RejectedError", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const askPromise = PermissionNext.ask({
+      const askPromise = Permission.ask({
         id: PermissionID.make("per_test2"),
         sessionID: SessionID.make("session_test"),
         permission: "bash",
@@ -667,12 +659,12 @@ test("reply - reject throws RejectedError", async () => {
 
       await waitForPending(1)
 
-      await PermissionNext.reply({
+      await Permission.reply({
         requestID: PermissionID.make("per_test2"),
         reply: "reject",
       })
 
-      await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError)
+      await expect(askPromise).rejects.toBeInstanceOf(Permission.RejectedError)
     },
   })
 })
@@ -682,7 +674,7 @@ test("reply - reject with message throws CorrectedError", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const ask = PermissionNext.ask({
+      const ask = Permission.ask({
         id: PermissionID.make("per_test2b"),
         sessionID: SessionID.make("session_test"),
         permission: "bash",
@@ -694,14 +686,14 @@ test("reply - reject with message throws CorrectedError", async () => {
 
       await waitForPending(1)
 
-      await PermissionNext.reply({
+      await Permission.reply({
         requestID: PermissionID.make("per_test2b"),
         reply: "reject",
         message: "Use a safer command",
       })
 
       const err = await ask.catch((err) => err)
-      expect(err).toBeInstanceOf(PermissionNext.CorrectedError)
+      expect(err).toBeInstanceOf(Permission.CorrectedError)
       expect(err.message).toContain("Use a safer command")
     },
   })
@@ -712,7 +704,7 @@ test("reply - always persists approval and resolves", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const askPromise = PermissionNext.ask({
+      const askPromise = Permission.ask({
         id: PermissionID.make("per_test3"),
         sessionID: SessionID.make("session_test"),
         permission: "bash",
@@ -724,7 +716,7 @@ test("reply - always persists approval and resolves", async () => {
 
       await waitForPending(1)
 
-      await PermissionNext.reply({
+      await Permission.reply({
         requestID: PermissionID.make("per_test3"),
         reply: "always",
       })
@@ -737,7 +729,7 @@ test("reply - always persists approval and resolves", async () => {
     directory: tmp.path,
     fn: async () => {
       // Stored approval should allow without asking
-      const result = await PermissionNext.ask({
+      const result = await Permission.ask({
         sessionID: SessionID.make("session_test2"),
         permission: "bash",
         patterns: ["ls"],
@@ -755,7 +747,7 @@ test("reply - reject cancels all pending for same session", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const askPromise1 = PermissionNext.ask({
+      const askPromise1 = Permission.ask({
         id: PermissionID.make("per_test4a"),
         sessionID: SessionID.make("session_same"),
         permission: "bash",
@@ -765,7 +757,7 @@ test("reply - reject cancels all pending for same session", async () => {
         ruleset: [],
       })
 
-      const askPromise2 = PermissionNext.ask({
+      const askPromise2 = Permission.ask({
         id: PermissionID.make("per_test4b"),
         sessionID: SessionID.make("session_same"),
         permission: "edit",
@@ -782,14 +774,14 @@ test("reply - reject cancels all pending for same session", async () => {
       const result2 = askPromise2.catch((e) => e)
 
       // Reject the first one
-      await PermissionNext.reply({
+      await Permission.reply({
         requestID: PermissionID.make("per_test4a"),
         reply: "reject",
       })
 
       // Both should be rejected
-      expect(await result1).toBeInstanceOf(PermissionNext.RejectedError)
-      expect(await result2).toBeInstanceOf(PermissionNext.RejectedError)
+      expect(await result1).toBeInstanceOf(Permission.RejectedError)
+      expect(await result2).toBeInstanceOf(Permission.RejectedError)
     },
   })
 })
@@ -799,7 +791,7 @@ test("reply - always resolves matching pending requests in same session", async
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const a = PermissionNext.ask({
+      const a = Permission.ask({
         id: PermissionID.make("per_test5a"),
         sessionID: SessionID.make("session_same"),
         permission: "bash",
@@ -809,7 +801,7 @@ test("reply - always resolves matching pending requests in same session", async
         ruleset: [],
       })
 
-      const b = PermissionNext.ask({
+      const b = Permission.ask({
         id: PermissionID.make("per_test5b"),
         sessionID: SessionID.make("session_same"),
         permission: "bash",
@@ -821,14 +813,14 @@ test("reply - always resolves matching pending requests in same session", async
 
       await waitForPending(2)
 
-      await PermissionNext.reply({
+      await Permission.reply({
         requestID: PermissionID.make("per_test5a"),
         reply: "always",
       })
 
       await expect(a).resolves.toBeUndefined()
       await expect(b).resolves.toBeUndefined()
-      expect(await PermissionNext.list()).toHaveLength(0)
+      expect(await Permission.list()).toHaveLength(0)
     },
   })
 })
@@ -838,7 +830,7 @@ test("reply - always keeps other session pending", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const a = PermissionNext.ask({
+      const a = Permission.ask({
         id: PermissionID.make("per_test6a"),
         sessionID: SessionID.make("session_a"),
         permission: "bash",
@@ -848,7 +840,7 @@ test("reply - always keeps other session pending", async () => {
         ruleset: [],
       })
 
-      const b = PermissionNext.ask({
+      const b = Permission.ask({
         id: PermissionID.make("per_test6b"),
         sessionID: SessionID.make("session_b"),
         permission: "bash",
@@ -860,13 +852,13 @@ test("reply - always keeps other session pending", async () => {
 
       await waitForPending(2)
 
-      await PermissionNext.reply({
+      await Permission.reply({
         requestID: PermissionID.make("per_test6a"),
         reply: "always",
       })
 
       await expect(a).resolves.toBeUndefined()
-      expect((await PermissionNext.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
+      expect((await Permission.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
 
       await rejectAll()
       await b.catch(() => {})
@@ -879,7 +871,7 @@ test("reply - publishes replied event", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const ask = PermissionNext.ask({
+      const ask = Permission.ask({
         id: PermissionID.make("per_test7"),
         sessionID: SessionID.make("session_test"),
         permission: "bash",
@@ -895,14 +887,14 @@ test("reply - publishes replied event", async () => {
         | {
             sessionID: SessionID
             requestID: PermissionID
-            reply: PermissionNext.Reply
+            reply: Permission.Reply
           }
         | undefined
-      const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => {
+      const unsub = Bus.subscribe(Permission.Event.Replied, (event) => {
         seen = event.properties
       })
 
-      await PermissionNext.reply({
+      await Permission.reply({
         requestID: PermissionID.make("per_test7"),
         reply: "once",
       })
@@ -918,16 +910,141 @@ test("reply - publishes replied event", async () => {
   })
 })
 
+test("permission requests stay isolated by directory", async () => {
+  await using one = await tmpdir({ git: true })
+  await using two = await tmpdir({ git: true })
+
+  const a = Instance.provide({
+    directory: one.path,
+    fn: () =>
+      Permission.ask({
+        id: PermissionID.make("per_dir_a"),
+        sessionID: SessionID.make("session_dir_a"),
+        permission: "bash",
+        patterns: ["ls"],
+        metadata: {},
+        always: [],
+        ruleset: [],
+      }),
+  })
+
+  const b = Instance.provide({
+    directory: two.path,
+    fn: () =>
+      Permission.ask({
+        id: PermissionID.make("per_dir_b"),
+        sessionID: SessionID.make("session_dir_b"),
+        permission: "bash",
+        patterns: ["pwd"],
+        metadata: {},
+        always: [],
+        ruleset: [],
+      }),
+  })
+
+  const onePending = await Instance.provide({
+    directory: one.path,
+    fn: () => waitForPending(1),
+  })
+  const twoPending = await Instance.provide({
+    directory: two.path,
+    fn: () => waitForPending(1),
+  })
+
+  expect(onePending).toHaveLength(1)
+  expect(twoPending).toHaveLength(1)
+  expect(onePending[0].id).toBe(PermissionID.make("per_dir_a"))
+  expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b"))
+
+  await Instance.provide({
+    directory: one.path,
+    fn: () => Permission.reply({ requestID: onePending[0].id, reply: "reject" }),
+  })
+  await Instance.provide({
+    directory: two.path,
+    fn: () => Permission.reply({ requestID: twoPending[0].id, reply: "reject" }),
+  })
+
+  await a.catch(() => {})
+  await b.catch(() => {})
+})
+
+test("pending permission rejects on instance dispose", async () => {
+  await using tmp = await tmpdir({ git: true })
+
+  const ask = Instance.provide({
+    directory: tmp.path,
+    fn: () =>
+      Permission.ask({
+        id: PermissionID.make("per_dispose"),
+        sessionID: SessionID.make("session_dispose"),
+        permission: "bash",
+        patterns: ["ls"],
+        metadata: {},
+        always: [],
+        ruleset: [],
+      }),
+  })
+  const result = ask.then(
+    () => "resolved" as const,
+    (err) => err,
+  )
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const pending = await waitForPending(1)
+      expect(pending).toHaveLength(1)
+      await Instance.dispose()
+    },
+  })
+
+  expect(await result).toBeInstanceOf(Permission.RejectedError)
+})
+
+test("pending permission rejects on instance reload", async () => {
+  await using tmp = await tmpdir({ git: true })
+
+  const ask = Instance.provide({
+    directory: tmp.path,
+    fn: () =>
+      Permission.ask({
+        id: PermissionID.make("per_reload"),
+        sessionID: SessionID.make("session_reload"),
+        permission: "bash",
+        patterns: ["ls"],
+        metadata: {},
+        always: [],
+        ruleset: [],
+      }),
+  })
+  const result = ask.then(
+    () => "resolved" as const,
+    (err) => err,
+  )
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const pending = await waitForPending(1)
+      expect(pending).toHaveLength(1)
+      await Instance.reload({ directory: tmp.path })
+    },
+  })
+
+  expect(await result).toBeInstanceOf(Permission.RejectedError)
+})
+
 test("reply - does nothing for unknown requestID", async () => {
   await using tmp = await tmpdir({ git: true })
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      await PermissionNext.reply({
+      await Permission.reply({
         requestID: PermissionID.make("per_unknown"),
         reply: "once",
       })
-      expect(await PermissionNext.list()).toHaveLength(0)
+      expect(await Permission.list()).toHaveLength(0)
     },
   })
 })
@@ -938,7 +1055,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
     directory: tmp.path,
     fn: async () => {
       await expect(
-        PermissionNext.ask({
+        Permission.ask({
           sessionID: SessionID.make("session_test"),
           permission: "bash",
           patterns: ["echo hello", "rm -rf /"],
@@ -949,7 +1066,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
             { permission: "bash", pattern: "rm *", action: "deny" },
           ],
         }),
-      ).rejects.toBeInstanceOf(PermissionNext.DeniedError)
+      ).rejects.toBeInstanceOf(Permission.DeniedError)
     },
   })
 })
@@ -959,7 +1076,7 @@ test("ask - allows all patterns when all match allow rules", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const result = await PermissionNext.ask({
+      const result = await Permission.ask({
         sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["echo hello", "ls -la", "pwd"],
@@ -977,7 +1094,7 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const err = await PermissionNext.ask({
+      const err = await Permission.ask({
         sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["echo hello", "rm -rf /"],
@@ -992,8 +1109,8 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
         (err) => err,
       )
 
-      expect(err).toBeInstanceOf(PermissionNext.DeniedError)
-      expect(await PermissionNext.list()).toHaveLength(0)
+      expect(err).toBeInstanceOf(Permission.DeniedError)
+      expect(await Permission.list()).toHaveLength(0)
     },
   })
 })
@@ -1004,8 +1121,8 @@ test("ask - abort should clear pending request", async () => {
     directory: tmp.path,
     fn: async () => {
       const ctl = new AbortController()
-      const ask = runtime.runPromise(
-        S.Service.use((svc) =>
+      const ask = Permission.runPromise(
+        (svc) =>
           svc.ask({
             sessionID: SessionID.make("session_test"),
             permission: "bash",
@@ -1014,7 +1131,6 @@ test("ask - abort should clear pending request", async () => {
             always: [],
             ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
           }),
-        ).pipe(Effect.provide(Instances.get(Instance.directory))),
         { signal: ctl.signal },
       )
 
@@ -1023,7 +1139,7 @@ test("ask - abort should clear pending request", async () => {
       await ask.catch(() => {})
 
       try {
-        expect(await PermissionNext.list()).toHaveLength(0)
+        expect(await Permission.list()).toHaveLength(0)
       } finally {
         await rejectAll()
       }

+ 17 - 6
packages/opencode/test/plugin/auth-override.test.ts

@@ -31,15 +31,26 @@ describe("plugin.auth-override", () => {
       },
     })
 
-    await Instance.provide({
+    await using plain = await tmpdir()
+
+    const methods = await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const methods = await ProviderAuth.methods()
-        const copilot = methods[ProviderID.make("github-copilot")]
-        expect(copilot).toBeDefined()
-        expect(copilot.length).toBe(1)
-        expect(copilot[0].label).toBe("Test Override Auth")
+        return ProviderAuth.methods()
+      },
+    })
+
+    const plainMethods = await Instance.provide({
+      directory: plain.path,
+      fn: async () => {
+        return ProviderAuth.methods()
       },
     })
+
+    const copilot = methods[ProviderID.make("github-copilot")]
+    expect(copilot).toBeDefined()
+    expect(copilot.length).toBe(1)
+    expect(copilot[0].label).toBe("Test Override Auth")
+    expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
   }, 30000) // Increased timeout for plugin installation
 })

+ 5 - 3
packages/opencode/test/project/vcs.test.ts

@@ -25,8 +25,8 @@ function withVcs(
     directory,
     Layer.merge(FileWatcher.layer, Vcs.layer),
     async (rt) => {
-      await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
-      await rt.runPromise(Vcs.Service.use(() => Effect.void))
+      await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
+      await rt.runPromise(Vcs.Service.use((s) => s.init()))
       await Bun.sleep(500)
       await body(rt)
     },
@@ -67,7 +67,9 @@ function nextBranchUpdate(directory: string, timeout = 10_000) {
 // ---------------------------------------------------------------------------
 
 describeVcs("Vcs", () => {
-  afterEach(() => Instance.disposeAll())
+  afterEach(async () => {
+    await Instance.disposeAll()
+  })
 
   test("branch() returns current branch name", async () => {
     await using tmp = await tmpdir({ git: true })

+ 131 - 0
packages/opencode/test/question/question.test.ts

@@ -320,3 +320,134 @@ test("list - returns empty when no pending", async () => {
     },
   })
 })
+
+test("questions stay isolated by directory", async () => {
+  await using one = await tmpdir({ git: true })
+  await using two = await tmpdir({ git: true })
+
+  const p1 = Instance.provide({
+    directory: one.path,
+    fn: () =>
+      Question.ask({
+        sessionID: SessionID.make("ses_one"),
+        questions: [
+          {
+            question: "Question 1?",
+            header: "Q1",
+            options: [{ label: "A", description: "A" }],
+          },
+        ],
+      }),
+  })
+
+  const p2 = Instance.provide({
+    directory: two.path,
+    fn: () =>
+      Question.ask({
+        sessionID: SessionID.make("ses_two"),
+        questions: [
+          {
+            question: "Question 2?",
+            header: "Q2",
+            options: [{ label: "B", description: "B" }],
+          },
+        ],
+      }),
+  })
+
+  const onePending = await Instance.provide({
+    directory: one.path,
+    fn: () => Question.list(),
+  })
+  const twoPending = await Instance.provide({
+    directory: two.path,
+    fn: () => Question.list(),
+  })
+
+  expect(onePending.length).toBe(1)
+  expect(twoPending.length).toBe(1)
+  expect(onePending[0].sessionID).toBe(SessionID.make("ses_one"))
+  expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two"))
+
+  await Instance.provide({
+    directory: one.path,
+    fn: () => Question.reject(onePending[0].id),
+  })
+  await Instance.provide({
+    directory: two.path,
+    fn: () => Question.reject(twoPending[0].id),
+  })
+
+  await p1.catch(() => {})
+  await p2.catch(() => {})
+})
+
+test("pending question rejects on instance dispose", async () => {
+  await using tmp = await tmpdir({ git: true })
+
+  const ask = Instance.provide({
+    directory: tmp.path,
+    fn: () => {
+      return Question.ask({
+        sessionID: SessionID.make("ses_dispose"),
+        questions: [
+          {
+            question: "Dispose me?",
+            header: "Dispose",
+            options: [{ label: "Yes", description: "Yes" }],
+          },
+        ],
+      })
+    },
+  })
+  const result = ask.then(
+    () => "resolved" as const,
+    (err) => err,
+  )
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const pending = await Question.list()
+      expect(pending).toHaveLength(1)
+      await Instance.dispose()
+    },
+  })
+
+  expect(await result).toBeInstanceOf(Question.RejectedError)
+})
+
+test("pending question rejects on instance reload", async () => {
+  await using tmp = await tmpdir({ git: true })
+
+  const ask = Instance.provide({
+    directory: tmp.path,
+    fn: () => {
+      return Question.ask({
+        sessionID: SessionID.make("ses_reload"),
+        questions: [
+          {
+            question: "Reload me?",
+            header: "Reload",
+            options: [{ label: "Yes", description: "Yes" }],
+          },
+        ],
+      })
+    },
+  })
+  const result = ask.then(
+    () => "resolved" as const,
+    (err) => err,
+  )
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const pending = await Question.list()
+      expect(pending).toHaveLength(1)
+      await Instance.reload({ directory: tmp.path })
+    },
+  })
+
+  expect(await result).toBeInstanceOf(Question.RejectedError)
+})

+ 3 - 3
packages/opencode/test/share/share-next.test.ts

@@ -7,7 +7,7 @@ test("ShareNext.request uses legacy share API without active org account", async
   const originalActive = Account.active
   const originalConfigGet = Config.get
 
-  Account.active = mock(() => undefined)
+  Account.active = mock(async () => undefined)
   Config.get = mock(async () => ({ enterprise: { url: "https://legacy-share.example.com" } }))
 
   try {
@@ -29,7 +29,7 @@ test("ShareNext.request uses org share API with auth headers when account is act
   const originalActive = Account.active
   const originalToken = Account.token
 
-  Account.active = mock(() => ({
+  Account.active = mock(async () => ({
     id: AccountID.make("account-1"),
     email: "[email protected]",
     url: "https://control.example.com",
@@ -59,7 +59,7 @@ test("ShareNext.request fails when org account has no token", async () => {
   const originalActive = Account.active
   const originalToken = Account.token
 
-  Account.active = mock(() => ({
+  Account.active = mock(async () => ({
     id: AccountID.make("account-1"),
     email: "[email protected]",
     url: "https://control.example.com",

+ 5 - 1
packages/opencode/test/skill/skill.test.ts

@@ -1,10 +1,14 @@
-import { test, expect } from "bun:test"
+import { afterEach, test, expect } from "bun:test"
 import { Skill } from "../../src/skill"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
 import path from "path"
 import fs from "fs/promises"
 
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
 async function createGlobalSkill(homeDir: string) {
   const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
   await fs.mkdir(skillDir, { recursive: true })

+ 5 - 1
packages/opencode/test/snapshot/snapshot.test.ts

@@ -1,4 +1,4 @@
-import { test, expect } from "bun:test"
+import { afterEach, test, expect } from "bun:test"
 import { $ } from "bun"
 import fs from "fs/promises"
 import path from "path"
@@ -12,6 +12,10 @@ import { tmpdir } from "../fixture/fixture"
 // This helper does the same for expected values so assertions match cross-platform.
 const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/")
 
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
 async function bootstrap() {
   return tmpdir({
     git: true,

+ 21 - 21
packages/opencode/test/tool/bash.test.ts

@@ -5,7 +5,7 @@ import { BashTool } from "../../src/tool/bash"
 import { Instance } from "../../src/project/instance"
 import { Filesystem } from "../../src/util/filesystem"
 import { tmpdir } from "../fixture/fixture"
-import type { PermissionNext } from "../../src/permission"
+import type { Permission } from "../../src/permission"
 import { Truncate } from "../../src/tool/truncate"
 import { SessionID, MessageID } from "../../src/session/schema"
 
@@ -49,10 +49,10 @@ describe("tool.bash permissions", () => {
       directory: tmp.path,
       fn: async () => {
         const bash = await BashTool.init()
-        const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const testCtx = {
           ...ctx,
-          ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+          ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
             requests.push(req)
           },
         }
@@ -76,10 +76,10 @@ describe("tool.bash permissions", () => {
       directory: tmp.path,
       fn: async () => {
         const bash = await BashTool.init()
-        const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const testCtx = {
           ...ctx,
-          ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+          ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
             requests.push(req)
           },
         }
@@ -104,10 +104,10 @@ describe("tool.bash permissions", () => {
       directory: tmp.path,
       fn: async () => {
         const bash = await BashTool.init()
-        const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const testCtx = {
           ...ctx,
-          ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+          ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
             requests.push(req)
           },
         }
@@ -130,10 +130,10 @@ describe("tool.bash permissions", () => {
       directory: tmp.path,
       fn: async () => {
         const bash = await BashTool.init()
-        const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const testCtx = {
           ...ctx,
-          ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+          ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
             requests.push(req)
           },
         }
@@ -163,10 +163,10 @@ describe("tool.bash permissions", () => {
       directory: tmp.path,
       fn: async () => {
         const bash = await BashTool.init()
-        const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const testCtx = {
           ...ctx,
-          ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+          ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
             requests.push(req)
           },
         }
@@ -193,10 +193,10 @@ describe("tool.bash permissions", () => {
       directory: tmp.path,
       fn: async () => {
         const bash = await BashTool.init()
-        const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const testCtx = {
           ...ctx,
-          ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+          ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
             requests.push(req)
           },
         }
@@ -223,10 +223,10 @@ describe("tool.bash permissions", () => {
       directory: tmp.path,
       fn: async () => {
         const bash = await BashTool.init()
-        const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const testCtx = {
           ...ctx,
-          ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+          ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
             requests.push(req)
           },
         }
@@ -250,10 +250,10 @@ describe("tool.bash permissions", () => {
       directory: tmp.path,
       fn: async () => {
         const bash = await BashTool.init()
-        const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const testCtx = {
           ...ctx,
-          ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+          ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
             requests.push(req)
           },
         }
@@ -276,10 +276,10 @@ describe("tool.bash permissions", () => {
       directory: tmp.path,
       fn: async () => {
         const bash = await BashTool.init()
-        const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const testCtx = {
           ...ctx,
-          ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+          ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
             requests.push(req)
           },
         }
@@ -297,10 +297,10 @@ describe("tool.bash permissions", () => {
       directory: tmp.path,
       fn: async () => {
         const bash = await BashTool.init()
-        const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const testCtx = {
           ...ctx,
-          ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+          ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
             requests.push(req)
           },
         }

+ 5 - 1
packages/opencode/test/tool/edit.test.ts

@@ -1,4 +1,4 @@
-import { describe, test, expect } from "bun:test"
+import { afterEach, describe, test, expect } from "bun:test"
 import path from "path"
 import fs from "fs/promises"
 import { EditTool } from "../../src/tool/edit"
@@ -18,6 +18,10 @@ const ctx = {
   ask: async () => {},
 }
 
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
 async function touch(file: string, time: number) {
   const date = new Date(time)
   await fs.utimes(file, date, date)

+ 6 - 6
packages/opencode/test/tool/external-directory.test.ts

@@ -3,7 +3,7 @@ import path from "path"
 import type { Tool } from "../../src/tool/tool"
 import { Instance } from "../../src/project/instance"
 import { assertExternalDirectory } from "../../src/tool/external-directory"
-import type { PermissionNext } from "../../src/permission"
+import type { Permission } from "../../src/permission"
 import { SessionID, MessageID } from "../../src/session/schema"
 
 const baseCtx: Omit<Tool.Context, "ask"> = {
@@ -18,7 +18,7 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
 
 describe("tool.assertExternalDirectory", () => {
   test("no-ops for empty target", async () => {
-    const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+    const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
     const ctx: Tool.Context = {
       ...baseCtx,
       ask: async (req) => {
@@ -37,7 +37,7 @@ describe("tool.assertExternalDirectory", () => {
   })
 
   test("no-ops for paths inside Instance.directory", async () => {
-    const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+    const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
     const ctx: Tool.Context = {
       ...baseCtx,
       ask: async (req) => {
@@ -56,7 +56,7 @@ describe("tool.assertExternalDirectory", () => {
   })
 
   test("asks with a single canonical glob", async () => {
-    const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+    const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
     const ctx: Tool.Context = {
       ...baseCtx,
       ask: async (req) => {
@@ -82,7 +82,7 @@ describe("tool.assertExternalDirectory", () => {
   })
 
   test("uses target directory when kind=directory", async () => {
-    const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+    const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
     const ctx: Tool.Context = {
       ...baseCtx,
       ask: async (req) => {
@@ -108,7 +108,7 @@ describe("tool.assertExternalDirectory", () => {
   })
 
   test("skips prompting when bypass=true", async () => {
-    const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+    const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
     const ctx: Tool.Context = {
       ...baseCtx,
       ask: async (req) => {

+ 17 - 13
packages/opencode/test/tool/read.test.ts

@@ -1,15 +1,19 @@
-import { describe, expect, test } from "bun:test"
+import { afterEach, describe, expect, test } from "bun:test"
 import path from "path"
 import { ReadTool } from "../../src/tool/read"
 import { Instance } from "../../src/project/instance"
 import { Filesystem } from "../../src/util/filesystem"
 import { tmpdir } from "../fixture/fixture"
-import { PermissionNext } from "../../src/permission"
+import { Permission } from "../../src/permission"
 import { Agent } from "../../src/agent/agent"
 import { SessionID, MessageID } from "../../src/session/schema"
 
 const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
 
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
 const ctx = {
   sessionID: SessionID.make("ses_test"),
   messageID: MessageID.make(""),
@@ -65,10 +69,10 @@ describe("tool.read external_directory permission", () => {
       directory: tmp.path,
       fn: async () => {
         const read = await ReadTool.init()
-        const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const testCtx = {
           ...ctx,
-          ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+          ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
             requests.push(req)
           },
         }
@@ -91,10 +95,10 @@ describe("tool.read external_directory permission", () => {
       directory: tmp.path,
       fn: async () => {
         const read = await ReadTool.init()
-        const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const testCtx = {
           ...ctx,
-          ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+          ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
             requests.push(req)
           },
         }
@@ -112,10 +116,10 @@ describe("tool.read external_directory permission", () => {
       directory: tmp.path,
       fn: async () => {
         const read = await ReadTool.init()
-        const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const testCtx = {
           ...ctx,
-          ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+          ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
             requests.push(req)
           },
         }
@@ -138,10 +142,10 @@ describe("tool.read external_directory permission", () => {
       directory: tmp.path,
       fn: async () => {
         const read = await ReadTool.init()
-        const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const testCtx = {
           ...ctx,
-          ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+          ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
             requests.push(req)
           },
         }
@@ -176,14 +180,14 @@ describe("tool.read env file permissions", () => {
           let askedForEnv = false
           const ctxWithPermissions = {
             ...ctx,
-            ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+            ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
               for (const pattern of req.patterns) {
-                const rule = PermissionNext.evaluate(req.permission, pattern, agent.permission)
+                const rule = Permission.evaluate(req.permission, pattern, agent.permission)
                 if (rule.action === "ask" && req.permission === "read") {
                   askedForEnv = true
                 }
                 if (rule.action === "deny") {
-                  throw new PermissionNext.DeniedError({ ruleset: agent.permission })
+                  throw new Permission.DeniedError({ ruleset: agent.permission })
                 }
               }
             },

+ 5 - 1
packages/opencode/test/tool/registry.test.ts

@@ -1,10 +1,14 @@
-import { describe, expect, test } from "bun:test"
+import { afterEach, describe, expect, test } from "bun:test"
 import path from "path"
 import fs from "fs/promises"
 import { tmpdir } from "../fixture/fixture"
 import { Instance } from "../../src/project/instance"
 import { ToolRegistry } from "../../src/tool/registry"
 
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
 describe("tool.registry", () => {
   test("loads tools from .opencode/tool (singular)", async () => {
     await using tmp = await tmpdir({

+ 7 - 3
packages/opencode/test/tool/skill.test.ts

@@ -1,7 +1,7 @@
-import { describe, expect, test } from "bun:test"
+import { afterEach, describe, expect, test } from "bun:test"
 import path from "path"
 import { pathToFileURL } from "url"
-import type { PermissionNext } from "../../src/permission"
+import type { Permission } from "../../src/permission"
 import type { Tool } from "../../src/tool/tool"
 import { Instance } from "../../src/project/instance"
 import { SkillTool } from "../../src/tool/skill"
@@ -18,6 +18,10 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
   metadata: () => {},
 }
 
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
 describe("tool.skill", () => {
   test("description lists skill location URL", async () => {
     await using tmp = await tmpdir({
@@ -133,7 +137,7 @@ Use this skill.
         directory: tmp.path,
         fn: async () => {
           const tool = await SkillTool.init()
-          const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+          const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
           const ctx: Tool.Context = {
             ...baseCtx,
             ask: async (req) => {

+ 5 - 1
packages/opencode/test/tool/task.test.ts

@@ -1,9 +1,13 @@
-import { describe, expect, test } from "bun:test"
+import { afterEach, describe, expect, test } from "bun:test"
 import { Agent } from "../../src/agent/agent"
 import { Instance } from "../../src/project/instance"
 import { TaskTool } from "../../src/tool/task"
 import { tmpdir } from "../fixture/fixture"
 
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
 describe("tool.task", () => {
   test("description sorts subagents by name and is stable across calls", async () => {
     await using tmp = await tmpdir({

+ 2 - 3
packages/opencode/test/tool/truncation.test.ts

@@ -1,8 +1,7 @@
 import { describe, test, expect } from "bun:test"
 import { NodeFileSystem } from "@effect/platform-node"
 import { Effect, FileSystem, Layer } from "effect"
-import { Truncate } from "../../src/tool/truncate"
-import { Truncate as TruncateSvc } from "../../src/tool/truncate-effect"
+import { Truncate, Truncate as TruncateSvc } from "../../src/tool/truncate"
 import { Identifier } from "../../src/id/id"
 import { Process } from "../../src/util/process"
 import { Filesystem } from "../../src/util/filesystem"
@@ -129,7 +128,7 @@ describe("Truncate", () => {
     })
 
     test("loads truncate effect in a fresh process", async () => {
-      const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate-effect.ts")], {
+      const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
         cwd: ROOT,
       })
 

+ 5 - 1
packages/opencode/test/tool/write.test.ts

@@ -1,4 +1,4 @@
-import { describe, test, expect } from "bun:test"
+import { afterEach, describe, test, expect } from "bun:test"
 import path from "path"
 import fs from "fs/promises"
 import { WriteTool } from "../../src/tool/write"
@@ -17,6 +17,10 @@ const ctx = {
   ask: async () => {},
 }
 
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
 describe("tool.write", () => {
   describe("new file creation", () => {
     test("writes content to new file", async () => {