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

refactor(core): migrate built-in tools to internal plugins (#34956)

Kit Langton 1 месяц назад
Родитель
Сommit
88dc960af8

+ 0 - 2
packages/core/src/location-services.ts

@@ -41,7 +41,6 @@ import { InstructionContext } from "./instruction-context"
 import { SystemContextBuiltIns } from "./system-context/builtins"
 import { SystemContextBuiltIns } from "./system-context/builtins"
 import { SessionContextEntry } from "./session/context-entry"
 import { SessionContextEntry } from "./session/context-entry"
 import { SessionInstructions } from "./session/instructions"
 import { SessionInstructions } from "./session/instructions"
-import { BuiltInTools } from "./tool/builtins"
 import { McpTool } from "./tool/mcp"
 import { McpTool } from "./tool/mcp"
 import { ReadToolFileSystem } from "./tool/read-filesystem"
 import { ReadToolFileSystem } from "./tool/read-filesystem"
 import { ToolRegistry } from "./tool/registry"
 import { ToolRegistry } from "./tool/registry"
@@ -89,7 +88,6 @@ export const locationServices = LayerNode.group([
   QuestionV2.node,
   QuestionV2.node,
   Generate.node,
   Generate.node,
   ReadToolFileSystem.node,
   ReadToolFileSystem.node,
-  BuiltInTools.node,
   McpTool.node,
   McpTool.node,
   SessionInstructions.node,
   SessionInstructions.node,
   SessionRunnerModel.node,
   SessionRunnerModel.node,

+ 47 - 0
packages/core/src/plugin/internal.ts

@@ -15,9 +15,11 @@ import { ConfigProviderPlugin } from "../config/plugin/provider"
 import { ConfigReferencePlugin } from "../config/plugin/reference"
 import { ConfigReferencePlugin } from "../config/plugin/reference"
 import { ConfigSkillPlugin } from "../config/plugin/skill"
 import { ConfigSkillPlugin } from "../config/plugin/skill"
 import { EventV2 } from "../event"
 import { EventV2 } from "../event"
+import { FileMutation } from "../file-mutation"
 import { FileSystem } from "../filesystem"
 import { FileSystem } from "../filesystem"
 import { FSUtil } from "../fs-util"
 import { FSUtil } from "../fs-util"
 import { Global } from "../global"
 import { Global } from "../global"
+import { Image } from "../image"
 import { Integration } from "../integration"
 import { Integration } from "../integration"
 import { Location } from "../location"
 import { Location } from "../location"
 import { LocationMutation } from "../location-mutation"
 import { LocationMutation } from "../location-mutation"
@@ -26,8 +28,11 @@ import { Npm } from "../npm"
 import { PluginV2 } from "../plugin"
 import { PluginV2 } from "../plugin"
 import { PluginRuntime } from "../plugin/runtime"
 import { PluginRuntime } from "../plugin/runtime"
 import { PermissionV2 } from "../permission"
 import { PermissionV2 } from "../permission"
+import { QuestionV2 } from "../question"
 import { Reference } from "../reference"
 import { Reference } from "../reference"
 import { Ripgrep } from "../ripgrep"
 import { Ripgrep } from "../ripgrep"
+import { SessionInstructions } from "../session/instructions"
+import { SessionTodo } from "../session/todo"
 import { Shell } from "../shell"
 import { Shell } from "../shell"
 import { SkillV2 } from "../skill"
 import { SkillV2 } from "../skill"
 import { State } from "../state"
 import { State } from "../state"
@@ -41,9 +46,20 @@ import { ProviderPlugins } from "./provider"
 import { SdkPlugins } from "./sdk"
 import { SdkPlugins } from "./sdk"
 import { SkillPlugin } from "./skill"
 import { SkillPlugin } from "./skill"
 import { VariantPlugin } from "./variant"
 import { VariantPlugin } from "./variant"
+import { ApplyPatchTool } from "../tool/apply-patch"
+import { EditTool } from "../tool/edit"
 import { GlobTool } from "../tool/glob"
 import { GlobTool } from "../tool/glob"
+import { GrepTool } from "../tool/grep"
+import { QuestionTool } from "../tool/question"
+import { ReadTool } from "../tool/read"
+import { ReadToolFileSystem } from "../tool/read-filesystem"
 import { ShellTool } from "../tool/shell"
 import { ShellTool } from "../tool/shell"
+import { SkillTool } from "../tool/skill"
 import { SubagentTool } from "../tool/subagent"
 import { SubagentTool } from "../tool/subagent"
+import { TodoWriteTool } from "../tool/todowrite"
+import { WebFetchTool } from "../tool/webfetch"
+import { WebSearchTool } from "../tool/websearch"
+import { WriteTool } from "../tool/write"
 
 
 export type Requirements =
 export type Requirements =
   | AgentV2.Service
   | AgentV2.Service
@@ -51,10 +67,12 @@ export type Requirements =
   | CommandV2.Service
   | CommandV2.Service
   | Config.Service
   | Config.Service
   | EventV2.Service
   | EventV2.Service
+  | FileMutation.Service
   | FileSystem.Service
   | FileSystem.Service
   | FSUtil.Service
   | FSUtil.Service
   | Global.Service
   | Global.Service
   | HttpClient.HttpClient
   | HttpClient.HttpClient
+  | Image.Service
   | Integration.Service
   | Integration.Service
   | Location.Service
   | Location.Service
   | LocationMutation.Service
   | LocationMutation.Service
@@ -62,11 +80,16 @@ export type Requirements =
   | Npm.Service
   | Npm.Service
   | PermissionV2.Service
   | PermissionV2.Service
   | PluginRuntime.Service
   | PluginRuntime.Service
+  | QuestionV2.Service
+  | ReadToolFileSystem.Service
   | Reference.Service
   | Reference.Service
   | Ripgrep.Service
   | Ripgrep.Service
+  | SessionInstructions.Service
+  | SessionTodo.Service
   | Shell.Service
   | Shell.Service
   | SkillV2.Service
   | SkillV2.Service
   | Tools.Service
   | Tools.Service
+  | WebSearchTool.ConfigService
 
 
 export interface Plugin<R = never> {
 export interface Plugin<R = never> {
   readonly id: string
   readonly id: string
@@ -96,13 +119,20 @@ const layer = Layer.effectDiscard(
       Context.make(Global.Service, yield* Global.Service),
       Context.make(Global.Service, yield* Global.Service),
       Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient),
       Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient),
       Context.make(LocationMutation.Service, yield* LocationMutation.Service),
       Context.make(LocationMutation.Service, yield* LocationMutation.Service),
+      Context.make(FileMutation.Service, yield* FileMutation.Service),
+      Context.make(Image.Service, yield* Image.Service),
       Context.make(PermissionV2.Service, yield* PermissionV2.Service),
       Context.make(PermissionV2.Service, yield* PermissionV2.Service),
+      Context.make(QuestionV2.Service, yield* QuestionV2.Service),
+      Context.make(ReadToolFileSystem.Service, yield* ReadToolFileSystem.Service),
+      Context.make(SessionInstructions.Service, yield* SessionInstructions.Service),
+      Context.make(SessionTodo.Service, yield* SessionTodo.Service),
       Context.make(SkillV2.Service, yield* SkillV2.Service),
       Context.make(SkillV2.Service, yield* SkillV2.Service),
       Context.make(Reference.Service, yield* Reference.Service),
       Context.make(Reference.Service, yield* Reference.Service),
       Context.make(Ripgrep.Service, yield* Ripgrep.Service),
       Context.make(Ripgrep.Service, yield* Ripgrep.Service),
       Context.make(Shell.Service, yield* Shell.Service),
       Context.make(Shell.Service, yield* Shell.Service),
       Context.make(Tools.Service, yield* Tools.Service),
       Context.make(Tools.Service, yield* Tools.Service),
       Context.make(PluginRuntime.Service, yield* PluginRuntime.Service),
       Context.make(PluginRuntime.Service, yield* PluginRuntime.Service),
+      Context.make(WebSearchTool.ConfigService, yield* WebSearchTool.ConfigService),
     )
     )
     const add = (input: Plugin<Requirements | Scope.Scope>) =>
     const add = (input: Plugin<Requirements | Scope.Scope>) =>
       plugin.add(PluginV2.ID.make(input.id), (context: PluginContext) =>
       plugin.add(PluginV2.ID.make(input.id), (context: PluginContext) =>
@@ -117,9 +147,19 @@ const layer = Layer.effectDiscard(
         yield* add(SkillPlugin.Plugin)
         yield* add(SkillPlugin.Plugin)
         yield* add(ModelsDevPlugin)
         yield* add(ModelsDevPlugin)
         yield* add(ConfigExternalPlugin.Plugin)
         yield* add(ConfigExternalPlugin.Plugin)
+        yield* add(ApplyPatchTool.Plugin)
+        yield* add(EditTool.Plugin)
         yield* add(GlobTool.Plugin)
         yield* add(GlobTool.Plugin)
+        yield* add(GrepTool.Plugin)
+        yield* add(QuestionTool.Plugin)
+        yield* add(ReadTool.Plugin)
         yield* add(ShellTool.Plugin)
         yield* add(ShellTool.Plugin)
+        yield* add(SkillTool.Plugin)
         yield* add(SubagentTool.Plugin)
         yield* add(SubagentTool.Plugin)
+        yield* add(TodoWriteTool.Plugin)
+        yield* add(WebFetchTool.Plugin)
+        yield* add(WebSearchTool.Plugin)
+        yield* add(WriteTool.Plugin)
         yield* add(ConfigAgentPlugin.Plugin)
         yield* add(ConfigAgentPlugin.Plugin)
         yield* add(ConfigCommandPlugin.Plugin)
         yield* add(ConfigCommandPlugin.Plugin)
         yield* add(ConfigSkillPlugin.Plugin)
         yield* add(ConfigSkillPlugin.Plugin)
@@ -145,6 +185,8 @@ export const node = makeLocationNode({
     Config.node,
     Config.node,
     Location.node,
     Location.node,
     LocationMutation.node,
     LocationMutation.node,
+    FileMutation.node,
+    Image.node,
     ModelsDev.node,
     ModelsDev.node,
     Npm.node,
     Npm.node,
     EventV2.node,
     EventV2.node,
@@ -153,6 +195,10 @@ export const node = makeLocationNode({
     Global.node,
     Global.node,
     httpClient,
     httpClient,
     PermissionV2.node,
     PermissionV2.node,
+    QuestionV2.node,
+    ReadToolFileSystem.node,
+    SessionInstructions.node,
+    SessionTodo.node,
     SkillV2.node,
     SkillV2.node,
     Reference.node,
     Reference.node,
     Ripgrep.node,
     Ripgrep.node,
@@ -160,5 +206,6 @@ export const node = makeLocationNode({
     ToolRegistry.toolsNode,
     ToolRegistry.toolsNode,
     PluginRuntime.node,
     PluginRuntime.node,
     SdkPlugins.node,
     SdkPlugins.node,
+    WebSearchTool.configNode,
   ],
   ],
 })
 })

+ 7 - 15
packages/core/src/tool/apply-patch.ts

@@ -1,18 +1,16 @@
 export * as ApplyPatchTool from "./apply-patch"
 export * as ApplyPatchTool from "./apply-patch"
 
 
+import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
 import { ToolFailure } from "@opencode-ai/llm"
 import { ToolFailure } from "@opencode-ai/llm"
 import { FileDiff } from "@opencode-ai/schema/file-diff"
 import { FileDiff } from "@opencode-ai/schema/file-diff"
 import { createTwoFilesPatch, diffLines } from "diff"
 import { createTwoFilesPatch, diffLines } from "diff"
-import { Effect, Layer, Schema } from "effect"
-import { makeLocationNode } from "../effect/app-node"
+import { Effect, Schema } from "effect"
 import { FileMutation } from "../file-mutation"
 import { FileMutation } from "../file-mutation"
 import { FSUtil } from "../fs-util"
 import { FSUtil } from "../fs-util"
 import { LocationMutation } from "../location-mutation"
 import { LocationMutation } from "../location-mutation"
 import { Patch } from "../patch"
 import { Patch } from "../patch"
 import { PermissionV2 } from "../permission"
 import { PermissionV2 } from "../permission"
-import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
-import { Tools } from "./tools"
 
 
 export const name = "apply_patch"
 export const name = "apply_patch"
 
 
@@ -56,15 +54,15 @@ type Prepared =
       readonly after: string
       readonly after: string
     })
     })
 
 
-const layer = Layer.effectDiscard(
-  Effect.gen(function* () {
-    const tools = yield* Tools.Service
+export const Plugin = {
+  id: "core-apply-patch-tool",
+  effect: Effect.fn("ApplyPatchTool.Plugin")(function* (ctx: PluginContext) {
     const mutation = yield* LocationMutation.Service
     const mutation = yield* LocationMutation.Service
     const files = yield* FileMutation.Service
     const files = yield* FileMutation.Service
     const fs = yield* FSUtil.Service
     const fs = yield* FSUtil.Service
     const permission = yield* PermissionV2.Service
     const permission = yield* PermissionV2.Service
 
 
-    yield* tools
+    yield* ctx.tool
       .register({
       .register({
         [name]: Tool.withPermission(
         [name]: Tool.withPermission(
           Tool.make({
           Tool.make({
@@ -194,13 +192,7 @@ const layer = Layer.effectDiscard(
       })
       })
       .pipe(Effect.orDie)
       .pipe(Effect.orDie)
   }),
   }),
-)
-
-export const node = makeLocationNode({
-  name: "tool/apply-patch",
-  layer,
-  deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
-})
+}
 
 
 function patchFile(change: Prepared): typeof FileDiff.Info.Type {
 function patchFile(change: Prepared): typeof FileDiff.Info.Type {
   const counts = diffLines(change.before, change.after).reduce(
   const counts = diffLines(change.before, change.after).reduce(

+ 0 - 51
packages/core/src/tool/builtins.ts

@@ -1,51 +0,0 @@
-export * as BuiltInTools from "./builtins"
-
-import { makeLocationNode } from "../effect/app-node"
-import { Context, Layer } from "effect"
-import { ApplyPatchTool } from "./apply-patch"
-import { EditTool } from "./edit"
-import { GrepTool } from "./grep"
-import { QuestionTool } from "./question"
-import { ReadTool } from "./read"
-import { ReadToolFileSystem } from "./read-filesystem"
-import { SkillTool } from "./skill"
-import { TodoWriteTool } from "./todowrite"
-import { WebFetchTool } from "./webfetch"
-import { WebSearchTool } from "./websearch"
-import { WriteTool } from "./write"
-
-export class Service extends Context.Service<Service, Record<string, never>>()("@opencode/v2/BuiltInTools") {}
-
-/**
- * Composes only the shipped Location-scoped built-in tool transforms.
- * Each tool retains its implementation and focused tests independently. Dynamic
- * MCP and plugin tools later use separate scoped canonical registrations, while
- * provider/model filtering belongs to a future materialization phase rather
- * than this static list. The caller intentionally supplies shared Location
- * services once to this merged set.
- *
- * TODO: Port the remaining launch-follow-up leaves deliberately: edit fuzzy
- * parity, task, LSP,
- * repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin
- * transforms separate from this static built-in list.
- */
-const layer = Layer.succeed(Service, Service.of({}))
-
-export const node = makeLocationNode({
-  service: Service,
-  layer,
-  deps: [
-    ApplyPatchTool.node,
-    EditTool.node,
-    GrepTool.node,
-    QuestionTool.node,
-    ReadTool.node,
-    ReadToolFileSystem.node,
-    SkillTool.node,
-    TodoWriteTool.node,
-    WebFetchTool.node,
-    WebSearchTool.node,
-    WebSearchTool.configNode,
-    WriteTool.node,
-  ],
-})

+ 7 - 15
packages/core/src/tool/edit.ts

@@ -6,18 +6,16 @@
  */
  */
 export * as EditTool from "./edit"
 export * as EditTool from "./edit"
 
 
+import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
 import { ToolFailure } from "@opencode-ai/llm"
 import { ToolFailure } from "@opencode-ai/llm"
 import { FileDiff } from "@opencode-ai/schema/file-diff"
 import { FileDiff } from "@opencode-ai/schema/file-diff"
 import { createTwoFilesPatch, diffLines } from "diff"
 import { createTwoFilesPatch, diffLines } from "diff"
-import { Effect, Layer, Schema } from "effect"
-import { makeLocationNode } from "../effect/app-node"
+import { Effect, Schema } from "effect"
 import { FileMutation } from "../file-mutation"
 import { FileMutation } from "../file-mutation"
 import { FSUtil } from "../fs-util"
 import { FSUtil } from "../fs-util"
 import { LocationMutation } from "../location-mutation"
 import { LocationMutation } from "../location-mutation"
 import { PermissionV2 } from "../permission"
 import { PermissionV2 } from "../permission"
-import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
-import { Tools } from "./tools"
 
 
 export const name = "edit"
 export const name = "edit"
 
 
@@ -87,15 +85,15 @@ export const toModelOutput = (output: Output, oldString: string, newString: stri
 // TODO: Add snapshots / undo after design exists.
 // TODO: Add snapshots / undo after design exists.
 // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
 // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
 
 
-const layer = Layer.effectDiscard(
-  Effect.gen(function* () {
-    const tools = yield* Tools.Service
+export const Plugin = {
+  id: "core-edit-tool",
+  effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
     const mutation = yield* LocationMutation.Service
     const mutation = yield* LocationMutation.Service
     const files = yield* FileMutation.Service
     const files = yield* FileMutation.Service
     const fs = yield* FSUtil.Service
     const fs = yield* FSUtil.Service
     const permission = yield* PermissionV2.Service
     const permission = yield* PermissionV2.Service
 
 
-    yield* tools
+    yield* ctx.tool
       .register({
       .register({
         [name]: Tool.withPermission(
         [name]: Tool.withPermission(
           Tool.make({
           Tool.make({
@@ -214,10 +212,4 @@ const layer = Layer.effectDiscard(
       })
       })
       .pipe(Effect.orDie)
       .pipe(Effect.orDie)
   }),
   }),
-)
-
-export const node = makeLocationNode({
-  name: "tool/edit",
-  layer,
-  deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
-})
+}

+ 7 - 15
packages/core/src/tool/grep.ts

@@ -1,18 +1,16 @@
 export * as GrepTool from "./grep"
 export * as GrepTool from "./grep"
 
 
+import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
 import { ToolFailure } from "@opencode-ai/llm"
 import { ToolFailure } from "@opencode-ai/llm"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Schema } from "effect"
 import path from "path"
 import path from "path"
-import { makeLocationNode } from "../effect/app-node"
 import { FileSystem } from "../filesystem"
 import { FileSystem } from "../filesystem"
 import { FSUtil } from "../fs-util"
 import { FSUtil } from "../fs-util"
 import { Location } from "../location"
 import { Location } from "../location"
 import { PermissionV2 } from "../permission"
 import { PermissionV2 } from "../permission"
 import { Ripgrep } from "../ripgrep"
 import { Ripgrep } from "../ripgrep"
 import { RelativePath } from "../schema"
 import { RelativePath } from "../schema"
-import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
-import { Tools } from "./tools"
 
 
 export const name = "grep"
 export const name = "grep"
 
 
@@ -50,15 +48,15 @@ export const toModelOutput = (output: ModelOutput) => {
 }
 }
 
 
 /** Grep leaf that defaults its filesystem root to the active Location. */
 /** Grep leaf that defaults its filesystem root to the active Location. */
-const layer = Layer.effectDiscard(
-  Effect.gen(function* () {
-    const tools = yield* Tools.Service
+export const Plugin = {
+  id: "core-grep-tool",
+  effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
     const fs = yield* FSUtil.Service
     const fs = yield* FSUtil.Service
     const ripgrep = yield* Ripgrep.Service
     const ripgrep = yield* Ripgrep.Service
     const location = yield* Location.Service
     const location = yield* Location.Service
     const permission = yield* PermissionV2.Service
     const permission = yield* PermissionV2.Service
 
 
-    yield* tools
+    yield* ctx.tool
       .register({
       .register({
         [name]: Tool.make({
         [name]: Tool.make({
           description:
           description:
@@ -128,10 +126,4 @@ const layer = Layer.effectDiscard(
       })
       })
       .pipe(Effect.orDie)
       .pipe(Effect.orDie)
   }),
   }),
-)
-
-export const node = makeLocationNode({
-  name: "tool/grep",
-  layer,
-  deps: [ToolRegistry.node, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
-})
+}

+ 7 - 15
packages/core/src/tool/question.ts

@@ -1,13 +1,11 @@
 export * as QuestionTool from "./question"
 export * as QuestionTool from "./question"
 
 
+import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
 import { ToolFailure } from "@opencode-ai/llm"
 import { ToolFailure } from "@opencode-ai/llm"
-import { Effect, Layer, Schema } from "effect"
-import { makeLocationNode } from "../effect/app-node"
+import { Effect, Schema } from "effect"
 import { PermissionV2 } from "../permission"
 import { PermissionV2 } from "../permission"
 import { QuestionV2 } from "../question"
 import { QuestionV2 } from "../question"
-import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
-import { Tools } from "./tools"
 
 
 export const name = "question"
 export const name = "question"
 
 
@@ -44,13 +42,13 @@ export const toModelOutput = (
   return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
   return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
 }
 }
 
 
-const layer = Layer.effectDiscard(
-  Effect.gen(function* () {
-    const tools = yield* Tools.Service
+export const Plugin = {
+  id: "core-question-tool",
+  effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
     const question = yield* QuestionV2.Service
     const question = yield* QuestionV2.Service
     const permission = yield* PermissionV2.Service
     const permission = yield* PermissionV2.Service
 
 
-    yield* tools
+    yield* ctx.tool
       .register({
       .register({
         [name]: Tool.make({
         [name]: Tool.make({
           description,
           description,
@@ -85,10 +83,4 @@ const layer = Layer.effectDiscard(
       })
       })
       .pipe(Effect.orDie)
       .pipe(Effect.orDie)
   }),
   }),
-)
-
-export const node = makeLocationNode({
-  name: "tool/question",
-  layer,
-  deps: [ToolRegistry.node, PermissionV2.node, QuestionV2.node],
-})
+}

+ 11 - 25
packages/core/src/tool/read.ts

@@ -1,9 +1,9 @@
 export * as ReadTool from "./read"
 export * as ReadTool from "./read"
 
 
+import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
 import { dirname } from "path"
 import { dirname } from "path"
 import { ToolFailure } from "@opencode-ai/llm"
 import { ToolFailure } from "@opencode-ai/llm"
-import { Effect, Layer, Schema } from "effect"
-import { makeLocationNode } from "../effect/app-node"
+import { Effect, Schema } from "effect"
 import { FileSystem } from "../filesystem"
 import { FileSystem } from "../filesystem"
 import { FSUtil } from "../fs-util"
 import { FSUtil } from "../fs-util"
 import { Image } from "../image"
 import { Image } from "../image"
@@ -13,9 +13,7 @@ import { PermissionV2 } from "../permission"
 import { SessionInstructions } from "../session/instructions"
 import { SessionInstructions } from "../session/instructions"
 import { AbsolutePath } from "../schema"
 import { AbsolutePath } from "../schema"
 import { ReadToolFileSystem } from "./read-filesystem"
 import { ReadToolFileSystem } from "./read-filesystem"
-import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
-import { Tools } from "./tools"
 
 
 export const name = "read"
 export const name = "read"
 const FILENAME = "AGENTS.md"
 const FILENAME = "AGENTS.md"
@@ -32,9 +30,9 @@ const LocationInput = Schema.Struct({
 const Input = LocationInput
 const Input = LocationInput
 const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
 const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
 
 
-const layer = Layer.effectDiscard(
-  Effect.gen(function* () {
-    const tools = yield* Tools.Service
+export const Plugin = {
+  id: "core-read-tool",
+  effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
     const reader = yield* ReadToolFileSystem.Service
     const reader = yield* ReadToolFileSystem.Service
     const mutation = yield* LocationMutation.Service
     const mutation = yield* LocationMutation.Service
     const image = yield* Image.Service
     const image = yield* Image.Service
@@ -43,7 +41,7 @@ const layer = Layer.effectDiscard(
     const fs = yield* FSUtil.Service
     const fs = yield* FSUtil.Service
     const location = yield* Location.Service
     const location = yield* Location.Service
 
 
-    yield* tools
+    yield* ctx.tool
       .register({
       .register({
         [name]: Tool.make({
         [name]: Tool.make({
           description:
           description:
@@ -111,7 +109,10 @@ const layer = Layer.effectDiscard(
                 const candidates = discovered.map(FSUtil.resolve).filter((file) => dirname(file) !== root)
                 const candidates = discovered.map(FSUtil.resolve).filter((file) => dirname(file) !== root)
                 if (candidates.length === 0) return
                 if (candidates.length === 0) return
                 yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
                 yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
-              }).pipe(Effect.catch(() => Effect.void), Effect.catchDefect(() => Effect.void))
+              }).pipe(
+                Effect.catch(() => Effect.void),
+                Effect.catchDefect(() => Effect.void),
+              )
               if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
               if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
                 return yield* image
                 return yield* image
                   .normalize(resource, { ...content, encoding: "base64" })
                   .normalize(resource, { ...content, encoding: "base64" })
@@ -137,19 +138,4 @@ const layer = Layer.effectDiscard(
       })
       })
       .pipe(Effect.orDie)
       .pipe(Effect.orDie)
   }),
   }),
-)
-
-export const node = makeLocationNode({
-  name: "tool/read",
-  layer,
-  deps: [
-    ToolRegistry.node,
-    ReadToolFileSystem.node,
-    LocationMutation.node,
-    Image.node,
-    PermissionV2.node,
-    SessionInstructions.node,
-    FSUtil.node,
-    Location.node,
-  ],
-})
+}

+ 7 - 15
packages/core/src/tool/skill.ts

@@ -1,15 +1,13 @@
 export * as SkillTool from "./skill"
 export * as SkillTool from "./skill"
 
 
+import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
 import path from "path"
 import path from "path"
 import { ToolFailure } from "@opencode-ai/llm"
 import { ToolFailure } from "@opencode-ai/llm"
-import { Effect, Layer, Schema } from "effect"
-import { makeLocationNode } from "../effect/app-node"
+import { Effect, Schema } from "effect"
 import { FSUtil } from "../fs-util"
 import { FSUtil } from "../fs-util"
 import { SkillV2 } from "../skill"
 import { SkillV2 } from "../skill"
 import { PermissionV2 } from "../permission"
 import { PermissionV2 } from "../permission"
-import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
-import { Tools } from "./tools"
 
 
 export const name = "skill"
 export const name = "skill"
 const FILE_LIMIT = 10
 const FILE_LIMIT = 10
@@ -54,13 +52,13 @@ export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray<string>)
 const unableToLoad = (name: string, error?: unknown) =>
 const unableToLoad = (name: string, error?: unknown) =>
   new ToolFailure({ message: `Unable to load skill ${name}`, error })
   new ToolFailure({ message: `Unable to load skill ${name}`, error })
 
 
-const layer = Layer.effectDiscard(
-  Effect.gen(function* () {
-    const tools = yield* Tools.Service
+export const Plugin = {
+  id: "core-skill-tool",
+  effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) {
     const fs = yield* FSUtil.Service
     const fs = yield* FSUtil.Service
     const skills = yield* SkillV2.Service
     const skills = yield* SkillV2.Service
     const permission = yield* PermissionV2.Service
     const permission = yield* PermissionV2.Service
-    yield* tools
+    yield* ctx.tool
       .register({
       .register({
         [name]: Tool.make({
         [name]: Tool.make({
           description,
           description,
@@ -100,10 +98,4 @@ const layer = Layer.effectDiscard(
       })
       })
       .pipe(Effect.orDie)
       .pipe(Effect.orDie)
   }),
   }),
-)
-
-export const node = makeLocationNode({
-  name: "tool/skill",
-  layer,
-  deps: [ToolRegistry.node, FSUtil.node, SkillV2.node, PermissionV2.node],
-})
+}

+ 7 - 15
packages/core/src/tool/todowrite.ts

@@ -1,13 +1,11 @@
 export * as TodoWriteTool from "./todowrite"
 export * as TodoWriteTool from "./todowrite"
 
 
+import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
 import { ToolFailure } from "@opencode-ai/llm"
 import { ToolFailure } from "@opencode-ai/llm"
-import { Effect, Layer, Schema } from "effect"
-import { makeLocationNode } from "../effect/app-node"
+import { Effect, Schema } from "effect"
 import { PermissionV2 } from "../permission"
 import { PermissionV2 } from "../permission"
 import { SessionTodo } from "../session/todo"
 import { SessionTodo } from "../session/todo"
-import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
-import { Tools } from "./tools"
 
 
 export const name = "todowrite"
 export const name = "todowrite"
 
 
@@ -22,13 +20,13 @@ export type Output = typeof Output.Type
 
 
 export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2)
 export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2)
 
 
-const layer = Layer.effectDiscard(
-  Effect.gen(function* () {
-    const tools = yield* Tools.Service
+export const Plugin = {
+  id: "core-todowrite-tool",
+  effect: Effect.fn("TodoWriteTool.Plugin")(function* (ctx: PluginContext) {
     const todos = yield* SessionTodo.Service
     const todos = yield* SessionTodo.Service
     const permission = yield* PermissionV2.Service
     const permission = yield* PermissionV2.Service
 
 
-    yield* tools
+    yield* ctx.tool
       .register({
       .register({
         [name]: Tool.make({
         [name]: Tool.make({
           description:
           description:
@@ -53,10 +51,4 @@ const layer = Layer.effectDiscard(
       })
       })
       .pipe(Effect.orDie)
       .pipe(Effect.orDie)
   }),
   }),
-)
-
-export const node = makeLocationNode({
-  name: "tool/todowrite",
-  layer,
-  deps: [ToolRegistry.node, PermissionV2.node, SessionTodo.node],
-})
+}

+ 7 - 16
packages/core/src/tool/webfetch.ts

@@ -1,17 +1,14 @@
 export * as WebFetchTool from "./webfetch"
 export * as WebFetchTool from "./webfetch"
 
 
+import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
 import { ToolFailure } from "@opencode-ai/llm"
 import { ToolFailure } from "@opencode-ai/llm"
-import { Duration, Effect, Layer, Schema } from "effect"
+import { Duration, Effect, Schema } from "effect"
 import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
 import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
 import { Parser } from "htmlparser2"
 import { Parser } from "htmlparser2"
 import TurndownService from "turndown"
 import TurndownService from "turndown"
-import { makeLocationNode } from "../effect/app-node"
-import { LayerNodePlatform } from "../effect/app-node-platform"
 import { PermissionV2 } from "../permission"
 import { PermissionV2 } from "../permission"
 import { collectBoundedResponseBody } from "./http-body"
 import { collectBoundedResponseBody } from "./http-body"
-import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
-import { Tools } from "./tools"
 
 
 export const name = "webfetch"
 export const name = "webfetch"
 export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
 export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
@@ -115,13 +112,13 @@ const convert = (content: string, contentType: string, format: Format) => {
   return content
   return content
 }
 }
 
 
-const layer = Layer.effectDiscard(
-  Effect.gen(function* () {
-    const tools = yield* Tools.Service
+export const Plugin = {
+  id: "core-webfetch-tool",
+  effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: PluginContext) {
     const http = yield* HttpClient.HttpClient
     const http = yield* HttpClient.HttpClient
     const permission = yield* PermissionV2.Service
     const permission = yield* PermissionV2.Service
 
 
-    yield* tools
+    yield* ctx.tool
       .register({
       .register({
         [name]: Tool.make({
         [name]: Tool.make({
           description,
           description,
@@ -178,13 +175,7 @@ const layer = Layer.effectDiscard(
       })
       })
       .pipe(Effect.orDie)
       .pipe(Effect.orDie)
   }),
   }),
-)
-
-export const node = makeLocationNode({
-  name: "tool/webfetch",
-  layer,
-  deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient],
-})
+}
 
 
 export function extractTextFromHTML(html: string) {
 export function extractTextFromHTML(html: string) {
   let text = ""
   let text = ""

+ 6 - 14
packages/core/src/tool/websearch.ts

@@ -1,19 +1,17 @@
 export * as WebSearchTool from "./websearch"
 export * as WebSearchTool from "./websearch"
 
 
+import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
 import { ToolFailure } from "@opencode-ai/llm"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Context, Duration, Effect, Layer, Schema } from "effect"
 import { Context, Duration, Effect, Layer, Schema } from "effect"
 import { HttpClient, HttpClientRequest } from "effect/unstable/http"
 import { HttpClient, HttpClientRequest } from "effect/unstable/http"
 import { makeLocationNode } from "../effect/app-node"
 import { makeLocationNode } from "../effect/app-node"
-import { LayerNodePlatform } from "../effect/app-node-platform"
 import { truthy } from "../flag/flag"
 import { truthy } from "../flag/flag"
 import { InstallationVersion } from "../installation/version"
 import { InstallationVersion } from "../installation/version"
 import { PositiveInt } from "../schema"
 import { PositiveInt } from "../schema"
 import { PermissionV2 } from "../permission"
 import { PermissionV2 } from "../permission"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
-import { Tools } from "./tools"
 import { collectBoundedResponseBody } from "./http-body"
 import { collectBoundedResponseBody } from "./http-body"
 import { checksum } from "../util/encode"
 import { checksum } from "../util/encode"
-import { ToolRegistry } from "./registry"
 
 
 export const name = "websearch"
 export const name = "websearch"
 export const NO_RESULTS = "No search results found. Please try a different query."
 export const NO_RESULTS = "No search results found. Please try a different query."
@@ -189,14 +187,14 @@ const Output = Schema.Struct({
   text: Schema.String,
   text: Schema.String,
 })
 })
 
 
-const layer = Layer.effectDiscard(
-  Effect.gen(function* () {
-    const tools = yield* Tools.Service
+export const Plugin = {
+  id: "core-websearch-tool",
+  effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
     const http = yield* HttpClient.HttpClient
     const http = yield* HttpClient.HttpClient
     const config = yield* ConfigService
     const config = yield* ConfigService
     const permission = yield* PermissionV2.Service
     const permission = yield* PermissionV2.Service
 
 
-    yield* tools
+    yield* ctx.tool
       .register({
       .register({
         [name]: Tool.make({
         [name]: Tool.make({
           description,
           description,
@@ -251,10 +249,4 @@ const layer = Layer.effectDiscard(
       })
       })
       .pipe(Effect.orDie)
       .pipe(Effect.orDie)
   }),
   }),
-)
-
-export const node = makeLocationNode({
-  name: "tool/websearch",
-  layer,
-  deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient, configNode],
-})
+}

+ 7 - 15
packages/core/src/tool/write.ts

@@ -6,15 +6,13 @@
  */
  */
 export * as WriteTool from "./write"
 export * as WriteTool from "./write"
 
 
+import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
 import { ToolFailure } from "@opencode-ai/llm"
 import { ToolFailure } from "@opencode-ai/llm"
-import { Effect, Layer, Schema } from "effect"
-import { makeLocationNode } from "../effect/app-node"
+import { Effect, Schema } from "effect"
 import { FileMutation } from "../file-mutation"
 import { FileMutation } from "../file-mutation"
 import { LocationMutation } from "../location-mutation"
 import { LocationMutation } from "../location-mutation"
 import { PermissionV2 } from "../permission"
 import { PermissionV2 } from "../permission"
-import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
-import { Tools } from "./tools"
 
 
 export const name = "write"
 export const name = "write"
 
 
@@ -44,14 +42,14 @@ export const toModelOutput = (output: Output) =>
 // TODO: Add snapshots / undo after design exists.
 // TODO: Add snapshots / undo after design exists.
 // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
 // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
 
 
-const layer = Layer.effectDiscard(
-  Effect.gen(function* () {
-    const tools = yield* Tools.Service
+export const Plugin = {
+  id: "core-write-tool",
+  effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
     const mutation = yield* LocationMutation.Service
     const mutation = yield* LocationMutation.Service
     const files = yield* FileMutation.Service
     const files = yield* FileMutation.Service
     const permission = yield* PermissionV2.Service
     const permission = yield* PermissionV2.Service
 
 
-    yield* tools
+    yield* ctx.tool
       .register({
       .register({
         [name]: Tool.withPermission(
         [name]: Tool.withPermission(
           Tool.make({
           Tool.make({
@@ -92,10 +90,4 @@ const layer = Layer.effectDiscard(
       })
       })
       .pipe(Effect.orDie)
       .pipe(Effect.orDie)
   }),
   }),
-)
-
-export const node = makeLocationNode({
-  name: "tool/write",
-  layer,
-  deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, PermissionV2.node],
-})
+}

+ 26 - 1
packages/core/test/lib/tool.ts

@@ -2,7 +2,9 @@ import { AgentV2 } from "@opencode-ai/core/agent"
 import type { PermissionV2 } from "@opencode-ai/core/permission"
 import type { PermissionV2 } from "@opencode-ai/core/permission"
 import { SessionMessage } from "@opencode-ai/core/session/message"
 import { SessionMessage } from "@opencode-ai/core/session/message"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
-import { Effect } from "effect"
+import { Tools } from "@opencode-ai/core/tool/tools"
+import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import { Effect, type Scope } from "effect"
 
 
 export const toolIdentity = {
 export const toolIdentity = {
   agent: AgentV2.ID.make("build"),
   agent: AgentV2.ID.make("build"),
@@ -34,6 +36,29 @@ export function waitForTool(
   })
   })
 }
 }
 
 
+/**
+ * Registers a core tool plugin's tools against the real registry without booting the
+ * full plugin host. Only the tool domain is live; focused tool tests exercise
+ * registration, materialization, and settlement through the same path production uses.
+ */
+export const registerToolPlugin = <R>(plugin: {
+  readonly id: string
+  readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
+}): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
+  Effect.gen(function* () {
+    const tools = yield* Tools.Service
+    const context: Pick<PluginContext, "tool"> = {
+      tool: {
+        register: tools.register,
+        execute: {
+          before: () => Effect.die("registerToolPlugin does not support tool hooks"),
+          after: () => Effect.die("registerToolPlugin does not support tool hooks"),
+        },
+      },
+    }
+    yield* plugin.effect(context as PluginContext)
+  })
+
 export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
 export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
   registry.materialize({ model }).pipe(Effect.flatMap((materialized) => materialized.settle(input)))
   registry.materialize({ model }).pipe(Effect.flatMap((materialized) => materialized.settle(input)))
 
 

+ 19 - 3
packages/core/test/location-layer.test.ts

@@ -73,9 +73,25 @@ describe("LocationServiceMap", () => {
               const catalog = yield* Catalog.Service
               const catalog = yield* Catalog.Service
               yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
               yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
               const registry = yield* ToolRegistry.Service
               const registry = yield* ToolRegistry.Service
-              yield* waitForTool(registry, "glob")
-              yield* waitForTool(registry, "shell")
-              yield* waitForTool(registry, "subagent")
+              // Tool plugins register during the forked PluginInternal boot; wait for
+              // every expected tool rather than relying on batch ordering.
+              yield* Effect.forEach(
+                [
+                  "edit",
+                  "glob",
+                  "grep",
+                  "question",
+                  "read",
+                  "shell",
+                  "skill",
+                  "subagent",
+                  "todowrite",
+                  "webfetch",
+                  "websearch",
+                  "write",
+                ],
+                (name) => waitForTool(registry, name),
+              )
               return {
               return {
                 providers: yield* catalog.provider.all(),
                 providers: yield* catalog.provider.all(),
                 tools: yield* toolDefinitions(registry),
                 tools: yield* toolDefinitions(registry),

+ 58 - 38
packages/core/test/session-instructions.test.ts

@@ -33,8 +33,24 @@ import { ToolHooks } from "@opencode-ai/core/tool/hooks"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { tempLocationLayer } from "./fixture/location"
 import { tempLocationLayer } from "./fixture/location"
+import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
 import { testEffect } from "./lib/effect"
 import { testEffect } from "./lib/effect"
-import { settleTool, testModel } from "./lib/tool"
+import { registerToolPlugin, settleTool, testModel } from "./lib/tool"
+
+const readToolNode = makeLocationNode({
+  name: "test/read-tool-plugin",
+  layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)),
+  deps: [
+    ToolRegistry.toolsNode,
+    ReadToolFileSystem.node,
+    LocationMutation.node,
+    Image.node,
+    PermissionV2.node,
+    SessionInstructions.node,
+    FSUtil.node,
+    Location.node,
+  ],
+})
 
 
 const projects = Layer.succeed(
 const projects = Layer.succeed(
   ProjectV2.Service,
   ProjectV2.Service,
@@ -69,7 +85,7 @@ const testLayer = AppNodeBuilder.build(
     FSUtil.node,
     FSUtil.node,
     LocationMutation.node,
     LocationMutation.node,
     ReadToolFileSystem.node,
     ReadToolFileSystem.node,
-    ReadTool.node,
+    readToolNode,
     ToolRegistry.node,
     ToolRegistry.node,
     ToolRegistry.toolsNode,
     ToolRegistry.toolsNode,
     ToolHooks.node,
     ToolHooks.node,
@@ -156,7 +172,9 @@ describe("SessionInstructions", () => {
       expect(firstInjected[0]!.text).toBe(
       expect(firstInjected[0]!.text).toBe(
         `Instructions from: ${deepPath}\ndeep-instructions\n\nInstructions from: ${subPath}\nsub-instructions`,
         `Instructions from: ${deepPath}\ndeep-instructions\n\nInstructions from: ${subPath}\nsub-instructions`,
       )
       )
-      expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, deepPath)}, ${path.relative(dir, subPath)}`)
+      expect(firstInjected[0]!.description).toBe(
+        `Loaded ${path.relative(dir, deepPath)}, ${path.relative(dir, subPath)}`,
+      )
       // The synthetic's metadata carries the durable dedup ledger.
       // The synthetic's metadata carries the durable dedup ledger.
       expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [deepPath, subPath] } })
       expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [deepPath, subPath] } })
       expect(firstInjected[0]!.text).not.toContain("root-instructions")
       expect(firstInjected[0]!.text).not.toContain("root-instructions")
@@ -192,47 +210,49 @@ describe("SessionInstructions", () => {
       // Seed the durable history with a prior synthetic that already claims sub's AGENTS.md
       // Seed the durable history with a prior synthetic that already claims sub's AGENTS.md
       // via the instruction metadata ledger.
       // via the instruction metadata ledger.
       yield* seedSynthetic(sessionID, [subPath])
       yield* seedSynthetic(sessionID, [subPath])
-      expect((yield* synthetics(sessionID))).toHaveLength(1)
+      expect(yield* synthetics(sessionID)).toHaveLength(1)
 
 
       yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt"))
       yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt"))
 
 
       // The durable claim on the prior synthetic prevents re-injection; no new synthetic.
       // The durable claim on the prior synthetic prevents re-injection; no new synthetic.
-      expect((yield* synthetics(sessionID))).toHaveLength(1)
+      expect(yield* synthetics(sessionID)).toHaveLength(1)
     }),
     }),
   )
   )
 
 
-  it.effect("discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read", () =>
-    Effect.gen(function* () {
-      const location = yield* Location.Service
-      const dir = location.directory
-      const rootPath = path.resolve(dir, "AGENTS.md")
-      const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md")
-      yield* mkdir(path.resolve(dir, "packages", "foo"))
-      yield* writeAgents(rootPath, "root-instructions")
-      yield* writeAgents(pkgPath, "pkg-instructions")
-      yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content"))
-
-      const session = yield* SessionV2.Service
-      const registry = yield* ToolRegistry.Service
-      const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
-
-      // Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
-      // the Location root (already supplied by the core/instructions baseline).
-      yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
-
-      const firstInjected = yield* synthetics(sessionID)
-      expect(firstInjected).toHaveLength(1)
-      expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`)
-      expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`)
-      expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } })
-      expect(firstInjected[0]!.text).not.toContain("root-instructions")
-
-      // A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is
-      // already injected for this session, so nothing new is emitted.
-      yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
-
-      expect((yield* synthetics(sessionID))).toHaveLength(1)
-    }),
+  it.effect(
+    "discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read",
+    () =>
+      Effect.gen(function* () {
+        const location = yield* Location.Service
+        const dir = location.directory
+        const rootPath = path.resolve(dir, "AGENTS.md")
+        const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md")
+        yield* mkdir(path.resolve(dir, "packages", "foo"))
+        yield* writeAgents(rootPath, "root-instructions")
+        yield* writeAgents(pkgPath, "pkg-instructions")
+        yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content"))
+
+        const session = yield* SessionV2.Service
+        const registry = yield* ToolRegistry.Service
+        const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
+
+        // Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
+        // the Location root (already supplied by the core/instructions baseline).
+        yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
+
+        const firstInjected = yield* synthetics(sessionID)
+        expect(firstInjected).toHaveLength(1)
+        expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`)
+        expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`)
+        expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } })
+        expect(firstInjected[0]!.text).not.toContain("root-instructions")
+
+        // A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is
+        // already injected for this session, so nothing new is emitted.
+        yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
+
+        expect(yield* synthetics(sessionID)).toHaveLength(1)
+      }),
   )
   )
 
 
   it.effect("listing the Location root directory injects no instructions", () =>
   it.effect("listing the Location root directory injects no instructions", () =>
@@ -253,7 +273,7 @@ describe("SessionInstructions", () => {
       // dropped by the dirname filter, and up() only walks upward so nested dirs are unseen.
       // dropped by the dirname filter, and up() only walks upward so nested dirs are unseen.
       yield* settleTool(registry, readCall(sessionID, "call-root-list", "."))
       yield* settleTool(registry, readCall(sessionID, "call-root-list", "."))
 
 
-      expect((yield* synthetics(sessionID))).toHaveLength(0)
+      expect(yield* synthetics(sessionID)).toHaveLength(0)
     }),
     }),
   )
   )
 
 

+ 9 - 2
packages/core/test/tool-apply-patch.test.ts

@@ -16,8 +16,15 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
 import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
 import { location } from "./fixture/location"
 import { location } from "./fixture/location"
 import { tmpdir } from "./fixture/tmpdir"
 import { tmpdir } from "./fixture/tmpdir"
+import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
 import { testEffect } from "./lib/effect"
 import { testEffect } from "./lib/effect"
-import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
+import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
+
+const applyPatchToolNode = makeLocationNode({
+  name: "test/apply-patch-tool-plugin",
+  layer: Layer.effectDiscard(registerToolPlugin(ApplyPatchTool.Plugin)),
+  deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
+})
 
 
 const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test")
 const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test")
 const assertions: PermissionV2.AssertInput[] = []
 const assertions: PermissionV2.AssertInput[] = []
@@ -101,7 +108,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
           ToolRegistry.toolsNode,
           ToolRegistry.toolsNode,
           LocationMutation.node,
           LocationMutation.node,
           FileMutation.node,
           FileMutation.node,
-          ApplyPatchTool.node,
+          applyPatchToolNode,
         ]),
         ]),
         [
         [
           [FSUtil.node, filesystem],
           [FSUtil.node, filesystem],

+ 9 - 2
packages/core/test/tool-edit.test.ts

@@ -17,8 +17,15 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { EditTool } from "@opencode-ai/core/tool/edit"
 import { EditTool } from "@opencode-ai/core/tool/edit"
 import { location } from "./fixture/location"
 import { location } from "./fixture/location"
 import { tmpdir } from "./fixture/tmpdir"
 import { tmpdir } from "./fixture/tmpdir"
+import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
 import { testEffect } from "./lib/effect"
 import { testEffect } from "./lib/effect"
-import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
+import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
+
+const editToolNode = makeLocationNode({
+  name: "test/edit-tool-plugin",
+  layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
+  deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
+})
 
 
 const sessionID = SessionV2.ID.make("ses_edit_tool_test")
 const sessionID = SessionV2.ID.make("ses_edit_tool_test")
 const assertions: PermissionV2.AssertInput[] = []
 const assertions: PermissionV2.AssertInput[] = []
@@ -91,7 +98,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
           ToolRegistry.toolsNode,
           ToolRegistry.toolsNode,
           LocationMutation.node,
           LocationMutation.node,
           FileMutation.node,
           FileMutation.node,
-          EditTool.node,
+          editToolNode,
         ]),
         ]),
         [
         [
           [FSUtil.node, filesystem],
           [FSUtil.node, filesystem],

+ 9 - 2
packages/core/test/tool-question.test.ts

@@ -9,7 +9,8 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { QuestionTool } from "@opencode-ai/core/tool/question"
 import { QuestionTool } from "@opencode-ai/core/tool/question"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { testEffect } from "./lib/effect"
 import { testEffect } from "./lib/effect"
-import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
+import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
+import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
 
 
 const sessionID = SessionV2.ID.make("ses_question_tool_test")
 const sessionID = SessionV2.ID.make("ses_question_tool_test")
 const assertions: PermissionV2.AssertInput[] = []
 const assertions: PermissionV2.AssertInput[] = []
@@ -43,8 +44,14 @@ const question = Layer.succeed(
     list: () => Effect.die("unused"),
     list: () => Effect.die("unused"),
   }),
   }),
 )
 )
+const questionToolNode = makeLocationNode({
+  name: "test/question-tool-plugin",
+  layer: Layer.effectDiscard(registerToolPlugin(QuestionTool.Plugin)),
+  deps: [ToolRegistry.toolsNode, PermissionV2.node, QuestionV2.node],
+})
+
 const it = testEffect(
 const it = testEffect(
-  AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [
+  AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, questionToolNode]), [
     [PermissionV2.node, permission],
     [PermissionV2.node, permission],
     [QuestionV2.node, question],
     [QuestionV2.node, question],
     [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
     [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],

+ 19 - 2
packages/core/test/tool-read.test.ts

@@ -19,8 +19,25 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { ReadTool } from "@opencode-ai/core/tool/read"
 import { ReadTool } from "@opencode-ai/core/tool/read"
 import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
 import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
+import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
+import { SessionInstructions } from "@opencode-ai/core/session/instructions"
 import { testEffect } from "./lib/effect"
 import { testEffect } from "./lib/effect"
-import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
+import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
+
+const readToolNode = makeLocationNode({
+  name: "test/read-tool-plugin",
+  layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)),
+  deps: [
+    ToolRegistry.toolsNode,
+    ReadToolFileSystem.node,
+    LocationMutation.node,
+    Image.node,
+    PermissionV2.node,
+    SessionInstructions.node,
+    FSUtil.node,
+    Location.node,
+  ],
+})
 
 
 const assertions: PermissionV2.AssertInput[] = []
 const assertions: PermissionV2.AssertInput[] = []
 const missingPath = "__missing_read_target__.txt"
 const missingPath = "__missing_read_target__.txt"
@@ -130,7 +147,7 @@ const unavailableImage = Layer.succeed(
   Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
   Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
 )
 )
 const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
 const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
-  AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, ReadTool.node]), [
+  AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, readToolNode]), [
     [ReadToolFileSystem.node, reader],
     [ReadToolFileSystem.node, reader],
     [PermissionV2.node, permission],
     [PermissionV2.node, permission],
     [Config.node, config],
     [Config.node, config],

+ 10 - 2
packages/core/test/tool-skill.test.ts

@@ -13,7 +13,15 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { tmpdir } from "./fixture/tmpdir"
 import { tmpdir } from "./fixture/tmpdir"
 import { it } from "./lib/effect"
 import { it } from "./lib/effect"
-import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
+import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
+import { FSUtil } from "@opencode-ai/core/fs-util"
+import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
+
+const skillToolNode = makeLocationNode({
+  name: "test/skill-tool-plugin",
+  layer: Layer.effectDiscard(registerToolPlugin(SkillTool.Plugin)),
+  deps: [ToolRegistry.toolsNode, FSUtil.node, SkillV2.node, PermissionV2.node],
+})
 
 
 const sessionID = SessionV2.ID.make("ses_skill_tool_test")
 const sessionID = SessionV2.ID.make("ses_skill_tool_test")
 
 
@@ -66,7 +74,7 @@ describe("SkillTool", () => {
             }),
             }),
           )
           )
           const skillToolLayer = AppNodeBuilder.build(
           const skillToolLayer = AppNodeBuilder.build(
-            LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, SkillTool.node]),
+            LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, skillToolNode]),
             [
             [
               [PermissionV2.node, permission],
               [PermissionV2.node, permission],
               [SkillV2.node, skills],
               [SkillV2.node, skills],

+ 9 - 2
packages/core/test/tool-todowrite.test.ts

@@ -15,7 +15,14 @@ import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { testEffect } from "./lib/effect"
 import { testEffect } from "./lib/effect"
-import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
+import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
+import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
+
+const todoWriteToolNode = makeLocationNode({
+  name: "test/todowrite-tool-plugin",
+  layer: Layer.effectDiscard(registerToolPlugin(TodoWriteTool.Plugin)),
+  deps: [ToolRegistry.toolsNode, PermissionV2.node, SessionTodo.node],
+})
 
 
 const sessionID = SessionV2.ID.make("ses_todowrite_tool_test")
 const sessionID = SessionV2.ID.make("ses_todowrite_tool_test")
 const assertions: PermissionV2.AssertInput[] = []
 const assertions: PermissionV2.AssertInput[] = []
@@ -43,7 +50,7 @@ const it = testEffect(
       SessionTodo.node,
       SessionTodo.node,
       ToolRegistry.node,
       ToolRegistry.node,
       ToolRegistry.toolsNode,
       ToolRegistry.toolsNode,
-      TodoWriteTool.node,
+      todoWriteToolNode,
     ]),
     ]),
     [
     [
       [PermissionV2.node, permission],
       [PermissionV2.node, permission],

+ 9 - 2
packages/core/test/tool-webfetch.test.ts

@@ -10,8 +10,15 @@ import { SessionV2 } from "@opencode-ai/core/session"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
 import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
+import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
 import { testEffect } from "./lib/effect"
 import { testEffect } from "./lib/effect"
-import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
+import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
+
+const webFetchToolNode = makeLocationNode({
+  name: "test/webfetch-tool-plugin",
+  layer: Layer.effectDiscard(registerToolPlugin(WebFetchTool.Plugin)),
+  deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient],
+})
 
 
 const sessionID = SessionV2.ID.make("ses_webfetch_test")
 const sessionID = SessionV2.ID.make("ses_webfetch_test")
 const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
 const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
@@ -40,7 +47,7 @@ const permission = Layer.succeed(
   }),
   }),
 )
 )
 const toolLayer = (replacements: LayerNode.Replacements = []) =>
 const toolLayer = (replacements: LayerNode.Replacements = []) =>
-  AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebFetchTool.node]), [
+  AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, webFetchToolNode]), [
     [PermissionV2.node, permission],
     [PermissionV2.node, permission],
     [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
     [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
     ...replacements,
     ...replacements,

+ 9 - 2
packages/core/test/tool-websearch.test.ts

@@ -9,8 +9,15 @@ import { SessionV2 } from "@opencode-ai/core/session"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
 import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
+import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
 import { testEffect } from "./lib/effect"
 import { testEffect } from "./lib/effect"
-import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
+import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
+
+const webSearchToolNode = makeLocationNode({
+  name: "test/websearch-tool-plugin",
+  layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
+  deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode],
+})
 
 
 const sessionID = SessionV2.ID.make("ses_websearch_test")
 const sessionID = SessionV2.ID.make("ses_websearch_test")
 const payload = (text: string) =>
 const payload = (text: string) =>
@@ -125,7 +132,7 @@ const websearchConfig = Layer.succeed(
 )
 )
 const it = testEffect(
 const it = testEffect(
   AppNodeBuilder.build(
   AppNodeBuilder.build(
-    LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, WebSearchTool.node]),
+    LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]),
     [
     [
       [PermissionV2.node, permission],
       [PermissionV2.node, permission],
       [LayerNodePlatform.httpClient, http],
       [LayerNodePlatform.httpClient, http],

+ 9 - 2
packages/core/test/tool-write.test.ts

@@ -17,8 +17,15 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { WriteTool } from "@opencode-ai/core/tool/write"
 import { WriteTool } from "@opencode-ai/core/tool/write"
 import { location } from "./fixture/location"
 import { location } from "./fixture/location"
 import { tmpdir } from "./fixture/tmpdir"
 import { tmpdir } from "./fixture/tmpdir"
+import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
 import { testEffect } from "./lib/effect"
 import { testEffect } from "./lib/effect"
-import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
+import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
+
+const writeToolNode = makeLocationNode({
+  name: "test/write-tool-plugin",
+  layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
+  deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, PermissionV2.node],
+})
 
 
 const sessionID = SessionV2.ID.make("ses_write_tool_test")
 const sessionID = SessionV2.ID.make("ses_write_tool_test")
 const assertions: PermissionV2.AssertInput[] = []
 const assertions: PermissionV2.AssertInput[] = []
@@ -75,7 +82,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
           ToolRegistry.toolsNode,
           ToolRegistry.toolsNode,
           LocationMutation.node,
           LocationMutation.node,
           FileMutation.node,
           FileMutation.node,
-          WriteTool.node,
+          writeToolNode,
         ]),
         ]),
         [
         [
           [FSUtil.node, filesystem],
           [FSUtil.node, filesystem],