Bladeren bron

fix: apply Layer.fresh at instance service definition site (#18418)

Kit Langton 4 weken geleden
bovenliggende
commit
d70099b059

+ 19 - 19
packages/opencode/src/effect/instances.ts

@@ -1,15 +1,15 @@
 import { Effect, Layer, LayerMap, ServiceMap } from "effect"
-import { File } from "@/file"
-import { FileTime } from "@/file/time"
+import { File } from "@/file/service"
+import { FileTime } from "@/file/time-service"
 import { FileWatcher } from "@/file/watcher"
-import { Format } from "@/format"
-import { PermissionNext } from "@/permission"
+import { Format } from "@/format/service"
+import { Permission } from "@/permission/service"
 import { Instance } from "@/project/instance"
 import { Vcs } from "@/project/vcs"
-import { ProviderAuth } from "@/provider/auth"
-import { Question } from "@/question"
-import { Skill } from "@/skill/skill"
-import { Snapshot } from "@/snapshot"
+import { ProviderAuth } from "@/provider/auth-service"
+import { Question } from "@/question/service"
+import { Skill } from "@/skill/service"
+import { Snapshot } from "@/snapshot/service"
 import { InstanceContext } from "./instance-context"
 import { registerDisposer } from "./instance-registry"
 
@@ -17,7 +17,7 @@ export { InstanceContext } from "./instance-context"
 
 export type InstanceServices =
   | Question.Service
-  | PermissionNext.Service
+  | Permission.Service
   | ProviderAuth.Service
   | FileWatcher.Service
   | Vcs.Service
@@ -36,16 +36,16 @@ export type InstanceServices =
 function lookup(_key: string) {
   const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
   return Layer.mergeAll(
-    Layer.fresh(Question.layer),
-    Layer.fresh(PermissionNext.layer),
-    Layer.fresh(ProviderAuth.defaultLayer),
-    Layer.fresh(FileWatcher.layer).pipe(Layer.orDie),
-    Layer.fresh(Vcs.layer),
-    Layer.fresh(FileTime.layer).pipe(Layer.orDie),
-    Layer.fresh(Format.layer),
-    Layer.fresh(File.layer),
-    Layer.fresh(Skill.defaultLayer),
-    Layer.fresh(Snapshot.defaultLayer),
+    Question.layer,
+    Permission.layer,
+    ProviderAuth.defaultLayer,
+    FileWatcher.layer,
+    Vcs.layer,
+    FileTime.layer,
+    Format.layer,
+    File.layer,
+    Skill.defaultLayer,
+    Snapshot.defaultLayer,
   ).pipe(Layer.provide(ctx))
 }
 

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

@@ -1,695 +1,40 @@
-import { BusEvent } from "@/bus/bus-event"
-import { InstanceContext } from "@/effect/instance-context"
 import { runPromiseInstance } from "@/effect/runtime"
-import { git } from "@/util/git"
-import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
-import { formatPatch, structuredPatch } from "diff"
-import fs from "fs"
-import fuzzysort from "fuzzysort"
-import ignore from "ignore"
-import path from "path"
-import z from "zod"
-import { Global } from "../global"
-import { Instance } from "../project/instance"
-import { Filesystem } from "../util/filesystem"
-import { Log } from "../util/log"
-import { Protected } from "./protected"
-import { Ripgrep } from "./ripgrep"
+import { File as S } from "./service"
 
 export namespace File {
-  export const Info = z
-    .object({
-      path: z.string(),
-      added: z.number().int(),
-      removed: z.number().int(),
-      status: z.enum(["added", "deleted", "modified"]),
-    })
-    .meta({
-      ref: "File",
-    })
+  export const Info = S.Info
+  export type Info = S.Info
 
-  export type Info = z.infer<typeof Info>
+  export const Node = S.Node
+  export type Node = S.Node
 
-  export const Node = z
-    .object({
-      name: z.string(),
-      path: z.string(),
-      absolute: z.string(),
-      type: z.enum(["file", "directory"]),
-      ignored: z.boolean(),
-    })
-    .meta({
-      ref: "FileNode",
-    })
-  export type Node = z.infer<typeof Node>
+  export const Content = S.Content
+  export type Content = S.Content
 
-  export const Content = z
-    .object({
-      type: z.enum(["text", "binary"]),
-      content: z.string(),
-      diff: z.string().optional(),
-      patch: z
-        .object({
-          oldFileName: z.string(),
-          newFileName: z.string(),
-          oldHeader: z.string().optional(),
-          newHeader: z.string().optional(),
-          hunks: z.array(
-            z.object({
-              oldStart: z.number(),
-              oldLines: z.number(),
-              newStart: z.number(),
-              newLines: z.number(),
-              lines: z.array(z.string()),
-            }),
-          ),
-          index: z.string().optional(),
-        })
-        .optional(),
-      encoding: z.literal("base64").optional(),
-      mimeType: z.string().optional(),
-    })
-    .meta({
-      ref: "FileContent",
-    })
-  export type Content = z.infer<typeof Content>
+  export const Event = S.Event
 
-  export const Event = {
-    Edited: BusEvent.define(
-      "file.edited",
-      z.object({
-        file: z.string(),
-      }),
-    ),
-  }
+  export type Interface = S.Interface
+
+  export const Service = S.Service
+  export const layer = S.layer
 
   export function init() {
-    return runPromiseInstance(Service.use((svc) => svc.init()))
+    return runPromiseInstance(S.Service.use((svc) => svc.init()))
   }
 
   export async function status() {
-    return runPromiseInstance(Service.use((svc) => svc.status()))
+    return runPromiseInstance(S.Service.use((svc) => svc.status()))
   }
 
   export async function read(file: string): Promise<Content> {
-    return runPromiseInstance(Service.use((svc) => svc.read(file)))
+    return runPromiseInstance(S.Service.use((svc) => svc.read(file)))
   }
 
   export async function list(dir?: string) {
-    return runPromiseInstance(Service.use((svc) => svc.list(dir)))
+    return runPromiseInstance(S.Service.use((svc) => svc.list(dir)))
   }
 
   export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
-    return runPromiseInstance(Service.use((svc) => svc.search(input)))
-  }
-
-  const log = Log.create({ service: "file" })
-
-  const binary = new Set([
-    "exe",
-    "dll",
-    "pdb",
-    "bin",
-    "so",
-    "dylib",
-    "o",
-    "a",
-    "lib",
-    "wav",
-    "mp3",
-    "ogg",
-    "oga",
-    "ogv",
-    "ogx",
-    "flac",
-    "aac",
-    "wma",
-    "m4a",
-    "weba",
-    "mp4",
-    "avi",
-    "mov",
-    "wmv",
-    "flv",
-    "webm",
-    "mkv",
-    "zip",
-    "tar",
-    "gz",
-    "gzip",
-    "bz",
-    "bz2",
-    "bzip",
-    "bzip2",
-    "7z",
-    "rar",
-    "xz",
-    "lz",
-    "z",
-    "pdf",
-    "doc",
-    "docx",
-    "ppt",
-    "pptx",
-    "xls",
-    "xlsx",
-    "dmg",
-    "iso",
-    "img",
-    "vmdk",
-    "ttf",
-    "otf",
-    "woff",
-    "woff2",
-    "eot",
-    "sqlite",
-    "db",
-    "mdb",
-    "apk",
-    "ipa",
-    "aab",
-    "xapk",
-    "app",
-    "pkg",
-    "deb",
-    "rpm",
-    "snap",
-    "flatpak",
-    "appimage",
-    "msi",
-    "msp",
-    "jar",
-    "war",
-    "ear",
-    "class",
-    "kotlin_module",
-    "dex",
-    "vdex",
-    "odex",
-    "oat",
-    "art",
-    "wasm",
-    "wat",
-    "bc",
-    "ll",
-    "s",
-    "ko",
-    "sys",
-    "drv",
-    "efi",
-    "rom",
-    "com",
-    "cmd",
-    "ps1",
-    "sh",
-    "bash",
-    "zsh",
-    "fish",
-  ])
-
-  const image = new Set([
-    "png",
-    "jpg",
-    "jpeg",
-    "gif",
-    "bmp",
-    "webp",
-    "ico",
-    "tif",
-    "tiff",
-    "svg",
-    "svgz",
-    "avif",
-    "apng",
-    "jxl",
-    "heic",
-    "heif",
-    "raw",
-    "cr2",
-    "nef",
-    "arw",
-    "dng",
-    "orf",
-    "raf",
-    "pef",
-    "x3f",
-  ])
-
-  const text = new Set([
-    "ts",
-    "tsx",
-    "mts",
-    "cts",
-    "mtsx",
-    "ctsx",
-    "js",
-    "jsx",
-    "mjs",
-    "cjs",
-    "sh",
-    "bash",
-    "zsh",
-    "fish",
-    "ps1",
-    "psm1",
-    "cmd",
-    "bat",
-    "json",
-    "jsonc",
-    "json5",
-    "yaml",
-    "yml",
-    "toml",
-    "md",
-    "mdx",
-    "txt",
-    "xml",
-    "html",
-    "htm",
-    "css",
-    "scss",
-    "sass",
-    "less",
-    "graphql",
-    "gql",
-    "sql",
-    "ini",
-    "cfg",
-    "conf",
-    "env",
-  ])
-
-  const textName = new Set([
-    "dockerfile",
-    "makefile",
-    ".gitignore",
-    ".gitattributes",
-    ".editorconfig",
-    ".npmrc",
-    ".nvmrc",
-    ".prettierrc",
-    ".eslintrc",
-  ])
-
-  const mime: Record<string, string> = {
-    png: "image/png",
-    jpg: "image/jpeg",
-    jpeg: "image/jpeg",
-    gif: "image/gif",
-    bmp: "image/bmp",
-    webp: "image/webp",
-    ico: "image/x-icon",
-    tif: "image/tiff",
-    tiff: "image/tiff",
-    svg: "image/svg+xml",
-    svgz: "image/svg+xml",
-    avif: "image/avif",
-    apng: "image/apng",
-    jxl: "image/jxl",
-    heic: "image/heic",
-    heif: "image/heif",
-  }
-
-  type Entry = { files: string[]; dirs: string[] }
-
-  const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
-  const name = (file: string) => path.basename(file).toLowerCase()
-  const isImageByExtension = (file: string) => image.has(ext(file))
-  const isTextByExtension = (file: string) => text.has(ext(file))
-  const isTextByName = (file: string) => textName.has(name(file))
-  const isBinaryByExtension = (file: string) => binary.has(ext(file))
-  const isImage = (mimeType: string) => mimeType.startsWith("image/")
-  const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
-
-  function shouldEncode(mimeType: string) {
-    const type = mimeType.toLowerCase()
-    log.info("shouldEncode", { type })
-    if (!type) return false
-    if (type.startsWith("text/")) return false
-    if (type.includes("charset=")) return false
-    const top = type.split("/", 2)[0]
-    return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
-  }
-
-  const hidden = (item: string) => {
-    const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
-    return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
+    return runPromiseInstance(S.Service.use((svc) => svc.search(input)))
   }
-
-  const sortHiddenLast = (items: string[], prefer: boolean) => {
-    if (prefer) return items
-    const visible: string[] = []
-    const hiddenItems: string[] = []
-    for (const item of items) {
-      if (hidden(item)) hiddenItems.push(item)
-      else visible.push(item)
-    }
-    return [...visible, ...hiddenItems]
-  }
-
-  export interface Interface {
-    readonly init: () => Effect.Effect<void>
-    readonly status: () => Effect.Effect<File.Info[]>
-    readonly read: (file: string) => Effect.Effect<File.Content>
-    readonly list: (dir?: string) => Effect.Effect<File.Node[]>
-    readonly search: (input: {
-      query: string
-      limit?: number
-      dirs?: boolean
-      type?: "file" | "directory"
-    }) => Effect.Effect<string[]>
-  }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const instance = yield* InstanceContext
-      let cache: Entry = { files: [], dirs: [] }
-      const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
-
-      const scan = Effect.fn("File.scan")(function* () {
-        if (instance.directory === path.parse(instance.directory).root) return
-        const next: Entry = { files: [], dirs: [] }
-
-        yield* Effect.promise(async () => {
-          if (isGlobalHome) {
-            const dirs = new Set<string>()
-            const protectedNames = Protected.names()
-            const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
-            const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
-            const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
-            const top = await fs.promises
-              .readdir(instance.directory, { withFileTypes: true })
-              .catch(() => [] as fs.Dirent[])
-
-            for (const entry of top) {
-              if (!entry.isDirectory()) continue
-              if (shouldIgnoreName(entry.name)) continue
-              dirs.add(entry.name + "/")
-
-              const base = path.join(instance.directory, entry.name)
-              const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
-              for (const child of children) {
-                if (!child.isDirectory()) continue
-                if (shouldIgnoreNested(child.name)) continue
-                dirs.add(entry.name + "/" + child.name + "/")
-              }
-            }
-
-            next.dirs = Array.from(dirs).toSorted()
-          } else {
-            const seen = new Set<string>()
-            for await (const file of Ripgrep.files({ cwd: instance.directory })) {
-              next.files.push(file)
-              let current = file
-              while (true) {
-                const dir = path.dirname(current)
-                if (dir === ".") break
-                if (dir === current) break
-                current = dir
-                if (seen.has(dir)) continue
-                seen.add(dir)
-                next.dirs.push(dir + "/")
-              }
-            }
-          }
-        })
-
-        cache = next
-      })
-
-      const getFiles = () => cache
-
-      const scope = yield* Scope.Scope
-      let fiber: Fiber.Fiber<void> | undefined
-
-      const init = Effect.fn("File.init")(function* () {
-        if (!fiber) {
-          fiber = yield* scan().pipe(
-            Effect.catchCause(() => Effect.void),
-            Effect.forkIn(scope),
-          )
-        }
-        yield* Fiber.join(fiber)
-      })
-
-      const status = Effect.fn("File.status")(function* () {
-        if (instance.project.vcs !== "git") return []
-
-        return yield* Effect.promise(async () => {
-          const diffOutput = (
-            await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
-              cwd: instance.directory,
-            })
-          ).text()
-
-          const changed: File.Info[] = []
-
-          if (diffOutput.trim()) {
-            for (const line of diffOutput.trim().split("\n")) {
-              const [added, removed, file] = line.split("\t")
-              changed.push({
-                path: file,
-                added: added === "-" ? 0 : parseInt(added, 10),
-                removed: removed === "-" ? 0 : parseInt(removed, 10),
-                status: "modified",
-              })
-            }
-          }
-
-          const untrackedOutput = (
-            await git(
-              [
-                "-c",
-                "core.fsmonitor=false",
-                "-c",
-                "core.quotepath=false",
-                "ls-files",
-                "--others",
-                "--exclude-standard",
-              ],
-              {
-                cwd: instance.directory,
-              },
-            )
-          ).text()
-
-          if (untrackedOutput.trim()) {
-            for (const file of untrackedOutput.trim().split("\n")) {
-              try {
-                const content = await Filesystem.readText(path.join(instance.directory, file))
-                changed.push({
-                  path: file,
-                  added: content.split("\n").length,
-                  removed: 0,
-                  status: "added",
-                })
-              } catch {
-                continue
-              }
-            }
-          }
-
-          const deletedOutput = (
-            await git(
-              [
-                "-c",
-                "core.fsmonitor=false",
-                "-c",
-                "core.quotepath=false",
-                "diff",
-                "--name-only",
-                "--diff-filter=D",
-                "HEAD",
-              ],
-              {
-                cwd: instance.directory,
-              },
-            )
-          ).text()
-
-          if (deletedOutput.trim()) {
-            for (const file of deletedOutput.trim().split("\n")) {
-              changed.push({
-                path: file,
-                added: 0,
-                removed: 0,
-                status: "deleted",
-              })
-            }
-          }
-
-          return changed.map((item) => {
-            const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
-            return {
-              ...item,
-              path: path.relative(instance.directory, full),
-            }
-          })
-        })
-      })
-
-      const read = Effect.fn("File.read")(function* (file: string) {
-        return yield* Effect.promise(async (): Promise<File.Content> => {
-          using _ = log.time("read", { file })
-          const full = path.join(instance.directory, file)
-
-          if (!Instance.containsPath(full)) {
-            throw new Error("Access denied: path escapes project directory")
-          }
-
-          if (isImageByExtension(file)) {
-            if (await Filesystem.exists(full)) {
-              const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
-              return {
-                type: "text",
-                content: buffer.toString("base64"),
-                mimeType: getImageMimeType(file),
-                encoding: "base64",
-              }
-            }
-            return { type: "text", content: "" }
-          }
-
-          const knownText = isTextByExtension(file) || isTextByName(file)
-
-          if (isBinaryByExtension(file) && !knownText) {
-            return { type: "binary", content: "" }
-          }
-
-          if (!(await Filesystem.exists(full))) {
-            return { type: "text", content: "" }
-          }
-
-          const mimeType = Filesystem.mimeType(full)
-          const encode = knownText ? false : shouldEncode(mimeType)
-
-          if (encode && !isImage(mimeType)) {
-            return { type: "binary", content: "", mimeType }
-          }
-
-          if (encode) {
-            const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
-            return {
-              type: "text",
-              content: buffer.toString("base64"),
-              mimeType,
-              encoding: "base64",
-            }
-          }
-
-          const content = (await Filesystem.readText(full).catch(() => "")).trim()
-
-          if (instance.project.vcs === "git") {
-            let diff = (
-              await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
-            ).text()
-            if (!diff.trim()) {
-              diff = (
-                await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
-                  cwd: instance.directory,
-                })
-              ).text()
-            }
-            if (diff.trim()) {
-              const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
-              const patch = structuredPatch(file, file, original, content, "old", "new", {
-                context: Infinity,
-                ignoreWhitespace: true,
-              })
-              return {
-                type: "text",
-                content,
-                patch,
-                diff: formatPatch(patch),
-              }
-            }
-          }
-
-          return { type: "text", content }
-        })
-      })
-
-      const list = Effect.fn("File.list")(function* (dir?: string) {
-        return yield* Effect.promise(async () => {
-          const exclude = [".git", ".DS_Store"]
-          let ignored = (_: string) => false
-          if (instance.project.vcs === "git") {
-            const ig = ignore()
-            const gitignore = path.join(instance.project.worktree, ".gitignore")
-            if (await Filesystem.exists(gitignore)) {
-              ig.add(await Filesystem.readText(gitignore))
-            }
-            const ignoreFile = path.join(instance.project.worktree, ".ignore")
-            if (await Filesystem.exists(ignoreFile)) {
-              ig.add(await Filesystem.readText(ignoreFile))
-            }
-            ignored = ig.ignores.bind(ig)
-          }
-
-          const resolved = dir ? path.join(instance.directory, dir) : instance.directory
-          if (!Instance.containsPath(resolved)) {
-            throw new Error("Access denied: path escapes project directory")
-          }
-
-          const nodes: File.Node[] = []
-          for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
-            if (exclude.includes(entry.name)) continue
-            const absolute = path.join(resolved, entry.name)
-            const file = path.relative(instance.directory, absolute)
-            const type = entry.isDirectory() ? "directory" : "file"
-            nodes.push({
-              name: entry.name,
-              path: file,
-              absolute,
-              type,
-              ignored: ignored(type === "directory" ? file + "/" : file),
-            })
-          }
-
-          return nodes.sort((a, b) => {
-            if (a.type !== b.type) return a.type === "directory" ? -1 : 1
-            return a.name.localeCompare(b.name)
-          })
-        })
-      })
-
-      const search = Effect.fn("File.search")(function* (input: {
-        query: string
-        limit?: number
-        dirs?: boolean
-        type?: "file" | "directory"
-      }) {
-        return yield* Effect.promise(async () => {
-          const query = input.query.trim()
-          const limit = input.limit ?? 100
-          const kind = input.type ?? (input.dirs === false ? "file" : "all")
-          log.info("search", { query, kind })
-
-          const result = getFiles()
-          const preferHidden = query.startsWith(".") || query.includes("/.")
-
-          if (!query) {
-            if (kind === "file") return result.files.slice(0, limit)
-            return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
-          }
-
-          const items =
-            kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
-
-          const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
-          const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
-          const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
-
-          log.info("search", { query, kind, results: output.length })
-          return output
-        })
-      })
-
-      log.info("init")
-      return Service.of({ init, status, read, list, search })
-    }),
-  )
 }

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

