Compare commits

...

5 Commits

Author SHA1 Message Date
Kit Langton 9609670544 fix(tui): prevent stale shell counts 2026-07-07 20:22:42 -04:00
Dax e1abf59c59 fix(core): provide PluginHooks in plugin test layer (#35811) 2026-07-07 20:03:41 -04:00
Dax 4f976bcf1a feat(plugin): add session request hook (#35794) 2026-07-07 19:56:47 -04:00
Kit Langton e25a724bc2 test(tui): cover patch diff display aliases (#35810) 2026-07-07 19:44:54 -04:00
Kit Langton 521ea87192 fix(core): retain reasoning delta state (#35758) 2026-07-07 19:44:40 -04:00
128 changed files with 7150 additions and 4927 deletions
+2 -3
View File
@@ -3,15 +3,14 @@ import { compile, emitEffectImported, emitEffectShape, emitPromise, write } from
import { import {
ClientApi, ClientApi,
effectOmitEndpoints, effectOmitEndpoints,
endpointNames,
groupNames, groupNames,
promiseOmitEndpoints, promiseOmitEndpoints,
} from "@opencode-ai/protocol/client" } from "@opencode-ai/protocol/client"
import { Effect } from "effect" import { Effect } from "effect"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
const promiseContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) const promiseContract = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
const effectContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: effectOmitEndpoints }) const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmitEndpoints })
await Effect.runPromise( await Effect.runPromise(
Effect.all( Effect.all(
-1
View File
@@ -1,7 +1,6 @@
export { export {
ClientApi, ClientApi,
effectOmitEndpoints, effectOmitEndpoints,
endpointNames,
groupNames, groupNames,
promiseOmitEndpoints, promiseOmitEndpoints,
} from "@opencode-ai/protocol/client" } from "@opencode-ai/protocol/client"
+30 -24
View File
@@ -291,9 +291,11 @@ export interface SessionApi<E = never> {
readonly shell: SessionShellOperation<E> readonly shell: SessionShellOperation<E>
readonly compact: SessionCompactOperation<E> readonly compact: SessionCompactOperation<E>
readonly wait: SessionWaitOperation<E> readonly wait: SessionWaitOperation<E>
readonly revertStage: SessionRevertStageOperation<E> readonly revert: {
readonly revertClear: SessionRevertClearOperation<E> readonly stage: SessionRevertStageOperation<E>
readonly revertCommit: SessionRevertCommitOperation<E> readonly clear: SessionRevertClearOperation<E>
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E> readonly context: SessionContextOperation<E>
readonly instructions: { readonly instructions: {
readonly entry: { readonly entry: {
@@ -438,11 +440,15 @@ export type IntegrationAttemptCancelOperation<E = never> = (
export interface IntegrationApi<E = never> { export interface IntegrationApi<E = never> {
readonly list: IntegrationListOperation<E> readonly list: IntegrationListOperation<E>
readonly get: IntegrationGetOperation<E> readonly get: IntegrationGetOperation<E>
readonly connectKey: IntegrationConnectKeyOperation<E> readonly connect: {
readonly connectOauth: IntegrationConnectOauthOperation<E> readonly key: IntegrationConnectKeyOperation<E>
readonly attemptStatus: IntegrationAttemptStatusOperation<E> readonly oauth: IntegrationConnectOauthOperation<E>
readonly attemptComplete: IntegrationAttemptCompleteOperation<E> }
readonly attemptCancel: IntegrationAttemptCancelOperation<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] 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] type Endpoint10_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
export type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] } export type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] }
export type Endpoint10_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>> 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> { export interface ServerMcpApi<E = never> {
readonly list: ServerMcpListOperation<E> readonly list: ServerMcpListOperation<E>
readonly catalog: ServerMcpCatalogOperation<E> readonly resource: { readonly catalog: ServerMcpResourceCatalogOperation<E> }
} }
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0] 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] type Endpoint13_0Request = Parameters<RawClient["server.form"]["form.request.list"]>[0]
export type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } export type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] }
export type Endpoint13_0Output = EffectValue<ReturnType<RawClient["server.form"]["form.request.list"]>> 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] type Endpoint13_1Request = Parameters<RawClient["server.form"]["session.form.list"]>[0]
export type Endpoint13_1Input = { readonly sessionID: Endpoint13_1Request["params"]["sessionID"] } 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 type FormCancelOperation<E = never> = (input: Endpoint13_6Input) => Effect.Effect<Endpoint13_6Output, E>
export interface FormApi<E = never> { export interface FormApi<E = never> {
readonly listRequests: FormListRequestsOperation<E> readonly request: { readonly list: FormRequestListOperation<E> }
readonly list: FormListOperation<E> readonly list: FormListOperation<E>
readonly create: FormCreateOperation<E> readonly create: FormCreateOperation<E>
readonly get: FormGetOperation<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] type Endpoint14_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
export type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } export type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
export type Endpoint14_0Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.request.list"]>> 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, input?: Endpoint14_0Input,
) => Effect.Effect<Endpoint14_0Output, E> ) => Effect.Effect<Endpoint14_0Output, E>
@@ -582,14 +590,14 @@ export type Endpoint14_1Input = { readonly projectID?: Endpoint14_1Request["quer
export type Endpoint14_1Output = EffectValue< export type Endpoint14_1Output = EffectValue<
ReturnType<RawClient["server.permission"]["permission.saved.list"]> ReturnType<RawClient["server.permission"]["permission.saved.list"]>
>["data"] >["data"]
export type PermissionListSavedOperation<E = never> = ( export type PermissionSavedListOperation<E = never> = (
input?: Endpoint14_1Input, input?: Endpoint14_1Input,
) => Effect.Effect<Endpoint14_1Output, E> ) => Effect.Effect<Endpoint14_1Output, E>
type Endpoint14_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0] type Endpoint14_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
export type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] } export type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] }
export type Endpoint14_2Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.saved.remove"]>> 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, input: Endpoint14_2Input,
) => Effect.Effect<Endpoint14_2Output, E> ) => 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 type PermissionReplyOperation<E = never> = (input: Endpoint14_6Input) => Effect.Effect<Endpoint14_6Output, E>
export interface PermissionApi<E = never> { export interface PermissionApi<E = never> {
readonly listRequests: PermissionListRequestsOperation<E> readonly request: { readonly list: PermissionRequestListOperation<E> }
readonly listSaved: PermissionListSavedOperation<E> readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
readonly removeSaved: PermissionRemoveSavedOperation<E>
readonly create: PermissionCreateOperation<E> readonly create: PermissionCreateOperation<E>
readonly list: PermissionListOperation<E> readonly list: PermissionListOperation<E>
readonly get: PermissionGetOperation<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] type Endpoint21_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
export type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] } export type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] }
export type Endpoint21_0Output = EffectValue<ReturnType<RawClient["server.question"]["question.request.list"]>> 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, input?: Endpoint21_0Input,
) => Effect.Effect<Endpoint21_0Output, E> ) => 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 type QuestionRejectOperation<E = never> = (input: Endpoint21_3Input) => Effect.Effect<Endpoint21_3Output, E>
export interface QuestionApi<E = never> { export interface QuestionApi<E = never> {
readonly listRequests: QuestionListRequestsOperation<E> readonly request: { readonly list: QuestionRequestListOperation<E> }
readonly list: QuestionListOperation<E> readonly list: QuestionListOperation<E>
readonly reply: QuestionReplyOperation<E> readonly reply: QuestionReplyOperation<E>
readonly reject: QuestionRejectOperation<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 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] type Endpoint25_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
export type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] } export type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] }
export type Endpoint25_1Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location.evict"]>> 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> { export interface DebugApi<E = never> {
readonly location: DebugLocationOperation<E> readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
readonly evictLocation: DebugEvictLocationOperation<E>
} }
export interface AppApi<E = never> { export interface AppApi<E = never> {
+12 -16
View File
@@ -381,9 +381,7 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
shell: Endpoint4_14(raw), shell: Endpoint4_14(raw),
compact: Endpoint4_15(raw), compact: Endpoint4_15(raw),
wait: Endpoint4_16(raw), wait: Endpoint4_16(raw),
revertStage: Endpoint4_17(raw), revert: { stage: Endpoint4_17(raw), clear: Endpoint4_18(raw), commit: Endpoint4_19(raw) },
revertClear: Endpoint4_18(raw),
revertCommit: Endpoint4_19(raw),
context: Endpoint4_20(raw), context: Endpoint4_20(raw),
instructions: { entry: { list: Endpoint4_21(raw), put: Endpoint4_22(raw), remove: Endpoint4_23(raw) } }, instructions: { entry: { list: Endpoint4_21(raw), put: Endpoint4_22(raw), remove: Endpoint4_23(raw) } },
log: Endpoint4_24(raw), log: Endpoint4_24(raw),
@@ -536,11 +534,8 @@ const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_
const adaptGroup9 = (raw: RawClient["server.integration"]) => ({ const adaptGroup9 = (raw: RawClient["server.integration"]) => ({
list: Endpoint9_0(raw), list: Endpoint9_0(raw),
get: Endpoint9_1(raw), get: Endpoint9_1(raw),
connectKey: Endpoint9_2(raw), connect: { key: Endpoint9_2(raw), oauth: Endpoint9_3(raw) },
connectOauth: Endpoint9_3(raw), attempt: { status: Endpoint9_4(raw), complete: Endpoint9_5(raw), cancel: Endpoint9_6(raw) },
attemptStatus: Endpoint9_4(raw),
attemptComplete: Endpoint9_5(raw),
attemptCancel: Endpoint9_6(raw),
}) })
type Endpoint10_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0] 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) => const Endpoint10_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_1Input) =>
raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) 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_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
type Endpoint11_0Input = { type Endpoint11_0Input = {
@@ -690,7 +688,7 @@ const Endpoint13_6 = (raw: RawClient["server.form"]) => (input: Endpoint13_6Inpu
) )
const adaptGroup13 = (raw: RawClient["server.form"]) => ({ const adaptGroup13 = (raw: RawClient["server.form"]) => ({
listRequests: Endpoint13_0(raw), request: { list: Endpoint13_0(raw) },
list: Endpoint13_1(raw), list: Endpoint13_1(raw),
create: Endpoint13_2(raw), create: Endpoint13_2(raw),
get: Endpoint13_3(raw), get: Endpoint13_3(raw),
@@ -778,9 +776,8 @@ const Endpoint14_6 = (raw: RawClient["server.permission"]) => (input: Endpoint14
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
const adaptGroup14 = (raw: RawClient["server.permission"]) => ({ const adaptGroup14 = (raw: RawClient["server.permission"]) => ({
listRequests: Endpoint14_0(raw), request: { list: Endpoint14_0(raw) },
listSaved: Endpoint14_1(raw), saved: { list: Endpoint14_1(raw), remove: Endpoint14_2(raw) },
removeSaved: Endpoint14_2(raw),
create: Endpoint14_3(raw), create: Endpoint14_3(raw),
list: Endpoint14_4(raw), list: Endpoint14_4(raw),
get: Endpoint14_5(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"]) => ({ const adaptGroup21 = (raw: RawClient["server.question"]) => ({
listRequests: Endpoint21_0(raw), request: { list: Endpoint21_0(raw) },
list: Endpoint21_1(raw), list: Endpoint21_1(raw),
reply: Endpoint21_2(raw), reply: Endpoint21_2(raw),
reject: Endpoint21_3(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)) raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ const adaptGroup25 = (raw: RawClient["server.debug"]) => ({
location: Endpoint25_0(raw), location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) },
evictLocation: Endpoint25_1(raw),
}) })
const adaptClient = (raw: RawClient) => ({ const adaptClient = (raw: RawClient) => ({
+224 -206
View File
@@ -89,8 +89,8 @@ import type {
IntegrationAttemptCancelOutput, IntegrationAttemptCancelOutput,
ServerMcpListInput, ServerMcpListInput,
ServerMcpListOutput, ServerMcpListOutput,
ServerMcpCatalogInput, ServerMcpResourceCatalogInput,
ServerMcpCatalogOutput, ServerMcpResourceCatalogOutput,
CredentialUpdateInput, CredentialUpdateInput,
CredentialUpdateOutput, CredentialUpdateOutput,
CredentialRemoveInput, CredentialRemoveInput,
@@ -100,8 +100,8 @@ import type {
ProjectCurrentOutput, ProjectCurrentOutput,
ProjectDirectoriesInput, ProjectDirectoriesInput,
ProjectDirectoriesOutput, ProjectDirectoriesOutput,
FormListRequestsInput, FormRequestListInput,
FormListRequestsOutput, FormRequestListOutput,
FormListInput, FormListInput,
FormListOutput, FormListOutput,
FormCreateInput, FormCreateInput,
@@ -114,12 +114,12 @@ import type {
FormReplyOutput, FormReplyOutput,
FormCancelInput, FormCancelInput,
FormCancelOutput, FormCancelOutput,
PermissionListRequestsInput, PermissionRequestListInput,
PermissionListRequestsOutput, PermissionRequestListOutput,
PermissionListSavedInput, PermissionSavedListInput,
PermissionListSavedOutput, PermissionSavedListOutput,
PermissionRemoveSavedInput, PermissionSavedRemoveInput,
PermissionRemoveSavedOutput, PermissionSavedRemoveOutput,
PermissionCreateInput, PermissionCreateInput,
PermissionCreateOutput, PermissionCreateOutput,
PermissionListInput, PermissionListInput,
@@ -161,8 +161,8 @@ import type {
ShellOutputOutput, ShellOutputOutput,
ShellRemoveInput, ShellRemoveInput,
ShellRemoveOutput, ShellRemoveOutput,
QuestionListRequestsInput, QuestionRequestListInput,
QuestionListRequestsOutput, QuestionRequestListOutput,
QuestionListInput, QuestionListInput,
QuestionListOutput, QuestionListOutput,
QuestionReplyInput, QuestionReplyInput,
@@ -181,9 +181,9 @@ import type {
VcsStatusOutput, VcsStatusOutput,
VcsDiffInput, VcsDiffInput,
VcsDiffOutput, VcsDiffOutput,
DebugLocationOutput, DebugLocationListOutput,
DebugEvictLocationInput, DebugLocationEvictInput,
DebugEvictLocationOutput, DebugLocationEvictOutput,
} from "./types" } from "./types"
import { ClientError } from "./client-error" import { ClientError } from "./client-error"
@@ -601,40 +601,42 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
revertStage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) => revert: {
request<{ readonly data: SessionRevertStageOutput }>( stage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) =>
{ request<{ readonly data: SessionRevertStageOutput }>(
method: "POST", {
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, method: "POST",
body: { messageID: input["messageID"], files: input["files"] }, path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
successStatus: 200, body: { messageID: input["messageID"], files: input["files"] },
declaredStatuses: [404, 409, 500, 400, 401], successStatus: 200,
empty: false, declaredStatuses: [404, 409, 500, 400, 401],
}, empty: false,
requestOptions, },
).then((value) => value.data), requestOptions,
revertClear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) => ).then((value) => value.data),
request<SessionRevertClearOutput>( clear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) =>
{ request<SessionRevertClearOutput>(
method: "POST", {
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`, method: "POST",
successStatus: 204, path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
declaredStatuses: [404, 409, 500, 400, 401], successStatus: 204,
empty: true, declaredStatuses: [404, 409, 500, 400, 401],
}, empty: true,
requestOptions, },
), requestOptions,
revertCommit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) => ),
request<SessionRevertCommitOutput>( commit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) =>
{ request<SessionRevertCommitOutput>(
method: "POST", {
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, method: "POST",
successStatus: 204, path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
declaredStatuses: [404, 409, 400, 401], successStatus: 204,
empty: true, declaredStatuses: [404, 409, 400, 401],
}, empty: true,
requestOptions, },
), requestOptions,
),
},
context: (input: SessionContextInput, requestOptions?: RequestOptions) => context: (input: SessionContextInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionContextOutput }>( request<{ readonly data: SessionContextOutput }>(
{ {
@@ -836,69 +838,73 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
connectKey: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) => connect: {
request<IntegrationConnectKeyOutput>( key: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) =>
{ request<IntegrationConnectKeyOutput>(
method: "POST", {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`, method: "POST",
query: { location: input["location"] }, path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
body: { key: input["key"], label: input["label"] }, query: { location: input["location"] },
successStatus: 204, body: { key: input["key"], label: input["label"] },
declaredStatuses: [400, 401], successStatus: 204,
empty: true, declaredStatuses: [400, 401],
}, empty: true,
requestOptions, },
), requestOptions,
connectOauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) => ),
request<IntegrationConnectOauthOutput>( oauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) =>
{ request<IntegrationConnectOauthOutput>(
method: "POST", {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`, method: "POST",
query: { location: input["location"] }, path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, query: { location: input["location"] },
successStatus: 200, body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
declaredStatuses: [400, 401], successStatus: 200,
empty: false, declaredStatuses: [400, 401],
}, empty: false,
requestOptions, },
), requestOptions,
attemptStatus: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) => ),
request<IntegrationAttemptStatusOutput>( },
{ attempt: {
method: "GET", status: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) =>
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, request<IntegrationAttemptStatusOutput>(
query: { location: input["location"] }, {
successStatus: 200, method: "GET",
declaredStatuses: [401, 400], path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
empty: false, query: { location: input["location"] },
}, successStatus: 200,
requestOptions, declaredStatuses: [401, 400],
), empty: false,
attemptComplete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) => },
request<IntegrationAttemptCompleteOutput>( requestOptions,
{ ),
method: "POST", complete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) =>
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`, request<IntegrationAttemptCompleteOutput>(
query: { location: input["location"] }, {
body: { code: input["code"] }, method: "POST",
successStatus: 204, path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`,
declaredStatuses: [400, 401], query: { location: input["location"] },
empty: true, body: { code: input["code"] },
}, successStatus: 204,
requestOptions, declaredStatuses: [400, 401],
), empty: true,
attemptCancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) => },
request<IntegrationAttemptCancelOutput>( requestOptions,
{ ),
method: "DELETE", cancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) =>
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, request<IntegrationAttemptCancelOutput>(
query: { location: input["location"] }, {
successStatus: 204, method: "DELETE",
declaredStatuses: [401, 400], path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
empty: true, query: { location: input["location"] },
}, successStatus: 204,
requestOptions, declaredStatuses: [401, 400],
), empty: true,
},
requestOptions,
),
},
}, },
"server.mcp": { "server.mcp": {
list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) => list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) =>
@@ -913,18 +919,20 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
catalog: (input?: ServerMcpCatalogInput, requestOptions?: RequestOptions) => resource: {
request<ServerMcpCatalogOutput>( catalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) =>
{ request<ServerMcpResourceCatalogOutput>(
method: "GET", {
path: `/api/mcp/resource`, method: "GET",
query: { location: input?.["location"] }, path: `/api/mcp/resource`,
successStatus: 200, query: { location: input?.["location"] },
declaredStatuses: [401, 400], successStatus: 200,
empty: false, declaredStatuses: [401, 400],
}, empty: false,
requestOptions, },
), requestOptions,
),
},
}, },
credential: { credential: {
update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) => update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) =>
@@ -985,18 +993,20 @@ export function make(options: ClientOptions) {
), ),
}, },
form: { form: {
listRequests: (input?: FormListRequestsInput, requestOptions?: RequestOptions) => request: {
request<FormListRequestsOutput>( list: (input?: FormRequestListInput, requestOptions?: RequestOptions) =>
{ request<FormRequestListOutput>(
method: "GET", {
path: `/api/form/request`, method: "GET",
query: { location: input?.["location"] }, path: `/api/form/request`,
successStatus: 200, query: { location: input?.["location"] },
declaredStatuses: [401, 400], successStatus: 200,
empty: false, declaredStatuses: [401, 400],
}, empty: false,
requestOptions, },
), requestOptions,
),
},
list: (input: FormListInput, requestOptions?: RequestOptions) => list: (input: FormListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormListOutput }>( request<{ readonly data: FormListOutput }>(
{ {
@@ -1074,41 +1084,45 @@ export function make(options: ClientOptions) {
), ),
}, },
permission: { permission: {
listRequests: (input?: PermissionListRequestsInput, requestOptions?: RequestOptions) => request: {
request<PermissionListRequestsOutput>( list: (input?: PermissionRequestListInput, requestOptions?: RequestOptions) =>
{ request<PermissionRequestListOutput>(
method: "GET", {
path: `/api/permission/request`, method: "GET",
query: { location: input?.["location"] }, path: `/api/permission/request`,
successStatus: 200, query: { location: input?.["location"] },
declaredStatuses: [401, 400], successStatus: 200,
empty: false, declaredStatuses: [401, 400],
}, empty: false,
requestOptions, },
), requestOptions,
listSaved: (input?: PermissionListSavedInput, requestOptions?: RequestOptions) => ),
request<{ readonly data: PermissionListSavedOutput }>( },
{ saved: {
method: "GET", list: (input?: PermissionSavedListInput, requestOptions?: RequestOptions) =>
path: `/api/permission/saved`, request<{ readonly data: PermissionSavedListOutput }>(
query: { projectID: input?.["projectID"] }, {
successStatus: 200, method: "GET",
declaredStatuses: [401, 400], path: `/api/permission/saved`,
empty: false, query: { projectID: input?.["projectID"] },
}, successStatus: 200,
requestOptions, declaredStatuses: [401, 400],
).then((value) => value.data), empty: false,
removeSaved: (input: PermissionRemoveSavedInput, requestOptions?: RequestOptions) => },
request<PermissionRemoveSavedOutput>( requestOptions,
{ ).then((value) => value.data),
method: "DELETE", remove: (input: PermissionSavedRemoveInput, requestOptions?: RequestOptions) =>
path: `/api/permission/saved/${encodeURIComponent(input.id)}`, request<PermissionSavedRemoveOutput>(
successStatus: 204, {
declaredStatuses: [401, 400], method: "DELETE",
empty: true, path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
}, successStatus: 204,
requestOptions, declaredStatuses: [401, 400],
), empty: true,
},
requestOptions,
),
},
create: (input: PermissionCreateInput, requestOptions?: RequestOptions) => create: (input: PermissionCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionCreateOutput }>( request<{ readonly data: PermissionCreateOutput }>(
{ {
@@ -1390,18 +1404,20 @@ export function make(options: ClientOptions) {
), ),
}, },
question: { question: {
listRequests: (input?: QuestionListRequestsInput, requestOptions?: RequestOptions) => request: {
request<QuestionListRequestsOutput>( list: (input?: QuestionRequestListInput, requestOptions?: RequestOptions) =>
{ request<QuestionRequestListOutput>(
method: "GET", {
path: `/api/question/request`, method: "GET",
query: { location: input?.["location"] }, path: `/api/question/request`,
successStatus: 200, query: { location: input?.["location"] },
declaredStatuses: [401, 400], successStatus: 200,
empty: false, declaredStatuses: [401, 400],
}, empty: false,
requestOptions, },
), requestOptions,
),
},
list: (input: QuestionListInput, requestOptions?: RequestOptions) => list: (input: QuestionListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: QuestionListOutput }>( request<{ readonly data: QuestionListOutput }>(
{ {
@@ -1518,29 +1534,31 @@ export function make(options: ClientOptions) {
), ),
}, },
debug: { debug: {
location: (requestOptions?: RequestOptions) => location: {
request<DebugLocationOutput>( list: (requestOptions?: RequestOptions) =>
{ request<DebugLocationListOutput>(
method: "GET", {
path: `/api/debug/location`, method: "GET",
successStatus: 200, path: `/api/debug/location`,
declaredStatuses: [401, 400], successStatus: 200,
empty: false, declaredStatuses: [401, 400],
}, empty: false,
requestOptions, },
), requestOptions,
evictLocation: (input?: DebugEvictLocationInput, requestOptions?: RequestOptions) => ),
request<DebugEvictLocationOutput>( evict: (input?: DebugLocationEvictInput, requestOptions?: RequestOptions) =>
{ request<DebugLocationEvictOutput>(
method: "DELETE", {
path: `/api/debug/location`, method: "DELETE",
query: { location: input?.["location"] }, path: `/api/debug/location`,
successStatus: 204, query: { location: input?.["location"] },
declaredStatuses: [401, 400], successStatus: 204,
empty: true, declaredStatuses: [401, 400],
}, empty: true,
requestOptions, },
), requestOptions,
),
},
}, },
} }
} }
+15 -15
View File
@@ -2508,13 +2508,13 @@ export type ServerMcpListOutput = {
}> }>
} }
export type ServerMcpCatalogInput = { export type ServerMcpResourceCatalogInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"] }["location"]
} }
export type ServerMcpCatalogOutput = { export type ServerMcpResourceCatalogOutput = {
readonly location: { readonly location: {
readonly directory: string readonly directory: string
readonly workspaceID?: string readonly workspaceID?: string
@@ -2585,13 +2585,13 @@ export type ProjectDirectoriesInput = {
export type ProjectDirectoriesOutput = ReadonlyArray<{ readonly directory: string; readonly strategy?: string }> export type ProjectDirectoriesOutput = ReadonlyArray<{ readonly directory: string; readonly strategy?: string }>
export type FormListRequestsInput = { export type FormRequestListInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"] }["location"]
} }
export type FormListRequestsOutput = { export type FormRequestListOutput = {
readonly location: { readonly location: {
readonly directory: string readonly directory: string
readonly workspaceID?: string readonly workspaceID?: string
@@ -3659,13 +3659,13 @@ export type FormCancelInput = {
export type FormCancelOutput = void export type FormCancelOutput = void
export type PermissionListRequestsInput = { export type PermissionRequestListInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"] }["location"]
} }
export type PermissionListRequestsOutput = { export type PermissionRequestListOutput = {
readonly location: { readonly location: {
readonly directory: string readonly directory: string
readonly workspaceID?: 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 data: ReadonlyArray<{
readonly id: string readonly id: string
readonly projectID: string readonly projectID: string
@@ -3693,9 +3693,9 @@ export type PermissionListSavedOutput = {
}> }>
}["data"] }["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 = { export type PermissionCreateInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"] readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -6049,13 +6049,13 @@ export type ShellRemoveInput = {
export type ShellRemoveOutput = void export type ShellRemoveOutput = void
export type QuestionListRequestsInput = { export type QuestionRequestListInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"] }["location"]
} }
export type QuestionListRequestsOutput = { export type QuestionRequestListOutput = {
readonly location: { readonly location: {
readonly directory: string readonly directory: string
readonly workspaceID?: 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 location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"] }["location"]
} }
export type DebugEvictLocationOutput = void export type DebugLocationEvictOutput = void
@@ -19,7 +19,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace" import { Workspace } from "@opencode-ai/schema/workspace"
import { Api } from "@opencode-ai/server/api" import { Api } from "@opencode-ai/server/api"
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen" 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") 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", () => { test("client and Server contracts generate identically", () => {
const server = compile(Api, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) const server = compile(Api, { groupNames, omitEndpoints: promiseOmitEndpoints })
const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) const client = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
expect(emitPromise(client)).toEqual(emitPromise(server)) expect(emitPromise(client)).toEqual(emitPromise(server))
}) })
+6 -11
View File
@@ -32,17 +32,12 @@ test("exposes every standard HTTP API group", () => {
"vcs", "vcs",
"debug", "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.message)).toEqual(["list"])
expect(Object.keys(client.integration)).toEqual([ expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "attempt"])
"list", expect(Object.keys(client.integration.connect)).toEqual(["key", "oauth"])
"get", expect(Object.keys(client.integration.attempt)).toEqual(["status", "complete", "cancel"])
"connectKey",
"connectOauth",
"attemptStatus",
"attemptComplete",
"attemptCancel",
])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) 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(result.data.resources[0]?.uri).toBe("docs://readme")
expect(request?.method).toBe("GET") expect(request?.method).toBe("GET")
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -177,13 +177,13 @@ describe("OpenAPI.fromSpec", () => {
const spec = await opencodeSpec() const spec = await opencodeSpec()
const result = OpenAPI.fromSpec({ spec, baseUrl }) const result = OpenAPI.fromSpec({ spec, baseUrl })
expect(result.skipped).toHaveLength(5) expect(result.skipped).toHaveLength(4)
expect(result.skipped).toContainEqual({ expect(result.skipped).toContainEqual({
method: "GET", method: "GET",
path: "/api/pty/{ptyID}/connect", path: "/api/pty/{ptyID}/connect",
reason: "WebSocket operations are not supported", 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({ expect(result.skipped).toContainEqual({
method: "GET", method: "GET",
path: "/api/fs/read/*", 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") 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(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_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.session.log")).toBeUndefined()
expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined() expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "v2.fs.read")).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", () => { test("preserves operation path sanitization and collision handling", () => {
+13 -6
View File
@@ -1,6 +1,6 @@
export * as PluginV2 from "./plugin" 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 { Event, ID, type Info } from "@opencode-ai/schema/plugin"
import { makeLocationNode } from "./effect/app-node" import { makeLocationNode } from "./effect/app-node"
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect" import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
@@ -18,6 +18,7 @@ import { SkillV2 } from "./skill"
import { State } from "./state" import { State } from "./state"
import { ToolRegistry } from "./tool/registry" import { ToolRegistry } from "./tool/registry"
import { ToolHooks } from "./tool/hooks" import { ToolHooks } from "./tool/hooks"
import { PluginHooks } from "./plugin/hooks"
export interface Interface { export interface Interface {
readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect<void> 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.length === definitions.length &&
generation.every( generation.every(
(plugin, index) => plugin.id === definitions[index]?.id && plugin.version === definitions[index]?.version, (plugin, index) => plugin.id === definitions[index]?.id && plugin.version === definitions[index]?.version,
) ) &&
definitions.every((definition) => active.has(definition.id))
) { ) {
return return
} }
generation = undefined generation = undefined
const exit = yield* State.batch( yield* State.batch(
Effect.gen(function* () { Effect.gen(function* () {
const scopes = Array.from(active.values()).toReversed() const scopes = Array.from(active.values()).toReversed()
active.clear() active.clear()
@@ -81,13 +83,17 @@ const layer = Layer.effect(
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit, 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) active.set(definition.id, child)
} }
return Exit.void
}), }),
) )
if (Exit.isFailure(exit)) return yield* exit
generation = definitions.map((definition) => ({ generation = definitions.map((definition) => ({
id: definition.id, id: definition.id,
...(definition.version === undefined ? {} : { version: definition.version }), ...(definition.version === undefined ? {} : { version: definition.version }),
@@ -131,6 +137,7 @@ export const node = makeLocationNode({
SkillV2.node, SkillV2.node,
ToolRegistry.toolsNode, ToolRegistry.toolsNode,
ToolHooks.node, ToolHooks.node,
PluginHooks.node,
PluginRuntime.node, PluginRuntime.node,
], ],
}) })
+67
View File
@@ -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
View File
@@ -1,6 +1,6 @@
export * as PluginHost from "./host" 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 { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
@@ -22,6 +22,7 @@ import { Tool } from "../tool/tool"
import { Tools } from "../tool/tools" import { Tools } from "../tool/tools"
import { ToolHooks } from "../tool/hooks" import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace" import { WorkspaceV2 } from "../workspace"
import { PluginHooks } from "./hooks"
const mutable = <T>(value: T) => value as DeepMutable<T> const mutable = <T>(value: T) => value as DeepMutable<T>
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { 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 skill = yield* SkillV2.Service
const tools = yield* Tools.Service const tools = yield* Tools.Service
const toolHooks = yield* ToolHooks.Service const toolHooks = yield* ToolHooks.Service
const hooks = yield* PluginHooks.Service
const runtime = yield* PluginRuntime.Service const runtime = yield* PluginRuntime.Service
const locationInfo = () => const locationInfo = () =>
new Location.Info({ new Location.Info({
@@ -43,7 +45,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
workspaceID: location.workspaceID, workspaceID: location.workspaceID,
project: location.project, project: location.project,
}) })
const locationRef = (input?: Parameters<PluginContext["agent"]["list"]>[0]) => const locationRef = (input?: Parameters<Plugin.Context["agent"]["list"]>[0]) =>
input?.location === undefined input?.location === undefined
? undefined ? undefined
: Location.Ref.make({ : Location.Ref.make({
@@ -79,32 +81,32 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}), }),
}, },
aisdk: { aisdk: {
sdk: (callback) => hook: (name, callback) => {
aisdk.hook.sdk((event) => { 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 = { const output = {
model: mutable(event.model), model: mutable(event.model),
package: event.package,
options: event.options, options: event.options,
sdk: event.sdk, 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, language: event.language,
} }
const result = callback(output) return Reflect.apply(callback, undefined, [output]).pipe(
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() => Effect.sync(() => (event.language = output.language))), Effect.tap(() => Effect.sync(() => (event.language = output.language))),
) )
}), })
},
}, },
catalog: { catalog: {
provider: { provider: {
@@ -164,25 +166,29 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
integration: { integration: {
list: () => response(integration.list()), list: () => response(integration.list()),
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))), get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
connectKey: (input) => connect: {
integration.connection.key({ key: (input) =>
integrationID: Integration.ID.make(input.integrationID), integration.connection.key({
key: input.key,
label: input.label,
}),
connectOauth: (input) =>
response(
integration.connection.oauth({
integrationID: Integration.ID.make(input.integrationID), integrationID: Integration.ID.make(input.integrationID),
methodID: Integration.MethodID.make(input.methodID), key: input.key,
inputs: input.inputs,
label: input.label, label: input.label,
}), }),
), oauth: (input) =>
attemptStatus: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))), response(
attemptComplete: (input) => integration.connection.oauth({
integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }), integrationID: Integration.ID.make(input.integrationID),
attemptCancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)), 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, reload: integration.reload,
connection: { connection: {
active: (id) => integration.connection.active(Integration.ID.make(id)), 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, registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options), (registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true }, { discard: true },
) ).pipe(Effect.orDie)
return { dispose: Effect.void }
}), }),
execute: { hook: (name, callback) => {
before: (callback) => if (name === "execute.before") {
toolHooks.hook.before((event) => { return toolHooks.hook.before((event) => {
const output = { const output = {
tool: event.tool, tool: event.tool,
sessionID: event.sessionID, sessionID: event.sessionID,
@@ -330,38 +337,37 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
toolCallID: event.toolCallID, toolCallID: event.toolCallID,
input: event.input, input: event.input,
} }
const result = callback(output) return Reflect.apply(callback, undefined, [output]).pipe(
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() => Effect.sync(() => (event.input = output.input))), Effect.tap(() => Effect.sync(() => (event.input = output.input))),
) )
}), })
after: (callback) => }
toolHooks.hook.after((event) => { return toolHooks.hook.after((event) => {
const output = { const output = {
tool: event.tool, tool: event.tool,
sessionID: event.sessionID, sessionID: event.sessionID,
agent: event.agent, agent: event.agent,
assistantMessageID: event.assistantMessageID, assistantMessageID: event.assistantMessageID,
toolCallID: event.toolCallID, toolCallID: event.toolCallID,
input: event.input, input: event.input,
result: event.result, result: event.result,
output: event.output, output: event.output,
outputPaths: event.outputPaths, outputPaths: event.outputPaths,
} }
const result = callback(output) return Reflect.apply(callback, undefined, [output]).pipe(
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( Effect.tap(() =>
Effect.tap(() => Effect.sync(() => {
Effect.sync(() => { event.result = output.result
event.result = output.result event.output = output.output
event.output = output.output event.outputPaths = output.outputPaths
event.outputPaths = output.outputPaths }),
}), ),
), )
) })
}),
}, },
}, },
session: { session: {
hook: (name, callback) => hooks.register("session", name, callback),
create: (input) => create: (input) =>
runtime.session.create({ runtime.session.create({
id: input?.id, id: input?.id,
@@ -375,5 +381,5 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
command: runtime.session.command, command: runtime.session.command,
interrupt: (input) => runtime.session.interrupt(input.sessionID), interrupt: (input) => runtime.session.interrupt(input.sessionID),
}, },
} satisfies PluginContext } satisfies Plugin.Context
}) })
+1 -1
View File
@@ -1,6 +1,6 @@
export * as PluginInternal from "./internal" 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 { Context, Effect, Scope } from "effect"
import { HttpClient } from "effect/unstable/http" import { HttpClient } from "effect/unstable/http"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
+24 -14
View File
@@ -1,11 +1,12 @@
export * as PluginPromise from "./promise" export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/v2/effect" import { Plugin } from "@opencode-ai/plugin/v2/effect"
import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise"
import { Effect, Scope, Stream } from "effect" import { Effect, Scope, Stream } from "effect"
type HostRegistration = { readonly dispose: Effect.Effect<void> } type HostRegistration = { readonly dispose: Effect.Effect<void> }
type Registration = { readonly dispose: () => Promise<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 * 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 * preserves boot-time batching, so Promise-plugin transforms still coalesce
* into one reload per domain. * into one reload per domain.
*/ */
export function fromPromise(plugin: Plugin) { export function fromPromise(plugin: PromisePlugin) {
return define({ return Plugin.define({
id: plugin.id, id: plugin.id,
effect: (host) => effect: (host) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -43,7 +44,7 @@ export function fromPromise(plugin: Plugin) {
}), }),
) )
const context2: PluginContext = { const context2: PromisePluginContext = {
options: host.options, options: host.options,
agent: { agent: {
list: (input) => run(host.agent.list(input)), list: (input) => run(host.agent.list(input)),
@@ -51,10 +52,8 @@ export function fromPromise(plugin: Plugin) {
reload: () => run(host.agent.reload()), reload: () => run(host.agent.reload()),
}, },
aisdk: { aisdk: {
sdk: (callback) => hook: (name, callback) =>
register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))), register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
language: (callback) =>
register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))),
}, },
catalog: { catalog: {
provider: { provider: {
@@ -79,11 +78,15 @@ export function fromPromise(plugin: Plugin) {
integration: { integration: {
list: (input) => run(host.integration.list(input)), list: (input) => run(host.integration.list(input)),
get: (input) => run(host.integration.get(input)), get: (input) => run(host.integration.get(input)),
connectKey: (input) => run(host.integration.connectKey(input)), connect: {
connectOauth: (input) => run(host.integration.connectOauth(input)), key: (input) => run(host.integration.connect.key(input)),
attemptStatus: (input) => run(host.integration.attemptStatus(input)), oauth: (input) => run(host.integration.connect.oauth(input)),
attemptComplete: (input) => run(host.integration.attemptComplete(input)), },
attemptCancel: (input) => run(host.integration.attemptCancel(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), transform: transform(host.integration),
reload: () => run(host.integration.reload()), reload: () => run(host.integration.reload()),
connection: { connection: {
@@ -104,12 +107,19 @@ export function fromPromise(plugin: Plugin) {
transform: transform(host.skill), transform: transform(host.skill),
reload: () => run(host.skill.reload()), 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: { session: {
create: (input) => run(host.session.create(input)), create: (input) => run(host.session.create(input)),
get: (input) => run(host.session.get(input)), get: (input) => run(host.session.get(input)),
prompt: (input) => run(host.session.prompt(input)), prompt: (input) => run(host.session.prompt(input)),
command: (input) => run(host.session.command(input)), command: (input) => run(host.session.command(input)),
interrupt: (input) => run(host.session.interrupt(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
View File
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const AlibabaPlugin = define({ export const AlibabaPlugin = define({
id: "opencode.provider.alibaba", id: "opencode.provider.alibaba",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/alibaba") return if (evt.package !== "@ai-sdk/alibaba") return
const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))
@@ -75,7 +75,8 @@ export const AmazonBedrockPlugin = define({
}) })
} }
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return
const options = { ...evt.options } const options = { ...evt.options }
@@ -108,7 +109,8 @@ export const AmazonBedrockPlugin = define({
evt.sdk = mod.createAmazonBedrock(options) evt.sdk = mod.createAmazonBedrock(options)
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return
if ( if (
@@ -17,7 +17,8 @@ export const AnthropicPlugin = define({
}) })
} }
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/anthropic") return if (evt.package !== "@ai-sdk/anthropic") return
const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic"))
+6 -3
View File
@@ -26,7 +26,8 @@ export const AzurePlugin = define({
}) })
} }
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/azure") return if (evt.package !== "@ai-sdk/azure") return
if (evt.model.providerID === ProviderV2.ID.azure) { if (evt.model.providerID === ProviderV2.ID.azure) {
@@ -44,7 +45,8 @@ export const AzurePlugin = define({
evt.sdk = mod.createAzure(evt.options) evt.sdk = mod.createAzure(evt.options)
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.azure) return if (evt.model.providerID !== ProviderV2.ID.azure) return
evt.language = selectLanguage( evt.language = selectLanguage(
@@ -75,7 +77,8 @@ export const AzureCognitiveServicesPlugin = define({
}) })
} }
}) })
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
evt.language = selectLanguage( evt.language = selectLanguage(
@@ -14,7 +14,8 @@ export const CerebrasPlugin = define({
}) })
} }
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cerebras") return if (evt.package !== "@ai-sdk/cerebras") return
const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras"))
@@ -6,7 +6,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const CloudflareAIGatewayPlugin = define({ export const CloudflareAIGatewayPlugin = define({
id: "opencode.provider.cloudflare-ai-gateway", id: "opencode.provider.cloudflare-ai-gateway",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "ai-gateway-provider") return if (evt.package !== "ai-gateway-provider") return
if (evt.options.baseURL) return if (evt.options.baseURL) return
@@ -19,7 +19,8 @@ export const CloudflareWorkersAIPlugin = define({
if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) } if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
}) })
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return if (evt.model.providerID !== providerID) return
if (evt.package !== "@ai-sdk/openai-compatible") 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) { Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return if (evt.model.providerID !== providerID) return
evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id) evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id)
+2 -1
View File
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const CoherePlugin = define({ export const CoherePlugin = define({
id: "opencode.provider.cohere", id: "opencode.provider.cohere",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cohere") return if (evt.package !== "@ai-sdk/cohere") return
const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const DeepInfraPlugin = define({ export const DeepInfraPlugin = define({
id: "opencode.provider.deepinfra", id: "opencode.provider.deepinfra",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/deepinfra") return if (evt.package !== "@ai-sdk/deepinfra") return
const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))
+2 -1
View File
@@ -7,7 +7,8 @@ export const DynamicProviderPlugin = define({
id: "opencode.provider.dynamic", id: "opencode.provider.dynamic",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service const npm = yield* Npm.Service
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.sdk) return if (evt.sdk) return
+2 -1
View File
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GatewayPlugin = define({ export const GatewayPlugin = define({
id: "opencode.provider.gateway", id: "opencode.provider.gateway",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/gateway") return if (evt.package !== "@ai-sdk/gateway") return
const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))
@@ -23,14 +23,16 @@ export const GithubCopilotPlugin = define({
model.enabled = false model.enabled = false
}) })
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/github-copilot") return if (evt.package !== "@ai-sdk/github-copilot") return
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
evt.sdk = mod.createOpenaiCompatible(evt.options) evt.sdk = mod.createOpenaiCompatible(evt.options)
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {
+4 -2
View File
@@ -7,7 +7,8 @@ import { ProviderV2 } from "../../provider"
export const GitLabPlugin = define({ export const GitLabPlugin = define({
id: "opencode.provider.gitlab", id: "opencode.provider.gitlab",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "gitlab-ai-provider") return if (evt.package !== "gitlab-ai-provider") return
const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) 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) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.gitlab) return if (evt.model.providerID !== ProviderV2.ID.gitlab) return
const featureFlags = const featureFlags =
@@ -85,7 +85,8 @@ export const GoogleVertexPlugin = define({
}) })
} }
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
evt.options.fetch = authFetch(evt.options.fetch) 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) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.googleVertex) return if (evt.model.providerID !== ProviderV2.ID.googleVertex) return
evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim()) 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) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) 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) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return
evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim()) evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim())
+2 -1
View File
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GooglePlugin = define({ export const GooglePlugin = define({
id: "opencode.provider.google", id: "opencode.provider.google",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google") return if (evt.package !== "@ai-sdk/google") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google")) const mod = yield* Effect.promise(() => import("@ai-sdk/google"))
+2 -1
View File
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GroqPlugin = define({ export const GroqPlugin = define({
id: "opencode.provider.groq", id: "opencode.provider.groq",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/groq") return if (evt.package !== "@ai-sdk/groq") return
const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))
+2 -1
View File
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const MistralPlugin = define({ export const MistralPlugin = define({
id: "opencode.provider.mistral", id: "opencode.provider.mistral",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/mistral") return if (evt.package !== "@ai-sdk/mistral") return
const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const OpenAICompatiblePlugin = define({ export const OpenAICompatiblePlugin = define({
id: "opencode.provider.openai-compatible", id: "opencode.provider.openai-compatible",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.sdk) return if (evt.sdk) return
if (!evt.package.includes("@ai-sdk/openai-compatible")) return if (!evt.package.includes("@ai-sdk/openai-compatible")) return
+4 -2
View File
@@ -210,14 +210,16 @@ export const OpenAIPlugin = define({
Effect.forkScoped({ startImmediately: true }), Effect.forkScoped({ startImmediately: true }),
) )
yield* refresh().pipe(Effect.forkScoped) yield* refresh().pipe(Effect.forkScoped)
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return if (evt.package !== "@ai-sdk/openai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
evt.sdk = mod.createOpenAI(evt.options) evt.sdk = mod.createOpenAI(evt.options)
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.openai) return if (evt.model.providerID !== ProviderV2.ID.openai) return
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id) evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
@@ -23,7 +23,8 @@ export const OpenRouterPlugin = define({
} }
} }
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@openrouter/ai-sdk-provider") return if (evt.package !== "@openrouter/ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const PerplexityPlugin = define({ export const PerplexityPlugin = define({
id: "opencode.provider.perplexity", id: "opencode.provider.perplexity",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/perplexity") return if (evt.package !== "@ai-sdk/perplexity") return
const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))
@@ -8,7 +8,8 @@ export const SapAICorePlugin = define({
id: "opencode.provider.sap-ai-core", id: "opencode.provider.sap-ai-core",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service const npm = yield* Npm.Service
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
const serviceKey = const serviceKey =
@@ -37,7 +38,8 @@ export const SapAICorePlugin = define({
) )
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
evt.language = evt.sdk(evt.model.modelID ?? evt.model.id) evt.language = evt.sdk(evt.model.modelID ?? evt.model.id)
@@ -67,7 +67,8 @@ export function cortexFetch(upstream: FetchLike = fetch) {
export const SnowflakeCortexPlugin = define({ export const SnowflakeCortexPlugin = define({
id: "opencode.provider.snowflake-cortex", id: "opencode.provider.snowflake-cortex",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
const token = const token =
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const TogetherAIPlugin = define({ export const TogetherAIPlugin = define({
id: "opencode.provider.togetherai", id: "opencode.provider.togetherai",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/togetherai") return if (evt.package !== "@ai-sdk/togetherai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai")) const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai"))
+2 -1
View File
@@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const VenicePlugin = define({ export const VenicePlugin = define({
id: "opencode.provider.venice", id: "opencode.provider.venice",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "venice-ai-sdk-provider") return if (evt.package !== "venice-ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider")) const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider"))
+2 -1
View File
@@ -14,7 +14,8 @@ export const VercelPlugin = define({
}) })
} }
}) })
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/vercel") return if (evt.package !== "@ai-sdk/vercel") return
const mod = yield* Effect.promise(() => import("@ai-sdk/vercel")) const mod = yield* Effect.promise(() => import("@ai-sdk/vercel"))
+4 -2
View File
@@ -5,14 +5,16 @@ import { ProviderV2 } from "../../provider"
export const XAIPlugin = define({ export const XAIPlugin = define({
id: "opencode.provider.xai", id: "opencode.provider.xai",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/xai") return if (evt.package !== "@ai-sdk/xai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
evt.sdk = mod.createXai(evt.options) evt.sdk = mod.createXai(evt.options)
}), }),
) )
yield* ctx.aisdk.language( yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("xai")) return if (evt.model.providerID !== ProviderV2.ID.make("xai")) return
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id) evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
+1 -1
View File
@@ -1,6 +1,6 @@
export * as SdkPlugins from "./sdk" 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 { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "../effect/app-node" import { makeGlobalNode } from "../effect/app-node"
import { EventV2 } from "../event" import { EventV2 } from "../event"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as PluginSupervisor from "./supervisor" 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 { Event } from "@opencode-ai/schema/config"
import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect" import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect"
import path from "path" import path from "path"
+32 -4
View File
@@ -50,6 +50,8 @@ import { llmClient } from "../../effect/app-node-platform"
import { StepFailedError, UserInterruptedError } from "../error" import { StepFailedError, UserInterruptedError } from "../error"
import { toSessionError } from "../to-session-error" import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry" import { SessionRunnerRetry } from "./retry"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { PluginHooks } from "../../plugin/hooks"
type StepTokens = { type StepTokens = {
readonly input: number readonly input: number
@@ -132,6 +134,7 @@ const layer = Layer.effect(
const llm = yield* LLMClient.Service const llm = yield* LLMClient.Service
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
const tools = yield* ToolRegistry.Service const tools = yield* ToolRegistry.Service
const hooks = yield* PluginHooks.Service
const models = yield* SessionRunnerModel.Service const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const location = yield* Location.Service const location = yield* Location.Service
@@ -252,10 +255,34 @@ const layer = Layer.effect(
tools: toolMaterialization?.definitions ?? [], tools: toolMaterialization?.definitions ?? [],
toolChoice: isLastStep ? "none" : undefined, 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. // Automatic compaction completed; rebuild the request from compacted history.
if ( if (
!(yield* SessionInput.pendingCompaction(db, session.id)) && !(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 return { _tag: "RestartAfterCompaction", step: currentStep } as const
const startSnapshot = yield* snapshots.capture() const startSnapshot = yield* snapshots.capture()
@@ -276,7 +303,7 @@ const layer = Layer.effect(
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) => const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) =>
serialized(publisher.publish(event, outputPaths, error)) serialized(publisher.publish(event, outputPaths, error))
let overflowFailure: ProviderErrorEvent | undefined let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(request).pipe( const providerStream = llm.stream(hookedRequest).pipe(
Stream.runForEach((event) => Stream.runForEach((event) =>
Effect.gen(function* () { Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return if (overflowFailure || publisher.hasProviderError()) return
@@ -288,11 +315,11 @@ const layer = Layer.effect(
} }
yield* publish(event) yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) { if (!toolMaterialization || !advertisedTools.has(event.name)) {
yield* serialized( yield* serialized(
publisher.failUnsettledTools({ publisher.failUnsettledTools({
type: "tool.execution", type: "tool.execution",
message: "Tools are disabled after the maximum agent steps", message: `Tool is not available for this request: ${event.name}`,
}), }),
) )
return return
@@ -625,6 +652,7 @@ export const node = makeLocationNode({
llmClient, llmClient,
AgentV2.node, AgentV2.node,
ToolRegistry.node, ToolRegistry.node,
PluginHooks.node,
SessionRunnerModel.node, SessionRunnerModel.node,
SessionStore.node, SessionStore.node,
Location.node, Location.node,
@@ -101,27 +101,36 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>, ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
single = false, single = false,
) => { ) => {
const chunks = new Map<string, { readonly ordinal: number; readonly values: string[] }>() const chunks = new Map<
string,
{ readonly ordinal: number; readonly values: string[]; state?: Record<string, unknown> }
>()
let nextOrdinal = 0 let nextOrdinal = 0
const start = (id: string) => const start = (id: string, state?: Record<string, unknown>) =>
Effect.suspend(() => { Effect.suspend(() => {
if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`)) if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`))
if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`)) if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`))
const ordinal = nextOrdinal++ const ordinal = nextOrdinal++
chunks.set(id, { ordinal, values: [] }) chunks.set(id, { ordinal, values: [], state })
return Effect.succeed(ordinal) return Effect.succeed(ordinal)
}) })
const append = (id: string, value: string) => const append = (id: string, value: string, state?: Record<string, unknown>) =>
Effect.suspend(() => { Effect.suspend(() => {
const current = chunks.get(id) const current = chunks.get(id)
if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`)) if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`))
current.values.push(value) current.values.push(value)
if (state !== undefined) current.state = { ...current.state, ...state }
return Effect.succeed(current.ordinal) return Effect.succeed(current.ordinal)
}) })
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) { const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) {
const current = chunks.get(id) const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`)) if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
yield* ended(id, current.values.join(""), current.ordinal, state) yield* ended(
id,
current.values.join(""),
current.ordinal,
state === undefined ? current.state : { ...current.state, ...state },
)
chunks.delete(id) chunks.delete(id)
}) })
const flush = Effect.fnUntraced(function* () { const flush = Effect.fnUntraced(function* () {
@@ -288,7 +297,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return return
case "reasoning-start": case "reasoning-start":
retryEvidence = true retryEvidence = true
const startedReasoningOrdinal = yield* reasoning.start(event.id) const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata))
yield* events.publish(SessionEvent.Reasoning.Started, { yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(), assistantMessageID: yield* startAssistant(),
@@ -297,7 +306,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
}) })
return return
case "reasoning-delta": case "reasoning-delta":
const deltaReasoningOrdinal = yield* reasoning.append(event.id, event.text) const deltaReasoningOrdinal = yield* reasoning.append(
event.id,
event.text,
providerState(event.providerMetadata),
)
yield* events.publish(SessionEvent.Reasoning.Delta, { yield* events.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(), assistantMessageID: yield* currentAssistantMessageID(),
+14 -1
View File
@@ -1,6 +1,6 @@
export * as ApplyPatchTool from "./apply-patch" 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 { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff" import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
@@ -194,6 +194,19 @@ export const Plugin = {
), ),
) )
.pipe(Effect.orDie) .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
View File
@@ -6,7 +6,7 @@
*/ */
export * as EditTool from "./edit" 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 { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff" import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
+1 -1
View File
@@ -1,7 +1,7 @@
export * as GlobTool from "./glob" export * as GlobTool from "./glob"
import { ToolFailure } from "@opencode-ai/llm" 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 { Effect, Schema } from "effect"
import path from "path" import path from "path"
import { FileSystem } from "../filesystem" import { FileSystem } from "../filesystem"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as GrepTool from "./grep" 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 { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import path from "path" import path from "path"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as QuestionTool from "./question" 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 { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { Form } from "../form" import { Form } from "../form"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ReadTool from "./read" 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 { dirname } from "path"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
-4
View File
@@ -191,12 +191,8 @@ const registryLayer = Layer.effect(
const registration = entries.at(-1)?.registration const registration = entries.at(-1)?.registration
if (registration) registrations.set(name, 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) { for (const [name, registration] of registrations) {
const wrongEditTool = name === "patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
if ( if (
wrongEditTool ||
(registration.deferred && !Flag.CODEMODE_ENABLED) || (registration.deferred && !Flag.CODEMODE_ENABLED) ||
whollyDisabled(permission(registration.tool, name), input.permissions ?? []) whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
) )
+1 -1
View File
@@ -2,7 +2,7 @@ export * as ShellTool from "./shell"
import path from "path" import path from "path"
import { ToolFailure } from "@opencode-ai/llm" 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 { Effect, Schema, Scope } from "effect"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as SkillTool from "./skill" 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 path from "path"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
+44 -1
View File
@@ -1,10 +1,11 @@
export * as SubagentTool from "./subagent" export * as SubagentTool from "./subagent"
import { ToolFailure } from "@opencode-ai/llm" 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 { Effect, Schema, Scope } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { PluginRuntime } from "../plugin/runtime" import { PluginRuntime } from "../plugin/runtime"
import { PermissionV2 } from "../permission"
import { SessionSchema } from "../session/schema" import { SessionSchema } from "../session/schema"
import { Tool } from "./tool" import { Tool } from "./tool"
@@ -42,6 +43,7 @@ export const Plugin = {
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service const runtime = yield* PluginRuntime.Service
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
const permission = yield* PermissionV2.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
// Concatenate the child's final completed assistant text. Distinguishes "completed with no // 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 === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
if (agent.mode === "primary") if (agent.mode === "primary")
return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` }) 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. // Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model const model = agent.model ?? parent.model
@@ -176,5 +192,32 @@ export const Plugin = {
), ),
) )
.pipe(Effect.orDie) .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
View File
@@ -1,6 +1,6 @@
export * as TodoWriteTool from "./todowrite" 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 { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as WebFetchTool from "./webfetch" 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 { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Schema } from "effect" import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as WebSearchTool from "./websearch" 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 { ToolFailure } from "@opencode-ai/llm"
import { Context, Duration, Effect, Layer, Schema } from "effect" import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { HttpClient, HttpClientRequest } from "effect/unstable/http"
+1 -1
View File
@@ -6,7 +6,7 @@
*/ */
export * as WriteTool from "./write" 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 { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { FileMutation } from "../file-mutation" import { FileMutation } from "../file-mutation"
@@ -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", id: "directory-plugin",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.agent.transform((agents) => { await ctx.agent.transform((agents) => {
@@ -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", id: "folder-plugin",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.agent.transform((agents) => { await ctx.agent.transform((agents) => {
+3 -3
View File
@@ -2,7 +2,7 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { describe, expect } from "bun:test" 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 { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin" import { Plugin } from "@opencode-ai/schema/plugin"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
@@ -178,7 +178,7 @@ describe("PluginSupervisor config", () => {
it.live("loads user plugins before internal post plugins", () => it.live("loads user plugins before internal post plugins", () =>
Effect.gen(function* () { Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service 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( yield* withLocation(
{ {
plugins: [ plugins: [
@@ -275,7 +275,7 @@ function mutablePlugin(description: string) {
return ` return `
import { define } from ${JSON.stringify(plugin)} import { define } from ${JSON.stringify(plugin)}
export default define({ export default EffectPlugin.define({
id: "mutable-plugin", id: "mutable-plugin",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.agent.transform((agents) => { await ctx.agent.transform((agents) => {
+4 -6
View File
@@ -4,7 +4,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Tool } from "@opencode-ai/core/tool/tool" import { Tool } from "@opencode-ai/core/tool/tool"
import { Tools } from "@opencode-ai/core/tool/tools" 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" import { Effect, type Scope } from "effect"
export const toolIdentity = { export const toolIdentity = {
@@ -66,12 +66,10 @@ export const registerToolPlugin = <R>(plugin: {
registrations, registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options), (registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true }, { discard: true },
) ).pipe(Effect.orDie)
return { dispose: Effect.void }
}), }),
execute: { hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
before: () => Effect.die("registerToolPlugin does not support tool hooks"),
after: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
}, },
} }
yield* plugin.effect(context as PluginContext) yield* plugin.effect(context as PluginContext)
+3 -3
View File
@@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config" import { Config } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin" import { Plugin } from "@opencode-ai/schema/plugin"
import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect" 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 { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -44,7 +44,7 @@ describe("LocationServiceMap", () => {
const sdk = yield* SdkPlugins.Service const sdk = yield* SdkPlugins.Service
const locations = yield* LocationServiceMap.Service const locations = yield* LocationServiceMap.Service
const id = AgentV2.ID.make("persistent-sdk-agent") const id = AgentV2.ID.make("persistent-sdk-agent")
const plugin = define({ const plugin = EffectPlugin.define({
id: "persistent-sdk-plugin", id: "persistent-sdk-plugin",
effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})), effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})),
}) })
@@ -432,7 +432,7 @@ describe("LocationServiceMap", () => {
Effect.flatMap((dir) => Effect.flatMap((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service
const reviewer = define({ const reviewer = EffectPlugin.define({
id: "reviewer", id: "reviewer",
effect: (ctx) => effect: (ctx) =>
ctx.agent ctx.agent
+47
View File
@@ -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
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect" 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 { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin" import { Plugin } from "@opencode-ai/schema/plugin"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
@@ -49,7 +49,7 @@ describe("PluginV2", () => {
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true })) .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
const managed = () => const managed = () =>
define({ EffectPlugin.define({
id: "managed", id: "managed",
effect: (ctx) => effect: (ctx) =>
ctx.agent 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* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
let fail = true let fail = true
const plugin = define({ const good = EffectPlugin.define({
id: "retry", id: "good",
effect: (ctx) => effect: (ctx) =>
ctx.agent ctx.agent
.transform(() => { .transform((agents) =>
if (fail) throw new Error("materialization failed") agents.update("configured", (agent) => {
}) agent.description = "loaded"
}),
)
.pipe(Effect.asVoid), .pipe(Effect.asVoid),
}) })
const bad = EffectPlugin.define({
id: "bad",
effect: () => {
if (fail) return Effect.die(new Error("materialization failed"))
return Effect.void
},
})
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(Exit.isFailure(yield* plugins.activate([{ plugin }]).pipe(Effect.exit))).toBe(true)
fail = false fail = false
yield* plugins.activate([{ plugin }]) yield* plugins.activate([{ plugin: good }, { plugin: bad }])
expect(yield* plugins.list()).toEqual([
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("retry") }]) { id: Plugin.ID.make("good") },
{ id: Plugin.ID.make("bad") },
])
}), }),
) )
@@ -142,7 +157,7 @@ describe("PluginV2", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service
let visible = true let visible = true
const plugin = define({ const plugin = EffectPlugin.define({
id: "isolated", id: "isolated",
effect: () => effect: () =>
Effect.serviceOption(Secret).pipe( Effect.serviceOption(Secret).pipe(
@@ -161,7 +176,7 @@ describe("PluginV2", () => {
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const plugin = define({ const plugin = EffectPlugin.define({
id: "tool-plugin", id: "tool-plugin",
effect: (ctx) => effect: (ctx) =>
ctx.tool ctx.tool
@@ -202,7 +217,7 @@ describe("PluginV2", () => {
output: Schema.Struct({ ok: Schema.Boolean }), output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }), execute: () => Effect.succeed({ ok: true }),
}) })
const plugin = define({ const plugin = EffectPlugin.define({
id: "grouped-tools", id: "grouped-tools",
effect: (ctx) => effect: (ctx) =>
ctx.tool ctx.tool
@@ -234,7 +249,7 @@ describe("PluginV2", () => {
after?: { input: unknown; result: unknown; output: unknown } after?: { input: unknown; result: unknown; output: unknown }
} = {} } = {}
const plugin = define({ const plugin = EffectPlugin.define({
id: "tool-hooks", id: "tool-hooks",
effect: (ctx) => effect: (ctx) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -252,19 +267,23 @@ describe("PluginV2", () => {
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* ctx.tool.execute yield* ctx.tool
.before((event) => { .hook("execute.before", (event) =>
seen.before = event.input Effect.sync(() => {
event.input = { text: "before-mutated" } seen.before = event.input
}) event.input = { text: "before-mutated" }
}),
)
.pipe(Effect.asVoid) .pipe(Effect.asVoid)
yield* ctx.tool.execute yield* ctx.tool
.after((event) => { .hook("execute.after", (event) =>
seen.after = { input: event.input, result: event.result, output: event.output } Effect.sync(() => {
event.result = { type: "text", value: "after-mutated" } seen.after = { input: event.input, result: event.result, output: event.output }
event.output = { structured: { rewritten: true }, content: [] } event.result = { type: "text", value: "after-mutated" }
}) event.output = { structured: { rewritten: true }, content: [] }
}),
)
.pipe(Effect.asVoid) .pipe(Effect.asVoid)
}), }),
}) })
+2
View File
@@ -13,6 +13,7 @@ import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Reference } from "@opencode-ai/core/reference" import { Reference } from "@opencode-ai/core/reference"
import { SkillV2 } from "@opencode-ai/core/skill" import { SkillV2 } from "@opencode-ai/core/skill"
@@ -46,6 +47,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
CommandV2.node, CommandV2.node,
Integration.node, Integration.node,
PluginRuntime.node, PluginRuntime.node,
PluginHooks.node,
Reference.node, Reference.node,
SkillV2.node, SkillV2.node,
ToolHooks.node, ToolHooks.node,
@@ -1,7 +1,7 @@
import { define } from "@opencode-ai/plugin/v2/effect" import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect" import { Effect } from "effect"
export default define({ export default Plugin.define({
id: "config-effect-plugin", id: "config-effect-plugin",
effect: (ctx) => effect: (ctx) =>
ctx.agent ctx.agent
@@ -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", id: "config-promise-plugin",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.agent.transform((agents) => { await ctx.agent.transform((agents) => {
@@ -1,7 +1,7 @@
import { define } from "@opencode-ai/plugin/v2/effect" import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect" import { Effect } from "effect"
export default define({ export default Plugin.define({
id: "failing-plugin", id: "failing-plugin",
effect: () => Effect.die("plugin failed"), effect: () => Effect.die("plugin failed"),
}) })
@@ -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 { ProviderV2 } from "@opencode-ai/core/provider"
import { Effect } from "effect" import { Effect } from "effect"
export default define({ export default Plugin.define({
id: "variant-source", id: "variant-source",
effect: (ctx) => effect: (ctx) =>
ctx.catalog ctx.catalog
+22 -17
View File
@@ -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 { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
@@ -19,8 +19,7 @@ export function host(overrides: Overrides = {}): PluginContext {
reload: () => Effect.die("unused agent.reload"), reload: () => Effect.die("unused agent.reload"),
}, },
aisdk: overrides.aisdk ?? { aisdk: overrides.aisdk ?? {
sdk: () => Effect.die("unused aisdk.sdk"), hook: () => Effect.die("unused aisdk.hook"),
language: () => Effect.die("unused aisdk.language"),
}, },
catalog: overrides.catalog ?? { catalog: overrides.catalog ?? {
provider: { provider: {
@@ -45,11 +44,15 @@ export function host(overrides: Overrides = {}): PluginContext {
integration: overrides.integration ?? { integration: overrides.integration ?? {
list: () => Effect.die("unused integration.list"), list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"), get: () => Effect.die("unused integration.get"),
connectKey: () => Effect.die("unused integration.connectKey"), connect: {
connectOauth: () => Effect.die("unused integration.connectOauth"), key: () => Effect.die("unused integration.connect.key"),
attemptStatus: () => Effect.die("unused integration.attemptStatus"), oauth: () => Effect.die("unused integration.connect.oauth"),
attemptComplete: () => Effect.die("unused integration.attemptComplete"), },
attemptCancel: () => Effect.die("unused integration.attemptCancel"), 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"), transform: () => Effect.die("unused integration.transform"),
reload: () => Effect.die("unused integration.reload"), reload: () => Effect.die("unused integration.reload"),
connection: { connection: {
@@ -72,10 +75,7 @@ export function host(overrides: Overrides = {}): PluginContext {
}, },
tool: overrides.tool ?? { tool: overrides.tool ?? {
transform: () => Effect.die("unused tool.transform"), transform: () => Effect.die("unused tool.transform"),
execute: { hook: () => Effect.die("unused tool.hook"),
before: () => Effect.die("unused tool.execute.before"),
after: () => Effect.die("unused tool.execute.after"),
},
}, },
session: overrides.session ?? { session: overrides.session ?? {
create: () => Effect.die("unused session.create"), create: () => Effect.die("unused session.create"),
@@ -83,6 +83,7 @@ export function host(overrides: Overrides = {}): PluginContext {
prompt: () => Effect.die("unused session.prompt"), prompt: () => Effect.die("unused session.prompt"),
command: () => Effect.die("unused session.command"), command: () => Effect.die("unused session.command"),
interrupt: () => Effect.die("unused session.interrupt"), interrupt: () => Effect.die("unused session.interrupt"),
hook: () => Effect.die("unused session.hook"),
}, },
} }
} }
@@ -188,11 +189,15 @@ export function integrationHost(integration: Integration.Interface): PluginConte
return { return {
list: () => Effect.die("unused integration.list"), list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"), get: () => Effect.die("unused integration.get"),
connectKey: () => Effect.die("unused integration.connectKey"), connect: {
connectOauth: () => Effect.die("unused integration.connectOauth"), key: () => Effect.die("unused integration.connect.key"),
attemptStatus: () => Effect.die("unused integration.attemptStatus"), oauth: () => Effect.die("unused integration.connect.oauth"),
attemptComplete: () => Effect.die("unused integration.attemptComplete"), },
attemptCancel: () => Effect.die("unused integration.attemptCancel"), 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, reload: integration.reload,
connection: { connection: {
active: (id) => integration.connection.active(Integration.ID.make(id)), active: (id) => integration.connection.active(Integration.ID.make(id)),
+4 -4
View File
@@ -4,7 +4,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise" 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 { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture" import { PluginTestLayer } from "./fixture"
@@ -16,7 +16,7 @@ describe("fromPromise", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin) const host = yield* PluginHost.make(plugin)
const seen: string[] = [] const seen: string[] = []
const promisePlugin = define({ const promisePlugin = Plugin.define({
id: "promise-client-reads", id: "promise-client-reads",
setup: async (ctx) => { setup: async (ctx) => {
const results = await Promise.all([ const results = await Promise.all([
@@ -46,7 +46,7 @@ describe("fromPromise", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin) const host = yield* PluginHost.make(plugin)
const promisePlugin = define({ const promisePlugin = Plugin.define({
id: "promise-example", id: "promise-example",
setup: async (ctx) => { setup: async (ctx) => {
expect(ctx.options.mode).toBe("strict") expect(ctx.options.mode).toBe("strict")
@@ -75,7 +75,7 @@ describe("fromPromise", () => {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin) const host = yield* PluginHost.make(plugin)
const promisePlugin = define({ const promisePlugin = Plugin.define({
id: "promise-dispose", id: "promise-dispose",
setup: async (ctx) => { setup: async (ctx) => {
const registration = await ctx.agent.transform((draft) => { const registration = await ctx.agent.transform((draft) => {
@@ -110,6 +110,25 @@ test("provider state uses the route provider instead of the catalog provider", a
}) })
}) })
test("reasoning state from an empty delta is retained at reasoning end", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.reasoningStart({ id: "reasoning" })))
await Effect.runPromise(
publisher.publish(
LLMEvent.reasoningDelta({
id: "reasoning",
text: "",
providerMetadata: { openai: { signature: "signed" } },
}),
),
)
await Effect.runPromise(publisher.publish(LLMEvent.reasoningEnd({ id: "reasoning" })))
expect(published.find((event) => event.type === "session.reasoning.ended.1")?.data).toMatchObject({
state: { signature: "signed" },
})
})
test("binary failure emits no success event", async () => { test("binary failure emits no success event", async () => {
const { published, publisher } = capture() const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call)) await Effect.runPromise(publisher.publish(call))
@@ -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* () { Effect.gen(function* () {
const service = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
yield* service.register({ yield* service.register({
@@ -96,10 +96,13 @@ describe("ToolRegistry", () => {
.materialize({ model }) .materialize({ model })
.pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name))) .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-5", provider: "openai" })).toEqual(["read", "edit", "write", "patch"])
expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "patch"]) expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual([
expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "patch"]) "read",
expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write"]) "edit",
"write",
"patch",
])
}), }),
) )
+1865 -689
View File
File diff suppressed because it is too large Load Diff
+12 -16
View File
@@ -1,7 +1,7 @@
import { isAbsolute, join } from "node:path" 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 { 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" import { format } from "prettier"
export type InputField = { export type InputField = {
@@ -35,8 +35,6 @@ export type Contract = {
readonly groups: ReadonlyArray<Group> readonly groups: ReadonlyArray<Group>
} }
export type EndpointName = string | readonly [string, ...Array<string>]
export class GenerationError extends Schema.TaggedErrorClass<GenerationError>()("GenerationError", { export class GenerationError extends Schema.TaggedErrorClass<GenerationError>()("GenerationError", {
reason: Schema.String, reason: Schema.String,
}) { }) {
@@ -89,7 +87,6 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
api: HttpApi.HttpApi<Id, Groups>, api: HttpApi.HttpApi<Id, Groups>,
options?: { options?: {
readonly groupNames?: Readonly<Record<string, string>> readonly groupNames?: Readonly<Record<string, string>>
readonly endpointNames?: Readonly<Record<string, EndpointName>>
readonly omitEndpoints?: ReadonlySet<string> readonly omitEndpoints?: ReadonlySet<string>
}, },
): Contract { ): 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) for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable)
} }
const clientPath = normalizeClientPath( const clientPath = clientEndpointPath(
options?.endpointNames?.[endpoint.name] ?? clientEndpointName(endpoint.name), group.identifier,
Context.getOrElse(endpoint.annotations, OpenApi.Identifier, () =>
group.topLevel ? endpoint.name : `${group.identifier}.${endpoint.name}`,
),
) )
endpoints.push({ endpoints.push({
group: groupName, group: groupName,
@@ -699,21 +699,17 @@ function clientOperationKey(group: Group, endpoint: Endpoint) {
return [group.identifier, ...endpoint.clientPath].join(".") return [group.identifier, ...endpoint.clientPath].join(".")
} }
function clientEndpointName(name: string) { function clientEndpointPath(group: string, name: string) {
return name.slice(name.lastIndexOf(".") + 1) const parts = name.split(".")
} if (parts[0] === "v2") parts.shift()
const index = parts.lastIndexOf(group.slice(group.lastIndexOf(".") + 1))
function normalizeClientPath(path: EndpointName) { const result = index < 0 ? parts : parts.slice(index + 1)
const result = typeof path === "string" ? [path] : [...path]
if (result.length === 0 || result.some((part) => part.length === 0)) { if (result.length === 0 || result.some((part) => part.length === 0)) {
throw new GenerationError({ reason: "Client endpoint path must contain non-empty names" }) throw new GenerationError({ reason: "Client endpoint path must contain non-empty names" })
} }
if (result.some((part) => part === "__proto__")) { if (result.some((part) => part === "__proto__")) {
throw new GenerationError({ reason: "Client endpoint path cannot contain __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>] return result as [string, ...Array<string>]
} }
+111 -81
View File
@@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect" 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 { format } from "prettier"
import { import {
compile as compileContract, compile as compileContract,
@@ -139,48 +139,50 @@ describe("HttpApiCodegen.generate", () => {
expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" }) 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( const source = HttpApi.make("test").add(
HttpApiGroup.make("server.permission") HttpApiGroup.make("server.session").add(
.add(HttpApiEndpoint.get("permission.request.list", "/request", { success: Schema.String })) HttpApiEndpoint.get("internal.stage", "/session/revert/stage", { success: Schema.String }).annotateMerge(
.add(HttpApiEndpoint.get("session.permission.list", "/session", { success: Schema.String })), OpenApi.annotations({ identifier: "v2.session.revert.stage" }),
),
),
) )
const contract = compileContract(source, { const contract = compileContract(source, { groupNames: { "server.session": "session" } })
endpointNames: { "permission.request.list": "listRequests" },
})
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( const source = HttpApi.make("test").add(
HttpApiGroup.make("server.session") HttpApiGroup.make("server.session")
.add(HttpApiEndpoint.get("session.instructions.list", "/session/instructions", { success: Schema.String })) .add(
.add(HttpApiEndpoint.put("session.instructions.put", "/session/instructions", { success: Schema.String })) HttpApiEndpoint.get("list", "/session/instructions", { success: Schema.String }).annotateMerge(
.add(HttpApiEndpoint.delete("session.instructions.remove", "/session/instructions", { success: Schema.String })) OpenApi.annotations({ identifier: "v2.session.instructions.list" }),
.add(HttpApiEndpoint.get("session.messages", "/session/message", { success: Schema.String })), ),
)
.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, { const contract = compileContract(source, { groupNames: { "server.session": "session" } })
groupNames: { "server.session": "session" },
endpointNames: {
"session.instructions.list": ["instructions", "list"],
"session.instructions.put": ["instructions", "put"],
"session.instructions.remove": ["instructions", "remove"],
"session.messages": "instructions.flat",
},
})
expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.clientPath)).toEqual([ expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.clientPath)).toEqual([
["instructions", "list"], ["instructions", "list"],
["instructions", "put"], ["instructions", "put"],
["instructions", "remove"], ["instructions", "remove"],
["instructions.flat"],
]) ])
expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual([ expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual([
"instructions.list", "instructions.list",
"instructions.put", "instructions.put",
"instructions.remove", "instructions.remove",
"instructions.flat",
]) ])
const promise = emitPromise(contract, { const promise = emitPromise(contract, {
@@ -196,7 +198,6 @@ describe("HttpApiCodegen.generate", () => {
expect(promiseClient).toContain('"session": { "instructions": { "list": (requestOptions?: RequestOptions)') expect(promiseClient).toContain('"session": { "instructions": { "list": (requestOptions?: RequestOptions)')
expect(promiseClient).toContain('"put": (requestOptions?: RequestOptions)') expect(promiseClient).toContain('"put": (requestOptions?: RequestOptions)')
expect(promiseClient).toContain('"remove": (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('import type { InstructionListWire } from "./instruction-list-wire"')
expect(promiseTypes).toContain("export type SessionInstructionsListOutput = InstructionListWire") expect(promiseTypes).toContain("export type SessionInstructionsListOutput = InstructionListWire")
expect(promiseTypes).toContain("export type SessionInstructionsPutOutput = string") expect(promiseTypes).toContain("export type SessionInstructionsPutOutput = string")
@@ -204,12 +205,12 @@ describe("HttpApiCodegen.generate", () => {
const effect = emitEffect(contract) const effect = emitEffect(contract)
expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain( 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" }) const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" })
expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain( 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" }) 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 "instructions": { readonly "list": SessionInstructionsListOperation<E>')
expect(apiShape).toContain('readonly "put": SessionInstructionsPutOperation<E>') expect(apiShape).toContain('readonly "put": SessionInstructionsPutOperation<E>')
expect(apiShape).toContain('readonly "remove": SessionInstructionsRemoveOperation<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( const source = HttpApi.make("test").add(
HttpApiGroup.make("session") HttpApiGroup.make("session")
.add(HttpApiEndpoint.get("instructions.list", "/session/instructions", { success: Schema.String })) .add(
.add(HttpApiEndpoint.put("instructions.put", "/session/instructions", { success: Schema.String })) HttpApiEndpoint.get("list", "/session/instructions", { success: Schema.String }).annotateMerge(
.add(HttpApiEndpoint.delete("instructions.remove", "/session/instructions", { success: Schema.String })), OpenApi.annotations({ identifier: "session.instructions.list" }),
) ),
const output = emitPromise( )
compileContract(source, { .add(
endpointNames: { HttpApiEndpoint.put("put", "/session/instructions", { success: Schema.String }).annotateMerge(
"instructions.list": ["instructions", "list"], OpenApi.annotations({ identifier: "session.instructions.put" }),
"instructions.put": ["instructions", "put"], ),
"instructions.remove": ["instructions", "remove"], )
}, .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 directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
const methods: Array<string> = [] const methods: Array<string> = []
@@ -262,59 +266,67 @@ describe("HttpApiCodegen.generate", () => {
test("rejects duplicate and leaf-namespace endpoint paths", () => { test("rejects duplicate and leaf-namespace endpoint paths", () => {
const source = HttpApi.make("test").add( const source = HttpApi.make("test").add(
HttpApiGroup.make("session") HttpApiGroup.make("session")
.add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })) .add(
.add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })), 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(() => expect(() => compileContract(source)).toThrow("Client endpoint name collision: session.instructions.list")
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")
}) })
test("rejects nested root collisions across top-level groups", () => { test("rejects nested root collisions across top-level groups", () => {
const source = HttpApi.make("test") const source = HttpApi.make("test")
.add( .add(
HttpApiGroup.make("first", { topLevel: true }).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( .add(
HttpApiGroup.make("second", { topLevel: true }).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(() => expect(() => compileContract(source)).toThrow("Client name collision: instructions")
compileContract(source, {
endpointNames: { "first.list": ["instructions", "list"], "second.put": ["instructions", "put"] },
}),
).toThrow("Client name collision: instructions")
}) })
test("rejects nested paths that collide after type-name normalization", () => { test("rejects nested paths that collide after type-name normalization", () => {
const source = HttpApi.make("test").add( const source = HttpApi.make("test").add(
HttpApiGroup.make("session") HttpApiGroup.make("session")
.add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })) .add(
.add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })), 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( expect(() => compileContract(source)).toThrow("Client endpoint type collision: SessionFooBar")
"Client endpoint type collision: SessionFooBar",
)
}) })
test("rejects ambiguous and prototype-mutating nested path segments", () => { test("rejects ambiguous and prototype-mutating nested path segments", () => {
const source = api(HttpApiEndpoint.get("get", "/session", { success: Schema.String })) const source = api(
HttpApiEndpoint.get("get", "/session", { success: Schema.String }).annotateMerge(
OpenApi.annotations({ identifier: "session.__proto__.get" }),
),
)
expect(() => compileContract(source, { endpointNames: { get: ["a.b", "get"] } })).toThrow( expect(() => compileContract(source)).toThrow("Client endpoint path cannot contain __proto__")
"Nested client endpoint path segments cannot contain dots",
)
expect(() => compileContract(source, { endpointNames: { get: ["__proto__", "get"] } })).toThrow(
"Client endpoint path cannot contain __proto__",
)
}) })
test("rejects normalized group, operation-key, and group prototype collisions", () => { 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") expect(() => compileContract(normalized)).toThrow("Client group type collision: FooBar")
const endpointType = HttpApi.make("test") const endpointType = HttpApi.make("test")
.add(HttpApiGroup.make("foo").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String }))) .add(
.add(HttpApiGroup.make("fooBar").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))) HttpApiGroup.make("foo").add(
expect(() => HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge(
compileContract(endpointType, { OpenApi.annotations({ identifier: "foo.bar.baz" }),
endpointNames: { first: ["bar", "baz"], second: ["baz"] }, ),
}), ),
).toThrow("Client endpoint type collision: FooBarBaz") )
.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") const operationKey = HttpApi.make("test")
.add(HttpApiGroup.make("a.b").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String }))) .add(
.add(HttpApiGroup.make("a").add(HttpApiEndpoint.get("b.c", "/second", { success: Schema.String }))) HttpApiGroup.make("a.b").add(
expect(() => compileContract(operationKey, { endpointNames: { get: "c", "b.c": ["b", "c"] } })).toThrow( HttpApiEndpoint.get("get", "/first", { success: Schema.String }).annotateMerge(
"Client operation key collision: a.b.c", 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( const prototype = HttpApi.make("test").add(
HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })), HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })),
+3 -4
View File
@@ -14,10 +14,9 @@
"./tool": "./src/tool.ts", "./tool": "./src/tool.ts",
"./tui": "./src/tui.ts", "./tui": "./src/tui.ts",
"./v2/effect": "./src/v2/effect/index.ts", "./v2/effect": "./src/v2/effect/index.ts",
"./v2/effect/integration": "./src/v2/effect/integration.ts", "./v2/effect/*": "./src/v2/effect/*.ts",
"./v2/effect/plugin": "./src/v2/effect/plugin.ts", "./v2": "./src/v2/promise/index.ts",
"./v2/effect/tool": "./src/v2/effect/tool.ts", "./v2/*": "./src/v2/promise/*.ts"
"./v2/promise": "./src/v2/promise/index.ts"
}, },
"files": [ "files": [
"dist" "dist"
+1 -1
View File
@@ -176,7 +176,7 @@ Both use the same low-level scoped registration registry, but consumers invoke t
```ts ```ts
ctx.tool.transform(...) // replayed to build effective tool registry state 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. 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
View File
@@ -5,15 +5,13 @@ The Effect plugin API grants plugins two in-process capabilities:
- `hook` installs behavior at an OpenCode extension point. - `hook` installs behavior at an OpenCode extension point.
- `reload` reruns every transform hook for a stateful domain. - `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 ## Defining A Plugin
```ts ```ts
import { define } from "@opencode-ai/plugin/v2/effect" import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect" import { Effect } from "effect"
export const Plugin = define({ export default Plugin.define({
id: "example", id: "example",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((catalog) => { 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`. 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 ```ts
yield * yield *
ctx.aisdk.sdk( ctx.aisdk.hook(
"sdk",
Effect.fn(function* (event) { Effect.fn(function* (event) {
if (event.package !== "@ai-sdk/xai") return if (event.package !== "@ai-sdk/xai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
@@ -73,7 +72,7 @@ yield *
) )
yield * yield *
ctx.aisdk.language((event) => { ctx.aisdk.hook("language", (event) => {
if (event.model.providerID !== "xai") return if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.api.id) 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. 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 ## Reloading A Domain
When data captured by a transform changes, reload the affected domain: When data captured by a transform changes, reload the affected domain:
+3 -3
View File
@@ -1,7 +1,7 @@
import type { AgentApi } from "@opencode-ai/client/effect/api" import type { AgentApi } from "@opencode-ai/client/effect/api"
import type { AgentV2Info } from "@opencode-ai/sdk/v2/types" import type { AgentV2Info } from "@opencode-ai/sdk/v2/types"
import type { Effect } from "effect" import type { Effect } from "effect"
import type { TransformHook } from "./registration.js" import type { Transform } from "./registration.js"
export interface AgentDraft { export interface AgentDraft {
list(): readonly AgentV2Info[] list(): readonly AgentV2Info[]
@@ -11,7 +11,7 @@ export interface AgentDraft {
remove(id: string): void remove(id: string): void
} }
export interface AgentHooks extends AgentApi<unknown> { export interface AgentDomain extends AgentApi<unknown> {
readonly transform: TransformHook<AgentDraft> readonly transform: Transform<AgentDraft>
readonly reload: () => Effect.Effect<void> readonly reload: () => Effect.Effect<void>
} }
+6 -2
View File
@@ -2,7 +2,7 @@ import type { LanguageModelV3 } from "@ai-sdk/provider"
import type { Model } from "@opencode-ai/schema/model" import type { Model } from "@opencode-ai/schema/model"
import type { Hooks } from "./registration.js" import type { Hooks } from "./registration.js"
export type AISDKHooks = Hooks<{ export interface AISDKHooks {
sdk: { sdk: {
readonly model: Model.Info readonly model: Model.Info
readonly package: string readonly package: string
@@ -15,4 +15,8 @@ export type AISDKHooks = Hooks<{
readonly options: Record<string, any> readonly options: Record<string, any>
language?: LanguageModelV3 language?: LanguageModelV3
} }
}> }
export interface AISDKDomain {
readonly hook: Hooks<AISDKHooks>
}
+3 -3
View File
@@ -1,7 +1,7 @@
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types" import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types"
import type { CatalogApi } from "@opencode-ai/client/effect/api" import type { CatalogApi } from "@opencode-ai/client/effect/api"
import type { Effect } from "effect" import type { Effect } from "effect"
import type { TransformHook } from "./registration.js" import type { Transform } from "./registration.js"
export interface CatalogProviderRecord { export interface CatalogProviderRecord {
readonly provider: ProviderV2Info readonly provider: ProviderV2Info
@@ -26,7 +26,7 @@ export interface CatalogDraft {
} }
} }
export interface CatalogHooks extends CatalogApi<unknown> { export interface CatalogDomain extends CatalogApi<unknown> {
readonly transform: TransformHook<CatalogDraft> readonly transform: Transform<CatalogDraft>
readonly reload: () => Effect.Effect<void> readonly reload: () => Effect.Effect<void>
} }
+3 -3
View File
@@ -1,7 +1,7 @@
import type { CommandV2Info } from "@opencode-ai/sdk/v2/types" import type { CommandV2Info } from "@opencode-ai/sdk/v2/types"
import type { CommandApi } from "@opencode-ai/client/effect/api" import type { CommandApi } from "@opencode-ai/client/effect/api"
import type { Effect } from "effect" import type { Effect } from "effect"
import type { TransformHook } from "./registration.js" import type { Transform } from "./registration.js"
export interface CommandDraft { export interface CommandDraft {
list(): readonly CommandV2Info[] list(): readonly CommandV2Info[]
@@ -10,7 +10,7 @@ export interface CommandDraft {
remove(name: string): void remove(name: string): void
} }
export interface CommandHooks extends CommandApi<unknown> { export interface CommandDomain extends CommandApi<unknown> {
readonly transform: TransformHook<CommandDraft> readonly transform: Transform<CommandDraft>
readonly reload: () => Effect.Effect<void> readonly reload: () => Effect.Effect<void>
} }
-27
View File
@@ -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
View File
@@ -1,3 +1,3 @@
import type { EventApi } from "@opencode-ai/client/effect/api" 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
View File
@@ -1,17 +1,4 @@
export type { PluginContext } from "./context.js" export * as Plugin from "./plugin.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 { Agent } from "@opencode-ai/schema/agent" export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command" export { Command } from "@opencode-ai/schema/command"
+3 -3
View File
@@ -11,7 +11,7 @@ import type {
} from "@opencode-ai/sdk/v2/types" } from "@opencode-ai/sdk/v2/types"
import type { IntegrationApi } from "@opencode-ai/client/effect/api" import type { IntegrationApi } from "@opencode-ai/client/effect/api"
import type { Effect, Scope } from "effect" import type { Effect, Scope } from "effect"
import type { TransformHook } from "./registration.js" import type { Transform } from "./registration.js"
export type IntegrationOAuthAuthorization = { export type IntegrationOAuthAuthorization = {
readonly url: string readonly url: string
@@ -56,8 +56,8 @@ export interface IntegrationDraft {
} }
} }
export interface IntegrationHooks extends IntegrationApi<unknown> { export interface IntegrationDomain extends IntegrationApi<unknown> {
readonly transform: TransformHook<IntegrationDraft> readonly transform: Transform<IntegrationDraft>
readonly reload: () => Effect.Effect<void> readonly reload: () => Effect.Effect<void>
readonly connection: { readonly connection: {
readonly active: (integrationID: string) => Effect.Effect<ConnectionInfo | undefined> readonly active: (integrationID: string) => Effect.Effect<ConnectionInfo | undefined>
+27 -4
View File
@@ -1,14 +1,37 @@
import type { PluginApi } from "@opencode-ai/client/effect/api" import type { PluginApi } from "@opencode-ai/client/effect/api"
import type { Effect, Scope } from "effect" 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> { export interface Plugin<R = Scope.Scope> {
readonly id: string 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>) { export function define<R = Scope.Scope>(plugin: Plugin<R>) {
return plugin return plugin
} }
export interface PluginDomain extends PluginApi<unknown> {}
+3 -3
View File
@@ -1,7 +1,7 @@
import type { ReferenceGitSource, ReferenceLocalSource } from "@opencode-ai/sdk/v2/types" import type { ReferenceGitSource, ReferenceLocalSource } from "@opencode-ai/sdk/v2/types"
import type { ReferenceApi } from "@opencode-ai/client/effect/api" import type { ReferenceApi } from "@opencode-ai/client/effect/api"
import type { Effect } from "effect" import type { Effect } from "effect"
import type { TransformHook } from "./registration.js" import type { Transform } from "./registration.js"
export interface ReferenceDraft { export interface ReferenceDraft {
add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void
@@ -9,7 +9,7 @@ export interface ReferenceDraft {
list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[] list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[]
} }
export interface ReferenceHooks extends ReferenceApi<unknown> { export interface ReferenceDomain extends ReferenceApi<unknown> {
readonly transform: TransformHook<ReferenceDraft> readonly transform: Transform<ReferenceDraft>
readonly reload: () => Effect.Effect<void> readonly reload: () => Effect.Effect<void>
} }
@@ -4,10 +4,9 @@ export interface Registration {
readonly dispose: Effect.Effect<void> readonly dispose: Effect.Effect<void>
} }
export type Hooks<Spec> = { export type Hooks<Spec> = <Name extends keyof Spec>(
readonly [Name in keyof Spec]: ( name: Name,
callback: (input: Spec[Name]) => Effect.Effect<void> | void, callback: (input: Spec[Name]) => Effect.Effect<void>,
) => Effect.Effect<Registration, never, Scope.Scope> ) => 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>
-4
View File
@@ -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
View File
@@ -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
View File
@@ -1,14 +1,14 @@
import type { SkillV2Source } from "@opencode-ai/sdk/v2/types" import type { SkillV2Source } from "@opencode-ai/sdk/v2/types"
import type { SkillApi } from "@opencode-ai/client/effect/api" import type { SkillApi } from "@opencode-ai/client/effect/api"
import type { Effect } from "effect" import type { Effect } from "effect"
import type { TransformHook } from "./registration.js" import type { Transform } from "./registration.js"
export interface SkillDraft { export interface SkillDraft {
source(source: SkillV2Source): void source(source: SkillV2Source): void
list(): readonly SkillV2Source[] list(): readonly SkillV2Source[]
} }
export interface SkillHooks extends SkillApi<unknown> { export interface SkillDomain extends SkillApi<unknown> {
readonly transform: TransformHook<SkillDraft> readonly transform: Transform<SkillDraft>
readonly reload: () => Effect.Effect<void> readonly reload: () => Effect.Effect<void>
} }
+9 -4
View File
@@ -5,7 +5,7 @@ import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session" import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message" import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Effect, JsonSchema, Schema, type Scope } from "effect" import { Effect, JsonSchema, Schema, type Scope } from "effect"
import type { Hooks } from "./registration.js" import type { Hooks, Transform } from "./registration.js"
export interface Context { export interface Context {
readonly sessionID: Session.ID readonly sessionID: Session.ID
@@ -253,7 +253,12 @@ export interface ToolDraft {
add(name: string, tool: AnyTool, options?: RegisterOptions): void add(name: string, tool: AnyTool, options?: RegisterOptions): void
} }
export interface ToolDomain { export interface ToolHooks {
readonly transform: (callback: (draft: ToolDraft) => void) => Effect.Effect<void, RegistrationError, Scope.Scope> readonly "execute.before": ToolExecuteBeforeEvent
readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }> readonly "execute.after": ToolExecuteAfterEvent
}
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly hook: Hooks<ToolHooks>
} }
+31 -6
View File
@@ -1,6 +1,6 @@
# OpenCode V2 Promise Plugin API # 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. - `hook` installs behavior at an OpenCode extension point.
- `reload` reruns every transform hook for a stateful domain. - `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 ## Defining A Plugin
```ts ```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", id: "example",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.catalog.transform((catalog) => { 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`. Configuration supplied for the plugin is available as `ctx.options`.
@@ -64,18 +64,43 @@ ctx.skill.transform
Runtime hooks intercept live operations: Runtime hooks intercept live operations:
```ts ```ts
await ctx.aisdk.sdk(async (event) => { await ctx.aisdk.hook("sdk", async (event) => {
if (event.package !== "@ai-sdk/xai") return if (event.package !== "@ai-sdk/xai") return
const mod = await import("@ai-sdk/xai") const mod = await import("@ai-sdk/xai")
event.sdk = mod.createXai(event.options) event.sdk = mod.createXai(event.options)
}) })
await ctx.aisdk.language((event) => { await ctx.aisdk.hook("language", (event) => {
if (event.model.providerID !== "xai") return if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.api.id) 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 ## Reloading A Domain
When data captured by a transform changes, reload the affected domain: When data captured by a transform changes, reload the affected domain:
+3 -3
View File
@@ -1,10 +1,10 @@
import type { AgentApi } from "@opencode-ai/client/promise/api" import type { AgentApi } from "@opencode-ai/client/promise/api"
import type { AgentDraft } from "../effect/agent.js" import type { AgentDraft } from "../effect/agent.js"
import type { TransformHook } from "./registration.js" import type { Transform } from "./registration.js"
export type { AgentDraft } export type { AgentDraft }
export interface AgentHooks extends AgentApi { export interface AgentDomain extends AgentApi {
readonly transform: TransformHook<AgentDraft> readonly transform: Transform<AgentDraft>
readonly reload: () => Promise<void> readonly reload: () => Promise<void>
} }
+6 -2
View File
@@ -2,7 +2,7 @@ import type { LanguageModelV3 } from "@ai-sdk/provider"
import type { Model } from "@opencode-ai/schema/model" import type { Model } from "@opencode-ai/schema/model"
import type { Hooks } from "./registration.js" import type { Hooks } from "./registration.js"
export type AISDKHooks = Hooks<{ export interface AISDKHooks {
sdk: { sdk: {
readonly model: Model.Info readonly model: Model.Info
readonly package: string readonly package: string
@@ -15,4 +15,8 @@ export type AISDKHooks = Hooks<{
readonly options: Record<string, any> readonly options: Record<string, any>
language?: LanguageModelV3 language?: LanguageModelV3
} }
}> }
export interface AISDKDomain {
readonly hook: Hooks<AISDKHooks>
}

Some files were not shown because too many files have changed in this diff Show More