Browse Source

fix: lazy runtime imports in facades to break bundle cycles

All service facades now use @/effect/run (lazy runtime import) instead
of directly importing @/effect/runtime. This breaks the circular
dependency chain that caused "undefined is not an object" crashes in
bun's bundled binary.

- Add src/effect/run.ts with run() and runInstance() lazy wrappers
- Strip all facades to runtime-only functions (no schema re-exports)
- Consumers that need schemas import from service modules directly
- Update specs/effect-migration.md with facade rules and full list
Kit Langton 3 weeks ago
parent
commit
e07589ec02
44 changed files with 355 additions and 422 deletions
  1. 46 17
      packages/opencode/specs/effect-migration.md
  2. 2 1
      packages/opencode/src/account/index.ts
  3. 1 1
      packages/opencode/src/agent/agent.ts
  4. 2 1
      packages/opencode/src/auth/index.ts
  5. 5 5
      packages/opencode/src/cli/cmd/account.ts
  6. 1 1
      packages/opencode/src/cli/cmd/debug/agent.ts
  7. 1 1
      packages/opencode/src/cli/cmd/run.ts
  8. 5 6
      packages/opencode/src/config/config.ts
  9. 9 6
      packages/opencode/src/effect/run.ts
  10. 2 0
      packages/opencode/src/effect/runtime.ts
  11. 4 14
      packages/opencode/src/file/index.ts
  12. 2 7
      packages/opencode/src/file/time.ts
  13. 2 1
      packages/opencode/src/format/index.ts
  14. 5 12
      packages/opencode/src/installation/index.ts
  15. 5 29
      packages/opencode/src/permission/index.ts
  16. 14 36
      packages/opencode/src/provider/auth.ts
  17. 6 21
      packages/opencode/src/question/index.ts
  18. 2 1
      packages/opencode/src/server/routes/session.ts
  19. 1 1
      packages/opencode/src/server/server.ts
  20. 3 2
      packages/opencode/src/session/processor.ts
  21. 4 3
      packages/opencode/src/session/prompt.ts
  22. 2 1
      packages/opencode/src/session/revert.ts
  23. 2 1
      packages/opencode/src/session/summary.ts
  24. 2 1
      packages/opencode/src/session/system.ts
  25. 2 9
      packages/opencode/src/skill/index.ts
  26. 4 12
      packages/opencode/src/snapshot/index.ts
  27. 1 1
      packages/opencode/src/tool/bash.ts
  28. 3 2
      packages/opencode/src/tool/plan.ts
  29. 2 1
      packages/opencode/src/tool/skill.ts
  30. 7 13
      packages/opencode/src/tool/truncate.ts
  31. 20 20
      packages/opencode/test/agent/agent.test.ts
  32. 1 1
      packages/opencode/test/config/config.test.ts
  33. 2 2
      packages/opencode/test/effect/runtime.test.ts
  34. 1 1
      packages/opencode/test/format/format.test.ts
  35. 54 54
      packages/opencode/test/permission-task.test.ts
  36. 77 85
      packages/opencode/test/permission/next.test.ts
  37. 2 1
      packages/opencode/test/question/question.test.ts
  38. 1 1
      packages/opencode/test/session/message-v2.test.ts
  39. 22 22
      packages/opencode/test/tool/bash.test.ts
  40. 2 2
      packages/opencode/test/tool/edit.test.ts
  41. 6 6
      packages/opencode/test/tool/external-directory.test.ts
  42. 12 12
      packages/opencode/test/tool/read.test.ts
  43. 2 2
      packages/opencode/test/tool/skill.test.ts
  44. 6 6
      packages/opencode/test/tool/truncation.test.ts

+ 46 - 17
packages/opencode/specs/effect-migration.md

@@ -46,35 +46,64 @@ Rules:
 - Export `defaultLayer` only when wiring dependencies is useful
 - Use the direct namespace form once the module is fully migrated
 
-## Temporary mixed-mode pattern
+## Service / Facade split
 
-Prefer a single namespace whenever possible.
+Migrated services are split into two files:
 
-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.
+- **Service module** (`service.ts`, `*-service.ts`, or `*-effect.ts`) — contains `Interface`, `Service`, `layer`, `defaultLayer`, schemas, types, errors, and pure helpers. Must **never** import `@/effect/runtime`.
+- **Facade** (`index.ts`) — thin async wrapper that calls `runInstance()` or `run()` from `@/effect/run`. Contains **only** runtime-backed convenience functions. No re-exports of schemas, types, Service, layer, or anything else.
 
-```ts
-export namespace FooEffect {
-  export interface Interface {
-    readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
-  }
+### Facade rules (critical for bundle safety)
 
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
+1. **No eager import of `@/effect/runtime`** — use `run()` / `runInstance()` from `@/effect/run` instead, which lazy-imports the runtime.
+2. **No eager import of the service module** if the service is in the circular dependency SCC (auth, account, skill, truncate). Use the lazy `svc()` pattern:
+   ```ts
+   const svc = () => import("./service").then((m) => m.Foo.Service)
+   ```
+3. **No value re-exports** — consumers that need schemas, types, `Service`, or `layer` import from the service module directly.
+4. **Only async wrapper functions** — each function awaits `svc()` and passes an Effect to `run()` / `runInstance()`.
 
-  export const layer = Layer.effect(...)
-}
-```
+### Why
+
+Bun's bundler flattens all modules into a single file. When a circular dependency exists (`runtime → instances → services → config → auth → runtime`), the bundler picks an arbitrary evaluation order. If a facade eagerly imports `@/effect/runtime` or re-exports values from a service in the SCC, those values may be `undefined` when accessed at module load time — causing `undefined is not an object` crashes.
 
