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

Merge branch 'dev' into feat/fff-search-tools

Shoubhit Dash 3 недель назад
Родитель
Сommit
05145ba8f2
100 измененных файлов с 3978 добавлено и 1737 удалено
  1. 1 0
      .github/VOUCHED.td
  2. 2 2
      bun.lock
  3. 0 1
      github/index.ts
  4. 4 4
      nix/hashes.json
  5. 3 3
      packages/app/src/context/global-sync/bootstrap.ts
  6. 6 4
      packages/app/src/context/global-sync/event-reducer.test.ts
  7. 2 2
      packages/app/src/context/global-sync/event-reducer.ts
  8. 35 0
      packages/app/src/context/global-sync/utils.test.ts
  9. 15 1
      packages/app/src/context/global-sync/utils.ts
  10. 0 2
      packages/app/src/i18n/ar.ts
  11. 0 2
      packages/app/src/i18n/br.ts
  12. 0 2
      packages/app/src/i18n/bs.ts
  13. 0 2
      packages/app/src/i18n/da.ts
  14. 0 2
      packages/app/src/i18n/de.ts
  15. 2 2
      packages/app/src/i18n/en.ts
  16. 0 2
      packages/app/src/i18n/es.ts
  17. 0 2
      packages/app/src/i18n/fr.ts
  18. 0 2
      packages/app/src/i18n/ja.ts
  19. 0 2
      packages/app/src/i18n/ko.ts
  20. 0 2
      packages/app/src/i18n/no.ts
  21. 0 2
      packages/app/src/i18n/pl.ts
  22. 0 2
      packages/app/src/i18n/ru.ts
  23. 0 2
      packages/app/src/i18n/th.ts
  24. 0 2
      packages/app/src/i18n/tr.ts
  25. 0 2
      packages/app/src/i18n/zh.ts
  26. 0 2
      packages/app/src/i18n/zht.ts
  27. 232 40
      packages/app/src/pages/session.tsx
  28. 22 36
      packages/app/src/pages/session/session-side-panel.tsx
  29. 1 5
      packages/app/src/pages/session/use-session-commands.tsx
  30. 5 4
      packages/console/app/src/routes/zen/util/handler.ts
  31. 63 3
      packages/console/core/script/lookup-user.ts
  32. 8 6
      packages/opencode/AGENTS.md
  33. 1 1
      packages/opencode/package.json
  34. 21 9
      packages/opencode/specs/effect-migration.md
  35. 2 2
      packages/opencode/src/account/index.ts
  36. 2 3
      packages/opencode/src/agent/agent.ts
  37. 2 2
      packages/opencode/src/auth/index.ts
  38. 154 75
      packages/opencode/src/bus/index.ts
  39. 1 13
      packages/opencode/src/cli/cmd/agent.ts
  40. 5 6
      packages/opencode/src/cli/cmd/github.ts
  41. 4 4
      packages/opencode/src/cli/cmd/pr.ts
  42. 1 1
      packages/opencode/src/cli/cmd/tui/app.tsx
  43. 21 5
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  44. 10 0
      packages/opencode/src/cli/cmd/tui/util/clipboard.ts
  45. 2 2
      packages/opencode/src/command/index.ts
  46. 0 1
      packages/opencode/src/config/config.ts
  47. 0 14
      packages/opencode/src/effect/instance-context.ts
  48. 8 4
      packages/opencode/src/effect/run-service.ts
  49. 13 25
      packages/opencode/src/file/index.ts
  50. 2 2
      packages/opencode/src/file/time.ts
  51. 4 4
      packages/opencode/src/file/watcher.ts
  52. 48 47
      packages/opencode/src/format/index.ts
  53. 308 0
      packages/opencode/src/git/index.ts
  54. 2 2
      packages/opencode/src/installation/index.ts
  55. 147 96
      packages/opencode/src/mcp/auth.ts
  56. 519 560
      packages/opencode/src/mcp/index.ts
  57. 26 3
      packages/opencode/src/mcp/oauth-callback.ts
  58. 2 2
      packages/opencode/src/permission/index.ts
  59. 12 9
      packages/opencode/src/plugin/index.ts
  60. 2 2
      packages/opencode/src/project/project.ts
  61. 161 35
      packages/opencode/src/project/vcs.ts
  62. 7 6
      packages/opencode/src/provider/auth.ts
  63. 2 2
      packages/opencode/src/pty/index.ts
  64. 2 2
      packages/opencode/src/question/index.ts
  65. 30 1
      packages/opencode/src/server/server.ts
  66. 6 1
      packages/opencode/src/session/compaction.ts
  67. 26 1
      packages/opencode/src/session/message-v2.ts
  68. 1 1
      packages/opencode/src/session/processor.ts
  69. 38 19
      packages/opencode/src/session/projectors.ts
  70. 7 4
      packages/opencode/src/session/status.ts
  71. 47 71
      packages/opencode/src/skill/index.ts
  72. 198 150
      packages/opencode/src/snapshot/index.ts
  73. 2 2
      packages/opencode/src/storage/storage.ts
  74. 3 3
      packages/opencode/src/tool/apply_patch.ts
  75. 5 6
      packages/opencode/src/tool/edit.ts
  76. 2 2
      packages/opencode/src/tool/registry.ts
  77. 11 12
      packages/opencode/src/tool/task.ts
  78. 1 23
      packages/opencode/src/tool/todo.ts
  79. 0 14
      packages/opencode/src/tool/todoread.txt
  80. 2 2
      packages/opencode/src/tool/truncate.ts
  81. 3 3
      packages/opencode/src/tool/write.ts
  82. 0 35
      packages/opencode/src/util/git.ts
  83. 12 43
      packages/opencode/src/worktree/index.ts
  84. 0 2
      packages/opencode/test/agent/agent.test.ts
  85. 164 0
      packages/opencode/test/bus/bus-effect.test.ts
  86. 87 0
      packages/opencode/test/bus/bus-integration.test.ts
  87. 219 0
      packages/opencode/test/bus/bus.test.ts
  88. 0 2
      packages/opencode/test/config/config.test.ts
  89. 4 4
      packages/opencode/test/effect/run-service.test.ts
  90. 22 10
      packages/opencode/test/file/watcher.test.ts
  91. 68 0
      packages/opencode/test/fixture/fixture.ts
  92. 0 51
      packages/opencode/test/fixture/instance.ts
  93. 171 161
      packages/opencode/test/format/format.test.ts
  94. 128 0
      packages/opencode/test/git/git.test.ts
  95. 660 0
      packages/opencode/test/mcp/lifecycle.test.ts
  96. 123 20
      packages/opencode/test/project/vcs.test.ts
  97. 27 0
      packages/opencode/test/session/message-v2.test.ts
  98. 12 0
      packages/opencode/test/session/retry.test.ts
  99. 7 3
      packages/opencode/test/sync/index.test.ts
  100. 0 7
      packages/opencode/test/tool/edit.test.ts

+ 1 - 0
.github/VOUCHED.td

@@ -25,3 +25,4 @@ r44vc0rp
 rekram1-node
 -spider-yamet clawdbot/llm psychosis, spam pinging the team
 thdxr
+-OpenCodeEngineer bot that spams issues

+ 2 - 2
bun.lock

@@ -358,7 +358,7 @@
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "fuzzysort": "3.1.0",
-        "gitlab-ai-provider": "5.3.2",
+        "gitlab-ai-provider": "5.3.3",
         "glob": "13.0.5",
         "google-auth-library": "10.5.0",
         "gray-matter": "4.0.3",
