Forráskód Böngészése

feat(core): attach global native tools (#30832)

Kit Langton 2 hónapja
szülő
commit
64dc6d39ab
39 módosított fájl, 647 hozzáadás és 71 törlés
  1. 3 1
      packages/core/src/location-layer.ts
  2. 1 0
      packages/core/src/public/index.ts
  3. 7 1
      packages/core/src/public/opencode.ts
  4. 19 0
      packages/core/src/public/tool.ts
  5. 1 1
      packages/core/src/session/runner/llm.ts
  6. 139 0
      packages/core/src/tool/AGENTS.md
  7. 51 0
      packages/core/src/tool/application-tools.ts
  8. 1 1
      packages/core/src/tool/apply-patch.ts
  9. 1 1
      packages/core/src/tool/bash.ts
  10. 1 1
      packages/core/src/tool/edit.ts
  11. 1 1
      packages/core/src/tool/glob.ts
  12. 1 1
      packages/core/src/tool/grep.ts
  13. 73 0
      packages/core/src/tool/native.ts
  14. 1 1
      packages/core/src/tool/question.ts
  15. 2 2
      packages/core/src/tool/read.ts
  16. 32 10
      packages/core/src/tool/registry.ts
  17. 1 1
      packages/core/src/tool/skill.ts
  18. 1 1
      packages/core/src/tool/todowrite.ts
  19. 1 1
      packages/core/src/tool/webfetch.ts
  20. 1 1
      packages/core/src/tool/websearch.ts
  21. 1 1
      packages/core/src/tool/write.ts
  22. 184 0
      packages/core/test/application-tools.test.ts
  23. 28 12
      packages/core/test/location-layer.test.ts
  24. 12 2
      packages/core/test/public-opencode.test.ts
  25. 2 2
      packages/core/test/session-runner-recorded.test.ts
  26. 2 2
      packages/core/test/session-runner-tool-registry.test.ts
  27. 55 2
      packages/core/test/session-runner.test.ts
  28. 2 2
      packages/core/test/tool-apply-patch.test.ts
  29. 2 2
      packages/core/test/tool-bash.test.ts
  30. 2 2
      packages/core/test/tool-edit.test.ts
  31. 2 2
      packages/core/test/tool-glob.test.ts
  32. 3 3
      packages/core/test/tool-grep.test.ts
  33. 2 2
      packages/core/test/tool-question.test.ts
  34. 2 2
      packages/core/test/tool-read.test.ts
  35. 2 2
      packages/core/test/tool-skill.test.ts
  36. 2 2
      packages/core/test/tool-todowrite.test.ts
  37. 2 2
      packages/core/test/tool-webfetch.test.ts
  38. 2 2
      packages/core/test/tool-websearch.test.ts
  39. 2 2
      packages/core/test/tool-write.test.ts

+ 3 - 1
packages/core/src/location-layer.ts

@@ -27,7 +27,8 @@ import { RepositoryCache } from "./repository-cache"
 import { Pty } from "./pty"
 import { SkillV2 } from "./skill"
 import { BuiltInTools } from "./tool/builtins"
-import { ToolRegistry } from "./tool-registry"
+import { ToolRegistry } from "./tool/registry"
+import { ApplicationTools } from "./tool/application-tools"
 import { ToolOutputStore } from "./tool-output-store"
 import { AppProcess } from "./process"
 import { Ripgrep } from "./ripgrep"
@@ -108,5 +109,6 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
     LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
     FetchHttpClient.layer,
     ToolOutputStore.defaultCleanupLayer,
+    ApplicationTools.layer,
   ],
 }) {}

+ 1 - 0
packages/core/src/public/index.ts

@@ -3,6 +3,7 @@ export { Agent } from "./agent"
 export { Model } from "./model"
 export { OpenCode } from "./opencode"
 export { Session } from "./session"
+export { Tool } from "./tool"
 export { Location } from "./location"
 export { Prompt } from "../session/prompt"
 export { AbsolutePath } from "../schema"

+ 7 - 1
packages/core/src/public/opencode.ts

@@ -9,10 +9,13 @@ import { SessionV2 } from "../session"
 import * as SessionExecutionLocal from "../session/execution/local"
 import { SessionProjector } from "../session/projector"
 import { SessionStore } from "../session/store"
+import { ApplicationTools } from "../tool/application-tools"
 import { Session } from "./session"
+import { Tool } from "./tool"
 
 export interface Interface {
   readonly sessions: Session.Interface
+  readonly tools: Tool.Service
 }
 
 /** Intentional public native API for Effect applications embedding OpenCode. */
@@ -28,13 +31,16 @@ const SessionsLayer = SessionV2.layer.pipe(
   Layer.provide(ProjectV2.defaultLayer),
   Layer.orDie,
 )
+const ApplicationToolsLayer = ApplicationTools.layer
 
 // TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
     const sessions = yield* SessionV2.Service
+    const tools = yield* ApplicationTools.Service
     return Service.of({
+      tools: { attach: tools.attach },
       sessions: {
         create: (input) =>
           sessions.create({
@@ -65,6 +71,6 @@ export const layer = Layer.effect(
       },
     })
   }),
-).pipe(Layer.provide(SessionsLayer))
+).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer)))
 
 // TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics.

+ 19 - 0
packages/core/src/public/tool.ts

@@ -0,0 +1,19 @@
+export * as Tool from "./tool"
+
+import { Effect, Scope } from "effect"
+import type { NativeTool } from "../tool/native"
+
+export { Failure, make } from "../tool/native"
+export type { Any, Content, Context, Executable } from "../tool/native"
+
+export interface Service {
+  /**
+   * Attach same-process tools to this OpenCode instance for the current Scope.
+   * Location tools with the same name take precedence where they are installed.
+   * Closing the Scope removes the tools immediately, so calls that have not
+   * started settling may fail because the tool is no longer available.
+   */
+  readonly attach: (
+    tools: Readonly<Record<string, NativeTool.Any>>,
+  ) => Effect.Effect<void, never, Scope.Scope>
+}

+ 1 - 1
packages/core/src/session/runner/llm.ts