@@ -0,0 +1,674 @@
+import { BusEvent } from "@/bus/bus-event"
+import { InstanceContext } from "@/effect/instance-context"
+import { git } from "@/util/git"
+import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
+import { formatPatch, structuredPatch } from "diff"
+import fs from "fs"
+import fuzzysort from "fuzzysort"
+import ignore from "ignore"
+import path from "path"
+import z from "zod"
+import { Global } from "../global"
+import { Instance } from "../project/instance"
+import { Filesystem } from "../util/filesystem"
+import { Log } from "../util/log"
+import { Protected } from "./protected"
+import { Ripgrep } from "./ripgrep"
+
+export namespace File {
+  export const Info = z
+    .object({
+      path: z.string(),
+      added: z.number().int(),
+      removed: z.number().int(),
+      status: z.enum(["added", "deleted", "modified"]),
+    })
+    .meta({
+      ref: "File",
+    })
+
+  export type Info = z.infer<typeof Info>
+
+  export const Node = z
+    .object({
+      name: z.string(),
+      path: z.string(),
+      absolute: z.string(),
+      type: z.enum(["file", "directory"]),
+      ignored: z.boolean(),
+    })
+    .meta({
+      ref: "FileNode",
+    })
+  export type Node = z.infer<typeof Node>
+
+  export const Content = z
+    .object({
+      type: z.enum(["text", "binary"]),
+      content: z.string(),
+      diff: z.string().optional(),
+      patch: z
+        .object({
+          oldFileName: z.string(),
+          newFileName: z.string(),
+          oldHeader: z.string().optional(),
+          newHeader: z.string().optional(),
+          hunks: z.array(
+            z.object({
+              oldStart: z.number(),
+              oldLines: z.number(),
+              newStart: z.number(),
+              newLines: z.number(),
+              lines: z.array(z.string()),
+            }),
+          ),
+          index: z.string().optional(),
+        })
+        .optional(),
+      encoding: z.literal("base64").optional(),
+      mimeType: z.string().optional(),
+    })
+    .meta({
+      ref: "FileContent",
+    })
+  export type Content = z.infer<typeof Content>
+
+  export const Event = {
+    Edited: BusEvent.define(
+      "file.edited",
+      z.object({
+        file: z.string(),
+      }),
+    ),
+  }
+
+  const log = Log.create({ service: "file" })
+
+  const binary = new Set([
+    "exe",
+    "dll",
+    "pdb",
+    "bin",
+    "so",
+    "dylib",
+    "o",
+    "a",
+    "lib",
+    "wav",
+    "mp3",
+    "ogg",
+    "oga",
+    "ogv",
+    "ogx",
+    "flac",
+    "aac",
+    "wma",
+    "m4a",
+    "weba",
+    "mp4",
+    "avi",
+    "mov",
+    "wmv",
+    "flv",
+    "webm",
+    "mkv",
+    "zip",
+    "tar",
+    "gz",
+    "gzip",
+    "bz",
+    "bz2",
+    "bzip",
+    "bzip2",
+    "7z",
+    "rar",
+    "xz",
+    "lz",
+    "z",
+    "pdf",
+    "doc",
+    "docx",
+    "ppt",
+    "pptx",
+    "xls",
+    "xlsx",
+    "dmg",
+    "iso",
+    "img",
+    "vmdk",
+    "ttf",
+    "otf",
+    "woff",
+    "woff2",
+    "eot",
+    "sqlite",
+    "db",
+    "mdb",
+    "apk",
+    "ipa",
+    "aab",
+    "xapk",
+    "app",
+    "pkg",
+    "deb",
+    "rpm",
+    "snap",
+    "flatpak",
+    "appimage",
+    "msi",
+    "msp",
+    "jar",
+    "war",
+    "ear",
+    "class",
+    "kotlin_module",
+    "dex",
+    "vdex",
+    "odex",
+    "oat",
+    "art",
+    "wasm",
+    "wat",
+    "bc",
+    "ll",
+    "s",
+    "ko",
+    "sys",
+    "drv",
+    "efi",
+    "rom",
+    "com",
+    "cmd",
+    "ps1",
+    "sh",
+    "bash",
+    "zsh",
+    "fish",
+  ])
+
+  const image = new Set([
+    "png",
+    "jpg",
+    "jpeg",
+    "gif",
+    "bmp",
+    "webp",
+    "ico",
+    "tif",
+    "tiff",
+    "svg",
+    "svgz",
+    "avif",
+    "apng",
+    "jxl",
+    "heic",
+    "heif",
+    "raw",
+    "cr2",
+    "nef",
+    "arw",
+    "dng",
+    "orf",
+    "raf",
+    "pef",
+    "x3f",
+  ])
+
+  const text = new Set([
+    "ts",
+    "tsx",
+    "mts",
+    "cts",
+    "mtsx",
+    "ctsx",
+    "js",
+    "jsx",
+    "mjs",
+    "cjs",
+    "sh",
+    "bash",
+    "zsh",
+    "fish",
+    "ps1",
+    "psm1",
+    "cmd",
+    "bat",
+    "json",
+    "jsonc",
+    "json5",
+    "yaml",
+    "yml",
+    "toml",
+    "md",
+    "mdx",
+    "txt",
+    "xml",
+    "html",
+    "htm",
+    "css",
+    "scss",
+    "sass",
+    "less",
+    "graphql",
+    "gql",
+    "sql",
+    "ini",
+    "cfg",
+    "conf",
+    "env",
+  ])
+
+  const textName = new Set([
+    "dockerfile",
+    "makefile",
+    ".gitignore",
+    ".gitattributes",
+    ".editorconfig",
+    ".npmrc",
+    ".nvmrc",
+    ".prettierrc",
+    ".eslintrc",
+  ])
+
+  const mime: Record<string, string> = {
+    png: "image/png",
+    jpg: "image/jpeg",
+    jpeg: "image/jpeg",
+    gif: "image/gif",
+    bmp: "image/bmp",
+    webp: "image/webp",
+    ico: "image/x-icon",
+    tif: "image/tiff",
+    tiff: "image/tiff",
+    svg: "image/svg+xml",
+    svgz: "image/svg+xml",
+    avif: "image/avif",
+    apng: "image/apng",
+    jxl: "image/jxl",
+    heic: "image/heic",
+    heif: "image/heif",
+  }
+
+  type Entry = { files: string[]; dirs: string[] }
+
+  const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
+  const name = (file: string) => path.basename(file).toLowerCase()
+  const isImageByExtension = (file: string) => image.has(ext(file))
+  const isTextByExtension = (file: string) => text.has(ext(file))
+  const isTextByName = (file: string) => textName.has(name(file))
+  const isBinaryByExtension = (file: string) => binary.has(ext(file))
+  const isImage = (mimeType: string) => mimeType.startsWith("image/")
+  const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
+
+  function shouldEncode(mimeType: string) {
+    const type = mimeType.toLowerCase()
+    log.info("shouldEncode", { type })
+    if (!type) return false
+    if (type.startsWith("text/")) return false
+    if (type.includes("charset=")) return false
+    const top = type.split("/", 2)[0]
+    return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
+  }
+
+  const hidden = (item: string) => {
+    const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
+    return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
+  }
+
+  const sortHiddenLast = (items: string[], prefer: boolean) => {
+    if (prefer) return items
+    const visible: string[] = []
+    const hiddenItems: string[] = []
+    for (const item of items) {
+      if (hidden(item)) hiddenItems.push(item)
+      else visible.push(item)
+    }
+    return [...visible, ...hiddenItems]
+  }
+
+  export interface Interface {
+    readonly init: () => Effect.Effect<void>
+    readonly status: () => Effect.Effect<File.Info[]>
+    readonly read: (file: string) => Effect.Effect<File.Content>
+    readonly list: (dir?: string) => Effect.Effect<File.Node[]>
+    readonly search: (input: {
+      query: string
+      limit?: number
+      dirs?: boolean
+      type?: "file" | "directory"
+    }) => Effect.Effect<string[]>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const instance = yield* InstanceContext
+      let cache: Entry = { files: [], dirs: [] }
+      const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
+
+      const scan = Effect.fn("File.scan")(function* () {
+        if (instance.directory === path.parse(instance.directory).root) return
+        const next: Entry = { files: [], dirs: [] }
+
+        yield* Effect.promise(async () => {
+          if (isGlobalHome) {
+            const dirs = new Set<string>()
+            const protectedNames = Protected.names()
+            const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
+            const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
+            const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
+            const top = await fs.promises
+              .readdir(instance.directory, { withFileTypes: true })
+              .catch(() => [] as fs.Dirent[])
+
+            for (const entry of top) {
+              if (!entry.isDirectory()) continue
+              if (shouldIgnoreName(entry.name)) continue
+              dirs.add(entry.name + "/")
+
+              const base = path.join(instance.directory, entry.name)
+              const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
+              for (const child of children) {
+                if (!child.isDirectory()) continue
+                if (shouldIgnoreNested(child.name)) continue
+                dirs.add(entry.name + "/" + child.name + "/")
+              }
+            }
+
+            next.dirs = Array.from(dirs).toSorted()
+          } else {
+            const seen = new Set<string>()
+            for await (const file of Ripgrep.files({ cwd: instance.directory })) {
+              next.files.push(file)
+              let current = file
+              while (true) {
+                const dir = path.dirname(current)
+                if (dir === ".") break
+                if (dir === current) break
+                current = dir
+                if (seen.has(dir)) continue
+                seen.add(dir)
+                next.dirs.push(dir + "/")
+              }
+            }
+          }
+        })
+
+        cache = next
+      })
+
+      const getFiles = () => cache
+
+      const scope = yield* Scope.Scope
+      let fiber: Fiber.Fiber<void> | undefined
+
+      const init = Effect.fn("File.init")(function* () {
+        if (!fiber) {
+          fiber = yield* scan().pipe(
+            Effect.catchCause(() => Effect.void),
+            Effect.forkIn(scope),
+          )
+        }
+        yield* Fiber.join(fiber)
+      })
+
+      const status = Effect.fn("File.status")(function* () {
+        if (instance.project.vcs !== "git") return []
+
+        return yield* Effect.promise(async () => {
+          const diffOutput = (
+            await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
+              cwd: instance.directory,
+            })
+          ).text()
+
+          const changed: File.Info[] = []
+
+          if (diffOutput.trim()) {
+            for (const line of diffOutput.trim().split("\n")) {
+              const [added, removed, file] = line.split("\t")
+              changed.push({
+                path: file,
+                added: added === "-" ? 0 : parseInt(added, 10),
+                removed: removed === "-" ? 0 : parseInt(removed, 10),
+                status: "modified",
+              })
+            }
+          }
+
+          const untrackedOutput = (
+            await git(
+              [
+                "-c",
+                "core.fsmonitor=false",
+                "-c",
+                "core.quotepath=false",
+                "ls-files",
+                "--others",
+                "--exclude-standard",
+              ],
+              {
+                cwd: instance.directory,
+              },
+            )
+          ).text()
+
+          if (untrackedOutput.trim()) {
+            for (const file of untrackedOutput.trim().split("\n")) {
+              try {
+                const content = await Filesystem.readText(path.join(instance.directory, file))
+                changed.push({
+                  path: file,
+                  added: content.split("\n").length,
+                  removed: 0,
+                  status: "added",
+                })
+              } catch {
+                continue
+              }
+            }
+          }
+
+          const deletedOutput = (
+            await git(
+              [
+                "-c",
+                "core.fsmonitor=false",
+                "-c",
+                "core.quotepath=false",
+                "diff",
+                "--name-only",
+                "--diff-filter=D",
+                "HEAD",
+              ],
+              {
+                cwd: instance.directory,
+              },
+            )
+          ).text()
+
+          if (deletedOutput.trim()) {
+            for (const file of deletedOutput.trim().split("\n")) {
+              changed.push({
+                path: file,
+                added: 0,
+                removed: 0,
+                status: "deleted",
+              })
+            }
+          }
+
+          return changed.map((item) => {
+            const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
+            return {
+              ...item,
+              path: path.relative(instance.directory, full),
+            }
+          })
+        })
+      })
+
+      const read = Effect.fn("File.read")(function* (file: string) {
+        return yield* Effect.promise(async (): Promise<File.Content> => {
+          using _ = log.time("read", { file })
+          const full = path.join(instance.directory, file)
+
+          if (!Instance.containsPath(full)) {
+            throw new Error("Access denied: path escapes project directory")
+          }
+
+          if (isImageByExtension(file)) {
+            if (await Filesystem.exists(full)) {
+              const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
+              return {
+                type: "text",
+                content: buffer.toString("base64"),
+                mimeType: getImageMimeType(file),
+                encoding: "base64",
+              }
+            }
+            return { type: "text", content: "" }
+          }
+
+          const knownText = isTextByExtension(file) || isTextByName(file)
+
+          if (isBinaryByExtension(file) && !knownText) {
+            return { type: "binary", content: "" }
+          }
+
+          if (!(await Filesystem.exists(full))) {
+            return { type: "text", content: "" }
+          }
+
+          const mimeType = Filesystem.mimeType(full)
+          const encode = knownText ? false : shouldEncode(mimeType)
+
+          if (encode && !isImage(mimeType)) {
+            return { type: "binary", content: "", mimeType }
+          }
+
+          if (encode) {
+            const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
+            return {
+              type: "text",
+              content: buffer.toString("base64"),
+              mimeType,
+              encoding: "base64",
+            }
+          }
+
+          const content = (await Filesystem.readText(full).catch(() => "")).trim()
+
+          if (instance.project.vcs === "git") {
+            let diff = (
+              await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
+            ).text()
+            if (!diff.trim()) {
+              diff = (
+                await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
+                  cwd: instance.directory,
+                })
+              ).text()
+            }
+            if (diff.trim()) {
+              const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
+              const patch = structuredPatch(file, file, original, content, "old", "new", {
+                context: Infinity,
+                ignoreWhitespace: true,
+              })
+              return {
+                type: "text",
+                content,
+                patch,
+                diff: formatPatch(patch),
+              }
+            }
+          }
+
+          return { type: "text", content }
+        })
+      })
+
+      const list = Effect.fn("File.list")(function* (dir?: string) {
+        return yield* Effect.promise(async () => {
+          const exclude = [".git", ".DS_Store"]
+          let ignored = (_: string) => false
+          if (instance.project.vcs === "git") {
+            const ig = ignore()
+            const gitignore = path.join(instance.project.worktree, ".gitignore")
+            if (await Filesystem.exists(gitignore)) {
+              ig.add(await Filesystem.readText(gitignore))
+            }
+            const ignoreFile = path.join(instance.project.worktree, ".ignore")
+            if (await Filesystem.exists(ignoreFile)) {
+              ig.add(await Filesystem.readText(ignoreFile))
+            }
+            ignored = ig.ignores.bind(ig)
+          }
+
+          const resolved = dir ? path.join(instance.directory, dir) : instance.directory
+          if (!Instance.containsPath(resolved)) {
+            throw new Error("Access denied: path escapes project directory")
+          }
+
+          const nodes: File.Node[] = []
+          for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
+            if (exclude.includes(entry.name)) continue
+            const absolute = path.join(resolved, entry.name)
+            const file = path.relative(instance.directory, absolute)
+            const type = entry.isDirectory() ? "directory" : "file"
+            nodes.push({
+              name: entry.name,
+              path: file,
+              absolute,
+              type,
+              ignored: ignored(type === "directory" ? file + "/" : file),
+            })
+          }
+
+          return nodes.sort((a, b) => {
+            if (a.type !== b.type) return a.type === "directory" ? -1 : 1
+            return a.name.localeCompare(b.name)
+          })
+        })
+      })
+
+      const search = Effect.fn("File.search")(function* (input: {
+        query: string
+        limit?: number
+        dirs?: boolean
+        type?: "file" | "directory"
+      }) {
+        return yield* Effect.promise(async () => {
+          const query = input.query.trim()
+          const limit = input.limit ?? 100
+          const kind = input.type ?? (input.dirs === false ? "file" : "all")
+          log.info("search", { query, kind })
+
+          const result = getFiles()
+          const preferHidden = query.startsWith(".") || query.includes("/.")
+
+          if (!query) {
+            if (kind === "file") return result.files.slice(0, limit)
+            return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
+          }
+
+          const items =
+            kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
+
+          const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
+          const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
+          const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
+
+          log.info("search", { query, kind, results: output.length })
+          return output
+        })
+      })
+
+      log.info("init")
+      return Service.of({ init, status, read, list, search })
+    }),
+  ).pipe(Layer.fresh)
+}

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

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

+ 9 - 91
packages/opencode/src/file/time.ts

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

+ 1 - 1
packages/opencode/src/file/watcher.ts

@@ -137,5 +137,5 @@ export namespace FileWatcher {
         return Effect.succeed(Service.of({}))
       }),
     ),
-  )
+  ).pipe(Layer.orDie, Layer.fresh)
 }

+ 7 - 148
packages/opencode/src/format/index.ts

@@ -1,157 +1,16 @@
-import { Effect, Layer, ServiceMap } from "effect"
 import { runPromiseInstance } from "@/effect/runtime"
-import { InstanceContext } from "@/effect/instance-context"
-import path from "path"
-import { mergeDeep } from "remeda"
-import z from "zod"
-import { Bus } from "../bus"
-import { Config } from "../config/config"
-import { File } from "../file"
-import { Instance } from "../project/instance"
-import { Process } from "../util/process"
-import { Log } from "../util/log"
-import * as Formatter from "./formatter"
+import { Format as S } from "./service"
 
 export namespace Format {
-  const log = Log.create({ service: "format" })
+  export const Status = S.Status
+  export type Status = S.Status
 
-  export const Status = z
-    .object({
-      name: z.string(),
-      extensions: z.string().array(),
-      enabled: z.boolean(),
-    })
-    .meta({
-      ref: "FormatterStatus",
-    })
-  export type Status = z.infer<typeof Status>
+  export type Interface = S.Interface
 
-  export interface Interface {
-    readonly status: () => Effect.Effect<Status[]>
-  }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const instance = yield* InstanceContext
-
-      const enabled: Record<string, boolean> = {}
-      const formatters: Record<string, Formatter.Info> = {}
-
-      const cfg = yield* Effect.promise(() => Config.get())
-
-      if (cfg.formatter !== false) {
-        for (const item of Object.values(Formatter)) {
-          formatters[item.name] = item
-        }
-        for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
-          if (item.disabled) {
-            delete formatters[name]
-            continue
-          }
-          const info = mergeDeep(formatters[name] ?? {}, {
-            command: [],
-            extensions: [],
-            ...item,
-          })
-
-          if (info.command.length === 0) continue
-
-          formatters[name] = {
-            ...info,
-            name,
-            enabled: async () => true,
-          }
-        }
-      } else {
-        log.info("all formatters are disabled")
-      }
-
-      async function isEnabled(item: Formatter.Info) {
-        let status = enabled[item.name]
-        if (status === undefined) {
-          status = await item.enabled()
-          enabled[item.name] = status
-        }
-        return status
-      }
-
-      async function getFormatter(ext: string) {
-        const result = []
-        for (const item of Object.values(formatters)) {
-          log.info("checking", { name: item.name, ext })
-          if (!item.extensions.includes(ext)) continue
-          if (!(await isEnabled(item))) continue
-          log.info("enabled", { name: item.name, ext })
-          result.push(item)
-        }
-        return result
-      }
-
-      yield* Effect.acquireRelease(
-        Effect.sync(() =>
-          Bus.subscribe(
-            File.Event.Edited,
-            Instance.bind(async (payload) => {
-              const file = payload.properties.file
-              log.info("formatting", { file })
-              const ext = path.extname(file)
-
-              for (const item of await getFormatter(ext)) {
-                log.info("running", { command: item.command })
-                try {
-                  const proc = Process.spawn(
-                    item.command.map((x) => x.replace("$FILE", file)),
-                    {
-                      cwd: instance.directory,
-                      env: { ...process.env, ...item.environment },
-                      stdout: "ignore",
-                      stderr: "ignore",
-                    },
-                  )
-                  const exit = await proc.exited
-                  if (exit !== 0) {
-                    log.error("failed", {
-                      command: item.command,
-                      ...item.environment,
-                    })
-                  }
-                } catch (error) {
-                  log.error("failed to format file", {
-                    error,
-                    command: item.command,
-                    ...item.environment,
-                    file,
-                  })
-                }
-              }
-            }),
-          ),
-        ),
-        (unsubscribe) => Effect.sync(unsubscribe),
-      )
-      log.info("init")
-
-      const status = Effect.fn("Format.status")(function* () {
-        const result: Status[] = []
-        for (const formatter of Object.values(formatters)) {
-          const isOn = yield* Effect.promise(() => isEnabled(formatter))
-          result.push({
-            name: formatter.name,
-            extensions: formatter.extensions,
-            enabled: isOn,
-          })
-        }
-        return result
-      })
-
-      return Service.of({ status })
-    }),
-  )
+  export const Service = S.Service
+  export const layer = S.layer
 
   export async function status() {
-    return runPromiseInstance(Service.use((s) => s.status()))
+    return runPromiseInstance(S.Service.use((s) => s.status()))
   }
 }

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

@@ -0,0 +1,152 @@
+import { Effect, Layer, ServiceMap } from "effect"
+import { InstanceContext } from "@/effect/instance-context"
+import path from "path"
+import { mergeDeep } from "remeda"
+import z from "zod"
+import { Bus } from "../bus"
+import { Config } from "../config/config"
+import { File } from "../file/service"
+import { Instance } from "../project/instance"
+import { Process } from "../util/process"
+import { Log } from "../util/log"
+import * as Formatter from "./formatter"
+
+export namespace Format {
+  const log = Log.create({ service: "format" })
+
+  export const Status = z
+    .object({
+      name: z.string(),
+      extensions: z.string().array(),
+      enabled: z.boolean(),
+    })
+    .meta({
+      ref: "FormatterStatus",
+    })
+  export type Status = z.infer<typeof Status>
+
+  export interface Interface {
+    readonly status: () => Effect.Effect<Status[]>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const instance = yield* InstanceContext
+
+      const enabled: Record<string, boolean> = {}
+      const formatters: Record<string, Formatter.Info> = {}
+
+      const cfg = yield* Effect.promise(() => Config.get())
+
+      if (cfg.formatter !== false) {
+        for (const item of Object.values(Formatter)) {
+          formatters[item.name] = item
+        }
+        for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
+          if (item.disabled) {
+            delete formatters[name]
+            continue
+          }
+          const info = mergeDeep(formatters[name] ?? {}, {
+            command: [],
+            extensions: [],
+            ...item,
+          })
+
+          if (info.command.length === 0) continue
+
+          formatters[name] = {
+            ...info,
+            name,
+            enabled: async () => true,
+          }
+        }
+      } else {
+        log.info("all formatters are disabled")
+      }
+
+      async function isEnabled(item: Formatter.Info) {
+        let status = enabled[item.name]
+        if (status === undefined) {
+          status = await item.enabled()
+          enabled[item.name] = status
+        }
+        return status
+      }
+
+      async function getFormatter(ext: string) {
+        const result = []
+        for (const item of Object.values(formatters)) {
+          log.info("checking", { name: item.name, ext })
+          if (!item.extensions.includes(ext)) continue
+          if (!(await isEnabled(item))) continue
+          log.info("enabled", { name: item.name, ext })
+          result.push(item)
+        }
+        return result
+      }
+
+      yield* Effect.acquireRelease(
+        Effect.sync(() =>
+          Bus.subscribe(
+            File.Event.Edited,
+            Instance.bind(async (payload) => {
+              const file = payload.properties.file
+              log.info("formatting", { file })
+              const ext = path.extname(file)
+
+              for (const item of await getFormatter(ext)) {
+                log.info("running", { command: item.command })
+                try {
+                  const proc = Process.spawn(
+                    item.command.map((x) => x.replace("$FILE", file)),
+                    {
+                      cwd: instance.directory,
+                      env: { ...process.env, ...item.environment },
+                      stdout: "ignore",
+                      stderr: "ignore",
+                    },
+                  )
+                  const exit = await proc.exited
+                  if (exit !== 0) {
+                    log.error("failed", {
+                      command: item.command,
+                      ...item.environment,
+                    })
+                  }
+                } catch (error) {
+                  log.error("failed to format file", {
+                    error,
+                    command: item.command,
+                    ...item.environment,
+                    file,
+                  })
+                }
+              }
+            }),
+          ),
+        ),
+        (unsubscribe) => Effect.sync(unsubscribe),
+      )
+      log.info("init")
+
+      const status = Effect.fn("Format.status")(function* () {
+        const result: Status[] = []
+        for (const formatter of Object.values(formatters)) {
+          const isOn = yield* Effect.promise(() => isEnabled(formatter))
+          result.push({
+            name: formatter.name,
+            extensions: formatter.extensions,
+            enabled: isOn,
+          })
+        }
+        return result
+      })
+
+      return Service.of({ status })
+    }),
+  ).pipe(Layer.fresh)
+}

+ 30 - 270
packages/opencode/src/permission/index.ts

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

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

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

+ 1 - 1
packages/opencode/src/project/vcs.ts

@@ -79,5 +79,5 @@ export namespace Vcs {
         }),
       })
     }),
-  )
+  ).pipe(Layer.fresh)
 }

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

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

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

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

+ 25 - 169
packages/opencode/src/question/index.ts

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

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

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

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

@@ -0,0 +1,238 @@
+import os from "os"
+import path from "path"
+import { pathToFileURL } from "url"
+import z from "zod"
+import { Effect, Layer, ServiceMap } from "effect"
+import { NamedError } from "@opencode-ai/util/error"
+import type { Agent } from "@/agent/agent"
+import { Bus } from "@/bus"
+import { InstanceContext } from "@/effect/instance-context"
+import { Flag } from "@/flag/flag"
+import { Global } from "@/global"
+import { Permission } from "@/permission/service"
+import { Filesystem } from "@/util/filesystem"
+import { Config } from "../config/config"
+import { ConfigMarkdown } from "../config/markdown"
+import { Glob } from "../util/glob"
+import { Log } from "../util/log"
+import { Discovery } from "./discovery"
+
+export namespace Skill {
+  const log = Log.create({ service: "skill" })
+  const EXTERNAL_DIRS = [".claude", ".agents"]
+  const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
+  const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
+  const SKILL_PATTERN = "**/SKILL.md"
+
+  export const Info = z.object({
+    name: z.string(),
+    description: z.string(),
+    location: z.string(),
+    content: z.string(),
+  })
+  export type Info = z.infer<typeof Info>
+
+  export const InvalidError = NamedError.create(
+    "SkillInvalidError",
+    z.object({
+      path: z.string(),
+      message: z.string().optional(),
+      issues: z.custom<z.core.$ZodIssue[]>().optional(),
+    }),
+  )
+
+  export const NameMismatchError = NamedError.create(
+    "SkillNameMismatchError",
+    z.object({
+      path: z.string(),
+      expected: z.string(),
+      actual: z.string(),
+    }),
+  )
+
+  type State = {
+    skills: Record<string, Info>
+    dirs: Set<string>
+    task?: Promise<void>
+  }
+
+  type Cache = State & {
+    ensure: () => Promise<void>
+  }
+
+  export interface Interface {
+    readonly get: (name: string) => Effect.Effect<Info | undefined>
+    readonly all: () => Effect.Effect<Info[]>
+    readonly dirs: () => Effect.Effect<string[]>
+    readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
+  }
+
+  const add = async (state: State, match: string) => {
+    const md = await ConfigMarkdown.parse(match).catch(async (err) => {
+      const message = ConfigMarkdown.FrontmatterError.isInstance(err)
+        ? err.data.message
+        : `Failed to parse skill ${match}`
+      const { Session } = await import("@/session")
+      Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
+      log.error("failed to load skill", { skill: match, err })
+      return undefined
+    })
+
+    if (!md) return
+
+    const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
+    if (!parsed.success) return
+
+    if (state.skills[parsed.data.name]) {
+      log.warn("duplicate skill name", {
+        name: parsed.data.name,
+        existing: state.skills[parsed.data.name].location,
+        duplicate: match,
+      })
+    }
+
+    state.dirs.add(path.dirname(match))
+    state.skills[parsed.data.name] = {
+      name: parsed.data.name,
+      description: parsed.data.description,
+      location: match,
+      content: md.content,
+    }
+  }
+
+  const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
+    return Glob.scan(pattern, {
+      cwd: root,
+      absolute: true,
+      include: "file",
+      symlink: true,
+      dot: opts?.dot,
+    })
+      .then((matches) => Promise.all(matches.map((match) => add(state, match))))
+      .catch((error) => {
+        if (!opts?.scope) throw error
+        log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
+      })
+  }
+
+  // TODO: Migrate to Effect
+  const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
+    const state: State = {
+      skills: {},
+      dirs: new Set<string>(),
+    }
+
+    const load = async () => {
+      if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
+        for (const dir of EXTERNAL_DIRS) {
+          const root = path.join(Global.Path.home, dir)
+          if (!(await Filesystem.isDir(root))) continue
+          await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
+        }
+
+        for await (const root of Filesystem.up({
+          targets: EXTERNAL_DIRS,
+          start: instance.directory,
+          stop: instance.project.worktree,
+        })) {
+          await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
+        }
+      }
+
+      for (const dir of await Config.directories()) {
+        await scan(state, dir, OPENCODE_SKILL_PATTERN)
+      }
+
+      const cfg = await Config.get()
+      for (const item of cfg.skills?.paths ?? []) {
+        const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
+        const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
+        if (!(await Filesystem.isDir(dir))) {
+          log.warn("skill path not found", { path: dir })
+          continue
+        }
+
+        await scan(state, dir, SKILL_PATTERN)
+      }
+
+      for (const url of cfg.skills?.urls ?? []) {
+        for (const dir of await Effect.runPromise(discovery.pull(url))) {
+          state.dirs.add(dir)
+          await scan(state, dir, SKILL_PATTERN)
+        }
+      }
+
+      log.info("init", { count: Object.keys(state.skills).length })
+    }
+
+    const ensure = () => {
+      if (state.task) return state.task
+      state.task = load().catch((err) => {
+        state.task = undefined
+        throw err
+      })
+      return state.task
+    }
+
+    return { ...state, ensure }
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
+
+  export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const instance = yield* InstanceContext
+      const discovery = yield* Discovery.Service
+      const state = create(instance, discovery)
+
+      const get = Effect.fn("Skill.get")(function* (name: string) {
+        yield* Effect.promise(() => state.ensure())
+        return state.skills[name]
+      })
+
+      const all = Effect.fn("Skill.all")(function* () {
+        yield* Effect.promise(() => state.ensure())
+        return Object.values(state.skills)
+      })
+
+      const dirs = Effect.fn("Skill.dirs")(function* () {
+        yield* Effect.promise(() => state.ensure())
+        return Array.from(state.dirs)
+      })
+
+      const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
+        yield* Effect.promise(() => state.ensure())
+        const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
+        if (!agent) return list
+        return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
+      })
+
+      return Service.of({ get, all, dirs, available })
+    }),
+  ).pipe(Layer.fresh)
+
+  export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
+    Layer.provide(Discovery.defaultLayer),
+  )
+
+  export function fmt(list: Info[], opts: { verbose: boolean }) {
+    if (list.length === 0) return "No skills are currently available."
+
+    if (opts.verbose) {
+      return [
+        "<available_skills>",
+        ...list.flatMap((skill) => [
+          "  <skill>",
+          `    <name>${skill.name}</name>`,
+          `    <description>${skill.description}</description>`,
+          `    <location>${pathToFileURL(skill.location).href}</location>`,
+          "  </skill>",
+        ]),
+        "</available_skills>",
+      ].join("\n")
+    }
+
+    return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
+  }
+}