@@ -3077,7 +3077,7 @@
 
     "github-slugger": ["[email protected]", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
 
-    "gitlab-ai-provider": ["[email protected].2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-EiAipDMa4Ngsxp4MMaua5YHWsHhc9kGXKmBxulJg1Gueb+5IZmMwxaVtgWTGWZITxC3tzKEeRt/3U4McE2vTIA=="],
+    "gitlab-ai-provider": ["[email protected].3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-k0kRUoAhDvoRC28hQW4sPp+A3cfpT5c/oL9Ng10S0oBiF2Tci1AtsX1iclJM5Os8C1nIIAXBW8LMr0GY7rwcGA=="],
 
     "glob": ["[email protected]", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
 

+ 0 - 1
github/index.ts

@@ -496,7 +496,6 @@ async function subscribeSessionEvents() {
 
   const TOOL: Record<string, [string, string]> = {
     todowrite: ["Todo", "\x1b[33m\x1b[1m"],
-    todoread: ["Todo", "\x1b[33m\x1b[1m"],
     bash: ["Bash", "\x1b[31m\x1b[1m"],
     edit: ["Edit", "\x1b[32m\x1b[1m"],
     glob: ["Glob", "\x1b[34m\x1b[1m"],

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-MmN2+NfHeLPDClpLPOlCAZTmwI94M6XgNAqXrW5Ls4I=",
-    "aarch64-linux": "sha256-whVIlmDvoMmEMUY2Yxx2vAmFDuKQic6ChY1V+9gLd84=",
-    "aarch64-darwin": "sha256-TulGiC24w3usk26hKr3PyccatvIfmAlHgEJaOTUf3pQ=",
-    "x86_64-darwin": "sha256-T8NWm0bBybJKThRdp/jQdxilv1Ec9SF1iVT3udSoZOg="
+    "x86_64-linux": "sha256-0VwVhbOtK1r16cVSZcHaI/8fUPc6aYQiUnh7Q3bSHqs=",
+    "aarch64-linux": "sha256-z5b234MIS0QqDYLopyaT2hd9CAtEbcSo28y0eMfPsBs=",
+    "aarch64-darwin": "sha256-sn16mtZIhF9OSBrfAHpDCJO6Nt19mdoxvYAOnwWgwDk=",
+    "x86_64-darwin": "sha256-FaZpwGuWzfypA28ct86xAnW2RuFFUiXjPkr5wVTLN/o="
   }
 }

+ 3 - 3
packages/app/src/context/global-sync/bootstrap.ts

@@ -15,7 +15,7 @@ import { retry } from "@opencode-ai/util/retry"
 import { batch } from "solid-js"
 import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
 import type { State, VcsCache } from "./types"
-import { cmp, normalizeProviderList } from "./utils"
+import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
 import { formatServerError } from "@/utils/server-errors"
 
 type GlobalStore = {
@@ -174,7 +174,7 @@ export async function bootstrapDirectory(input: {
       seededProject
         ? Promise.resolve()
         : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
-    () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))),
+    () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
     () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
     () =>
       retry(() =>
@@ -190,7 +190,7 @@ export async function bootstrapDirectory(input: {
         input.sdk.vcs.get().then((x) => {
           const next = x.data ?? input.store.vcs
           input.setStore("vcs", next)
-          if (next?.branch) input.vcsCache.setStore("value", next)
+          if (next) input.vcsCache.setStore("value", next)
         }),
       ),
     () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),

+ 6 - 4
packages/app/src/context/global-sync/event-reducer.test.ts

@@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => {
   })
 
   test("updates vcs branch in store and cache", () => {
-    const [store, setStore] = createStore(baseState())
-    const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
+    const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
+    const [cacheStore, setCacheStore] = createStore({
+      value: { branch: "main", default_branch: "main" } as State["vcs"],
+    })
 
     applyDirectoryEvent({
       event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
@@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => {
       },
     })
 
-    expect(store.vcs).toEqual({ branch: "feature/test" })
-    expect(cacheStore.value).toEqual({ branch: "feature/test" })
+    expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
+    expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
   })
 
   test("routes disposal and lsp events to side-effect handlers", () => {

+ 2 - 2
packages/app/src/context/global-sync/event-reducer.ts

@@ -271,9 +271,9 @@ export function applyDirectoryEvent(input: {
       break
     }
     case "vcs.branch.updated": {
-      const props = event.properties as { branch: string }
+      const props = event.properties as { branch?: string }
       if (input.store.vcs?.branch === props.branch) break
-      const next = { branch: props.branch }
+      const next = { ...input.store.vcs, branch: props.branch }
       input.setStore("vcs", next)
       if (input.vcsCache) input.vcsCache.setStore("value", next)
       break

+ 35 - 0
packages/app/src/context/global-sync/utils.test.ts

@@ -0,0 +1,35 @@
+import { describe, expect, test } from "bun:test"
+import type { Agent } from "@opencode-ai/sdk/v2/client"
+import { normalizeAgentList } from "./utils"
+
+const agent = (name = "build") =>
+  ({
+    name,
+    mode: "primary",
+    permission: {},
+    options: {},
+  }) as Agent
+
+describe("normalizeAgentList", () => {
+  test("keeps array payloads", () => {
+    expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
+  })
+
+  test("wraps a single agent payload", () => {
+    expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
+  })
+
+  test("extracts agents from keyed objects", () => {
+    expect(
+      normalizeAgentList({
+        build: agent("build"),
+        docs: agent("docs"),
+      }),
+    ).toEqual([agent("build"), agent("docs")])
+  })
+
+  test("drops invalid payloads", () => {
+    expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
+    expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
+  })
+})

+ 15 - 1
packages/app/src/context/global-sync/utils.ts

@@ -1,7 +1,21 @@
-import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
+import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
 
 export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 
+function isAgent(input: unknown): input is Agent {
+  if (!input || typeof input !== "object") return false
+  const item = input as { name?: unknown; mode?: unknown }
+  if (typeof item.name !== "string") return false
+  return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
+}
+
+export function normalizeAgentList(input: unknown): Agent[] {
+  if (Array.isArray(input)) return input.filter(isAgent)
+  if (isAgent(input)) return [input]
+  if (!input || typeof input !== "object") return []
+  return Object.values(input).filter(isAgent)
+}
+
 export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
   return {
     ...input,

+ 0 - 2
packages/app/src/i18n/ar.ts

@@ -722,8 +722,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
-  "settings.permissions.tool.todoread.title": "قراءة المهام",
-  "settings.permissions.tool.todoread.description": "قراءة قائمة المهام",
   "settings.permissions.tool.todowrite.title": "كتابة المهام",
   "settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
   "settings.permissions.tool.webfetch.title": "جلب الويب",

+ 0 - 2
packages/app/src/i18n/br.ts

@@ -732,8 +732,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
-  "settings.permissions.tool.todoread.title": "Ler Tarefas",
-  "settings.permissions.tool.todoread.description": "Ler a lista de tarefas",
   "settings.permissions.tool.todowrite.title": "Escrever Tarefas",
   "settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
   "settings.permissions.tool.webfetch.title": "Buscar Web",

+ 0 - 2
packages/app/src/i18n/bs.ts

@@ -806,8 +806,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
-  "settings.permissions.tool.todoread.title": "Čitanje liste zadataka",
-  "settings.permissions.tool.todoread.description": "Čitanje liste zadataka",
   "settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka",
   "settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
   "settings.permissions.tool.webfetch.title": "Web preuzimanje",

+ 0 - 2
packages/app/src/i18n/da.ts

@@ -800,8 +800,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
-  "settings.permissions.tool.todoread.title": "Læs To-do",
-  "settings.permissions.tool.todoread.description": "Læs to-do listen",
   "settings.permissions.tool.todowrite.title": "Skriv To-do",
   "settings.permissions.tool.todowrite.description": "Opdater to-do listen",
   "settings.permissions.tool.webfetch.title": "Webhentning",

+ 0 - 2
packages/app/src/i18n/de.ts

@@ -743,8 +743,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen",
-  "settings.permissions.tool.todoread.title": "Todo lesen",
-  "settings.permissions.tool.todoread.description": "Die Todo-Liste lesen",
   "settings.permissions.tool.todowrite.title": "Todo schreiben",
   "settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
   "settings.permissions.tool.webfetch.title": "Web-Abruf",

+ 2 - 2
packages/app/src/i18n/en.ts

@@ -535,6 +535,8 @@ export const dict = {
   "session.review.noVcs.createGit.action": "Create Git repository",
   "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
   "session.review.noChanges": "No changes",
+  "session.review.noUncommittedChanges": "No uncommitted changes yet",
+  "session.review.noBranchChanges": "No branch changes yet",
 
   "session.files.selectToOpen": "Select a file to open",
   "session.files.all": "All files",
@@ -900,8 +902,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Load a skill by name",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Run language server queries",
-  "settings.permissions.tool.todoread.title": "Todo Read",
-  "settings.permissions.tool.todoread.description": "Read the todo list",
   "settings.permissions.tool.todowrite.title": "Todo Write",
   "settings.permissions.tool.todowrite.description": "Update the todo list",
   "settings.permissions.tool.webfetch.title": "Web Fetch",

+ 0 - 2
packages/app/src/i18n/es.ts

@@ -813,8 +813,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Cargar una habilidad por nombre",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje",
-  "settings.permissions.tool.todoread.title": "Leer Todo",
-  "settings.permissions.tool.todoread.description": "Leer la lista de tareas",
   "settings.permissions.tool.todowrite.title": "Escribir Todo",
   "settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
   "settings.permissions.tool.webfetch.title": "Web Fetch",

+ 0 - 2
packages/app/src/i18n/fr.ts

@@ -741,8 +741,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Charger une compétence par son nom",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage",
-  "settings.permissions.tool.todoread.title": "Lire Todo",
-  "settings.permissions.tool.todoread.description": "Lire la liste de tâches",
   "settings.permissions.tool.todowrite.title": "Écrire Todo",
   "settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
   "settings.permissions.tool.webfetch.title": "Récupération Web",

+ 0 - 2
packages/app/src/i18n/ja.ts

@@ -727,8 +727,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "名前によるスキルの読み込み",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "言語サーバークエリの実行",
-  "settings.permissions.tool.todoread.title": "Todo読み込み",
-  "settings.permissions.tool.todoread.description": "Todoリストの読み込み",
   "settings.permissions.tool.todowrite.title": "Todo書き込み",
   "settings.permissions.tool.todowrite.description": "Todoリストの更新",
   "settings.permissions.tool.webfetch.title": "Web取得",

+ 0 - 2
packages/app/src/i18n/ko.ts

@@ -726,8 +726,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "이름으로 기술 로드",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
-  "settings.permissions.tool.todoread.title": "할 일 읽기",
-  "settings.permissions.tool.todoread.description": "할 일 목록 읽기",
   "settings.permissions.tool.todowrite.title": "할 일 쓰기",
   "settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
   "settings.permissions.tool.webfetch.title": "웹 가져오기",

+ 0 - 2
packages/app/src/i18n/no.ts

@@ -807,8 +807,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Last en ferdighet etter navn",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler",
-  "settings.permissions.tool.todoread.title": "Les gjøremål",
-  "settings.permissions.tool.todoread.description": "Les gjøremålslisten",
   "settings.permissions.tool.todowrite.title": "Skriv gjøremål",
   "settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
   "settings.permissions.tool.webfetch.title": "Webhenting",

+ 0 - 2
packages/app/src/i18n/pl.ts

@@ -729,8 +729,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego",
-  "settings.permissions.tool.todoread.title": "Odczyt Todo",
-  "settings.permissions.tool.todoread.description": "Odczyt listy zadań",
   "settings.permissions.tool.todowrite.title": "Zapis Todo",
   "settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
   "settings.permissions.tool.webfetch.title": "Pobieranie z sieci",

+ 0 - 2
packages/app/src/i18n/ru.ts

@@ -808,8 +808,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Загрузка навыка по имени",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
-  "settings.permissions.tool.todoread.title": "Todo Read",
-  "settings.permissions.tool.todoread.description": "Чтение списка задач",
   "settings.permissions.tool.todowrite.title": "Todo Write",
   "settings.permissions.tool.todowrite.description": "Обновление списка задач",
   "settings.permissions.tool.webfetch.title": "Web Fetch",

+ 0 - 2
packages/app/src/i18n/th.ts

@@ -796,8 +796,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
-  "settings.permissions.tool.todoread.title": "อ่านรายการงาน",
-  "settings.permissions.tool.todoread.description": "อ่านรายการงาน",
   "settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
   "settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
   "settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",

+ 0 - 2
packages/app/src/i18n/tr.ts

@@ -816,8 +816,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Ada göre bir beceri yükle",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
-  "settings.permissions.tool.todoread.title": "Görev Oku",
-  "settings.permissions.tool.todoread.description": "Görev listesini oku",
   "settings.permissions.tool.todowrite.title": "Görev Yaz",
   "settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
   "settings.permissions.tool.webfetch.title": "Web Getir",

+ 0 - 2
packages/app/src/i18n/zh.ts

@@ -795,8 +795,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "按名称加载技能",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "运行语言服务器查询",
-  "settings.permissions.tool.todoread.title": "读取待办",
-  "settings.permissions.tool.todoread.description": "读取待办列表",
   "settings.permissions.tool.todowrite.title": "更新待办",
   "settings.permissions.tool.todowrite.description": "更新待办列表",
   "settings.permissions.tool.webfetch.title": "网页获取",

+ 0 - 2
packages/app/src/i18n/zht.ts

@@ -790,8 +790,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "按名稱載入技能",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
-  "settings.permissions.tool.todoread.title": "讀取待辦",
-  "settings.permissions.tool.todoread.description": "讀取待辦清單",
   "settings.permissions.tool.todowrite.title": "更新待辦",
   "settings.permissions.tool.todowrite.description": "更新待辦清單",
   "settings.permissions.tool.webfetch.title": "Web Fetch",

+ 232 - 40
packages/app/src/pages/session.tsx

@@ -1,4 +1,4 @@
-import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
+import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useMutation } from "@tanstack/solid-query"
 import {
@@ -64,6 +64,9 @@ import { formatServerError } from "@/utils/server-errors"
 const emptyUserMessages: UserMessage[] = []
 const emptyFollowups: (FollowupDraft & { id: string })[] = []
 
+type ChangeMode = "git" | "branch" | "session" | "turn"
+type VcsMode = "git" | "branch"
+
 type SessionHistoryWindowInput = {
   sessionID: () => string | undefined
   messagesReady: () => boolean
@@ -424,15 +427,16 @@ export default function Page() {
 
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
-  const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
-  const hasReview = createMemo(() => reviewCount() > 0)
+  const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
+  const hasSessionReview = createMemo(() => sessionCount() > 0)
+  const canReview = createMemo(() => !!params.id)
   const reviewTab = createMemo(() => isDesktop())
   const tabState = createSessionTabs({
     tabs,
     pathFromTab: file.pathFromTab,
     normalizeTab,
     review: reviewTab,
-    hasReview,
+    hasReview: canReview,
   })
   const contextOpen = tabState.contextOpen
   const openedTabs = tabState.openedTabs
@@ -455,6 +459,12 @@ export default function Page() {
     if (!id) return false
     return sync.session.history.loading(id)
   })
+  const diffsReady = createMemo(() => {
+    const id = params.id
+    if (!id) return true
+    if (!hasSessionReview()) return true
+    return sync.data.session_diff[id] !== undefined
+  })
 
   const userMessages = createMemo(
     () => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -508,11 +518,22 @@ export default function Page() {
   const [store, setStore] = createStore({
     messageId: undefined as string | undefined,
     mobileTab: "session" as "session" | "changes",
-    changes: "session" as "session" | "turn",
+    changes: "git" as ChangeMode,
     newSessionWorktree: "main",
     deferRender: false,
   })
 
+  const [vcs, setVcs] = createStore({
+    diff: {
+      git: [] as FileDiff[],
+      branch: [] as FileDiff[],
+    },
+    ready: {
+      git: false,
+      branch: false,
+    },
+  })
+
   const [followup, setFollowup] = createStore({
     items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
     failed: {} as Record<string, string | undefined>,
@@ -539,6 +560,68 @@ export default function Page() {
   let refreshTimer: number | undefined
   let diffFrame: number | undefined
   let diffTimer: number | undefined
+  const vcsTask = new Map<VcsMode, Promise<void>>()
+  const vcsRun = new Map<VcsMode, number>()
+
+  const bumpVcs = (mode: VcsMode) => {
+    const next = (vcsRun.get(mode) ?? 0) + 1
+    vcsRun.set(mode, next)
+    return next
+  }
+
+  const resetVcs = (mode?: VcsMode) => {
+    const list = mode ? [mode] : (["git", "branch"] as const)
+    list.forEach((item) => {
+      bumpVcs(item)
+      vcsTask.delete(item)
+      setVcs("diff", item, [])
+      setVcs("ready", item, false)
+    })
+  }
+
+  const loadVcs = (mode: VcsMode, force = false) => {
+    if (sync.project?.vcs !== "git") return Promise.resolve()
+    if (!force && vcs.ready[mode]) return Promise.resolve()
+
+    if (force) {
+      if (vcsTask.has(mode)) bumpVcs(mode)
+      vcsTask.delete(mode)
+      setVcs("ready", mode, false)
+    }
+
+    const current = vcsTask.get(mode)
+    if (current) return current
+
+    const run = bumpVcs(mode)
+
+    const task = sdk.client.vcs
+      .diff({ mode })
+      .then((result) => {
+        if (vcsRun.get(mode) !== run) return
+        setVcs("diff", mode, result.data ?? [])
+        setVcs("ready", mode, true)
+      })
+      .catch((error) => {
+        if (vcsRun.get(mode) !== run) return
+        console.debug("[session-review] failed to load vcs diff", { mode, error })
+        setVcs("diff", mode, [])
+        setVcs("ready", mode, true)
+      })
+      .finally(() => {
+        if (vcsTask.get(mode) === task) vcsTask.delete(mode)
+      })
+
+    vcsTask.set(mode, task)
+    return task
+  }
+
+  const refreshVcs = () => {
+    resetVcs()
+    const mode = untrack(vcsMode)
+    if (!mode) return
+    if (!untrack(wantsReview)) return
+    void loadVcs(mode, true)
+  }
 
   createComputed((prev) => {
     const open = desktopReviewOpen()
@@ -554,7 +637,42 @@ export default function Page() {
   }, desktopReviewOpen())
 
   const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
-  const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
+  const changesOptions = createMemo<ChangeMode[]>(() => {
+    const list: ChangeMode[] = []
+    if (sync.project?.vcs === "git") list.push("git")
+    if (
+      sync.project?.vcs === "git" &&
+      sync.data.vcs?.branch &&
+      sync.data.vcs?.default_branch &&
+      sync.data.vcs.branch !== sync.data.vcs.default_branch
+    ) {
+      list.push("branch")
+    }
+    list.push("session", "turn")
+    return list
+  })
+  const vcsMode = createMemo<VcsMode | undefined>(() => {
+    if (store.changes === "git" || store.changes === "branch") return store.changes
+  })
+  const reviewDiffs = createMemo(() => {
+    if (store.changes === "git") return vcs.diff.git
+    if (store.changes === "branch") return vcs.diff.branch
+    if (store.changes === "session") return diffs()
+    return turnDiffs()
+  })
+  const reviewCount = createMemo(() => {
+    if (store.changes === "git") return vcs.diff.git.length
+    if (store.changes === "branch") return vcs.diff.branch.length
+    if (store.changes === "session") return sessionCount()
+    return turnDiffs().length
+  })
+  const hasReview = createMemo(() => reviewCount() > 0)
+  const reviewReady = createMemo(() => {
+    if (store.changes === "git") return vcs.ready.git
+    if (store.changes === "branch") return vcs.ready.branch
+    if (store.changes === "session") return !hasSessionReview() || diffsReady()
+    return true
+  })
 
   const newSessionWorktree = createMemo(() => {
     if (store.newSessionWorktree === "create") return "create"
@@ -620,13 +738,7 @@ export default function Page() {
     scrollToMessage(msgs[targetIndex], "auto")
   }
 
-  const diffsReady = createMemo(() => {
-    const id = params.id
-    if (!id) return true
-    if (!hasReview()) return true
-    return sync.data.session_diff[id] !== undefined
-  })
-  const reviewEmptyKey = createMemo(() => {
+  const sessionEmptyKey = createMemo(() => {
     const project = sync.project
     if (project && !project.vcs) return "session.review.noVcs"
     if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
@@ -748,13 +860,46 @@ export default function Page() {
       sessionKey,
       () => {
         setStore("messageId", undefined)
-        setStore("changes", "session")
+        setStore("changes", "git")
         setUi("pendingMessage", undefined)
       },
       { defer: true },
     ),
   )
 
+  createEffect(
+    on(
+      () => sdk.directory,
+      () => {
+        resetVcs()
+      },
+      { defer: true },
+    ),
+  )
+
+  createEffect(
+    on(
+      () => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
+      (next, prev) => {
+        if (prev === undefined || same(next, prev)) return
+        refreshVcs()
+      },
+      { defer: true },
+    ),
+  )
+
+  const stopVcs = sdk.event.listen((evt) => {
+    if (evt.details.type !== "file.watcher.updated") return
+    const props =
+      typeof evt.details.properties === "object" && evt.details.properties
+        ? (evt.details.properties as Record<string, unknown>)
+        : undefined
+    const file = typeof props?.file === "string" ? props.file : undefined
+    if (!file || file.startsWith(".git/")) return
+    refreshVcs()
+  })
+  onCleanup(stopVcs)
+
   createEffect(
     on(
       () => params.dir,
@@ -877,6 +1022,40 @@ export default function Page() {
   }
 
   const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
+  const wantsReview = createMemo(() =>
+    isDesktop()
+      ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
+      : store.mobileTab === "changes",
+  )
+
+  createEffect(() => {
+    const list = changesOptions()
+    if (list.includes(store.changes)) return
+    const next = list[0]
+    if (!next) return
+    setStore("changes", next)
+  })
+
+  createEffect(() => {
+    const mode = vcsMode()
+    if (!mode) return
+    if (!wantsReview()) return
+    void loadVcs(mode)
+  })
+
+  createEffect(
+    on(
+      () => sync.data.session_status[params.id ?? ""]?.type,
+      (next, prev) => {
+        const mode = vcsMode()
+        if (!mode) return
+        if (!wantsReview()) return
+        if (next !== "idle" || prev === undefined || prev === "idle") return
+        void loadVcs(mode, true)
+      },
+      { defer: true },
+    ),
+  )
 
   const fileTreeTab = () => layout.fileTree.tab()
   const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@@ -923,21 +1102,23 @@ export default function Page() {
     loadFile: file.load,
   })
 
-  const changesOptions = ["session", "turn"] as const
-  const changesOptionsList = [...changesOptions]
-
   const changesTitle = () => {
-    if (!hasReview()) {
+    if (!canReview()) {
       return null
     }
 
+    const label = (option: ChangeMode) => {
+      if (option === "git") return language.t("ui.sessionReview.title.git")
+      if (option === "branch") return language.t("ui.sessionReview.title.branch")
+      if (option === "session") return language.t("ui.sessionReview.title")
+      return language.t("ui.sessionReview.title.lastTurn")
+    }
+
     return (
       <Select
-        options={changesOptionsList}
+        options={changesOptions()}
         current={store.changes}
-        label={(option) =>
-          option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
-        }
+        label={label}
         onSelect={(option) => option && setStore("changes", option)}
         variant="ghost"
         size="small"
@@ -946,20 +1127,34 @@ export default function Page() {
     )
   }
 
-  const emptyTurn = () => (
+  const empty = (text: string) => (
     <div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
-      <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
+      <div class="text-14-regular text-text-weak max-w-56">{text}</div>
     </div>
   )
 
+  const reviewEmptyText = createMemo(() => {
+    if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
+    if (store.changes === "branch") return language.t("session.review.noBranchChanges")
+    if (store.changes === "turn") return language.t("session.review.noChanges")
+    return language.t(sessionEmptyKey())
+  })
+
   const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
-    if (store.changes === "turn") return emptyTurn()
+    if (store.changes === "git" || store.changes === "branch") {
+      if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
+      return empty(reviewEmptyText())
+    }
 
-    if (hasReview() && !diffsReady()) {
+    if (store.changes === "turn") {
+      return empty(reviewEmptyText())
+    }
+
+    if (hasSessionReview() && !diffsReady()) {
       return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
     }
 
-    if (reviewEmptyKey() === "session.review.noVcs") {
+    if (sessionEmptyKey() === "session.review.noVcs") {
       return (
         <div class={input.emptyClass}>
           <div class="flex flex-col gap-3">
@@ -979,7 +1174,7 @@ export default function Page() {
 
     return (
       <div class={input.emptyClass}>
-        <div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
+        <div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
       </div>
     )
   }
@@ -1083,7 +1278,7 @@ export default function Page() {
     const pending = tree.pendingDiff
     if (!pending) return
     if (!tree.reviewScroll) return
-    if (!diffsReady()) return
+    if (!reviewReady()) return
 
     const attempt = (count: number) => {
       if (tree.pendingDiff !== pending) return
@@ -1124,10 +1319,7 @@ export default function Page() {
     const id = params.id
     if (!id) return
 
-    const wants = isDesktop()
-      ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
-      : store.mobileTab === "changes"
-    if (!wants) return
+    if (!wantsReview()) return
     if (sync.data.session_diff[id] !== undefined) return
     if (sync.status === "loading") return
 
@@ -1136,13 +1328,7 @@ export default function Page() {
 
   createEffect(
     on(
-      () =>
-        [
-          sessionKey(),
-          isDesktop()
-            ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
-            : store.mobileTab === "changes",
-        ] as const,
+      () => [sessionKey(), wantsReview()] as const,
       ([key, wants]) => {
         if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
         if (diffTimer !== undefined) window.clearTimeout(diffTimer)
@@ -1828,6 +2014,12 @@ export default function Page() {
         </div>
 
         <SessionSidePanel
+          canReview={canReview}
+          diffs={reviewDiffs}
+          diffsReady={reviewReady}
+          empty={reviewEmptyText}
+          hasReview={hasReview}
+          reviewCount={reviewCount}
           reviewPanel={reviewPanel}
           activeDiff={tree.activeDiff}
           focusReviewDiff={focusReviewDiff}

+ 22 - 36
packages/app/src/pages/session/session-side-panel.tsx

@@ -8,6 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Mark } from "@opencode-ai/ui/logo"
 import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
 import type { DragEvent } from "@thisbeyond/solid-dnd"
+import type { FileDiff } from "@opencode-ai/sdk/v2"
 import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 
@@ -19,7 +20,6 @@ import { useCommand } from "@/context/command"
 import { useFile, type SelectedLineRange } from "@/context/file"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
-import { useSync } from "@/context/sync"
 import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
 import { FileTabContent } from "@/pages/session/file-tabs"
 import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
@@ -27,6 +27,12 @@ import { setSessionHandoff } from "@/pages/session/handoff"
 import { useSessionLayout } from "@/pages/session/session-layout"
 
 export function SessionSidePanel(props: {
+  canReview: () => boolean
+  diffs: () => FileDiff[]
+  diffsReady: () => boolean
+  empty: () => string
+  hasReview: () => boolean
+  reviewCount: () => number
   reviewPanel: () => JSX.Element
   activeDiff?: string
   focusReviewDiff: (path: string) => void
@@ -34,12 +40,11 @@ export function SessionSidePanel(props: {
   size: Sizing
 }) {
   const layout = useLayout()
-  const sync = useSync()
   const file = useFile()
   const language = useLanguage()
   const command = useCommand()
   const dialog = useDialog()
-  const { params, sessionKey, tabs, view } = useSessionLayout()
+  const { sessionKey, tabs, view } = useSessionLayout()
 
   const isDesktop = createMediaQuery("(min-width: 768px)")
 
@@ -54,24 +59,7 @@ export function SessionSidePanel(props: {
   })
   const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
 
-  const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
-  const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
-  const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
-  const hasReview = createMemo(() => reviewCount() > 0)
-  const diffsReady = createMemo(() => {
-    const id = params.id
-    if (!id) return true
-    if (!hasReview()) return true
-    return sync.data.session_diff[id] !== undefined
-  })
-
-  const reviewEmptyKey = createMemo(() => {
-    if (sync.project && !sync.project.vcs) return "session.review.noVcs"
-    if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
-    return "session.review.noChanges"
-  })
-
-  const diffFiles = createMemo(() => diffs().map((d) => d.file))
+  const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
   const kinds = createMemo(() => {
     const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
       if (!a) return b
@@ -82,7 +70,7 @@ export function SessionSidePanel(props: {
     const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
 
     const out = new Map<string, "add" | "del" | "mix">()
-    for (const diff of diffs()) {
+    for (const diff of props.diffs()) {
       const file = normalize(diff.file)
       const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
 
@@ -136,7 +124,7 @@ export function SessionSidePanel(props: {
     pathFromTab: file.pathFromTab,
     normalizeTab,
     review: reviewTab,
-    hasReview,
+    hasReview: props.canReview,
   })
   const contextOpen = tabState.contextOpen
   const openedTabs = tabState.openedTabs
@@ -241,12 +229,12 @@ export function SessionSidePanel(props: {
                         onCleanup(stop)
                       }}
                     >
-                      <Show when={reviewTab()}>
+                      <Show when={reviewTab() && props.canReview()}>
                         <Tabs.Trigger value="review">
                           <div class="flex items-center gap-1.5">
                             <div>{language.t("session.tab.review")}</div>
-                            <Show when={hasReview()}>
-                              <div>{reviewCount()}</div>
+                            <Show when={props.hasReview()}>
+                              <div>{props.reviewCount()}</div>
                             </Show>
                           </div>
                         </Tabs.Trigger>
@@ -303,7 +291,7 @@ export function SessionSidePanel(props: {
                     </Tabs.List>
                   </div>
 
-                  <Show when={reviewTab()}>
+                  <Show when={reviewTab() && props.canReview()}>
                     <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
                       <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
                     </Tabs.Content>
@@ -377,8 +365,10 @@ export function SessionSidePanel(props: {
               >
                 <Tabs.List>
                   <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
-                    {reviewCount()}{" "}
-                    {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
+                    {props.reviewCount()}{" "}
+                    {language.t(
+                      props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
+                    )}
                   </Tabs.Trigger>
                   <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
                     {language.t("session.files.all")}
@@ -386,9 +376,9 @@ export function SessionSidePanel(props: {
                 </Tabs.List>
                 <Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
                   <Switch>
-                    <Match when={hasReview()}>
+                    <Match when={props.hasReview() || !props.diffsReady()}>
                       <Show
-                        when={diffsReady()}
+                        when={props.diffsReady()}
                         fallback={
                           <div class="px-2 py-2 text-12-regular text-text-weak">
                             {language.t("common.loading")}
@@ -407,11 +397,7 @@ export function SessionSidePanel(props: {
                         />
                       </Show>
                     </Match>
-                    <Match when={true}>
-                      {empty(
-                        language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
-                      )}
-                    </Match>
+                    <Match when={true}>{empty(props.empty())}</Match>
                   </Switch>
                 </Tabs.Content>
                 <Tabs.Content value="all" class="bg-background-stronger px-3 py-0">

+ 1 - 5
packages/app/src/pages/session/use-session-commands.tsx

@@ -56,11 +56,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
     if (!id) return
     return sync.session.get(id)
   }
-  const hasReview = () => {
-    const id = params.id
-    if (!id) return false
-    return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
-  }
+  const hasReview = () => !!params.id
   const normalizeTab = (tab: string) => {
     if (!tab.startsWith("file://")) return tab
     return file.tab(tab)

+ 5 - 4
packages/console/app/src/routes/zen/util/handler.ts

@@ -132,7 +132,7 @@ export async function handler(
         retry,
         stickyProvider,
       )
-      validateModelSettings(authInfo)
+      validateModelSettings(billingSource, authInfo)
       updateProviderKey(authInfo, providerInfo)
       logger.metric({ provider: providerInfo.id })
 
@@ -768,9 +768,10 @@ export async function handler(
     return "balance"
   }
 
-  function validateModelSettings(authInfo: AuthInfo) {
-    if (!authInfo) return
-    if (authInfo.isDisabled) throw new ModelError(t("zen.api.error.modelDisabled"))
+  function validateModelSettings(billingSource: BillingSource, authInfo: AuthInfo) {
+    if (billingSource === "lite") return
+    if (billingSource === "anonymous") return
+    if (authInfo!.isDisabled) throw new ModelError(t("zen.api.error.modelDisabled"))
   }
 
   function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) {

+ 63 - 3
packages/console/core/script/lookup-user.ts

@@ -14,6 +14,7 @@ import { KeyTable } from "../src/schema/key.sql.js"
 import { BlackData } from "../src/black.js"
 import { centsToMicroCents } from "../src/util/price.js"
 import { getWeekBounds } from "../src/util/date.js"
+import { ModelTable } from "../src/schema/model.sql.js"
 
 // get input from command line
 const identifier = process.argv[2]
@@ -178,9 +179,8 @@ async function printWorkspace(workspaceID: string) {
             balance: `$${(row.balance / 100000000).toFixed(2)}`,
             reload: row.reload ? "yes" : "no",
             customerID: row.customerID,
-            liteSubscriptionID: row.liteSubscriptionID,
-            blackSubscriptionID: row.blackSubscriptionID,
-            blackSubscription: row.blackSubscriptionID
+            GO: row.liteSubscriptionID,
+            Black: row.blackSubscriptionID
               ? [
                   `Black ${row.blackSubscription.enrichment!.plan}`,
                   row.blackSubscription.enrichment!.seats > 1
@@ -223,6 +223,50 @@ async function printWorkspace(workspaceID: string) {
       ),
   )
 
+  await printTable("28-Day Usage", (tx) =>
+    tx
+      .select({
+        date: sql<string>`DATE(${UsageTable.timeCreated})`.as("date"),
+        requests: sql<number>`COUNT(*)`.as("requests"),
+        inputTokens: sql<number>`SUM(${UsageTable.inputTokens})`.as("input_tokens"),
+        outputTokens: sql<number>`SUM(${UsageTable.outputTokens})`.as("output_tokens"),
+        reasoningTokens: sql<number>`SUM(${UsageTable.reasoningTokens})`.as("reasoning_tokens"),
+        cacheReadTokens: sql<number>`SUM(${UsageTable.cacheReadTokens})`.as("cache_read_tokens"),
+        cacheWrite5mTokens: sql<number>`SUM(${UsageTable.cacheWrite5mTokens})`.as("cache_write_5m_tokens"),
+        cacheWrite1hTokens: sql<number>`SUM(${UsageTable.cacheWrite1hTokens})`.as("cache_write_1h_tokens"),
+        cost: sql<number>`SUM(${UsageTable.cost})`.as("cost"),
+      })
+      .from(UsageTable)
+      .where(
+        and(
+          eq(UsageTable.workspaceID, workspace.id),
+          sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
+        ),
+      )
+      .groupBy(sql`DATE(${UsageTable.timeCreated})`)
+      .orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
+      .then((rows) => {
+        const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0)
+        const mapped = rows.map((row) => ({
+          ...row,
+          cost: `$${(Number(row.cost) / 100000000).toFixed(2)}`,
+        }))
+        if (mapped.length > 0) {
+          mapped.push({
+            date: "TOTAL",
+            requests: null as any,
+            inputTokens: null as any,
+            outputTokens: null as any,
+            reasoningTokens: null as any,
+            cacheReadTokens: null as any,
+            cacheWrite5mTokens: null as any,
+            cacheWrite1hTokens: null as any,
+            cost: `$${(totalCost / 100000000).toFixed(2)}`,
+          })
+        }
+        return mapped
+      }),
+  )
   /*
   await printTable("Usage", (tx) =>
     tx
@@ -248,6 +292,22 @@ async function printWorkspace(workspaceID: string) {
           cost: `$${(row.cost / 100000000).toFixed(2)}`,
         })),
       ),
+  )
+  await printTable("Disabled Models", (tx) =>
+    tx
+      .select({
+        model: ModelTable.model,
+        timeCreated: ModelTable.timeCreated,
+      })
+      .from(ModelTable)
+      .where(eq(ModelTable.workspaceID, workspace.id))
+      .orderBy(sql`${ModelTable.timeCreated} DESC`)
+      .then((rows) =>
+        rows.map((row) => ({
+          model: row.model,
+          timeCreated: formatDate(row.timeCreated),
+        })),
+      ),
   )
         */
 }

+ 8 - 6
packages/opencode/AGENTS.md

@@ -31,12 +31,14 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
 - Use `Schema.Defect` instead of `unknown` for defect-like causes.
 - In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
 
-## Runtime vs Instances
+## Runtime vs InstanceState
 
-- Use the shared runtime for process-wide services with one lifecycle for the whole app.
-- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup.
-- If two open directories should not share one copy of the service, it belongs in `Instances`.
-- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals.
+- Use `makeRuntime` (from `src/effect/run-service.ts`) for all services. It returns `{ runPromise, runFork, runCallback }` backed by a shared `memoMap` that deduplicates layers.
+- Use `InstanceState` (from `src/effect/instance-state.ts`) for per-directory or per-project state that needs per-instance cleanup. It uses `ScopedCache` keyed by directory — each open project gets its own state, automatically cleaned up on disposal.
+- If two open directories should not share one copy of the service, it needs `InstanceState`.
+- Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top.
+- Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.).
+- Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed.
 
 ## Preferred Effect services
 
@@ -51,7 +53,7 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
 
 `Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
 
-Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
+Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish` or anything that reads `Instance.directory`.
 
 You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
 

+ 1 - 1
packages/opencode/package.json

@@ -121,7 +121,7 @@
     "drizzle-orm": "catalog:",
     "effect": "catalog:",
     "fuzzysort": "3.1.0",
-    "gitlab-ai-provider": "5.3.2",
+    "gitlab-ai-provider": "5.3.3",
     "glob": "13.0.5",
     "google-auth-library": "10.5.0",
     "gray-matter": "4.0.3",

+ 21 - 9
packages/opencode/specs/effect-migration.md

@@ -6,7 +6,7 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
 
 Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
 
-Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
+Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
 
 - Global services (no per-directory state): Account, Auth, Installation, Truncate
 - Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
@@ -46,7 +46,7 @@ export namespace Foo {
   export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
 
   // Per-service runtime (inside the namespace)
-  const runPromise = makeRunPromise(Service, defaultLayer)
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   // Async facade functions
   export async function get(id: FooID) {
@@ -79,22 +79,24 @@ See `Auth.ZodInfo` for the canonical example.
 
 The `InstanceState.make` init callback receives a `Scope`, so you can use `Effect.acquireRelease`, `Effect.addFinalizer`, and `Effect.forkScoped` inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by `ScopedCache`. This makes it the right place for:
 
-- **Subscriptions**: Use `Effect.acquireRelease` to subscribe and auto-unsubscribe:
+- **Subscriptions**: Yield `Bus.Service` at the layer level, then use `Stream` + `forkScoped` inside the init closure. The fiber is automatically interrupted when the instance scope closes:
 
 ```ts
+const bus = yield * Bus.Service
+
 const cache =
   yield *
   InstanceState.make<State>(
     Effect.fn("Foo.state")(function* (ctx) {
       // ... load state ...
 
-      yield* Effect.acquireRelease(
-        Effect.sync(() =>
-          Bus.subscribeAll((event) => {
+      yield* bus.subscribeAll().pipe(
+        Stream.runForEach((event) =>
+          Effect.sync(() => {
             /* handle */
           }),
         ),
-        (unsub) => Effect.sync(unsub),
+        Effect.forkScoped,
       )
 
       return {
@@ -104,6 +106,16 @@ const cache =
   )
 ```
 
+- **Resource cleanup**: Use `Effect.acquireRelease` or `Effect.addFinalizer` for resources that need teardown (native watchers, process handles, etc.):
+
+```ts
+yield *
+  Effect.acquireRelease(
+    Effect.sync(() => nativeAddon.watch(dir)),
+    (watcher) => Effect.sync(() => watcher.close()),
+  )
+```
+
 - **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal.
 - **Side effects at init**: Config notification, event wiring, etc. all belong in the init closure. Callers just do `InstanceState.get(cache)` to trigger everything, and `ScopedCache` deduplicates automatically.
 
@@ -165,7 +177,7 @@ Still open and likely worth migrating:
 - [x] `ToolRegistry`
 - [ ] `Pty`
 - [x] `Worktree`
-- [ ] `Bus`
+- [x] `Bus`
 - [x] `Command`
 - [ ] `Config`
 - [ ] `Session`
@@ -175,4 +187,4 @@ Still open and likely worth migrating:
 - [ ] `Provider`
 - [x] `Project`
 - [ ] `LSP`
-- [ ] `MCP`
+- [x] `MCP`

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

@@ -1,7 +1,7 @@
 import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
 import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
 
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { withTransientReadRetry } from "@/util/effect-http-client"
 import { AccountRepo, type AccountRow } from "./repo"
 import {
@@ -379,7 +379,7 @@ export namespace Account {
 
   export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
 
-  export const runPromise = makeRunPromise(Service, defaultLayer)
+  export const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function active(): Promise<Info | undefined> {
     return Option.getOrUndefined(await runPromise((service) => service.active()))

+ 2 - 3
packages/opencode/src/agent/agent.ts

@@ -21,7 +21,7 @@ import { Plugin } from "@/plugin"
 import { Skill } from "../skill"
 import { Effect, ServiceMap, Layer } from "effect"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 
 export namespace Agent {
   export const Info = z
@@ -148,7 +148,6 @@ export namespace Agent {
               permission: Permission.merge(
                 defaults,
                 Permission.fromConfig({
-                  todoread: "deny",
                   todowrite: "deny",
                 }),
                 user,
@@ -394,7 +393,7 @@ export namespace Agent {
 
   export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
 
-  const runPromise = makeRunPromise(Service, defaultLayer)
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function get(agent: string) {
     return runPromise((svc) => svc.get(agent))

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

@@ -1,6 +1,6 @@
 import path from "path"
 import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { zod } from "@/util/effect-zod"
 import { Global } from "../global"
 import { Filesystem } from "../util/filesystem"
@@ -95,7 +95,7 @@ export namespace Auth {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export async function get(providerID: string) {
     return runPromise((service) => service.get(providerID))

+ 154 - 75
packages/opencode/src/bus/index.ts

@@ -1,12 +1,14 @@
 import z from "zod"
+import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
 import { Log } from "../util/log"
 import { Instance } from "../project/instance"
 import { BusEvent } from "./bus-event"
 import { GlobalBus } from "./global"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRuntime } from "@/effect/run-service"
 
 export namespace Bus {
   const log = Log.create({ service: "bus" })
-  type Subscription = (event: any) => void
 
   export const InstanceDisposed = BusEvent.define(
     "server.instance.disposed",
@@ -15,91 +17,168 @@ export namespace Bus {
     }),
   )
 
-  const state = Instance.state(
-    () => {
-      const subscriptions = new Map<any, Subscription[]>()
+  type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
+    type: D["type"]
+    properties: z.infer<D["properties"]>
+  }
+
+  type State = {
+    wildcard: PubSub.PubSub<Payload>
+    typed: Map<string, PubSub.PubSub<Payload>>
+  }
+
+  export interface Interface {
+    readonly publish: <D extends BusEvent.Definition>(
+      def: D,
+      properties: z.output<D["properties"]>,
+    ) => Effect.Effect<void>
+    readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
+    readonly subscribeAll: () => Stream.Stream<Payload>
+    readonly subscribeCallback: <D extends BusEvent.Definition>(
+      def: D,
+      callback: (event: Payload<D>) => unknown,
+    ) => Effect.Effect<() => void>
+    readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const cache = yield* InstanceState.make<State>(
+        Effect.fn("Bus.state")(function* (ctx) {
+          const wildcard = yield* PubSub.unbounded<Payload>()
+          const typed = new Map<string, PubSub.PubSub<Payload>>()
+
+          yield* Effect.addFinalizer(() =>
+            Effect.gen(function* () {
+              // Publish InstanceDisposed before shutting down so subscribers see it
+              yield* PubSub.publish(wildcard, {
+                type: InstanceDisposed.type,
+                properties: { directory: ctx.directory },
+              })
+              yield* PubSub.shutdown(wildcard)
+              for (const ps of typed.values()) {
+                yield* PubSub.shutdown(ps)
+              }
+            }),
+          )
 
-      return {
-        subscriptions,
+          return { wildcard, typed }
+        }),
+      )
+
+      function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
+        return Effect.gen(function* () {
+          let ps = state.typed.get(def.type)
+          if (!ps) {
+            ps = yield* PubSub.unbounded<Payload>()
+            state.typed.set(def.type, ps)
+          }
+          return ps as unknown as PubSub.PubSub<Payload<D>>
+        })
       }
-    },
-    async (entry) => {
-      const wildcard = entry.subscriptions.get("*")
-      if (!wildcard) return
-      const event = {
-        type: InstanceDisposed.type,
-        properties: {
-          directory: Instance.directory,
-        },
+
+      function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
+        return Effect.gen(function* () {
+          const state = yield* InstanceState.get(cache)
+          const payload: Payload = { type: def.type, properties }
+          log.info("publishing", { type: def.type })
+
+          const ps = state.typed.get(def.type)
+          if (ps) yield* PubSub.publish(ps, payload)
+          yield* PubSub.publish(state.wildcard, payload)
+
+          GlobalBus.emit("event", {
+            directory: Instance.directory,
+            payload,
+          })
+        })
       }
-      for (const sub of [...wildcard]) {
-        sub(event)
+
+      function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
+        log.info("subscribing", { type: def.type })
+        return Stream.unwrap(
+          Effect.gen(function* () {
+            const state = yield* InstanceState.get(cache)
+            const ps = yield* getOrCreate(state, def)
+            return Stream.fromPubSub(ps)
+          }),
+        ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
       }
-    },
-  )
 
-  export async function publish<Definition extends BusEvent.Definition>(
-    def: Definition,
-    properties: z.output<Definition["properties"]>,
-  ) {
-    const payload = {
-      type: def.type,
-      properties,
-    }
-    log.info("publishing", {
-      type: def.type,
-    })
-    const pending = []
-    for (const key of [def.type, "*"]) {
-      const match = [...(state().subscriptions.get(key) ?? [])]
-      for (const sub of match) {
-        pending.push(sub(payload))
+      function subscribeAll(): Stream.Stream<Payload> {
+        log.info("subscribing", { type: "*" })
+        return Stream.unwrap(
+          Effect.gen(function* () {
+            const state = yield* InstanceState.get(cache)
+            return Stream.fromPubSub(state.wildcard)
+          }),
+        ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
       }
-    }
-    GlobalBus.emit("event", {
-      directory: Instance.directory,
-      payload,
-    })
-    return Promise.all(pending)
-  }
 
-  export function subscribe<Definition extends BusEvent.Definition>(
-    def: Definition,
-    callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
-  ) {
-    return raw(def.type, callback)
-  }
+      function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
+        return Effect.gen(function* () {
+          log.info("subscribing", { type })
+          const scope = yield* Scope.make()
+          const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
 
-  export function once<Definition extends BusEvent.Definition>(
-    def: Definition,
-    callback: (event: {
-      type: Definition["type"]
-      properties: z.infer<Definition["properties"]>
-    }) => "done" | undefined,
-  ) {
-    const unsub = subscribe(def, (event) => {
-      if (callback(event)) unsub()
-    })
+          yield* Scope.provide(scope)(
+            Stream.fromSubscription(subscription).pipe(
+              Stream.runForEach((msg) =>
+                Effect.tryPromise({
+                  try: () => Promise.resolve().then(() => callback(msg)),
+                  catch: (cause) => {
+                    log.error("subscriber failed", { type, cause })
+                  },
+                }).pipe(Effect.ignore),
+              ),
+              Effect.forkScoped,
+            ),
+          )
+
+          return () => {
+            log.info("unsubscribing", { type })
+            Effect.runFork(Scope.close(scope, Exit.void))
+          }
+        })
+      }
+
+      const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
+        def: D,
+        callback: (event: Payload<D>) => unknown,
+      ) {
+        const state = yield* InstanceState.get(cache)
+        const ps = yield* getOrCreate(state, def)
+        return yield* on(ps, def.type, callback)
+      })
+
+      const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
+        const state = yield* InstanceState.get(cache)
+        return yield* on(state.wildcard, "*", callback)
+      })
+
+      return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
+    }),
+  )
+
+  const { runPromise, runSync } = makeRuntime(Service, layer)
+
+  // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
+  // Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
+  export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
+    return runPromise((svc) => svc.publish(def, properties))
   }
 
-  export function subscribeAll(callback: (event: any) => void) {
-    return raw("*", callback)
+  export function subscribe<D extends BusEvent.Definition>(
+    def: D,
+    callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
+  ) {
+    return runSync((svc) => svc.subscribeCallback(def, callback))
   }
 
-  function raw(type: string, callback: (event: any) => void) {
-    log.info("subscribing", { type })
-    const subscriptions = state().subscriptions
-    let match = subscriptions.get(type) ?? []
-    match.push(callback)
-    subscriptions.set(type, match)
-
-    return () => {
-      log.info("unsubscribing", { type })
-      const match = subscriptions.get(type)
-      if (!match) return
-      const index = match.indexOf(callback)
-      if (index === -1) return
-      match.splice(index, 1)
-    }
+  export function subscribeAll(callback: (event: any) => unknown) {
+    return runSync((svc) => svc.subscribeAllCallback(callback))
   }
 }

+ 1 - 13
packages/opencode/src/cli/cmd/agent.ts

@@ -14,19 +14,7 @@ import type { Argv } from "yargs"
 
 type AgentMode = "all" | "primary" | "subagent"
 
-const AVAILABLE_TOOLS = [
-  "bash",
-  "read",
-  "write",
-  "edit",
-  "list",
-  "glob",
-  "grep",
-  "webfetch",
-  "task",
-  "todowrite",
-  "todoread",
-]
+const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"]
 
 const AgentCreateCommand = cmd({
   command: "create",

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

@@ -28,9 +28,9 @@ import { Provider } from "../../provider/provider"
 import { Bus } from "../../bus"
 import { MessageV2 } from "../../session/message-v2"
 import { SessionPrompt } from "@/session/prompt"
+import { Git } from "@/git"
 import { setTimeout as sleep } from "node:timers/promises"
 import { Process } from "@/util/process"
-import { git } from "@/util/git"
 
 type GitHubAuthor = {
   login: string
@@ -257,7 +257,7 @@ export const GithubInstallCommand = cmd({
             }
 
             // Get repo info
-            const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
+            const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
             const parsed = parseGitHubRemote(info)
             if (!parsed) {
               prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -496,20 +496,20 @@ export const GithubRunCommand = cmd({
           : "issue"
         : undefined
       const gitText = async (args: string[]) => {
-        const result = await git(args, { cwd: Instance.worktree })
+        const result = await Git.run(args, { cwd: Instance.worktree })
         if (result.exitCode !== 0) {
           throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
         }
         return result.text().trim()
       }
       const gitRun = async (args: string[]) => {
-        const result = await git(args, { cwd: Instance.worktree })
+        const result = await Git.run(args, { cwd: Instance.worktree })
         if (result.exitCode !== 0) {
           throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
         }
         return result
       }
-      const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
+      const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree })
       const commitChanges = async (summary: string, actor?: string) => {
         const args = ["commit", "-m", summary]
         if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
@@ -869,7 +869,6 @@ export const GithubRunCommand = cmd({
       function subscribeSessionEvents() {
         const TOOL: Record<string, [string, string]> = {
           todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
-          todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
           bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
           edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
           glob: ["Glob", UI.Style.TEXT_INFO_BOLD],

+ 4 - 4
packages/opencode/src/cli/cmd/pr.ts

@@ -1,8 +1,8 @@
 import { UI } from "../ui"
 import { cmd } from "./cmd"
+import { Git } from "@/git"
 import { Instance } from "@/project/instance"
 import { Process } from "@/util/process"
-import { git } from "@/util/git"
 
 export const PrCommand = cmd({
   command: "pr <number>",
@@ -67,9 +67,9 @@ export const PrCommand = cmd({
               const remoteName = forkOwner
 
               // Check if remote already exists
-              const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
+              const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
               if (!remotes.split("\n").includes(remoteName)) {
-                await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
+                await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
                   cwd: Instance.worktree,
                 })
                 UI.println(`Added fork remote: ${remoteName}`)
@@ -77,7 +77,7 @@ export const PrCommand = cmd({
 
               // Set upstream to the fork so pushes go there
               const headRefName = prInfo.headRefName
-              await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
+              await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
                 cwd: Instance.worktree,
               })
             }

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

@@ -186,7 +186,7 @@ export function tui(input: {
         targetFps: 60,
         gatherStats: false,
         exitOnCtrlC: false,
-        useKittyKeyboard: {},
+        useKittyKeyboard: { events: process.platform === "win32" },
         autoFocus: false,
         openConsoleOnError: false,
         consoleOptions: {

+ 21 - 5
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -18,7 +18,7 @@ import { usePromptStash } from "./stash"
 import { DialogStash } from "../dialog-stash"
 import { type AutocompleteRef, Autocomplete } from "./autocomplete"
 import { useCommandDialog } from "../dialog-command"
-import { useRenderer } from "@opentui/solid"
+import { useKeyboard, useRenderer } from "@opentui/solid"
 import { Editor } from "@tui/util/editor"
 import { useExit } from "../../context/exit"
 import { Clipboard } from "../../util/clipboard"
@@ -356,6 +356,20 @@ export function Prompt(props: PromptProps) {
     ]
   })
 
+  // Windows Terminal 1.25+ handles Ctrl+V on keydown when kitty events are
+  // enabled, but still reports the kitty key-release event. Probe on release.
+  if (process.platform === "win32") {
+    useKeyboard(
+      (evt) => {
+        if (!input.focused) return
+        if (evt.name === "v" && evt.ctrl && evt.eventType === "release") {
+          command.trigger("prompt.paste")
+        }
+      },
+      { release: true },
+    )
+  }
+
   const ref: PromptRef = {
     get focused() {
       return input.focused
@@ -850,10 +864,9 @@ export function Prompt(props: PromptProps) {
                   e.preventDefault()
                   return
                 }
-                // Handle clipboard paste (Ctrl+V) - check for images first on Windows
-                // This is needed because Windows terminal doesn't properly send image data
-                // through bracketed paste, so we need to intercept the keypress and
-                // directly read from clipboard before the terminal handles it
+                // Check clipboard for images before terminal-handled paste runs.
+                // This helps terminals that forward Ctrl+V to the app; Windows
+                // Terminal 1.25+ usually handles Ctrl+V before this path.
                 if (keybind.match("input_paste", e)) {
                   const content = await Clipboard.read()
                   if (content?.mime.startsWith("image/")) {
@@ -936,6 +949,9 @@ export function Prompt(props: PromptProps) {
                 // Replace CRLF first, then any remaining CR
                 const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
                 const pastedContent = normalizedText.trim()
+
+                // Windows Terminal <1.25 can surface image-only clipboard as an
+                // empty bracketed paste. Windows Terminal 1.25+ does not.
                 if (!pastedContent) {
                   command.trigger("prompt.paste")
                   return

+ 10 - 0
packages/opencode/src/cli/cmd/tui/util/clipboard.ts

@@ -28,6 +28,14 @@ export namespace Clipboard {
     mime: string
   }
 
+  // Checks clipboard for images first, then falls back to text.
+  //
+  // On Windows prompt/ can call this from multiple paste signals because
+  // terminals surface image paste differently:
+  //   1. A forwarded Ctrl+V keypress
+  //   2. An empty bracketed-paste hint for image-only clipboard in Windows
+  //      Terminal <1.25
+  //   3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
   export async function read(): Promise<Content | undefined> {
     const os = platform()
 
@@ -58,6 +66,8 @@ export namespace Clipboard {
       }
     }
 
+    // Windows/WSL: probe clipboard for images via PowerShell.
+    // Bracketed paste can't carry image data so we read it directly.
     if (os === "win32" || release().includes("WSL")) {
       const script =
         "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"

+ 2 - 2
packages/opencode/src/command/index.ts

@@ -1,6 +1,6 @@
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { SessionID, MessageID } from "@/session/schema"
 import { Effect, Layer, ServiceMap } from "effect"
 import z from "zod"
@@ -173,7 +173,7 @@ export namespace Command {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export async function get(name: string) {
     return runPromise((svc) => svc.get(name))

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

@@ -673,7 +673,6 @@ export namespace Config {
           task: PermissionRule.optional(),
           external_directory: PermissionRule.optional(),
           todowrite: PermissionAction.optional(),
-          todoread: PermissionAction.optional(),
           question: PermissionAction.optional(),
           webfetch: PermissionAction.optional(),
           websearch: PermissionAction.optional(),

+ 0 - 14
packages/opencode/src/effect/instance-context.ts

@@ -1,14 +0,0 @@
-import { ServiceMap } from "effect"
-import type { Project } from "@/project/project"
-
-export declare namespace InstanceContext {
-  export interface Shape {
-    readonly directory: string
-    readonly worktree: string
-    readonly project: Project.Info
-  }
-}
-
-export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
-  "opencode/InstanceContext",
-) {}

+ 8 - 4
packages/opencode/src/effect/run-service.ts

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

+ 13 - 25
packages/opencode/src/file/index.ts

@@ -1,8 +1,8 @@
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
-import { git } from "@/util/git"
-import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
+import { makeRuntime } from "@/effect/run-service"
+import { Git } from "@/git"
+import { Effect, Layer, ServiceMap } from "effect"
 import { formatPatch, structuredPatch } from "diff"
 import fs from "fs"
 import fuzzysort from "fuzzysort"
@@ -324,7 +324,6 @@ export namespace File {
 
   interface State {
     cache: Entry
-    fiber: Fiber.Fiber<void> | undefined
   }
 
   export interface Interface {
@@ -349,7 +348,6 @@ export namespace File {
         Effect.fn("File.state")(() =>
           Effect.succeed({
             cache: { files: [], dirs: [] } as Entry,
-            fiber: undefined as Fiber.Fiber<void> | undefined,
           }),
         ),
       )
@@ -413,21 +411,11 @@ export namespace File {
         s.cache = next
       })
 
-      const scope = yield* Scope.Scope
+      let cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
 
       const ensure = Effect.fn("File.ensure")(function* () {
-        const s = yield* InstanceState.get(state)
-        if (!s.fiber)
-          s.fiber = yield* scan().pipe(
-            Effect.catchCause(() => Effect.void),
-            Effect.ensuring(
-              Effect.sync(() => {
-                s.fiber = undefined
-              }),
-            ),
-            Effect.forkIn(scope),
-          )
-        yield* Fiber.join(s.fiber)
+        yield* cachedScan
+        cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
       })
 
       const init = Effect.fn("File.init")(function* () {
@@ -439,7 +427,7 @@ export namespace File {
 
         return yield* Effect.promise(async () => {
           const diffOutput = (
-            await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
+            await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
               cwd: Instance.directory,
             })
           ).text()
@@ -459,7 +447,7 @@ export namespace File {
           }
 
           const untrackedOutput = (
-            await git(
+            await Git.run(
               [
                 "-c",
                 "core.fsmonitor=false",
@@ -492,7 +480,7 @@ export namespace File {
           }
 
           const deletedOutput = (
-            await git(
+            await Git.run(
               [
                 "-c",
                 "core.fsmonitor=false",
@@ -583,17 +571,17 @@ export namespace File {
 
           if (Instance.project.vcs === "git") {
             let diff = (
-              await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
+              await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
             ).text()
             if (!diff.trim()) {
               diff = (
-                await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
+                await Git.run(["-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 original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
               const patch = structuredPatch(file, file, original, content, "old", "new", {
                 context: Infinity,
                 ignoreWhitespace: true,
@@ -712,7 +700,7 @@ export namespace File {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export function init() {
     return runPromise((svc) => svc.init())

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

@@ -1,6 +1,6 @@
 import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { Flag } from "@/flag/flag"
 import type { SessionID } from "@/session/schema"
 import { Filesystem } from "../util/filesystem"
@@ -108,7 +108,7 @@ export namespace FileTime {
     }),
   ).pipe(Layer.orDie)
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export function read(sessionID: SessionID, file: string) {
     return runPromise((s) => s.read(sessionID, file))

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

@@ -8,10 +8,10 @@ import z from "zod"
 import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { Flag } from "@/flag/flag"
+import { Git } from "@/git"
 import { Instance } from "@/project/instance"
-import { git } from "@/util/git"
 import { lazy } from "@/util/lazy"
 import { Config } from "../config/config"
 import { FileIgnore } from "./ignore"
@@ -130,7 +130,7 @@ export namespace FileWatcher {
 
             if (Instance.project.vcs === "git") {
               const result = yield* Effect.promise(() =>
-                git(["rev-parse", "--git-dir"], {
+                Git.run(["rev-parse", "--git-dir"], {
                   cwd: Instance.project.worktree,
                 }),
               )
@@ -159,7 +159,7 @@ export namespace FileWatcher {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export function init() {
     return runPromise((svc) => svc.init())

+ 48 - 47
packages/opencode/src/format/index.ts

@@ -1,12 +1,10 @@
 import { Effect, Layer, ServiceMap } from "effect"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import path from "path"
 import { mergeDeep } from "remeda"
 import z from "zod"
-import { Bus } from "../bus"
 import { Config } from "../config/config"
-import { File } from "../file"
 import { Instance } from "../project/instance"
 import { Process } from "../util/process"
 import { Log } from "../util/log"
@@ -29,6 +27,7 @@ export namespace Format {
   export interface Interface {
     readonly init: () => Effect.Effect<void>
     readonly status: () => Effect.Effect<Status[]>
+    readonly file: (filepath: string) => Effect.Effect<void>
   }
 
   export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
@@ -97,53 +96,46 @@ export namespace Format {
             return checks.filter((x) => x.enabled).map((x) => x.item)
           }
 
-          yield* Effect.acquireRelease(
-            Effect.sync(() =>
-              Bus.subscribe(
-                File.Event.Edited,
-                Instance.bind(async (payload) => {
-                  const file = payload.properties.file
-                  log.info("formatting", { file })
-                  const ext = path.extname(file)
-
-                  for (const item of await getFormatter(ext)) {
-                    log.info("running", { command: item.command })
-                    try {
-                      const proc = Process.spawn(
-                        item.command.map((x) => x.replace("$FILE", file)),
-                        {
-                          cwd: Instance.directory,
-                          env: { ...process.env, ...item.environment },
-                          stdout: "ignore",
-                          stderr: "ignore",
-                        },
-                      )
-                      const exit = await proc.exited
-                      if (exit !== 0) {
-                        log.error("failed", {
-                          command: item.command,
-                          ...item.environment,
-                        })
-                      }
-                    } catch (error) {
-                      log.error("failed to format file", {
-                        error,
-                        command: item.command,
-                        ...item.environment,
-                        file,
-                      })
-                    }
-                  }
-                }),
-              ),
-            ),
-            (unsubscribe) => Effect.sync(unsubscribe),
-          )
+          async function formatFile(filepath: string) {
+            log.info("formatting", { file: filepath })
+            const ext = path.extname(filepath)
+
+            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", filepath)),
+                  {
+                    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: filepath,
+                })
+              }
+            }
+          }
+
           log.info("init")
 
           return {
             formatters,
             isEnabled,
+            formatFile,
           }
         }),
       )
@@ -166,11 +158,16 @@ export namespace Format {
         return result
       })
 
-      return Service.of({ init, status })
+      const file = Effect.fn("Format.file")(function* (filepath: string) {
+        const { formatFile } = yield* InstanceState.get(state)
+        yield* Effect.promise(() => formatFile(filepath))
+      })
+
+      return Service.of({ init, status, file })
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export async function init() {
     return runPromise((s) => s.init())
@@ -179,4 +176,8 @@ export namespace Format {
   export async function status() {
     return runPromise((s) => s.status())
   }
+
+  export async function file(filepath: string) {
+    return runPromise((s) => s.file(filepath))
+  }
 }

+ 308 - 0
packages/opencode/src/git/index.ts

@@ -0,0 +1,308 @@
+import { NodeFileSystem, NodePath } from "@effect/platform-node"
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+import { Effect, Layer, ServiceMap, Stream } from "effect"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import { makeRuntime } from "@/effect/run-service"
+
+export namespace Git {
+  const cfg = [
+    "--no-optional-locks",
+    "-c",
+    "core.autocrlf=false",
+    "-c",
+    "core.fsmonitor=false",
+    "-c",
+    "core.longpaths=true",
+    "-c",
+    "core.symlinks=true",
+    "-c",
+    "core.quotepath=false",
+  ] as const
+
+  const out = (result: { text(): string }) => result.text().trim()
+  const nuls = (text: string) => text.split("\0").filter(Boolean)
+  const fail = (err: unknown) =>
+    ({
+      exitCode: 1,
+      text: () => "",
+      stdout: Buffer.alloc(0),
+      stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
+    }) satisfies Result
+
+  export type Kind = "added" | "deleted" | "modified"
+
+  export type Base = {
+    readonly name: string
+    readonly ref: string
+  }
+
+  export type Item = {
+    readonly file: string
+    readonly code: string
+    readonly status: Kind
+  }
+
+  export type Stat = {
+    readonly file: string
+    readonly additions: number
+    readonly deletions: number
+  }
+
+  export interface Result {
+    readonly exitCode: number
+    readonly text: () => string
+    readonly stdout: Buffer
+    readonly stderr: Buffer
+  }
+
+  export interface Options {
+    readonly cwd: string
+    readonly env?: Record<string, string>
+  }
+
+  export interface Interface {
+    readonly run: (args: string[], opts: Options) => Effect.Effect<Result>
+    readonly branch: (cwd: string) => Effect.Effect<string | undefined>
+    readonly prefix: (cwd: string) => Effect.Effect<string>
+    readonly defaultBranch: (cwd: string) => Effect.Effect<Base | undefined>
+    readonly hasHead: (cwd: string) => Effect.Effect<boolean>
+    readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect<string | undefined>
+    readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect<string>
+    readonly status: (cwd: string) => Effect.Effect<Item[]>
+    readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
+    readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
+  }
+
+  const kind = (code: string): Kind => {
+    if (code === "??") return "added"
+    if (code.includes("U")) return "modified"
+    if (code.includes("A") && !code.includes("D")) return "added"
+    if (code.includes("D") && !code.includes("A")) return "deleted"
+    return "modified"
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Git") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+
+      const run = Effect.fn("Git.run")(
+        function* (args: string[], opts: Options) {
+          const proc = ChildProcess.make("git", [...cfg, ...args], {
+            cwd: opts.cwd,
+            env: opts.env,
+            extendEnv: true,
+            stdin: "ignore",
+            stdout: "pipe",
+            stderr: "pipe",
+          })
+          const handle = yield* spawner.spawn(proc)
+          const [stdout, stderr] = yield* Effect.all(
+            [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
+            { concurrency: 2 },
+          )
+          return {
+            exitCode: yield* handle.exitCode,
+            text: () => stdout,
+            stdout: Buffer.from(stdout),
+            stderr: Buffer.from(stderr),
+          } satisfies Result
+        },
+        Effect.scoped,
+        Effect.catch((err) => Effect.succeed(fail(err))),
+      )
+
+      const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) {
+        return (yield* run(args, opts)).text()
+      })
+
+      const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) {
+        return (yield* text(args, opts))
+          .split(/\r?\n/)
+          .map((item) => item.trim())
+          .filter(Boolean)
+      })
+
+      const refs = Effect.fnUntraced(function* (cwd: string) {
+        return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
+      })
+
+      const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) {
+        const result = yield* run(["config", "init.defaultBranch"], { cwd })
+        const name = out(result)
+        if (!name || !list.includes(name)) return
+        return { name, ref: name } satisfies Base
+      })
+
+      const primary = Effect.fnUntraced(function* (cwd: string) {
+        const list = yield* lines(["remote"], { cwd })
+        if (list.includes("origin")) return "origin"
+        if (list.length === 1) return list[0]
+        if (list.includes("upstream")) return "upstream"
+        return list[0]
+      })
+
+      const branch = Effect.fn("Git.branch")(function* (cwd: string) {
+        const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd })
+        if (result.exitCode !== 0) return
+        const text = out(result)
+        return text || undefined
+      })
+
+      const prefix = Effect.fn("Git.prefix")(function* (cwd: string) {
+        const result = yield* run(["rev-parse", "--show-prefix"], { cwd })
+        if (result.exitCode !== 0) return ""
+        return out(result)
+      })
+
+      const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) {
+        const remote = yield* primary(cwd)
+        if (remote) {
+          const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd })
+          if (head.exitCode === 0) {
+            const ref = out(head).replace(/^refs\/remotes\//, "")
+            const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
+            if (name) return { name, ref } satisfies Base
+          }
+        }
+
+        const list = yield* refs(cwd)
+        const next = yield* configured(cwd, list)
+        if (next) return next
+        if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
+        if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
+      })
+
+      const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) {
+        const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd })
+        return result.exitCode === 0
+      })
+
+      const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") {
+        const result = yield* run(["merge-base", base, head], { cwd })
+        if (result.exitCode !== 0) return
+        const text = out(result)
+        return text || undefined
+      })
+
+      const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") {
+        const target = prefix ? `${prefix}${file}` : file
+        const result = yield* run(["show", `${ref}:${target}`], { cwd })
+        if (result.exitCode !== 0) return ""
+        if (result.stdout.includes(0)) return ""
+        return result.text()
+      })
+
+      const status = Effect.fn("Git.status")(function* (cwd: string) {
+        return nuls(
+          yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], {
+            cwd,
+          }),
+        ).flatMap((item) => {
+          const file = item.slice(3)
+          if (!file) return []
+          const code = item.slice(0, 2)
+          return [{ file, code, status: kind(code) } satisfies Item]
+        })
+      })
+
+      const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) {
+        const list = nuls(
+          yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
+        )
+        return list.flatMap((code, idx) => {
+          if (idx % 2 !== 0) return []
+          const file = list[idx + 1]
+          if (!code || !file) return []
+          return [{ file, code, status: kind(code) } satisfies Item]
+        })
+      })
+
+      const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) {
+        return nuls(
+          yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
+        ).flatMap((item) => {
+          const a = item.indexOf("\t")
+          const b = item.indexOf("\t", a + 1)
+          if (a === -1 || b === -1) return []
+          const file = item.slice(b + 1)
+          if (!file) return []
+          const adds = item.slice(0, a)
+          const dels = item.slice(a + 1, b)
+          const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
+          const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
+          return [
+            {
+              file,
+              additions: Number.isFinite(additions) ? additions : 0,
+              deletions: Number.isFinite(deletions) ? deletions : 0,
+            } satisfies Stat,
+          ]
+        })
+      })
+
+      return Service.of({
+        run,
+        branch,
+        prefix,
+        defaultBranch,
+        hasHead,
+        mergeBase,
+        show,
+        status,
+        diff,
+        stats,
+      })
+    }),
+  )
+
+  export const defaultLayer = layer.pipe(
+    Layer.provide(CrossSpawnSpawner.layer),
+    Layer.provide(NodeFileSystem.layer),
+    Layer.provide(NodePath.layer),
+  )
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
+
+  export function run(args: string[], opts: Options) {
+    return runPromise((git) => git.run(args, opts))
+  }
+
+  export function branch(cwd: string) {
+    return runPromise((git) => git.branch(cwd))
+  }
+
+  export function prefix(cwd: string) {
+    return runPromise((git) => git.prefix(cwd))
+  }
+
+  export function defaultBranch(cwd: string) {
+    return runPromise((git) => git.defaultBranch(cwd))
+  }
+
+  export function hasHead(cwd: string) {
+    return runPromise((git) => git.hasHead(cwd))
+  }
+
+  export function mergeBase(cwd: string, base: string, head?: string) {
+    return runPromise((git) => git.mergeBase(cwd, base, head))
+  }
+
+  export function show(cwd: string, ref: string, file: string, prefix?: string) {
+    return runPromise((git) => git.show(cwd, ref, file, prefix))
+  }
+
+  export function status(cwd: string) {
+    return runPromise((git) => git.status(cwd))
+  }
+
+  export function diff(cwd: string, ref: string) {
+    return runPromise((git) => git.diff(cwd, ref))
+  }
+
+  export function stats(cwd: string, ref: string) {
+    return runPromise((git) => git.stats(cwd, ref))
+  }
+}

+ 2 - 2
packages/opencode/src/installation/index.ts

@@ -2,7 +2,7 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node"
 import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
 import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { withTransientReadRetry } from "@/util/effect-http-client"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import path from "path"
@@ -346,7 +346,7 @@ export namespace Installation {
     Layer.provide(NodePath.layer),
   )
 
-  const runPromise = makeRunPromise(Service, defaultLayer)
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function info(): Promise<Info> {
     return runPromise((svc) => svc.info())

+ 147 - 96
packages/opencode/src/mcp/auth.ts

@@ -1,7 +1,9 @@
 import path from "path"
 import z from "zod"
 import { Global } from "../global"
-import { Filesystem } from "../util/filesystem"
+import { Effect, Layer, ServiceMap } from "effect"
+import { AppFileSystem } from "@/filesystem"
+import { makeRuntime } from "@/effect/run-service"
 
 export namespace McpAuth {
   export const Tokens = z.object({
@@ -25,106 +27,155 @@ export namespace McpAuth {
     clientInfo: ClientInfo.optional(),
     codeVerifier: z.string().optional(),
     oauthState: z.string().optional(),
-    serverUrl: z.string().optional(), // Track the URL these credentials are for
+    serverUrl: z.string().optional(),
   })
   export type Entry = z.infer<typeof Entry>
 
   const filepath = path.join(Global.Path.data, "mcp-auth.json")
 
-  export async function get(mcpName: string): Promise<Entry | undefined> {
-    const data = await all()
-    return data[mcpName]
+  export interface Interface {
+    readonly all: () => Effect.Effect<Record<string, Entry>>
+    readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
+    readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
+    readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect<void>
+    readonly remove: (mcpName: string) => Effect.Effect<void>
+    readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect<void>
+    readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect<void>
+    readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect<void>
+    readonly clearCodeVerifier: (mcpName: string) => Effect.Effect<void>
+    readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect<void>
+    readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
+    readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
+    readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
   }
 
-  /**
-   * Get auth entry and validate it's for the correct URL.
-   * Returns undefined if URL has changed (credentials are invalid).
-   */
-  export async function getForUrl(mcpName: string, serverUrl: string): Promise<Entry | undefined> {
-    const entry = await get(mcpName)
-    if (!entry) return undefined
-
-    // If no serverUrl is stored, this is from an old version - consider it invalid
-    if (!entry.serverUrl) return undefined
-
-    // If URL has changed, credentials are invalid
-    if (entry.serverUrl !== serverUrl) return undefined
-
-    return entry
-  }
-
-  export async function all(): Promise<Record<string, Entry>> {
-    return Filesystem.readJson<Record<string, Entry>>(filepath).catch(() => ({}))
-  }
-
-  export async function set(mcpName: string, entry: Entry, serverUrl?: string): Promise<void> {
-    const data = await all()
-    // Always update serverUrl if provided
-    if (serverUrl) {
-      entry.serverUrl = serverUrl
-    }
-    await Filesystem.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600)
-  }
-
-  export async function remove(mcpName: string): Promise<void> {
-    const data = await all()
-    delete data[mcpName]
-    await Filesystem.writeJson(filepath, data, 0o600)
-  }
-
-  export async function updateTokens(mcpName: string, tokens: Tokens, serverUrl?: string): Promise<void> {
-    const entry = (await get(mcpName)) ?? {}
-    entry.tokens = tokens
-    await set(mcpName, entry, serverUrl)
-  }
-
-  export async function updateClientInfo(mcpName: string, clientInfo: ClientInfo, serverUrl?: string): Promise<void> {
-    const entry = (await get(mcpName)) ?? {}
-    entry.clientInfo = clientInfo
-    await set(mcpName, entry, serverUrl)
-  }
-
-  export async function updateCodeVerifier(mcpName: string, codeVerifier: string): Promise<void> {
-    const entry = (await get(mcpName)) ?? {}
-    entry.codeVerifier = codeVerifier
-    await set(mcpName, entry)
-  }
-
-  export async function clearCodeVerifier(mcpName: string): Promise<void> {
-    const entry = await get(mcpName)
-    if (entry) {
-      delete entry.codeVerifier
-      await set(mcpName, entry)
-    }
-  }
-
-  export async function updateOAuthState(mcpName: string, oauthState: string): Promise<void> {
-    const entry = (await get(mcpName)) ?? {}
-    entry.oauthState = oauthState
-    await set(mcpName, entry)
-  }
-
-  export async function getOAuthState(mcpName: string): Promise<string | undefined> {
-    const entry = await get(mcpName)
-    return entry?.oauthState
-  }
-
-  export async function clearOAuthState(mcpName: string): Promise<void> {
-    const entry = await get(mcpName)
-    if (entry) {
-      delete entry.oauthState
-      await set(mcpName, entry)
-    }
-  }
-
-  /**
-   * Check if stored tokens are expired.
-   * Returns null if no tokens exist, false if no expiry or not expired, true if expired.
-   */
-  export async function isTokenExpired(mcpName: string): Promise<boolean | null> {
-    const entry = await get(mcpName)
-    if (!entry?.tokens) return null
-    if (!entry.tokens.expiresAt) return false
-    return entry.tokens.expiresAt < Date.now() / 1000
-  }
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/McpAuth") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const fs = yield* AppFileSystem.Service
+
+      const all = Effect.fn("McpAuth.all")(function* () {
+        return yield* fs.readJson(filepath).pipe(
+          Effect.map((data) => data as Record<string, Entry>),
+          Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
+        )
+      })
+
+      const get = Effect.fn("McpAuth.get")(function* (mcpName: string) {
+        const data = yield* all()
+        return data[mcpName]
+      })
+
+      const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) {
+        const entry = yield* get(mcpName)
+        if (!entry) return undefined
+        if (!entry.serverUrl) return undefined
+        if (entry.serverUrl !== serverUrl) return undefined
+        return entry
+      })
+
+      const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) {
+        const data = yield* all()
+        if (serverUrl) entry.serverUrl = serverUrl
+        yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie)
+      })
+
+      const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) {
+        const data = yield* all()
+        delete data[mcpName]
+        yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie)
+      })
+
+      const updateField = <K extends keyof Entry>(field: K, spanName: string) =>
+        Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable<Entry[K]>, serverUrl?: string) {
+          const entry = (yield* get(mcpName)) ?? {}
+          entry[field] = value
+          yield* set(mcpName, entry, serverUrl)
+        })
+
+      const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
+        Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
+          const entry = yield* get(mcpName)
+          if (entry) {
+            delete entry[field]
+            yield* set(mcpName, entry)
+          }
+        })
+
+      const updateTokens = updateField("tokens", "updateTokens")
+      const updateClientInfo = updateField("clientInfo", "updateClientInfo")
+      const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier")
+      const updateOAuthState = updateField("oauthState", "updateOAuthState")
+      const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier")
+      const clearOAuthState = clearField("oauthState", "clearOAuthState")
+
+      const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) {
+        const entry = yield* get(mcpName)
+        return entry?.oauthState
+      })
+
+      const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) {
+        const entry = yield* get(mcpName)
+        if (!entry?.tokens) return null
+        if (!entry.tokens.expiresAt) return false
+        return entry.tokens.expiresAt < Date.now() / 1000
+      })
+
+      return Service.of({
+        all,
+        get,
+        getForUrl,
+        set,
+        remove,
+        updateTokens,
+        updateClientInfo,
+        updateCodeVerifier,
+        clearCodeVerifier,
+        updateOAuthState,
+        getOAuthState,
+        clearOAuthState,
+        isTokenExpired,
+      })
+    }),
+  )
+
+  const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
+
+  // Async facades for backward compat (used by McpOAuthProvider, CLI)
+
+  export const get = async (mcpName: string) => runPromise((svc) => svc.get(mcpName))
+
+  export const getForUrl = async (mcpName: string, serverUrl: string) =>
+    runPromise((svc) => svc.getForUrl(mcpName, serverUrl))
+
+  export const all = async () => runPromise((svc) => svc.all())
+
+  export const set = async (mcpName: string, entry: Entry, serverUrl?: string) =>
+    runPromise((svc) => svc.set(mcpName, entry, serverUrl))
+
+  export const remove = async (mcpName: string) => runPromise((svc) => svc.remove(mcpName))
+
+  export const updateTokens = async (mcpName: string, tokens: Tokens, serverUrl?: string) =>
+    runPromise((svc) => svc.updateTokens(mcpName, tokens, serverUrl))
+
+  export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) =>
+    runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl))
+
+  export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) =>
+    runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier))
+
+  export const clearCodeVerifier = async (mcpName: string) => runPromise((svc) => svc.clearCodeVerifier(mcpName))
+
+  export const updateOAuthState = async (mcpName: string, oauthState: string) =>
+    runPromise((svc) => svc.updateOAuthState(mcpName, oauthState))
+
+  export const getOAuthState = async (mcpName: string) => runPromise((svc) => svc.getOAuthState(mcpName))
+
+  export const clearOAuthState = async (mcpName: string) => runPromise((svc) => svc.clearOAuthState(mcpName))
+
+  export const isTokenExpired = async (mcpName: string) => runPromise((svc) => svc.isTokenExpired(mcpName))
 }

+ 519 - 560
packages/opencode/src/mcp/index.ts

@@ -11,12 +11,12 @@ import {
 } from "@modelcontextprotocol/sdk/types.js"
 import { Config } from "../config/config"
 import { Log } from "../util/log"
-import { Process } from "../util/process"
 import { NamedError } from "@opencode-ai/util/error"
 import z from "zod/v4"
 import { Instance } from "../project/instance"
 import { Installation } from "../installation"
 import { withTimeout } from "@/util/timeout"
+import { AppFileSystem } from "@/filesystem"
 import { McpOAuthProvider } from "./oauth-provider"
 import { McpOAuthCallback } from "./oauth-callback"
 import { McpAuth } from "./auth"
@@ -24,6 +24,13 @@ import { BusEvent } from "../bus/bus-event"
 import { Bus } from "@/bus"
 import { TuiEvent } from "@/cli/cmd/tui/event"
 import open from "open"
+import { Effect, Layer, Option, ServiceMap, Stream } from "effect"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRuntime } from "@/effect/run-service"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+import { NodeFileSystem } from "@effect/platform-node"
+import * as NodePath from "@effect/platform-node/NodePath"
 
 export namespace MCP {
   const log = Log.create({ service: "mcp" })
@@ -109,16 +116,21 @@ export namespace MCP {
     })
   export type Status = z.infer<typeof Status>
 
-  // Register notification handlers for MCP client
-  function registerNotificationHandlers(client: MCPClient, serverName: string) {
-    client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
-      log.info("tools list changed notification received", { server: serverName })
-      Bus.publish(ToolsChanged, { server: serverName })
-    })
+  // Store transports for OAuth servers to allow finishing auth
+  type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
+  const pendingOAuthTransports = new Map<string, TransportWithAuth>()
+
+  // Prompt cache types
+  type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
+  type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
+  type McpEntry = NonNullable<Config.Info["mcp"]>[string]
+
+  function isMcpConfigured(entry: McpEntry): entry is Config.Mcp {
+    return typeof entry === "object" && entry !== null && "type" in entry
   }
 
   // Convert MCP tool definition to AI SDK Tool type
-  async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Promise<Tool> {
+  function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
     const inputSchema = mcpTool.inputSchema
 
     // Spread first, then override type to ensure it's always "object"
@@ -148,178 +160,33 @@ export namespace MCP {
     })
   }
 
-  // Store transports for OAuth servers to allow finishing auth
-  type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
-  const pendingOAuthTransports = new Map<string, TransportWithAuth>()
-
-  // Prompt cache types
-  type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
-
-  type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
-  type McpEntry = NonNullable<Config.Info["mcp"]>[string]
-  function isMcpConfigured(entry: McpEntry): entry is Config.Mcp {
-    return typeof entry === "object" && entry !== null && "type" in entry
-  }
-
-  async function descendants(pid: number): Promise<number[]> {
-    if (process.platform === "win32") return []
-    const pids: number[] = []
-    const queue = [pid]
-    while (queue.length > 0) {
-      const current = queue.shift()!
-      const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
-      for (const tok of lines) {
-        const cpid = parseInt(tok, 10)
-        if (!isNaN(cpid) && !pids.includes(cpid)) {
-          pids.push(cpid)
-          queue.push(cpid)
-        }
-      }
-    }
-    return pids
-  }
-
-  const state = Instance.state(
-    async () => {
-      const cfg = await Config.get()
-      const config = cfg.mcp ?? {}
-      const clients: Record<string, MCPClient> = {}
-      const status: Record<string, Status> = {}
-
-      await Promise.all(
-        Object.entries(config).map(async ([key, mcp]) => {
-          if (!isMcpConfigured(mcp)) {
-            log.error("Ignoring MCP config entry without type", { key })
-            return
-          }
-
-          // If disabled by config, mark as disabled without trying to connect
-          if (mcp.enabled === false) {
-            status[key] = { status: "disabled" }
-            return
-          }
-
-          const result = await create(key, mcp).catch(() => undefined)
-          if (!result) return
-
-          status[key] = result.status
-
-          if (result.mcpClient) {
-            clients[key] = result.mcpClient
-          }
-        }),
-      )
-      return {
-        status,
-        clients,
-      }
-    },
-    async (state) => {
-      // The MCP SDK only signals the direct child process on close.
-      // Servers like chrome-devtools-mcp spawn grandchild processes
-      // (e.g. Chrome) that the SDK never reaches, leaving them orphaned.
-      // Kill the full descendant tree first so the server exits promptly
-      // and no processes are left behind.
-      for (const client of Object.values(state.clients)) {
-        const pid = (client.transport as any)?.pid
-        if (typeof pid !== "number") continue
-        for (const dpid of await descendants(pid)) {
-          try {
-            process.kill(dpid, "SIGTERM")
-          } catch {}
-        }
-      }
-
-      await Promise.all(
-        Object.values(state.clients).map((client) =>
-          client.close().catch((error) => {
-            log.error("Failed to close MCP client", {
-              error,
-            })
-          }),
-        ),
-      )
-      pendingOAuthTransports.clear()
-    },
-  )
-
-  // Helper function to fetch prompts for a specific client
-  async function fetchPromptsForClient(clientName: string, client: Client) {
-    const prompts = await client.listPrompts().catch((e) => {
-      log.error("failed to get prompts", { clientName, error: e.message })
+  async function defs(key: string, client: MCPClient, timeout?: number) {
+    const result = await withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT).catch((err) => {
+      log.error("failed to get tools from client", { key, error: err })
       return undefined
     })
-
-    if (!prompts) {
-      return
-    }
-
-    const commands: Record<string, PromptInfo & { client: string }> = {}
-
-    for (const prompt of prompts.prompts) {
-      const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
-      const sanitizedPromptName = prompt.name.replace(/[^a-zA-Z0-9_-]/g, "_")
-      const key = sanitizedClientName + ":" + sanitizedPromptName
-
-      commands[key] = { ...prompt, client: clientName }
-    }
-    return commands
+    return result?.tools
   }
 
-  async function fetchResourcesForClient(clientName: string, client: Client) {
-    const resources = await client.listResources().catch((e) => {
-      log.error("failed to get prompts", { clientName, error: e.message })
+  async function fetchFromClient<T extends { name: string }>(
+    clientName: string,
+    client: Client,
+    listFn: (c: Client) => Promise<T[]>,
+    label: string,
+  ): Promise<Record<string, T & { client: string }> | undefined> {
+    const items = await listFn(client).catch((e: any) => {
+      log.error(`failed to get ${label}`, { clientName, error: e.message })
       return undefined
     })
+    if (!items) return undefined
 
-    if (!resources) {
-      return
-    }
-
-    const commands: Record<string, ResourceInfo & { client: string }> = {}
-
-    for (const resource of resources.resources) {
-      const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
-      const sanitizedResourceName = resource.name.replace(/[^a-zA-Z0-9_-]/g, "_")
-      const key = sanitizedClientName + ":" + sanitizedResourceName
-
-      commands[key] = { ...resource, client: clientName }
-    }
-    return commands
-  }
-
-  export async function add(name: string, mcp: Config.Mcp) {
-    const s = await state()
-    const result = await create(name, mcp)
-    if (!result) {
-      const status = {
-        status: "failed" as const,
-        error: "unknown error",
-      }
-      s.status[name] = status
-      return {
-        status,
-      }
-    }
-    if (!result.mcpClient) {
-      s.status[name] = result.status
-      return {
-        status: s.status,
-      }
-    }
-    // Close existing client if present to prevent memory leaks
-    const existingClient = s.clients[name]
-    if (existingClient) {
-      await existingClient.close().catch((error) => {
-        log.error("Failed to close existing MCP client", { name, error })
-      })
-    }
-    s.clients[name] = result.mcpClient
-    s.status[name] = result.status
-
-    return {
-      status: s.status,
+    const out: Record<string, T & { client: string }> = {}
+    const sanitizedClient = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
+    for (const item of items) {
+      const sanitizedName = item.name.replace(/[^a-zA-Z0-9_-]/g, "_")
+      out[sanitizedClient + ":" + sanitizedName] = { ...item, client: clientName }
     }
+    return out
   }
 
   async function create(key: string, mcp: Config.Mcp) {
@@ -385,7 +252,6 @@ export namespace MCP {
             version: Installation.VERSION,
           })
           await withTimeout(client.connect(transport), connectTimeout)
-          registerNotificationHandlers(client, key)
           mcpClient = client
           log.info("connected", { key, transport: name })
           status = { status: "connected" }
@@ -470,7 +336,6 @@ export namespace MCP {
           version: Installation.VERSION,
         })
         await withTimeout(client.connect(transport), connectTimeout)
-        registerNotificationHandlers(client, key)
         mcpClient = client
         status = {
           status: "connected",
@@ -503,475 +368,569 @@ export namespace MCP {
       }
     }
 
-    const result = await withTimeout(mcpClient.listTools(), mcp.timeout ?? DEFAULT_TIMEOUT).catch((err) => {
-      log.error("failed to get tools from client", { key, error: err })
-      return undefined
-    })
-    if (!result) {
+    const listed = await defs(key, mcpClient, mcp.timeout)
+    if (!listed) {
       await mcpClient.close().catch((error) => {
         log.error("Failed to close MCP client", {
           error,
         })
       })
-      status = {
-        status: "failed",
-        error: "Failed to get tools",
-      }
       return {
         mcpClient: undefined,
-        status: {
-          status: "failed" as const,
-          error: "Failed to get tools",
-        },
+        status: { status: "failed" as const, error: "Failed to get tools" },
       }
     }
 
-    log.info("create() successfully created client", { key, toolCount: result.tools.length })
+    log.info("create() successfully created client", { key, toolCount: listed.length })
     return {
       mcpClient,
       status,
+      defs: listed,
     }
   }
 
-  export async function status() {
-    const s = await state()
-    const cfg = await Config.get()
-    const config = cfg.mcp ?? {}
-    const result: Record<string, Status> = {}
+  // --- Effect Service ---
 
-    // Include all configured MCPs from config, not just connected ones
-    for (const [key, mcp] of Object.entries(config)) {
-      if (!isMcpConfigured(mcp)) continue
-      result[key] = s.status[key] ?? { status: "disabled" }
-    }
-
-    return result
+  interface State {
+    status: Record<string, Status>
+    clients: Record<string, MCPClient>
+    defs: Record<string, MCPToolDef[]>
   }
 
-  export async function clients() {
-    return state().then((state) => state.clients)
+  export interface Interface {
+    readonly status: () => Effect.Effect<Record<string, Status>>
+    readonly clients: () => Effect.Effect<Record<string, MCPClient>>
+    readonly tools: () => Effect.Effect<Record<string, Tool>>
+    readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
+    readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
+    readonly add: (name: string, mcp: Config.Mcp) => Effect.Effect<{ status: Record<string, Status> | Status }>
+    readonly connect: (name: string) => Effect.Effect<void>
+    readonly disconnect: (name: string) => Effect.Effect<void>
+    readonly getPrompt: (
+      clientName: string,
+      name: string,
+      args?: Record<string, string>,
+    ) => Effect.Effect<Awaited<ReturnType<MCPClient["getPrompt"]>> | undefined>
+    readonly readResource: (
+      clientName: string,
+      resourceUri: string,
+    ) => Effect.Effect<Awaited<ReturnType<MCPClient["readResource"]>> | undefined>
+    readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }>
+    readonly authenticate: (mcpName: string) => Effect.Effect<Status>
+    readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status>
+    readonly removeAuth: (mcpName: string) => Effect.Effect<void>
+    readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean>
+    readonly hasStoredTokens: (mcpName: string) => Effect.Effect<boolean>
+    readonly getAuthStatus: (mcpName: string) => Effect.Effect<AuthStatus>
   }
 
-  export async function connect(name: string) {
-    const cfg = await Config.get()
-    const config = cfg.mcp ?? {}
-    const mcp = config[name]
-    if (!mcp) {
-      log.error("MCP config not found", { name })
-      return
-    }
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/MCP") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+      const auth = yield* McpAuth.Service
+
+      const descendants = Effect.fnUntraced(
+        function* (pid: number) {
+          if (process.platform === "win32") return [] as number[]
+          const pids: number[] = []
+          const queue = [pid]
+          while (queue.length > 0) {
+            const current = queue.shift()!
+            const handle = yield* spawner.spawn(
+              ChildProcess.make("pgrep", ["-P", String(current)], { stdin: "ignore" }),
+            )
+            const text = yield* Stream.mkString(Stream.decodeText(handle.stdout))
+            yield* handle.exitCode
+            for (const tok of text.split("\n")) {
+              const cpid = parseInt(tok, 10)
+              if (!isNaN(cpid) && !pids.includes(cpid)) {
+                pids.push(cpid)
+                queue.push(cpid)
+              }
+            }
+          }
+          return pids
+        },
+        Effect.scoped,
+        Effect.catch(() => Effect.succeed([] as number[])),
+      )
 
-    if (!isMcpConfigured(mcp)) {
-      log.error("Ignoring MCP connect request for config without type", { name })
-      return
-    }
+      function watch(s: State, name: string, client: MCPClient, timeout?: number) {
+        client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
+          log.info("tools list changed notification received", { server: name })
+          if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
 
-    const result = await create(name, { ...mcp, enabled: true })
+          const listed = await defs(name, client, timeout)
+          if (!listed) return
+          if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
 
-    if (!result) {
-      const s = await state()
-      s.status[name] = {
-        status: "failed",
-        error: "Unknown error during connection",
+          s.defs[name] = listed
+          await Bus.publish(ToolsChanged, { server: name }).catch((error) =>
+            log.warn("failed to publish tools changed", { server: name, error }),
+          )
+        })
       }
-      return
-    }
 
-    const s = await state()
-    s.status[name] = result.status
-    if (result.mcpClient) {
-      // Close existing client if present to prevent memory leaks
-      const existingClient = s.clients[name]
-      if (existingClient) {
-        await existingClient.close().catch((error) => {
-          log.error("Failed to close existing MCP client", { name, error })
-        })
+      const cache = yield* InstanceState.make<State>(
+        Effect.fn("MCP.state")(function* () {
+          const cfg = yield* Effect.promise(() => Config.get())
+          const config = cfg.mcp ?? {}
+          const s: State = {
+            status: {},
+            clients: {},
+            defs: {},
+          }
+
+          yield* Effect.forEach(
+            Object.entries(config),
+            ([key, mcp]) =>
+              Effect.gen(function* () {
+                if (!isMcpConfigured(mcp)) {
+                  log.error("Ignoring MCP config entry without type", { key })
+                  return
+                }
+
+                if (mcp.enabled === false) {
+                  s.status[key] = { status: "disabled" }
+                  return
+                }
+
+                const result = yield* Effect.promise(() => create(key, mcp).catch(() => undefined))
+                if (!result) return
+
+                s.status[key] = result.status
+                if (result.mcpClient) {
+                  s.clients[key] = result.mcpClient
+                  s.defs[key] = result.defs
+                  watch(s, key, result.mcpClient, mcp.timeout)
+                }
+              }),
+            { concurrency: "unbounded" },
+          )
+
+          yield* Effect.addFinalizer(() =>
+            Effect.gen(function* () {
+              yield* Effect.forEach(
+                Object.values(s.clients),
+                (client) =>
+                  Effect.gen(function* () {
+                    const pid = (client.transport as any)?.pid
+                    if (typeof pid === "number") {
+                      const pids = yield* descendants(pid)
+                      for (const dpid of pids) {
+                        try {
+                          process.kill(dpid, "SIGTERM")
+                        } catch {}
+                      }
+                    }
+                    yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
+                  }),
+                { concurrency: "unbounded" },
+              )
+              pendingOAuthTransports.clear()
+            }),
+          )
+
+          return s
+        }),
+      )
+
+      function closeClient(s: State, name: string) {
+        const client = s.clients[name]
+        delete s.defs[name]
+        if (!client) return Effect.void
+        return Effect.promise(() =>
+          client.close().catch((error: any) => log.error("failed to close MCP client", { name, error })),
+        )
       }
-      s.clients[name] = result.mcpClient
-    }
-  }
 
-  export async function disconnect(name: string) {
-    const s = await state()
-    const client = s.clients[name]
-    if (client) {
-      await client.close().catch((error) => {
-        log.error("Failed to close MCP client", { name, error })
+      const status = Effect.fn("MCP.status")(function* () {
+        const s = yield* InstanceState.get(cache)
+        const cfg = yield* Effect.promise(() => Config.get())
+        const config = cfg.mcp ?? {}
+        const result: Record<string, Status> = {}
+
+        for (const [key, mcp] of Object.entries(config)) {
+          if (!isMcpConfigured(mcp)) continue
+          result[key] = s.status[key] ?? { status: "disabled" }
+        }
+
+        return result
       })
-      delete s.clients[name]
-    }
-    s.status[name] = { status: "disabled" }
-  }
 
-  export async function tools() {
-    const result: Record<string, Tool> = {}
-    const s = await state()
-    const cfg = await Config.get()
-    const config = cfg.mcp ?? {}
-    const clientsSnapshot = await clients()
-    const defaultTimeout = cfg.experimental?.mcp_timeout
-
-    const connectedClients = Object.entries(clientsSnapshot).filter(
-      ([clientName]) => s.status[clientName]?.status === "connected",
-    )
-
-    const toolsResults = await Promise.all(
-      connectedClients.map(async ([clientName, client]) => {
-        const toolsResult = await client.listTools().catch((e) => {
-          log.error("failed to get tools", { clientName, error: e.message })
-          const failedStatus = {
-            status: "failed" as const,
-            error: e instanceof Error ? e.message : String(e),
-          }
-          s.status[clientName] = failedStatus
-          delete s.clients[clientName]
-          return undefined
-        })
-        return { clientName, client, toolsResult }
-      }),
-    )
-
-    for (const { clientName, client, toolsResult } of toolsResults) {
-      if (!toolsResult) continue
-      const mcpConfig = config[clientName]
-      const entry = isMcpConfigured(mcpConfig) ? mcpConfig : undefined
-      const timeout = entry?.timeout ?? defaultTimeout
-      for (const mcpTool of toolsResult.tools) {
-        const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
-        const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
-        result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client, timeout)
-      }
-    }
-    return result
-  }
+      const clients = Effect.fn("MCP.clients")(function* () {
+        const s = yield* InstanceState.get(cache)
+        return s.clients
+      })
 
-  export async function prompts() {
-    const s = await state()
-    const clientsSnapshot = await clients()
+      const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) {
+        const s = yield* InstanceState.get(cache)
+        const result = yield* Effect.promise(() => create(name, mcp))
 
-    const prompts = Object.fromEntries<PromptInfo & { client: string }>(
-      (
-        await Promise.all(
-          Object.entries(clientsSnapshot).map(async ([clientName, client]) => {
-            if (s.status[clientName]?.status !== "connected") {
-              return []
-            }
+        if (!result) {
+          yield* closeClient(s, name)
+          delete s.clients[name]
+          s.status[name] = { status: "failed" as const, error: "unknown error" }
+          return s.status[name]
+        }
 
-            return Object.entries((await fetchPromptsForClient(clientName, client)) ?? {})
-          }),
-        )
-      ).flat(),
-    )
+        s.status[name] = result.status
+        if (!result.mcpClient) {
+          yield* closeClient(s, name)
+          delete s.clients[name]
+          return result.status
+        }
 
-    return prompts
-  }
+        yield* closeClient(s, name)
+        s.clients[name] = result.mcpClient
+        s.defs[name] = result.defs
+        watch(s, name, result.mcpClient, mcp.timeout)
+        return result.status
+      })
 
-  export async function resources() {
-    const s = await state()
-    const clientsSnapshot = await clients()
+      const add = Effect.fn("MCP.add")(function* (name: string, mcp: Config.Mcp) {
+        yield* createAndStore(name, mcp)
+        const s = yield* InstanceState.get(cache)
+        return { status: s.status }
+      })
 
-    const result = Object.fromEntries<ResourceInfo & { client: string }>(
-      (
-        await Promise.all(
-          Object.entries(clientsSnapshot).map(async ([clientName, client]) => {
-            if (s.status[clientName]?.status !== "connected") {
-              return []
-            }
+      const connect = Effect.fn("MCP.connect")(function* (name: string) {
+        const mcp = yield* getMcpConfig(name)
+        if (!mcp) {
+          log.error("MCP config not found or invalid", { name })
+          return
+        }
+        yield* createAndStore(name, { ...mcp, enabled: true })
+      })
 
-            return Object.entries((await fetchResourcesForClient(clientName, client)) ?? {})
-          }),
+      const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
+        const s = yield* InstanceState.get(cache)
+        yield* closeClient(s, name)
+        delete s.clients[name]
+        s.status[name] = { status: "disabled" }
+      })
+
+      const tools = Effect.fn("MCP.tools")(function* () {
+        const result: Record<string, Tool> = {}
+        const s = yield* InstanceState.get(cache)
+        const cfg = yield* Effect.promise(() => Config.get())
+        const config = cfg.mcp ?? {}
+        const defaultTimeout = cfg.experimental?.mcp_timeout
+
+        const connectedClients = Object.entries(s.clients).filter(
+          ([clientName]) => s.status[clientName]?.status === "connected",
         )
-      ).flat(),
-    )
 
-    return result
-  }
+        yield* Effect.forEach(
+          connectedClients,
+          ([clientName, client]) =>
+            Effect.gen(function* () {
+              const mcpConfig = config[clientName]
+              const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : undefined
+
+              const listed = s.defs[clientName]
+              if (!listed) {
+                log.warn("missing cached tools for connected server", { clientName })
+                return
+              }
+
+              const timeout = entry?.timeout ?? defaultTimeout
+              for (const mcpTool of listed) {
+                const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
+                const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
+                result[sanitizedClientName + "_" + sanitizedToolName] = convertMcpTool(mcpTool, client, timeout)
+              }
+            }),
+          { concurrency: "unbounded" },
+        )
+        return result
+      })
 
-  export async function getPrompt(clientName: string, name: string, args?: Record<string, string>) {
-    const clientsSnapshot = await clients()
-    const client = clientsSnapshot[clientName]
+      function collectFromConnected<T>(
+        s: State,
+        fetchFn: (clientName: string, client: Client) => Promise<Record<string, T> | undefined>,
+      ) {
+        return Effect.forEach(
+          Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"),
+          ([clientName, client]) =>
+            Effect.promise(async () => Object.entries((await fetchFn(clientName, client)) ?? {})),
+          { concurrency: "unbounded" },
+        ).pipe(Effect.map((results) => Object.fromEntries<T>(results.flat())))
+      }
 
-    if (!client) {
-      log.warn("client not found for prompt", {
-        clientName,
+      const prompts = Effect.fn("MCP.prompts")(function* () {
+        const s = yield* InstanceState.get(cache)
+        return yield* collectFromConnected(s, (name, client) =>
+          fetchFromClient(name, client, (c) => c.listPrompts().then((r) => r.prompts), "prompts"),
+        )
       })
-      return undefined
-    }
 
-    const result = await client
-      .getPrompt({
-        name: name,
-        arguments: args,
+      const resources = Effect.fn("MCP.resources")(function* () {
+        const s = yield* InstanceState.get(cache)
+        return yield* collectFromConnected(s, (name, client) =>
+          fetchFromClient(name, client, (c) => c.listResources().then((r) => r.resources), "resources"),
+        )
       })
-      .catch((e) => {
-        log.error("failed to get prompt from MCP server", {
-          clientName,
+
+      const withClient = Effect.fnUntraced(function* <A>(
+        clientName: string,
+        fn: (client: MCPClient) => Promise<A>,
+        label: string,
+        meta?: Record<string, unknown>,
+      ) {
+        const s = yield* InstanceState.get(cache)
+        const client = s.clients[clientName]
+        if (!client) {
+          log.warn(`client not found for ${label}`, { clientName })
+          return undefined
+        }
+        return yield* Effect.tryPromise({
+          try: () => fn(client),
+          catch: (e: any) => {
+            log.error(`failed to ${label}`, { clientName, ...meta, error: e?.message })
+            return e
+          },
+        }).pipe(Effect.orElseSucceed(() => undefined))
+      })
+
+      const getPrompt = Effect.fn("MCP.getPrompt")(function* (
+        clientName: string,
+        name: string,
+        args?: Record<string, string>,
+      ) {
+        return yield* withClient(clientName, (client) => client.getPrompt({ name, arguments: args }), "getPrompt", {
           promptName: name,
-          error: e.message,
         })
-        return undefined
       })
 
-    return result
-  }
-
-  export async function readResource(clientName: string, resourceUri: string) {
-    const clientsSnapshot = await clients()
-    const client = clientsSnapshot[clientName]
-
-    if (!client) {
-      log.warn("client not found for prompt", {
-        clientName: clientName,
+      const readResource = Effect.fn("MCP.readResource")(function* (clientName: string, resourceUri: string) {
+        return yield* withClient(clientName, (client) => client.readResource({ uri: resourceUri }), "readResource", {
+          resourceUri,
+        })
       })
-      return undefined
-    }
 
-    const result = await client
-      .readResource({
-        uri: resourceUri,
+      const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
+        const cfg = yield* Effect.promise(() => Config.get())
+        const mcpConfig = cfg.mcp?.[mcpName]
+        if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined
+        return mcpConfig
       })
-      .catch((e) => {
-        log.error("failed to get prompt from MCP server", {
-          clientName: clientName,
-          resourceUri: resourceUri,
-          error: e.message,
+
+      const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) {
+        const mcpConfig = yield* getMcpConfig(mcpName)
+        if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`)
+        if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
+        if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
+
+        yield* Effect.promise(() => McpOAuthCallback.ensureRunning())
+
+        const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
+          .map((b) => b.toString(16).padStart(2, "0"))
+          .join("")
+        yield* auth.updateOAuthState(mcpName, oauthState)
+        const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
+        let capturedUrl: URL | undefined
+        const authProvider = new McpOAuthProvider(
+          mcpName,
+          mcpConfig.url,
+          {
+            clientId: oauthConfig?.clientId,
+            clientSecret: oauthConfig?.clientSecret,
+            scope: oauthConfig?.scope,
+          },
+          {
+            onRedirect: async (url) => {
+              capturedUrl = url
+            },
+          },
+        )
+
+        const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })
+
+        return yield* Effect.promise(async () => {
+          try {
+            const client = new Client({ name: "opencode", version: Installation.VERSION })
+            await client.connect(transport)
+            return { authorizationUrl: "", oauthState }
+          } catch (error) {
+            if (error instanceof UnauthorizedError && capturedUrl) {
+              pendingOAuthTransports.set(mcpName, transport)
+              return { authorizationUrl: capturedUrl.toString(), oauthState }
+            }
+            throw error
+          }
         })
-        return undefined
       })
 
-    return result
-  }
+      const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) {
+        const { authorizationUrl, oauthState } = yield* startAuth(mcpName)
+        if (!authorizationUrl) return { status: "connected" } as Status
+
+        log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
+
+        const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName)
+
+        yield* Effect.tryPromise(() => open(authorizationUrl)).pipe(
+          Effect.flatMap((subprocess) =>
+            Effect.callback<void, Error>((resume) => {
+              const timer = setTimeout(() => resume(Effect.void), 500)
+              subprocess.on("error", (err) => {
+                clearTimeout(timer)
+                resume(Effect.fail(err))
+              })
+              subprocess.on("exit", (code) => {
+                if (code !== null && code !== 0) {
+                  clearTimeout(timer)
+                  resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`)))
+                }
+              })
+            }),
+          ),
+          Effect.catch(() => {
+            log.warn("failed to open browser, user must open URL manually", { mcpName })
+            return Effect.promise(() => Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }))
+          }),
+        )
 
-  /**
-   * Start OAuth authentication flow for an MCP server.
-   * Returns the authorization URL that should be opened in a browser.
-   */
-  export async function startAuth(mcpName: string): Promise<{ authorizationUrl: string }> {
-    const cfg = await Config.get()
-    const mcpConfig = cfg.mcp?.[mcpName]
+        const code = yield* Effect.promise(() => callbackPromise)
 
-    if (!mcpConfig) {
-      throw new Error(`MCP server not found: ${mcpName}`)
-    }
+        const storedState = yield* auth.getOAuthState(mcpName)
+        if (storedState !== oauthState) {
+          yield* auth.clearOAuthState(mcpName)
+          throw new Error("OAuth state mismatch - potential CSRF attack")
+        }
+        yield* auth.clearOAuthState(mcpName)
+        return yield* finishAuth(mcpName, code)
+      })
 
-    if (!isMcpConfigured(mcpConfig)) {
-      throw new Error(`MCP server ${mcpName} is disabled or missing configuration`)
-    }
+      const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
+        const transport = pendingOAuthTransports.get(mcpName)
+        if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
 
-    if (mcpConfig.type !== "remote") {
-      throw new Error(`MCP server ${mcpName} is not a remote server`)
-    }
+        const result = yield* Effect.tryPromise({
+          try: async () => {
+            await transport.finishAuth(authorizationCode)
+            return true
+          },
+          catch: (error) => {
+            log.error("failed to finish oauth", { mcpName, error })
+            return error
+          },
+        }).pipe(Effect.option)
 
-    if (mcpConfig.oauth === false) {
-      throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
-    }
+        if (Option.isNone(result)) {
+          return { status: "failed", error: "OAuth completion failed" } as Status
+        }
 
-    // Start the callback server
-    await McpOAuthCallback.ensureRunning()
-
-    // Generate and store a cryptographically secure state parameter BEFORE creating the provider
-    // The SDK will call provider.state() to read this value
-    const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
-      .map((b) => b.toString(16).padStart(2, "0"))
-      .join("")
-    await McpAuth.updateOAuthState(mcpName, oauthState)
-
-    // Create a new auth provider for this flow
-    // OAuth config is optional - if not provided, we'll use auto-discovery
-    const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
-    let capturedUrl: URL | undefined
-    const authProvider = new McpOAuthProvider(
-      mcpName,
-      mcpConfig.url,
-      {
-        clientId: oauthConfig?.clientId,
-        clientSecret: oauthConfig?.clientSecret,
-        scope: oauthConfig?.scope,
-      },
-      {
-        onRedirect: async (url) => {
-          capturedUrl = url
-        },
-      },
-    )
+        yield* auth.clearCodeVerifier(mcpName)
+        pendingOAuthTransports.delete(mcpName)
 
-    // Create transport with auth provider
-    const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), {
-      authProvider,
-    })
+        const mcpConfig = yield* getMcpConfig(mcpName)
+        if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status
 
-    // Try to connect - this will trigger the OAuth flow
-    try {
-      const client = new Client({
-        name: "opencode",
-        version: Installation.VERSION,
+        return yield* createAndStore(mcpName, mcpConfig)
       })
-      await client.connect(transport)
-      // If we get here, we're already authenticated
-      return { authorizationUrl: "" }
-    } catch (error) {
-      if (error instanceof UnauthorizedError && capturedUrl) {
-        // Store transport for finishAuth
-        pendingOAuthTransports.set(mcpName, transport)
-        return { authorizationUrl: capturedUrl.toString() }
-      }
-      throw error
-    }
-  }
 
-  /**
-   * Complete OAuth authentication after user authorizes in browser.
-   * Opens the browser and waits for callback.
-   */
-  export async function authenticate(mcpName: string): Promise<Status> {
-    const { authorizationUrl } = await startAuth(mcpName)
-
-    if (!authorizationUrl) {
-      // Already authenticated
-      const s = await state()
-      return s.status[mcpName] ?? { status: "connected" }
-    }
+      const removeAuth = Effect.fn("MCP.removeAuth")(function* (mcpName: string) {
+        yield* auth.remove(mcpName)
+        McpOAuthCallback.cancelPending(mcpName)
+        pendingOAuthTransports.delete(mcpName)
+        log.info("removed oauth credentials", { mcpName })
+      })
 
-    // Get the state that was already generated and stored in startAuth()
-    const oauthState = await McpAuth.getOAuthState(mcpName)
-    if (!oauthState) {
-      throw new Error("OAuth state not found - this should not happen")
-    }
+      const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) {
+        const mcpConfig = yield* getMcpConfig(mcpName)
+        if (!mcpConfig) return false
+        return mcpConfig.type === "remote" && mcpConfig.oauth !== false
+      })
 
-    // The SDK has already added the state parameter to the authorization URL
-    // We just need to open the browser
-    log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
-
-    // Register the callback BEFORE opening the browser to avoid race condition
-    // when the IdP has an active SSO session and redirects immediately
-    const callbackPromise = McpOAuthCallback.waitForCallback(oauthState)
-
-    try {
-      const subprocess = await open(authorizationUrl)
-      // The open package spawns a detached process and returns immediately.
-      // We need to listen for errors which fire asynchronously:
-      // - "error" event: command not found (ENOENT)
-      // - "exit" with non-zero code: command exists but failed (e.g., no display)
-      await new Promise<void>((resolve, reject) => {
-        // Give the process a moment to fail if it's going to
-        const timeout = setTimeout(() => resolve(), 500)
-        subprocess.on("error", (error) => {
-          clearTimeout(timeout)
-          reject(error)
-        })
-        subprocess.on("exit", (code) => {
-          if (code !== null && code !== 0) {
-            clearTimeout(timeout)
-            reject(new Error(`Browser open failed with exit code ${code}`))
-          }
-        })
+      const hasStoredTokens = Effect.fn("MCP.hasStoredTokens")(function* (mcpName: string) {
+        const entry = yield* auth.get(mcpName)
+        return !!entry?.tokens
       })
-    } catch (error) {
-      // Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers)
-      // Emit event so CLI can display the URL for manual opening
-      log.warn("failed to open browser, user must open URL manually", { mcpName, error })
-      Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl })
-    }
 
-    // Wait for callback using the already-registered promise
-    const code = await callbackPromise
+      const getAuthStatus = Effect.fn("MCP.getAuthStatus")(function* (mcpName: string) {
+        const entry = yield* auth.get(mcpName)
+        if (!entry?.tokens) return "not_authenticated" as AuthStatus
+        const expired = yield* auth.isTokenExpired(mcpName)
+        return (expired ? "expired" : "authenticated") as AuthStatus
+      })
 
-    // Validate and clear the state
-    const storedState = await McpAuth.getOAuthState(mcpName)
-    if (storedState !== oauthState) {
-      await McpAuth.clearOAuthState(mcpName)
-      throw new Error("OAuth state mismatch - potential CSRF attack")
-    }
+      return Service.of({
+        status,
+        clients,
+        tools,
+        prompts,
+        resources,
+        add,
+        connect,
+        disconnect,
+        getPrompt,
+        readResource,
+        startAuth,
+        authenticate,
+        finishAuth,
+        removeAuth,
+        supportsOAuth,
+        hasStoredTokens,
+        getAuthStatus,
+      })
+    }),
+  )
 
-    await McpAuth.clearOAuthState(mcpName)
+  export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
 
-    // Finish auth
-    return finishAuth(mcpName, code)
-  }
+  // --- Per-service runtime ---
 
-  /**
-   * Complete OAuth authentication with the authorization code.
-   */
-  export async function finishAuth(mcpName: string, authorizationCode: string): Promise<Status> {
-    const transport = pendingOAuthTransports.get(mcpName)
+  const defaultLayer = layer.pipe(
+    Layer.provide(McpAuth.layer),
+    Layer.provide(CrossSpawnSpawner.layer),
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(NodeFileSystem.layer),
+    Layer.provide(NodePath.layer),
+  )
 
-    if (!transport) {
-      throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
-    }
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
-    try {
-      // Call finishAuth on the transport
-      await transport.finishAuth(authorizationCode)
+  // --- Async facade functions ---
 
-      // Clear the code verifier after successful auth
-      await McpAuth.clearCodeVerifier(mcpName)
+  export const status = async () => runPromise((svc) => svc.status())
 
-      // Now try to reconnect
-      const cfg = await Config.get()
-      const mcpConfig = cfg.mcp?.[mcpName]
+  export const clients = async () => runPromise((svc) => svc.clients())
 
-      if (!mcpConfig) {
-        throw new Error(`MCP server not found: ${mcpName}`)
-      }
+  export const tools = async () => runPromise((svc) => svc.tools())
 
-      if (!isMcpConfigured(mcpConfig)) {
-        throw new Error(`MCP server ${mcpName} is disabled or missing configuration`)
-      }
+  export const prompts = async () => runPromise((svc) => svc.prompts())
 
-      // Re-add the MCP server to establish connection
-      pendingOAuthTransports.delete(mcpName)
-      const result = await add(mcpName, mcpConfig)
+  export const resources = async () => runPromise((svc) => svc.resources())
 
-      const statusRecord = result.status as Record<string, Status>
-      return statusRecord[mcpName] ?? { status: "failed", error: "Unknown error after auth" }
-    } catch (error) {
-      log.error("failed to finish oauth", { mcpName, error })
-      return {
-        status: "failed",
-        error: error instanceof Error ? error.message : String(error),
-      }
-    }
-  }
+  export const add = async (name: string, mcp: Config.Mcp) => runPromise((svc) => svc.add(name, mcp))
 
-  /**
-   * Remove OAuth credentials for an MCP server.
-   */
-  export async function removeAuth(mcpName: string): Promise<void> {
-    await McpAuth.remove(mcpName)
-    McpOAuthCallback.cancelPending(mcpName)
-    pendingOAuthTransports.delete(mcpName)
-    await McpAuth.clearOAuthState(mcpName)
-    log.info("removed oauth credentials", { mcpName })
-  }
+  export const connect = async (name: string) => runPromise((svc) => svc.connect(name))
 
-  /**
-   * Check if an MCP server supports OAuth (remote servers support OAuth by default unless explicitly disabled).
-   */
-  export async function supportsOAuth(mcpName: string): Promise<boolean> {
-    const cfg = await Config.get()
-    const mcpConfig = cfg.mcp?.[mcpName]
-    if (!mcpConfig) return false
-    if (!isMcpConfigured(mcpConfig)) return false
-    return mcpConfig.type === "remote" && mcpConfig.oauth !== false
-  }
+  export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
 
-  /**
-   * Check if an MCP server has stored OAuth tokens.
-   */
-  export async function hasStoredTokens(mcpName: string): Promise<boolean> {
-    const entry = await McpAuth.get(mcpName)
-    return !!entry?.tokens
-  }
+  export const getPrompt = async (clientName: string, name: string, args?: Record<string, string>) =>
+    runPromise((svc) => svc.getPrompt(clientName, name, args))
 
-  export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
+  export const readResource = async (clientName: string, resourceUri: string) =>
+    runPromise((svc) => svc.readResource(clientName, resourceUri))
 
-  /**
-   * Get the authentication status for an MCP server.
-   */
-  export async function getAuthStatus(mcpName: string): Promise<AuthStatus> {
-    const hasTokens = await hasStoredTokens(mcpName)
-    if (!hasTokens) return "not_authenticated"
-    const expired = await McpAuth.isTokenExpired(mcpName)
-    return expired ? "expired" : "authenticated"
-  }
+  export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
+
+  export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
+
+  export const finishAuth = async (mcpName: string, authorizationCode: string) =>
+    runPromise((svc) => svc.finishAuth(mcpName, authorizationCode))
+
+  export const removeAuth = async (mcpName: string) => runPromise((svc) => svc.removeAuth(mcpName))
+
+  export const supportsOAuth = async (mcpName: string) => runPromise((svc) => svc.supportsOAuth(mcpName))
+
+  export const hasStoredTokens = async (mcpName: string) => runPromise((svc) => svc.hasStoredTokens(mcpName))
+
+  export const getAuthStatus = async (mcpName: string) => runPromise((svc) => svc.getAuthStatus(mcpName))
 }

+ 26 - 3
packages/opencode/src/mcp/oauth-callback.ts

@@ -54,6 +54,9 @@ interface PendingAuth {
 export namespace McpOAuthCallback {
   let server: ReturnType<typeof Bun.serve> | undefined
   const pendingAuths = new Map<string, PendingAuth>()
+  // Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
+  // find the right entry in pendingAuths (which is keyed by oauthState).
+  const mcpNameToState = new Map<string, string>()
 
   const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
 
@@ -98,6 +101,12 @@ export namespace McpOAuthCallback {
             const pending = pendingAuths.get(state)!
             clearTimeout(pending.timeout)
             pendingAuths.delete(state)
+            for (const [name, s] of mcpNameToState) {
+              if (s === state) {
+                mcpNameToState.delete(name)
+                break
+              }
+            }
             pending.reject(new Error(errorMsg))
           }
           return new Response(HTML_ERROR(errorMsg), {
@@ -126,6 +135,13 @@ export namespace McpOAuthCallback {
 
         clearTimeout(pending.timeout)
         pendingAuths.delete(state)
+        // Clean up reverse index
+        for (const [name, s] of mcpNameToState) {
+          if (s === state) {
+            mcpNameToState.delete(name)
+            break
+          }
+        }
         pending.resolve(code)
 
         return new Response(HTML_SUCCESS, {
@@ -137,11 +153,13 @@ export namespace McpOAuthCallback {
     log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
   }
 
-  export function waitForCallback(oauthState: string): Promise<string> {
+  export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
+    if (mcpName) mcpNameToState.set(mcpName, oauthState)
     return new Promise((resolve, reject) => {
       const timeout = setTimeout(() => {
         if (pendingAuths.has(oauthState)) {
           pendingAuths.delete(oauthState)
+          if (mcpName) mcpNameToState.delete(mcpName)
           reject(new Error("OAuth callback timeout - authorization took too long"))
         }
       }, CALLBACK_TIMEOUT_MS)
@@ -151,10 +169,14 @@ export namespace McpOAuthCallback {
   }
 
   export function cancelPending(mcpName: string): void {
-    const pending = pendingAuths.get(mcpName)
+    // Look up the oauthState for this mcpName via the reverse index
+    const oauthState = mcpNameToState.get(mcpName)
+    const key = oauthState ?? mcpName
+    const pending = pendingAuths.get(key)
     if (pending) {
       clearTimeout(pending.timeout)
-      pendingAuths.delete(mcpName)
+      pendingAuths.delete(key)
+      mcpNameToState.delete(mcpName)
       pending.reject(new Error("Authorization cancelled"))
     }
   }
@@ -184,6 +206,7 @@ export namespace McpOAuthCallback {
       pending.reject(new Error("OAuth callback server stopped"))
     }
     pendingAuths.clear()
+    mcpNameToState.clear()
   }
 
   export function isRunning(): boolean {

+ 2 - 2
packages/opencode/src/permission/index.ts

@@ -2,7 +2,7 @@ import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import { Config } from "@/config/config"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { ProjectID } from "@/project/schema"
 import { Instance } from "@/project/instance"
 import { MessageID, SessionID } from "@/session/schema"
@@ -306,7 +306,7 @@ export namespace Permission {
     return result
   }
 
-  export const runPromise = makeRunPromise(Service, layer)
+  export const { runPromise } = makeRuntime(Service, layer)
 
   export async function ask(input: z.infer<typeof AskInput>) {
     return runPromise((s) => s.ask(input))

+ 12 - 9
packages/opencode/src/plugin/index.ts

@@ -11,9 +11,9 @@ import { NamedError } from "@opencode-ai/util/error"
 import { CopilotAuthPlugin } from "./copilot"
 import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
 import { PoeAuthPlugin } from "opencode-poe-auth"
-import { Effect, Layer, ServiceMap } from "effect"
+import { Effect, Layer, ServiceMap, Stream } from "effect"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 
 export namespace Plugin {
   const log = Log.create({ service: "plugin" })
@@ -52,6 +52,8 @@ export namespace Plugin {
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
+      const bus = yield* Bus.Service
+
       const cache = yield* InstanceState.make<State>(
         Effect.fn("Plugin.state")(function* (ctx) {
           const hooks: Hooks[] = []
@@ -146,16 +148,16 @@ export namespace Plugin {
             }
           })
 
-          // Subscribe to bus events, clean up when scope is closed
-          yield* Effect.acquireRelease(
-            Effect.sync(() =>
-              Bus.subscribeAll(async (input) => {
+          // Subscribe to bus events, fiber interrupted when scope closes
+          yield* bus.subscribeAll().pipe(
+            Stream.runForEach((input) =>
+              Effect.sync(() => {
                 for (const hook of hooks) {
-                  hook["event"]?.({ event: input })
+                  hook["event"]?.({ event: input as any })
                 }
               }),
             ),
-            (unsub) => Effect.sync(unsub),
+            Effect.forkScoped,
           )
 
           return { hooks }
@@ -192,7 +194,8 @@ export namespace Plugin {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function trigger<
     Name extends TriggerName,

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

@@ -11,7 +11,7 @@ import { ProjectID } from "./schema"
 import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import { NodeFileSystem, NodePath } from "@effect/platform-node"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { AppFileSystem } from "@/filesystem"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 
@@ -462,7 +462,7 @@ export namespace Project {
     Layer.provide(NodeFileSystem.layer),
     Layer.provide(NodePath.layer),
   )
-  const runPromise = makeRunPromise(Service, defaultLayer)
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   // ---------------------------------------------------------------------------
   // Promise-based API (delegates to Effect service via runPromise)

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

@@ -1,17 +1,111 @@
-import { Effect, Layer, ServiceMap } from "effect"
+import { Effect, Layer, ServiceMap, Stream } from "effect"
+import path from "path"
 import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
+import { AppFileSystem } from "@/filesystem"
 import { FileWatcher } from "@/file/watcher"
+import { Git } from "@/git"
+import { Snapshot } from "@/snapshot"
 import { Log } from "@/util/log"
-import { git } from "@/util/git"
 import { Instance } from "./instance"
 import z from "zod"
 
 export namespace Vcs {
   const log = Log.create({ service: "vcs" })
 
+  const count = (text: string) => {
+    if (!text) return 0
+    if (!text.endsWith("\n")) return text.split("\n").length
+    return text.slice(0, -1).split("\n").length
+  }
+
+  const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
+    const full = path.join(cwd, file)
+    if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
+    const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
+    if (Buffer.from(buf).includes(0)) return ""
+    return Buffer.from(buf).toString("utf8")
+  })
+
+  const nums = (list: Git.Stat[]) =>
+    new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
+
+  const merge = (...lists: Git.Item[][]) => {
+    const out = new Map<string, Git.Item>()
+    lists.flat().forEach((item) => {
+      if (!out.has(item.file)) out.set(item.file, item)
+    })
+    return [...out.values()]
+  }
+
+  const files = Effect.fnUntraced(function* (
+    fs: AppFileSystem.Interface,
+    git: Git.Interface,
+    cwd: string,
+    ref: string | undefined,
+    list: Git.Item[],
+    map: Map<string, { additions: number; deletions: number }>,
+  ) {
+    const base = ref ? yield* git.prefix(cwd) : ""
+    const next = yield* Effect.forEach(
+      list,
+      (item) =>
+        Effect.gen(function* () {
+          const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
+          const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
+          const stat = map.get(item.file)
+          return {
+            file: item.file,
+            before,
+            after,
+            additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
+            deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
+            status: item.status,
+          } satisfies Snapshot.FileDiff
+        }),
+      { concurrency: 8 },
+    )
+    return next.toSorted((a, b) => a.file.localeCompare(b.file))
+  })
+
+  const track = Effect.fnUntraced(function* (
+    fs: AppFileSystem.Interface,
+    git: Git.Interface,
+    cwd: string,
+    ref: string | undefined,
+  ) {
+    if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
+    const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
+    return yield* files(fs, git, cwd, ref, list, nums(stats))
+  })
+
+  const compare = Effect.fnUntraced(function* (
+    fs: AppFileSystem.Interface,
+    git: Git.Interface,
+    cwd: string,
+    ref: string,
+  ) {
+    const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
+      concurrency: 3,
+    })
+    return yield* files(
+      fs,
+      git,
+      cwd,
+      ref,
+      merge(
+        list,
+        extra.filter((item) => item.code === "??"),
+      ),
+      nums(stats),
+    )
+  })
+
+  export const Mode = z.enum(["git", "branch"])
+  export type Mode = z.infer<typeof Mode>
+
   export const Event = {
     BranchUpdated: BusEvent.define(
       "vcs.branch.updated",
@@ -23,7 +117,8 @@ export namespace Vcs {
 
   export const Info = z
     .object({
-      branch: z.string(),
+      branch: z.string().optional(),
+      default_branch: z.string().optional(),
     })
     .meta({
       ref: "VcsInfo",
@@ -33,54 +128,50 @@ export namespace Vcs {
   export interface Interface {
     readonly init: () => Effect.Effect<void>
     readonly branch: () => Effect.Effect<string | undefined>
+    readonly defaultBranch: () => Effect.Effect<string | undefined>
+    readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]>
   }
 
   interface State {
     current: string | undefined
+    root: Git.Base | undefined
   }
 
   export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
 
-  export const layer = Layer.effect(
+  export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
     Service,
     Effect.gen(function* () {
+      const fs = yield* AppFileSystem.Service
+      const git = yield* Git.Service
+      const bus = yield* Bus.Service
       const state = yield* InstanceState.make<State>(
         Effect.fn("Vcs.state")((ctx) =>
           Effect.gen(function* () {
             if (ctx.project.vcs !== "git") {
-              return { current: undefined }
+              return { current: undefined, root: undefined }
             }
 
-            const getCurrentBranch = async () => {
-              const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
-                cwd: ctx.worktree,
-              })
-              if (result.exitCode !== 0) return undefined
-              const text = result.text().trim()
-              return text || undefined
-            }
+            const get = () => Effect.runPromise(git.branch(ctx.directory))
+            const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
+              concurrency: 2,
+            })
+            const value = { current, root }
+            log.info("initialized", { branch: value.current, default_branch: value.root?.name })
 
-            const value = {
-              current: yield* Effect.promise(() => getCurrentBranch()),
-            }
-            log.info("initialized", { branch: value.current })
-
-            yield* Effect.acquireRelease(
-              Effect.sync(() =>
-                Bus.subscribe(
-                  FileWatcher.Event.Updated,
-                  Instance.bind(async (evt) => {
-                    if (!evt.properties.file.endsWith("HEAD")) return
-                    const next = await getCurrentBranch()
-                    if (next !== value.current) {
-                      log.info("branch changed", { from: value.current, to: next })
-                      value.current = next
-                      Bus.publish(Event.BranchUpdated, { branch: next })
-                    }
-                  }),
-                ),
+            yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
+              Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
+              Stream.runForEach((_evt) =>
+                Effect.gen(function* () {
+                  const next = yield* Effect.promise(() => get())
+                  if (next !== value.current) {
+                    log.info("branch changed", { from: value.current, to: next })
+                    value.current = next
+                    yield* bus.publish(Event.BranchUpdated, { branch: next })
+                  }
+                }),
               ),
-              (unsubscribe) => Effect.sync(unsubscribe),
+              Effect.forkScoped,
             )
 
             return value
@@ -95,11 +186,38 @@ export namespace Vcs {
         branch: Effect.fn("Vcs.branch")(function* () {
           return yield* InstanceState.use(state, (x) => x.current)
         }),
+        defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
+          return yield* InstanceState.use(state, (x) => x.root?.name)
+        }),
+        diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
+          const value = yield* InstanceState.get(state)
+          if (Instance.project.vcs !== "git") return []
+          if (mode === "git") {
+            return yield* track(
+              fs,
+              git,
+              Instance.directory,
+              (yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
+            )
+          }
+
+          if (!value.root) return []
+          if (value.current && value.current === value.root.name) return []
+          const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
+          if (!ref) return []
+          return yield* compare(fs, git, Instance.directory, ref)
+        }),
       })
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  export const defaultLayer = layer.pipe(
+    Layer.provide(Git.defaultLayer),
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(Bus.layer),
+  )
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export function init() {
     return runPromise((svc) => svc.init())
@@ -108,4 +226,12 @@ export namespace Vcs {
   export function branch() {
     return runPromise((svc) => svc.branch())
   }
+
+  export function defaultBranch() {
+    return runPromise((svc) => svc.defaultBranch())
+  }
+
+  export function diff(mode: Mode) {
+    return runPromise((svc) => svc.diff(mode))
+  }
 }

+ 7 - 6
packages/opencode/src/provider/auth.ts

@@ -2,7 +2,7 @@ import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
 import { NamedError } from "@opencode-ai/util/error"
 import { Auth } from "@/auth"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { Plugin } from "../plugin"
 import { ProviderID } from "./schema"
 import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
@@ -215,12 +215,13 @@ export namespace ProviderAuth {
         }
 
         if ("refresh" in result) {
+          const { type: _, provider: __, refresh, access, expires, ...extra } = result
           yield* auth.set(input.providerID, {
             type: "oauth",
-            access: result.access,
-            refresh: result.refresh,
-            expires: result.expires,
-            ...(result.accountId ? { accountId: result.accountId } : {}),
+            access,
+            refresh,
+            expires,
+            ...extra,
           })
         }
       })
@@ -231,7 +232,7 @@ export namespace ProviderAuth {
 
   export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
 
-  const runPromise = makeRunPromise(Service, defaultLayer)
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function methods() {
     return runPromise((svc) => svc.methods())

+ 2 - 2
packages/opencode/src/pty/index.ts

@@ -1,7 +1,7 @@
 import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { Instance } from "@/project/instance"
 import { type IPty } from "bun-pty"
 import z from "zod"
@@ -361,7 +361,7 @@ export namespace Pty {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export async function list() {
     return runPromise((svc) => svc.list())

+ 2 - 2
packages/opencode/src/question/index.ts

@@ -2,7 +2,7 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
 import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { SessionID, MessageID } from "@/session/schema"
 import { Log } from "@/util/log"
 import z from "zod"
@@ -197,7 +197,7 @@ export namespace Question {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export async function ask(input: {
     sessionID: SessionID

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

@@ -39,6 +39,7 @@ import { websocket } from "hono/bun"
 import { HTTPException } from "hono/http-exception"
 import { errors } from "./error"
 import { Filesystem } from "@/util/filesystem"
+import { Snapshot } from "@/snapshot"
 import { QuestionRoutes } from "./routes/question"
 import { PermissionRoutes } from "./routes/permission"
 import { GlobalRoutes } from "./routes/global"
@@ -337,12 +338,40 @@ export namespace Server {
           },
         }),
         async (c) => {
-          const branch = await Vcs.branch()
+          const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
           return c.json({
             branch,
+            default_branch,
           })
         },
       )
+      .get(
+        "/vcs/diff",
+        describeRoute({
+          summary: "Get VCS diff",
+          description: "Retrieve the current git diff for the working tree or against the default branch.",
+          operationId: "vcs.diff",
+          responses: {
+            200: {
+              description: "VCS diff",
+              content: {
+                "application/json": {
+                  schema: resolver(Snapshot.FileDiff.array()),
+                },
+              },
+            },
+          },
+        }),
+        validator(
+          "query",
+          z.object({
+            mode: Vcs.Mode,
+          }),
+        ),
+        async (c) => {
+          return c.json(await Vcs.diff(c.req.valid("query").mode))
+        },
+      )
       .get(
         "/command",
         describeRoute({

+ 6 - 1
packages/opencode/src/session/compaction.ts

@@ -13,6 +13,7 @@ import { fn } from "@/util/fn"
 import { Agent } from "@/agent/agent"
 import { Plugin } from "@/plugin"
 import { Config } from "@/config/config"
+import { NotFoundError } from "@/storage/db"
 import { ProviderTransform } from "@/provider/transform"
 import { ModelID, ProviderID } from "@/provider/schema"
 
@@ -60,7 +61,11 @@ export namespace SessionCompaction {
     const config = await Config.get()
     if (config.compaction?.prune === false) return
     log.info("pruning")
-    const msgs = await Session.messages({ sessionID: input.sessionID })
+    const msgs = await Session.messages({ sessionID: input.sessionID }).catch((err) => {
+      if (NotFoundError.isInstance(err)) return undefined
+      throw err
+    })
+    if (!msgs) return
     let total = 0
     let pruned = 0
     const toPrune = []

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

@@ -15,6 +15,13 @@ import type { SystemError } from "bun"
 import type { Provider } from "@/provider/provider"
 import { ModelID, ProviderID } from "@/provider/schema"
 
+/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
+interface FetchDecompressionError extends Error {
+  code: "ZlibError"
+  errno: number
+  path: string
+}
+
 export namespace MessageV2 {
   export function isMedia(mime: string) {
     return mime.startsWith("image/") || mime === "application/pdf"
@@ -906,7 +913,10 @@ export namespace MessageV2 {
     return result
   }
 
-  export function fromError(e: unknown, ctx: { providerID: ProviderID }): NonNullable<Assistant["error"]> {
+  export function fromError(
+    e: unknown,
+    ctx: { providerID: ProviderID; aborted?: boolean },
+  ): NonNullable<Assistant["error"]> {
     switch (true) {
       case e instanceof DOMException && e.name === "AbortError":
         return new MessageV2.AbortedError(
@@ -938,6 +948,21 @@ export namespace MessageV2 {
           },
           { cause: e },
         ).toObject()
+      case e instanceof Error && (e as FetchDecompressionError).code === "ZlibError":
+        if (ctx.aborted) {
+          return new MessageV2.AbortedError({ message: e.message }, { cause: e }).toObject()
+        }
+        return new MessageV2.APIError(
+          {
+            message: "Response decompression failed",
+            isRetryable: true,
+            metadata: {
+              code: (e as FetchDecompressionError).code,
+              message: e.message,
+            },
+          },
+          { cause: e },
+        ).toObject()
       case APICallError.isInstance(e):
         const parsed = ProviderError.parseAPICallError({
           providerID: ctx.providerID,

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

@@ -356,7 +356,7 @@ export namespace SessionProcessor {
               error: e,
               stack: JSON.stringify(e.stack),
             })
-            const error = MessageV2.fromError(e, { providerID: input.model.providerID })
+            const error = MessageV2.fromError(e, { providerID: input.model.providerID, aborted: input.abort.aborted })
             if (MessageV2.ContextOverflowError.isInstance(error)) {
               needsCompaction = true
               Bus.publish(Session.Event.Error, {

+ 38 - 19
packages/opencode/src/session/projectors.ts

@@ -4,6 +4,15 @@ import { Session } from "./index"
 import { MessageV2 } from "./message-v2"
 import { SessionTable, MessageTable, PartTable } from "./session.sql"
 import { ProjectTable } from "../project/project.sql"
+import { Log } from "../util/log"
+
+const log = Log.create({ service: "session.projector" })
+
+function foreign(err: unknown) {
+  if (typeof err !== "object" || err === null) return false
+  if ("code" in err && err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") return true
+  return "message" in err && typeof err.message === "string" && err.message.includes("FOREIGN KEY constraint failed")
+}
 
 export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T
 
@@ -76,15 +85,20 @@ export default [
     const time_created = data.info.time.created
     const { id, sessionID, ...rest } = data.info
 
-    db.insert(MessageTable)
-      .values({
-        id,
-        session_id: sessionID,
-        time_created,
-        data: rest,
-      })
-      .onConflictDoUpdate({ target: MessageTable.id, set: { data: rest } })
-      .run()
+    try {
+      db.insert(MessageTable)
+        .values({
+          id,
+          session_id: sessionID,
+          time_created,
+          data: rest,
+        })
+        .onConflictDoUpdate({ target: MessageTable.id, set: { data: rest } })
+        .run()
+    } catch (err) {
+      if (!foreign(err)) throw err
+      log.warn("ignored late message update", { messageID: id, sessionID })
+    }
   }),
 
   SyncEvent.project(MessageV2.Event.Removed, (db, data) => {
@@ -102,15 +116,20 @@ export default [
   SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
     const { id, messageID, sessionID, ...rest } = data.part
 
-    db.insert(PartTable)
-      .values({
-        id,
-        message_id: messageID,
-        session_id: sessionID,
-        time_created: data.time,
-        data: rest,
-      })
-      .onConflictDoUpdate({ target: PartTable.id, set: { data: rest } })
-      .run()
+    try {
+      db.insert(PartTable)
+        .values({
+          id,
+          message_id: messageID,
+          session_id: sessionID,
+          time_created: data.time,
+          data: rest,
+        })
+        .onConflictDoUpdate({ target: PartTable.id, set: { data: rest } })
+        .run()
+    } catch (err) {
+      if (!foreign(err)) throw err
+      log.warn("ignored late part update", { partID: id, messageID, sessionID })
+    }
   }),
 ]

+ 7 - 4
packages/opencode/src/session/status.ts

@@ -1,7 +1,7 @@
 import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { SessionID } from "./schema"
 import { Effect, Layer, ServiceMap } from "effect"
 import z from "zod"
@@ -55,6 +55,8 @@ export namespace SessionStatus {
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
+      const bus = yield* Bus.Service
+
       const state = yield* InstanceState.make(
         Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())),
       )
@@ -70,9 +72,9 @@ export namespace SessionStatus {
 
       const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
         const data = yield* InstanceState.get(state)
-        yield* Effect.promise(() => Bus.publish(Event.Status, { sessionID, status }))
+        yield* bus.publish(Event.Status, { sessionID, status })
         if (status.type === "idle") {
-          yield* Effect.promise(() => Bus.publish(Event.Idle, { sessionID }))
+          yield* bus.publish(Event.Idle, { sessionID })
           data.delete(sessionID)
           return
         }
@@ -83,7 +85,8 @@ export namespace SessionStatus {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function get(sessionID: SessionID) {
     return runPromise((svc) => svc.get(sessionID))

+ 47 - 71
packages/opencode/src/skill/index.ts

@@ -7,7 +7,7 @@ import { NamedError } from "@opencode-ai/util/error"
 import type { Agent } from "@/agent/agent"
 import { Bus } from "@/bus"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { Flag } from "@/flag/flag"
 import { Global } from "@/global"
 import { Permission } from "@/permission"
@@ -54,11 +54,6 @@ export namespace Skill {
   type State = {
     skills: Record<string, Info>
     dirs: Set<string>
-    task?: Promise<void>
-  }
-
-  type Cache = State & {
-    ensure: () => Promise<void>
   }
 
   export interface Interface {
@@ -116,66 +111,47 @@ export namespace Skill {
       })
   }
 
-  // TODO: Migrate to Effect
-  const create = (discovery: Discovery.Interface, directory: string, worktree: string): Cache => {
-    const state: State = {
-      skills: {},
-      dirs: new Set<string>(),
-    }
-
-    const load = async () => {
-      if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
-        for (const dir of EXTERNAL_DIRS) {
-          const root = path.join(Global.Path.home, dir)
-          if (!(await Filesystem.isDir(root))) continue
-          await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
-        }
-
-        for await (const root of Filesystem.up({
-          targets: EXTERNAL_DIRS,
-          start: directory,
-          stop: worktree,
-        })) {
-          await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
-        }
+  async function loadSkills(state: State, discovery: Discovery.Interface, directory: string, worktree: string) {
+    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 (const dir of await Config.directories()) {
-        await scan(state, dir, OPENCODE_SKILL_PATTERN)
+      for await (const root of Filesystem.up({
+        targets: EXTERNAL_DIRS,
+        start: directory,
+        stop: worktree,
+      })) {
+        await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
       }
+    }
 
-      const cfg = await Config.get()
-      for (const item of cfg.skills?.paths ?? []) {
-        const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
-        const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
-        if (!(await Filesystem.isDir(dir))) {
-          log.warn("skill path not found", { path: dir })
-          continue
-        }
-
-        await scan(state, dir, SKILL_PATTERN)
-      }
+    for (const dir of await Config.directories()) {
+      await scan(state, dir, OPENCODE_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)
-        }
+    const cfg = await Config.get()
+    for (const item of cfg.skills?.paths ?? []) {
+      const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
+      const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
+      if (!(await Filesystem.isDir(dir))) {
+        log.warn("skill path not found", { path: dir })
+        continue
       }
 
-      log.info("init", { count: Object.keys(state.skills).length })
+      await scan(state, dir, SKILL_PATTERN)
     }
 
-    const ensure = () => {
-      if (state.task) return state.task
-      state.task = load().catch((err) => {
-        state.task = undefined
-        throw err
-      })
-      return state.task
+    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)
+      }
     }
 
-    return { ...state, ensure }
+    log.info("init", { count: Object.keys(state.skills).length })
   }
 
   export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
@@ -185,33 +161,33 @@ export namespace Skill {
     Effect.gen(function* () {
       const discovery = yield* Discovery.Service
       const state = yield* InstanceState.make(
-        Effect.fn("Skill.state")((ctx) => Effect.sync(() => create(discovery, ctx.directory, ctx.worktree))),
+        Effect.fn("Skill.state")((ctx) =>
+          Effect.gen(function* () {
+            const s: State = { skills: {}, dirs: new Set() }
+            yield* Effect.promise(() => loadSkills(s, discovery, ctx.directory, ctx.worktree))
+            return s
+          }),
+        ),
       )
 
-      const ensure = Effect.fn("Skill.ensure")(function* () {
-        const cache = yield* InstanceState.get(state)
-        yield* Effect.promise(() => cache.ensure())
-        return cache
-      })
-
       const get = Effect.fn("Skill.get")(function* (name: string) {
-        const cache = yield* ensure()
-        return cache.skills[name]
+        const s = yield* InstanceState.get(state)
+        return s.skills[name]
       })
 
       const all = Effect.fn("Skill.all")(function* () {
-        const cache = yield* ensure()
-        return Object.values(cache.skills)
+        const s = yield* InstanceState.get(state)
+        return Object.values(s.skills)
       })
 
       const dirs = Effect.fn("Skill.dirs")(function* () {
-        const cache = yield* ensure()
-        return Array.from(cache.dirs)
+        const s = yield* InstanceState.get(state)
+        return Array.from(s.dirs)
       })
 
       const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
-        const cache = yield* ensure()
-        const list = Object.values(cache.skills).toSorted((a, b) => a.name.localeCompare(b.name))
+        const s = yield* InstanceState.get(state)
+        const list = Object.values(s.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")
       })
@@ -242,7 +218,7 @@ export namespace Skill {
     return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
   }
 
-  const runPromise = makeRunPromise(Service, defaultLayer)
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function get(name: string) {
     return runPromise((skill) => skill.get(name))

+ 198 - 150
packages/opencode/src/snapshot/index.ts

@@ -1,12 +1,13 @@
 import { NodeFileSystem, NodePath } from "@effect/platform-node"
-import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
+import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import path from "path"
 import z from "zod"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { AppFileSystem } from "@/filesystem"
+import { Hash } from "@/util/hash"
 import { Config } from "../config/config"
 import { Global } from "../global"
 import { Log } from "../util/log"
@@ -38,7 +39,6 @@ export namespace Snapshot {
   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
@@ -66,12 +66,23 @@ export namespace Snapshot {
       Effect.gen(function* () {
         const fs = yield* AppFileSystem.Service
         const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+        const locks = new Map<string, Semaphore.Semaphore>()
+
+        const lock = (key: string) => {
+          const hit = locks.get(key)
+          if (hit) return hit
+
+          const next = Semaphore.makeUnsafe(1)
+          locks.set(key, next)
+          return next
+        }
+
         const state = yield* InstanceState.make<State>(
           Effect.fn("Snapshot.state")(function* (ctx) {
             const state = {
               directory: ctx.directory,
               worktree: ctx.worktree,
-              gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id),
+              gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
               vcs: ctx.project.vcs,
             }
 
@@ -108,6 +119,7 @@ export namespace Snapshot {
             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 locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
 
             const enabled = Effect.fnUntraced(function* () {
               if (state.vcs !== "git") return false
@@ -190,175 +202,211 @@ export namespace Snapshot {
             })
 
             const cleanup = Effect.fnUntraced(function* () {
-              if (!(yield* enabled())) return
-              if (!(yield* exists(state.gitdir))) return
-              const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
-              if (result.code !== 0) {
-                log.warn("cleanup failed", {
-                  exitCode: result.code,
-                  stderr: result.stderr,
-                })
-                return
-              }
-              log.info("cleanup", { prune })
+              return yield* locked(
+                Effect.gen(function* () {
+                  if (!(yield* enabled())) return
+                  if (!(yield* exists(state.gitdir))) return
+                  const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
+                  if (result.code !== 0) {
+                    log.warn("cleanup failed", {
+                      exitCode: result.code,
+                      stderr: result.stderr,
+                    })
+                    return
+                  }
+                  log.info("cleanup", { prune })
+                }),
+              )
             })
 
             const track = Effect.fnUntraced(function* () {
-              if (!(yield* enabled())) return
-              const existed = yield* exists(state.gitdir)
-              yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
-              if (!existed) {
-                yield* git(["init"], {
-                  env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
-                })
-                yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
-                yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
-                yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
-                yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
-                log.info("initialized")
-              }
-              yield* add()
-              const result = yield* git(args(["write-tree"]), { cwd: state.directory })
-              const hash = result.text.trim()
-              log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
-              return hash
+              return yield* locked(
+                Effect.gen(function* () {
+                  if (!(yield* enabled())) return
+                  const existed = yield* exists(state.gitdir)
+                  yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
+                  if (!existed) {
+                    yield* git(["init"], {
+                      env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
+                    })
+                    yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
+                    yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
+                    yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
+                    yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
+                    log.info("initialized")
+                  }
+                  yield* add()
+                  const result = yield* git(args(["write-tree"]), { cwd: state.directory })
+                  const hash = result.text.trim()
+                  log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
+                  return hash
+                }),
+              )
             })
 
             const patch = Effect.fnUntraced(function* (hash: string) {
-              yield* add()
-              const result = yield* git(
-                [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
-                {
-                  cwd: state.directory,
-                },
+              return yield* locked(
+                Effect.gen(function* () {
+                  yield* add()
+                  const result = yield* git(
+                    [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
+                    {
+                      cwd: state.directory,
+                    },
+                  )
+                  if (result.code !== 0) {
+                    log.warn("failed to get diff", { hash, exitCode: result.code })
+                    return { hash, files: [] }
+                  }
+                  return {
+                    hash,
+                    files: result.text
+                      .trim()
+                      .split("\n")
+                      .map((x) => x.trim())
+                      .filter(Boolean)
+                      .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
+                  }
+                }),
               )
-              if (result.code !== 0) {
-                log.warn("failed to get diff", { hash, exitCode: result.code })
-                return { hash, files: [] }
-              }
-              return {
-                hash,
-                files: result.text
-                  .trim()
-                  .split("\n")
-                  .map((x) => x.trim())
-                  .filter(Boolean)
-                  .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
-              }
             })
 
             const restore = Effect.fnUntraced(function* (snapshot: string) {
-              log.info("restore", { commit: snapshot })
-              const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
-              if (result.code === 0) {
-                const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: state.worktree })
-                if (checkout.code === 0) return
-                log.error("failed to restore snapshot", {
-                  snapshot,
-                  exitCode: checkout.code,
-                  stderr: checkout.stderr,
-                })
-                return
-              }
-              log.error("failed to restore snapshot", {
-                snapshot,
-                exitCode: result.code,
-                stderr: result.stderr,
-              })
+              return yield* locked(
+                Effect.gen(function* () {
+                  log.info("restore", { commit: snapshot })
+                  const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
+                  if (result.code === 0) {
+                    const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
+                      cwd: state.worktree,
+                    })
+                    if (checkout.code === 0) return
+                    log.error("failed to restore snapshot", {
+                      snapshot,
+                      exitCode: checkout.code,
+                      stderr: checkout.stderr,
+                    })
+                    return
+                  }
+                  log.error("failed to restore snapshot", {
+                    snapshot,
+                    exitCode: result.code,
+                    stderr: result.stderr,
+                  })
+                }),
+              )
             })
 
             const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
-              const seen = new Set<string>()
-              for (const item of patches) {
-                for (const file of item.files) {
-                  if (seen.has(file)) continue
-                  seen.add(file)
-                  log.info("reverting", { file, hash: item.hash })
-                  const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
-                    cwd: state.worktree,
-                  })
-                  if (result.code !== 0) {
-                    const rel = path.relative(state.worktree, file)
-                    const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
-                      cwd: state.worktree,
-                    })
-                    if (tree.code === 0 && tree.text.trim()) {
-                      log.info("file existed in snapshot but checkout failed, keeping", { file })
-                    } else {
-                      log.info("file did not exist in snapshot, deleting", { file })
-                      yield* remove(file)
+              return yield* locked(
+                Effect.gen(function* () {
+                  const seen = new Set<string>()
+                  for (const item of patches) {
+                    for (const file of item.files) {
+                      if (seen.has(file)) continue
+                      seen.add(file)
+                      log.info("reverting", { file, hash: item.hash })
+                      const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
+                        cwd: state.worktree,
+                      })
+                      if (result.code !== 0) {
+                        const rel = path.relative(state.worktree, file)
+                        const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
+                          cwd: state.worktree,
+                        })
+                        if (tree.code === 0 && tree.text.trim()) {
+                          log.info("file existed in snapshot but checkout failed, keeping", { file })
+                        } else {
+                          log.info("file did not exist in snapshot, deleting", { file })
+                          yield* remove(file)
+                        }
+                      }
                     }
                   }
-                }
-              }
+                }),
+              )
             })
 
             const diff = Effect.fnUntraced(function* (hash: string) {
-              yield* add()
-              const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
-                cwd: state.worktree,
-              })
-              if (result.code !== 0) {
-                log.warn("failed to get diff", {
-                  hash,
-                  exitCode: result.code,
-                  stderr: result.stderr,
-                })
-                return ""
-              }
-              return result.text.trim()
+              return yield* locked(
+                Effect.gen(function* () {
+                  yield* add()
+                  const result = yield* git(
+                    [...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])],
+                    {
+                      cwd: state.worktree,
+                    },
+                  )
+                  if (result.code !== 0) {
+                    log.warn("failed to get diff", {
+                      hash,
+                      exitCode: result.code,
+                      stderr: result.stderr,
+                    })
+                    return ""
+                  }
+                  return result.text.trim()
+                }),
+              )
             })
 
             const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
-              const result: Snapshot.FileDiff[] = []
-              const status = new Map<string, "added" | "deleted" | "modified">()
-
-              const statuses = yield* git(
-                [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
-                { cwd: state.directory },
-              )
+              return yield* locked(
+                Effect.gen(function* () {
+                  const result: Snapshot.FileDiff[] = []
+                  const status = new Map<string, "added" | "deleted" | "modified">()
+
+                  const statuses = yield* git(
+                    [
+                      ...quote,
+                      ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
+                    ],
+                    { cwd: state.directory },
+                  )
+
+                  for (const line of statuses.text.trim().split("\n")) {
+                    if (!line) continue
+                    const [code, file] = line.split("\t")
+                    if (!code || !file) continue
+                    status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
+                  }
 
-              for (const line of statuses.text.trim().split("\n")) {
-                if (!line) continue
-                const [code, file] = line.split("\t")
-                if (!code || !file) continue
-                status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
-              }
+                  const numstat = yield* git(
+                    [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
+                    {
+                      cwd: state.directory,
+                    },
+                  )
+
+                  for (const line of numstat.text.trim().split("\n")) {
+                    if (!line) continue
+                    const [adds, dels, file] = line.split("\t")
+                    if (!file) continue
+                    const binary = adds === "-" && dels === "-"
+                    const [before, after] = binary
+                      ? ["", ""]
+                      : yield* Effect.all(
+                          [
+                            git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
+                            git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
+                          ],
+                          { concurrency: 2 },
+                        )
+                    const additions = binary ? 0 : parseInt(adds)
+                    const deletions = binary ? 0 : parseInt(dels)
+                    result.push({
+                      file,
+                      before,
+                      after,
+                      additions: Number.isFinite(additions) ? additions : 0,
+                      deletions: Number.isFinite(deletions) ? deletions : 0,
+                      status: status.get(file) ?? "modified",
+                    })
+                  }
 
-              const numstat = yield* git(
-                [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
-                {
-                  cwd: state.directory,
-                },
+                  return result
+                }),
               )
-
-              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(
@@ -411,7 +459,7 @@ export namespace Snapshot {
     Layer.provide(NodePath.layer),
   )
 
-  const runPromise = makeRunPromise(Service, defaultLayer)
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function init() {
     return runPromise((svc) => svc.init())

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

@@ -7,8 +7,8 @@ import { lazy } from "../util/lazy"
 import { Lock } from "../util/lock"
 import { NamedError } from "@opencode-ai/util/error"
 import z from "zod"
+import { Git } from "@/git"
 import { Glob } from "../util/glob"
-import { git } from "@/util/git"
 
 export namespace Storage {
   const log = Log.create({ service: "storage" })
@@ -49,7 +49,7 @@ export namespace Storage {
           }
           if (!worktree) continue
           if (!(await Filesystem.isDir(worktree))) continue
-          const result = await git(["rev-list", "--max-parents=0", "--all"], {
+          const result = await Git.run(["rev-list", "--max-parents=0", "--all"], {
             cwd: worktree,
           })
           const [id] = result

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

@@ -13,6 +13,7 @@ import { LSP } from "../lsp"
 import { Filesystem } from "../util/filesystem"
 import DESCRIPTION from "./apply_patch.txt"
 import { File } from "../file"
+import { Format } from "../format"
 
 const PatchParams = z.object({
   patchText: z.string().describe("The full patch text that describes all changes to be made"),
@@ -220,9 +221,8 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
       }
 
       if (edited) {
-        await Bus.publish(File.Event.Edited, {
-          file: edited,
-        })
+        await Format.file(edited)
+        Bus.publish(File.Event.Edited, { file: edited })
       }
     }
 

+ 5 - 6
packages/opencode/src/tool/edit.ts

@@ -12,6 +12,7 @@ import DESCRIPTION from "./edit.txt"
 import { File } from "../file"
 import { FileWatcher } from "../file/watcher"
 import { Bus } from "../bus"
+import { Format } from "../format"
 import { FileTime } from "../file/time"
 import { Filesystem } from "../util/filesystem"
 import { Instance } from "../project/instance"
@@ -71,9 +72,8 @@ export const EditTool = Tool.define("edit", {
           },
         })
         await Filesystem.write(filePath, params.newString)
-        await Bus.publish(File.Event.Edited, {
-          file: filePath,
-        })
+        await Format.file(filePath)
+        Bus.publish(File.Event.Edited, { file: filePath })
         await Bus.publish(FileWatcher.Event.Updated, {
           file: filePath,
           event: existed ? "change" : "add",
@@ -108,9 +108,8 @@ export const EditTool = Tool.define("edit", {
       })
 
       await Filesystem.write(filePath, contentNew)
-      await Bus.publish(File.Event.Edited, {
-        file: filePath,
-      })
+      await Format.file(filePath)
+      Bus.publish(File.Event.Edited, { file: filePath })
       await Bus.publish(FileWatcher.Event.Updated, {
         file: filePath,
         event: "change",

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

@@ -31,7 +31,7 @@ import { Glob } from "../util/glob"
 import { pathToFileURL } from "url"
 import { Effect, Layer, ServiceMap } from "effect"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 
 export namespace ToolRegistry {
   const log = Log.create({ service: "tool.registry" })
@@ -198,7 +198,7 @@ export namespace ToolRegistry {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export async function register(tool: Tool.Info) {
     return runPromise((svc) => svc.register(tool))

+ 11 - 12
packages/opencode/src/tool/task.ts

@@ -64,6 +64,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
       if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
 
       const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task")
+      const hasTodoWritePermission = agent.permission.some((rule) => rule.permission === "todowrite")
 
       const session = await iife(async () => {
         if (params.task_id) {
@@ -75,16 +76,15 @@ export const TaskTool = Tool.define("task", async (ctx) => {
           parentID: ctx.sessionID,
           title: params.description + ` (@${agent.name} subagent)`,
           permission: [
-            {
-              permission: "todowrite",
-              pattern: "*",
-              action: "deny",
-            },
-            {
-              permission: "todoread",
-              pattern: "*",
-              action: "deny",
-            },
+            ...(hasTodoWritePermission
+              ? []
+              : [
+                  {
+                    permission: "todowrite" as const,
+                    pattern: "*" as const,
+                    action: "deny" as const,
+                  },
+                ]),
             ...(hasTaskPermission
               ? []
               : [
@@ -136,8 +136,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
         },
         agent: agent.name,
         tools: {
-          todowrite: false,
-          todoread: false,
+          ...(hasTodoWritePermission ? {} : { todowrite: false }),
           ...(hasTaskPermission ? {} : { task: false }),
           ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
         },

+ 1 - 23
packages/opencode/src/tool/todo.ts

@@ -16,7 +16,7 @@ export const TodoWriteTool = Tool.define("todowrite", {
       metadata: {},
     })
 
-    await Todo.update({
+    Todo.update({
       sessionID: ctx.sessionID,
       todos: params.todos,
     })
@@ -29,25 +29,3 @@ export const TodoWriteTool = Tool.define("todowrite", {
     }
   },
 })
-
-export const TodoReadTool = Tool.define("todoread", {
-  description: "Use this tool to read your todo list",
-  parameters: z.object({}),
-  async execute(_params, ctx) {
-    await ctx.ask({
-      permission: "todoread",
-      patterns: ["*"],
-      always: ["*"],
-      metadata: {},
-    })
-
-    const todos = await Todo.get(ctx.sessionID)
-    return {
-      title: `${todos.filter((x) => x.status !== "completed").length} todos`,
-      metadata: {
-        todos,
-      },
-      output: JSON.stringify(todos, null, 2),
-    }
-  },
-})

+ 0 - 14
packages/opencode/src/tool/todoread.txt

@@ -1,14 +0,0 @@
-Use this tool to read the current to-do list for the session. This tool should be used proactively and frequently to ensure that you are aware of
-the status of the current task list. You should make use of this tool as often as possible, especially in the following situations:
-- At the beginning of conversations to see what's pending
-- Before starting new tasks to prioritize work
-- When the user asks about previous tasks or plans
-- Whenever you're uncertain about what to do next
-- After completing tasks to update your understanding of remaining work
-- After every few messages to ensure you're on track
-
-Usage:
-- This tool takes in no parameters. So leave the input blank or empty. DO NOT include a dummy object, placeholder string or a key like "input" or "empty". LEAVE IT BLANK.
-- Returns a list of todo items with their status, priority, and content
-- Use this information to track progress and plan next steps
-- If no todos exist yet, an empty list will be returned

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

@@ -2,7 +2,7 @@ import { NodePath } from "@effect/platform-node"
 import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
 import path from "path"
 import type { Agent } from "../agent/agent"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { AppFileSystem } from "@/filesystem"
 import { evaluate } from "@/permission/evaluate"
 import { Identifier } from "../id/id"
@@ -136,7 +136,7 @@ export namespace Truncate {
 
   export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
 
-  const runPromise = makeRunPromise(Service, defaultLayer)
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
     return runPromise((s) => s.output(text, options, agent))

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

@@ -7,6 +7,7 @@ import DESCRIPTION from "./write.txt"
 import { Bus } from "../bus"
 import { File } from "../file"
 import { FileWatcher } from "../file/watcher"
+import { Format } from "../format"
 import { FileTime } from "../file/time"
 import { Filesystem } from "../util/filesystem"
 import { Instance } from "../project/instance"
@@ -42,9 +43,8 @@ export const WriteTool = Tool.define("write", {
     })
 
     await Filesystem.write(filepath, params.content)
-    await Bus.publish(File.Event.Edited, {
-      file: filepath,
-    })
+    await Format.file(filepath)
+    Bus.publish(File.Event.Edited, { file: filepath })
     await Bus.publish(FileWatcher.Event.Updated, {
       file: filepath,
       event: exists ? "change" : "add",

+ 0 - 35
packages/opencode/src/util/git.ts

@@ -1,35 +0,0 @@
-import { Process } from "./process"
-
-export interface GitResult {
-  exitCode: number
-  text(): string
-  stdout: Buffer
-  stderr: Buffer
-}
-
-/**
- * Run a git command.
- *
- * Uses Process helpers with stdin ignored to avoid protocol pipe inheritance
- * issues in embedded/client environments.
- */
-export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
-  return Process.run(["git", ...args], {
-    cwd: opts.cwd,
-    env: opts.env,
-    stdin: "ignore",
-    nothrow: true,
-  })
-    .then((result) => ({
-      exitCode: result.code,
-      text: () => result.stdout.toString(),
-      stdout: result.stdout,
-      stderr: result.stderr,
-    }))
-    .catch((error) => ({
-      exitCode: 1,
-      text: () => "",
-      stdout: Buffer.alloc(0),
-      stderr: Buffer.from(error instanceof Error ? error.message : String(error)),
-    }))
-}

+ 12 - 43
packages/opencode/src/worktree/index.ts

@@ -11,10 +11,11 @@ import { Log } from "../util/log"
 import { Slug } from "@opencode-ai/util/slug"
 import { BusEvent } from "@/bus/bus-event"
 import { GlobalBus } from "@/bus/global"
+import { Git } from "@/git"
 import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import { NodeFileSystem, NodePath } from "@effect/platform-node"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 
 export namespace Worktree {
@@ -504,56 +505,24 @@ export namespace Worktree {
 
         const worktreePath = entry.path
 
-        const remoteList = yield* git(["remote"], { cwd: Instance.worktree })
-        if (remoteList.code !== 0) {
-          throw new ResetFailedError({ message: remoteList.stderr || remoteList.text || "Failed to list git remotes" })
-        }
-
-        const remotes = remoteList.text
-          .split("\n")
-          .map((l) => l.trim())
-          .filter(Boolean)
-        const remote = remotes.includes("origin")
-          ? "origin"
-          : remotes.length === 1
-            ? remotes[0]
-            : remotes.includes("upstream")
-              ? "upstream"
-              : ""
-
-        const remoteHead = remote
-          ? yield* git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
-          : { code: 1, text: "", stderr: "" }
-
-        const remoteRef = remoteHead.code === 0 ? remoteHead.text.trim() : ""
-        const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
-        const remoteBranch =
-          remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
-
-        const [mainCheck, masterCheck] = yield* Effect.all(
-          [
-            git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { cwd: Instance.worktree }),
-            git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { cwd: Instance.worktree }),
-          ],
-          { concurrency: 2 },
-        )
-        const localBranch = mainCheck.code === 0 ? "main" : masterCheck.code === 0 ? "master" : ""
-
-        const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
-        if (!target) {
+        const base = yield* Effect.promise(() => Git.defaultBranch(Instance.worktree))
+        if (!base) {
           throw new ResetFailedError({ message: "Default branch not found" })
         }
 
-        if (remoteBranch) {
+        const sep = base.ref.indexOf("/")
+        if (base.ref !== base.name && sep > 0) {
+          const remote = base.ref.slice(0, sep)
+          const branch = base.ref.slice(sep + 1)
           yield* gitExpect(
-            ["fetch", remote, remoteBranch],
+            ["fetch", remote, branch],
             { cwd: Instance.worktree },
-            (r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${target}` }),
+            (r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }),
           )
         }
 
         yield* gitExpect(
-          ["reset", "--hard", target],
+          ["reset", "--hard", base.ref],
           { cwd: worktreePath },
           (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
         )
@@ -607,7 +576,7 @@ export namespace Worktree {
     Layer.provide(NodeFileSystem.layer),
     Layer.provide(NodePath.layer),
   )
-  const runPromise = makeRunPromise(Service, defaultLayer)
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function makeWorktreeInfo(name?: string) {
     return runPromise((svc) => svc.makeWorktreeInfo(name))

+ 0 - 2
packages/opencode/test/agent/agent.test.ts

@@ -73,7 +73,6 @@ test("explore agent denies edit and write", async () => {
       expect(explore?.mode).toBe("subagent")
       expect(evalPerm(explore, "edit")).toBe("deny")
       expect(evalPerm(explore, "write")).toBe("deny")
-      expect(evalPerm(explore, "todoread")).toBe("deny")
       expect(evalPerm(explore, "todowrite")).toBe("deny")
     },
   })
@@ -102,7 +101,6 @@ test("general agent denies todo tools", async () => {
       expect(general).toBeDefined()
       expect(general?.mode).toBe("subagent")
       expect(general?.hidden).toBeUndefined()
-      expect(evalPerm(general, "todoread")).toBe("deny")
       expect(evalPerm(general, "todowrite")).toBe("deny")
     },
   })

+ 164 - 0
packages/opencode/test/bus/bus-effect.test.ts

@@ -0,0 +1,164 @@
+import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
+import { describe, expect } from "bun:test"
+import { Deferred, Effect, Layer, Stream } from "effect"
+import z from "zod"
+import { Bus } from "../../src/bus"
+import { BusEvent } from "../../src/bus/bus-event"
+import { Instance } from "../../src/project/instance"
+import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+
+const TestEvent = {
+  Ping: BusEvent.define("test.effect.ping", z.object({ value: z.number() })),
+  Pong: BusEvent.define("test.effect.pong", z.object({ message: z.string() })),
+}
+
+const node = NodeChildProcessSpawner.layer.pipe(
+  Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
+)
+
+const live = Layer.mergeAll(Bus.layer, node)
+
+const it = testEffect(live)
+
+describe("Bus (Effect-native)", () => {
+  it.effect("publish + subscribe stream delivers events", () =>
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        const bus = yield* Bus.Service
+        const received: number[] = []
+        const done = yield* Deferred.make<void>()
+
+        yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
+          Effect.sync(() => {
+            received.push(evt.properties.value)
+            if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
+          }),
+        ).pipe(Effect.forkScoped)
+
+        yield* Effect.sleep("10 millis")
+        yield* bus.publish(TestEvent.Ping, { value: 1 })
+        yield* bus.publish(TestEvent.Ping, { value: 2 })
+        yield* Deferred.await(done)
+
+        expect(received).toEqual([1, 2])
+      }),
+    ),
+  )
+
+  it.effect("subscribe filters by event type", () =>
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        const bus = yield* Bus.Service
+        const pings: number[] = []
+        const done = yield* Deferred.make<void>()
+
+        yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
+          Effect.sync(() => {
+            pings.push(evt.properties.value)
+            Deferred.doneUnsafe(done, Effect.void)
+          }),
+        ).pipe(Effect.forkScoped)
+
+        yield* Effect.sleep("10 millis")
+        yield* bus.publish(TestEvent.Pong, { message: "ignored" })
+        yield* bus.publish(TestEvent.Ping, { value: 42 })
+        yield* Deferred.await(done)
+
+        expect(pings).toEqual([42])
+      }),
+    ),
+  )
+
+  it.effect("subscribeAll receives all types", () =>
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        const bus = yield* Bus.Service
+        const types: string[] = []
+        const done = yield* Deferred.make<void>()
+
+        yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
+          Effect.sync(() => {
+            types.push(evt.type)
+            if (types.length === 2) Deferred.doneUnsafe(done, Effect.void)
+          }),
+        ).pipe(Effect.forkScoped)
+
+        yield* Effect.sleep("10 millis")
+        yield* bus.publish(TestEvent.Ping, { value: 1 })
+        yield* bus.publish(TestEvent.Pong, { message: "hi" })
+        yield* Deferred.await(done)
+
+        expect(types).toContain("test.effect.ping")
+        expect(types).toContain("test.effect.pong")
+      }),
+    ),
+  )
+
+  it.effect("multiple subscribers each receive the event", () =>
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        const bus = yield* Bus.Service
+        const a: number[] = []
+        const b: number[] = []
+        const doneA = yield* Deferred.make<void>()
+        const doneB = yield* Deferred.make<void>()
+
+        yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
+          Effect.sync(() => {
+            a.push(evt.properties.value)
+            Deferred.doneUnsafe(doneA, Effect.void)
+          }),
+        ).pipe(Effect.forkScoped)
+
+        yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
+          Effect.sync(() => {
+            b.push(evt.properties.value)
+            Deferred.doneUnsafe(doneB, Effect.void)
+          }),
+        ).pipe(Effect.forkScoped)
+
+        yield* Effect.sleep("10 millis")
+        yield* bus.publish(TestEvent.Ping, { value: 99 })
+        yield* Deferred.await(doneA)
+        yield* Deferred.await(doneB)
+
+        expect(a).toEqual([99])
+        expect(b).toEqual([99])
+      }),
+    ),
+  )
+
+  it.effect("subscribeAll stream sees InstanceDisposed on disposal", () =>
+    Effect.gen(function* () {
+      const dir = yield* tmpdirScoped()
+      const types: string[] = []
+      const seen = yield* Deferred.make<void>()
+      const disposed = yield* Deferred.make<void>()
+
+      // Set up subscriber inside the instance
+      yield* Effect.gen(function* () {
+        const bus = yield* Bus.Service
+
+        yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
+          Effect.sync(() => {
+            types.push(evt.type)
+            if (evt.type === TestEvent.Ping.type) Deferred.doneUnsafe(seen, Effect.void)
+            if (evt.type === Bus.InstanceDisposed.type) Deferred.doneUnsafe(disposed, Effect.void)
+          }),
+        ).pipe(Effect.forkScoped)
+
+        yield* Effect.sleep("10 millis")
+        yield* bus.publish(TestEvent.Ping, { value: 1 })
+        yield* Deferred.await(seen)
+      }).pipe(provideInstance(dir))
+
+      // Dispose from OUTSIDE the instance scope
+      yield* Effect.promise(() => Instance.disposeAll())
+      yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds"))
+
+      expect(types).toContain("test.effect.ping")
+      expect(types).toContain(Bus.InstanceDisposed.type)
+    }),
+  )
+})

+ 87 - 0
packages/opencode/test/bus/bus-integration.test.ts

@@ -0,0 +1,87 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import z from "zod"
+import { Bus } from "../../src/bus"
+import { BusEvent } from "../../src/bus/bus-event"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+const TestEvent = BusEvent.define("test.integration", z.object({ value: z.number() }))
+
+function withInstance(directory: string, fn: () => Promise<void>) {
+  return Instance.provide({ directory, fn })
+}
+
+describe("Bus integration: acquireRelease subscriber pattern", () => {
+  afterEach(() => Instance.disposeAll())
+
+  test("subscriber via callback facade receives events and cleans up on unsub", async () => {
+    await using tmp = await tmpdir()
+    const received: number[] = []
+
+    await withInstance(tmp.path, async () => {
+      const unsub = Bus.subscribe(TestEvent, (evt) => {
+        received.push(evt.properties.value)
+      })
+      await Bun.sleep(10)
+      await Bus.publish(TestEvent, { value: 1 })
+      await Bus.publish(TestEvent, { value: 2 })
+      await Bun.sleep(10)
+
+      expect(received).toEqual([1, 2])
+
+      unsub()
+      await Bun.sleep(10)
+      await Bus.publish(TestEvent, { value: 3 })
+      await Bun.sleep(10)
+
+      expect(received).toEqual([1, 2])
+    })
+  })
+
+  test("subscribeAll receives events from multiple types", async () => {
+    await using tmp = await tmpdir()
+    const received: Array<{ type: string; value?: number }> = []
+
+    const OtherEvent = BusEvent.define("test.other", z.object({ value: z.number() }))
+
+    await withInstance(tmp.path, async () => {
+      Bus.subscribeAll((evt) => {
+        received.push({ type: evt.type, value: evt.properties.value })
+      })
+      await Bun.sleep(10)
+      await Bus.publish(TestEvent, { value: 10 })
+      await Bus.publish(OtherEvent, { value: 20 })
+      await Bun.sleep(10)
+    })
+
+    expect(received).toEqual([
+      { type: "test.integration", value: 10 },
+      { type: "test.other", value: 20 },
+    ])
+  })
+
+  test("subscriber cleanup on instance disposal interrupts the stream", async () => {
+    await using tmp = await tmpdir()
+    const received: number[] = []
+    let disposed = false
+
+    await withInstance(tmp.path, async () => {
+      Bus.subscribeAll((evt) => {
+        if (evt.type === Bus.InstanceDisposed.type) {
+          disposed = true
+          return
+        }
+        received.push(evt.properties.value)
+      })
+      await Bun.sleep(10)
+      await Bus.publish(TestEvent, { value: 1 })
+      await Bun.sleep(10)
+    })
+
+    await Instance.disposeAll()
+    await Bun.sleep(50)
+
+    expect(received).toEqual([1])
+    expect(disposed).toBe(true)
+  })
+})

+ 219 - 0
packages/opencode/test/bus/bus.test.ts

@@ -0,0 +1,219 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import z from "zod"
+import { Bus } from "../../src/bus"
+import { BusEvent } from "../../src/bus/bus-event"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+const TestEvent = {
+  Ping: BusEvent.define("test.ping", z.object({ value: z.number() })),
+  Pong: BusEvent.define("test.pong", z.object({ message: z.string() })),
+}
+
+function withInstance(directory: string, fn: () => Promise<void>) {
+  return Instance.provide({ directory, fn })
+}
+
+describe("Bus", () => {
+  afterEach(() => Instance.disposeAll())
+
+  describe("publish + subscribe", () => {
+    test("subscriber is live immediately after subscribe returns", async () => {
+      await using tmp = await tmpdir()
+      const received: number[] = []
+
+      await withInstance(tmp.path, async () => {
+        Bus.subscribe(TestEvent.Ping, (evt) => {
+          received.push(evt.properties.value)
+        })
+        await Bus.publish(TestEvent.Ping, { value: 42 })
+        await Bun.sleep(10)
+      })
+
+      expect(received).toEqual([42])
+    })
+
+    test("subscriber receives matching events", async () => {
+      await using tmp = await tmpdir()
+      const received: number[] = []
+
+      await withInstance(tmp.path, async () => {
+        Bus.subscribe(TestEvent.Ping, (evt) => {
+          received.push(evt.properties.value)
+        })
+        // Give the subscriber fiber time to start consuming
+        await Bun.sleep(10)
+        await Bus.publish(TestEvent.Ping, { value: 42 })
+        await Bus.publish(TestEvent.Ping, { value: 99 })
+        // Give subscriber time to process
+        await Bun.sleep(10)
+      })
+
+      expect(received).toEqual([42, 99])
+    })
+
+    test("subscriber does not receive events of other types", async () => {
+      await using tmp = await tmpdir()
+      const pings: number[] = []
+
+      await withInstance(tmp.path, async () => {
+        Bus.subscribe(TestEvent.Ping, (evt) => {
+          pings.push(evt.properties.value)
+        })
+        await Bun.sleep(10)
+        await Bus.publish(TestEvent.Pong, { message: "hello" })
+        await Bus.publish(TestEvent.Ping, { value: 1 })
+        await Bun.sleep(10)
+      })
+
+      expect(pings).toEqual([1])
+    })
+
+    test("publish with no subscribers does not throw", async () => {
+      await using tmp = await tmpdir()
+
+      await withInstance(tmp.path, async () => {
+        await Bus.publish(TestEvent.Ping, { value: 1 })
+      })
+    })
+  })
+
+  describe("unsubscribe", () => {
+    test("unsubscribe stops delivery", async () => {
+      await using tmp = await tmpdir()
+      const received: number[] = []
+
+      await withInstance(tmp.path, async () => {
+        const unsub = Bus.subscribe(TestEvent.Ping, (evt) => {
+          received.push(evt.properties.value)
+        })
+        await Bun.sleep(10)
+        await Bus.publish(TestEvent.Ping, { value: 1 })
+        await Bun.sleep(10)
+        unsub()
+        await Bun.sleep(10)
+        await Bus.publish(TestEvent.Ping, { value: 2 })
+        await Bun.sleep(10)
+      })
+
+      expect(received).toEqual([1])
+    })
+  })
+
+  describe("subscribeAll", () => {
+    test("subscribeAll is live immediately after subscribe returns", async () => {
+      await using tmp = await tmpdir()
+      const received: string[] = []
+
+      await withInstance(tmp.path, async () => {
+        Bus.subscribeAll((evt) => {
+          received.push(evt.type)
+        })
+        await Bus.publish(TestEvent.Ping, { value: 1 })
+        await Bun.sleep(10)
+      })
+
+      expect(received).toEqual(["test.ping"])
+    })
+
+    test("receives all event types", async () => {
+      await using tmp = await tmpdir()
+      const received: string[] = []
+
+      await withInstance(tmp.path, async () => {
+        Bus.subscribeAll((evt) => {
+          received.push(evt.type)
+        })
+        await Bun.sleep(10)
+        await Bus.publish(TestEvent.Ping, { value: 1 })
+        await Bus.publish(TestEvent.Pong, { message: "hi" })
+        await Bun.sleep(10)
+      })
+
+      expect(received).toContain("test.ping")
+      expect(received).toContain("test.pong")
+    })
+  })
+
+  describe("multiple subscribers", () => {
+    test("all subscribers for same event type are called", async () => {
+      await using tmp = await tmpdir()
+      const a: number[] = []
+      const b: number[] = []
+
+      await withInstance(tmp.path, async () => {
+        Bus.subscribe(TestEvent.Ping, (evt) => {
+          a.push(evt.properties.value)
+        })
+        Bus.subscribe(TestEvent.Ping, (evt) => {
+          b.push(evt.properties.value)
+        })
+        await Bun.sleep(10)
+        await Bus.publish(TestEvent.Ping, { value: 7 })
+        await Bun.sleep(10)
+      })
+
+      expect(a).toEqual([7])
+      expect(b).toEqual([7])
+    })
+  })
+
+  describe("instance isolation", () => {
+    test("events in one directory do not reach subscribers in another", async () => {
+      await using tmpA = await tmpdir()
+      await using tmpB = await tmpdir()
+      const receivedA: number[] = []
+      const receivedB: number[] = []
+
+      await withInstance(tmpA.path, async () => {
+        Bus.subscribe(TestEvent.Ping, (evt) => {
+          receivedA.push(evt.properties.value)
+        })
+        await Bun.sleep(10)
+      })
+
+      await withInstance(tmpB.path, async () => {
+        Bus.subscribe(TestEvent.Ping, (evt) => {
+          receivedB.push(evt.properties.value)
+        })
+        await Bun.sleep(10)
+      })
+
+      await withInstance(tmpA.path, async () => {
+        await Bus.publish(TestEvent.Ping, { value: 1 })
+        await Bun.sleep(10)
+      })
+
+      await withInstance(tmpB.path, async () => {
+        await Bus.publish(TestEvent.Ping, { value: 2 })
+        await Bun.sleep(10)
+      })
+
+      expect(receivedA).toEqual([1])
+      expect(receivedB).toEqual([2])
+    })
+  })
+
+  describe("instance disposal", () => {
+    test("InstanceDisposed is delivered to wildcard subscribers before stream ends", async () => {
+      await using tmp = await tmpdir()
+      const received: string[] = []
+
+      await withInstance(tmp.path, async () => {
+        Bus.subscribeAll((evt) => {
+          received.push(evt.type)
+        })
+        await Bun.sleep(10)
+        await Bus.publish(TestEvent.Ping, { value: 1 })
+        await Bun.sleep(10)
+      })
+
+      // Instance.disposeAll triggers the finalizer which publishes InstanceDisposed
+      await Instance.disposeAll()
+      await Bun.sleep(50)
+
+      expect(received).toContain("test.ping")
+      expect(received).toContain(Bus.InstanceDisposed.type)
+    })
+  })
+})

+ 0 - 2
packages/opencode/test/config/config.test.ts

@@ -1400,7 +1400,6 @@ test("permission config preserves key order", async () => {
             external_directory: "ask",
             read: "allow",
             todowrite: "allow",
-            todoread: "allow",
             "thoughts_*": "allow",
             "reasoning_model_*": "allow",
             "tools_*": "allow",
@@ -1421,7 +1420,6 @@ test("permission config preserves key order", async () => {
         "external_directory",
         "read",
         "todowrite",
-        "todoread",
         "thoughts_*",
         "reasoning_model_*",
         "tools_*",

+ 4 - 4
packages/opencode/test/effect/run-service.test.ts

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

+ 22 - 10
packages/opencode/test/file/watcher.test.ts

@@ -2,9 +2,8 @@ import { $ } from "bun"
 import { afterEach, describe, expect, test } from "bun:test"
 import fs from "fs/promises"
 import path from "path"
-import { Deferred, Effect, Option } from "effect"
+import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect"
 import { tmpdir } from "../fixture/fixture"
-import { watcherConfigLayer, withServices } from "../fixture/instance"
 import { Bus } from "../../src/bus"
 import { FileWatcher } from "../../src/file/watcher"
 import { Instance } from "../../src/project/instance"
@@ -16,20 +15,33 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
 // Helpers
 // ---------------------------------------------------------------------------
 
+const watcherConfigLayer = ConfigProvider.layer(
+  ConfigProvider.fromUnknown({
+    OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
+    OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
+  }),
+)
+
 type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
 
 /** Run `body` with a live FileWatcher service. */
 function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
-  return withServices(
+  return Instance.provide({
     directory,
-    FileWatcher.layer,
-    async (rt) => {
-      await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
-      await Effect.runPromise(ready(directory))
-      await Effect.runPromise(body)
+    fn: async () => {
+      const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
+        Layer.provide(watcherConfigLayer),
+      )
+      const rt = ManagedRuntime.make(layer)
+      try {
+        await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
+        await Effect.runPromise(ready(directory))
+        await Effect.runPromise(body)
+      } finally {
+        await rt.dispose()
+      }
     },
-    { provide: [watcherConfigLayer] },
-  )
+  })
 }
 
 function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {

+ 68 - 0
packages/opencode/test/fixture/fixture.ts

@@ -2,7 +2,10 @@ import { $ } from "bun"
 import * as fs from "fs/promises"
 import os from "os"
 import path from "path"
+import { Effect, FileSystem, ServiceMap } from "effect"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import type { Config } from "../../src/config/config"
+import { Instance } from "../../src/project/instance"
 
 // Strip null bytes from paths (defensive fix for CI environment issues)
 function sanitizePath(p: string): string {
@@ -71,3 +74,68 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
   }
   return result
 }
+
+/** Effectful scoped tmpdir. Cleaned up when the scope closes. Make sure these stay in sync */
+export function tmpdirScoped(options?: { git?: boolean; config?: Partial<Config.Info> }) {
+  return Effect.gen(function* () {
+    const fs = yield* FileSystem.FileSystem
+    const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+    const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" })
+
+    const git = (...args: string[]) =>
+      spawner.spawn(ChildProcess.make("git", args, { cwd: dir })).pipe(Effect.flatMap((handle) => handle.exitCode))
+
+    if (options?.git) {
+      yield* git("init")
+      yield* git("config", "core.fsmonitor", "false")
+      yield* git("config", "user.email", "[email protected]")
+      yield* git("config", "user.name", "Test")
+      yield* git("commit", "--allow-empty", "-m", "root commit")
+    }
+
+    if (options?.config) {
+      yield* fs.writeFileString(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({ $schema: "https://opencode.ai/config.json", ...options.config }),
+      )
+    }
+
+    return dir
+  })
+}
+
+export const provideInstance =
+  (directory: string) =>
+  <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
+    Effect.servicesWith((services: ServiceMap.ServiceMap<R>) =>
+      Effect.promise<A>(async () =>
+        Instance.provide({
+          directory,
+          fn: () => Effect.runPromiseWith(services)(self),
+        }),
+      ),
+    )
+
+export function provideTmpdirInstance<A, E, R>(
+  self: (path: string) => Effect.Effect<A, E, R>,
+  options?: { git?: boolean; config?: Partial<Config.Info> },
+) {
+  return Effect.gen(function* () {
+    const path = yield* tmpdirScoped(options)
+    let provided = false
+
+    yield* Effect.addFinalizer(() =>
+      provided
+        ? Effect.promise(() =>
+            Instance.provide({
+              directory: path,
+              fn: () => Instance.dispose(),
+            }),
+          ).pipe(Effect.ignore)
+        : Effect.void,
+    )
+
+    provided = true
+    return yield* self(path).pipe(provideInstance(path))
+  })
+}

+ 0 - 51
packages/opencode/test/fixture/instance.ts

@@ -1,51 +0,0 @@
-import { ConfigProvider, Layer, ManagedRuntime } from "effect"
-import { InstanceContext } from "../../src/effect/instance-context"
-import { Instance } from "../../src/project/instance"
-
-/** ConfigProvider that enables the experimental file watcher. */
-export const watcherConfigLayer = ConfigProvider.layer(
-  ConfigProvider.fromUnknown({
-    OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
-    OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
-  }),
-)
-
-/**
- * Boot an Instance with the given service layers and run `body` with
- * the ManagedRuntime. Cleanup is automatic — the runtime is disposed
- * and Instance context is torn down when `body` completes.
- *
- * Layers may depend on InstanceContext (provided automatically).
- * Pass extra layers via `options.provide` (e.g. ConfigProvider.layer).
- */
-export function withServices<S>(
-  directory: string,
-  layer: Layer.Layer<S, any, InstanceContext>,
-  body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>,
-  options?: { provide?: Layer.Layer<never>[] },
-) {
-  return Instance.provide({
-    directory,
-    fn: async () => {
-      const ctx = Layer.sync(InstanceContext, () =>
-        InstanceContext.of({
-          directory: Instance.directory,
-          worktree: Instance.worktree,
-          project: Instance.project,
-        }),
-      )
-      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
-        }
-      }
-      const rt = ManagedRuntime.make(resolved)
-      try {
-        await body(rt)
-      } finally {
-        await rt.dispose()
-      }
-    },
-  })
-}

+ 171 - 161
packages/opencode/test/format/format.test.ts

@@ -1,172 +1,182 @@
-import { Effect } from "effect"
-import { afterEach, describe, expect, test } from "bun:test"
-import { tmpdir } from "../fixture/fixture"
-import { withServices } from "../fixture/instance"
-import { Bus } from "../../src/bus"
-import { File } from "../../src/file"
+import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
+import { describe, expect } from "bun:test"
+import { Effect, Layer } from "effect"
+import { provideTmpdirInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
 import { Format } from "../../src/format"
 import * as Formatter from "../../src/format/formatter"
-import { Instance } from "../../src/project/instance"
+
+const node = NodeChildProcessSpawner.layer.pipe(
+  Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
+)
+
+const it = testEffect(Layer.mergeAll(Format.layer, node))
 
 describe("Format", () => {
-  afterEach(async () => {
-    await Instance.disposeAll()
-  })
-
-  test("status() returns built-in formatters when no config overrides", async () => {
-    await using tmp = await tmpdir()
-
-    await withServices(tmp.path, Format.layer, async (rt) => {
-      const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
-      expect(Array.isArray(statuses)).toBe(true)
-      expect(statuses.length).toBeGreaterThan(0)
-
-      for (const s of statuses) {
-        expect(typeof s.name).toBe("string")
-        expect(Array.isArray(s.extensions)).toBe(true)
-        expect(typeof s.enabled).toBe("boolean")
-      }
-
-      const gofmt = statuses.find((s) => s.name === "gofmt")
-      expect(gofmt).toBeDefined()
-      expect(gofmt!.extensions).toContain(".go")
-    })
-  })
-
-  test("status() returns empty list when formatter is disabled", async () => {
-    await using tmp = await tmpdir({
-      config: { formatter: false },
-    })
-
-    await withServices(tmp.path, Format.layer, async (rt) => {
-      const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
-      expect(statuses).toEqual([])
-    })
-  })
-
-  test("status() excludes formatters marked as disabled in config", async () => {
-    await using tmp = await tmpdir({
-      config: {
-        formatter: {
-          gofmt: { disabled: true },
+  it.effect("status() returns built-in formatters when no config overrides", () =>
+    provideTmpdirInstance(() =>
+      Format.Service.use((fmt) =>
+        Effect.gen(function* () {
+          const statuses = yield* fmt.status()
+          expect(Array.isArray(statuses)).toBe(true)
+          expect(statuses.length).toBeGreaterThan(0)
+
+          for (const item of statuses) {
+            expect(typeof item.name).toBe("string")
+            expect(Array.isArray(item.extensions)).toBe(true)
+            expect(typeof item.enabled).toBe("boolean")
+          }
+
+          const gofmt = statuses.find((item) => item.name === "gofmt")
+          expect(gofmt).toBeDefined()
+          expect(gofmt!.extensions).toContain(".go")
+        }),
+      ),
+    ),
+  )
+
+  it.effect("status() returns empty list when formatter is disabled", () =>
+    provideTmpdirInstance(
+      () =>
+        Format.Service.use((fmt) =>
+          Effect.gen(function* () {
+            expect(yield* fmt.status()).toEqual([])
+          }),
+        ),
+      { config: { formatter: false } },
+    ),
+  )
+
+  it.effect("status() excludes formatters marked as disabled in config", () =>
+    provideTmpdirInstance(
+      () =>
+        Format.Service.use((fmt) =>
+          Effect.gen(function* () {
+            const statuses = yield* fmt.status()
+            const gofmt = statuses.find((item) => item.name === "gofmt")
+            expect(gofmt).toBeUndefined()
+          }),
+        ),
+      {
+        config: {
+          formatter: {
+            gofmt: { disabled: true },
+          },
         },
       },
-    })
-
-    await withServices(tmp.path, Format.layer, async (rt) => {
-      const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
-      const gofmt = statuses.find((s) => s.name === "gofmt")
-      expect(gofmt).toBeUndefined()
-    })
-  })
-
-  test("service initializes without error", async () => {
-    await using tmp = await tmpdir()
-
-    await withServices(tmp.path, Format.layer, async (rt) => {
-      await rt.runPromise(Format.Service.use(() => Effect.void))
-    })
-  })
-
-  test("status() initializes formatter state per directory", async () => {
-    await using off = await tmpdir({
-      config: { formatter: false },
-    })
-    await using on = await tmpdir()
-
-    const a = await Instance.provide({
-      directory: off.path,
-      fn: () => Format.status(),
-    })
-    const b = await Instance.provide({
-      directory: on.path,
-      fn: () => Format.status(),
-    })
-
-    expect(a).toEqual([])
-    expect(b.length).toBeGreaterThan(0)
-  })
-
-  test("runs enabled checks for matching formatters in parallel", async () => {
-    await using tmp = await tmpdir()
-
-    const file = `${tmp.path}/test.parallel`
-    await Bun.write(file, "x")
-
-    const one = {
-      extensions: Formatter.gofmt.extensions,
-      enabled: Formatter.gofmt.enabled,
-      command: Formatter.gofmt.command,
-    }
-    const two = {
-      extensions: Formatter.mix.extensions,
-      enabled: Formatter.mix.enabled,
-      command: Formatter.mix.command,
-    }
-
-    let active = 0
-    let max = 0
-
-    Formatter.gofmt.extensions = [".parallel"]
-    Formatter.mix.extensions = [".parallel"]
-    Formatter.gofmt.command = ["sh", "-c", "true"]
-    Formatter.mix.command = ["sh", "-c", "true"]
-    Formatter.gofmt.enabled = async () => {
-      active++
-      max = Math.max(max, active)
-      await Bun.sleep(20)
-      active--
-      return true
-    }
-    Formatter.mix.enabled = async () => {
-      active++
-      max = Math.max(max, active)
-      await Bun.sleep(20)
-      active--
-      return true
-    }
-
-    try {
-      await withServices(tmp.path, Format.layer, async (rt) => {
-        await rt.runPromise(Format.Service.use((s) => s.init()))
-        await Bus.publish(File.Event.Edited, { file })
+    ),
+  )
+
+  it.effect("service initializes without error", () =>
+    provideTmpdirInstance(() => Format.Service.use(() => Effect.void)),
+  )
+
+  it.effect("status() initializes formatter state per directory", () =>
+    Effect.gen(function* () {
+      const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), {
+        config: { formatter: false },
       })
-    } finally {
-      Formatter.gofmt.extensions = one.extensions
-      Formatter.gofmt.enabled = one.enabled
-      Formatter.gofmt.command = one.command
-      Formatter.mix.extensions = two.extensions
-      Formatter.mix.enabled = two.enabled
-      Formatter.mix.command = two.command
-    }
-
-    expect(max).toBe(2)
-  })
-
-  test("runs matching formatters sequentially for the same file", async () => {
-    await using tmp = await tmpdir({
-      config: {
-        formatter: {
-          first: {
-            command: ["sh", "-c", 'sleep 0.05; v=$(cat "$1"); printf \'%sA\' "$v" > "$1"', "sh", "$FILE"],
-            extensions: [".seq"],
-          },
-          second: {
-            command: ["sh", "-c", 'v=$(cat "$1"); printf \'%sB\' "$v" > "$1"', "sh", "$FILE"],
-            extensions: [".seq"],
+      const b = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()))
+
+      expect(a).toEqual([])
+      expect(b.length).toBeGreaterThan(0)
+    }),
+  )
+
+  it.effect("runs enabled checks for matching formatters in parallel", () =>
+    provideTmpdirInstance((path) =>
+      Effect.gen(function* () {
+        const file = `${path}/test.parallel`
+        yield* Effect.promise(() => Bun.write(file, "x"))
+
+        const one = {
+          extensions: Formatter.gofmt.extensions,
+          enabled: Formatter.gofmt.enabled,
+          command: Formatter.gofmt.command,
+        }
+        const two = {
+          extensions: Formatter.mix.extensions,
+          enabled: Formatter.mix.enabled,
+          command: Formatter.mix.command,
+        }
+
+        let active = 0
+        let max = 0
+
+        yield* Effect.acquireUseRelease(
+          Effect.sync(() => {
+            Formatter.gofmt.extensions = [".parallel"]
+            Formatter.mix.extensions = [".parallel"]
+            Formatter.gofmt.command = ["sh", "-c", "true"]
+            Formatter.mix.command = ["sh", "-c", "true"]
+            Formatter.gofmt.enabled = async () => {
+              active++
+              max = Math.max(max, active)
+              await Bun.sleep(20)
+              active--
+              return true
+            }
+            Formatter.mix.enabled = async () => {
+              active++
+              max = Math.max(max, active)
+              await Bun.sleep(20)
+              active--
+              return true
+            }
+          }),
+          () =>
+            Format.Service.use((fmt) =>
+              Effect.gen(function* () {
+                yield* fmt.init()
+                yield* fmt.file(file)
+              }),
+            ),
+          () =>
+            Effect.sync(() => {
+              Formatter.gofmt.extensions = one.extensions
+              Formatter.gofmt.enabled = one.enabled
+              Formatter.gofmt.command = one.command
+              Formatter.mix.extensions = two.extensions
+              Formatter.mix.enabled = two.enabled
+              Formatter.mix.command = two.command
+            }),
+        )
+
+        expect(max).toBe(2)
+      }),
+    ),
+  )
+
+  it.effect("runs matching formatters sequentially for the same file", () =>
+    provideTmpdirInstance(
+      (path) =>
+        Effect.gen(function* () {
+          const file = `${path}/test.seq`
+          yield* Effect.promise(() => Bun.write(file, "x"))
+
+          yield* Format.Service.use((fmt) =>
+            Effect.gen(function* () {
+              yield* fmt.init()
+              yield* fmt.file(file)
+            }),
+          )
+
+          expect(yield* Effect.promise(() => Bun.file(file).text())).toBe("xAB")
+        }),
+      {
+        config: {
+          formatter: {
+            first: {
+              command: ["sh", "-c", 'sleep 0.05; v=$(cat "$1"); printf \'%sA\' "$v" > "$1"', "sh", "$FILE"],
+              extensions: [".seq"],
+            },
+            second: {
+              command: ["sh", "-c", 'v=$(cat "$1"); printf \'%sB\' "$v" > "$1"', "sh", "$FILE"],
+              extensions: [".seq"],
+            },
           },
         },
       },
-    })
-
-    const file = `${tmp.path}/test.seq`
-    await Bun.write(file, "x")
-
-    await withServices(tmp.path, Format.layer, async (rt) => {
-      await rt.runPromise(Format.Service.use((s) => s.init()))
-      await Bus.publish(File.Event.Edited, { file })
-    })
-
-    expect(await Bun.file(file).text()).toBe("xAB")
-  })
+    ),
+  )
 })

+ 128 - 0
packages/opencode/test/git/git.test.ts

@@ -0,0 +1,128 @@
+import { $ } from "bun"
+import { describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { ManagedRuntime } from "effect"
+import { Git } from "../../src/git"
+import { tmpdir } from "../fixture/fixture"
+
+const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
+
+async function withGit<T>(body: (rt: ManagedRuntime.ManagedRuntime<Git.Service, never>) => Promise<T>) {
+  const rt = ManagedRuntime.make(Git.defaultLayer)
+  try {
+    return await body(rt)
+  } finally {
+    await rt.dispose()
+  }
+}
+
+describe("Git", () => {
+  test("branch() returns current branch name", async () => {
+    await using tmp = await tmpdir({ git: true })
+
+    await withGit(async (rt) => {
+      const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
+      expect(branch).toBeDefined()
+      expect(typeof branch).toBe("string")
+    })
+  })
+
+  test("branch() returns undefined for non-git directories", async () => {
+    await using tmp = await tmpdir()
+
+    await withGit(async (rt) => {
+      const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
+      expect(branch).toBeUndefined()
+    })
+  })
+
+  test("branch() returns undefined for detached HEAD", async () => {
+    await using tmp = await tmpdir({ git: true })
+    const hash = (await $`git rev-parse HEAD`.cwd(tmp.path).quiet().text()).trim()
+    await $`git checkout --detach ${hash}`.cwd(tmp.path).quiet()
+
+    await withGit(async (rt) => {
+      const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
+      expect(branch).toBeUndefined()
+    })
+  })
+
+  test("defaultBranch() uses init.defaultBranch when available", async () => {
+    await using tmp = await tmpdir({ git: true })
+    await $`git branch -M trunk`.cwd(tmp.path).quiet()
+    await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
+
+    await withGit(async (rt) => {
+      const branch = await rt.runPromise(Git.Service.use((git) => git.defaultBranch(tmp.path)))
+      expect(branch?.name).toBe("trunk")
+      expect(branch?.ref).toBe("trunk")
+    })
+  })
+
+  test("status() handles special filenames", async () => {
+    await using tmp = await tmpdir({ git: true })
+    await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
+
+    await withGit(async (rt) => {
+      const status = await rt.runPromise(Git.Service.use((git) => git.status(tmp.path)))
+      expect(status).toEqual(
+        expect.arrayContaining([
+          expect.objectContaining({
+            file: weird,
+            status: "added",
+          }),
+        ]),
+      )
+    })
+  })
+
+  test("diff(), stats(), and mergeBase() parse tracked changes", async () => {
+    await using tmp = await tmpdir({ git: true })
+    await $`git branch -M main`.cwd(tmp.path).quiet()
+    await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")
+    await $`git add .`.cwd(tmp.path).quiet()
+    await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
+    await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
+    await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")
+
+    await withGit(async (rt) => {
+      const [base, diff, stats] = await Promise.all([
+        rt.runPromise(Git.Service.use((git) => git.mergeBase(tmp.path, "main"))),
+        rt.runPromise(Git.Service.use((git) => git.diff(tmp.path, "HEAD"))),
+        rt.runPromise(Git.Service.use((git) => git.stats(tmp.path, "HEAD"))),
+      ])
+
+      expect(base).toBeTruthy()
+      expect(diff).toEqual(
+        expect.arrayContaining([
+          expect.objectContaining({
+            file: weird,
+            status: "modified",
+          }),
+        ]),
+      )
+      expect(stats).toEqual(
+        expect.arrayContaining([
+          expect.objectContaining({
+            file: weird,
+            additions: 1,
+            deletions: 1,
+          }),
+        ]),
+      )
+    })
+  })
+
+  test("show() returns empty text for binary blobs", async () => {
+    await using tmp = await tmpdir({ git: true })
+    await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3]))
+    await $`git add .`.cwd(tmp.path).quiet()
+    await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet()
+
+    await withGit(async (rt) => {
+      const text = await rt.runPromise(Git.Service.use((git) => git.show(tmp.path, "HEAD", "bin.dat")))
+      expect(text).toBe("")
+    })
+  })
+})

+ 660 - 0
packages/opencode/test/mcp/lifecycle.test.ts

@@ -0,0 +1,660 @@
+import { test, expect, mock, beforeEach } from "bun:test"
+
+// --- Mock infrastructure ---
+
+// Per-client state for controlling mock behavior
+interface MockClientState {
+  tools: Array<{ name: string; description?: string; inputSchema: object }>
+  listToolsCalls: number
+  listToolsShouldFail: boolean
+  listToolsError: string
+  listPromptsShouldFail: boolean
+  listResourcesShouldFail: boolean
+  prompts: Array<{ name: string; description?: string }>
+  resources: Array<{ name: string; uri: string; description?: string }>
+  closed: boolean
+  notificationHandlers: Map<unknown, (...args: any[]) => any>
+}
+
+const clientStates = new Map<string, MockClientState>()
+let lastCreatedClientName: string | undefined
+let connectShouldFail = false
+let connectError = "Mock transport cannot connect"
+// Tracks how many Client instances were created (detects leaks)
+let clientCreateCount = 0
+
+function getOrCreateClientState(name?: string): MockClientState {
+  const key = name ?? "default"
+  let state = clientStates.get(key)
+  if (!state) {
+    state = {
+      tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }],
+      listToolsCalls: 0,
+      listToolsShouldFail: false,
+      listToolsError: "listTools failed",
+      listPromptsShouldFail: false,
+      listResourcesShouldFail: false,
+      prompts: [],
+      resources: [],
+      closed: false,
+      notificationHandlers: new Map(),
+    }
+    clientStates.set(key, state)
+  }
+  return state
+}
+
+// Mock transport that succeeds or fails based on connectShouldFail
+class MockStdioTransport {
+  stderr: null = null
+  pid = 12345
+  constructor(_opts: any) {}
+  async start() {
+    if (connectShouldFail) throw new Error(connectError)
+  }
+  async close() {}
+}
+
+class MockStreamableHTTP {
+  constructor(_url: URL, _opts?: any) {}
+  async start() {
+    if (connectShouldFail) throw new Error(connectError)
+  }
+  async close() {}
+  async finishAuth() {}
+}
+
+class MockSSE {
+  constructor(_url: URL, _opts?: any) {}
+  async start() {
+    throw new Error("SSE fallback - not used in these tests")
+  }
+  async close() {}
+}
+
+mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({
+  StdioClientTransport: MockStdioTransport,
+}))
+
+mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
+  StreamableHTTPClientTransport: MockStreamableHTTP,
+}))
+
+mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
+  SSEClientTransport: MockSSE,
+}))
+
+mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
+  UnauthorizedError: class extends Error {
+    constructor() {
+      super("Unauthorized")
+    }
+  },
+}))
+
+// Mock Client that delegates to per-name MockClientState
+mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
+  Client: class MockClient {
+    _state!: MockClientState
+    transport: any
+
+    constructor(_opts: any) {
+      clientCreateCount++
+    }
+
+    async connect(transport: { start: () => Promise<void> }) {
+      this.transport = transport
+      await transport.start()
+      // After successful connect, bind to the last-created client name
+      this._state = getOrCreateClientState(lastCreatedClientName)
+    }
+
+    setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) {
+      this._state?.notificationHandlers.set(schema, handler)
+    }
+
+    async listTools() {
+      if (this._state) this._state.listToolsCalls++
+      if (this._state?.listToolsShouldFail) {
+        throw new Error(this._state.listToolsError)
+      }
+      return { tools: this._state?.tools ?? [] }
+    }
+
+    async listPrompts() {
+      if (this._state?.listPromptsShouldFail) {
+        throw new Error("listPrompts failed")
+      }
+      return { prompts: this._state?.prompts ?? [] }
+    }
+
+    async listResources() {
+      if (this._state?.listResourcesShouldFail) {
+        throw new Error("listResources failed")
+      }
+      return { resources: this._state?.resources ?? [] }
+    }
+
+    async close() {
+      if (this._state) this._state.closed = true
+    }
+  },
+}))
+
+beforeEach(() => {
+  clientStates.clear()
+  lastCreatedClientName = undefined
+  connectShouldFail = false
+  connectError = "Mock transport cannot connect"
+  clientCreateCount = 0
+})
+
+// Import after mocks
+const { MCP } = await import("../../src/mcp/index")
+const { Instance } = await import("../../src/project/instance")
+const { tmpdir } = await import("../fixture/fixture")
+
+// --- Helper ---
+
+function withInstance(config: Record<string, any>, fn: () => Promise<void>) {
+  return async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(
+          `${dir}/opencode.json`,
+          JSON.stringify({
+            $schema: "https://opencode.ai/config.json",
+            mcp: config,
+          }),
+        )
+      },
+    })
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        await fn()
+        // dispose instance to clean up state between tests
+        await Instance.dispose()
+      },
+    })
+  }
+}
+
+// ========================================================================
+// Test: tools() are cached after connect
+// ========================================================================
+
+test(
+  "tools() reuses cached tool definitions after connect",
+  withInstance({}, async () => {
+    lastCreatedClientName = "my-server"
+    const serverState = getOrCreateClientState("my-server")
+    serverState.tools = [
+      { name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } },
+    ]
+
+    // First: add the server successfully
+    const addResult = await MCP.add("my-server", {
+      type: "local",
+      command: ["echo", "test"],
+    })
+    expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected")
+
+    expect(serverState.listToolsCalls).toBe(1)
+
+    const toolsA = await MCP.tools()
+    const toolsB = await MCP.tools()
+    expect(Object.keys(toolsA).length).toBeGreaterThan(0)
+    expect(Object.keys(toolsB).length).toBeGreaterThan(0)
+    expect(serverState.listToolsCalls).toBe(1)
+  }),
+)
+
+// ========================================================================
+// Test: tool change notifications refresh the cache
+// ========================================================================
+
+test(
+  "tool change notifications refresh cached tool definitions",
+  withInstance({}, async () => {
+    lastCreatedClientName = "status-server"
+    const serverState = getOrCreateClientState("status-server")
+
+    await MCP.add("status-server", {
+      type: "local",
+      command: ["echo", "test"],
+    })
+
+    const before = await MCP.tools()
+    expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true)
+    expect(serverState.listToolsCalls).toBe(1)
+
+    serverState.tools = [{ name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }]
+
+    const handler = Array.from(serverState.notificationHandlers.values())[0]
+    expect(handler).toBeDefined()
+    await handler?.()
+
+    const after = await MCP.tools()
+    expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true)
+    expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false)
+    expect(serverState.listToolsCalls).toBe(2)
+  }),
+)
+
+// ========================================================================
+// Test: connect() / disconnect() lifecycle
+// ========================================================================
+
+test(
+  "disconnect sets status to disabled and removes client",
+  withInstance(
+    {
+      "disc-server": {
+        type: "local",
+        command: ["echo", "test"],
+      },
+    },
+    async () => {
+      lastCreatedClientName = "disc-server"
+      getOrCreateClientState("disc-server")
+
+      await MCP.add("disc-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
+
+      const statusBefore = await MCP.status()
+      expect(statusBefore["disc-server"]?.status).toBe("connected")
+
+      await MCP.disconnect("disc-server")
+
+      const statusAfter = await MCP.status()
+      expect(statusAfter["disc-server"]?.status).toBe("disabled")
+
+      // Tools should be empty after disconnect
+      const tools = await MCP.tools()
+      const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server"))
+      expect(serverTools.length).toBe(0)
+    },
+  ),
+)
+
+test(
+  "connect() after disconnect() re-establishes the server",
+  withInstance(
+    {
+      "reconn-server": {
+        type: "local",
+        command: ["echo", "test"],
+      },
+    },
+    async () => {
+      lastCreatedClientName = "reconn-server"
+      const serverState = getOrCreateClientState("reconn-server")
+      serverState.tools = [{ name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } }]
+
+      await MCP.add("reconn-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
+
+      await MCP.disconnect("reconn-server")
+      expect((await MCP.status())["reconn-server"]?.status).toBe("disabled")
+
+      // Reconnect
+      await MCP.connect("reconn-server")
+      expect((await MCP.status())["reconn-server"]?.status).toBe("connected")
+
+      const tools = await MCP.tools()
+      expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true)
+    },
+  ),
+)
+
+// ========================================================================
+// Test: add() closes existing client before replacing
+// ========================================================================
+
+test(
+  "add() closes the old client when replacing a server",
+  // Don't put the server in config — add it dynamically so we control
+  // exactly which client instance is "first" vs "second".
+  withInstance({}, async () => {
+    lastCreatedClientName = "replace-server"
+    const firstState = getOrCreateClientState("replace-server")
+
+    await MCP.add("replace-server", {
+      type: "local",
+      command: ["echo", "test"],
+    })
+
+    expect(firstState.closed).toBe(false)
+
+    // Create new state for second client
+    clientStates.delete("replace-server")
+    const secondState = getOrCreateClientState("replace-server")
+
+    // Re-add should close the first client
+    await MCP.add("replace-server", {
+      type: "local",
+      command: ["echo", "test"],
+    })
+
+    expect(firstState.closed).toBe(true)
+    expect(secondState.closed).toBe(false)
+  }),
+)
+
+// ========================================================================
+// Test: state init with mixed success/failure
+// ========================================================================
+
+test(
+  "init connects available servers even when one fails",
+  withInstance(
+    {
+      "good-server": {
+        type: "local",
+        command: ["echo", "good"],
+      },
+      "bad-server": {
+        type: "local",
+        command: ["echo", "bad"],
+      },
+    },
+    async () => {
+      // Set up good server
+      const goodState = getOrCreateClientState("good-server")
+      goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }]
+
+      // Set up bad server - will fail on listTools during create()
+      const badState = getOrCreateClientState("bad-server")
+      badState.listToolsShouldFail = true
+
+      // Add good server first
+      lastCreatedClientName = "good-server"
+      await MCP.add("good-server", {
+        type: "local",
+        command: ["echo", "good"],
+      })
+
+      // Add bad server - should fail but not affect good server
+      lastCreatedClientName = "bad-server"
+      await MCP.add("bad-server", {
+        type: "local",
+        command: ["echo", "bad"],
+      })
+
+      const status = await MCP.status()
+      expect(status["good-server"]?.status).toBe("connected")
+      expect(status["bad-server"]?.status).toBe("failed")
+
+      // Good server's tools should still be available
+      const tools = await MCP.tools()
+      expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true)
+    },
+  ),
+)
+
+// ========================================================================
+// Test: disabled server via config
+// ========================================================================
+
+test(
+  "disabled server is marked as disabled without attempting connection",
+  withInstance(
+    {
+      "disabled-server": {
+        type: "local",
+        command: ["echo", "test"],
+        enabled: false,
+      },
+    },
+    async () => {
+      const countBefore = clientCreateCount
+
+      await MCP.add("disabled-server", {
+        type: "local",
+        command: ["echo", "test"],
+        enabled: false,
+      } as any)
+
+      // No client should have been created
+      expect(clientCreateCount).toBe(countBefore)
+
+      const status = await MCP.status()
+      expect(status["disabled-server"]?.status).toBe("disabled")
+    },
+  ),
+)
+
+// ========================================================================
+// Test: prompts() and resources()
+// ========================================================================
+
+test(
+  "prompts() returns prompts from connected servers",
+  withInstance(
+    {
+      "prompt-server": {
+        type: "local",
+        command: ["echo", "test"],
+      },
+    },
+    async () => {
+      lastCreatedClientName = "prompt-server"
+      const serverState = getOrCreateClientState("prompt-server")
+      serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }]
+
+      await MCP.add("prompt-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
+
+      const prompts = await MCP.prompts()
+      expect(Object.keys(prompts).length).toBe(1)
+      const key = Object.keys(prompts)[0]
+      expect(key).toContain("prompt-server")
+      expect(key).toContain("my-prompt")
+    },
+  ),
+)
+
+test(
+  "resources() returns resources from connected servers",
+  withInstance(
+    {
+      "resource-server": {
+        type: "local",
+        command: ["echo", "test"],
+      },
+    },
+    async () => {
+      lastCreatedClientName = "resource-server"
+      const serverState = getOrCreateClientState("resource-server")
+      serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }]
+
+      await MCP.add("resource-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
+
+      const resources = await MCP.resources()
+      expect(Object.keys(resources).length).toBe(1)
+      const key = Object.keys(resources)[0]
+      expect(key).toContain("resource-server")
+      expect(key).toContain("my-resource")
+    },
+  ),
+)
+
+test(
+  "prompts() skips disconnected servers",
+  withInstance(
+    {
+      "prompt-disc-server": {
+        type: "local",
+        command: ["echo", "test"],
+      },
+    },
+    async () => {
+      lastCreatedClientName = "prompt-disc-server"
+      const serverState = getOrCreateClientState("prompt-disc-server")
+      serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }]
+
+      await MCP.add("prompt-disc-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
+
+      await MCP.disconnect("prompt-disc-server")
+
+      const prompts = await MCP.prompts()
+      expect(Object.keys(prompts).length).toBe(0)
+    },
+  ),
+)
+
+// ========================================================================
+// Test: connect() on nonexistent server
+// ========================================================================
+
+test(
+  "connect() on nonexistent server does not throw",
+  withInstance({}, async () => {
+    // Should not throw
+    await MCP.connect("nonexistent")
+    const status = await MCP.status()
+    expect(status["nonexistent"]).toBeUndefined()
+  }),
+)
+
+// ========================================================================
+// Test: disconnect() on nonexistent server
+// ========================================================================
+
+test(
+  "disconnect() on nonexistent server does not throw",
+  withInstance({}, async () => {
+    await MCP.disconnect("nonexistent")
+    // Should complete without error
+  }),
+)
+
+// ========================================================================
+// Test: tools() with no MCP servers configured
+// ========================================================================
+
+test(
+  "tools() returns empty when no MCP servers are configured",
+  withInstance({}, async () => {
+    const tools = await MCP.tools()
+    expect(Object.keys(tools).length).toBe(0)
+  }),
+)
+
+// ========================================================================
+// Test: connect failure during create()
+// ========================================================================
+
+test(
+  "server that fails to connect is marked as failed",
+  withInstance(
+    {
+      "fail-connect": {
+        type: "local",
+        command: ["echo", "test"],
+      },
+    },
+    async () => {
+      lastCreatedClientName = "fail-connect"
+      getOrCreateClientState("fail-connect")
+      connectShouldFail = true
+      connectError = "Connection refused"
+
+      await MCP.add("fail-connect", {
+        type: "local",
+        command: ["echo", "test"],
+      })
+
+      const status = await MCP.status()
+      expect(status["fail-connect"]?.status).toBe("failed")
+      if (status["fail-connect"]?.status === "failed") {
+        expect(status["fail-connect"].error).toContain("Connection refused")
+      }
+
+      // No tools should be available
+      const tools = await MCP.tools()
+      expect(Object.keys(tools).length).toBe(0)
+    },
+  ),
+)
+
+// ========================================================================
+// Bug #5: McpOAuthCallback.cancelPending uses wrong key
+// ========================================================================
+
+test("McpOAuthCallback.cancelPending is keyed by mcpName but pendingAuths uses oauthState", async () => {
+  const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
+
+  // Register a pending auth with an oauthState key, associated to an mcpName
+  const oauthState = "abc123hexstate"
+  const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, "my-mcp-server")
+
+  // cancelPending is called with mcpName — should find the entry via reverse index
+  McpOAuthCallback.cancelPending("my-mcp-server")
+
+  // The callback should still be pending because cancelPending looked up
+  // "my-mcp-server" in a map keyed by "abc123hexstate"
+  let resolved = false
+  let rejected = false
+  callbackPromise.then(() => (resolved = true)).catch(() => (rejected = true))
+
+  // Give it a tick
+  await new Promise((r) => setTimeout(r, 50))
+
+  // cancelPending("my-mcp-server") should have rejected the pending callback
+  expect(rejected).toBe(true)
+
+  await McpOAuthCallback.stop()
+})
+
+// ========================================================================
+// Test: multiple tools from same server get correct name prefixes
+// ========================================================================
+
+test(
+  "tools() prefixes tool names with sanitized server name",
+  withInstance(
+    {
+      "my.special-server": {
+        type: "local",
+        command: ["echo", "test"],
+      },
+    },
+    async () => {
+      lastCreatedClientName = "my.special-server"
+      const serverState = getOrCreateClientState("my.special-server")
+      serverState.tools = [
+        { name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } },
+        { name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } },
+      ]
+
+      await MCP.add("my.special-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
+
+      const tools = await MCP.tools()
+      const keys = Object.keys(tools)
+
+      // Server name dots should be replaced with underscores
+      expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true)
+      // Tool name dots should be replaced with underscores
+      expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true)
+      expect(keys.length).toBe(2)
+    },
+  ),
+)

+ 123 - 20
packages/opencode/test/project/vcs.test.ts

@@ -2,9 +2,7 @@ import { $ } from "bun"
 import { afterEach, describe, expect, test } from "bun:test"
 import fs from "fs/promises"
 import path from "path"
-import { Effect, Layer, ManagedRuntime } from "effect"
 import { tmpdir } from "../fixture/fixture"
-import { watcherConfigLayer, withServices } from "../fixture/instance"
 import { FileWatcher } from "../../src/file/watcher"
 import { Instance } from "../../src/project/instance"
 import { GlobalBus } from "../../src/bus/global"
@@ -17,24 +15,30 @@ const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe
 // Helpers
 // ---------------------------------------------------------------------------
 
-function withVcs(
-  directory: string,
-  body: (rt: ManagedRuntime.ManagedRuntime<FileWatcher.Service | Vcs.Service, never>) => Promise<void>,
-) {
-  return withServices(
+async function withVcs(directory: string, body: () => Promise<void>) {
+  return Instance.provide({
     directory,
-    Layer.merge(FileWatcher.layer, Vcs.layer),
-    async (rt) => {
-      await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
-      await rt.runPromise(Vcs.Service.use((s) => s.init()))
+    fn: async () => {
+      FileWatcher.init()
+      Vcs.init()
       await Bun.sleep(500)
-      await body(rt)
+      await body()
     },
-    { provide: [watcherConfigLayer] },
-  )
+  })
+}
+
+function withVcsOnly(directory: string, body: () => Promise<void>) {
+  return Instance.provide({
+    directory,
+    fn: async () => {
+      Vcs.init()
+      await body()
+    },
+  })
 }
 
 type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
+const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
 
 /** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */
 function nextBranchUpdate(directory: string, timeout = 10_000) {
@@ -74,8 +78,8 @@ describeVcs("Vcs", () => {
   test("branch() returns current branch name", async () => {
     await using tmp = await tmpdir({ git: true })
 
-    await withVcs(tmp.path, async (rt) => {
-      const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
+    await withVcs(tmp.path, async () => {
+      const branch = await Vcs.branch()
       expect(branch).toBeDefined()
       expect(typeof branch).toBe("string")
     })
@@ -84,8 +88,8 @@ describeVcs("Vcs", () => {
   test("branch() returns undefined for non-git directories", async () => {
     await using tmp = await tmpdir()
 
-    await withVcs(tmp.path, async (rt) => {
-      const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
+    await withVcs(tmp.path, async () => {
+      const branch = await Vcs.branch()
       expect(branch).toBeUndefined()
     })
   })
@@ -111,15 +115,114 @@ describeVcs("Vcs", () => {
     const branch = `test-${Math.random().toString(36).slice(2)}`
     await $`git branch ${branch}`.cwd(tmp.path).quiet()
 
-    await withVcs(tmp.path, async (rt) => {
+    await withVcs(tmp.path, async () => {
       const pending = nextBranchUpdate(tmp.path)
 
       const head = path.join(tmp.path, ".git", "HEAD")
       await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
 
       await pending
-      const current = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
+      const current = await Vcs.branch()
       expect(current).toBe(branch)
     })
   })
 })
+
+describe("Vcs diff", () => {
+  afterEach(async () => {
+    await Instance.disposeAll()
+  })
+
+  test("defaultBranch() falls back to main", async () => {
+    await using tmp = await tmpdir({ git: true })
+    await $`git branch -M main`.cwd(tmp.path).quiet()
+
+    await withVcsOnly(tmp.path, async () => {
+      const branch = await Vcs.defaultBranch()
+      expect(branch).toBe("main")
+    })
+  })
+
+  test("defaultBranch() uses init.defaultBranch when available", async () => {
+    await using tmp = await tmpdir({ git: true })
+    await $`git branch -M trunk`.cwd(tmp.path).quiet()
+    await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
+
+    await withVcsOnly(tmp.path, async () => {
+      const branch = await Vcs.defaultBranch()
+      expect(branch).toBe("trunk")
+    })
+  })
+
+  test("detects current branch from the active worktree", async () => {
+    await using tmp = await tmpdir({ git: true })
+    await using wt = await tmpdir()
+    await $`git branch -M main`.cwd(tmp.path).quiet()
+    const dir = path.join(wt.path, "feature")
+    await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet()
+
+    await withVcsOnly(dir, async () => {
+      const [branch, base] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
+      expect(branch).toBe("feature/test")
+      expect(base).toBe("main")
+    })
+  })
+
+  test("diff('git') returns uncommitted changes", async () => {
+    await using tmp = await tmpdir({ git: true })
+    await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8")
+    await $`git add .`.cwd(tmp.path).quiet()
+    await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
+    await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8")
+
+    await withVcsOnly(tmp.path, async () => {
+      const diff = await Vcs.diff("git")
+      expect(diff).toEqual(
+        expect.arrayContaining([
+          expect.objectContaining({
+            file: "file.txt",
+            status: "modified",
+          }),
+        ]),
+      )
+    })
+  })
+
+  test("diff('git') handles special filenames", async () => {
+    await using tmp = await tmpdir({ git: true })
+    await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
+
+    await withVcsOnly(tmp.path, async () => {
+      const diff = await Vcs.diff("git")
+      expect(diff).toEqual(
+        expect.arrayContaining([
+          expect.objectContaining({
+            file: weird,
+            status: "added",
+          }),
+        ]),
+      )
+    })
+  })
+
+  test("diff('branch') returns changes against default branch", async () => {
+    await using tmp = await tmpdir({ git: true })
+    await $`git branch -M main`.cwd(tmp.path).quiet()
+    await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
+    await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8")
+    await $`git add .`.cwd(tmp.path).quiet()
+    await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet()
+
+    await withVcsOnly(tmp.path, async () => {
+      const diff = await Vcs.diff("branch")
+      expect(diff).toEqual(
+        expect.arrayContaining([
+          expect.objectContaining({
+            file: "branch.txt",
+            status: "added",
+          }),
+        ]),
+      )
+    })
+  })
+})

+ 27 - 0
packages/opencode/test/session/message-v2.test.ts

@@ -927,4 +927,31 @@ describe("session.message-v2.fromError", () => {
       },
     })
   })
+
+  test("classifies ZlibError from fetch as retryable APIError", () => {
+    const zlibError = new Error(
+      'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()',
+    )
+    ;(zlibError as any).code = "ZlibError"
+    ;(zlibError as any).errno = 0
+    ;(zlibError as any).path = ""
+
+    const result = MessageV2.fromError(zlibError, { providerID })
+
+    expect(MessageV2.APIError.isInstance(result)).toBe(true)
+    expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
+    expect((result as MessageV2.APIError).data.message).toInclude("decompression")
+  })
+
+  test("classifies ZlibError as AbortedError when abort context is provided", () => {
+    const zlibError = new Error(
+      'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()',
+    )
+    ;(zlibError as any).code = "ZlibError"
+    ;(zlibError as any).errno = 0
+
+    const result = MessageV2.fromError(zlibError, { providerID, aborted: true })
+
+    expect(result.name).toBe("MessageAbortedError")
+  })
 })

+ 12 - 0
packages/opencode/test/session/retry.test.ts

@@ -125,6 +125,18 @@ describe("session.retry.retryable", () => {
 
     expect(SessionRetry.retryable(error)).toBeUndefined()
   })
+
+  test("retries ZlibError decompression failures", () => {
+    const error = new MessageV2.APIError({
+      message: "Response decompression failed",
+      isRetryable: true,
+      metadata: { code: "ZlibError" },
+    }).toObject() as MessageV2.APIError
+
+    const retryable = SessionRetry.retryable(error)
+    expect(retryable).toBeDefined()
+    expect(retryable).toBe("Response decompression failed")
+  })
 })
 
 describe("session.message-v2.fromError", () => {

+ 7 - 3
packages/opencode/test/sync/index.test.ts

@@ -110,10 +110,16 @@ describe("SyncEvent", () => {
           type: string
           properties: { id: string; name: string }
         }> = []
-        const unsub = Bus.subscribeAll((event) => events.push(event))
+        const received = new Promise<void>((resolve) => {
+          Bus.subscribeAll((event) => {
+            events.push(event)
+            resolve()
+          })
+        })
 
         SyncEvent.run(Created, { id: "evt_1", name: "test" })
 
+        await received
         expect(events).toHaveLength(1)
         expect(events[0]).toEqual({
           type: "item.created",
@@ -122,8 +128,6 @@ describe("SyncEvent", () => {
             name: "test",
           },
         })
-
-        unsub()
       }),
     )
   })

+ 0 - 7
packages/opencode/test/tool/edit.test.ts

@@ -89,7 +89,6 @@ describe("tool.edit", () => {
           const { FileWatcher } = await import("../../src/file/watcher")
 
           const events: string[] = []
-          const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
           const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
 
           const edit = await EditTool.init()
@@ -102,9 +101,7 @@ describe("tool.edit", () => {
             ctx,
           )
 
-          expect(events).toContain("edited")
           expect(events).toContain("updated")
-          unsubEdited()
           unsubUpdated()
         },
       })
@@ -305,11 +302,9 @@ describe("tool.edit", () => {
           await FileTime.read(ctx.sessionID, filepath)
 
           const { Bus } = await import("../../src/bus")
-          const { File } = await import("../../src/file")
           const { FileWatcher } = await import("../../src/file/watcher")
 
           const events: string[] = []
-          const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
           const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
 
           const edit = await EditTool.init()
@@ -322,9 +317,7 @@ describe("tool.edit", () => {
             ctx,
           )
 
-          expect(events).toContain("edited")
           expect(events).toContain("updated")
-          unsubEdited()
           unsubUpdated()
         },
       })

Некоторые файлы не были показаны из-за большого количества измененных файлов