@@ -9,7 +9,7 @@ import { SessionStore } from "../store"
 import { Service, StepLimitExceededError } from "./index"
 import { createLLMEventPublisher } from "./publish-llm-event"
 import { toLLMMessages } from "./to-llm-message"
-import { ToolRegistry } from "../../tool-registry"
+import { ToolRegistry } from "../../tool/registry"
 import { SessionRunnerModel } from "./model"
 import { Database } from "../../database/database"
 import { SessionInput } from "../input"

+ 139 - 0
packages/core/src/tool/AGENTS.md

@@ -0,0 +1,139 @@
+# Core Tool Architecture
+
+This folder owns Core-native tool definition, contribution, effective lookup, and execution. Keep those concerns distinct even though `ToolRegistry` brings them together at runtime.
+
+## Current Architecture
+
+```txt
+Public Tool.make      NativeTool value      ApplicationTools      Location built-ins       Location ToolRegistry                        Session runner
+        │                     │                     │                      │                         │                                         │
+        ├─ construct ─────────▶                     │                      │                         │                                         │
+        │                     │                     │                      │                         │                                         │
+        │                     ├─ scoped attach ─────▶                      │                         │                                         │
+        │                     │                     │                      │                         │                                         │
+        │                     │                     │                      ├─ scoped contributions ──▶                                         │
+        │                     │                     │                      │                         │                                         │
+        │                     │                     ├─ shared current entries ───────────────────────▶                                         │
+        │                     │                     │                      │                         │                                         │
+        │                     │                     │                      │                         ├─ effective definitions and settlement ──▶
+        │                     │                     │                      │                         │                                         │
+```
+
+There are three relevant representations:
+
+- `native.ts` defines the plain Core-native executable value exposed publicly as `Tool.make(...)`. It combines an `@opencode-ai/llm` model-facing definition with a Session-aware handler.
+- `application-tools.ts` stores process-scoped application contributions. It owns availability and scoped attachment, but it does not execute tools.
+- `registry.ts` is the single execution registry. Each Location owns one registry, its built-in contributions, effective precedence, input/output validation, permissions, and settlement.
+
+`ToolRegistry.Entry` is intentionally more powerful than the public native tool value. Internal Location tools may use Core-owned capabilities such as `assertPermission`; embedding applications receive only the narrow public execution context.
+
+## Placement And Layers
+
+- `ApplicationTools.Service` is process-scoped and must be shared by current and future Locations.
+- `ToolRegistry.Service` is Location-scoped because built-in handlers close over Location services such as filesystem, permissions, and tool-output storage.
+- `LocationServiceMap` constructs fresh Location services while receiving the shared `ApplicationTools.Service` as a dependency.
+- `OpenCode.layer` exposes the same shared application-tool service through `opencode.tools.attach(...)`.
+- `ToolRegistry.defaultLayer` creates isolated application-tool state. It is suitable for self-contained consumers and tests, but not when attachments must be shared with a separately constructed `LocationServiceMap`.
+
+Do not make `ToolRegistry` process-global. Do not move Location resources into `ApplicationTools`. Do not construct independent `ApplicationTools.layer` instances when the caller expects one attachment to appear across Locations.
+
+## Contribution And Precedence
+
+Built-in Location tools contribute through `ToolRegistry.contribute(...)`. Application tools attach through `ApplicationTools.attach(...)`, exposed publicly as `opencode.tools.attach(...)`.
+
+Both contribution mechanisms use `State` scoped transforms:
+
+- Closing a contribution Scope rebuilds state without that contribution.
+- A later same-name application attachment wins while active.
+- Closing that later attachment reveals the earlier active application contribution.
+- A Location tool always takes precedence over an application tool with the same name.
+- Application attachment inputs are captured before registering the replayable transform; later caller mutation must not alter a contribution during an unrelated rebuild.
+
+Do not introduce another application-specific tool type or registry. Plugins should contribute existing native tools or internal registry entries at the lifetime they actually own.
+
+## Dynamic Removal Semantics
+
+Definitions and settlement intentionally resolve the current effective tools independently. There is no provider-turn snapshot, attachment lease, or draining detach.
+
+```txt
+Embedding App               ApplicationTools       Location ToolRegistry                 Session Runner
+      │                             │                        │                                  │
+      ├─ attach({ opencord_run }) ──▶                        │                                  │
+      │                             │                        │                                  │
+      │                             │                        ◀─ definitions() ──────────────────┤
+      │                             │                        │                                  │
+      │                             ◀─ entries() ────────────┤                                  │
+      │                             │                        │                                  │
+      │                             │                        ├─ current effective definitions ──▶
+      │                             │                        │                                  │
+      ├─ attachment Scope closes ───▶                        │                                  │
+      │                             │                        │                                  │
+      │                             │                        ◀─ settle(opencord_run) ───────────┤
+      │                             │                        │                                  │
+      │                             ◀─ current lookup ───────┤                                  │
+      │                             │                        │                                  │
+      │                             │                        ├─ Unknown tool ───────────────────▶
+      │                             │                        │                                  │
+```
+
+Consequences of this choice:
+
+- Closing an attachment Scope revokes the tool immediately for calls that have not started settling.
+- A call produced from an earlier advertised definition may fail as unknown.
+- If a same-name replacement is currently active, a later call may execute that replacement.
+- An execution that already resolved its entry continues with the handler it captured.
+- Attachment Scope closure does not wait for already-started executions. Applications whose handlers depend on scoped resources must coordinate graceful shutdown themselves.
+
+These are deliberate simplifications. Do not add snapshots, semaphores, leases, or deferred finalizers without a concrete requirement for stronger consistency or graceful draining.
+
+## File Roles
+
+```txt
+tool/
+  native.ts             plain public/Core-native executable tool value
+  application-tools.ts  process-scoped State-backed application contributions
+  registry.ts           Location-scoped effective lookup, validation, and execution
+  builtins.ts           shipped Location tool layer composition
+  read.ts, bash.ts, ... individual Location-scoped built-in contributions
+```
+
+Keep model/provider-neutral tool schemas and output projection in `@opencode-ai/llm`. Keep Session identity, permissions, Location precedence, and settlement in Core.
+
+## Future Directions
+
+Tool availability may eventually gain a real third scope, such as Session-specific or plugin-owned contributions:
+
+```txt
+                                            ╭─────────────────╮
+                                            │ Tool definition │
+                                            ╰────────┬────────╯
+            ╭────────────────────────────────────────╰╮─ ─ ─ ─ ─ ─ ─ ─ future  ─ ─ ─ ─ ─ ─ ─ ─ ╮
+            │                                         │
+            ▼                                         ▼                                        ▼
+╭───────────────────────╮                ╭────────────────────────╮                ╭───────────────────────╮
+│ Process contributions │                │ Location contributions │                │ Session contributions │
+╰───────────┬───────────╯                ╰────────────┬───────────╯                ╰───────────┬───────────╯
+            │                                         │                                        │
+            │                                         │
+            ╰─────────────────────────────────────────◀─ ─ ─ ─ ─ ─ ─ ─  future ─ ─ ─ ─ ─ ─ ─ ─ ╯
+                                          ╭──────────────────────╮
+                                          │ Effective resolution │
+                                ╭─────────╰───────────┬──────────╯────────────╮
+                                │                     │                       │
+                                ▼                                             ▼
+                ╭───────────────────────────────╮                ╭─────────────────────────╮
+                │ Advertise current definitions │                │ Execute current handler │
+                ╰───────────────────────────────╯                ╰─────────────────────────╯
+```
+
+Prefer these directions only when a concrete use requires them:
+
+- **Contextual availability:** Add Session/agent/plugin filtering at effective resolution. Keep tool definitions independent from where they are enabled.
+- **Hierarchical overlays:** If a third contribution scope becomes real, consider one registry abstraction with process, Location, and Session overlays rather than adding another special registry service.
+- **Plugin tools:** Reuse the existing native tool value for restricted handlers and `ToolRegistry.Entry` for trusted Core-owned capabilities. Choose process or Location contribution lifetime explicitly.
+- **Stale-call rejection:** If executing a same-name replacement is unsafe, attach an identity/version to advertised definitions and reject stale calls without retaining removed handlers.
+- **Pinned provider turns:** If exact advertisement-to-execution consistency becomes necessary, snapshot effective entries for one provider turn. This weakens immediate revocation.
+- **Graceful plugin unload:** If attachment-owned resources must outlive started executions, add explicit execution draining. Keep this separate from whether new calls can discover the tool.
+- **Cluster placement:** `ApplicationTools` is process-global, not cluster-global. Cluster-wide contribution and execution ownership require a separate durable design.
+
+When choosing stronger semantics, state which property matters: immediate revocation, stale-call rejection, exact handler pinning, or graceful resource draining. They are different guarantees and should not arrive as one bundled lifecycle mechanism.

+ 51 - 0
packages/core/src/tool/application-tools.ts

@@ -0,0 +1,51 @@
+export * as ApplicationTools from "./application-tools"
+
+import { Context, Effect, Layer, Scope } from "effect"
+import { castDraft, enableMapSet } from "immer"
+import { State } from "../state"
+import { NativeTool } from "./native"
+
+type Data = {
+  readonly entries: Map<string, NativeTool.Any>
+}
+
+type Editor = {
+  readonly set: (name: string, tool: NativeTool.Any) => void
+}
+
+export interface Interface {
+  readonly attach: (tools: Readonly<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope>
+  readonly entries: () => ReadonlyMap<string, NativeTool.Any>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {}
+
+enableMapSet()
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const state = State.create<Data, Editor>({
+      initial: () => ({ entries: new Map() }),
+      editor: (draft) => ({
+        set: (name, tool) => {
+          draft.entries.set(
+            name,
+            castDraft(tool) as typeof draft.entries extends Map<string, infer Value> ? Value : never,
+          )
+        },
+      }),
+    })
+
+    return Service.of({
+      attach: Effect.fn("ApplicationTools.attach")(function* (tools) {
+        const entries = Object.entries(tools)
+        const transform = yield* state.transform()
+        yield* transform((editor) => {
+          for (const [name, tool] of entries) editor.set(name, tool)
+        })
+      }),
+      entries: () => state.get().entries,
+    })
+  }),
+)

+ 1 - 1
packages/core/src/tool/apply-patch.ts

@@ -6,7 +6,7 @@ import { FileMutation } from "../file-mutation"
 import { FSUtil } from "../fs-util"
 import { LocationMutation } from "../location-mutation"
 import { Patch } from "../patch"
-import { ToolRegistry } from "../tool-registry"
+import { ToolRegistry } from "./registry"
 
 export const name = "apply_patch"
 

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

@@ -10,7 +10,7 @@ import { LocationMutation } from "../location-mutation"
 import { AppProcess } from "../process"
 import { PositiveInt } from "../schema"
 import { ToolOutputStore } from "../tool-output-store"
-import { ToolRegistry } from "../tool-registry"
+import { ToolRegistry } from "./registry"
 
 export const name = "bash"
 export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000

+ 1 - 1
packages/core/src/tool/edit.ts

@@ -12,7 +12,7 @@ import { Cause, Effect, Layer, Schema } from "effect"
 import { FileMutation } from "../file-mutation"
 import { FSUtil } from "../fs-util"
 import { LocationMutation } from "../location-mutation"
-import { ToolRegistry } from "../tool-registry"
+import { ToolRegistry } from "./registry"
 
 export const name = "edit"
 

+ 1 - 1
packages/core/src/tool/glob.ts

@@ -4,7 +4,7 @@ import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
 import { Cause, Effect, Layer, Schema } from "effect"
 import { FileSystem } from "../filesystem"
 import { LocationSearch } from "../location-search"
-import { ToolRegistry } from "../tool-registry"
+import { ToolRegistry } from "./registry"
 
 export const name = "glob"
 

+ 1 - 1
packages/core/src/tool/grep.ts

@@ -5,7 +5,7 @@ import { Cause, Effect, Layer, Schema } from "effect"
 import { FileSystem } from "../filesystem"
 import { LocationSearch } from "../location-search"
 import { Ripgrep } from "../ripgrep"
-import { ToolRegistry } from "../tool-registry"
+import { ToolRegistry } from "./registry"
 
 export const name = "grep"
 