+ 15 - 235
packages/opencode/src/skill/skill.ts

@@ -1,255 +1,35 @@
-import os from "os"
-import path from "path"
-import { pathToFileURL } from "url"
-import z from "zod"
-import { Effect, Layer, ServiceMap } from "effect"
-import { NamedError } from "@opencode-ai/util/error"
-import type { Agent } from "@/agent/agent"
-import { Bus } from "@/bus"
-import { InstanceContext } from "@/effect/instance-context"
 import { runPromiseInstance } from "@/effect/runtime"
-import { Flag } from "@/flag/flag"
-import { Global } from "@/global"
-import { PermissionNext } from "@/permission"
-import { Filesystem } from "@/util/filesystem"
-import { Config } from "../config/config"
-import { ConfigMarkdown } from "../config/markdown"
-import { Glob } from "../util/glob"
-import { Log } from "../util/log"
-import { Discovery } from "./discovery"
+import type { Agent } from "@/agent/agent"
+import { Skill as S } from "./service"
 
 export namespace Skill {
-  const log = Log.create({ service: "skill" })
-  const EXTERNAL_DIRS = [".claude", ".agents"]
-  const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
-  const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
-  const SKILL_PATTERN = "**/SKILL.md"
-
-  export const Info = z.object({
-    name: z.string(),
-    description: z.string(),
-    location: z.string(),
-    content: z.string(),
-  })
-  export type Info = z.infer<typeof Info>
-
-  export const InvalidError = NamedError.create(
-    "SkillInvalidError",
-    z.object({
-      path: z.string(),
-      message: z.string().optional(),
-      issues: z.custom<z.core.$ZodIssue[]>().optional(),
-    }),
-  )
-
-  export const NameMismatchError = NamedError.create(
-    "SkillNameMismatchError",
-    z.object({
-      path: z.string(),
-      expected: z.string(),
-      actual: z.string(),
-    }),
-  )
-
-  type State = {
-    skills: Record<string, Info>
-    dirs: Set<string>
-    task?: Promise<void>
-  }
-
-  type Cache = State & {
-    ensure: () => Promise<void>
-  }
-
-  export interface Interface {
-    readonly get: (name: string) => Effect.Effect<Info | undefined>
-    readonly all: () => Effect.Effect<Info[]>
-    readonly dirs: () => Effect.Effect<string[]>
-    readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
-  }
-
-  const add = async (state: State, match: string) => {
-    const md = await ConfigMarkdown.parse(match).catch(async (err) => {
-      const message = ConfigMarkdown.FrontmatterError.isInstance(err)
-        ? err.data.message
-        : `Failed to parse skill ${match}`
-      const { Session } = await import("@/session")
-      Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
-      log.error("failed to load skill", { skill: match, err })
-      return undefined
-    })
-
-    if (!md) return
-
-    const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
-    if (!parsed.success) return
-
-    if (state.skills[parsed.data.name]) {
-      log.warn("duplicate skill name", {
-        name: parsed.data.name,
-        existing: state.skills[parsed.data.name].location,
-        duplicate: match,
-      })
-    }
-
-    state.dirs.add(path.dirname(match))
-    state.skills[parsed.data.name] = {
-      name: parsed.data.name,
-      description: parsed.data.description,
-      location: match,
-      content: md.content,
-    }
-  }
-
-  const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
-    return Glob.scan(pattern, {
-      cwd: root,
-      absolute: true,
-      include: "file",
-      symlink: true,
-      dot: opts?.dot,
-    })
-      .then((matches) => Promise.all(matches.map((match) => add(state, match))))
-      .catch((error) => {
-        if (!opts?.scope) throw error
-        log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
-      })
-  }
+  export const Info = S.Info
+  export type Info = S.Info
 
