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

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