+ 73 - 0
packages/core/src/tool/native.ts

@@ -0,0 +1,73 @@
+export * as NativeTool from "./native"
+
+import { Tool, ToolFailure } from "@opencode-ai/llm"
+import { Effect, Schema } from "effect"
+import type { SessionSchema } from "../session/schema"
+
+export interface Context {
+  readonly sessionID: SessionSchema.ID
+  readonly id: string
+  readonly name: string
+}
+
+export type SchemaType<A> = Schema.Codec<A, any, never, never>
+
+export interface Executable<Parameters extends SchemaType<any>, Success extends SchemaType<any>> {
+  readonly definition: Tool.Tool<Parameters, Success>
+  readonly execute: (
+    parameters: Schema.Schema.Type<Parameters>,
+    context: Context,
+  ) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
+}
+
+export type Any = Executable<any, any>
+
+export const Failure = ToolFailure
+export type Failure = ToolFailure
+
+export type Content =
+  | { readonly type: "text"; readonly text: string }
+  | {
+      readonly type: "file"
+      readonly data: string
+      readonly mime: string
+      readonly name?: string
+    }
+
+export function make<Parameters extends SchemaType<any>, Success extends SchemaType<any>>(config: {
+  readonly description: string
+  readonly parameters: Parameters
+  readonly success: Success
+  readonly execute: (
+    parameters: Schema.Schema.Type<Parameters>,
+    context: Context,
+  ) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
+  readonly toModelOutput?: (input: {
+    readonly callID: string
+    readonly parameters: Schema.Schema.Type<Parameters>
+    readonly output: Success["Encoded"]
+  }) => ReadonlyArray<Content>
+}): Executable<Parameters, Success> {
+  const toModelOutput = config.toModelOutput
+  return {
+    definition: Tool.make({
+      description: config.description,
+      parameters: config.parameters,
+      success: config.success,
+      toModelOutput: toModelOutput
+        ? (input) =>
+            toModelOutput(input).map((content) =>
+              content.type === "text"
+                ? content
+                : {
+                    type: "file",
+                    source: { type: "data", data: content.data },
+                    mime: content.mime,
+                    name: content.name,
+                  },
+            )
+        : undefined,
+    }),
+    execute: config.execute,
+  }
+}

+ 1 - 1
packages/core/src/tool/question.ts

@@ -3,7 +3,7 @@ export * as QuestionTool from "./question"
 import { Tool, toolText } from "@opencode-ai/llm"
 import { Effect, Layer, Schema } from "effect"
 import { QuestionV2 } from "../question"
-import { ToolRegistry } from "../tool-registry"
+import { ToolRegistry } from "./registry"
 
 export const name = "question"
 

+ 2 - 2
packages/core/src/tool/read.ts

@@ -6,7 +6,7 @@ import { FileSystem } from "../filesystem"
 import { NonNegativeInt, PositiveInt } from "../schema"
 import { PermissionV2 } from "../permission"
 import { ToolOutputStore } from "../tool-output-store"
-import { ToolRegistry } from "../tool-registry"
+import { ToolRegistry } from "./registry"
 
 export const name = "read"
 const LocationInput = Schema.Struct({
@@ -93,7 +93,7 @@ export const layer = Layer.effectDiscard(
   }),
 )
 export const locationLayer = layer.pipe(
-  Layer.provideMerge(ToolRegistry.layer),
+  Layer.provideMerge(ToolRegistry.defaultLayer),
   Layer.provideMerge(FileSystem.locationLayer),
   Layer.provideMerge(PermissionV2.locationLayer),
   Layer.provideMerge(ToolOutputStore.defaultLayer),

+ 32 - 10
packages/core/src/tool-registry.ts → packages/core/src/tool/registry.ts

@@ -1,4 +1,4 @@
-export * as ToolRegistry from "./tool-registry"
+export * as ToolRegistry from "./registry"
 
 import {
   Tool,
@@ -13,10 +13,11 @@ import {
 } from "@opencode-ai/llm"
 import { Context, Effect, Layer, Schema, Scope } from "effect"
 import { castDraft, enableMapSet } from "immer"
-import { PermissionV2 } from "./permission"
-import { State } from "./state"
-import { SessionSchema } from "./session/schema"
-import type { SessionV2 } from "./session"
+import { PermissionV2 } from "../permission"
+import { State } from "../state"
+import { SessionSchema } from "../session/schema"
+import type { SessionV2 } from "../session"
+import { ApplicationTools } from "./application-tools"
 
 export type ExecuteInput = {
   readonly sessionID: SessionSchema.ID
@@ -86,6 +87,7 @@ export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
     const permission = yield* PermissionV2.Service
+    const applications = yield* ApplicationTools.Service
     const state = State.create<Data, Editor>({
       initial: () => ({ entries: new Map() }),
       editor: (draft) => ({
@@ -104,19 +106,36 @@ export const layer = Layer.effect(
     })
 
     const definitions = Effect.fn("ToolRegistry.definitions")(function* () {
-      return Tool.toDefinitions(
-        Object.fromEntries(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool])),
-      )
+      const tools = new Map(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool] as const))
+      // Location tools own their names. Application tools fill otherwise-unclaimed names.
+      for (const [name, tool] of applications.entries()) {
+        if (!tools.has(name)) tools.set(name, tool.definition)
+      }
+      return Tool.toDefinitions(Object.fromEntries(tools))
     })
 
+    const entry = (name: string): Entry | undefined => {
+      const local = state.get().entries.get(name)
+      if (local !== undefined) return local
+      const tool = applications.entries().get(name)
+      if (tool === undefined) return
+      return {
+        tool: tool.definition,
+        execute: ({ parameters, sessionID, call }) =>
+          tool.execute(parameters, { sessionID, id: call.id, name: call.name }),
+      }
+    }
+
     const invocation = (input: ExecuteInput): Invocation => ({
       ...input,
       // Source needs the durable owning assistant message ID, which the registry does not receive yet.
       assertPermission: (request) => permission.assert({ ...request, sessionID: input.sessionID }),
     })
 
-    const settle = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput) {
-      const entry = state.get().entries.get(input.call.name)
+    const settleEntry = Effect.fn("ToolRegistry.settleEntry")(function* (
+      entry: Entry | undefined,
+      input: ExecuteInput,
+    ) {
       if (!entry) return { result: { type: "error" as const, value: `Unknown tool: ${input.call.name}` } }
       if (!entry.execute && !entry.tool.execute)
         return { result: { type: "error" as const, value: `Tool has no execute handler: ${input.call.name}` } }
@@ -155,6 +174,7 @@ export const layer = Layer.effect(
       )
     })
 
+    const settle = Effect.fn("ToolRegistry.settle")((input: ExecuteInput) => settleEntry(entry(input.call.name), input))
     const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) {
       return (yield* settle(input)).result
     })
@@ -171,3 +191,5 @@ export const layer = Layer.effect(
     })
   }),
 )
+
+export const defaultLayer = layer.pipe(Layer.provide(ApplicationTools.layer))

+ 1 - 1
packages/core/src/tool/skill.ts

@@ -8,7 +8,7 @@ import { FSUtil } from "../fs-util"
 import { PluginBoot } from "../plugin/boot"
 import { SkillV2 } from "../skill"
 import { ToolOutputStore } from "../tool-output-store"
-import { ToolRegistry } from "../tool-registry"
+import { ToolRegistry } from "./registry"
 
 export const name = "skill"
 const FILE_LIMIT = 10

+ 1 - 1
packages/core/src/tool/todowrite.ts

@@ -3,7 +3,7 @@ export * as TodoWriteTool from "./todowrite"
 import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
 import { Cause, Effect, Layer, Schema } from "effect"
 import { SessionTodo } from "../session/todo"
-import { ToolRegistry } from "../tool-registry"
+import { ToolRegistry } from "./registry"
 
 export const name = "todowrite"
 

+ 1 - 1
packages/core/src/tool/webfetch.ts

@@ -6,7 +6,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
 import { Parser } from "htmlparser2"
 import TurndownService from "turndown"
 import { ToolOutputStore } from "../tool-output-store"
-import { ToolRegistry } from "../tool-registry"
+import { ToolRegistry } from "./registry"
 
 export const name = "webfetch"
 export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024

+ 1 - 1
packages/core/src/tool/websearch.ts

@@ -7,7 +7,7 @@ import { truthy } from "../flag/flag"
 import { InstallationVersion } from "../installation/version"
 import { PositiveInt } from "../schema"
 import { ToolOutputStore } from "../tool-output-store"
-import { ToolRegistry } from "../tool-registry"
+import { ToolRegistry } from "./registry"
 import { checksum } from "../util/encode"
 
 export const name = "websearch"

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

@@ -11,7 +11,7 @@ import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
 import { Cause, Effect, Layer, Schema } from "effect"
 import { FileMutation } from "../file-mutation"
 import { LocationMutation } from "../location-mutation"
-import { ToolRegistry } from "../tool-registry"
+import { ToolRegistry } from "./registry"
 
 export const name = "write"
 

+ 184 - 0
packages/core/test/application-tools.test.ts

@@ -0,0 +1,184 @@
+import { describe, expect } from "bun:test"
+import { Tool } from "@opencode-ai/core/public"
+import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
+import { PermissionV2 } from "@opencode-ai/core/permission"
+import { SessionV2 } from "@opencode-ai/core/session"
+import { ToolRegistry } from "@opencode-ai/core/tool/registry"
+import { Effect, Exit, Layer, Schema, Scope } from "effect"
+import { testEffect } from "./lib/effect"
+
+const permission = Layer.mock(PermissionV2.Service, {
+  assert: () => Effect.void,
+})
+const applications = ApplicationTools.layer
+const registry = ToolRegistry.layer.pipe(Layer.provide(permission), Layer.provide(applications))
+const it = testEffect(Layer.mergeAll(applications, registry))
+
+const sessionID = SessionV2.ID.make("ses_application_tool")
+const contextual = (contexts: Tool.Context[]) =>
+  Tool.make({
+    description: "Read application context",
+    parameters: Schema.Struct({ query: Schema.String }),
+    success: Schema.Struct({ answer: Schema.String }),
+    execute: ({ query }, context) =>
+      Effect.sync(() => {
+        contexts.push(context)
+        return { answer: query.toUpperCase() }
+      }),
+    toModelOutput: ({ output }) => [
+      { type: "text", text: output.answer },
+      { type: "file", data: "aGVsbG8=", mime: "image/png", name: "result.png" },
+    ],
+  })
+
+describe("ApplicationTools", () => {
+  it.effect("advertises and executes a scoped application tool with Session context", () =>
+    Effect.gen(function* () {
+      const applications = yield* ApplicationTools.Service
+      const registry = yield* ToolRegistry.Service
+      const contexts: Tool.Context[] = []
+
+      yield* applications.attach({ application_context: contextual(contexts) })
+
+      expect(yield* registry.definitions()).toMatchObject([
+        { name: "application_context", description: "Read application context" },
+      ])
+      expect(
+        yield* registry.settle({
+          sessionID,
+          call: { type: "tool-call", id: "call-context", name: "application_context", input: { query: "hello" } },
+        }),
+      ).toEqual({
+        result: {
+          type: "content",
+          value: [
+            { type: "text", text: "HELLO" },
+            { type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "result.png" },
+          ],
+        },
+        output: {
+          structured: { answer: "HELLO" },
+          content: [
+            { type: "text", text: "HELLO" },
+            { type: "file", source: { type: "data", data: "aGVsbG8=" }, mime: "image/png", name: "result.png" },
+          ],
+        },
+      })
+      expect(contexts).toEqual([{ sessionID, id: "call-context", name: "application_context" }])
+    }),
+  )
+
+  it.effect("removes an application tool when its attachment scope closes", () =>
+    Effect.gen(function* () {
+      const applications = yield* ApplicationTools.Service
+      const registry = yield* ToolRegistry.Service
+      const scope = yield* Scope.make()
+
+      yield* applications.attach({ temporary: contextual([]) }).pipe(Scope.provide(scope))
+      expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["temporary"])
+
+      yield* Scope.close(scope, Exit.void)
+      expect(yield* registry.definitions()).toEqual([])
+    }),
+  )
+
+  it.effect("removes a tool before settling a call produced from an earlier definition", () =>
+    Effect.gen(function* () {
+      const applications = yield* ApplicationTools.Service
+      const registry = yield* ToolRegistry.Service
+      const attachmentScope = yield* Scope.make()
+      yield* applications.attach({ contextual: contextual([]) }).pipe(Scope.provide(attachmentScope))
+      expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"])
+
+      yield* Scope.close(attachmentScope, Exit.void)
+      expect(
+        yield* registry.settle({
+          sessionID,
+          call: { type: "tool-call", id: "call-removed", name: "contextual", input: { query: "hello" } },
+        }),
+      ).toEqual({ result: { type: "error", value: "Unknown tool: contextual" } })
+    }),
+  )
+
+  it.effect("does not leak an attachment into an already closed scope", () =>
+    Effect.gen(function* () {
+      const applications = yield* ApplicationTools.Service
+      const registry = yield* ToolRegistry.Service
+      const scope = yield* Scope.make()
+      yield* Scope.close(scope, Exit.void)
+
+      yield* applications.attach({ closed: contextual([]) }).pipe(Scope.provide(scope))
+
+      expect(yield* registry.definitions()).toEqual([])
+    }),
+  )
+
+  it.effect("captures the attached record before later State rebuilds", () =>
+    Effect.gen(function* () {
+      const applications = yield* ApplicationTools.Service
+      const registry = yield* ToolRegistry.Service
+      const attached = { stable: contextual([]) }
+      yield* applications.attach(attached)
+      Object.assign(attached, { late: contextual([]) })
+
+      yield* Effect.scoped(applications.attach({ temporary: contextual([]) }))
+
+      expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["stable"])
+    }),
+  )
+
+  it.effect("settles with the current same-name application tool and restores earlier attachments", () =>
+    Effect.gen(function* () {
+      const applications = yield* ApplicationTools.Service
+      const registry = yield* ToolRegistry.Service
+      const firstContexts: Tool.Context[] = []
+      const secondContexts: Tool.Context[] = []
+      const scope = yield* Scope.make()
+      yield* applications.attach({ contextual: contextual(firstContexts) })
+      expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"])
+      yield* applications.attach({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope))
+
+      yield* registry.settle({
+        sessionID,
+        call: { type: "tool-call", id: "call-second", name: "contextual", input: { query: "second" } },
+      })
+      yield* Scope.close(scope, Exit.void)
+      yield* registry.settle({
+        sessionID,
+        call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } },
+      })
+
+      expect(secondContexts).toEqual([{ sessionID, id: "call-second", name: "contextual" }])
+      expect(firstContexts).toEqual([{ sessionID, id: "call-first", name: "contextual" }])
+    }),
+  )
+
+  it.effect("keeps the Location tool when an application tool has the same name", () =>
+    Effect.gen(function* () {
+      const applications = yield* ApplicationTools.Service
+      const registry = yield* ToolRegistry.Service
+      const transform = yield* registry.transform()
+      const locationContexts: Tool.Context[] = []
+      const applicationContexts: Tool.Context[] = []
+      const location = contextual(locationContexts)
+      yield* transform((editor) =>
+        editor.set("shared", {
+          tool: location.definition,
+          execute: ({ parameters, sessionID, call }) =>
+            location.execute(parameters, { sessionID, id: call.id, name: call.name }),
+        }),
+      )
+      yield* applications.attach({ shared: contextual(applicationContexts) })
+
+      expect((yield* registry.definitions()).map((definition) => definition.name)).toEqual(["shared"])
+      expect(
+        yield* registry.settle({
+          sessionID,
+          call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } },
+        }),
+      ).toMatchObject({ result: { type: "content" } })
+      expect(locationContexts).toEqual([{ sessionID, id: "call-shared", name: "shared" }])
+      expect(applicationContexts).toEqual([])
+    }),
+  )
+})

+ 28 - 12
packages/core/test/location-layer.test.ts

@@ -1,7 +1,8 @@
 import fs from "fs/promises"
 import path from "path"
 import { describe, expect } from "bun:test"
-import { Effect, Layer } from "effect"
+import { Effect, Layer, Schema } from "effect"
+import { Tool } from "@opencode-ai/core/public"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { LocationServiceMap } from "@opencode-ai/core/location-layer"
 import { PluginBoot } from "@opencode-ai/core/plugin/boot"
@@ -18,19 +19,24 @@ import { Npm } from "../src/npm"
 import { Project } from "../src/project"
 import { ProjectReference } from "../src/project-reference"
 import { LocationSearch } from "../src/location-search"
-import { ToolRegistry } from "../src/tool-registry"
+import { ToolRegistry } from "../src/tool/registry"
+import { ApplicationTools } from "../src/tool/application-tools"
 