-Then keep the old boundary thin:
+The lazy `svc()` + `run()` pattern defers all access to call time, when all modules have finished initializing.
+
+### Example facade
 
 ```ts
-export namespace Foo {
-  export function get(id: FooID) {
-    return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
+// src/question/index.ts (facade)
+import { runInstance } from "@/effect/run"
+import type { Question as S } from "./service"
+
+const svc = () => import("./service").then((m) => m.Question.Service)
+
+export namespace Question {
+  export async function ask(input: { ... }): Promise<S.Answer[]> {
+    return runInstance((await svc()).use((s) => s.ask(input)))
+  }
+
+  export async function list() {
+    return runInstance((await svc()).use((s) => s.list()))
   }
 }
 ```
 
-Remove the `Effect` suffix when the boundary split is gone.
+### Current facades
+
+| Facade | Service module | Scope |
+|---|---|---|
+| `src/question/index.ts` | `src/question/service.ts` | instance |
+| `src/permission/index.ts` | `src/permission/service.ts` | instance |
+| `src/format/index.ts` | `src/format/service.ts` | instance |
+| `src/file/index.ts` | `src/file/service.ts` | instance |
+| `src/file/time.ts` | `src/file/time-service.ts` | instance |
+| `src/provider/auth.ts` | `src/provider/auth-service.ts` | instance |
+| `src/skill/index.ts` | `src/skill/service.ts` | instance |
+| `src/snapshot/index.ts` | `src/snapshot/service.ts` | instance |
+| `src/auth/index.ts` | `src/auth/effect.ts` | global |
+| `src/account/index.ts` | `src/account/effect.ts` | global |
+| `src/tool/truncate.ts` | `src/tool/truncate-effect.ts` | global |
 
 ## Scheduled Tasks
 

+ 2 - 1
packages/opencode/src/account/index.ts

@@ -1,10 +1,11 @@
 import { Option } from "effect"
 import { run } from "@/effect/run"
+import { lazy } from "@/util/lazy"
 import { type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
 
 export { AccessToken, AccountID, OrgID } from "./effect"
 
-const svc = () => import("./effect").then((m) => m.Account.Service)
+const svc = lazy(() => import("./effect").then((m) => m.Account.Service))
 
 export namespace Account {
   export const Info = Model

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

@@ -5,7 +5,7 @@ import { ModelID, ProviderID } from "../provider/schema"
 import { generateObject, streamObject, type ModelMessage } from "ai"
 import { SystemPrompt } from "../session/system"
 import { Instance } from "../project/instance"
-import { Truncate } from "../tool/truncate"
+import { Truncate } from "../tool/truncate-effect"
 import { Auth } from "../auth"
 import { ProviderTransform } from "../provider/transform"
 

+ 2 - 1
packages/opencode/src/auth/index.ts

@@ -1,9 +1,10 @@
 import z from "zod"
 import { run } from "@/effect/run"
+import { lazy } from "@/util/lazy"
 
 export { OAUTH_DUMMY_KEY } from "./effect"
 
-const svc = () => import("./effect").then((m) => m.Auth.Service)
+const svc = lazy(() => import("./effect").then((m) => m.Auth.Service))
 
 export namespace Auth {
   export const Oauth = z

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

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

+ 1 - 1
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 as PermissionNext } from "../../../permission/service"
 import { iife } from "../../../util/iife"
 import { bootstrap } from "../../bootstrap"
 import { cmd } from "../cmd"

+ 1 - 1
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 as PermissionNext } from "../../permission/service"
 import { Tool } from "../../tool/tool"
 import { GlobTool } from "../../tool/glob"
 import { GrepTool } from "../../tool/grep"

+ 5 - 6
packages/opencode/src/config/config.ts

@@ -37,8 +37,8 @@ import { Filesystem } from "@/util/filesystem"
 import { Process } from "@/util/process"
 import { Lock } from "@/util/lock"
 
-const auth = lazy(() => import("../auth").then((x) => x.Auth))
-const account = lazy(() => import("@/account").then((x) => x.Account))
+import { Auth } from "../auth"
+import { Account } from "@/account"
 
 export namespace Config {
   const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -77,7 +77,7 @@ export namespace Config {
   }
 
   export const state = Instance.state(async () => {
-    const entries = await (await auth()).all()
+    const entries = await Auth.all()
 
     // Config loading order (low -> high precedence): https://opencode.ai/docs/config#precedence-order
     // 1) Remote .well-known/opencode (org defaults)
@@ -178,11 +178,10 @@ export namespace Config {
       log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
     }
 
-    const acct = await account()
-    const active = await acct.active()
+    const active = await Account.active()
     if (active?.active_org_id) {
       try {
-        const [config, token] = await Promise.all([acct.config(active.id, active.active_org_id), acct.token(active.id)])
+        const [config, token] = await Promise.all([Account.config(active.id, active.active_org_id), Account.token(active.id)])
         if (token) {
           process.env["OPENCODE_CONSOLE_TOKEN"] = token
           Env.set("OPENCODE_CONSOLE_TOKEN", token)

+ 9 - 6
packages/opencode/src/effect/run.ts

@@ -1,4 +1,7 @@
 import type { Effect } from "effect"
+import type { GlobalServices } from "@/effect/runtime"
+import type { InstanceServices } from "@/effect/instances"
+import { lazy } from "@/util/lazy"
 
 /**
  * Lazy wrappers that defer the import of @/effect/runtime to call time.
@@ -8,14 +11,14 @@ import type { Effect } from "effect"
  * before their dependencies have finished initializing.
  */
 
+const runtime = lazy(() => import("@/effect/runtime"))
+
 /** For global services (Auth, Account, etc.) */
-export async function run<A, E>(effect: Effect.Effect<A, E, any>): Promise<A> {
-  const { runtime } = await import("@/effect/runtime")
-  return runtime.runPromise(effect)
+export async function run<A, E>(effect: Effect.Effect<A, E, GlobalServices>): Promise<A> {
+  return (await runtime()).runtime.runPromise(effect)
 }
 
 /** For instance-scoped services (Skill, Snapshot, Question, etc.) */
-export async function runInstance<A, E>(effect: Effect.Effect<A, E, any>): Promise<A> {
-  const { runPromiseInstance } = await import("@/effect/runtime")
-  return runPromiseInstance(effect)
+export async function runInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>): Promise<A> {
+  return (await runtime()).runPromiseInstance(effect)
 }

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

@@ -7,6 +7,8 @@ import { Installation } from "@/installation"
 import { Truncate } from "@/tool/truncate-effect"
 import { Instance } from "@/project/instance"
 
+export type GlobalServices = Account.Service | Installation.Service | Truncate.Service | Instances | Auth.Service
+
 export const runtime = ManagedRuntime.make(
   Layer.mergeAll(
     Account.defaultLayer,

+ 4 - 14
packages/opencode/src/file/index.ts

@@ -1,20 +1,10 @@
 import { runInstance } from "@/effect/run"
-import { File as S } from "./service"
+import { lazy } from "@/util/lazy"
+import type { File as S } from "./service"
 
-const svc = () => import("./service").then((m) => m.File.Service)
+const svc = lazy(() => import("./service").then((m) => m.File.Service))
 
 export namespace File {
-  export const Info = S.Info
-  export type Info = S.Info
-  export const Node = S.Node
-  export type Node = S.Node
-  export const Content = S.Content
-  export type Content = S.Content
-  export const Event = S.Event
-  export type Interface = S.Interface
-  export const Service = S.Service
-  export const layer = S.layer
-
   export async function init() {
     return runInstance((await svc()).use((s) => s.init()))
   }
@@ -23,7 +13,7 @@ export namespace File {
     return runInstance((await svc()).use((s) => s.status()))
   }
 
-  export async function read(file: string): Promise<Content> {
+  export async function read(file: string): Promise<S.Content> {
     return runInstance((await svc()).use((s) => s.read(file)))
   }
 

+ 2 - 7
packages/opencode/src/file/time.ts

@@ -1,15 +1,10 @@
 import { runInstance } from "@/effect/run"
 import type { SessionID } from "@/session/schema"
-import { FileTime as S } from "./time-service"
+import { lazy } from "@/util/lazy"
 
-const svc = () => import("./time-service").then((m) => m.FileTime.Service)
+const svc = lazy(() => import("./time-service").then((m) => m.FileTime.Service))
 
 export namespace FileTime {
-  export type Stamp = S.Stamp
-  export type Interface = S.Interface
-  export const Service = S.Service
-  export const layer = S.layer
-
   export async function read(sessionID: SessionID, file: string) {
     return runInstance((await svc()).use((s) => s.read(sessionID, file)))
   }

+ 2 - 1
packages/opencode/src/format/index.ts

@@ -1,6 +1,7 @@
 import { runInstance } from "@/effect/run"
+import { lazy } from "@/util/lazy"
 
-const svc = () => import("./service").then((m) => m.Format.Service)
+const svc = lazy(() => import("./service").then((m) => m.Format.Service))
 
 export namespace Format {
   export async function status() {

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

@@ -1,4 +1,5 @@
 import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
+import { run } from "@/effect/run"
 import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
 import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
 import { withTransientReadRetry } from "@/util/effect-http-client"
@@ -329,27 +330,19 @@ 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))
-  }
-
   export function info(): Promise<Info> {
-    return runPromise((svc) => svc.info())
+    return run(Service.use((svc) => svc.info()))
   }
 
   export function method(): Promise<Method> {
-    return runPromise((svc) => svc.method())
+    return run(Service.use((svc) => svc.method()))
   }
 
   export function latest(installMethod?: Method): Promise<string> {
-    return runPromise((svc) => svc.latest(installMethod))
+    return run(Service.use((svc) => svc.latest(installMethod)))
   }
 
   export function upgrade(m: Method, target: string): Promise<void> {
-    return runPromise((svc) => svc.upgrade(m, target))
+    return run(Service.use((svc) => svc.upgrade(m, target)))
   }
 }

+ 5 - 29
packages/opencode/src/permission/index.ts

@@ -1,39 +1,15 @@
 import { runInstance } from "@/effect/run"
-import { Permission as S } from "./service"
+import { lazy } from "@/util/lazy"
+import type { Permission } from "./service"
 
-const svc = () => import("./service").then((m) => m.Permission.Service)
+const svc = lazy(() => import("./service").then((m) => m.Permission.Service))
 
 export namespace PermissionNext {
-  export const Action = S.Action
-  export type Action = S.Action
-  export const Rule = S.Rule
-  export type Rule = S.Rule
-  export type Ruleset = S.Ruleset
-  export const Request = S.Request
-  export type Request = S.Request
-  export const Reply = S.Reply
-  export type Reply = S.Reply
-  export const Approval = S.Approval
-  export const Event = S.Event
-  export const RejectedError = S.RejectedError
-  export const CorrectedError = S.CorrectedError
-  export const DeniedError = S.DeniedError
-  export type Error = S.Error
-  export const AskInput = S.AskInput
-  export const ReplyInput = S.ReplyInput
-  export type Interface = S.Interface
-  export const Service = S.Service
-  export const layer = S.layer
-  export const evaluate = S.evaluate
-  export const fromConfig = S.fromConfig
-  export const merge = S.merge
-  export const disabled = S.disabled
-
-  export async function ask(input: S.AskInput) {
+  export async function ask(input: Permission.AskInput) {
     return runInstance((await svc()).use((s) => s.ask(input)))
   }
 
-  export async function reply(input: S.ReplyInput) {
+  export async function reply(input: Permission.ReplyInput) {
     return runInstance((await svc()).use((s) => s.reply(input)))
   }
 

+ 14 - 36
packages/opencode/src/provider/auth.ts

@@ -1,46 +1,24 @@
 import { runInstance } from "@/effect/run"
-import { fn } from "@/util/fn"
-import { ProviderID } from "./schema"
-import z from "zod"
-import { ProviderAuth as S } from "./auth-service"
+import { lazy } from "@/util/lazy"
+import type { ProviderAuth as S } from "./auth-service"
+import type { ProviderID } from "./schema"
 
-const svc = () => import("./auth-service").then((m) => m.ProviderAuth.Service)
+const svc = lazy(() => import("./auth-service").then((m) => m.ProviderAuth.Service))
 
 export namespace ProviderAuth {
-  export const Method = S.Method
-  export type Method = S.Method
-  export const Authorization = S.Authorization
-  export type Authorization = S.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 type Interface = S.Interface
-  export const Service = S.Service
-  export const layer = S.layer
-  export const defaultLayer = S.defaultLayer
-
   export async function methods() {
     return runInstance((await svc()).use((s) => s.methods()))
   }
 
-  export const authorize = fn(
-    z.object({
-      providerID: ProviderID.zod,
-      method: z.number(),
-      inputs: z.record(z.string(), z.string()).optional(),
-    }),
-    async (input): Promise<Authorization | undefined> =>
-      runInstance((await svc()).use((s) => s.authorize(input))),
-  )
+  export async function authorize(input: {
+    providerID: ProviderID
+    method: number
+    inputs?: Record<string, string>
+  }): Promise<S.Authorization | undefined> {
+    return runInstance((await svc()).use((s) => s.authorize(input)))
+  }
 
-  export const callback = fn(
-    z.object({
-      providerID: ProviderID.zod,
-      method: z.number(),
-      code: z.string().optional(),
-    }),
-    async (input) => runInstance((await svc()).use((s) => s.callback(input))),
-  )
+  export async function callback(input: { providerID: ProviderID; method: number; code?: string }) {
+    return runInstance((await svc()).use((s) => s.callback(input)))
+  }
 }

+ 6 - 21
packages/opencode/src/question/index.ts

@@ -1,36 +1,21 @@
 import { runInstance } from "@/effect/run"
 import type { MessageID, SessionID } from "@/session/schema"
+import { lazy } from "@/util/lazy"
 import type { QuestionID } from "./schema"
-import { Question as S } from "./service"
+import type { Question as S } from "./service"
 
-const svc = () => import("./service").then((m) => m.Question.Service)
+const svc = lazy(() => import("./service").then((m) => m.Question.Service))
 
 export namespace Question {
-  export const Info = S.Info
-  export type Info = S.Info
-  export const Request = S.Request
-  export type Request = S.Request
-  export const Answer = S.Answer
-  export type Answer = S.Answer
-  export const Reply = S.Reply
-  export type Reply = S.Reply
-  export const Option = S.Option
-  export type Option = S.Option
-  export const Event = S.Event
-  export const RejectedError = S.RejectedError
-  export type Interface = S.Interface
-  export const Service = S.Service
-  export const layer = S.layer
-
   export async function ask(input: {
     sessionID: SessionID
-    questions: Info[]
+    questions: S.Info[]
     tool?: { messageID: MessageID; callID: string }
-  }): Promise<Answer[]> {
+  }): Promise<S.Answer[]> {
     return runInstance((await svc()).use((s) => s.ask(input)))
   }
 
-  export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
+  export async function reply(input: { requestID: QuestionID; answers: S.Answer[] }) {
     return runInstance((await svc()).use((s) => s.reply(input)))
   }
 

+ 2 - 1
packages/opencode/src/server/routes/session.ts

@@ -14,6 +14,7 @@ import { Todo } from "../../session/todo"
 import { Agent } from "../../agent/agent"
 import { Snapshot } from "@/snapshot/service"
 import { Log } from "../../util/log"
+import { Permission } from "@/permission/service"
 import { PermissionNext } from "@/permission"
 import { PermissionID } from "@/permission/schema"
 import { ModelID, ProviderID } from "@/provider/schema"
@@ -1010,7 +1011,7 @@ 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({

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

@@ -13,7 +13,7 @@ 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 { runInstance as runPromiseInstance } from "@/effect/run"
 import { Agent } from "../agent/agent"
 import { Skill as SkillService } from "../skill/service"
 import { Skill } from "../skill"

+ 3 - 2
packages/opencode/src/session/processor.ts

@@ -12,7 +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 { Permission as PermissionNext } from "@/permission/service"
+import { PermissionNext as PermissionNextApi } from "@/permission"
 import { Question } from "@/question/service"
 import { PartID } from "./schema"
 import type { SessionID, MessageID } from "./schema"
@@ -163,7 +164,7 @@ export namespace SessionProcessor {
                       )
                     ) {
                       const agent = await Agent.get(input.assistantMessage.agent)
-                      await PermissionNext.ask({
+                      await PermissionNextApi.ask({
                         permission: "doom_loop",
                         patterns: [value.toolName],
                         sessionID: input.assistantMessage.sessionID,

+ 4 - 3
packages/opencode/src/session/prompt.ts

@@ -41,7 +41,8 @@ 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 as PermissionNext } from "@/permission/service"
+import { PermissionNext as PermissionNextApi } from "@/permission"
 import { SessionStatus } from "./status"
 import { LLM } from "./llm"
 import { iife } from "@/util/iife"
@@ -437,7 +438,7 @@ export namespace SessionPrompt {
             } satisfies MessageV2.ToolPart)) as MessageV2.ToolPart
           },
           async ask(req) {
-            await PermissionNext.ask({
+            await PermissionNextApi.ask({
               ...req,
               sessionID: sessionID,
               ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []),
@@ -781,7 +782,7 @@ export namespace SessionPrompt {
         }
       },
       async ask(req) {
-        await PermissionNext.ask({
+        await PermissionNextApi.ask({
           ...req,
           sessionID: input.session.id,
           tool: { messageID: input.processor.message.id, callID: options.toolCallId },

+ 2 - 1
packages/opencode/src/session/revert.ts

@@ -1,5 +1,6 @@
 import z from "zod"
 import { SessionID, MessageID, PartID } from "./schema"
+import { Snapshot as SnapshotService } from "../snapshot/service"
 import { Snapshot } from "../snapshot"
 import { MessageV2 } from "./message-v2"
 import { Session } from "."
@@ -28,7 +29,7 @@ export namespace SessionRevert {
     const session = await Session.get(input.sessionID)
 
     let revert: Session.Info["revert"]
-    const patches: Snapshot.Patch[] = []
+    const patches: SnapshotService.Patch[] = []
     for (const msg of all) {
       if (msg.info.role === "user") lastUser = msg.info
       const remaining = []

+ 2 - 1
packages/opencode/src/session/summary.ts

@@ -5,6 +5,7 @@ import { Session } from "."
 import { MessageV2 } from "./message-v2"
 import { Identifier } from "@/id/id"
 import { SessionID, MessageID } from "./schema"
+import { Snapshot as SnapshotService } from "@/snapshot/service"
 import { Snapshot } from "@/snapshot"
 
 import { Storage } from "@/storage/storage"
@@ -126,7 +127,7 @@ export namespace SessionSummary {
       messageID: MessageID.zod.optional(),
     }),
     async (input) => {
-      const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
+      const diffs = await Storage.read<SnapshotService.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
       const next = diffs.map((item) => {
         const file = unquoteGitPath(item.file)
         if (file === item.file) return item

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

@@ -13,6 +13,7 @@ import type { Provider } from "@/provider/provider"
 import type { Agent } from "@/agent/agent"
 import { Permission as PermissionNext } from "@/permission/service"
 import { Skill } from "@/skill"
+import { Skill as SkillService } from "@/skill/service"
 
 export namespace SystemPrompt {
   export function provider(model: Provider.Model) {
@@ -62,7 +63,7 @@ export namespace SystemPrompt {
       "Use the skill tool to load a skill when a task matches its description.",
       // the agents seem to ingest the information about skills a bit better if we present a more verbose
       // version of them here and a less verbose version in tool description, rather than vice versa.
-      Skill.fmt(list, { verbose: true }),
+      SkillService.fmt(list, { verbose: true }),
     ].join("\n")
   }
 }

+ 2 - 9
packages/opencode/src/skill/index.ts

@@ -1,13 +1,10 @@
 import type { Agent } from "@/agent/agent"
 import { runInstance } from "@/effect/run"
+import { lazy } from "@/util/lazy"
 
-const svc = () => import("./service").then((m) => m.Skill.Service)
-const mod = () => import("./service").then((m) => m.Skill)
+const svc = lazy(() => import("./service").then((m) => m.Skill.Service))
 
 export namespace Skill {
-  export type Info = import("./service").Skill.Info
-  export type Interface = import("./service").Skill.Interface
-
   export async function get(name: string) {
     return runInstance((await svc()).use((s) => s.get(name)))
   }
@@ -23,8 +20,4 @@ export namespace Skill {
   export async function available(agent?: Agent.Info) {
     return runInstance((await svc()).use((s) => s.available(agent)))
   }
-
-  export async function fmt(list: Info[], opts: { verbose: boolean }) {
-    return (await mod()).fmt(list, opts)
-  }
 }

+ 4 - 12
packages/opencode/src/snapshot/index.ts

@@ -1,18 +1,10 @@
 import { runInstance } from "@/effect/run"
-import { Snapshot as S } from "./service"
+import { lazy } from "@/util/lazy"
+import type { Snapshot as S } from "./service"
 
-const svc = () => import("./service").then((m) => m.Snapshot.Service)
+const svc = lazy(() => import("./service").then((m) => m.Snapshot.Service))
 
 export namespace Snapshot {
-  export const Patch = S.Patch
-  export type Patch = S.Patch
-  export const FileDiff = S.FileDiff
-  export type FileDiff = S.FileDiff
-  export type Interface = S.Interface
-  export const Service = S.Service
-  export const layer = S.layer
-  export const defaultLayer = S.defaultLayer
-
   export async function cleanup() {
     return runInstance((await svc()).use((s) => s.cleanup()))
   }
@@ -29,7 +21,7 @@ export namespace Snapshot {
     return runInstance((await svc()).use((s) => s.restore(snapshot)))
   }
 
-  export async function revert(patches: Patch[]) {
+  export async function revert(patches: S.Patch[]) {
     return runInstance((await svc()).use((s) => s.revert(patches)))
   }
 

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

@@ -15,7 +15,7 @@ import { Flag } from "@/flag/flag.ts"
 import { Shell } from "@/shell/shell"
 
 import { BashArity } from "@/permission/arity"
-import { Truncate } from "./truncate"
+import { Truncate } from "./truncate-effect"
 import { Plugin } from "@/plugin"
 
 const MAX_METADATA_LENGTH = 30_000

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

@@ -1,6 +1,7 @@
 import z from "zod"
 import path from "path"
 import { Tool } from "./tool"
+import { Question as QuestionService } from "../question/service"
 import { Question } from "../question"
 import { Session } from "../session"
 import { MessageV2 } from "../session/message-v2"
@@ -39,7 +40,7 @@ export const PlanExitTool = Tool.define("plan_exit", {
     })
 
     const answer = answers[0]?.[0]
-    if (answer === "No") throw new Question.RejectedError()
+    if (answer === "No") throw new QuestionService.RejectedError()
 
     const model = await getLastModel(ctx.sessionID)
 
@@ -97,7 +98,7 @@ export const PlanEnterTool = Tool.define("plan_enter", {
 
     const answer = answers[0]?.[0]
 
-    if (answer === "No") throw new Question.RejectedError()
+    if (answer === "No") throw new QuestionService.RejectedError()
 
     const model = await getLastModel(ctx.sessionID)
 

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

@@ -3,6 +3,7 @@ import { pathToFileURL } from "url"
 import z from "zod"
 import { Tool } from "./tool"
 import { Skill } from "../skill"
+import { Skill as SkillService } from "../skill/service"
 import { Ripgrep } from "../file/ripgrep"
 import { iife } from "@/util/iife"
 
@@ -24,7 +25,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
           "The following skills provide specialized sets of instructions for particular tasks",
           "Invoke this tool to load a skill when a task matches one of the available skills listed below:",
           "",
-          Skill.fmt(list, { verbose: false }),
+          SkillService.fmt(list, { verbose: false }),
         ].join("\n")
 
   const examples = list

+ 7 - 13
packages/opencode/src/tool/truncate.ts

@@ -1,18 +1,12 @@
 import type { Agent } from "../agent/agent"
-import { runtime } from "@/effect/runtime"
-import { Truncate as S } from "./truncate-effect"
+import type { Truncate as S } from "./truncate-effect"
+import { run } from "@/effect/run"
+import { lazy } from "@/util/lazy"
 
-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
-
-  export type Result = S.Result
+const svc = lazy(() => import("./truncate-effect").then((m) => m.Truncate.Service))
 
-  export type Options = S.Options
-
-  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)))
+export namespace Truncate {
+  export async function output(text: string, options: S.Options = {}, agent?: Agent.Info): Promise<S.Result> {
+    return run((await svc()).use((s) => s.output(text, options, agent)))
   }
 }

+ 20 - 20
packages/opencode/test/agent/agent.test.ts

@@ -3,12 +3,12 @@ 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/service"
 
 // 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
 }
 
 test("returns default native agents when no config", async () => {
@@ -54,7 +54,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")
     },
   })
 })
@@ -76,15 +76,15 @@ test("explore agent denies edit and write", async () => {
 })
 
 test("explore agent asks for external directories and allows Truncate.GLOB", async () => {
-  const { Truncate } = await import("../../src/tool/truncate")
+  const { Truncate } = await import("../../src/tool/truncate-effect")
   await using tmp = await tmpdir()
   await Instance.provide({
     directory: tmp.path,
     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 +216,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")
     },
@@ -489,7 +489,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
 })
 
 test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => {
-  const { Truncate } = await import("../../src/tool/truncate")
+  const { Truncate } = await import("../../src/tool/truncate-effect")
   await using tmp = await tmpdir({
     config: {
       permission: {
@@ -501,15 +501,15 @@ 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")
     },
   })
 })
 
 test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
-  const { Truncate } = await import("../../src/tool/truncate")
+  const { Truncate } = await import("../../src/tool/truncate-effect")
   await using tmp = await tmpdir({
     config: {
       agent: {
@@ -525,15 +525,15 @@ 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")
     },
   })
 })
 
 test("explicit Truncate.GLOB deny is respected", async () => {
-  const { Truncate } = await import("../../src/tool/truncate")
+  const { Truncate } = await import("../../src/tool/truncate-effect")
   await using tmp = await tmpdir({
     config: {
       permission: {
@@ -548,8 +548,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 +582,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",

+ 2 - 2
packages/opencode/test/effect/runtime.test.ts

@@ -4,9 +4,9 @@ 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 { ProviderAuth } from "../../src/provider/auth-service"
 import { Vcs } from "../../src/project/vcs"
-import { Question } from "../../src/question"
+import { Question } from "../../src/question/service"
 import { tmpdir } from "../fixture/fixture"
 
 /**

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

@@ -2,7 +2,7 @@ import { Effect } from "effect"
 import { afterEach, describe, expect, test } from "bun:test"
 import { tmpdir } from "../fixture/fixture"
 import { withServices } from "../fixture/instance"
-import { Format } from "../../src/format"
+import { Format } from "../../src/format/service"
 import { Instance } from "../../src/project/instance"
 
 describe("Format", () => {

+ 54 - 54
packages/opencode/test/permission-task.test.ts

@@ -1,11 +1,11 @@
 import { describe, test, expect } from "bun:test"
-import { PermissionNext } from "../src/permission"
+import { Permission } from "../src/permission/service"
 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 =>
+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 +13,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 +56,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 +85,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 +95,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 +113,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 +129,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 +155,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 +180,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 +205,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 +232,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 +270,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 +301,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)
       },
     })

+ 77 - 85
packages/opencode/test/permission/next.test.ts

@@ -5,7 +5,7 @@ import { Bus } from "../../src/bus"
 import { runtime } from "../../src/effect/runtime"
 import { Instances } from "../../src/effect/instances"
 import { PermissionNext } from "../../src/permission"
-import { PermissionNext as S } from "../../src/permission"
+import { Permission as Svc } from "../../src/permission/service"
 import { PermissionID } from "../../src/permission/schema"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
@@ -37,12 +37,12 @@ async function waitForPending(count: number) {
 // fromConfig tests
 
 test("fromConfig - string value becomes wildcard rule", () => {
-  const result = PermissionNext.fromConfig({ bash: "allow" })
+  const result = Svc.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 = Svc.fromConfig({ bash: { "*": "allow", rm: "deny" } })
   expect(result).toEqual([
     { permission: "bash", pattern: "*", action: "allow" },
     { permission: "bash", pattern: "rm", action: "deny" },
@@ -50,7 +50,7 @@ test("fromConfig - object value converts to rules array", () => {
 })
 
 test("fromConfig - mixed string and object values", () => {
-  const result = PermissionNext.fromConfig({
+  const result = Svc.fromConfig({
     bash: { "*": "allow", rm: "deny" },
     edit: "allow",
     webfetch: "ask",
@@ -64,51 +64,51 @@ test("fromConfig - mixed string and object values", () => {
 })
 
 test("fromConfig - empty object", () => {
-  const result = PermissionNext.fromConfig({})
+  const result = Svc.fromConfig({})
   expect(result).toEqual([])
 })
 
 test("fromConfig - expands tilde to home directory", () => {
-  const result = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } })
+  const result = Svc.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 = Svc.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 = Svc.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 = Svc.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 = Svc.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 = Svc.fromConfig({ external_directory: { "~/projects/*": "allow" } })
+  const result = Svc.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 = Svc.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
+  const result = Svc.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 = Svc.merge(
     [{ permission: "bash", pattern: "*", action: "allow" }],
     [{ permission: "bash", pattern: "*", action: "deny" }],
   )
@@ -119,7 +119,7 @@ test("merge - simple concatenation", () => {
 })
 
 test("merge - adds new permission", () => {
-  const result = PermissionNext.merge(
+  const result = Svc.merge(
     [{ permission: "bash", pattern: "*", action: "allow" }],
     [{ permission: "edit", pattern: "*", action: "deny" }],
   )
@@ -130,7 +130,7 @@ test("merge - adds new permission", () => {
 })
 
 test("merge - concatenates rules for same permission", () => {
-  const result = PermissionNext.merge(
+  const result = Svc.merge(
     [{ permission: "bash", pattern: "foo", action: "ask" }],
     [{ permission: "bash", pattern: "*", action: "deny" }],
   )
@@ -141,7 +141,7 @@ test("merge - concatenates rules for same permission", () => {
 })
 
 test("merge - multiple rulesets", () => {
-  const result = PermissionNext.merge(
+  const result = Svc.merge(
     [{ permission: "bash", pattern: "*", action: "allow" }],
     [{ permission: "bash", pattern: "rm", action: "ask" }],
     [{ permission: "edit", pattern: "*", action: "allow" }],
@@ -154,12 +154,12 @@ test("merge - multiple rulesets", () => {
 })
 
 test("merge - empty ruleset does nothing", () => {
-  const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
+  const result = Svc.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 = Svc.merge(
     [
       { permission: "edit", pattern: "src/*", action: "allow" },
       { permission: "edit", pattern: "src/secret/*", action: "deny" },
@@ -175,40 +175,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: Svc.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
+  const config: Svc.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+  const merged = Svc.merge(defaults, config)
 
   // Config's bash allow should override default ask
-  expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("allow")
+  expect(Svc.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(Svc.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: Svc.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+  const config: Svc.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
+  const merged = Svc.merge(defaults, config)
 
   // Config's ask should override default allow
-  expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("ask")
+  expect(Svc.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 = Svc.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 = Svc.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 = Svc.evaluate("bash", "rm", [
     { permission: "bash", pattern: "*", action: "allow" },
     { permission: "bash", pattern: "rm", action: "deny" },
   ])
@@ -216,7 +216,7 @@ test("evaluate - last matching rule wins", () => {
 })
 
 test("evaluate - last matching rule wins (wildcard after specific)", () => {
-  const result = PermissionNext.evaluate("bash", "rm", [
+  const result = Svc.evaluate("bash", "rm", [
     { permission: "bash", pattern: "rm", action: "deny" },
     { permission: "bash", pattern: "*", action: "allow" },
   ])
@@ -224,14 +224,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 = Svc.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 = Svc.evaluate("edit", "src/components/Button.tsx", [
     { permission: "edit", pattern: "src/*", action: "deny" },
     { permission: "edit", pattern: "src/components/*", action: "allow" },
   ])
@@ -240,7 +238,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 = Svc.evaluate("edit", "src/components/Button.tsx", [
     { permission: "edit", pattern: "src/components/*", action: "allow" },
     { permission: "edit", pattern: "src/*", action: "deny" },
   ])
@@ -248,31 +246,27 @@ test("evaluate - order matters for specificity", () => {
 })
 
 test("evaluate - unknown permission returns ask", () => {
-  const result = PermissionNext.evaluate("unknown_tool", "anything", [
-    { permission: "bash", pattern: "*", action: "allow" },
-  ])
+  const result = Svc.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 = Svc.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 = Svc.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 = Svc.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 = Svc.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 +275,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 = Svc.evaluate("edit", "src/foo.ts", [
     { permission: "edit", pattern: "*", action: "ask" },
     { permission: "edit", pattern: "test/*", action: "deny" },
     { permission: "edit", pattern: "src/*", action: "allow" },
@@ -290,7 +284,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 = Svc.evaluate("bash", "/bin/rm", [
     { permission: "bash", pattern: "*", action: "allow" },
     { permission: "bash", pattern: "/bin/rm", action: "deny" },
   ])
@@ -298,7 +292,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 = Svc.evaluate("bash", "/bin/rm", [
     { permission: "bash", pattern: "/bin/rm", action: "deny" },
     { permission: "bash", pattern: "*", action: "allow" },
   ])
@@ -308,24 +302,22 @@ 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 = Svc.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 = Svc.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", [
-    { permission: "mcp_*", pattern: "*", action: "allow" },
-  ])
+  const result = Svc.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 = Svc.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 = Svc.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 = Svc.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 = Svc.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 = Svc.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: Svc.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+  const approved: Svc.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 = Svc.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 = Svc.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 = Svc.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 = Svc.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 = Svc.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 = Svc.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 = Svc.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 = Svc.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 = Svc.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 = Svc.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 = Svc.disabled(
     ["bash", "edit", "read"],
     [
       { permission: "*", pattern: "*", action: "deny" },
@@ -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(Svc.DeniedError)
     },
   })
 })
@@ -588,8 +580,8 @@ 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: Svc.Request | undefined
+      const unsub = Bus.subscribe(Svc.Event.Asked, (event) => {
         seen = event.properties
       })
 
@@ -672,7 +664,7 @@ test("reply - reject throws RejectedError", async () => {
         reply: "reject",
       })
 
-      await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError)
+      await expect(askPromise).rejects.toBeInstanceOf(Svc.RejectedError)
     },
   })
 })
@@ -701,7 +693,7 @@ test("reply - reject with message throws CorrectedError", async () => {
       })
 
       const err = await ask.catch((err) => err)
-      expect(err).toBeInstanceOf(PermissionNext.CorrectedError)
+      expect(err).toBeInstanceOf(Svc.CorrectedError)
       expect(err.message).toContain("Use a safer command")
     },
   })
@@ -788,8 +780,8 @@ test("reply - reject cancels all pending for same session", async () => {
       })
 
       // Both should be rejected
-      expect(await result1).toBeInstanceOf(PermissionNext.RejectedError)
-      expect(await result2).toBeInstanceOf(PermissionNext.RejectedError)
+      expect(await result1).toBeInstanceOf(Svc.RejectedError)
+      expect(await result2).toBeInstanceOf(Svc.RejectedError)
     },
   })
 })
@@ -895,10 +887,10 @@ test("reply - publishes replied event", async () => {
         | {
             sessionID: SessionID
             requestID: PermissionID
-            reply: PermissionNext.Reply
+            reply: Svc.Reply
           }
         | undefined
-      const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => {
+      const unsub = Bus.subscribe(Svc.Event.Replied, (event) => {
         seen = event.properties
       })
 
@@ -949,7 +941,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
             { permission: "bash", pattern: "rm *", action: "deny" },
           ],
         }),
-      ).rejects.toBeInstanceOf(PermissionNext.DeniedError)
+      ).rejects.toBeInstanceOf(Svc.DeniedError)
     },
   })
 })
@@ -992,7 +984,7 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
         (err) => err,
       )
 
-      expect(err).toBeInstanceOf(PermissionNext.DeniedError)
+      expect(err).toBeInstanceOf(Svc.DeniedError)
       expect(await PermissionNext.list()).toHaveLength(0)
     },
   })
@@ -1005,7 +997,7 @@ test("ask - abort should clear pending request", async () => {
     fn: async () => {
       const ctl = new AbortController()
       const ask = runtime.runPromise(
-        S.Service.use((svc) =>
+        Svc.Service.use((svc) =>
           svc.ask({
             sessionID: SessionID.make("session_test"),
             permission: "bash",

+ 2 - 1
packages/opencode/test/question/question.test.ts

@@ -1,5 +1,6 @@
 import { afterEach, test, expect } from "bun:test"
 import { Question } from "../../src/question"
+import { Question as QuestionService } from "../../src/question/service"
 import { Instance } from "../../src/project/instance"
 import { QuestionID } from "../../src/question/schema"
 import { tmpdir } from "../fixture/fixture"
@@ -181,7 +182,7 @@ test("reject - throws RejectedError", async () => {
       const pending = await Question.list()
       await Question.reject(pending[0].id)
 
-      await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError)
+      await expect(askPromise).rejects.toBeInstanceOf(QuestionService.RejectedError)
     },
   })
 })

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

@@ -4,7 +4,7 @@ import { MessageV2 } from "../../src/session/message-v2"
 import type { Provider } from "../../src/provider/provider"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { SessionID, MessageID, PartID } from "../../src/session/schema"
-import { Question } from "../../src/question"
+import { Question } from "../../src/question/service"
 
 const sessionID = SessionID.make("session")
 const providerID = ProviderID.make("test")

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

@@ -5,8 +5,8 @@ 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 { Truncate } from "../../src/tool/truncate"
+import type { Permission } from "../../src/permission/service"
+import { Truncate } from "../../src/tool/truncate-effect"
 import { SessionID, MessageID } from "../../src/session/schema"
 
 const ctx = {
@@ -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)
           },
         }

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

@@ -81,7 +81,7 @@ describe("tool.edit", () => {
         directory: tmp.path,
         fn: async () => {
           const { Bus } = await import("../../src/bus")
-          const { File } = await import("../../src/file")
+          const { File } = await import("../../src/file/service")
           const { FileWatcher } = await import("../../src/file/watcher")
 
           const events: string[] = []
@@ -301,7 +301,7 @@ describe("tool.edit", () => {
           await FileTime.read(ctx.sessionID, filepath)
 
           const { Bus } = await import("../../src/bus")
-          const { File } = await import("../../src/file")
+          const { File } = await import("../../src/file/service")
           const { FileWatcher } = await import("../../src/file/watcher")
 
           const events: string[] = []

+ 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/service"
 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) => {

+ 12 - 12
packages/opencode/test/tool/read.test.ts

@@ -4,7 +4,7 @@ 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/service"
 import { Agent } from "../../src/agent/agent"
 import { SessionID, MessageID } from "../../src/session/schema"
 
@@ -65,10 +65,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 +91,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 +112,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 +138,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 +176,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 })
                 }
               }
             },

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

@@ -1,7 +1,7 @@
 import { 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/service"
 import type { Tool } from "../../src/tool/tool"
 import { Instance } from "../../src/project/instance"
 import { SkillTool } from "../../src/tool/skill"
@@ -133,7 +133,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) => {

+ 6 - 6
packages/opencode/test/tool/truncation.test.ts

@@ -71,8 +71,8 @@ describe("Truncate", () => {
     })
 
     test("uses default MAX_LINES and MAX_BYTES", () => {
-      expect(Truncate.MAX_LINES).toBe(2000)
-      expect(Truncate.MAX_BYTES).toBe(50 * 1024)
+      expect(TruncateSvc.MAX_LINES).toBe(2000)
+      expect(TruncateSvc.MAX_BYTES).toBe(50 * 1024)
     })
 
     test("large single-line file truncates with byte message", async () => {
@@ -81,7 +81,7 @@ describe("Truncate", () => {
 
       expect(result.truncated).toBe(true)
       expect(result.content).toContain("bytes truncated...")
-      expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
+      expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(TruncateSvc.MAX_BYTES)
     })
 
     test("writes full output to file when truncated", async () => {
@@ -145,10 +145,10 @@ describe("Truncate", () => {
       Effect.gen(function* () {
         const fs = yield* FileSystem.FileSystem
 
-        yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
+        yield* fs.makeDirectory(TruncateSvc.DIR, { recursive: true })
 
-        const old = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 10 * DAY_MS))
-        const recent = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 3 * DAY_MS))
+        const old = path.join(TruncateSvc.DIR, Identifier.create("tool", false, Date.now() - 10 * DAY_MS))
+        const recent = path.join(TruncateSvc.DIR, Identifier.create("tool", false, Date.now() - 3 * DAY_MS))
 
         yield* writeFileStringScoped(old, "old content")
         yield* writeFileStringScoped(recent, "recent content")