Procházet zdrojové kódy

feat(plugin): add session request hook (#35794)

Dax před 1 měsícem
rodič
revize
4f976bcf1a
100 změnil soubory, kde provedl 2224 přidání a 1035 odebrání
  1. 2 3
      packages/client/script/build.ts
  2. 0 1
      packages/client/src/contract.ts
  3. 30 24
      packages/client/src/effect/api/api.ts
  4. 12 16
      packages/client/src/effect/generated/client.ts
  5. 224 206
      packages/client/src/promise/generated/client.ts
  6. 15 15
      packages/client/src/promise/generated/types.ts
  7. 3 3
      packages/client/test/contract-identity.test.ts
  8. 6 11
      packages/client/test/promise.test.ts
  9. 564 81
      packages/codemode/test/fixtures/opencode-v2-openapi.json
  10. 4 4
      packages/codemode/test/openapi.test.ts
  11. 13 6
      packages/core/src/plugin.ts
  12. 67 0
      packages/core/src/plugin/hooks.ts
  13. 74 68
      packages/core/src/plugin/host.ts
  14. 1 1
      packages/core/src/plugin/internal.ts
  15. 24 14
      packages/core/src/plugin/promise.ts
  16. 2 1
      packages/core/src/plugin/provider/alibaba.ts
  17. 4 2
      packages/core/src/plugin/provider/amazon-bedrock.ts
  18. 2 1
      packages/core/src/plugin/provider/anthropic.ts
  19. 6 3
      packages/core/src/plugin/provider/azure.ts
  20. 2 1
      packages/core/src/plugin/provider/cerebras.ts
  21. 2 1
      packages/core/src/plugin/provider/cloudflare-ai-gateway.ts
  22. 4 2
      packages/core/src/plugin/provider/cloudflare-workers-ai.ts
  23. 2 1
      packages/core/src/plugin/provider/cohere.ts
  24. 2 1
      packages/core/src/plugin/provider/deepinfra.ts
  25. 2 1
      packages/core/src/plugin/provider/dynamic.ts
  26. 2 1
      packages/core/src/plugin/provider/gateway.ts
  27. 4 2
      packages/core/src/plugin/provider/github-copilot.ts
  28. 4 2
      packages/core/src/plugin/provider/gitlab.ts
  29. 8 4
      packages/core/src/plugin/provider/google-vertex.ts
  30. 2 1
      packages/core/src/plugin/provider/google.ts
  31. 2 1
      packages/core/src/plugin/provider/groq.ts
  32. 2 1
      packages/core/src/plugin/provider/mistral.ts
  33. 2 1
      packages/core/src/plugin/provider/openai-compatible.ts
  34. 4 2
      packages/core/src/plugin/provider/openai.ts
  35. 2 1
      packages/core/src/plugin/provider/openrouter.ts
  36. 2 1
      packages/core/src/plugin/provider/perplexity.ts
  37. 4 2
      packages/core/src/plugin/provider/sap-ai-core.ts
  38. 2 1
      packages/core/src/plugin/provider/snowflake-cortex.ts
  39. 2 1
      packages/core/src/plugin/provider/togetherai.ts
  40. 2 1
      packages/core/src/plugin/provider/venice.ts
  41. 2 1
      packages/core/src/plugin/provider/vercel.ts
  42. 4 2
      packages/core/src/plugin/provider/xai.ts
  43. 1 1
      packages/core/src/plugin/sdk.ts
  44. 1 1
      packages/core/src/plugin/supervisor.ts
  45. 32 4
      packages/core/src/session/runner/llm.ts
  46. 14 1
      packages/core/src/tool/apply-patch.ts
  47. 1 1
      packages/core/src/tool/edit.ts
  48. 1 1
      packages/core/src/tool/glob.ts
  49. 1 1
      packages/core/src/tool/grep.ts
  50. 1 1
      packages/core/src/tool/question.ts
  51. 1 1
      packages/core/src/tool/read.ts
  52. 0 4
      packages/core/src/tool/registry.ts
  53. 1 1
      packages/core/src/tool/shell.ts
  54. 1 1
      packages/core/src/tool/skill.ts
  55. 44 1
      packages/core/src/tool/subagent.ts
  56. 1 1
      packages/core/src/tool/todowrite.ts
  57. 1 1
      packages/core/src/tool/webfetch.ts
  58. 1 1
      packages/core/src/tool/websearch.ts
  59. 1 1
      packages/core/src/tool/write.ts
  60. 2 2
      packages/core/test/config/fixtures/plugin/directory-plugin.ts
  61. 2 2
      packages/core/test/config/fixtures/plugins/folder-plugin/index.ts
  62. 3 3
      packages/core/test/config/plugin.test.ts
  63. 4 6
      packages/core/test/lib/tool.ts
  64. 3 3
      packages/core/test/location-layer.test.ts
  65. 47 0
      packages/core/test/plugin-hooks.test.ts
  66. 46 27
      packages/core/test/plugin.test.ts
  67. 1 0
      packages/core/test/plugin/fixture.ts
  68. 2 2
      packages/core/test/plugin/fixtures/config-effect-plugin.ts
  69. 2 2
      packages/core/test/plugin/fixtures/config-promise-plugin.ts
  70. 2 2
      packages/core/test/plugin/fixtures/failing-plugin.ts
  71. 2 2
      packages/core/test/plugin/fixtures/variant-source-plugin.ts
  72. 22 17
      packages/core/test/plugin/host.ts
  73. 4 4
      packages/core/test/plugin/promise.test.ts
  74. 8 5
      packages/core/test/session-runner-tool-registry.test.ts
  75. 573 237
      packages/docs/openapi.json
  76. 12 16
      packages/httpapi-codegen/src/index.ts
  77. 112 82
      packages/httpapi-codegen/test/generate.test.ts
  78. 3 4
      packages/plugin/package.json
  79. 1 1
      packages/plugin/src/v2/effect/PLAN.md
  80. 16 7
      packages/plugin/src/v2/effect/README.md
  81. 3 3
      packages/plugin/src/v2/effect/agent.ts
  82. 6 2
      packages/plugin/src/v2/effect/aisdk.ts
  83. 3 3
      packages/plugin/src/v2/effect/catalog.ts
  84. 3 3
      packages/plugin/src/v2/effect/command.ts
  85. 0 27
      packages/plugin/src/v2/effect/context.ts
  86. 1 1
      packages/plugin/src/v2/effect/event.ts
  87. 1 14
      packages/plugin/src/v2/effect/index.ts
  88. 3 3
      packages/plugin/src/v2/effect/integration.ts
  89. 27 4
      packages/plugin/src/v2/effect/plugin.ts
  90. 3 3
      packages/plugin/src/v2/effect/reference.ts
  91. 5 6
      packages/plugin/src/v2/effect/registration.ts
  92. 0 4
      packages/plugin/src/v2/effect/runtime.ts
  93. 25 0
      packages/plugin/src/v2/effect/session.ts
  94. 3 3
      packages/plugin/src/v2/effect/skill.ts
  95. 8 3
      packages/plugin/src/v2/effect/tool.ts
  96. 31 6
      packages/plugin/src/v2/promise/README.md
  97. 3 3
      packages/plugin/src/v2/promise/agent.ts
  98. 6 2
      packages/plugin/src/v2/promise/aisdk.ts
  99. 3 3
      packages/plugin/src/v2/promise/catalog.ts
  100. 3 3
      packages/plugin/src/v2/promise/command.ts

+ 2 - 3
packages/client/script/build.ts

@@ -3,15 +3,14 @@ import { compile, emitEffectImported, emitEffectShape, emitPromise, write } from
 import {
   ClientApi,
   effectOmitEndpoints,
-  endpointNames,
   groupNames,
   promiseOmitEndpoints,
 } from "@opencode-ai/protocol/client"
 import { Effect } from "effect"
 import { fileURLToPath } from "url"
 
-const promiseContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints })
-const effectContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: effectOmitEndpoints })
+const promiseContract = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
+const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmitEndpoints })
 
 await Effect.runPromise(
   Effect.all(

+ 0 - 1
packages/client/src/contract.ts

@@ -1,7 +1,6 @@
 export {
   ClientApi,
   effectOmitEndpoints,
-  endpointNames,
   groupNames,
   promiseOmitEndpoints,
 } from "@opencode-ai/protocol/client"

+ 30 - 24
packages/client/src/effect/api/api.ts

@@ -291,9 +291,11 @@ export interface SessionApi<E = never> {
   readonly shell: SessionShellOperation<E>
   readonly compact: SessionCompactOperation<E>
   readonly wait: SessionWaitOperation<E>
-  readonly revertStage: SessionRevertStageOperation<E>
-  readonly revertClear: SessionRevertClearOperation<E>
-  readonly revertCommit: SessionRevertCommitOperation<E>
+  readonly revert: {
+    readonly stage: SessionRevertStageOperation<E>
+    readonly clear: SessionRevertClearOperation<E>
+    readonly commit: SessionRevertCommitOperation<E>
+  }
   readonly context: SessionContextOperation<E>
   readonly instructions: {
     readonly entry: {
@@ -438,11 +440,15 @@ export type IntegrationAttemptCancelOperation<E = never> = (
 export interface IntegrationApi<E = never> {
   readonly list: IntegrationListOperation<E>
   readonly get: IntegrationGetOperation<E>
-  readonly connectKey: IntegrationConnectKeyOperation<E>
-  readonly connectOauth: IntegrationConnectOauthOperation<E>
-  readonly attemptStatus: IntegrationAttemptStatusOperation<E>
-  readonly attemptComplete: IntegrationAttemptCompleteOperation<E>
-  readonly attemptCancel: IntegrationAttemptCancelOperation<E>
+  readonly connect: {
+    readonly key: IntegrationConnectKeyOperation<E>
+    readonly oauth: IntegrationConnectOauthOperation<E>
+  }
+  readonly attempt: {
+    readonly status: IntegrationAttemptStatusOperation<E>
+    readonly complete: IntegrationAttemptCompleteOperation<E>
+    readonly cancel: IntegrationAttemptCancelOperation<E>
+  }
 }
 
 type Endpoint10_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
@@ -453,11 +459,13 @@ export type ServerMcpListOperation<E = never> = (input?: Endpoint10_0Input) => E
 type Endpoint10_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
 export type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] }
 export type Endpoint10_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
-export type ServerMcpCatalogOperation<E = never> = (input?: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
+export type ServerMcpResourceCatalogOperation<E = never> = (
+  input?: Endpoint10_1Input,
+) => Effect.Effect<Endpoint10_1Output, E>
 
 export interface ServerMcpApi<E = never> {
   readonly list: ServerMcpListOperation<E>
-  readonly catalog: ServerMcpCatalogOperation<E>
+  readonly resource: { readonly catalog: ServerMcpResourceCatalogOperation<E> }
 }
 
 type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
@@ -507,7 +515,7 @@ export interface ProjectApi<E = never> {
 type Endpoint13_0Request = Parameters<RawClient["server.form"]["form.request.list"]>[0]
 export type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] }
 export type Endpoint13_0Output = EffectValue<ReturnType<RawClient["server.form"]["form.request.list"]>>
-export type FormListRequestsOperation<E = never> = (input?: Endpoint13_0Input) => Effect.Effect<Endpoint13_0Output, E>
+export type FormRequestListOperation<E = never> = (input?: Endpoint13_0Input) => Effect.Effect<Endpoint13_0Output, E>
 
 type Endpoint13_1Request = Parameters<RawClient["server.form"]["session.form.list"]>[0]
 export type Endpoint13_1Input = { readonly sessionID: Endpoint13_1Request["params"]["sessionID"] }
@@ -561,7 +569,7 @@ export type Endpoint13_6Output = EffectValue<ReturnType<RawClient["server.form"]
 export type FormCancelOperation<E = never> = (input: Endpoint13_6Input) => Effect.Effect<Endpoint13_6Output, E>
 
 export interface FormApi<E = never> {
-  readonly listRequests: FormListRequestsOperation<E>
+  readonly request: { readonly list: FormRequestListOperation<E> }
   readonly list: FormListOperation<E>
   readonly create: FormCreateOperation<E>
   readonly get: FormGetOperation<E>
@@ -573,7 +581,7 @@ export interface FormApi<E = never> {
 type Endpoint14_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
 export type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
 export type Endpoint14_0Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.request.list"]>>
-export type PermissionListRequestsOperation<E = never> = (
+export type PermissionRequestListOperation<E = never> = (
   input?: Endpoint14_0Input,
 ) => Effect.Effect<Endpoint14_0Output, E>
 
@@ -582,14 +590,14 @@ export type Endpoint14_1Input = { readonly projectID?: Endpoint14_1Request["quer
 export type Endpoint14_1Output = EffectValue<
   ReturnType<RawClient["server.permission"]["permission.saved.list"]>
 >["data"]
-export type PermissionListSavedOperation<E = never> = (
+export type PermissionSavedListOperation<E = never> = (
   input?: Endpoint14_1Input,
 ) => Effect.Effect<Endpoint14_1Output, E>
 
 type Endpoint14_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
 export type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] }
 export type Endpoint14_2Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.saved.remove"]>>
-export type PermissionRemoveSavedOperation<E = never> = (
+export type PermissionSavedRemoveOperation<E = never> = (
   input: Endpoint14_2Input,
 ) => Effect.Effect<Endpoint14_2Output, E>
 
@@ -637,9 +645,8 @@ export type Endpoint14_6Output = EffectValue<ReturnType<RawClient["server.permis
 export type PermissionReplyOperation<E = never> = (input: Endpoint14_6Input) => Effect.Effect<Endpoint14_6Output, E>
 
 export interface PermissionApi<E = never> {
-  readonly listRequests: PermissionListRequestsOperation<E>
-  readonly listSaved: PermissionListSavedOperation<E>
-  readonly removeSaved: PermissionRemoveSavedOperation<E>
+  readonly request: { readonly list: PermissionRequestListOperation<E> }
+  readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
   readonly create: PermissionCreateOperation<E>
   readonly list: PermissionListOperation<E>
   readonly get: PermissionGetOperation<E>
@@ -808,7 +815,7 @@ export interface ShellApi<E = never> {
 type Endpoint21_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
 export type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] }
 export type Endpoint21_0Output = EffectValue<ReturnType<RawClient["server.question"]["question.request.list"]>>
-export type QuestionListRequestsOperation<E = never> = (
+export type QuestionRequestListOperation<E = never> = (
   input?: Endpoint21_0Input,
 ) => Effect.Effect<Endpoint21_0Output, E>
 
@@ -835,7 +842,7 @@ export type Endpoint21_3Output = EffectValue<ReturnType<RawClient["server.questi
 export type QuestionRejectOperation<E = never> = (input: Endpoint21_3Input) => Effect.Effect<Endpoint21_3Output, E>
 
 export interface QuestionApi<E = never> {
-  readonly listRequests: QuestionListRequestsOperation<E>
+  readonly request: { readonly list: QuestionRequestListOperation<E> }
   readonly list: QuestionListOperation<E>
   readonly reply: QuestionReplyOperation<E>
   readonly reject: QuestionRejectOperation<E>
@@ -905,16 +912,15 @@ export interface VcsApi<E = never> {
 }
 
 export type Endpoint25_0Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location"]>>
-export type DebugLocationOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
+export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
 
 type Endpoint25_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
 export type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] }
 export type Endpoint25_1Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location.evict"]>>
-export type DebugEvictLocationOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
+export type DebugLocationEvictOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
 
 export interface DebugApi<E = never> {
-  readonly location: DebugLocationOperation<E>
-  readonly evictLocation: DebugEvictLocationOperation<E>
+  readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
 }
 
 export interface AppApi<E = never> {

+ 12 - 16
packages/client/src/effect/generated/client.ts

@@ -381,9 +381,7 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
   shell: Endpoint4_14(raw),
   compact: Endpoint4_15(raw),
   wait: Endpoint4_16(raw),
-  revertStage: Endpoint4_17(raw),
-  revertClear: Endpoint4_18(raw),
-  revertCommit: Endpoint4_19(raw),
+  revert: { stage: Endpoint4_17(raw), clear: Endpoint4_18(raw), commit: Endpoint4_19(raw) },
   context: Endpoint4_20(raw),
   instructions: { entry: { list: Endpoint4_21(raw), put: Endpoint4_22(raw), remove: Endpoint4_23(raw) } },
   log: Endpoint4_24(raw),
@@ -536,11 +534,8 @@ const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_
 const adaptGroup9 = (raw: RawClient["server.integration"]) => ({
   list: Endpoint9_0(raw),
   get: Endpoint9_1(raw),
-  connectKey: Endpoint9_2(raw),
-  connectOauth: Endpoint9_3(raw),
-  attemptStatus: Endpoint9_4(raw),
-  attemptComplete: Endpoint9_5(raw),
-  attemptCancel: Endpoint9_6(raw),
+  connect: { key: Endpoint9_2(raw), oauth: Endpoint9_3(raw) },
+  attempt: { status: Endpoint9_4(raw), complete: Endpoint9_5(raw), cancel: Endpoint9_6(raw) },
 })
 
 type Endpoint10_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
@@ -553,7 +548,10 @@ type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["loc
 const Endpoint10_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_1Input) =>
   raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
 
-const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint10_0(raw), catalog: Endpoint10_1(raw) })
+const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({
+  list: Endpoint10_0(raw),
+  resource: { catalog: Endpoint10_1(raw) },
+})
 
 type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
 type Endpoint11_0Input = {
@@ -690,7 +688,7 @@ const Endpoint13_6 = (raw: RawClient["server.form"]) => (input: Endpoint13_6Inpu
   )
 
 const adaptGroup13 = (raw: RawClient["server.form"]) => ({
-  listRequests: Endpoint13_0(raw),
+  request: { list: Endpoint13_0(raw) },
   list: Endpoint13_1(raw),
   create: Endpoint13_2(raw),
   get: Endpoint13_3(raw),
@@ -778,9 +776,8 @@ const Endpoint14_6 = (raw: RawClient["server.permission"]) => (input: Endpoint14
   }).pipe(Effect.mapError(mapClientError))
 
 const adaptGroup14 = (raw: RawClient["server.permission"]) => ({
-  listRequests: Endpoint14_0(raw),
-  listSaved: Endpoint14_1(raw),
-  removeSaved: Endpoint14_2(raw),
+  request: { list: Endpoint14_0(raw) },
+  saved: { list: Endpoint14_1(raw), remove: Endpoint14_2(raw) },
   create: Endpoint14_3(raw),
   list: Endpoint14_4(raw),
   get: Endpoint14_5(raw),
@@ -1013,7 +1010,7 @@ const Endpoint21_3 = (raw: RawClient["server.question"]) => (input: Endpoint21_3
   )
 
 const adaptGroup21 = (raw: RawClient["server.question"]) => ({
-  listRequests: Endpoint21_0(raw),
+  request: { list: Endpoint21_0(raw) },
   list: Endpoint21_1(raw),
   reply: Endpoint21_2(raw),
   reject: Endpoint21_3(raw),
@@ -1099,8 +1096,7 @@ const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1In
   raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
 
 const adaptGroup25 = (raw: RawClient["server.debug"]) => ({
-  location: Endpoint25_0(raw),
-  evictLocation: Endpoint25_1(raw),
+  location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) },
 })
 
 const adaptClient = (raw: RawClient) => ({

+ 224 - 206
packages/client/src/promise/generated/client.ts

@@ -89,8 +89,8 @@ import type {
   IntegrationAttemptCancelOutput,
   ServerMcpListInput,
   ServerMcpListOutput,
-  ServerMcpCatalogInput,
-  ServerMcpCatalogOutput,
+  ServerMcpResourceCatalogInput,
+  ServerMcpResourceCatalogOutput,
   CredentialUpdateInput,
   CredentialUpdateOutput,
   CredentialRemoveInput,
@@ -100,8 +100,8 @@ import type {
   ProjectCurrentOutput,
   ProjectDirectoriesInput,
   ProjectDirectoriesOutput,
-  FormListRequestsInput,
-  FormListRequestsOutput,
+  FormRequestListInput,
+  FormRequestListOutput,
   FormListInput,
   FormListOutput,
   FormCreateInput,
@@ -114,12 +114,12 @@ import type {
   FormReplyOutput,
   FormCancelInput,
   FormCancelOutput,
-  PermissionListRequestsInput,
-  PermissionListRequestsOutput,
-  PermissionListSavedInput,
-  PermissionListSavedOutput,
-  PermissionRemoveSavedInput,
-  PermissionRemoveSavedOutput,
+  PermissionRequestListInput,
+  PermissionRequestListOutput,
+  PermissionSavedListInput,
+  PermissionSavedListOutput,
+  PermissionSavedRemoveInput,
+  PermissionSavedRemoveOutput,
   PermissionCreateInput,
   PermissionCreateOutput,
   PermissionListInput,
@@ -161,8 +161,8 @@ import type {
   ShellOutputOutput,
   ShellRemoveInput,
   ShellRemoveOutput,
-  QuestionListRequestsInput,
-  QuestionListRequestsOutput,
+  QuestionRequestListInput,
+  QuestionRequestListOutput,
   QuestionListInput,
   QuestionListOutput,
   QuestionReplyInput,
@@ -181,9 +181,9 @@ import type {
   VcsStatusOutput,
   VcsDiffInput,
   VcsDiffOutput,
-  DebugLocationOutput,
-  DebugEvictLocationInput,
-  DebugEvictLocationOutput,
+  DebugLocationListOutput,
+  DebugLocationEvictInput,
+  DebugLocationEvictOutput,
 } from "./types"
 import { ClientError } from "./client-error"
 
@@ -601,40 +601,42 @@ export function make(options: ClientOptions) {
           },
           requestOptions,
         ),
-      revertStage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) =>
-        request<{ readonly data: SessionRevertStageOutput }>(
-          {
-            method: "POST",
-            path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
-            body: { messageID: input["messageID"], files: input["files"] },
-            successStatus: 200,
-            declaredStatuses: [404, 409, 500, 400, 401],
-            empty: false,
-          },
-          requestOptions,
-        ).then((value) => value.data),
-      revertClear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) =>
-        request<SessionRevertClearOutput>(
-          {
-            method: "POST",
-            path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
-            successStatus: 204,
-            declaredStatuses: [404, 409, 500, 400, 401],
-            empty: true,
-          },
-          requestOptions,
-        ),
-      revertCommit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) =>
-        request<SessionRevertCommitOutput>(
-          {
-            method: "POST",
-            path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
-            successStatus: 204,
-            declaredStatuses: [404, 409, 400, 401],
-            empty: true,
-          },
-          requestOptions,
-        ),
+      revert: {
+        stage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) =>
+          request<{ readonly data: SessionRevertStageOutput }>(
+            {
+              method: "POST",
+              path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
+              body: { messageID: input["messageID"], files: input["files"] },
+              successStatus: 200,
+              declaredStatuses: [404, 409, 500, 400, 401],
+              empty: false,
+            },
+            requestOptions,
+          ).then((value) => value.data),
+        clear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) =>
+          request<SessionRevertClearOutput>(
+            {
+              method: "POST",
+              path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
+              successStatus: 204,
+              declaredStatuses: [404, 409, 500, 400, 401],
+              empty: true,
+            },
+            requestOptions,
+          ),
+        commit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) =>
+          request<SessionRevertCommitOutput>(
+            {
+              method: "POST",
+              path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
+              successStatus: 204,
+              declaredStatuses: [404, 409, 400, 401],
+              empty: true,
+            },
+            requestOptions,
+          ),
+      },
       context: (input: SessionContextInput, requestOptions?: RequestOptions) =>
         request<{ readonly data: SessionContextOutput }>(
           {
@@ -836,69 +838,73 @@ export function make(options: ClientOptions) {
           },
           requestOptions,
         ),
-      connectKey: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) =>
-        request<IntegrationConnectKeyOutput>(
-          {
-            method: "POST",
-            path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
-            query: { location: input["location"] },
-            body: { key: input["key"], label: input["label"] },
-            successStatus: 204,
-            declaredStatuses: [400, 401],
-            empty: true,
-          },
-          requestOptions,
-        ),
-      connectOauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) =>
-        request<IntegrationConnectOauthOutput>(
-          {
-            method: "POST",
-            path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
-            query: { location: input["location"] },
-            body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
-            successStatus: 200,
-            declaredStatuses: [400, 401],
-            empty: false,
-          },
-          requestOptions,
-        ),
-      attemptStatus: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) =>
-        request<IntegrationAttemptStatusOutput>(
-          {
-            method: "GET",
-            path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
-            query: { location: input["location"] },
-            successStatus: 200,
-            declaredStatuses: [401, 400],
-            empty: false,
-          },
-          requestOptions,
-        ),
-      attemptComplete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) =>
-        request<IntegrationAttemptCompleteOutput>(
-          {
-            method: "POST",
-            path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`,
-            query: { location: input["location"] },
-            body: { code: input["code"] },
-            successStatus: 204,
-            declaredStatuses: [400, 401],
-            empty: true,
-          },
-          requestOptions,
-        ),
-      attemptCancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) =>
-        request<IntegrationAttemptCancelOutput>(
-          {
-            method: "DELETE",
-            path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
-            query: { location: input["location"] },
-            successStatus: 204,
-            declaredStatuses: [401, 400],
-            empty: true,
-          },
-          requestOptions,
-        ),
+      connect: {
+        key: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) =>
+          request<IntegrationConnectKeyOutput>(
+            {
+              method: "POST",
+              path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
+              query: { location: input["location"] },
+              body: { key: input["key"], label: input["label"] },
+              successStatus: 204,
+              declaredStatuses: [400, 401],
+              empty: true,
+            },
+            requestOptions,
+          ),
+        oauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) =>
+          request<IntegrationConnectOauthOutput>(
+            {
+              method: "POST",
+              path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
+              query: { location: input["location"] },
+              body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
+              successStatus: 200,
+              declaredStatuses: [400, 401],
+              empty: false,
+            },
+            requestOptions,
+          ),
+      },
+      attempt: {
+        status: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) =>
+          request<IntegrationAttemptStatusOutput>(
+            {
+              method: "GET",
+              path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
+              query: { location: input["location"] },
+              successStatus: 200,
+              declaredStatuses: [401, 400],
+              empty: false,
+            },
+            requestOptions,
+          ),
+        complete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) =>
+          request<IntegrationAttemptCompleteOutput>(
+            {
+              method: "POST",
+              path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`,
+              query: { location: input["location"] },
+              body: { code: input["code"] },
+              successStatus: 204,
+              declaredStatuses: [400, 401],
+              empty: true,
+            },
+            requestOptions,
+          ),
+        cancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) =>
+          request<IntegrationAttemptCancelOutput>(
+            {
+              method: "DELETE",
+              path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
+              query: { location: input["location"] },
+              successStatus: 204,
+              declaredStatuses: [401, 400],
+              empty: true,
+            },
+            requestOptions,
+          ),
+      },
     },
     "server.mcp": {
       list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) =>
@@ -913,18 +919,20 @@ export function make(options: ClientOptions) {
           },
           requestOptions,
         ),
-      catalog: (input?: ServerMcpCatalogInput, requestOptions?: RequestOptions) =>
-        request<ServerMcpCatalogOutput>(
-          {
-            method: "GET",
-            path: `/api/mcp/resource`,
-            query: { location: input?.["location"] },
-            successStatus: 200,
-            declaredStatuses: [401, 400],
-            empty: false,
-          },
-          requestOptions,
-        ),
+      resource: {
+        catalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) =>
+          request<ServerMcpResourceCatalogOutput>(
+            {
+              method: "GET",
+              path: `/api/mcp/resource`,
+              query: { location: input?.["location"] },
+              successStatus: 200,
+              declaredStatuses: [401, 400],
+              empty: false,
+            },
+            requestOptions,
+          ),
+      },
     },
     credential: {
       update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) =>
@@ -985,18 +993,20 @@ export function make(options: ClientOptions) {
         ),
     },
     form: {
-      listRequests: (input?: FormListRequestsInput, requestOptions?: RequestOptions) =>
-        request<FormListRequestsOutput>(
-          {
-            method: "GET",
-            path: `/api/form/request`,
-            query: { location: input?.["location"] },
-            successStatus: 200,
-            declaredStatuses: [401, 400],
-            empty: false,
-          },
-          requestOptions,
-        ),
+      request: {
+        list: (input?: FormRequestListInput, requestOptions?: RequestOptions) =>
+          request<FormRequestListOutput>(
+            {
+              method: "GET",
+              path: `/api/form/request`,
+              query: { location: input?.["location"] },
+              successStatus: 200,
+              declaredStatuses: [401, 400],
+              empty: false,
+            },
+            requestOptions,
+          ),
+      },
       list: (input: FormListInput, requestOptions?: RequestOptions) =>
         request<{ readonly data: FormListOutput }>(
           {
@@ -1074,41 +1084,45 @@ export function make(options: ClientOptions) {
         ),
     },
     permission: {
-      listRequests: (input?: PermissionListRequestsInput, requestOptions?: RequestOptions) =>
-        request<PermissionListRequestsOutput>(
-          {
-            method: "GET",
-            path: `/api/permission/request`,
-            query: { location: input?.["location"] },
-            successStatus: 200,
-            declaredStatuses: [401, 400],
-            empty: false,
-          },
-          requestOptions,
-        ),
-      listSaved: (input?: PermissionListSavedInput, requestOptions?: RequestOptions) =>
-        request<{ readonly data: PermissionListSavedOutput }>(
-          {
-            method: "GET",
-            path: `/api/permission/saved`,
-            query: { projectID: input?.["projectID"] },
-            successStatus: 200,
-            declaredStatuses: [401, 400],
-            empty: false,
-          },
-          requestOptions,
-        ).then((value) => value.data),
-      removeSaved: (input: PermissionRemoveSavedInput, requestOptions?: RequestOptions) =>
-        request<PermissionRemoveSavedOutput>(
-          {
-            method: "DELETE",
-            path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
-            successStatus: 204,
-            declaredStatuses: [401, 400],
-            empty: true,
-          },
-          requestOptions,
-        ),
+      request: {
+        list: (input?: PermissionRequestListInput, requestOptions?: RequestOptions) =>
+          request<PermissionRequestListOutput>(
+            {
+              method: "GET",
+              path: `/api/permission/request`,
+              query: { location: input?.["location"] },
+              successStatus: 200,
+              declaredStatuses: [401, 400],
+              empty: false,
+            },
+            requestOptions,
+          ),
+      },
+      saved: {
+        list: (input?: PermissionSavedListInput, requestOptions?: RequestOptions) =>
+          request<{ readonly data: PermissionSavedListOutput }>(
+            {
+              method: "GET",
+              path: `/api/permission/saved`,
+              query: { projectID: input?.["projectID"] },
+              successStatus: 200,
+              declaredStatuses: [401, 400],
+              empty: false,
+            },
+            requestOptions,
+          ).then((value) => value.data),
+        remove: (input: PermissionSavedRemoveInput, requestOptions?: RequestOptions) =>
+          request<PermissionSavedRemoveOutput>(
+            {
+              method: "DELETE",
+              path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
+              successStatus: 204,
+              declaredStatuses: [401, 400],
+              empty: true,
+            },
+            requestOptions,
+          ),
+      },
       create: (input: PermissionCreateInput, requestOptions?: RequestOptions) =>
         request<{ readonly data: PermissionCreateOutput }>(
           {
@@ -1390,18 +1404,20 @@ export function make(options: ClientOptions) {
         ),
     },
     question: {
-      listRequests: (input?: QuestionListRequestsInput, requestOptions?: RequestOptions) =>
-        request<QuestionListRequestsOutput>(
-          {
-            method: "GET",
-            path: `/api/question/request`,
-            query: { location: input?.["location"] },
-            successStatus: 200,
-            declaredStatuses: [401, 400],
-            empty: false,
-          },
-          requestOptions,
-        ),
+      request: {
+        list: (input?: QuestionRequestListInput, requestOptions?: RequestOptions) =>
+          request<QuestionRequestListOutput>(
+            {
+              method: "GET",
+              path: `/api/question/request`,
+              query: { location: input?.["location"] },
+              successStatus: 200,
+              declaredStatuses: [401, 400],
+              empty: false,
+            },
+            requestOptions,
+          ),
+      },
       list: (input: QuestionListInput, requestOptions?: RequestOptions) =>
         request<{ readonly data: QuestionListOutput }>(
           {
@@ -1518,29 +1534,31 @@ export function make(options: ClientOptions) {
         ),
     },
     debug: {
-      location: (requestOptions?: RequestOptions) =>
-        request<DebugLocationOutput>(
-          {
-            method: "GET",
-            path: `/api/debug/location`,
-            successStatus: 200,
-            declaredStatuses: [401, 400],
-            empty: false,
-          },
-          requestOptions,
-        ),
-      evictLocation: (input?: DebugEvictLocationInput, requestOptions?: RequestOptions) =>
-        request<DebugEvictLocationOutput>(
-          {
-            method: "DELETE",
-            path: `/api/debug/location`,
-            query: { location: input?.["location"] },
-            successStatus: 204,
-            declaredStatuses: [401, 400],
-            empty: true,
-          },
-          requestOptions,
-        ),
+      location: {
+        list: (requestOptions?: RequestOptions) =>
+          request<DebugLocationListOutput>(
+            {
+              method: "GET",
+              path: `/api/debug/location`,
+              successStatus: 200,
+              declaredStatuses: [401, 400],
+              empty: false,
+            },
+            requestOptions,
+          ),
+        evict: (input?: DebugLocationEvictInput, requestOptions?: RequestOptions) =>
+          request<DebugLocationEvictOutput>(
+            {
+              method: "DELETE",
+              path: `/api/debug/location`,
+              query: { location: input?.["location"] },
+              successStatus: 204,
+              declaredStatuses: [401, 400],
+              empty: true,
+            },
+            requestOptions,
+          ),
+      },
     },
   }
 }

+ 15 - 15
packages/client/src/promise/generated/types.ts

@@ -2508,13 +2508,13 @@ export type ServerMcpListOutput = {
   }>
 }
 
-export type ServerMcpCatalogInput = {
+export type ServerMcpResourceCatalogInput = {
   readonly location?: {
     readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
   }["location"]
 }
 
-export type ServerMcpCatalogOutput = {
+export type ServerMcpResourceCatalogOutput = {
   readonly location: {
     readonly directory: string
     readonly workspaceID?: string
@@ -2585,13 +2585,13 @@ export type ProjectDirectoriesInput = {
 
 export type ProjectDirectoriesOutput = ReadonlyArray<{ readonly directory: string; readonly strategy?: string }>
 
-export type FormListRequestsInput = {
+export type FormRequestListInput = {
   readonly location?: {
     readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
   }["location"]
 }
 
-export type FormListRequestsOutput = {
+export type FormRequestListOutput = {
   readonly location: {
     readonly directory: string
     readonly workspaceID?: string
@@ -3659,13 +3659,13 @@ export type FormCancelInput = {
 
 export type FormCancelOutput = void
 
-export type PermissionListRequestsInput = {
+export type PermissionRequestListInput = {
   readonly location?: {
     readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
   }["location"]
 }
 
-export type PermissionListRequestsOutput = {
+export type PermissionRequestListOutput = {
   readonly location: {
     readonly directory: string
     readonly workspaceID?: string
@@ -3682,9 +3682,9 @@ export type PermissionListRequestsOutput = {
   }>
 }
 
-export type PermissionListSavedInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] }
+export type PermissionSavedListInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] }
 
-export type PermissionListSavedOutput = {
+export type PermissionSavedListOutput = {
   readonly data: ReadonlyArray<{
     readonly id: string
     readonly projectID: string
@@ -3693,9 +3693,9 @@ export type PermissionListSavedOutput = {
   }>
 }["data"]
 
-export type PermissionRemoveSavedInput = { readonly id: { readonly id: string }["id"] }
+export type PermissionSavedRemoveInput = { readonly id: { readonly id: string }["id"] }
 
-export type PermissionRemoveSavedOutput = void
+export type PermissionSavedRemoveOutput = void
 
 export type PermissionCreateInput = {
   readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -6049,13 +6049,13 @@ export type ShellRemoveInput = {
 
 export type ShellRemoveOutput = void
 
-export type QuestionListRequestsInput = {
+export type QuestionRequestListInput = {
   readonly location?: {
     readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
   }["location"]
 }
 
-export type QuestionListRequestsOutput = {
+export type QuestionRequestListOutput = {
   readonly location: {
     readonly directory: string
     readonly workspaceID?: string
@@ -6221,12 +6221,12 @@ export type VcsDiffOutput = {
   }>
 }
 
-export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }>
+export type DebugLocationListOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }>
 
-export type DebugEvictLocationInput = {
+export type DebugLocationEvictInput = {
   readonly location?: {
     readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
   }["location"]
 }
 
-export type DebugEvictLocationOutput = void
+export type DebugLocationEvictOutput = void

+ 3 - 3
packages/client/test/contract-identity.test.ts

@@ -19,7 +19,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
 import { Workspace } from "@opencode-ai/schema/workspace"
 import { Api } from "@opencode-ai/server/api"
 import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
-import { ClientApi, endpointNames, groupNames, promiseOmitEndpoints } from "../src/contract"
+import { ClientApi, groupNames, promiseOmitEndpoints } from "../src/contract"
 
 const Client = await import("../src/effect")
 
@@ -49,8 +49,8 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
 })
 
 test("client and Server contracts generate identically", () => {
-  const server = compile(Api, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints })
-  const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints })
+  const server = compile(Api, { groupNames, omitEndpoints: promiseOmitEndpoints })
+  const client = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
 
   expect(emitPromise(client)).toEqual(emitPromise(server))
 })

+ 6 - 11
packages/client/test/promise.test.ts

@@ -32,17 +32,12 @@ test("exposes every standard HTTP API group", () => {
     "vcs",
     "debug",
   ])
-  expect(Object.keys(client.debug)).toEqual(["location", "evictLocation"])
+  expect(Object.keys(client.debug)).toEqual(["location"])
+  expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
   expect(Object.keys(client.message)).toEqual(["list"])
-  expect(Object.keys(client.integration)).toEqual([
-    "list",
-    "get",
-    "connectKey",
-    "connectOauth",
-    "attemptStatus",
-    "attemptComplete",
-    "attemptCancel",
-  ])
+  expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "attempt"])
+  expect(Object.keys(client.integration.connect)).toEqual(["key", "oauth"])
+  expect(Object.keys(client.integration.attempt)).toEqual(["status", "complete", "cancel"])
   expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
   expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
   expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
@@ -66,7 +61,7 @@ test("MCP resource catalog uses the public HTTP contract", async () => {
     },
   })
 
-  const result = await client["server.mcp"].catalog({ location: { directory: "/tmp/project" } })
+  const result = await client["server.mcp"].resource.catalog({ location: { directory: "/tmp/project" } })
 
   expect(result.data.resources[0]?.uri).toBe("docs://readme")
   expect(request?.method).toBe("GET")

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 564 - 81
packages/codemode/test/fixtures/opencode-v2-openapi.json


+ 4 - 4
packages/codemode/test/openapi.test.ts

@@ -177,13 +177,13 @@ describe("OpenAPI.fromSpec", () => {
     const spec = await opencodeSpec()
     const result = OpenAPI.fromSpec({ spec, baseUrl })
 
-    expect(result.skipped).toHaveLength(5)
+    expect(result.skipped).toHaveLength(4)
     expect(result.skipped).toContainEqual({
       method: "GET",
       path: "/api/pty/{ptyID}/connect",
       reason: "WebSocket operations are not supported",
     })
-    expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(3)
+    expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(2)
     expect(result.skipped).toContainEqual({
       method: "GET",
       path: "/api/fs/read/*",
@@ -210,11 +210,11 @@ describe("OpenAPI.fromSpec", () => {
     if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
     expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
     expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined()
-    expect(toolAt(result.tools, "v2.pty.connect")).toBeUndefined()
+    expect(Tool.isDefinition(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
     expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
     expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
     expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
-    expect(toolAt(result.tools, "v2.pty.connectToken")).not.toBeUndefined()
+    expect(toolAt(result.tools, "v2.pty.connect.token")).not.toBeUndefined()
   })
 
   test("preserves operation path sanitization and collision handling", () => {

+ 13 - 6
packages/core/src/plugin.ts

@@ -1,6 +1,6 @@
 export * as PluginV2 from "./plugin"
 
-import type { Plugin } from "@opencode-ai/plugin/v2/effect"
+import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
 import { Event, ID, type Info } from "@opencode-ai/schema/plugin"
 import { makeLocationNode } from "./effect/app-node"
 import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
@@ -18,6 +18,7 @@ import { SkillV2 } from "./skill"
 import { State } from "./state"
 import { ToolRegistry } from "./tool/registry"
 import { ToolHooks } from "./tool/hooks"
+import { PluginHooks } from "./plugin/hooks"
 
 export interface Interface {
   readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect<void>
@@ -57,12 +58,13 @@ const layer = Layer.effect(
             generation.length === definitions.length &&
             generation.every(
               (plugin, index) => plugin.id === definitions[index]?.id && plugin.version === definitions[index]?.version,
-            )
+            ) &&
+            definitions.every((definition) => active.has(definition.id))
           ) {
             return
           }
           generation = undefined
-          const exit = yield* State.batch(
+          yield* State.batch(
             Effect.gen(function* () {
               const scopes = Array.from(active.values()).toReversed()
               active.clear()
@@ -81,13 +83,17 @@ const layer = Layer.effect(
                   Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
                   Effect.exit,
                 )
-                if (Exit.isFailure(loaded)) return loaded
+                if (Exit.isFailure(loaded)) {
+                  yield* Effect.logWarning("failed to load plugin", {
+                    "plugin.id": definition.id,
+                    cause: loaded.cause,
+                  })
+                  continue
+                }
                 active.set(definition.id, child)
               }
-              return Exit.void
             }),
           )
-          if (Exit.isFailure(exit)) return yield* exit
           generation = definitions.map((definition) => ({
             id: definition.id,
             ...(definition.version === undefined ? {} : { version: definition.version }),
@@ -131,6 +137,7 @@ export const node = makeLocationNode({
     SkillV2.node,
     ToolRegistry.toolsNode,
     ToolHooks.node,
+    PluginHooks.node,
     PluginRuntime.node,
   ],
 })

+ 67 - 0
packages/core/src/plugin/hooks.ts

@@ -0,0 +1,67 @@
+export * as PluginHooks from "./hooks"
+
+import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk"
+import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
+import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool"
+import { Context, Effect, Layer, Scope } from "effect"
+import { makeLocationNode } from "../effect/app-node"
+import { State } from "../state"
+
+export interface Domains {
+  readonly aisdk: AISDKHooks
+  readonly session: SessionHooks
+  readonly tool: ToolHooks
+}
+
+type Callback<Event> = (event: Event) => Effect.Effect<void>
+
+export interface Interface {
+  readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
+    domain: Domain,
+    name: Name,
+    callback: Callback<Domains[Domain][Name]>,
+  ) => Effect.Effect<State.Registration, never, Scope.Scope>
+  readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
+    domain: Domain,
+    name: Name,
+    event: Domains[Domain][Name],
+  ) => Effect.Effect<Domains[Domain][Name]>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginHooks") {}
+
+const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const callbacks = new Map<string, Function[]>()
+    const key = (domain: keyof Domains, name: PropertyKey) => `${domain}.${String(name)}`
+
+    const register: Interface["register"] = Effect.fn("PluginHooks.register")(function* (domain, name, callback) {
+      const scope = yield* Scope.Scope
+      const id = key(domain, name)
+      let active = true
+      callbacks.set(id, [...(callbacks.get(id) ?? []), callback])
+      const dispose = Effect.sync(() => {
+        if (!active) return
+        active = false
+        const next = (callbacks.get(id) ?? []).filter((item) => item !== callback)
+        if (next.length === 0) callbacks.delete(id)
+        else callbacks.set(id, next)
+      })
+      yield* Scope.addFinalizer(scope, dispose)
+      return { dispose }
+    })
+
+    const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
+      for (const callback of callbacks.get(key(domain, name)) ?? []) {
+        const result: Effect.Effect<void> = Reflect.apply(callback, undefined, [event])
+        yield* result
+      }
+      return event
+    })
+
+    return Service.of({ register, trigger })
+  }),
+)
+
+export const node = makeLocationNode({ service: Service, layer, deps: [] })

+ 74 - 68
packages/core/src/plugin/host.ts

@@ -1,6 +1,6 @@
 export * as PluginHost from "./host"
 
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Plugin } from "@opencode-ai/plugin/v2/effect"
 import { EventManifest } from "@opencode-ai/schema/event-manifest"
 import { Effect, Schema, Stream } from "effect"
 import { AgentV2 } from "../agent"
@@ -22,6 +22,7 @@ import { Tool } from "../tool/tool"
 import { Tools } from "../tool/tools"
 import { ToolHooks } from "../tool/hooks"
 import { WorkspaceV2 } from "../workspace"
+import { PluginHooks } from "./hooks"
 
 const mutable = <T>(value: T) => value as DeepMutable<T>
 export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
@@ -36,6 +37,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
   const skill = yield* SkillV2.Service
   const tools = yield* Tools.Service
   const toolHooks = yield* ToolHooks.Service
+  const hooks = yield* PluginHooks.Service
   const runtime = yield* PluginRuntime.Service
   const locationInfo = () =>
     new Location.Info({
@@ -43,7 +45,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
       workspaceID: location.workspaceID,
       project: location.project,
     })
-  const locationRef = (input?: Parameters<PluginContext["agent"]["list"]>[0]) =>
+  const locationRef = (input?: Parameters<Plugin.Context["agent"]["list"]>[0]) =>
     input?.location === undefined
       ? undefined
       : Location.Ref.make({
@@ -79,32 +81,32 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
         }),
     },
     aisdk: {
-      sdk: (callback) =>
-        aisdk.hook.sdk((event) => {
+      hook: (name, callback) => {
+        if (name === "sdk") {
+          return aisdk.hook.sdk((event) => {
+            const output = {
+              model: mutable(event.model),
+              package: event.package,
+              options: event.options,
+              sdk: event.sdk,
+            }
+            return Reflect.apply(callback, undefined, [output]).pipe(
+              Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
+            )
+          })
+        }
+        return aisdk.hook.language((event) => {
           const output = {
             model: mutable(event.model),
-            package: event.package,
             options: event.options,
             sdk: event.sdk,
-          }
-          const result = callback(output)
-          return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
-            Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
-          )
-        }),
-      language: (callback) =>
-        aisdk.hook.language((event) => {
-          const output = {
-            model: mutable(event.model),
-            sdk: event.sdk,
-            options: event.options,
             language: event.language,
           }
-          const result = callback(output)
-          return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
+          return Reflect.apply(callback, undefined, [output]).pipe(
             Effect.tap(() => Effect.sync(() => (event.language = output.language))),
           )
-        }),
+        })
+      },
     },
     catalog: {
       provider: {
@@ -164,25 +166,29 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
     integration: {
       list: () => response(integration.list()),
       get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
-      connectKey: (input) =>
-        integration.connection.key({
-          integrationID: Integration.ID.make(input.integrationID),
-          key: input.key,
-          label: input.label,
-        }),
-      connectOauth: (input) =>
-        response(
-          integration.connection.oauth({
+      connect: {
+        key: (input) =>
+          integration.connection.key({
             integrationID: Integration.ID.make(input.integrationID),
-            methodID: Integration.MethodID.make(input.methodID),
-            inputs: input.inputs,
+            key: input.key,
             label: input.label,
           }),
-        ),
-      attemptStatus: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))),
-      attemptComplete: (input) =>
-        integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }),
-      attemptCancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)),
+        oauth: (input) =>
+          response(
+            integration.connection.oauth({
+              integrationID: Integration.ID.make(input.integrationID),
+              methodID: Integration.MethodID.make(input.methodID),
+              inputs: input.inputs,
+              label: input.label,
+            }),
+          ),
+      },
+      attempt: {
+        status: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))),
+        complete: (input) =>
+          integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }),
+        cancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)),
+      },
       reload: integration.reload,
       connection: {
         active: (id) => integration.connection.active(Integration.ID.make(id)),
@@ -317,11 +323,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
             registrations,
             (registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
             { discard: true },
-          )
+          ).pipe(Effect.orDie)
+          return { dispose: Effect.void }
         }),
-      execute: {
-        before: (callback) =>
-          toolHooks.hook.before((event) => {
+      hook: (name, callback) => {
+        if (name === "execute.before") {
+          return toolHooks.hook.before((event) => {
             const output = {
               tool: event.tool,
               sessionID: event.sessionID,
@@ -330,38 +337,37 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
               toolCallID: event.toolCallID,
               input: event.input,
             }
-            const result = callback(output)
-            return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
+            return Reflect.apply(callback, undefined, [output]).pipe(
               Effect.tap(() => Effect.sync(() => (event.input = output.input))),
             )
-          }),
-        after: (callback) =>
-          toolHooks.hook.after((event) => {
-            const output = {
-              tool: event.tool,
-              sessionID: event.sessionID,
-              agent: event.agent,
-              assistantMessageID: event.assistantMessageID,
-              toolCallID: event.toolCallID,
-              input: event.input,
-              result: event.result,
-              output: event.output,
-              outputPaths: event.outputPaths,
-            }
-            const result = callback(output)
-            return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
-              Effect.tap(() =>
-                Effect.sync(() => {
-                  event.result = output.result
-                  event.output = output.output
-                  event.outputPaths = output.outputPaths
-                }),
-              ),
-            )
-          }),
+          })
+        }
+        return toolHooks.hook.after((event) => {
+          const output = {
+            tool: event.tool,
+            sessionID: event.sessionID,
+            agent: event.agent,
+            assistantMessageID: event.assistantMessageID,
+            toolCallID: event.toolCallID,
+            input: event.input,
+            result: event.result,
+            output: event.output,
+            outputPaths: event.outputPaths,
+          }
+          return Reflect.apply(callback, undefined, [output]).pipe(
+            Effect.tap(() =>
+              Effect.sync(() => {
+                event.result = output.result
+                event.output = output.output
+                event.outputPaths = output.outputPaths
+              }),
+            ),
+          )
+        })
       },
     },
     session: {
+      hook: (name, callback) => hooks.register("session", name, callback),
       create: (input) =>
         runtime.session.create({
           id: input?.id,
@@ -375,5 +381,5 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
       command: runtime.session.command,
       interrupt: (input) => runtime.session.interrupt(input.sessionID),
     },
-  } satisfies PluginContext
+  } satisfies Plugin.Context
 })