+const applicationTools = ApplicationTools.layer
 const it = testEffect(
-  LocationServiceMap.layer.pipe(
-    Layer.provide(
-      Layer.mergeAll(
-        Project.defaultLayer,
-        EventV2.defaultLayer,
-        Auth.defaultLayer,
-        Npm.defaultLayer,
-        ModelsDev.defaultLayer,
-        FSUtil.defaultLayer,
-        Global.defaultLayer,
+  Layer.merge(
+    applicationTools,
+    LocationServiceMap.layer.pipe(
+      Layer.provide(
+        Layer.mergeAll(
+          Project.defaultLayer,
+          EventV2.defaultLayer,
+          Auth.defaultLayer,
+          Npm.defaultLayer,
+          ModelsDev.defaultLayer,
+          FSUtil.defaultLayer,
+          Global.defaultLayer,
+        ),
       ),
     ),
   ),
@@ -44,6 +50,14 @@ describe("LocationServiceMap", () => {
     ).pipe(
       Effect.flatMap(([blocked, allowed]) =>
         Effect.gen(function* () {
+          yield* (yield* ApplicationTools.Service).attach({
+            application_context: Tool.make({
+              description: "Read application context",
+              parameters: Schema.Struct({}),
+              success: Schema.Struct({ ok: Schema.Boolean }),
+              execute: () => Effect.succeed({ ok: true }),
+            }),
+          })
           yield* Effect.promise(() =>
             fs.writeFile(
               path.join(blocked.path, "opencode.json"),
@@ -70,6 +84,7 @@ describe("LocationServiceMap", () => {
           const blockedState = yield* update(blocked.path)
           expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false)
           expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
+            "application_context",
             "apply_patch",
             "bash",
             "edit",
@@ -86,6 +101,7 @@ describe("LocationServiceMap", () => {
           const allowedState = yield* update(allowed.path)
           expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true)
           expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
+            "application_context",
             "apply_patch",
             "bash",
             "edit",

+ 12 - 2
packages/core/test/public-opencode.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect } from "bun:test"
-import { Effect } from "effect"
-import { OpenCode, Session } from "@opencode-ai/core/public"
+import { Effect, Schema } from "effect"
+import { OpenCode, Session, Tool } from "@opencode-ai/core/public"
 import { testEffect } from "./lib/effect"
 
 const it = testEffect(OpenCode.layer)
@@ -10,6 +10,8 @@ describe("public native OpenCode API", () => {
     Effect.gen(function* () {
       const opencode = yield* OpenCode.Service
 
+      expect(Object.keys(opencode).sort()).toEqual(["sessions", "tools"])
+
       expect(Object.keys(opencode.sessions).sort()).toEqual([
         "context",
         "create",
@@ -23,6 +25,14 @@ describe("public native OpenCode API", () => {
       expect(Session.ID.create()).toStartWith("ses_")
       expect(Session.MessageID.create()).toStartWith("msg_")
       expect(yield* opencode.sessions.list()).toBeArray()
+      yield* opencode.tools.attach({
+        public_tool: Tool.make({
+          description: "Public tool",
+          parameters: Schema.Struct({}),
+          success: Schema.Struct({ ok: Schema.Boolean }),
+          execute: () => Effect.succeed({ ok: true }),
+        }),
+      })
     }),
   )
 })

+ 2 - 2
packages/core/test/session-runner-recorded.test.ts

@@ -16,7 +16,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
 import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
 import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
 import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
-import { ToolRegistry } from "@opencode-ai/core/tool-registry"
+import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { SessionTable } from "@opencode-ai/core/session/sql"
 import { SessionStore } from "@opencode-ai/core/session/store"
 import { describe, expect } from "bun:test"
@@ -46,7 +46,7 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
 const model = OpenAIChat.route
   .with({
     endpoint: { baseURL: "https://api.openai.com/v1" },

+ 2 - 2
packages/core/test/session-runner-tool-registry.test.ts

@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
 import { Tool, ToolFailure } from "@opencode-ai/llm"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { ToolRegistry } from "@opencode-ai/core/tool-registry"
+import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { Effect, Exit, Layer, Schema, Scope } from "effect"
 import { testEffect } from "./lib/effect"
 
@@ -24,7 +24,7 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
 const it = testEffect(Layer.mergeAll(permission, registry))
 
 const echo = Tool.make({

+ 55 - 2
packages/core/test/session-runner.test.ts

@@ -29,7 +29,9 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator
 import { SessionRunner } from "@opencode-ai/core/session/runner"
 import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
 import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
-import { ToolRegistry } from "@opencode-ai/core/tool-registry"
+import { ToolRegistry } from "@opencode-ai/core/tool/registry"
+import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
+import { NativeTool } from "@opencode-ai/core/tool/native"
 import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
 import { SessionStore } from "@opencode-ai/core/session/store"
 import { ModelV2 } from "@opencode-ai/core/model"
@@ -93,7 +95,8 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+const applications = ApplicationTools.layer
+const registry = ToolRegistry.layer.pipe(Layer.provide(permission), Layer.provide(applications))
 const echo = Layer.effectDiscard(
   ToolRegistry.Service.use((registry) =>
     registry.contribute((editor) => {
@@ -163,6 +166,7 @@ const it = testEffect(
     store,
     client,
     permission,
+    applications,
     registry,
     echo,
     models,
@@ -414,6 +418,55 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
   })
 
 describe("SessionRunnerLLM", () => {
+  it.effect("advertises and executes a globally attached application tool", () =>
+    Effect.gen(function* () {
+      yield* setup
+      const applicationTools = yield* ApplicationTools.Service
+      const session = yield* SessionV2.Service
+      const contexts: NativeTool.Context[] = []
+      yield* applicationTools.attach({
+        application_context: NativeTool.make({
+          description: "Read application context",
+          parameters: Schema.Struct({ query: Schema.String }),
+          success: Schema.Struct({ answer: Schema.String }),
+          execute: ({ query }, context) =>
+            Effect.sync(() => {
+              contexts.push(context)
+              return { answer: query.toUpperCase() }
+            }),
+        }),
+      })
+      yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Use application context" }), resume: false })
+      responses = [
+        [
+          LLMEvent.stepStart({ index: 0 }),
+          LLMEvent.toolCall({ id: "call-application", name: "application_context", input: { query: "hello" } }),
+          LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
+          LLMEvent.finish({ reason: "tool-calls" }),
+        ],
+        [],
+      ]
+
+      yield* session.resume(sessionID)
+
+      expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context")
+      expect(contexts).toEqual([{ sessionID, id: "call-application", name: "application_context" }])
+      expect(yield* session.context(sessionID)).toMatchObject([
+        { type: "user", text: "Use application context" },
+        {
+          type: "assistant",
+          content: [
+            {
+              type: "tool",
+              id: "call-application",
+              state: { status: "completed", structured: { answer: "HELLO" } },
+            },
+          ],
+        },
+      ])
+    }),
+  )
+
   it.effect("starts a real runner turn after default prompt recording", () =>
     Effect.gen(function* () {
       yield* setup

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

@@ -9,7 +9,7 @@ import { LocationMutation } from "@opencode-ai/core/location-mutation"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { ToolRegistry } from "@opencode-ai/core/tool-registry"
+import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
 import { location } from "./fixture/location"
 import { tmpdir } from "./fixture/tmpdir"
@@ -86,7 +86,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
   )
   const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
   const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
-  const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+  const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
   const patch = ApplyPatchTool.layer.pipe(
     Layer.provide(registry),
     Layer.provide(planning),

+ 2 - 2
packages/core/test/tool-bash.test.ts

@@ -14,7 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { BashTool } from "@opencode-ai/core/tool/bash"
 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 { location } from "./fixture/location"
 import { tmpdir } from "./fixture/tmpdir"
 import { testEffect } from "./lib/effect"
@@ -113,7 +113,7 @@ const withTool = <A, E, R>(
     Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
   )
   const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
-  const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+  const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
   const bash = BashTool.layer.pipe(
     Layer.provide(registry),
     Layer.provide(permission),

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

@@ -10,7 +10,7 @@ import { LocationMutation } from "@opencode-ai/core/location-mutation"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { ToolRegistry } from "@opencode-ai/core/tool-registry"
+import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { EditTool } from "@opencode-ai/core/tool/edit"
 import { location } from "./fixture/location"
 import { tmpdir } from "./fixture/tmpdir"
@@ -79,7 +79,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
   )
   const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
   const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
-  const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+  const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
   const edit = EditTool.layer.pipe(
     Layer.provide(registry),
     Layer.provide(planning),

+ 2 - 2
packages/core/test/tool-glob.test.ts

@@ -6,7 +6,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
 import { RelativePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { GlobTool } from "@opencode-ai/core/tool/glob"
-import { ToolRegistry } from "@opencode-ai/core/tool-registry"
+import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { testEffect } from "./lib/effect"
 
 const sessionID = SessionV2.ID.make("ses_glob_tool_test")
@@ -81,7 +81,7 @@ const search = Layer.succeed(
   }),
 )
 
-const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
 const glob = GlobTool.layer.pipe(
   Layer.provide(registry),
   Layer.provide(permission),

+ 3 - 3
packages/core/test/tool-grep.test.ts

@@ -14,7 +14,7 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { GrepTool } from "@opencode-ai/core/tool/grep"
-import { ToolRegistry } from "@opencode-ai/core/tool-registry"
+import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { location } from "./fixture/location"
 import { tmpdir } from "./fixture/tmpdir"
 import { it as runtimeIt } from "./lib/effect"
@@ -86,7 +86,7 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
 const grep = GrepTool.layer.pipe(
   Layer.provide(registry),
   Layer.provide(filesystem),
@@ -140,7 +140,7 @@ function provideLive(directory: string, projectReferences = references({})) {
     Layer.provide(FSUtil.defaultLayer),
     Layer.provide(dependencies),
   )
-  const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+  const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
   const grep = GrepTool.layer.pipe(
     Layer.provide(registry),
     Layer.provide(filesystem),

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

@@ -3,7 +3,7 @@ import { Effect, Exit, Fiber, Layer } from "effect"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { QuestionV2 } from "@opencode-ai/core/question"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { ToolRegistry } from "@opencode-ai/core/tool-registry"
+import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { QuestionTool } from "@opencode-ai/core/tool/question"
 import { testEffect } from "./lib/effect"
 
@@ -23,7 +23,7 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
 const question = Layer.succeed(
   QuestionV2.Service,
   QuestionV2.Service.of({

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

@@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"
 import { FileSystem } from "@opencode-ai/core/filesystem"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { ToolRegistry } from "@opencode-ai/core/tool-registry"
+import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { ReadTool } from "@opencode-ai/core/tool/read"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { RelativePath } from "@opencode-ai/core/schema"
@@ -127,7 +127,7 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
 const resources = Layer.succeed(
   ToolOutputStore.Service,
   ToolOutputStore.Service.of({

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

@@ -10,7 +10,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
 import { SkillV2 } from "@opencode-ai/core/skill"
 import { SkillTool } from "@opencode-ai/core/tool/skill"
 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 { tmpdir } from "./fixture/tmpdir"
 import { it } from "./lib/effect"
 
@@ -72,7 +72,7 @@ describe("SkillTool", () => {
               forAgent: () => Effect.die("unused"),
             }),
           )
-          const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+          const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
           const resources = Layer.succeed(
             ToolOutputStore.Service,
             ToolOutputStore.Service.of({

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

@@ -10,7 +10,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
 import { SessionTable } from "@opencode-ai/core/session/sql"
 import { SessionTodo } from "@opencode-ai/core/session/todo"
 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 { testEffect } from "./lib/effect"
 
 const sessionID = SessionV2.ID.make("ses_todowrite_tool_test")
@@ -34,7 +34,7 @@ const permission = Layer.succeed(
 const database = Database.layerFromPath(":memory:")
 const events = EventV2.layer.pipe(Layer.provide(database))
 const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events))
-const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
 const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(todos))
 const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool))
 

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

@@ -5,7 +5,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { SessionV2 } from "@opencode-ai/core/session"
 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 { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
 import { testEffect } from "./lib/effect"
 
@@ -48,7 +48,7 @@ const resources = Layer.succeed(
     cleanup: () => Effect.die("unused"),
   }),
 )
-const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
 const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(http), Layer.provide(resources))
 const it = testEffect(Layer.mergeAll(registry, permission, http, resources, webfetch))
 const fetchWebfetch = WebFetchTool.layer.pipe(

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

@@ -3,7 +3,7 @@ import { Effect, Layer, Schema } from "effect"
 import { HttpClient, HttpClientResponse } from "effect/unstable/http"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 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 { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { testEffect } from "./lib/effect"
@@ -96,7 +96,7 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
 const websearchConfig = Layer.succeed(
   WebSearchTool.ConfigService,
   WebSearchTool.ConfigService.of({

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

@@ -10,7 +10,7 @@ import { LocationMutation } from "@opencode-ai/core/location-mutation"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { ToolRegistry } from "@opencode-ai/core/tool-registry"
+import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { WriteTool } from "@opencode-ai/core/tool/write"
 import { location } from "./fixture/location"
 import { tmpdir } from "./fixture/tmpdir"
@@ -67,7 +67,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
   )
   const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
   const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
-  const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
+  const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
   const write = WriteTool.layer.pipe(Layer.provide(registry), Layer.provide(planning), Layer.provide(commits))
   return Effect.gen(function* () {
     return yield* body(yield* ToolRegistry.Service)