-  // TODO: Migrate to Effect
-  const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
-    const state: State = {
-      skills: {},
-      dirs: new Set<string>(),
-    }
+  export const InvalidError = S.InvalidError
+  export const NameMismatchError = S.NameMismatchError
 
-    const load = async () => {
-      if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
-        for (const dir of EXTERNAL_DIRS) {
-          const root = path.join(Global.Path.home, dir)
-          if (!(await Filesystem.isDir(root))) continue
-          await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
-        }
+  export type Interface = S.Interface
 
-        for await (const root of Filesystem.up({
-          targets: EXTERNAL_DIRS,
-          start: instance.directory,
-          stop: instance.project.worktree,
-        })) {
-          await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
-        }
-      }
+  export const Service = S.Service
+  export const layer = S.layer
+  export const defaultLayer = S.defaultLayer
 
-      for (const dir of await Config.directories()) {
-        await scan(state, dir, OPENCODE_SKILL_PATTERN)
-      }
-
-      const cfg = await Config.get()
-      for (const item of cfg.skills?.paths ?? []) {
-        const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
-        const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
-        if (!(await Filesystem.isDir(dir))) {
-          log.warn("skill path not found", { path: dir })
-          continue
-        }
-
-        await scan(state, dir, SKILL_PATTERN)
-      }
-
-      for (const url of cfg.skills?.urls ?? []) {
-        for (const dir of await Effect.runPromise(discovery.pull(url))) {
-          state.dirs.add(dir)
-          await scan(state, dir, SKILL_PATTERN)
-        }
-      }
-
-      log.info("init", { count: Object.keys(state.skills).length })
-    }
-
-    const ensure = () => {
-      if (state.task) return state.task
-      state.task = load().catch((err) => {
-        state.task = undefined
-        throw err
-      })
-      return state.task
-    }
-
-    return { ...state, ensure }
-  }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
-
-  export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const instance = yield* InstanceContext
-      const discovery = yield* Discovery.Service
-      const state = create(instance, discovery)
-
-      const get = Effect.fn("Skill.get")(function* (name: string) {
-        yield* Effect.promise(() => state.ensure())
-        return state.skills[name]
-      })
-
-      const all = Effect.fn("Skill.all")(function* () {
-        yield* Effect.promise(() => state.ensure())
-        return Object.values(state.skills)
-      })
-
-      const dirs = Effect.fn("Skill.dirs")(function* () {
-        yield* Effect.promise(() => state.ensure())
-        return Array.from(state.dirs)
-      })
-
-      const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
-        yield* Effect.promise(() => state.ensure())
-        const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
-        if (!agent) return list
-        return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
-      })
-
-      return Service.of({ get, all, dirs, available })
-    }),
-  )
-
-  export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
-    Layer.provide(Discovery.defaultLayer),
-  )
+  export const fmt = S.fmt
 
   export async function get(name: string) {
-    return runPromiseInstance(Service.use((skill) => skill.get(name)))
+    return runPromiseInstance(S.Service.use((skill) => skill.get(name)))
   }
 
   export async function all() {
-    return runPromiseInstance(Service.use((skill) => skill.all()))
+    return runPromiseInstance(S.Service.use((skill) => skill.all()))
   }
 
   export async function dirs() {
-    return runPromiseInstance(Service.use((skill) => skill.dirs()))
+    return runPromiseInstance(S.Service.use((skill) => skill.dirs()))
   }
 
   export async function available(agent?: Agent.Info) {
-    return runPromiseInstance(Service.use((skill) => skill.available(agent)))
-  }
-
-  export function fmt(list: Info[], opts: { verbose: boolean }) {
-    if (list.length === 0) return "No skills are currently available."
-
-    if (opts.verbose) {
-      return [
-        "<available_skills>",
-        ...list.flatMap((skill) => [
-          "  <skill>",
-          `    <name>${skill.name}</name>`,
-          `    <description>${skill.description}</description>`,
-          `    <location>${pathToFileURL(skill.location).href}</location>`,
-          "  </skill>",
-        ]),
-        "</available_skills>",
-      ].join("\n")
-    }
-
-    return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
+    return runPromiseInstance(S.Service.use((skill) => skill.available(agent)))
   }
 }

+ 18 - 323
packages/opencode/src/snapshot/index.ts

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

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

@@ -0,0 +1,320 @@
+import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
+import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import path from "path"
+import z from "zod"
+import { InstanceContext } from "@/effect/instance-context"
+import { AppFileSystem } from "@/filesystem"
+import { Config } from "../config/config"
+import { Global } from "../global"
+import { Log } from "../util/log"
+
+export namespace Snapshot {
+  export const Patch = z.object({
+    hash: z.string(),
+    files: z.string().array(),
+  })
+  export type Patch = z.infer<typeof Patch>
+
+  export const FileDiff = z
+    .object({
+      file: z.string(),
+      before: z.string(),
+      after: z.string(),
+      additions: z.number(),
+      deletions: z.number(),
+      status: z.enum(["added", "deleted", "modified"]).optional(),
+    })
+    .meta({
+      ref: "FileDiff",
+    })
+  export type FileDiff = z.infer<typeof FileDiff>
+
+  const log = Log.create({ service: "snapshot" })
+  const prune = "7.days"
+  const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
+  const cfg = ["-c", "core.autocrlf=false", ...core]
+  const quote = [...cfg, "-c", "core.quotepath=false"]
+
+  interface GitResult {
+    readonly code: ChildProcessSpawner.ExitCode
+    readonly text: string
+    readonly stderr: string
+  }
+
+  export interface Interface {
+    readonly cleanup: () => Effect.Effect<void>
+    readonly track: () => Effect.Effect<string | undefined>
+    readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
+    readonly restore: (snapshot: string) => Effect.Effect<void>
+    readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
+    readonly diff: (hash: string) => Effect.Effect<string>
+    readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
+
+  export const layer: Layer.Layer<
+    Service,
+    never,
+    InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
+  > = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const ctx = yield* InstanceContext
+      const fs = yield* AppFileSystem.Service
+      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+      const directory = ctx.directory
+      const worktree = ctx.worktree
+      const project = ctx.project
+      const gitdir = path.join(Global.Path.data, "snapshot", project.id)
+
+      const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
+
+      const git = Effect.fnUntraced(
+        function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
+          const proc = ChildProcess.make("git", cmd, {
+            cwd: opts?.cwd,
+            env: opts?.env,
+            extendEnv: true,
+          })
+          const handle = yield* spawner.spawn(proc)
+          const [text, stderr] = yield* Effect.all(
+            [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
+            { concurrency: 2 },
+          )
+          const code = yield* handle.exitCode
+          return { code, text, stderr } satisfies GitResult
+        },
+        Effect.scoped,
+        Effect.catch((err) =>
+          Effect.succeed({
+            code: ChildProcessSpawner.ExitCode(1),
+            text: "",
+            stderr: String(err),
+          }),
+        ),
+      )
+
+      // Snapshot-specific error handling on top of AppFileSystem
+      const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
+      const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
+      const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
+
+      const enabled = Effect.fnUntraced(function* () {
+        if (project.vcs !== "git") return false
+        return (yield* Effect.promise(() => Config.get())).snapshot !== false
+      })
+
+      const excludes = Effect.fnUntraced(function* () {
+        const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
+          cwd: worktree,
+        })
+        const file = result.text.trim()
+        if (!file) return
+        if (!(yield* exists(file))) return
+        return file
+      })
+
+      const sync = Effect.fnUntraced(function* () {
+        const file = yield* excludes()
+        const target = path.join(gitdir, "info", "exclude")
+        yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie)
+        if (!file) {
+          yield* fs.writeFileString(target, "").pipe(Effect.orDie)
+          return
+        }
+        yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
+      })
+
+      const add = Effect.fnUntraced(function* () {
+        yield* sync()
+        yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
+      })
+
+      const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
+        if (!(yield* enabled())) return
+        if (!(yield* exists(gitdir))) return
+        const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
+        if (result.code !== 0) {
+          log.warn("cleanup failed", {
+            exitCode: result.code,
+            stderr: result.stderr,
+          })
+          return
+        }
+        log.info("cleanup", { prune })
+      })
+
+      const track = Effect.fn("Snapshot.track")(function* () {
+        if (!(yield* enabled())) return
+        const existed = yield* exists(gitdir)
+        yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
+        if (!existed) {
+          yield* git(["init"], {
+            env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
+          })
+          yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
+          yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
+          yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
+          yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
+          log.info("initialized")
+        }
+        yield* add()
+        const result = yield* git(args(["write-tree"]), { cwd: directory })
+        const hash = result.text.trim()
+        log.info("tracking", { hash, cwd: directory, git: gitdir })
+        return hash
+      })
+
+      const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
+        yield* add()
+        const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
+          cwd: directory,
+        })
+        if (result.code !== 0) {
+          log.warn("failed to get diff", { hash, exitCode: result.code })
+          return { hash, files: [] }
+        }
+        return {
+          hash,
+          files: result.text
+            .trim()
+            .split("\n")
+            .map((x) => x.trim())
+            .filter(Boolean)
+            .map((x) => path.join(worktree, x).replaceAll("\\", "/")),
+        }
+      })
+
+      const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
+        log.info("restore", { commit: snapshot })
+        const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
+        if (result.code === 0) {
+          const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
+          if (checkout.code === 0) return
+          log.error("failed to restore snapshot", {
+            snapshot,
+            exitCode: checkout.code,
+            stderr: checkout.stderr,
+          })
+          return
+        }
+        log.error("failed to restore snapshot", {
+          snapshot,
+          exitCode: result.code,
+          stderr: result.stderr,
+        })
+      })
+
+      const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
+        const seen = new Set<string>()
+        for (const item of patches) {
+          for (const file of item.files) {
+            if (seen.has(file)) continue
+            seen.add(file)
+            log.info("reverting", { file, hash: item.hash })
+            const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree })
+            if (result.code !== 0) {
+              const rel = path.relative(worktree, file)
+              const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree })
+              if (tree.code === 0 && tree.text.trim()) {
+                log.info("file existed in snapshot but checkout failed, keeping", { file })
+              } else {
+                log.info("file did not exist in snapshot, deleting", { file })
+                yield* remove(file)
+              }
+            }
+          }
+        }
+      })
+
+      const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
+        yield* add()
+        const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
+          cwd: worktree,
+        })
+        if (result.code !== 0) {
+          log.warn("failed to get diff", {
+            hash,
+            exitCode: result.code,
+            stderr: result.stderr,
+          })
+          return ""
+        }
+        return result.text.trim()
+      })
+
+      const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
+        const result: Snapshot.FileDiff[] = []
+        const status = new Map<string, "added" | "deleted" | "modified">()
+
+        const statuses = yield* git(
+          [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
+          { cwd: directory },
+        )
+
+        for (const line of statuses.text.trim().split("\n")) {
+          if (!line) continue
+          const [code, file] = line.split("\t")
+          if (!code || !file) continue
+          status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
+        }
+
+        const numstat = yield* git(
+          [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
+          {
+            cwd: directory,
+          },
+        )
+
+        for (const line of numstat.text.trim().split("\n")) {
+          if (!line) continue
+          const [adds, dels, file] = line.split("\t")
+          if (!file) continue
+          const binary = adds === "-" && dels === "-"
+          const [before, after] = binary
+            ? ["", ""]
+            : yield* Effect.all(
+                [
+                  git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
+                  git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
+                ],
+                { concurrency: 2 },
+              )
+          const additions = binary ? 0 : parseInt(adds)
+          const deletions = binary ? 0 : parseInt(dels)
+          result.push({
+            file,
+            before,
+            after,
+            additions: Number.isFinite(additions) ? additions : 0,
+            deletions: Number.isFinite(deletions) ? deletions : 0,
+            status: status.get(file) ?? "modified",
+          })
+        }
+
+        return result
+      })
+
+      yield* cleanup().pipe(
+        Effect.catchCause((cause) => {
+          log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
+          return Effect.void
+        }),
+        Effect.repeat(Schedule.spaced(Duration.hours(1))),
+        Effect.delay(Duration.minutes(1)),
+        Effect.forkScoped,
+      )
+
+      return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
+    }),
+  ).pipe(Layer.fresh)
+
+  export const defaultLayer = layer.pipe(
+    Layer.provide(NodeChildProcessSpawner.layer),
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
+    Layer.provide(NodePath.layer),
+  )
+}

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

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

+ 1 - 1
packages/opencode/test/fixture/instance.ts

@@ -34,7 +34,7 @@ export function withServices<S>(
           project: Instance.project,
         }),
       )
-      let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any
+      let resolved: Layer.Layer<S> = layer.pipe(Layer.provide(ctx)) as any
       if (options?.provide) {
         for (const l of options.provide) {
           resolved = resolved.pipe(Layer.provide(l)) as any