+ 1 - 1
packages/core/src/plugin/internal.ts

@@ -1,6 +1,6 @@
 export * as PluginInternal from "./internal"
 
-import type { Plugin } from "@opencode-ai/plugin/v2/effect"
+import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
 import { Context, Effect, Scope } from "effect"
 import { HttpClient } from "effect/unstable/http"
 import { AgentV2 } from "../agent"

+ 24 - 14
packages/core/src/plugin/promise.ts

@@ -1,11 +1,12 @@
 export * as PluginPromise from "./promise"
 
-import { define } from "@opencode-ai/plugin/v2/effect"
-import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise"
+import { Plugin } from "@opencode-ai/plugin/v2/effect"
 import { Effect, Scope, Stream } from "effect"
 
 type HostRegistration = { readonly dispose: Effect.Effect<void> }
 type Registration = { readonly dispose: () => Promise<void> }
+type PromisePlugin = import("@opencode-ai/plugin/v2/plugin").Plugin
+type PromisePluginContext = import("@opencode-ai/plugin/v2/plugin").Context
 
 /**
  * Adapts a Promise plugin into an Effect plugin so the existing Effect-only
@@ -16,8 +17,8 @@ type Registration = { readonly dispose: () => Promise<void> }
  * preserves boot-time batching, so Promise-plugin transforms still coalesce
  * into one reload per domain.
  */
-export function fromPromise(plugin: Plugin) {
-  return define({
+export function fromPromise(plugin: PromisePlugin) {
+  return Plugin.define({
     id: plugin.id,
     effect: (host) =>
       Effect.gen(function* () {
@@ -43,7 +44,7 @@ export function fromPromise(plugin: Plugin) {
               }),
             )
 
-        const context2: PluginContext = {
+        const context2: PromisePluginContext = {
           options: host.options,
           agent: {
             list: (input) => run(host.agent.list(input)),
@@ -51,10 +52,8 @@ export function fromPromise(plugin: Plugin) {
             reload: () => run(host.agent.reload()),
           },
           aisdk: {
-            sdk: (callback) =>
-              register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))),
-            language: (callback) =>
-              register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))),
+            hook: (name, callback) =>
+              register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
           },
           catalog: {
             provider: {
@@ -79,11 +78,15 @@ export function fromPromise(plugin: Plugin) {
           integration: {
             list: (input) => run(host.integration.list(input)),
             get: (input) => run(host.integration.get(input)),
-            connectKey: (input) => run(host.integration.connectKey(input)),
-            connectOauth: (input) => run(host.integration.connectOauth(input)),
-            attemptStatus: (input) => run(host.integration.attemptStatus(input)),
-            attemptComplete: (input) => run(host.integration.attemptComplete(input)),
-            attemptCancel: (input) => run(host.integration.attemptCancel(input)),
+            connect: {
+              key: (input) => run(host.integration.connect.key(input)),
+              oauth: (input) => run(host.integration.connect.oauth(input)),
+            },
+            attempt: {
+              status: (input) => run(host.integration.attempt.status(input)),
+              complete: (input) => run(host.integration.attempt.complete(input)),
+              cancel: (input) => run(host.integration.attempt.cancel(input)),
+            },
             transform: transform(host.integration),
             reload: () => run(host.integration.reload()),
             connection: {
@@ -104,12 +107,19 @@ export function fromPromise(plugin: Plugin) {
             transform: transform(host.skill),
             reload: () => run(host.skill.reload()),
           },
+          tool: {
+            transform: transform(host.tool),
+            hook: (name, callback) =>
+              register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
+          },
           session: {
             create: (input) => run(host.session.create(input)),
             get: (input) => run(host.session.get(input)),
             prompt: (input) => run(host.session.prompt(input)),
             command: (input) => run(host.session.command(input)),
             interrupt: (input) => run(host.session.interrupt(input)),
+            hook: (name, callback) =>
+              register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
           },
         }
 

+ 2 - 1
packages/core/src/plugin/provider/alibaba.ts

@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 export const AlibabaPlugin = define({
   id: "opencode.provider.alibaba",
   effect: Effect.fn(function* (ctx) {
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/alibaba") return
         const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))

+ 4 - 2
packages/core/src/plugin/provider/amazon-bedrock.ts

@@ -75,7 +75,8 @@ export const AmazonBedrockPlugin = define({
         })
       }
     })
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return
         const options = { ...evt.options }
@@ -108,7 +109,8 @@ export const AmazonBedrockPlugin = define({
         evt.sdk = mod.createAmazonBedrock(options)
       }),
     )
-    yield* ctx.aisdk.language(
+    yield* ctx.aisdk.hook(
+      "language",
       Effect.fn(function* (evt) {
         if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return
         if (

+ 2 - 1
packages/core/src/plugin/provider/anthropic.ts

@@ -17,7 +17,8 @@ export const AnthropicPlugin = define({
         })
       }
     })
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/anthropic") return
         const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic"))

+ 6 - 3
packages/core/src/plugin/provider/azure.ts

@@ -26,7 +26,8 @@ export const AzurePlugin = define({
         })
       }
     })
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/azure") return
         if (evt.model.providerID === ProviderV2.ID.azure) {
@@ -44,7 +45,8 @@ export const AzurePlugin = define({
         evt.sdk = mod.createAzure(evt.options)
       }),
     )
-    yield* ctx.aisdk.language(
+    yield* ctx.aisdk.hook(
+      "language",
       Effect.fn(function* (evt) {
         if (evt.model.providerID !== ProviderV2.ID.azure) return
         evt.language = selectLanguage(
@@ -75,7 +77,8 @@ export const AzureCognitiveServicesPlugin = define({
         })
       }
     })
-    yield* ctx.aisdk.language(
+    yield* ctx.aisdk.hook(
+      "language",
       Effect.fn(function* (evt) {
         if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
         evt.language = selectLanguage(

+ 2 - 1
packages/core/src/plugin/provider/cerebras.ts

@@ -14,7 +14,8 @@ export const CerebrasPlugin = define({
         })
       }
     })
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/cerebras") return
         const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras"))

+ 2 - 1
packages/core/src/plugin/provider/cloudflare-ai-gateway.ts

@@ -6,7 +6,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 export const CloudflareAIGatewayPlugin = define({
   id: "opencode.provider.cloudflare-ai-gateway",
   effect: Effect.fn(function* (ctx) {
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "ai-gateway-provider") return
         if (evt.options.baseURL) return

+ 4 - 2
packages/core/src/plugin/provider/cloudflare-workers-ai.ts

@@ -19,7 +19,8 @@ export const CloudflareWorkersAIPlugin = define({
         if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
       })
     })
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.model.providerID !== providerID) return
         if (evt.package !== "@ai-sdk/openai-compatible") return
@@ -35,7 +36,8 @@ export const CloudflareWorkersAIPlugin = define({
         )
       }),
     )
-    yield* ctx.aisdk.language(
+    yield* ctx.aisdk.hook(
+      "language",
       Effect.fn(function* (evt) {
         if (evt.model.providerID !== providerID) return
         evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id)

+ 2 - 1
packages/core/src/plugin/provider/cohere.ts

@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 export const CoherePlugin = define({
   id: "opencode.provider.cohere",
   effect: Effect.fn(function* (ctx) {
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/cohere") return
         const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))

+ 2 - 1
packages/core/src/plugin/provider/deepinfra.ts

@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 export const DeepInfraPlugin = define({
   id: "opencode.provider.deepinfra",
   effect: Effect.fn(function* (ctx) {
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/deepinfra") return
         const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))

+ 2 - 1
packages/core/src/plugin/provider/dynamic.ts

@@ -7,7 +7,8 @@ export const DynamicProviderPlugin = define({
   id: "opencode.provider.dynamic",
   effect: Effect.fn(function* (ctx) {
     const npm = yield* Npm.Service
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.sdk) return
 

+ 2 - 1
packages/core/src/plugin/provider/gateway.ts

@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 export const GatewayPlugin = define({
   id: "opencode.provider.gateway",
   effect: Effect.fn(function* (ctx) {
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/gateway") return
         const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))

+ 4 - 2
packages/core/src/plugin/provider/github-copilot.ts

@@ -23,14 +23,16 @@ export const GithubCopilotPlugin = define({
         model.enabled = false
       })
     })
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/github-copilot") return
         const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
         evt.sdk = mod.createOpenaiCompatible(evt.options)
       }),
     )
-    yield* ctx.aisdk.language(
+    yield* ctx.aisdk.hook(
+      "language",
       Effect.fn(function* (evt) {
         if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
         if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {

+ 4 - 2
packages/core/src/plugin/provider/gitlab.ts

@@ -7,7 +7,8 @@ import { ProviderV2 } from "../../provider"
 export const GitLabPlugin = define({
   id: "opencode.provider.gitlab",
   effect: Effect.fn(function* (ctx) {
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "gitlab-ai-provider") return
         const mod = yield* Effect.promise(() => import("gitlab-ai-provider"))
@@ -31,7 +32,8 @@ export const GitLabPlugin = define({
         })
       }),
     )
-    yield* ctx.aisdk.language(
+    yield* ctx.aisdk.hook(
+      "language",
       Effect.fn(function* (evt) {
         if (evt.model.providerID !== ProviderV2.ID.gitlab) return
         const featureFlags =

+ 8 - 4
packages/core/src/plugin/provider/google-vertex.ts

@@ -85,7 +85,8 @@ export const GoogleVertexPlugin = define({
         })
       }
     })
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
           evt.options.fetch = authFetch(evt.options.fetch)
@@ -104,7 +105,8 @@ export const GoogleVertexPlugin = define({
         })
       }),
     )
-    yield* ctx.aisdk.language(
+    yield* ctx.aisdk.hook(
+      "language",
       Effect.fn(function* (evt) {
         if (evt.model.providerID !== ProviderV2.ID.googleVertex) return
         evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim())
@@ -135,7 +137,8 @@ export const GoogleVertexAnthropicPlugin = define({
         })
       }
     })
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
         const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
@@ -161,7 +164,8 @@ export const GoogleVertexAnthropicPlugin = define({
         })
       }),
     )
-    yield* ctx.aisdk.language(
+    yield* ctx.aisdk.hook(
+      "language",
       Effect.fn(function* (evt) {
         if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return
         evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim())

+ 2 - 1
packages/core/src/plugin/provider/google.ts

@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 export const GooglePlugin = define({
   id: "opencode.provider.google",
   effect: Effect.fn(function* (ctx) {
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/google") return
         const mod = yield* Effect.promise(() => import("@ai-sdk/google"))

+ 2 - 1
packages/core/src/plugin/provider/groq.ts

@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 export const GroqPlugin = define({
   id: "opencode.provider.groq",
   effect: Effect.fn(function* (ctx) {
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/groq") return
         const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))

+ 2 - 1
packages/core/src/plugin/provider/mistral.ts

@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 export const MistralPlugin = define({
   id: "opencode.provider.mistral",
   effect: Effect.fn(function* (ctx) {
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/mistral") return
         const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))

+ 2 - 1
packages/core/src/plugin/provider/openai-compatible.ts

@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 export const OpenAICompatiblePlugin = define({
   id: "opencode.provider.openai-compatible",
   effect: Effect.fn(function* (ctx) {
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.sdk) return
         if (!evt.package.includes("@ai-sdk/openai-compatible")) return

+ 4 - 2
packages/core/src/plugin/provider/openai.ts

@@ -210,14 +210,16 @@ export const OpenAIPlugin = define({
       Effect.forkScoped({ startImmediately: true }),
     )
     yield* refresh().pipe(Effect.forkScoped)
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/openai") return
         const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
         evt.sdk = mod.createOpenAI(evt.options)
       }),
     )
-    yield* ctx.aisdk.language(
+    yield* ctx.aisdk.hook(
+      "language",
       Effect.fn(function* (evt) {
         if (evt.model.providerID !== ProviderV2.ID.openai) return
         evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)

+ 2 - 1
packages/core/src/plugin/provider/openrouter.ts

@@ -23,7 +23,8 @@ export const OpenRouterPlugin = define({
         }
       }
     })
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@openrouter/ai-sdk-provider") return
         const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))

+ 2 - 1
packages/core/src/plugin/provider/perplexity.ts

@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 export const PerplexityPlugin = define({
   id: "opencode.provider.perplexity",
   effect: Effect.fn(function* (ctx) {
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/perplexity") return
         const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))

+ 4 - 2
packages/core/src/plugin/provider/sap-ai-core.ts

@@ -8,7 +8,8 @@ export const SapAICorePlugin = define({
   id: "opencode.provider.sap-ai-core",
   effect: Effect.fn(function* (ctx) {
     const npm = yield* Npm.Service
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
         const serviceKey =
@@ -37,7 +38,8 @@ export const SapAICorePlugin = define({
         )
       }),
     )
-    yield* ctx.aisdk.language(
+    yield* ctx.aisdk.hook(
+      "language",
       Effect.fn(function* (evt) {
         if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
         evt.language = evt.sdk(evt.model.modelID ?? evt.model.id)

+ 2 - 1
packages/core/src/plugin/provider/snowflake-cortex.ts

@@ -67,7 +67,8 @@ export function cortexFetch(upstream: FetchLike = fetch) {
 export const SnowflakeCortexPlugin = define({
   id: "opencode.provider.snowflake-cortex",
   effect: Effect.fn(function* (ctx) {
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
         const token =

+ 2 - 1
packages/core/src/plugin/provider/togetherai.ts

@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 export const TogetherAIPlugin = define({
   id: "opencode.provider.togetherai",
   effect: Effect.fn(function* (ctx) {
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/togetherai") return
         const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai"))

+ 2 - 1
packages/core/src/plugin/provider/venice.ts

@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 export const VenicePlugin = define({
   id: "opencode.provider.venice",
   effect: Effect.fn(function* (ctx) {
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "venice-ai-sdk-provider") return
         const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider"))

+ 2 - 1
packages/core/src/plugin/provider/vercel.ts

@@ -14,7 +14,8 @@ export const VercelPlugin = define({
         })
       }
     })
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/vercel") return
         const mod = yield* Effect.promise(() => import("@ai-sdk/vercel"))

+ 4 - 2
packages/core/src/plugin/provider/xai.ts

@@ -5,14 +5,16 @@ import { ProviderV2 } from "../../provider"
 export const XAIPlugin = define({
   id: "opencode.provider.xai",
   effect: Effect.fn(function* (ctx) {
-    yield* ctx.aisdk.sdk(
+    yield* ctx.aisdk.hook(
+      "sdk",
       Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/xai") return
         const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
         evt.sdk = mod.createXai(evt.options)
       }),
     )
-    yield* ctx.aisdk.language(
+    yield* ctx.aisdk.hook(
+      "language",
       Effect.fn(function* (evt) {
         if (evt.model.providerID !== ProviderV2.ID.make("xai")) return
         evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)

+ 1 - 1
packages/core/src/plugin/sdk.ts

@@ -1,6 +1,6 @@
 export * as SdkPlugins from "./sdk"
 
-import type { Plugin } from "@opencode-ai/plugin/v2/effect"
+import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
 import { Context, Effect, Layer } from "effect"
 import { makeGlobalNode } from "../effect/app-node"
 import { EventV2 } from "../event"

+ 1 - 1
packages/core/src/plugin/supervisor.ts

@@ -1,6 +1,6 @@
 export * as PluginSupervisor from "./supervisor"
 
-import type { Plugin } from "@opencode-ai/plugin/v2/effect"
+import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
 import { Event } from "@opencode-ai/schema/config"
 import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect"
 import path from "path"

+ 32 - 4
packages/core/src/session/runner/llm.ts

@@ -50,6 +50,8 @@ import { llmClient } from "../../effect/app-node-platform"
 import { StepFailedError, UserInterruptedError } from "../error"
 import { toSessionError } from "../to-session-error"
 import { SessionRunnerRetry } from "./retry"
+import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
+import { PluginHooks } from "../../plugin/hooks"
 
 type StepTokens = {
   readonly input: number
@@ -132,6 +134,7 @@ const layer = Layer.effect(
     const llm = yield* LLMClient.Service
     const agents = yield* AgentV2.Service
     const tools = yield* ToolRegistry.Service
+    const hooks = yield* PluginHooks.Service
     const models = yield* SessionRunnerModel.Service
     const store = yield* SessionStore.Service
     const location = yield* Location.Service
@@ -252,10 +255,34 @@ const layer = Layer.effect(
         tools: toolMaterialization?.definitions ?? [],
         toolChoice: isLastStep ? "none" : undefined,
       })
+      const availableTools = new Map(request.tools.map((tool) => [tool.name, tool]))
+      const requestEvent: SessionHooks["request"] = {
+        sessionID: session.id,
+        agent: agent.id,
+        model: resolved.ref,
+        system: [...request.system],
+        messages: [...request.messages],
+        tools: Object.fromEntries(
+          request.tools.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
+        ),
+      }
+      // Plugins may reshape the draft, but cannot advertise tools excluded earlier
+      // by permissions or registration state.
+      yield* hooks.trigger("session", "request", requestEvent)
+      const hookedRequest = LLM.updateRequest(request, {
+        system: requestEvent.system,
+        messages: requestEvent.messages,
+        tools: Object.entries(requestEvent.tools).flatMap(([name, tool]) => {
+          const registered = availableTools.get(name)
+          if (!registered) return []
+          return [{ ...registered, description: tool.description, inputSchema: tool.input }]
+        }),
+      })
+      const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name))
       // Automatic compaction completed; rebuild the request from compacted history.
       if (
         !(yield* SessionInput.pendingCompaction(db, session.id)) &&
-        (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }))
+        (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest }))
       )
         return { _tag: "RestartAfterCompaction", step: currentStep } as const
       const startSnapshot = yield* snapshots.capture()
@@ -276,7 +303,7 @@ const layer = Layer.effect(
       const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) =>
         serialized(publisher.publish(event, outputPaths, error))
       let overflowFailure: ProviderErrorEvent | undefined
-      const providerStream = llm.stream(request).pipe(
+      const providerStream = llm.stream(hookedRequest).pipe(
         Stream.runForEach((event) =>
           Effect.gen(function* () {
             if (overflowFailure || publisher.hasProviderError()) return
@@ -288,11 +315,11 @@ const layer = Layer.effect(
             }
             yield* publish(event)
             if (event.type !== "tool-call" || event.providerExecuted) return
-            if (!toolMaterialization) {
+            if (!toolMaterialization || !advertisedTools.has(event.name)) {
               yield* serialized(
                 publisher.failUnsettledTools({
                   type: "tool.execution",
-                  message: "Tools are disabled after the maximum agent steps",
+                  message: `Tool is not available for this request: ${event.name}`,
                 }),
               )
               return
@@ -625,6 +652,7 @@ export const node = makeLocationNode({
     llmClient,
     AgentV2.node,
     ToolRegistry.node,
+    PluginHooks.node,
     SessionRunnerModel.node,
     SessionStore.node,
     Location.node,

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

@@ -1,6 +1,6 @@
 export * as ApplyPatchTool from "./apply-patch"
 
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
 import { ToolFailure } from "@opencode-ai/llm"
 import { FileDiff } from "@opencode-ai/schema/file-diff"
 import { createTwoFilesPatch, diffLines } from "diff"
@@ -194,6 +194,19 @@ export const Plugin = {
         ),
       )
       .pipe(Effect.orDie)
+
+    yield* ctx.session.hook("request", (event) =>
+      Effect.sync(() => {
+        const usePatch =
+          event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt")
+        if (usePatch) {
+          delete event.tools.edit
+          delete event.tools.write
+          return
+        }
+        delete event.tools.patch
+      }),
+    )
   }),
 }
 

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

@@ -6,7 +6,7 @@
  */
 export * as EditTool from "./edit"
 
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
 import { ToolFailure } from "@opencode-ai/llm"
 import { FileDiff } from "@opencode-ai/schema/file-diff"
 import { createTwoFilesPatch, diffLines } from "diff"

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

@@ -1,7 +1,7 @@
 export * as GlobTool from "./glob"
 
 import { ToolFailure } from "@opencode-ai/llm"
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
 import { Effect, Schema } from "effect"
 import path from "path"
 import { FileSystem } from "../filesystem"

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

@@ -1,6 +1,6 @@
 export * as GrepTool from "./grep"
 
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Schema } from "effect"
 import path from "path"

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

@@ -1,6 +1,6 @@
 export * as QuestionTool from "./question"
 
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Schema } from "effect"
 import { Form } from "../form"

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

@@ -1,6 +1,6 @@
 export * as ReadTool from "./read"
 
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
 import { dirname } from "path"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Schema } from "effect"

+ 0 - 4
packages/core/src/tool/registry.ts

@@ -191,12 +191,8 @@ const registryLayer = Layer.effect(
           const registration = entries.at(-1)?.registration
           if (registration) registrations.set(name, registration)
         }
-        // OpenAI/GPT models use patch; every other model uses edit and write.
-        const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
         for (const [name, registration] of registrations) {
-          const wrongEditTool = name === "patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
           if (
-            wrongEditTool ||
             (registration.deferred && !Flag.CODEMODE_ENABLED) ||
             whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
           )

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

@@ -2,7 +2,7 @@ export * as ShellTool from "./shell"
 
 import path from "path"
 import { ToolFailure } from "@opencode-ai/llm"
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
 import { Effect, Schema, Scope } from "effect"
 import { FSUtil } from "../fs-util"
 import { LocationMutation } from "../location-mutation"

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

@@ -1,6 +1,6 @@
 export * as SkillTool from "./skill"
 
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
 import path from "path"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Schema } from "effect"

+ 44 - 1
packages/core/src/tool/subagent.ts

@@ -1,10 +1,11 @@
 export * as SubagentTool from "./subagent"
 
 import { ToolFailure } from "@opencode-ai/llm"
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
 import { Effect, Schema, Scope } from "effect"
 import { AgentV2 } from "../agent"
 import { PluginRuntime } from "../plugin/runtime"
+import { PermissionV2 } from "../permission"
 import { SessionSchema } from "../session/schema"
 import { Tool } from "./tool"
 
@@ -42,6 +43,7 @@ export const Plugin = {
   effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
     const runtime = yield* PluginRuntime.Service
     const agents = yield* AgentV2.Service
+    const permission = yield* PermissionV2.Service
     const scope = yield* Scope.Scope
 
     // Concatenate the child's final completed assistant text. Distinguishes "completed with no
@@ -114,6 +116,20 @@ export const Plugin = {
                 if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
                 if (agent.mode === "primary")
                   return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` })
+                yield* permission
+                  .assert({
+                    action: name,
+                    resources: [agent.id],
+                    save: [agent.id],
+                    sessionID: context.sessionID,
+                    agent: context.agent,
+                    source: {
+                      type: "tool",
+                      messageID: context.assistantMessageID,
+                      callID: context.toolCallID,
+                    },
+                  })
+                  .pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
 
                 // Model selection is policy/config/session state, not an LLM-facing tool argument.
                 const model = agent.model ?? parent.model
@@ -176,5 +192,32 @@ export const Plugin = {
         ),
       )
       .pipe(Effect.orDie)
+
+    yield* ctx.session.hook("request", (event) =>
+      Effect.gen(function* () {
+        const tool = event.tools[name]
+        if (!tool) return
+        const selected = yield* agents.resolve(event.agent)
+        if (!selected) return
+        const available = (yield* agents.list())
+          .filter(
+            (agent) =>
+              agent.mode !== "primary" &&
+              !agent.hidden &&
+              PermissionV2.evaluate(name, agent.id, selected.permissions).effect !== "deny",
+          )
+          .toSorted((a, b) => a.id.localeCompare(b.id))
+        if (available.length === 0) return
+        tool.description = [
+          tool.description,
+          "",
+          "Available subagents:",
+          ...available.map(
+            (agent) =>
+              `- ${agent.id}: ${agent.description ?? "This subagent should only be called when explicitly requested."}`,
+          ),
+        ].join("\n")
+      }),
+    )
   }),
 }

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

@@ -1,6 +1,6 @@
 export * as TodoWriteTool from "./todowrite"
 
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Schema } from "effect"
 import { PermissionV2 } from "../permission"

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

@@ -1,6 +1,6 @@
 export * as WebFetchTool from "./webfetch"
 
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Duration, Effect, Schema } from "effect"
 import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"

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

@@ -1,6 +1,6 @@
 export * as WebSearchTool from "./websearch"
 
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Context, Duration, Effect, Layer, Schema } from "effect"
 import { HttpClient, HttpClientRequest } from "effect/unstable/http"

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

@@ -6,7 +6,7 @@
  */
 export * as WriteTool from "./write"
 
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Schema } from "effect"
 import { FileMutation } from "../file-mutation"

+ 2 - 2
packages/core/test/config/fixtures/plugin/directory-plugin.ts

@@ -1,6 +1,6 @@
-import { define } from "@opencode-ai/plugin/v2/promise"
+import { Plugin } from "@opencode-ai/plugin/v2"
 
-export default define({
+export default Plugin.define({
   id: "directory-plugin",
   setup: async (ctx) => {
     await ctx.agent.transform((agents) => {

+ 2 - 2
packages/core/test/config/fixtures/plugins/folder-plugin/index.ts

@@ -1,6 +1,6 @@
-import { define } from "@opencode-ai/plugin/v2/promise"
+import { Plugin } from "@opencode-ai/plugin/v2"
 
-export default define({
+export default Plugin.define({
   id: "folder-plugin",
   setup: async (ctx) => {
     await ctx.agent.transform((agents) => {

+ 3 - 3
packages/core/test/config/plugin.test.ts

@@ -2,7 +2,7 @@ import fs from "fs/promises"
 import path from "path"
 import { pathToFileURL } from "url"
 import { describe, expect } from "bun:test"
-import { define } from "@opencode-ai/plugin/v2/effect"
+import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
 import { Config as ConfigSchema } from "@opencode-ai/schema/config"
 import { Plugin } from "@opencode-ai/schema/plugin"
 import { AgentV2 } from "@opencode-ai/core/agent"
@@ -178,7 +178,7 @@ describe("PluginSupervisor config", () => {
   it.live("loads user plugins before internal post plugins", () =>
     Effect.gen(function* () {
       const sdk = yield* SdkPlugins.Service
-      yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
+      yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void }))
       yield* withLocation(
         {
           plugins: [
@@ -275,7 +275,7 @@ function mutablePlugin(description: string) {
   return `
 import { define } from ${JSON.stringify(plugin)}
 
-export default define({
+export default EffectPlugin.define({
   id: "mutable-plugin",
   setup: async (ctx) => {
     await ctx.agent.transform((agents) => {

+ 4 - 6
packages/core/test/lib/tool.ts

@@ -4,7 +4,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { Tool } from "@opencode-ai/core/tool/tool"
 import { Tools } from "@opencode-ai/core/tool/tools"
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
 import { Effect, type Scope } from "effect"
 
 export const toolIdentity = {
@@ -66,12 +66,10 @@ export const registerToolPlugin = <R>(plugin: {
               registrations,
               (registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
               { discard: true },
-            )
+            ).pipe(Effect.orDie)
+            return { dispose: Effect.void }
           }),
-        execute: {
-          before: () => Effect.die("registerToolPlugin does not support tool hooks"),
-          after: () => Effect.die("registerToolPlugin does not support tool hooks"),
-        },
+        hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
       },
     }
     yield* plugin.effect(context as PluginContext)

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

@@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
 import { Config } from "@opencode-ai/schema/config"
 import { Plugin } from "@opencode-ai/schema/plugin"
 import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect"
-import { define } from "@opencode-ai/plugin/v2/effect"
+import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -44,7 +44,7 @@ describe("LocationServiceMap", () => {
           const sdk = yield* SdkPlugins.Service
           const locations = yield* LocationServiceMap.Service
           const id = AgentV2.ID.make("persistent-sdk-agent")
-          const plugin = define({
+          const plugin = EffectPlugin.define({
             id: "persistent-sdk-plugin",
             effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})),
           })
@@ -432,7 +432,7 @@ describe("LocationServiceMap", () => {
       Effect.flatMap((dir) =>
         Effect.gen(function* () {
           const plugins = yield* PluginV2.Service
-          const reviewer = define({
+          const reviewer = EffectPlugin.define({
             id: "reviewer",
             effect: (ctx) =>
               ctx.agent

+ 47 - 0
packages/core/test/plugin-hooks.test.ts

@@ -0,0 +1,47 @@
+import { describe, expect, it } from "bun:test"
+import { Message, SystemPart } from "@opencode-ai/llm"
+import { Agent } from "@opencode-ai/schema/agent"
+import { Model } from "@opencode-ai/schema/model"
+import { Provider } from "@opencode-ai/schema/provider"
+import { Session } from "@opencode-ai/schema/session"
+import { Effect, Layer } from "effect"
+import { PluginHooks } from "../src/plugin/hooks"
+
+describe("PluginHooks", () => {
+  it("registers scoped domain hooks and triggers them sequentially", async () => {
+    const seen: string[] = []
+    const program = Effect.gen(function* () {
+      const hooks = yield* PluginHooks.Service
+      yield* hooks.register("session", "request", (event) =>
+        Effect.sync(() => {
+          seen.push("first")
+          event.system.push(SystemPart.make("second"))
+        }),
+      )
+      yield* hooks.register("session", "request", (event) =>
+        Effect.sync(() => {
+          seen.push(event.system[1]?.text ?? "missing")
+          event.messages = [Message.user("changed")]
+        }),
+      )
+      const event = {
+        sessionID: Session.ID.make("ses_hooks"),
+        agent: Agent.ID.make("build"),
+        model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
+        system: [SystemPart.make("first")],
+        messages: [Message.user("original")],
+        tools: {},
+      }
+
+      expect(yield* hooks.trigger("session", "request", event)).toBe(event)
+      expect(seen).toEqual(["first", "second"])
+      expect(event.messages).toEqual([Message.user("changed")])
+    })
+
+    await Effect.runPromise(
+      Effect.scoped(program).pipe(
+        Effect.provide(PluginHooks.node.implementation as Layer.Layer<PluginHooks.Service>),
+      ),
+    )
+  })
+})

+ 46 - 27
packages/core/test/plugin.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect } from "bun:test"
 import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
-import { define } from "@opencode-ai/plugin/v2/effect"
+import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
 import { Config as ConfigSchema } from "@opencode-ai/schema/config"
 import { Plugin } from "@opencode-ai/schema/plugin"
 import { AgentV2 } from "@opencode-ai/core/agent"
@@ -49,7 +49,7 @@ describe("PluginV2", () => {
         .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
 
       const managed = () =>
-        define({
+        EffectPlugin.define({
           id: "managed",
           effect: (ctx) =>
             ctx.agent
@@ -97,25 +97,40 @@ describe("PluginV2", () => {
     }),
   )
 
-  it.effect("retries the same generation after materialization fails", () =>
+  it.effect("skips failed plugins and loads the rest", () =>
     Effect.gen(function* () {
       const plugins = yield* PluginV2.Service
+      const agents = yield* AgentV2.Service
       let fail = true
-      const plugin = define({
-        id: "retry",
+      const good = EffectPlugin.define({
+        id: "good",
         effect: (ctx) =>
           ctx.agent
-            .transform(() => {
-              if (fail) throw new Error("materialization failed")
-            })
+            .transform((agents) =>
+              agents.update("configured", (agent) => {
+                agent.description = "loaded"
+              }),
+            )
             .pipe(Effect.asVoid),
       })
+      const bad = EffectPlugin.define({
+        id: "bad",
+        effect: () => {
+          if (fail) return Effect.die(new Error("materialization failed"))
+          return Effect.void
+        },
+      })
 
-      expect(Exit.isFailure(yield* plugins.activate([{ plugin }]).pipe(Effect.exit))).toBe(true)
-      fail = false
-      yield* plugins.activate([{ plugin }])
+      yield* plugins.activate([{ plugin: good }, { plugin: bad }])
+      expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
+      expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("loaded")
 
-      expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("retry") }])
+      fail = false
+      yield* plugins.activate([{ plugin: good }, { plugin: bad }])
+      expect(yield* plugins.list()).toEqual([
+        { id: Plugin.ID.make("good") },
+        { id: Plugin.ID.make("bad") },
+      ])
     }),
   )
 
@@ -142,7 +157,7 @@ describe("PluginV2", () => {
     Effect.gen(function* () {
       const plugins = yield* PluginV2.Service
       let visible = true
-      const plugin = define({
+      const plugin = EffectPlugin.define({
         id: "isolated",
         effect: () =>
           Effect.serviceOption(Secret).pipe(
@@ -161,7 +176,7 @@ describe("PluginV2", () => {
     Effect.gen(function* () {
       const plugins = yield* PluginV2.Service
       const registry = yield* ToolRegistry.Service
-      const plugin = define({
+      const plugin = EffectPlugin.define({
         id: "tool-plugin",
         effect: (ctx) =>
           ctx.tool
@@ -202,7 +217,7 @@ describe("PluginV2", () => {
           output: Schema.Struct({ ok: Schema.Boolean }),
           execute: () => Effect.succeed({ ok: true }),
         })
-      const plugin = define({
+      const plugin = EffectPlugin.define({
         id: "grouped-tools",
         effect: (ctx) =>
           ctx.tool
@@ -234,7 +249,7 @@ describe("PluginV2", () => {
         after?: { input: unknown; result: unknown; output: unknown }
       } = {}
 
-      const plugin = define({
+      const plugin = EffectPlugin.define({
         id: "tool-hooks",
         effect: (ctx) =>
           Effect.gen(function* () {
@@ -252,19 +267,23 @@ describe("PluginV2", () => {
               )
               .pipe(Effect.orDie)
 
-            yield* ctx.tool.execute
-              .before((event) => {
-                seen.before = event.input
-                event.input = { text: "before-mutated" }
-              })
+            yield* ctx.tool
+              .hook("execute.before", (event) =>
+                Effect.sync(() => {
+                  seen.before = event.input
+                  event.input = { text: "before-mutated" }
+                }),
+              )
               .pipe(Effect.asVoid)
 
-            yield* ctx.tool.execute
-              .after((event) => {
-                seen.after = { input: event.input, result: event.result, output: event.output }
-                event.result = { type: "text", value: "after-mutated" }
-                event.output = { structured: { rewritten: true }, content: [] }
-              })
+            yield* ctx.tool
+              .hook("execute.after", (event) =>
+                Effect.sync(() => {
+                  seen.after = { input: event.input, result: event.result, output: event.output }
+                  event.result = { type: "text", value: "after-mutated" }
+                  event.output = { structured: { rewritten: true }, content: [] }
+                }),
+              )
               .pipe(Effect.asVoid)
           }),
       })

+ 1 - 0
packages/core/test/plugin/fixture.ts

@@ -13,6 +13,7 @@ import { Integration } from "@opencode-ai/core/integration"
 import { Location } from "@opencode-ai/core/location"
 import { Npm } from "@opencode-ai/core/npm"
 import { PluginV2 } from "@opencode-ai/core/plugin"
+import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
 import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
 import { Reference } from "@opencode-ai/core/reference"
 import { SkillV2 } from "@opencode-ai/core/skill"

+ 2 - 2
packages/core/test/plugin/fixtures/config-effect-plugin.ts

@@ -1,7 +1,7 @@
-import { define } from "@opencode-ai/plugin/v2/effect"
+import { Plugin } from "@opencode-ai/plugin/v2/effect"
 import { Effect } from "effect"
 
-export default define({
+export default Plugin.define({
   id: "config-effect-plugin",
   effect: (ctx) =>
     ctx.agent

+ 2 - 2
packages/core/test/plugin/fixtures/config-promise-plugin.ts

@@ -1,6 +1,6 @@
-import { define } from "@opencode-ai/plugin/v2/promise"
+import { Plugin } from "@opencode-ai/plugin/v2"
 
-export default define({
+export default Plugin.define({
   id: "config-promise-plugin",
   setup: async (ctx) => {
     await ctx.agent.transform((agents) => {

+ 2 - 2
packages/core/test/plugin/fixtures/failing-plugin.ts

@@ -1,7 +1,7 @@
-import { define } from "@opencode-ai/plugin/v2/effect"
+import { Plugin } from "@opencode-ai/plugin/v2/effect"
 import { Effect } from "effect"
 
-export default define({
+export default Plugin.define({
   id: "failing-plugin",
   effect: () => Effect.die("plugin failed"),
 })

+ 2 - 2
packages/core/test/plugin/fixtures/variant-source-plugin.ts

@@ -1,8 +1,8 @@
-import { define } from "@opencode-ai/plugin/v2/effect"
+import { Plugin } from "@opencode-ai/plugin/v2/effect"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { Effect } from "effect"
 
-export default define({
+export default Plugin.define({
   id: "variant-source",
   effect: (ctx) =>
     ctx.catalog

+ 22 - 17
packages/core/test/plugin/host.ts

@@ -1,4 +1,4 @@
-import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
+import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { Credential } from "@opencode-ai/core/credential"
@@ -19,8 +19,7 @@ export function host(overrides: Overrides = {}): PluginContext {
       reload: () => Effect.die("unused agent.reload"),
     },
     aisdk: overrides.aisdk ?? {
-      sdk: () => Effect.die("unused aisdk.sdk"),
-      language: () => Effect.die("unused aisdk.language"),
+      hook: () => Effect.die("unused aisdk.hook"),
     },
     catalog: overrides.catalog ?? {
       provider: {
@@ -45,11 +44,15 @@ export function host(overrides: Overrides = {}): PluginContext {
     integration: overrides.integration ?? {
       list: () => Effect.die("unused integration.list"),
       get: () => Effect.die("unused integration.get"),
-      connectKey: () => Effect.die("unused integration.connectKey"),
-      connectOauth: () => Effect.die("unused integration.connectOauth"),
-      attemptStatus: () => Effect.die("unused integration.attemptStatus"),
-      attemptComplete: () => Effect.die("unused integration.attemptComplete"),
-      attemptCancel: () => Effect.die("unused integration.attemptCancel"),
+      connect: {
+        key: () => Effect.die("unused integration.connect.key"),
+        oauth: () => Effect.die("unused integration.connect.oauth"),
+      },
+      attempt: {
+        status: () => Effect.die("unused integration.attempt.status"),
+        complete: () => Effect.die("unused integration.attempt.complete"),
+        cancel: () => Effect.die("unused integration.attempt.cancel"),
+      },
       transform: () => Effect.die("unused integration.transform"),
       reload: () => Effect.die("unused integration.reload"),
       connection: {
@@ -72,10 +75,7 @@ export function host(overrides: Overrides = {}): PluginContext {
     },
     tool: overrides.tool ?? {
       transform: () => Effect.die("unused tool.transform"),
-      execute: {
-        before: () => Effect.die("unused tool.execute.before"),
-        after: () => Effect.die("unused tool.execute.after"),
-      },
+      hook: () => Effect.die("unused tool.hook"),
     },
     session: overrides.session ?? {
       create: () => Effect.die("unused session.create"),
@@ -83,6 +83,7 @@ export function host(overrides: Overrides = {}): PluginContext {
       prompt: () => Effect.die("unused session.prompt"),
       command: () => Effect.die("unused session.command"),
       interrupt: () => Effect.die("unused session.interrupt"),
+      hook: () => Effect.die("unused session.hook"),
     },
   }
 }
@@ -188,11 +189,15 @@ export function integrationHost(integration: Integration.Interface): PluginConte
   return {
     list: () => Effect.die("unused integration.list"),
     get: () => Effect.die("unused integration.get"),
-    connectKey: () => Effect.die("unused integration.connectKey"),
-    connectOauth: () => Effect.die("unused integration.connectOauth"),
-    attemptStatus: () => Effect.die("unused integration.attemptStatus"),
-    attemptComplete: () => Effect.die("unused integration.attemptComplete"),
-    attemptCancel: () => Effect.die("unused integration.attemptCancel"),
+    connect: {
+      key: () => Effect.die("unused integration.connect.key"),
+      oauth: () => Effect.die("unused integration.connect.oauth"),
+    },
+    attempt: {
+      status: () => Effect.die("unused integration.attempt.status"),
+      complete: () => Effect.die("unused integration.attempt.complete"),
+      cancel: () => Effect.die("unused integration.attempt.cancel"),
+    },
     reload: integration.reload,
     connection: {
       active: (id) => integration.connection.active(Integration.ID.make(id)),

+ 4 - 4
packages/core/test/plugin/promise.test.ts

@@ -4,7 +4,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { PluginHost } from "@opencode-ai/core/plugin/host"
 import { PluginPromise } from "@opencode-ai/core/plugin/promise"
-import { define } from "@opencode-ai/plugin/v2/promise"
+import { Plugin } from "@opencode-ai/plugin/v2"
 import { testEffect } from "../lib/effect"
 import { PluginTestLayer } from "./fixture"
 
@@ -16,7 +16,7 @@ describe("fromPromise", () => {
       const plugin = yield* PluginV2.Service
       const host = yield* PluginHost.make(plugin)
       const seen: string[] = []
-      const promisePlugin = define({
+      const promisePlugin = Plugin.define({
         id: "promise-client-reads",
         setup: async (ctx) => {
           const results = await Promise.all([
@@ -46,7 +46,7 @@ describe("fromPromise", () => {
       const plugin = yield* PluginV2.Service
       const host = yield* PluginHost.make(plugin)
 
-      const promisePlugin = define({
+      const promisePlugin = Plugin.define({
         id: "promise-example",
         setup: async (ctx) => {
           expect(ctx.options.mode).toBe("strict")
@@ -75,7 +75,7 @@ describe("fromPromise", () => {
       const plugin = yield* PluginV2.Service
       const host = yield* PluginHost.make(plugin)
 
-      const promisePlugin = define({
+      const promisePlugin = Plugin.define({
         id: "promise-dispose",
         setup: async (ctx) => {
           const registration = await ctx.agent.transform((draft) => {

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

@@ -82,7 +82,7 @@ describe("ToolRegistry", () => {
     }),
   )
 
-  it.effect("selects one edit tool family for each model", () =>
+  it.effect("materializes all permission-eligible edit tools before request policy", () =>
     Effect.gen(function* () {
       const service = yield* ToolRegistry.Service
       yield* service.register({
@@ -96,10 +96,13 @@ describe("ToolRegistry", () => {
           .materialize({ model })
           .pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name)))
 
-      expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "patch"])
-      expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "patch"])
-      expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "patch"])
-      expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write"])
+      expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "edit", "write", "patch"])
+      expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual([
+        "read",
+        "edit",
+        "write",
+        "patch",
+      ])
     }),
   )
 

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 573 - 237
packages/docs/openapi.json


+ 12 - 16
packages/httpapi-codegen/src/index.ts

@@ -1,7 +1,7 @@
 import { isAbsolute, join } from "node:path"
-import { Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect"
+import { Context, Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect"
 import { HttpMethod, type HttpRouter } from "effect/unstable/http"
-import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
+import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import { format } from "prettier"
 
 export type InputField = {
@@ -35,8 +35,6 @@ export type Contract = {
   readonly groups: ReadonlyArray<Group>
 }
 
-export type EndpointName = string | readonly [string, ...Array<string>]
-
 export class GenerationError extends Schema.TaggedErrorClass<GenerationError>()("GenerationError", {
   reason: Schema.String,
 }) {
@@ -89,7 +87,6 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
   api: HttpApi.HttpApi<Id, Groups>,
   options?: {
     readonly groupNames?: Readonly<Record<string, string>>
-    readonly endpointNames?: Readonly<Record<string, EndpointName>>
     readonly omitEndpoints?: ReadonlySet<string>
   },
 ): Contract {
@@ -151,8 +148,11 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
         for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable)
       }
 
-      const clientPath = normalizeClientPath(
-        options?.endpointNames?.[endpoint.name] ?? clientEndpointName(endpoint.name),
+      const clientPath = clientEndpointPath(
+        group.identifier,
+        Context.getOrElse(endpoint.annotations, OpenApi.Identifier, () =>
+          group.topLevel ? endpoint.name : `${group.identifier}.${endpoint.name}`,
+        ),
       )
       endpoints.push({
         group: groupName,
@@ -699,21 +699,17 @@ function clientOperationKey(group: Group, endpoint: Endpoint) {
   return [group.identifier, ...endpoint.clientPath].join(".")
 }
 
-function clientEndpointName(name: string) {
-  return name.slice(name.lastIndexOf(".") + 1)
-}
-
-function normalizeClientPath(path: EndpointName) {
-  const result = typeof path === "string" ? [path] : [...path]
+function clientEndpointPath(group: string, name: string) {
+  const parts = name.split(".")
+  if (parts[0] === "v2") parts.shift()
+  const index = parts.lastIndexOf(group.slice(group.lastIndexOf(".") + 1))
+  const result = index < 0 ? parts : parts.slice(index + 1)
   if (result.length === 0 || result.some((part) => part.length === 0)) {
     throw new GenerationError({ reason: "Client endpoint path must contain non-empty names" })
   }
   if (result.some((part) => part === "__proto__")) {
     throw new GenerationError({ reason: "Client endpoint path cannot contain __proto__" })
   }
-  if (typeof path !== "string" && result.some((part) => part.includes("."))) {
-    throw new GenerationError({ reason: "Nested client endpoint path segments cannot contain dots" })
-  }
   return result as [string, ...Array<string>]
 }
 

+ 112 - 82
packages/httpapi-codegen/test/generate.test.ts

@@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"
 import { tmpdir } from "node:os"
 import { join } from "node:path"
 import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect"
-import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi"
+import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import { format } from "prettier"
 import {
   compile as compileContract,
@@ -139,48 +139,50 @@ describe("HttpApiCodegen.generate", () => {
     expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" })
   })
 
-  test("supports explicit public endpoint names", () => {
+  test("derives nested paths from OpenAPI operation IDs", () => {
     const source = HttpApi.make("test").add(
-      HttpApiGroup.make("server.permission")
-        .add(HttpApiEndpoint.get("permission.request.list", "/request", { success: Schema.String }))
-        .add(HttpApiEndpoint.get("session.permission.list", "/session", { success: Schema.String })),
+      HttpApiGroup.make("server.session").add(
+        HttpApiEndpoint.get("internal.stage", "/session/revert/stage", { success: Schema.String }).annotateMerge(
+          OpenApi.annotations({ identifier: "v2.session.revert.stage" }),
+        ),
+      ),
     )
-    const contract = compileContract(source, {
-      endpointNames: { "permission.request.list": "listRequests" },
-    })
+    const contract = compileContract(source, { groupNames: { "server.session": "session" } })
 
-    expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual(["listRequests", "list"])
+    expect(contract.groups[0]?.endpoints[0]?.clientPath).toEqual(["revert", "stage"])
+    expect(OpenApi.fromApi(source).paths["/session/revert/stage"]?.get?.operationId).toBe("v2.session.revert.stage")
   })
 
-  test("supports explicit nested endpoint paths while string aliases remain flat", () => {
+  test("uses nested OpenAPI operation IDs across emitters", () => {
     const source = HttpApi.make("test").add(
       HttpApiGroup.make("server.session")
-        .add(HttpApiEndpoint.get("session.instructions.list", "/session/instructions", { success: Schema.String }))
-        .add(HttpApiEndpoint.put("session.instructions.put", "/session/instructions", { success: Schema.String }))
-        .add(HttpApiEndpoint.delete("session.instructions.remove", "/session/instructions", { success: Schema.String }))
-        .add(HttpApiEndpoint.get("session.messages", "/session/message", { success: Schema.String })),
-    )
-    const contract = compileContract(source, {
-      groupNames: { "server.session": "session" },
-      endpointNames: {
-        "session.instructions.list": ["instructions", "list"],
-        "session.instructions.put": ["instructions", "put"],
-        "session.instructions.remove": ["instructions", "remove"],
-        "session.messages": "instructions.flat",
-      },
-    })
+        .add(
+          HttpApiEndpoint.get("list", "/session/instructions", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "v2.session.instructions.list" }),
+          ),
+        )
+        .add(
+          HttpApiEndpoint.put("put", "/session/instructions", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "v2.session.instructions.put" }),
+          ),
+        )
+        .add(
+          HttpApiEndpoint.delete("remove", "/session/instructions", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "v2.session.instructions.remove" }),
+          ),
+        ),
+    )
+    const contract = compileContract(source, { groupNames: { "server.session": "session" } })
 
     expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.clientPath)).toEqual([
       ["instructions", "list"],
       ["instructions", "put"],
       ["instructions", "remove"],
-      ["instructions.flat"],
     ])
     expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual([
       "instructions.list",
       "instructions.put",
       "instructions.remove",
-      "instructions.flat",
     ])
 
     const promise = emitPromise(contract, {
@@ -196,7 +198,6 @@ describe("HttpApiCodegen.generate", () => {
     expect(promiseClient).toContain('"session": { "instructions": { "list": (requestOptions?: RequestOptions)')
     expect(promiseClient).toContain('"put": (requestOptions?: RequestOptions)')
     expect(promiseClient).toContain('"remove": (requestOptions?: RequestOptions)')
-    expect(promiseClient).toContain('"instructions.flat": (requestOptions?: RequestOptions)')
     expect(promiseTypes).toContain('import type { InstructionListWire } from "./instruction-list-wire"')
     expect(promiseTypes).toContain("export type SessionInstructionsListOutput = InstructionListWire")
     expect(promiseTypes).toContain("export type SessionInstructionsPutOutput = string")
@@ -204,12 +205,12 @@ describe("HttpApiCodegen.generate", () => {
 
     const effect = emitEffect(contract)
     expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain(
-      '"instructions": { "list": Endpoint0(raw), "put": Endpoint1(raw), "remove": Endpoint2(raw) }, "instructions.flat": Endpoint3(raw)',
+      '"instructions": { "list": Endpoint0(raw), "put": Endpoint1(raw), "remove": Endpoint2(raw) }',
     )
 
     const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" })
     expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain(
-      '"instructions": { "list": Endpoint0_0(raw), "put": Endpoint0_1(raw), "remove": Endpoint0_2(raw) }, "instructions.flat": Endpoint0_3(raw)',
+      '"instructions": { "list": Endpoint0_0(raw), "put": Endpoint0_1(raw), "remove": Endpoint0_2(raw) }',
     )
 
     const shape = emitEffectShape(contract, { module: "@example/api", api: "Api" })
@@ -217,25 +218,28 @@ describe("HttpApiCodegen.generate", () => {
     expect(apiShape).toContain('readonly "instructions": { readonly "list": SessionInstructionsListOperation<E>')
     expect(apiShape).toContain('readonly "put": SessionInstructionsPutOperation<E>')
     expect(apiShape).toContain('readonly "remove": SessionInstructionsRemoveOperation<E>')
-    expect(apiShape).toContain('readonly "instructions.flat": SessionInstructionsFlatOperation<E>')
   })
 
-  test("executes nested Promise endpoint aliases", async () => {
+  test("executes nested Promise operation IDs", async () => {
     const source = HttpApi.make("test").add(
       HttpApiGroup.make("session")
-        .add(HttpApiEndpoint.get("instructions.list", "/session/instructions", { success: Schema.String }))
-        .add(HttpApiEndpoint.put("instructions.put", "/session/instructions", { success: Schema.String }))
-        .add(HttpApiEndpoint.delete("instructions.remove", "/session/instructions", { success: Schema.String })),
-    )
-    const output = emitPromise(
-      compileContract(source, {
-        endpointNames: {
-          "instructions.list": ["instructions", "list"],
-          "instructions.put": ["instructions", "put"],
-          "instructions.remove": ["instructions", "remove"],
-        },
-      }),
+        .add(
+          HttpApiEndpoint.get("list", "/session/instructions", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "session.instructions.list" }),
+          ),
+        )
+        .add(
+          HttpApiEndpoint.put("put", "/session/instructions", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "session.instructions.put" }),
+          ),
+        )
+        .add(
+          HttpApiEndpoint.delete("remove", "/session/instructions", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "session.instructions.remove" }),
+          ),
+        ),
     )
+    const output = emitPromise(compileContract(source))
     const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
     const methods: Array<string> = []
 
@@ -262,59 +266,67 @@ describe("HttpApiCodegen.generate", () => {
   test("rejects duplicate and leaf-namespace endpoint paths", () => {
     const source = HttpApi.make("test").add(
       HttpApiGroup.make("session")
-        .add(HttpApiEndpoint.get("first", "/first", { success: Schema.String }))
-        .add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })),
+        .add(
+          HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "session.instructions.list" }),
+          ),
+        )
+        .add(
+          HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "session.instructions.list" }),
+          ),
+        ),
     )
 
-    expect(() =>
-      compileContract(source, { endpointNames: { first: ["instructions", "list"], second: ["instructions", "list"] } }),
-    ).toThrow("Client endpoint name collision: session.instructions.list")
-    expect(() =>
-      compileContract(source, { endpointNames: { first: "instructions", second: ["instructions", "list"] } }),
-    ).toThrow("Client endpoint name collision: session.instructions.list")
+    expect(() => compileContract(source)).toThrow("Client endpoint name collision: session.instructions.list")
   })
 
   test("rejects nested root collisions across top-level groups", () => {
     const source = HttpApi.make("test")
       .add(
         HttpApiGroup.make("first", { topLevel: true }).add(
-          HttpApiEndpoint.get("first.list", "/first", { success: Schema.String }),
+          HttpApiEndpoint.get("first.list", "/first", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "instructions.list" }),
+          ),
         ),
       )
       .add(
         HttpApiGroup.make("second", { topLevel: true }).add(
-          HttpApiEndpoint.get("second.put", "/second", { success: Schema.String }),
+          HttpApiEndpoint.get("second.put", "/second", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "instructions.put" }),
+          ),
         ),
       )
 
-    expect(() =>
-      compileContract(source, {
-        endpointNames: { "first.list": ["instructions", "list"], "second.put": ["instructions", "put"] },
-      }),
-    ).toThrow("Client name collision: instructions")
+    expect(() => compileContract(source)).toThrow("Client name collision: instructions")
   })
 
   test("rejects nested paths that collide after type-name normalization", () => {
     const source = HttpApi.make("test").add(
       HttpApiGroup.make("session")
-        .add(HttpApiEndpoint.get("first", "/first", { success: Schema.String }))
-        .add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })),
+        .add(
+          HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "session.foo.bar" }),
+          ),
+        )
+        .add(
+          HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "session.foo-bar" }),
+          ),
+        ),
     )
 
-    expect(() => compileContract(source, { endpointNames: { first: ["foo", "bar"], second: "foo-bar" } })).toThrow(
-      "Client endpoint type collision: SessionFooBar",
-    )
+    expect(() => compileContract(source)).toThrow("Client endpoint type collision: SessionFooBar")
   })
 
   test("rejects ambiguous and prototype-mutating nested path segments", () => {
-    const source = api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }))
-
-    expect(() => compileContract(source, { endpointNames: { get: ["a.b", "get"] } })).toThrow(
-      "Nested client endpoint path segments cannot contain dots",
-    )
-    expect(() => compileContract(source, { endpointNames: { get: ["__proto__", "get"] } })).toThrow(
-      "Client endpoint path cannot contain __proto__",
+    const source = api(
+      HttpApiEndpoint.get("get", "/session", { success: Schema.String }).annotateMerge(
+        OpenApi.annotations({ identifier: "session.__proto__.get" }),
+      ),
     )
+
+    expect(() => compileContract(source)).toThrow("Client endpoint path cannot contain __proto__")
   })
 
   test("rejects normalized group, operation-key, and group prototype collisions", () => {
@@ -324,20 +336,38 @@ describe("HttpApiCodegen.generate", () => {
     expect(() => compileContract(normalized)).toThrow("Client group type collision: FooBar")
 
     const endpointType = HttpApi.make("test")
-      .add(HttpApiGroup.make("foo").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
-      .add(HttpApiGroup.make("fooBar").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })))
-    expect(() =>
-      compileContract(endpointType, {
-        endpointNames: { first: ["bar", "baz"], second: ["baz"] },
-      }),
-    ).toThrow("Client endpoint type collision: FooBarBaz")
+      .add(
+        HttpApiGroup.make("foo").add(
+          HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "foo.bar.baz" }),
+          ),
+        ),
+      )
+      .add(
+        HttpApiGroup.make("fooBar").add(
+          HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "fooBar.baz" }),
+          ),
+        ),
+      )
+    expect(() => compileContract(endpointType)).toThrow("Client endpoint type collision: FooBarBaz")
 
     const operationKey = HttpApi.make("test")
-      .add(HttpApiGroup.make("a.b").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String })))
-      .add(HttpApiGroup.make("a").add(HttpApiEndpoint.get("b.c", "/second", { success: Schema.String })))
-    expect(() => compileContract(operationKey, { endpointNames: { get: "c", "b.c": ["b", "c"] } })).toThrow(
-      "Client operation key collision: a.b.c",
-    )
+      .add(
+        HttpApiGroup.make("a.b").add(
+          HttpApiEndpoint.get("get", "/first", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "a.b.c" }),
+          ),
+        ),
+      )
+      .add(
+        HttpApiGroup.make("a").add(
+          HttpApiEndpoint.get("b.c", "/second", { success: Schema.String }).annotateMerge(
+            OpenApi.annotations({ identifier: "a.b.c" }),
+          ),
+        ),
+      )
+    expect(() => compileContract(operationKey)).toThrow("Client operation key collision: a.b.c")
 
     const prototype = HttpApi.make("test").add(
       HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })),

+ 3 - 4
packages/plugin/package.json

@@ -14,10 +14,9 @@
     "./tool": "./src/tool.ts",
     "./tui": "./src/tui.ts",
     "./v2/effect": "./src/v2/effect/index.ts",
-    "./v2/effect/integration": "./src/v2/effect/integration.ts",
-    "./v2/effect/plugin": "./src/v2/effect/plugin.ts",
-    "./v2/effect/tool": "./src/v2/effect/tool.ts",
-    "./v2/promise": "./src/v2/promise/index.ts"
+    "./v2/effect/*": "./src/v2/effect/*.ts",
+    "./v2": "./src/v2/promise/index.ts",
+    "./v2/*": "./src/v2/promise/*.ts"
   },
   "files": [
     "dist"

+ 1 - 1
packages/plugin/src/v2/effect/PLAN.md

@@ -176,7 +176,7 @@ Both use the same low-level scoped registration registry, but consumers invoke t
 
 ```ts
 ctx.tool.transform(...) // replayed to build effective tool registry state
-ctx.tool.hook(...)      // invoked at a live tool operation boundary
+ctx.tool.hook(...)     // invoked at a live tool operation boundary
 ```
 
 The shared low-level machinery owns registration order, scope cleanup, disposal, and snapshots. Each domain owns when its transforms or runtime hooks execute.

+ 16 - 7
packages/plugin/src/v2/effect/README.md

@@ -5,15 +5,13 @@ The Effect plugin API grants plugins two in-process capabilities:
 - `hook` installs behavior at an OpenCode extension point.
 - `reload` reruns every transform hook for a stateful domain.
 
-The public server client will be exposed separately. It is intentionally not part of `PluginContext` yet.
-
 ## Defining A Plugin
 
 ```ts
-import { define } from "@opencode-ai/plugin/v2/effect"
+import { Plugin } from "@opencode-ai/plugin/v2/effect"
 import { Effect } from "effect"
 
-export const Plugin = define({
+export default Plugin.define({
   id: "example",
   effect: Effect.fn(function* (ctx) {
     yield* ctx.catalog.transform((catalog) => {
@@ -25,7 +23,7 @@ export const Plugin = define({
 })
 ```
 
-Plugin setup registers hooks imperatively. It does not return a hook object.
+Plugin setup registers hooks imperatively through each domain's `hook` method.
 
 Configuration supplied for the plugin is available as `ctx.options`.
 
@@ -64,7 +62,8 @@ Runtime hooks intercept live operations rather than rebuilding domain state:
 
 ```ts
 yield *
-  ctx.aisdk.sdk(
+  ctx.aisdk.hook(
+    "sdk",
     Effect.fn(function* (event) {
       if (event.package !== "@ai-sdk/xai") return
       const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
@@ -73,7 +72,7 @@ yield *
   )
 
 yield *
-  ctx.aisdk.language((event) => {
+  ctx.aisdk.hook("language", (event) => {
     if (event.model.providerID !== "xai") return
     event.language = event.sdk.responses(event.model.api.id)
   })
@@ -81,6 +80,16 @@ yield *
 
 Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
 
+Session request context is mutable immediately before provider dispatch:
+
+```ts
+yield *
+  ctx.session.hook("request", (event) => {
+    event.tools.read.description = "Read a file using narrow line ranges."
+    delete event.tools.write
+  })
+```
+
 ## Reloading A Domain
 
 When data captured by a transform changes, reload the affected domain:

+ 3 - 3
packages/plugin/src/v2/effect/agent.ts

@@ -1,7 +1,7 @@
 import type { AgentApi } from "@opencode-ai/client/effect/api"
 import type { AgentV2Info } from "@opencode-ai/sdk/v2/types"
 import type { Effect } from "effect"
-import type { TransformHook } from "./registration.js"
+import type { Transform } from "./registration.js"
 
 export interface AgentDraft {
   list(): readonly AgentV2Info[]
@@ -11,7 +11,7 @@ export interface AgentDraft {
   remove(id: string): void
 }
 
-export interface AgentHooks extends AgentApi<unknown> {
-  readonly transform: TransformHook<AgentDraft>
+export interface AgentDomain extends AgentApi<unknown> {
+  readonly transform: Transform<AgentDraft>
   readonly reload: () => Effect.Effect<void>
 }

+ 6 - 2
packages/plugin/src/v2/effect/aisdk.ts

@@ -2,7 +2,7 @@ import type { LanguageModelV3 } from "@ai-sdk/provider"
 import type { Model } from "@opencode-ai/schema/model"
 import type { Hooks } from "./registration.js"
 
-export type AISDKHooks = Hooks<{
+export interface AISDKHooks {
   sdk: {
     readonly model: Model.Info
     readonly package: string
@@ -15,4 +15,8 @@ export type AISDKHooks = Hooks<{
     readonly options: Record<string, any>
     language?: LanguageModelV3
   }
-}>
+}
+
+export interface AISDKDomain {
+  readonly hook: Hooks<AISDKHooks>
+}

+ 3 - 3
packages/plugin/src/v2/effect/catalog.ts

@@ -1,7 +1,7 @@
 import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types"
 import type { CatalogApi } from "@opencode-ai/client/effect/api"
 import type { Effect } from "effect"
-import type { TransformHook } from "./registration.js"
+import type { Transform } from "./registration.js"
 
 export interface CatalogProviderRecord {
   readonly provider: ProviderV2Info
@@ -26,7 +26,7 @@ export interface CatalogDraft {
   }
 }
 
-export interface CatalogHooks extends CatalogApi<unknown> {
-  readonly transform: TransformHook<CatalogDraft>
+export interface CatalogDomain extends CatalogApi<unknown> {
+  readonly transform: Transform<CatalogDraft>
   readonly reload: () => Effect.Effect<void>
 }

+ 3 - 3
packages/plugin/src/v2/effect/command.ts

@@ -1,7 +1,7 @@
 import type { CommandV2Info } from "@opencode-ai/sdk/v2/types"
 import type { CommandApi } from "@opencode-ai/client/effect/api"
 import type { Effect } from "effect"
-import type { TransformHook } from "./registration.js"
+import type { Transform } from "./registration.js"
 
 export interface CommandDraft {
   list(): readonly CommandV2Info[]
@@ -10,7 +10,7 @@ export interface CommandDraft {
   remove(name: string): void
 }
 
-export interface CommandHooks extends CommandApi<unknown> {
-  readonly transform: TransformHook<CommandDraft>
+export interface CommandDomain extends CommandApi<unknown> {
+  readonly transform: Transform<CommandDraft>
   readonly reload: () => Effect.Effect<void>
 }

+ 0 - 27
packages/plugin/src/v2/effect/context.ts

@@ -1,27 +0,0 @@
-import type { PluginOptions } from "../options.js"
-import type { AgentHooks } from "./agent.js"
-import type { AISDKHooks } from "./aisdk.js"
-import type { CatalogHooks } from "./catalog.js"
-import type { CommandHooks } from "./command.js"
-import type { EventHooks } from "./event.js"
-import type { IntegrationHooks } from "./integration.js"
-import type { PluginDomain } from "./plugin.js"
-import type { ReferenceHooks } from "./reference.js"
-import type { SkillHooks } from "./skill.js"
-import type { ToolDomain } from "./tool.js"
-import type { SessionHooks } from "./runtime.js"
-
-export interface PluginContext {
-  readonly options: PluginOptions
-  readonly agent: AgentHooks
-  readonly aisdk: AISDKHooks
-  readonly catalog: CatalogHooks
-  readonly command: CommandHooks
-  readonly event: EventHooks
-  readonly integration: IntegrationHooks
-  readonly plugin: PluginDomain
-  readonly reference: ReferenceHooks
-  readonly skill: SkillHooks
-  readonly tool: ToolDomain
-  readonly session: SessionHooks
-}

+ 1 - 1
packages/plugin/src/v2/effect/event.ts

@@ -1,3 +1,3 @@
 import type { EventApi } from "@opencode-ai/client/effect/api"
 
-export interface EventHooks extends Pick<EventApi<unknown>, "subscribe"> {}
+export interface EventDomain extends Pick<EventApi<unknown>, "subscribe"> {}

+ 1 - 14
packages/plugin/src/v2/effect/index.ts

@@ -1,17 +1,4 @@
-export type { PluginContext } from "./context.js"
-export { define } from "./plugin.js"
-export type { Plugin, PluginDomain } from "./plugin.js"
-export type { AgentDraft, AgentHooks } from "./agent.js"
-export type { AISDKHooks } from "./aisdk.js"
-export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js"
-export type { CommandDraft, CommandHooks } from "./command.js"
-export type { EventHooks } from "./event.js"
-export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
-export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
-export type { SkillDraft, SkillHooks } from "./skill.js"
-export * as Tool from "./tool.js"
-export type { ToolDomain, ToolDraft, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js"
-export type { SessionHooks } from "./runtime.js"
+export * as Plugin from "./plugin.js"
 
 export { Agent } from "@opencode-ai/schema/agent"
 export { Command } from "@opencode-ai/schema/command"

+ 3 - 3
packages/plugin/src/v2/effect/integration.ts

@@ -11,7 +11,7 @@ import type {
 } from "@opencode-ai/sdk/v2/types"
 import type { IntegrationApi } from "@opencode-ai/client/effect/api"
 import type { Effect, Scope } from "effect"
-import type { TransformHook } from "./registration.js"
+import type { Transform } from "./registration.js"
 
 export type IntegrationOAuthAuthorization = {
   readonly url: string
@@ -56,8 +56,8 @@ export interface IntegrationDraft {
   }
 }
 
-export interface IntegrationHooks extends IntegrationApi<unknown> {
-  readonly transform: TransformHook<IntegrationDraft>
+export interface IntegrationDomain extends IntegrationApi<unknown> {
+  readonly transform: Transform<IntegrationDraft>
   readonly reload: () => Effect.Effect<void>
   readonly connection: {
     readonly active: (integrationID: string) => Effect.Effect<ConnectionInfo | undefined>

+ 27 - 4
packages/plugin/src/v2/effect/plugin.ts

@@ -1,14 +1,37 @@
 import type { PluginApi } from "@opencode-ai/client/effect/api"
 import type { Effect, Scope } from "effect"
-import type { PluginContext } from "./context.js"
+import type { PluginOptions } from "../options.js"
+import type { AgentDomain } from "./agent.js"
+import type { AISDKDomain } from "./aisdk.js"
+import type { CatalogDomain } from "./catalog.js"
+import type { CommandDomain } from "./command.js"
+import type { EventDomain } from "./event.js"
+import type { IntegrationDomain } from "./integration.js"
+import type { ReferenceDomain } from "./reference.js"
+import type { SessionDomain } from "./session.js"
+import type { SkillDomain } from "./skill.js"
+import type { ToolDomain } from "./tool.js"
+
+export interface Context {
+  readonly options: PluginOptions
+  readonly agent: AgentDomain
+  readonly aisdk: AISDKDomain
+  readonly catalog: CatalogDomain
+  readonly command: CommandDomain
+  readonly event: EventDomain
+  readonly integration: IntegrationDomain
+  readonly plugin: PluginApi<unknown>
+  readonly reference: ReferenceDomain
+  readonly session: SessionDomain
+  readonly skill: SkillDomain
+  readonly tool: ToolDomain
+}
 
 export interface Plugin<R = Scope.Scope> {
   readonly id: string
-  readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
+  readonly effect: (context: Context) => Effect.Effect<void, never, R>
 }
 
 export function define<R = Scope.Scope>(plugin: Plugin<R>) {
   return plugin
 }
-
-export interface PluginDomain extends PluginApi<unknown> {}

+ 3 - 3
packages/plugin/src/v2/effect/reference.ts

@@ -1,7 +1,7 @@
 import type { ReferenceGitSource, ReferenceLocalSource } from "@opencode-ai/sdk/v2/types"
 import type { ReferenceApi } from "@opencode-ai/client/effect/api"
 import type { Effect } from "effect"
-import type { TransformHook } from "./registration.js"
+import type { Transform } from "./registration.js"
 
 export interface ReferenceDraft {
   add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void
@@ -9,7 +9,7 @@ export interface ReferenceDraft {
   list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[]
 }
 
-export interface ReferenceHooks extends ReferenceApi<unknown> {
-  readonly transform: TransformHook<ReferenceDraft>
+export interface ReferenceDomain extends ReferenceApi<unknown> {
+  readonly transform: Transform<ReferenceDraft>
   readonly reload: () => Effect.Effect<void>
 }

+ 5 - 6
packages/plugin/src/v2/effect/registration.ts

@@ -4,10 +4,9 @@ export interface Registration {
   readonly dispose: Effect.Effect<void>
 }
 
-export type Hooks<Spec> = {
-  readonly [Name in keyof Spec]: (
-    callback: (input: Spec[Name]) => Effect.Effect<void> | void,
-  ) => Effect.Effect<Registration, never, Scope.Scope>
-}
+export type Hooks<Spec> = <Name extends keyof Spec>(
+  name: Name,
+  callback: (input: Spec[Name]) => Effect.Effect<void>,
+) => Effect.Effect<Registration, never, Scope.Scope>
 
-export type TransformHook<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>
+export type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>

+ 0 - 4
packages/plugin/src/v2/effect/runtime.ts

@@ -1,4 +0,0 @@
-import type { SessionApi } from "@opencode-ai/client/effect/api"
-
-export interface SessionHooks
-  extends Pick<SessionApi<unknown>, "create" | "get" | "prompt" | "command" | "interrupt"> {}

+ 25 - 0
packages/plugin/src/v2/effect/session.ts

@@ -0,0 +1,25 @@
+import type { SessionApi } from "@opencode-ai/client/effect/api"
+import type { Message, SystemPart } from "@opencode-ai/llm"
+import type { Agent } from "@opencode-ai/schema/agent"
+import type { Model } from "@opencode-ai/schema/model"
+import type { Session } from "@opencode-ai/schema/session"
+import type { JsonSchema } from "effect"
+import type { Hooks } from "./registration.js"
+
+export interface SessionRequestBeforeEvent {
+  readonly sessionID: Session.ID
+  readonly agent: Agent.ID
+  readonly model: Model.Ref
+  system: Array<SystemPart>
+  messages: Array<Message>
+  tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
+}
+
+export interface SessionHooks {
+  readonly request: SessionRequestBeforeEvent
+}
+
+export interface SessionDomain
+  extends Pick<SessionApi<unknown>, "create" | "get" | "prompt" | "command" | "interrupt"> {
+  readonly hook: Hooks<SessionHooks>
+}

+ 3 - 3
packages/plugin/src/v2/effect/skill.ts

@@ -1,14 +1,14 @@
 import type { SkillV2Source } from "@opencode-ai/sdk/v2/types"
 import type { SkillApi } from "@opencode-ai/client/effect/api"
 import type { Effect } from "effect"
-import type { TransformHook } from "./registration.js"
+import type { Transform } from "./registration.js"
 
 export interface SkillDraft {
   source(source: SkillV2Source): void
   list(): readonly SkillV2Source[]
 }
 
-export interface SkillHooks extends SkillApi<unknown> {
-  readonly transform: TransformHook<SkillDraft>
+export interface SkillDomain extends SkillApi<unknown> {
+  readonly transform: Transform<SkillDraft>
   readonly reload: () => Effect.Effect<void>
 }

+ 8 - 3
packages/plugin/src/v2/effect/tool.ts

@@ -5,7 +5,7 @@ import { Agent } from "@opencode-ai/schema/agent"
 import { Session } from "@opencode-ai/schema/session"
 import { SessionMessage } from "@opencode-ai/schema/session-message"
 import { Effect, JsonSchema, Schema, type Scope } from "effect"
-import type { Hooks } from "./registration.js"
+import type { Hooks, Transform } from "./registration.js"
 
 export interface Context {
   readonly sessionID: Session.ID
@@ -253,7 +253,12 @@ export interface ToolDraft {
   add(name: string, tool: AnyTool, options?: RegisterOptions): void
 }
 
+export interface ToolHooks {
+  readonly "execute.before": ToolExecuteBeforeEvent
+  readonly "execute.after": ToolExecuteAfterEvent
+}
+
 export interface ToolDomain {
-  readonly transform: (callback: (draft: ToolDraft) => void) => Effect.Effect<void, RegistrationError, Scope.Scope>
-  readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }>
+  readonly transform: Transform<ToolDraft>
+  readonly hook: Hooks<ToolHooks>
 }

+ 31 - 6
packages/plugin/src/v2/promise/README.md

@@ -1,6 +1,6 @@
 # OpenCode V2 Promise Plugin API
 
-The Promise plugin API is the async/await equivalent of `@opencode-ai/plugin/v2/effect`. It grants plugins the same two in-process capabilities:
+The Promise plugin API at `@opencode-ai/plugin/v2` is the async/await equivalent of `@opencode-ai/plugin/v2/effect`. It grants plugins the same two in-process capabilities:
 
 - `hook` installs behavior at an OpenCode extension point.
 - `reload` reruns every transform hook for a stateful domain.
@@ -10,9 +10,9 @@ The only difference from the Effect API is the async boundary: hook callbacks, h
 ## Defining A Plugin
 
 ```ts
-import { define } from "@opencode-ai/plugin/v2/promise"
+import { Plugin } from "@opencode-ai/plugin/v2"
 
-export const Plugin = define({
+export default Plugin.define({
   id: "example",
   setup: async (ctx) => {
     await ctx.catalog.transform((catalog) => {
@@ -24,7 +24,7 @@ export const Plugin = define({
 })
 ```
 
-Plugin setup registers hooks imperatively. It does not return a hook object.
+Plugin setup registers hooks imperatively through each domain's `hook` method.
 
 Configuration supplied for the plugin is available as `ctx.options`.
 
@@ -64,18 +64,43 @@ ctx.skill.transform
 Runtime hooks intercept live operations:
 
 ```ts
-await ctx.aisdk.sdk(async (event) => {
+await ctx.aisdk.hook("sdk", async (event) => {
   if (event.package !== "@ai-sdk/xai") return
   const mod = await import("@ai-sdk/xai")
   event.sdk = mod.createXai(event.options)
 })
 
-await ctx.aisdk.language((event) => {
+await ctx.aisdk.hook("language", (event) => {
   if (event.model.providerID !== "xai") return
   event.language = event.sdk.responses(event.model.api.id)
 })
 ```
 
+Session request context is mutable immediately before provider dispatch:
+
+```ts
+await ctx.session.hook("request", (event) => {
+  event.tools.read.description = "Read a file using narrow line ranges."
+  delete event.tools.write
+})
+```
+
+Promise tools use the same schemas and registration model as Effect tools, with async executors:
+
+```ts
+import { Schema } from "effect"
+import { Tool } from "@opencode-ai/plugin/v2/tool"
+
+const echo = Tool.make({
+  description: "Echo text",
+  input: Schema.Struct({ text: Schema.String }),
+  output: Schema.Struct({ text: Schema.String }),
+  execute: async ({ text }) => ({ text }),
+})
+
+await ctx.tool.transform((tools) => tools.add("echo", echo))
+```
+
 ## Reloading A Domain
 
 When data captured by a transform changes, reload the affected domain:

+ 3 - 3
packages/plugin/src/v2/promise/agent.ts

@@ -1,10 +1,10 @@
 import type { AgentApi } from "@opencode-ai/client/promise/api"
 import type { AgentDraft } from "../effect/agent.js"
-import type { TransformHook } from "./registration.js"
+import type { Transform } from "./registration.js"
 
 export type { AgentDraft }
 
-export interface AgentHooks extends AgentApi {
-  readonly transform: TransformHook<AgentDraft>
+export interface AgentDomain extends AgentApi {
+  readonly transform: Transform<AgentDraft>
   readonly reload: () => Promise<void>
 }

+ 6 - 2
packages/plugin/src/v2/promise/aisdk.ts

@@ -2,7 +2,7 @@ import type { LanguageModelV3 } from "@ai-sdk/provider"
 import type { Model } from "@opencode-ai/schema/model"
 import type { Hooks } from "./registration.js"
 
-export type AISDKHooks = Hooks<{
+export interface AISDKHooks {
   sdk: {
     readonly model: Model.Info
     readonly package: string
@@ -15,4 +15,8 @@ export type AISDKHooks = Hooks<{
     readonly options: Record<string, any>
     language?: LanguageModelV3
   }
-}>
+}
+
+export interface AISDKDomain {
+  readonly hook: Hooks<AISDKHooks>
+}

+ 3 - 3
packages/plugin/src/v2/promise/catalog.ts

@@ -1,10 +1,10 @@
 import type { CatalogApi } from "@opencode-ai/client/promise/api"
 import type { CatalogDraft, CatalogProviderRecord } from "../effect/catalog.js"
-import type { TransformHook } from "./registration.js"
+import type { Transform } from "./registration.js"
 
 export type { CatalogDraft, CatalogProviderRecord }
 
-export interface CatalogHooks extends CatalogApi {
-  readonly transform: TransformHook<CatalogDraft>
+export interface CatalogDomain extends CatalogApi {
+  readonly transform: Transform<CatalogDraft>
   readonly reload: () => Promise<void>
 }

+ 3 - 3
packages/plugin/src/v2/promise/command.ts

@@ -1,10 +1,10 @@
 import type { CommandApi } from "@opencode-ai/client/promise/api"
 import type { CommandDraft } from "../effect/command.js"
-import type { TransformHook } from "./registration.js"
+import type { Transform } from "./registration.js"
 
 export type { CommandDraft }
 
-export interface CommandHooks extends CommandApi {
-  readonly transform: TransformHook<CommandDraft>
+export interface CommandDomain extends CommandApi {
+  readonly transform: Transform<CommandDraft>
   readonly reload: () => Promise<void>
 }

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů