mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 06:33:01 -04:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed3154df2a | |||
| 34c29df60d | |||
| 2efe5d1034 | |||
| 64f84c1475 | |||
| 2ada79018e | |||
| 044d04df06 | |||
| 4b9d89e943 | |||
| 5ff6bb87cf | |||
| 511b4556a2 | |||
| cb39ea1136 | |||
| ff9452bf03 | |||
| 97265f8ac5 | |||
| 56e66656b8 | |||
| 02f3f3cb3e | |||
| b0c3a16ead | |||
| 594c395576 | |||
| b7402c264d | |||
| 3f79699bce | |||
| 67fe76057e | |||
| bac474aaa0 | |||
| 98c717cb5b | |||
| 5c8d46ab4b | |||
| d5e83fefda | |||
| 46378dda50 | |||
| b38d9d812f | |||
| 8df039d261 | |||
| c92fb2d41b | |||
| 958308c913 | |||
| 16390ca47d | |||
| 643eed300d | |||
| c3a6721de2 |
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"@opencode-ai/plugin": patch
|
||||||
|
"@opencode-ai/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add transport-neutral Session model request hooks and provider-scoped hook registration so eligible OpenAI Responses requests can prefer WebSocket without bypassing HTTP-only middleware.
|
||||||
@@ -82,6 +82,7 @@ jobs:
|
|||||||
build-cli:
|
build-cli:
|
||||||
needs: version
|
needs: version
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
timeout-minutes: 30
|
||||||
if: github.repository == 'anomalyco/opencode'
|
if: github.repository == 'anomalyco/opencode'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||||
|
|||||||
@@ -905,6 +905,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
|||||||
|
|
||||||
if (delta?.type === "input_json_delta" && event.index !== undefined) {
|
if (delta?.type === "input_json_delta" && event.index !== undefined) {
|
||||||
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult
|
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult
|
||||||
|
if (!state.tools[event.index]) return [state, NO_EVENTS] satisfies StepResult
|
||||||
const result = ToolStream.appendExisting(
|
const result = ToolStream.appendExisting(
|
||||||
ADAPTER,
|
ADAPTER,
|
||||||
state.tools,
|
state.tools,
|
||||||
|
|||||||
@@ -1010,6 +1010,34 @@ describe("Anthropic Messages route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("ignores tool input deltas without a matching tool start", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||||
|
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||||
|
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
|
||||||
|
{
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 1,
|
||||||
|
delta: { type: "input_json_delta", partial_json: '{"query":"orphaned"}' },
|
||||||
|
},
|
||||||
|
{ type: "content_block_stop", index: 0 },
|
||||||
|
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||||
|
{ type: "message_stop" },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text).toBe("Hello")
|
||||||
|
expect(response.toolCalls).toEqual([])
|
||||||
|
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("settles pending tool calls at message_stop", () =>
|
it.effect("settles pending tool calls at message_stop", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useMcpToggle } from "@/context/mcp"
|
|||||||
import { useWorkspaceLocation } from "@/context/location"
|
import { useWorkspaceLocation } from "@/context/location"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useData } from "@/context/server"
|
import { useData } from "@/context/server"
|
||||||
|
import { pluginLabel } from "@/utils/plugin"
|
||||||
import { ExternalLink } from "./external-link"
|
import { ExternalLink } from "./external-link"
|
||||||
|
|
||||||
type SkillItem = {
|
type SkillItem = {
|
||||||
@@ -101,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
|
|||||||
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
|
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
|
||||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||||
)
|
)
|
||||||
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
|
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
|
||||||
const projectPlugins = createMemo(() => {
|
const projectPlugins = createMemo(() => {
|
||||||
const shared = new Set(globalPlugins())
|
const shared = new Set(globalPlugins())
|
||||||
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
|
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
|
||||||
})
|
})
|
||||||
|
|
||||||
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
|
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
|
||||||
|
|||||||
@@ -67,32 +67,18 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
cmd &&
|
cmd &&
|
||||||
input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)
|
input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)
|
||||||
) {
|
) {
|
||||||
setBusy()
|
await input.api.command({
|
||||||
try {
|
sessionID: input.draft.sessionID,
|
||||||
const messageID = Identifier.ascending("message")
|
command: cmd,
|
||||||
await input.api.command({
|
text: tail.join(" "),
|
||||||
sessionID: input.draft.sessionID,
|
files: await Promise.all(
|
||||||
id: messageID,
|
images.map(async (attachment) => ({
|
||||||
command: cmd,
|
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||||
arguments: tail.join(" "),
|
name: attachment.filename,
|
||||||
agent: input.draft.agent,
|
})),
|
||||||
model: {
|
),
|
||||||
id: input.draft.model.modelID,
|
})
|
||||||
providerID: input.draft.model.providerID,
|
return true
|
||||||
variant: input.draft.variant,
|
|
||||||
},
|
|
||||||
files: await Promise.all(
|
|
||||||
images.map(async (attachment) => ({
|
|
||||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
|
||||||
name: attachment.filename,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
} catch (err) {
|
|
||||||
setIdle()
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageID = input.messageID ?? Identifier.ascending("message")
|
const messageID = input.messageID ?? Identifier.ascending("message")
|
||||||
@@ -446,16 +432,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
?.find((command) => command.name === commandName)
|
?.find((command) => command.name === commandName)
|
||||||
if (customCommand) {
|
if (customCommand) {
|
||||||
clearInput()
|
clearInput()
|
||||||
const messageID = Identifier.ascending("message")
|
|
||||||
submissionData.session.setStatus(session.id, "running")
|
|
||||||
void submissionServerSDK.api.session
|
void submissionServerSDK.api.session
|
||||||
.command({
|
.command({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
id: messageID,
|
|
||||||
command: commandName,
|
command: commandName,
|
||||||
arguments: args.join(" "),
|
text: args.join(" "),
|
||||||
agent,
|
|
||||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
|
||||||
files: await Promise.all(
|
files: await Promise.all(
|
||||||
images.map(async (attachment) => ({
|
images.map(async (attachment) => ({
|
||||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||||
@@ -464,7 +445,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
submissionData.session.setStatus(session.id, "idle")
|
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useData } from "@/context/server"
|
import { useData } from "@/context/server"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useMcpToggle } from "@/context/mcp"
|
import { useMcpToggle } from "@/context/mcp"
|
||||||
|
import { pluginLabel } from "@/utils/plugin"
|
||||||
import { ExternalLink } from "../external-link"
|
import { ExternalLink } from "../external-link"
|
||||||
import { InlineServerSelect } from "./parts/server-select"
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
@@ -44,7 +45,9 @@ export const SettingsExtensionsV2: Component = () => {
|
|||||||
() => serverSdk.connection.status() === "connected",
|
() => serverSdk.connection.status() === "connected",
|
||||||
() => serverSdk.api.plugin.list().then((result) => result.data),
|
() => serverSdk.api.plugin.list().then((result) => result.data),
|
||||||
)
|
)
|
||||||
const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
|
const plugins = createMemo<PluginRowItem[]>(() =>
|
||||||
|
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (serverSdk.connection.status() !== "connected") return
|
if (serverSdk.connection.status() !== "connected") return
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useMcpToggle } from "@/context/mcp"
|
|||||||
import { useWorkspaceLocation } from "@/context/location"
|
import { useWorkspaceLocation } from "@/context/location"
|
||||||
import { useData } from "@/context/server"
|
import { useData } from "@/context/server"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { pluginLabel } from "@/utils/plugin"
|
||||||
|
|
||||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||||
const parts = value.split(file)
|
const parts = value.split(file)
|
||||||
@@ -38,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
|||||||
() => (props.shown ? sdk().directory : undefined),
|
() => (props.shown ? sdk().directory : undefined),
|
||||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||||
)
|
)
|
||||||
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
|
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
|
||||||
const pluginCount = createMemo(() => plugins().length)
|
const pluginCount = createMemo(() => plugins().length)
|
||||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { PluginInfo } from "@opencode-ai/client"
|
||||||
|
|
||||||
|
export function pluginLabel(plugin: PluginInfo) {
|
||||||
|
if (plugin.id) return plugin.id
|
||||||
|
if (plugin.source.type === "package") return plugin.source.package
|
||||||
|
if (plugin.source.type === "local") return plugin.source.path
|
||||||
|
return plugin.source.type
|
||||||
|
}
|
||||||
@@ -42,8 +42,8 @@
|
|||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"tree-sitter-bash": "0.25.0",
|
"tree-sitter-bash": "0.25.0",
|
||||||
"tree-sitter-powershell": "0.25.10",
|
"tree-sitter-powershell": "0.25.10",
|
||||||
"web-tree-sitter": "0.25.10",
|
|
||||||
"uqr": "0.1.3",
|
"uqr": "0.1.3",
|
||||||
|
"web-tree-sitter": "0.25.10",
|
||||||
"ws": "8.21.0"
|
"ws": "8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ for (const item of targets) {
|
|||||||
external: ["node-gyp"],
|
external: ["node-gyp"],
|
||||||
format: "esm",
|
format: "esm",
|
||||||
minify: true,
|
minify: true,
|
||||||
sourcemap: "inline",
|
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
|
||||||
splitting: true,
|
splitting: true,
|
||||||
compile: {
|
compile: {
|
||||||
autoloadBunfig: false,
|
autoloadBunfig: false,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { Service } from "@opencode-ai/client/effect/service"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||||
import { Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
@@ -63,28 +64,22 @@ try {
|
|||||||
})
|
})
|
||||||
if (unauthorizedOpenApi.status !== 401)
|
if (unauthorizedOpenApi.status !== 401)
|
||||||
throw new Error("Compiled service exposed application routes without authentication")
|
throw new Error("Compiled service exposed application routes without authentication")
|
||||||
const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), {
|
const stopRoute = await fetch(new URL("/api/service/stop", info.url), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { ...headers, "content-type": "application/json" },
|
||||||
body: JSON.stringify({ instanceID: info.id }),
|
body: JSON.stringify({ instanceID: info.id }),
|
||||||
signal: AbortSignal.timeout(5_000),
|
signal: AbortSignal.timeout(5_000),
|
||||||
})
|
})
|
||||||
if (unauthorizedStop.status !== 401) throw new Error("Compiled service accepted unauthenticated stop")
|
if (stopRoute.status !== 404) throw new Error("Compiled service exposed the removed HTTP stop route")
|
||||||
|
|
||||||
const winner = processes.find((process) => process.pid === info.pid)
|
const winner = processes.find((process) => process.pid === info.pid)
|
||||||
const loser = processes.find((process) => process.pid !== info.pid)
|
const loser = processes.find((process) => process.pid !== info.pid)
|
||||||
if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner")
|
if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner")
|
||||||
if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit")
|
if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit")
|
||||||
|
|
||||||
const stopped = await Schema.decodeUnknownPromise(ServiceStatus.StopResponse)(
|
await Effect.runPromise(
|
||||||
await fetch(new URL("/api/service/stop", info.url), {
|
Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||||
method: "POST",
|
|
||||||
headers: { ...headers, "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ instanceID: info.id }),
|
|
||||||
signal: AbortSignal.timeout(5_000),
|
|
||||||
}).then((response) => response.json()),
|
|
||||||
)
|
)
|
||||||
if (!stopped.accepted) throw new Error("Compiled service rejected exact-instance stop")
|
|
||||||
if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop")
|
if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop")
|
||||||
for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25)
|
for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25)
|
||||||
if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed")
|
if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed")
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export async function streamTurn(input: {
|
|||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
readonly start: TurnStart
|
readonly start: TurnStart
|
||||||
readonly writeTextFile: boolean
|
readonly writeTextFile: boolean
|
||||||
|
readonly action?: boolean
|
||||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||||
readonly control: TurnControl
|
readonly control: TurnControl
|
||||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||||
@@ -345,6 +346,11 @@ export async function streamTurn(input: {
|
|||||||
await input.submit(control.admission.signal).catch((error) => {
|
await input.submit(control.admission.signal).catch((error) => {
|
||||||
if (!control.cancelled) throw error
|
if (!control.cancelled) throw error
|
||||||
})
|
})
|
||||||
|
if (input.action) {
|
||||||
|
streamController.abort()
|
||||||
|
await completed.catch(() => {})
|
||||||
|
return response(undefined, undefined, "succeeded", control.cancelled, undefined)
|
||||||
|
}
|
||||||
if (control.cancelled) {
|
if (control.cancelled) {
|
||||||
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||||
if (!started) {
|
if (!started) {
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
cwd: state.cwd,
|
cwd: state.cwd,
|
||||||
start: prepared.start,
|
start: prepared.start,
|
||||||
writeTextFile: capabilities.writeTextFile,
|
writeTextFile: capabilities.writeTextFile,
|
||||||
|
action: prepared.command !== undefined,
|
||||||
control,
|
control,
|
||||||
connectionSignal: input.connection.signal,
|
connectionSignal: input.connection.signal,
|
||||||
sessionSignal: state.abort.signal,
|
sessionSignal: state.abort.signal,
|
||||||
@@ -377,9 +378,8 @@ async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: P
|
|||||||
return client.session.command(
|
return client.session.command(
|
||||||
{
|
{
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
id: prompt.start.id,
|
|
||||||
command: prompt.command.name,
|
command: prompt.command.name,
|
||||||
arguments: prompt.slash?.args,
|
text: prompt.slash?.args ?? "",
|
||||||
files: prompt.files,
|
files: prompt.files,
|
||||||
delivery: "steer",
|
delivery: "steer",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -275,15 +275,36 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
|||||||
Spec.make("stop", { description: "Stop the background server" }),
|
Spec.make("stop", { description: "Stop the background server" }),
|
||||||
Spec.make("get", {
|
Spec.make("get", {
|
||||||
description: "Get service configuration",
|
description: "Get service configuration",
|
||||||
params: { key: Argument.string("key").pipe(Argument.optional) },
|
params: {
|
||||||
|
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env"), Argument.optional),
|
||||||
|
name: Argument.string("name").pipe(
|
||||||
|
Argument.withDescription("Environment variable name"),
|
||||||
|
Argument.optional,
|
||||||
|
),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Spec.make("set", {
|
Spec.make("set", {
|
||||||
description: "Set service configuration",
|
description: "Set service configuration",
|
||||||
params: { key: Argument.string("key"), value: Argument.string("value") },
|
params: {
|
||||||
|
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
|
||||||
|
value: Argument.string("value").pipe(
|
||||||
|
Argument.withDescription("Setting value or environment variable name"),
|
||||||
|
),
|
||||||
|
nestedValue: Argument.string("env-value").pipe(
|
||||||
|
Argument.withDescription("Environment variable value"),
|
||||||
|
Argument.optional,
|
||||||
|
),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Spec.make("unset", {
|
Spec.make("unset", {
|
||||||
description: "Unset service configuration",
|
description: "Unset service configuration",
|
||||||
params: { key: Argument.string("key") },
|
params: {
|
||||||
|
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
|
||||||
|
name: Argument.string("name").pipe(
|
||||||
|
Argument.withDescription("Environment variable name"),
|
||||||
|
Argument.optional,
|
||||||
|
),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
|
|||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { Context, Effect, FileSystem, Option } from "effect"
|
import { Context, Effect, FileSystem, Option, Queue } from "effect"
|
||||||
import { ServerConnection } from "../../services/server-connection"
|
import { ServerConnection } from "../../services/server-connection"
|
||||||
import { Updater } from "../../services/updater"
|
import { Updater } from "../../services/updater"
|
||||||
import { UpdatePreflight } from "../../services/update-preflight"
|
import { UpdatePreflight } from "../../services/update-preflight"
|
||||||
@@ -19,11 +19,21 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
||||||
const preflight = UpdatePreflight.make()
|
const preflight = UpdatePreflight.make()
|
||||||
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||||
|
const serviceStarts = yield* Queue.unbounded<{
|
||||||
|
readonly reason: "missing" | "version-mismatch"
|
||||||
|
readonly previousVersion?: string
|
||||||
|
}>()
|
||||||
|
yield* Queue.take(serviceStarts).pipe(
|
||||||
|
Effect.flatMap((event) => Effect.logInfo("background service starting", event)),
|
||||||
|
Effect.forever,
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
const server = yield* ServerConnection.resolve({
|
const server = yield* ServerConnection.resolve({
|
||||||
server: requestedServer,
|
server: requestedServer,
|
||||||
standalone: input.standalone,
|
standalone: input.standalone,
|
||||||
mismatch: "replace",
|
mismatch: "replace",
|
||||||
onStart: (reason, previousVersion) => {
|
onStart: (reason, previousVersion) => {
|
||||||
|
Queue.offerUnsafe(serviceStarts, { reason, previousVersion })
|
||||||
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
reason === "version-mismatch"
|
reason === "version-mismatch"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { EOL } from "node:os"
|
import { EOL } from "node:os"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { OpenCode } from "@opencode-ai/client"
|
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
|
||||||
import { Service } from "@opencode-ai/client/effect/service"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
@@ -14,11 +14,18 @@ export default Runtime.handler(
|
|||||||
const endpoint = found ?? (yield* Service.ensure(options))
|
const endpoint = found ?? (yield* Service.ensure(options))
|
||||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
||||||
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
|
const plugins = response.data.toSorted((a, b) => name(a).localeCompare(name(b)))
|
||||||
if (plugins.length === 0) {
|
if (plugins.length === 0) {
|
||||||
process.stdout.write("No plugins loaded" + EOL)
|
process.stdout.write("No plugins loaded" + EOL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
|
process.stdout.write(plugins.map(name).join(EOL) + EOL)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function name(plugin: PluginInfo) {
|
||||||
|
if (plugin.id) return plugin.id
|
||||||
|
if (plugin.source.type === "package") return plugin.source.package
|
||||||
|
if (plugin.source.type === "local") return plugin.source.path
|
||||||
|
return plugin.source.type
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { ServiceConfig } from "../../../services/service-config"
|
|||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.service.commands.get,
|
Commands.commands.service.commands.get,
|
||||||
Effect.fn("cli.service.get")(function* (input) {
|
Effect.fn("cli.service.get")(function* (input) {
|
||||||
process.stdout.write((yield* ServiceConfig.get(Option.getOrUndefined(input.key))) + EOL)
|
process.stdout.write(
|
||||||
|
(yield* ServiceConfig.get(Option.getOrUndefined(input.key), Option.getOrUndefined(input.name))) + EOL,
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { ServiceConfig } from "../../../services/service-config"
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
@@ -6,6 +6,6 @@ import { ServiceConfig } from "../../../services/service-config"
|
|||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.service.commands.set,
|
Commands.commands.service.commands.set,
|
||||||
Effect.fn("cli.service.set")(function* (input) {
|
Effect.fn("cli.service.set")(function* (input) {
|
||||||
yield* ServiceConfig.set(input.key, input.value)
|
yield* ServiceConfig.set(input.key, input.value, Option.getOrUndefined(input.nestedValue))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { ServiceConfig } from "../../../services/service-config"
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
@@ -6,6 +6,6 @@ import { ServiceConfig } from "../../../services/service-config"
|
|||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.service.commands.unset,
|
Commands.commands.service.commands.unset,
|
||||||
Effect.fn("cli.service.unset")(function* (input) {
|
Effect.fn("cli.service.unset")(function* (input) {
|
||||||
yield* ServiceConfig.unset(input.key)
|
yield* ServiceConfig.unset(input.key, Option.getOrUndefined(input.name))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -59,6 +59,21 @@ const Handlers = Runtime.handlers(Commands, {
|
|||||||
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Heap.listen
|
yield* Heap.listen
|
||||||
|
const runFork = Effect.runForkWith(yield* Effect.context<never>())
|
||||||
|
const uncaughtException = (cause: Error, origin: "uncaughtException" | "unhandledRejection") => {
|
||||||
|
runFork(Effect.logError("uncaught exception", { cause, origin }))
|
||||||
|
}
|
||||||
|
const unhandledRejection = (cause: unknown) => {
|
||||||
|
runFork(Effect.logError("unhandled rejection", { cause }))
|
||||||
|
}
|
||||||
|
process.on("uncaughtException", uncaughtException)
|
||||||
|
process.on("unhandledRejection", unhandledRejection)
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
process.off("uncaughtException", uncaughtException)
|
||||||
|
process.off("unhandledRejection", unhandledRejection)
|
||||||
|
}),
|
||||||
|
)
|
||||||
yield* Effect.logInfo("cli starting", {
|
yield* Effect.logInfo("cli starting", {
|
||||||
version: OPENCODE_VERSION,
|
version: OPENCODE_VERSION,
|
||||||
channel: OPENCODE_CHANNEL,
|
channel: OPENCODE_CHANNEL,
|
||||||
@@ -67,6 +82,12 @@ Effect.gen(function* () {
|
|||||||
})
|
})
|
||||||
return yield* Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })
|
return yield* Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })
|
||||||
}).pipe(
|
}).pipe(
|
||||||
|
Effect.catchCause((cause) =>
|
||||||
|
Effect.logError("cli process failed", {
|
||||||
|
cause,
|
||||||
|
args: process.argv.slice(2),
|
||||||
|
}).pipe(Effect.andThen(Effect.failCause(cause))),
|
||||||
|
),
|
||||||
Effect.annotateLogs({ role: "cli" }),
|
Effect.annotateLogs({ role: "cli" }),
|
||||||
Effect.provide(Config.layer),
|
Effect.provide(Config.layer),
|
||||||
Effect.provide(Updater.layer),
|
Effect.provide(Updater.layer),
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
|||||||
serviceOptions === undefined
|
serviceOptions === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
instanceID,
|
|
||||||
onListen: (address, shutdown) =>
|
onListen: (address, shutdown) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (!config.password) yield* ServiceConfig.password(password)
|
if (!config.password) yield* ServiceConfig.password(password)
|
||||||
@@ -180,18 +179,36 @@ const register = Effect.fnUntraced(function* (
|
|||||||
password,
|
password,
|
||||||
}
|
}
|
||||||
const encoded = yield* encodeInfo(info)
|
const encoded = yield* encodeInfo(info)
|
||||||
const current = fs.readFileString(file).pipe(
|
const current = fs.readFileString(file).pipe(Effect.flatMap(decodeInfo))
|
||||||
Effect.flatMap(decodeInfo),
|
const owns = (found: Info) =>
|
||||||
Effect.orElseSucceed(() => undefined),
|
found.id === info.id &&
|
||||||
)
|
|
||||||
const owns = (found: Info | undefined) =>
|
|
||||||
found?.id === info.id &&
|
|
||||||
found.version === info.version &&
|
found.version === info.version &&
|
||||||
found.url === info.url &&
|
found.url === info.url &&
|
||||||
found.pid === info.pid &&
|
found.pid === info.pid &&
|
||||||
found.password === info.password
|
found.password === info.password
|
||||||
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
|
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
|
||||||
yield* current.pipe(
|
yield* current.pipe(
|
||||||
|
Effect.catchCause((cause) =>
|
||||||
|
Effect.logWarning("managed service registration check failed; shutting down", {
|
||||||
|
cause,
|
||||||
|
serviceID: id,
|
||||||
|
servicePID: process.pid,
|
||||||
|
registration: file,
|
||||||
|
}).pipe(Effect.andThen(Effect.failCause(cause))),
|
||||||
|
),
|
||||||
|
Effect.tap((found) =>
|
||||||
|
owns(found)
|
||||||
|
? Effect.void
|
||||||
|
: Effect.logWarning("managed service registration replaced; shutting down", {
|
||||||
|
serviceID: id,
|
||||||
|
servicePID: process.pid,
|
||||||
|
registration: file,
|
||||||
|
observedServiceID: found.id,
|
||||||
|
observedServicePID: found.pid,
|
||||||
|
observedVersion: found.version,
|
||||||
|
observedURL: found.url,
|
||||||
|
}),
|
||||||
|
),
|
||||||
Effect.filterOrFail(owns),
|
Effect.filterOrFail(owns),
|
||||||
Effect.repeat(Schedule.spaced("5 seconds")),
|
Effect.repeat(Schedule.spaced("5 seconds")),
|
||||||
Effect.ignore,
|
Effect.ignore,
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ export const Info = Schema.Struct({
|
|||||||
hostname: Schema.optional(Schema.String),
|
hostname: Schema.optional(Schema.String),
|
||||||
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
|
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
|
||||||
password: Schema.optional(Schema.String),
|
password: Schema.optional(Schema.String),
|
||||||
|
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||||
})
|
})
|
||||||
export type Info = typeof Info.Type
|
export type Info = typeof Info.Type
|
||||||
|
|
||||||
const keys = ["hostname", "port", "password"] as const
|
const keys = ["hostname", "port", "password", "env"] as const
|
||||||
type Key = (typeof keys)[number]
|
type Key = (typeof keys)[number]
|
||||||
|
|
||||||
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||||
@@ -76,7 +77,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
|
|||||||
})
|
})
|
||||||
|
|
||||||
function configKey(key: string): Key {
|
function configKey(key: string): Key {
|
||||||
if (key === "hostname" || key === "port" || key === "password") return key
|
if (key === "hostname" || key === "port" || key === "password" || key === "env") return key
|
||||||
throw new Error(`Unknown service config key: ${key}`)
|
throw new Error(`Unknown service config key: ${key}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +105,7 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
|
|||||||
return {
|
return {
|
||||||
file,
|
file,
|
||||||
version: input.checkVersion ? OPENCODE_VERSION : undefined,
|
version: input.checkVersion ? OPENCODE_VERSION : undefined,
|
||||||
|
env: (yield* read()).env,
|
||||||
command: [
|
command: [
|
||||||
...selfCommand(),
|
...selfCommand(),
|
||||||
"serve",
|
"serve",
|
||||||
@@ -141,12 +143,14 @@ export const password = Effect.fn("cli.service-config.password")(function* (valu
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
export const get = Effect.fn("cli.service-config.get")(function* (key?: string) {
|
export const get = Effect.fn("cli.service-config.get")(function* (key?: string, name?: string) {
|
||||||
if (key === undefined) {
|
if (key === undefined) {
|
||||||
const { password: _password, ...safe } = yield* read()
|
const { password: _password, ...safe } = yield* read()
|
||||||
return JSON.stringify(safe, null, 2)
|
return JSON.stringify(safe, null, 2)
|
||||||
}
|
}
|
||||||
switch (configKey(key)) {
|
const selected = configKey(key)
|
||||||
|
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`)
|
||||||
|
switch (selected) {
|
||||||
case "hostname": {
|
case "hostname": {
|
||||||
return (yield* read()).hostname ?? ""
|
return (yield* read()).hostname ?? ""
|
||||||
}
|
}
|
||||||
@@ -157,12 +161,19 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string)
|
|||||||
case "password": {
|
case "password": {
|
||||||
return yield* password()
|
return yield* password()
|
||||||
}
|
}
|
||||||
|
case "env": {
|
||||||
|
const env = (yield* read()).env ?? {}
|
||||||
|
return name === undefined ? JSON.stringify(env, null, 2) : (env[name] ?? "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw new Error(`Unknown service config key: ${key}`)
|
throw new Error(`Unknown service config key: ${key}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string) {
|
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string, nestedValue?: string) {
|
||||||
switch (configKey(key)) {
|
const selected = configKey(key)
|
||||||
|
if (selected !== "env" && nestedValue !== undefined)
|
||||||
|
throw new Error(`Usage: opencode service set ${selected} <value>`)
|
||||||
|
switch (selected) {
|
||||||
case "hostname": {
|
case "hostname": {
|
||||||
yield* Service.stop(yield* options())
|
yield* Service.stop(yield* options())
|
||||||
yield* write({ ...(yield* read()), hostname: value })
|
yield* write({ ...(yield* read()), hostname: value })
|
||||||
@@ -180,11 +191,20 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
|
|||||||
yield* password(value)
|
yield* password(value)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case "env": {
|
||||||
|
if (nestedValue === undefined) throw new Error("Usage: opencode service set env <key> <value>")
|
||||||
|
yield* Service.stop(yield* options())
|
||||||
|
const existing = yield* read()
|
||||||
|
yield* write({ ...existing, env: { ...existing.env, [value]: nestedValue } })
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const unset = Effect.fn("cli.service-config.unset")(function* (key: string) {
|
export const unset = Effect.fn("cli.service-config.unset")(function* (key: string, name?: string) {
|
||||||
switch (configKey(key)) {
|
const selected = configKey(key)
|
||||||
|
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service unset ${selected}`)
|
||||||
|
switch (selected) {
|
||||||
case "hostname": {
|
case "hostname": {
|
||||||
yield* Service.stop(yield* options())
|
yield* Service.stop(yield* options())
|
||||||
const { hostname: _hostname, ...next } = yield* read()
|
const { hostname: _hostname, ...next } = yield* read()
|
||||||
@@ -203,6 +223,15 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
|
|||||||
yield* write(next)
|
yield* write(next)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case "env": {
|
||||||
|
if (name === undefined) throw new Error("Usage: opencode service unset env <key>")
|
||||||
|
yield* Service.stop(yield* options())
|
||||||
|
const existing = yield* read()
|
||||||
|
const { [name]: _removed, ...env } = existing.env ?? {}
|
||||||
|
const { env: _existingEnv, ...rest } = existing
|
||||||
|
yield* write(Object.keys(env).length === 0 ? rest : { ...rest, env })
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -121,3 +121,42 @@ test("acp prompt resolves after ordered turn updates", async () => {
|
|||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("acp action resolves without prompt lifecycle events", async () => {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(request) {
|
||||||
|
if (new URL(request.url).pathname !== "/api/event") return new Response(null, { status: 404 })
|
||||||
|
return new Response(
|
||||||
|
new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "server.connected", data: {} })}\n\n`))
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await streamTurn({
|
||||||
|
client: OpenCode.make({ baseUrl: server.url.toString() }),
|
||||||
|
connection: {
|
||||||
|
sessionUpdate: async () => {},
|
||||||
|
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||||
|
},
|
||||||
|
sessionID: "ses_test",
|
||||||
|
cwd: "/workspace",
|
||||||
|
start: { type: "input", id: "msg_action" },
|
||||||
|
writeTextFile: false,
|
||||||
|
action: true,
|
||||||
|
control: { cancelled: false, admission: new AbortController() },
|
||||||
|
submit: async () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response).toMatchObject({ stopReason: "end_turn" })
|
||||||
|
} finally {
|
||||||
|
await server.stop(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ export const planAgent = {
|
|||||||
export const reviewCommand = {
|
export const reviewCommand = {
|
||||||
name: "review",
|
name: "review",
|
||||||
description: "Review changes",
|
description: "Review changes",
|
||||||
template: "",
|
|
||||||
} satisfies CommandInfo
|
} satisfies CommandInfo
|
||||||
|
|
||||||
export const verifySkill = {
|
export const verifySkill = {
|
||||||
|
|||||||
@@ -60,6 +60,44 @@ test("local channel stores service config with the local service filename", asyn
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("service config manages environment variables", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-env-"))
|
||||||
|
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
|
||||||
|
try {
|
||||||
|
await Effect.runPromise(
|
||||||
|
ServiceConfig.set("env", "OPENCODE_SERVICE_ENV_TEST", "configured").pipe(
|
||||||
|
Effect.provide(layer),
|
||||||
|
Effect.provide(NodeFileSystem.layer),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
await Effect.runPromise(
|
||||||
|
ServiceConfig.get("env", "OPENCODE_SERVICE_ENV_TEST").pipe(
|
||||||
|
Effect.provide(layer),
|
||||||
|
Effect.provide(NodeFileSystem.layer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe("configured")
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await Effect.runPromise(
|
||||||
|
ServiceConfig.options().pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer)),
|
||||||
|
)
|
||||||
|
).env,
|
||||||
|
).toEqual({ OPENCODE_SERVICE_ENV_TEST: "configured" })
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
ServiceConfig.unset("env", "OPENCODE_SERVICE_ENV_TEST").pipe(
|
||||||
|
Effect.provide(layer),
|
||||||
|
Effect.provide(NodeFileSystem.layer),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({})
|
||||||
|
} finally {
|
||||||
|
await fs.rm(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("service filenames share release channels and identify preview channels", () => {
|
test("service filenames share release channels and identify preview channels", () => {
|
||||||
expect(ServiceConfig.filename("latest")).toBe("service.json")
|
expect(ServiceConfig.filename("latest")).toBe("service.json")
|
||||||
expect(ServiceConfig.filename("dev")).toBe("service.json")
|
expect(ServiceConfig.filename("dev")).toBe("service.json")
|
||||||
|
|||||||
@@ -41,13 +41,8 @@ import type { Config } from "@opencode-ai/schema/config"
|
|||||||
export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number }
|
export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number }
|
||||||
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
|
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
|
||||||
|
|
||||||
export type Endpoint0_1Input = { readonly instanceID: string }
|
|
||||||
export type Endpoint0_1Output = { readonly accepted: boolean }
|
|
||||||
export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E>
|
|
||||||
|
|
||||||
export interface HealthApi<E = never> {
|
export interface HealthApi<E = never> {
|
||||||
readonly get: HealthGetOperation<E>
|
readonly get: HealthGetOperation<E>
|
||||||
readonly stop: HealthStopOperation<E>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint1_0Output = { readonly urls: ReadonlyArray<string> }
|
export type Endpoint1_0Output = { readonly urls: ReadonlyArray<string> }
|
||||||
@@ -189,18 +184,14 @@ export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Ef
|
|||||||
|
|
||||||
export type Endpoint5_13Input = {
|
export type Endpoint5_13Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly id?: SessionMessage.ID | undefined
|
|
||||||
readonly command: string
|
readonly command: string
|
||||||
readonly arguments?: string | undefined
|
readonly text: string
|
||||||
readonly agent?: Agent.ID | undefined
|
|
||||||
readonly model?: Model.Ref | undefined
|
|
||||||
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
|
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
|
||||||
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
|
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
|
||||||
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
|
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
|
||||||
readonly delivery?: SessionInbox.Delivery | undefined
|
readonly delivery?: SessionInbox.Delivery | undefined
|
||||||
readonly resume?: boolean | undefined
|
|
||||||
}
|
}
|
||||||
export type Endpoint5_13Output = SessionInbox.User
|
export type Endpoint5_13Output = void
|
||||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||||
|
|
||||||
export type Endpoint5_14Input = {
|
export type Endpoint5_14Input = {
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { HttpApiClient } from "effect/unstable/httpapi"
|
|||||||
import { ClientApi } from "../../contract"
|
import { ClientApi } from "../../contract"
|
||||||
import type {
|
import type {
|
||||||
Endpoint0_0Output,
|
Endpoint0_0Output,
|
||||||
Endpoint0_1Input,
|
|
||||||
Endpoint0_1Output,
|
|
||||||
Endpoint1_0Output,
|
Endpoint1_0Output,
|
||||||
Endpoint2_0Input,
|
Endpoint2_0Input,
|
||||||
Endpoint2_0Output,
|
Endpoint2_0Output,
|
||||||
@@ -248,12 +246,7 @@ const preserveStream =
|
|||||||
const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
|
const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
|
||||||
preserveEffect<Endpoint0_0Output>()(raw["health.get"]({}).pipe(Effect.mapError(mapClientError)))
|
preserveEffect<Endpoint0_0Output>()(raw["health.get"]({}).pipe(Effect.mapError(mapClientError)))
|
||||||
|
|
||||||
const Endpoint0_1 = (raw: RawClient["server.health"]) => (input: Endpoint0_1Input) =>
|
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) })
|
||||||
preserveEffect<Endpoint0_1Output>()(
|
|
||||||
raw["health.stop"]({ payload: { instanceID: input["instanceID"] } }).pipe(Effect.mapError(mapClientError)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) })
|
|
||||||
|
|
||||||
const Endpoint1_0 = (raw: RawClient["server.server"]) => () =>
|
const Endpoint1_0 = (raw: RawClient["server.server"]) => () =>
|
||||||
preserveEffect<Endpoint1_0Output>()(raw["server.get"]({}).pipe(Effect.mapError(mapClientError)))
|
preserveEffect<Endpoint1_0Output>()(raw["server.get"]({}).pipe(Effect.mapError(mapClientError)))
|
||||||
@@ -422,21 +415,14 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
|
|||||||
raw["session.command"]({
|
raw["session.command"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: {
|
payload: {
|
||||||
id: input["id"],
|
|
||||||
command: input["command"],
|
command: input["command"],
|
||||||
arguments: input["arguments"],
|
text: input["text"],
|
||||||
agent: input["agent"],
|
|
||||||
model: input["model"],
|
|
||||||
files: input["files"],
|
files: input["files"],
|
||||||
agents: input["agents"],
|
agents: input["agents"],
|
||||||
skills: input["skills"],
|
skills: input["skills"],
|
||||||
delivery: input["delivery"],
|
delivery: input["delivery"],
|
||||||
resume: input["resume"],
|
|
||||||
},
|
},
|
||||||
}).pipe(
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
Effect.map((value) => value.data),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
|||||||
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
||||||
return yield* Effect.try({
|
return yield* Effect.try({
|
||||||
try: () => {
|
try: () => {
|
||||||
return spawnServiceContender(command, args)
|
return spawnServiceContender(command, args, options.env)
|
||||||
},
|
},
|
||||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||||
})
|
})
|
||||||
@@ -87,7 +87,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
|||||||
}
|
}
|
||||||
if (timeouts.count >= 3) {
|
if (timeouts.count >= 3) {
|
||||||
yield* announce("missing")
|
yield* announce("missing")
|
||||||
yield* evict(info, options, timing)
|
yield* terminate(info, options, timing)
|
||||||
timeouts = undefined
|
timeouts = undefined
|
||||||
lastSpawn = Date.now() - spawnDelay
|
lastSpawn = Date.now() - spawnDelay
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
|||||||
return yield* Effect.fail(new Error("Background service failed to start"))
|
return yield* Effect.fail(new Error("Background service failed to start"))
|
||||||
if (compatible) return Option.none<LocalService>()
|
if (compatible) return Option.none<LocalService>()
|
||||||
yield* announce("version-mismatch", service.version)
|
yield* announce("version-mismatch", service.version)
|
||||||
yield* kill(service, options, timing).pipe(Effect.ignore)
|
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
|
||||||
lastSpawn = 0
|
lastSpawn = 0
|
||||||
return Option.none<LocalService>()
|
return Option.none<LocalService>()
|
||||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||||
@@ -133,8 +133,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
|||||||
|
|
||||||
/** Stop the registered local service. */
|
/** Stop the registered local service. */
|
||||||
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
|
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
|
||||||
const existing = yield* find(options)
|
const info = yield* read(options.file)
|
||||||
if (existing !== undefined) yield* kill(existing, options, defaultEnsureTiming)
|
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
|
||||||
})
|
})
|
||||||
|
|
||||||
function fallback() {
|
function fallback() {
|
||||||
@@ -243,12 +243,6 @@ const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = fal
|
|||||||
return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
|
return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
|
||||||
// able to see (and replace or stop) a server from a different version.
|
|
||||||
const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
|
|
||||||
return (yield* registered(options.file, true)).service
|
|
||||||
})
|
|
||||||
|
|
||||||
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
|
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
|
||||||
// discovery window.
|
// discovery window.
|
||||||
const poll = (timing: EnsureTiming) =>
|
const poll = (timing: EnsureTiming) =>
|
||||||
@@ -269,59 +263,21 @@ function same(left: Info, right: Info) {
|
|||||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||||
}
|
}
|
||||||
|
|
||||||
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||||
const current = yield* read(options.file)
|
const current = yield* read(options.file)
|
||||||
if (current === undefined || !same(current, info)) return
|
if (current === undefined || !same(current, info)) return
|
||||||
yield* signal(info.pid, "SIGTERM")
|
yield* signal(info.pid, "SIGTERM")
|
||||||
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
|
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
|
||||||
if (Option.isSome(done)) return
|
if (Option.isNone(done)) {
|
||||||
|
const latest = yield* read(options.file)
|
||||||
|
if (latest === undefined || !same(latest, info)) return
|
||||||
|
yield* signal(info.pid, "SIGKILL")
|
||||||
|
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
|
||||||
|
}
|
||||||
const latest = yield* read(options.file)
|
const latest = yield* read(options.file)
|
||||||
if (latest === undefined || !same(latest, info)) return
|
if (latest === undefined || !same(latest, info)) return
|
||||||
yield* signal(info.pid, "SIGKILL")
|
const fs = yield* FileSystem.FileSystem
|
||||||
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
|
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
|
||||||
})
|
|
||||||
|
|
||||||
const kill = Effect.fnUntraced(function* (
|
|
||||||
service: LocalService,
|
|
||||||
options: { readonly file?: string },
|
|
||||||
timing: EnsureTiming,
|
|
||||||
) {
|
|
||||||
const requested = yield* requestStop(service, timing.requestTimeout)
|
|
||||||
if (requested === "rejected") return
|
|
||||||
if (requested === "unsupported") {
|
|
||||||
// A stale registration may point at a reused PID. Authenticate again
|
|
||||||
// immediately before the legacy signal fallback.
|
|
||||||
const current = yield* find(options)
|
|
||||||
if (current === undefined || !same(current.info, service.info)) return
|
|
||||||
yield* signal(service.info.pid, "SIGTERM")
|
|
||||||
}
|
|
||||||
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
|
|
||||||
if (Option.isSome(done)) return
|
|
||||||
|
|
||||||
const latest = yield* find(options)
|
|
||||||
if (latest === undefined || !same(latest.info, service.info)) return
|
|
||||||
yield* signal(service.info.pid, "SIGKILL")
|
|
||||||
yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)))
|
|
||||||
})
|
|
||||||
|
|
||||||
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
|
|
||||||
|
|
||||||
const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
|
|
||||||
if (service.info.id === undefined || service.legacy) return "unsupported" as const
|
|
||||||
const response = yield* Effect.tryPromise(() =>
|
|
||||||
fetch(new URL("/api/service/stop", service.info.url), {
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...headers(service.endpoint), "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ instanceID: service.info.id }),
|
|
||||||
signal: AbortSignal.timeout(timeout),
|
|
||||||
}),
|
|
||||||
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
|
||||||
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
|
|
||||||
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
|
||||||
const decoded = decodeStopResponse(body)
|
|
||||||
if (!response.ok || Option.isNone(decoded) || !decoded.value.accepted) return "rejected" as const
|
|
||||||
return "accepted" as const
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Effect-based local service lifecycle operations. */
|
/** Effect-based local service lifecycle operations. */
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
HealthGetOutput,
|
HealthGetOutput,
|
||||||
HealthStopInput,
|
|
||||||
HealthStopOutput,
|
|
||||||
ServerGetOutput,
|
ServerGetOutput,
|
||||||
LocationGetInput,
|
LocationGetInput,
|
||||||
LocationGetOutput,
|
LocationGetOutput,
|
||||||
@@ -367,18 +365,6 @@ export function make(options: ClientOptions) {
|
|||||||
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||||
requestOptions,
|
requestOptions,
|
||||||
),
|
),
|
||||||
stop: (input: HealthStopInput, requestOptions?: RequestOptions) =>
|
|
||||||
request<HealthStopOutput>(
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
path: `/api/service/stop`,
|
|
||||||
body: { instanceID: input["instanceID"] },
|
|
||||||
successStatus: 200,
|
|
||||||
declaredStatuses: [401, 400],
|
|
||||||
empty: false,
|
|
||||||
},
|
|
||||||
requestOptions,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
get: (requestOptions?: RequestOptions) =>
|
get: (requestOptions?: RequestOptions) =>
|
||||||
@@ -621,28 +607,24 @@ export function make(options: ClientOptions) {
|
|||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
command: (input: SessionCommandInput, requestOptions?: RequestOptions) =>
|
command: (input: SessionCommandInput, requestOptions?: RequestOptions) =>
|
||||||
request<{ readonly data: SessionCommandOutput }>(
|
request<SessionCommandOutput>(
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/command`,
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/command`,
|
||||||
body: {
|
body: {
|
||||||
id: input["id"],
|
|
||||||
command: input["command"],
|
command: input["command"],
|
||||||
arguments: input["arguments"],
|
text: input["text"],
|
||||||
agent: input["agent"],
|
|
||||||
model: input["model"],
|
|
||||||
files: input["files"],
|
files: input["files"],
|
||||||
agents: input["agents"],
|
agents: input["agents"],
|
||||||
skills: input["skills"],
|
skills: input["skills"],
|
||||||
delivery: input["delivery"],
|
delivery: input["delivery"],
|
||||||
resume: input["resume"],
|
|
||||||
},
|
},
|
||||||
successStatus: 200,
|
successStatus: 204,
|
||||||
declaredStatuses: [409, 400, 404, 500, 401],
|
declaredStatuses: [404, 500, 400, 401],
|
||||||
empty: false,
|
empty: true,
|
||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
),
|
||||||
skill: (input: SessionSkillInput, requestOptions?: RequestOptions) =>
|
skill: (input: SessionSkillInput, requestOptions?: RequestOptions) =>
|
||||||
request<SessionSkillOutput>(
|
request<SessionSkillOutput>(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ export type JsonValue = null | boolean | number | string | Array<JsonValue> | {
|
|||||||
|
|
||||||
export type ServiceHealth = { healthy: true; version: string; pid: number }
|
export type ServiceHealth = { healthy: true; version: string; pid: number }
|
||||||
|
|
||||||
export type ServiceStopResponse = { accepted: boolean }
|
|
||||||
|
|
||||||
export type ModelRef = { id: string; providerID: string; variant?: string }
|
export type ModelRef = { id: string; providerID: string; variant?: string }
|
||||||
|
|
||||||
export type ProviderSettings = { [x: string]: any }
|
export type ProviderSettings = { [x: string]: any }
|
||||||
@@ -12,7 +10,11 @@ export type AgentColor = string
|
|||||||
|
|
||||||
export type PermissionEffect = "allow" | "deny" | "ask"
|
export type PermissionEffect = "allow" | "deny" | "ask"
|
||||||
|
|
||||||
export type PluginInfo = { id: string }
|
export type PluginSource =
|
||||||
|
| { type: "builtin" }
|
||||||
|
| { type: "package"; package: string }
|
||||||
|
| { type: "local"; path: string }
|
||||||
|
| { type: "sdk" }
|
||||||
|
|
||||||
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
|
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
|
||||||
|
|
||||||
@@ -116,6 +118,8 @@ export type PermissionSavedInfo = { id: string; projectID: string; action: strin
|
|||||||
|
|
||||||
export type FileSystemEntry = { path: string; type: "file" | "directory" }
|
export type FileSystemEntry = { path: string; type: "file" | "directory" }
|
||||||
|
|
||||||
|
export type CommandInfo = { name: string; description?: string }
|
||||||
|
|
||||||
export type SkillInfo = {
|
export type SkillInfo = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -181,15 +185,6 @@ export type WebSearchProvider = { id: string; name: string }
|
|||||||
|
|
||||||
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
|
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
|
||||||
|
|
||||||
export type CommandInfo = {
|
|
||||||
name: string
|
|
||||||
template: string
|
|
||||||
description?: string
|
|
||||||
agent?: string
|
|
||||||
model?: ModelRef
|
|
||||||
subtask?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProviderRequest = {
|
export type ProviderRequest = {
|
||||||
settings: ProviderSettings
|
settings: ProviderSettings
|
||||||
headers: { [x: string]: string }
|
headers: { [x: string]: string }
|
||||||
@@ -198,6 +193,10 @@ export type ProviderRequest = {
|
|||||||
|
|
||||||
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||||
|
|
||||||
|
export type PluginInfo =
|
||||||
|
| { id: string; source: PluginSource; status: "active"; tui: boolean }
|
||||||
|
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
|
||||||
|
|
||||||
export type TokenUsageInfo = {
|
export type TokenUsageInfo = {
|
||||||
input: number
|
input: number
|
||||||
output: number
|
output: number
|
||||||
@@ -2168,13 +2167,13 @@ export type CommandNotFoundError = {
|
|||||||
export const isCommandNotFoundError = (value: unknown): value is CommandNotFoundError =>
|
export const isCommandNotFoundError = (value: unknown): value is CommandNotFoundError =>
|
||||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandNotFoundError"
|
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandNotFoundError"
|
||||||
|
|
||||||
export type CommandEvaluationError = {
|
export type CommandExecutionError = {
|
||||||
readonly _tag: "CommandEvaluationError"
|
readonly _tag: "CommandExecutionError"
|
||||||
readonly command: string
|
readonly command: string
|
||||||
readonly message: string
|
readonly message: string
|
||||||
}
|
}
|
||||||
export const isCommandEvaluationError = (value: unknown): value is CommandEvaluationError =>
|
export const isCommandExecutionError = (value: unknown): value is CommandExecutionError =>
|
||||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandEvaluationError"
|
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandExecutionError"
|
||||||
|
|
||||||
export type SkillNotFoundError = {
|
export type SkillNotFoundError = {
|
||||||
readonly _tag: "SkillNotFoundError"
|
readonly _tag: "SkillNotFoundError"
|
||||||
@@ -2273,10 +2272,6 @@ export const isWorktreeError = (value: unknown): value is WorktreeError =>
|
|||||||
|
|
||||||
export type HealthGetOutput = ServiceHealth
|
export type HealthGetOutput = ServiceHealth
|
||||||
|
|
||||||
export type HealthStopInput = { readonly instanceID: { readonly instanceID: string }["instanceID"] }
|
|
||||||
|
|
||||||
export type HealthStopOutput = ServiceStopResponse
|
|
||||||
|
|
||||||
export type ServerGetOutput = { urls: Array<string> }
|
export type ServerGetOutput = { urls: Array<string> }
|
||||||
|
|
||||||
export type LocationGetInput = {
|
export type LocationGetInput = {
|
||||||
@@ -3521,35 +3516,9 @@ export type SessionPromptOutput = { data: SessionInboxUser }["data"]
|
|||||||
|
|
||||||
export type SessionCommandInput = {
|
export type SessionCommandInput = {
|
||||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||||
readonly id?: {
|
|
||||||
readonly id?: string | null
|
|
||||||
readonly command: string
|
|
||||||
readonly arguments?: string | null
|
|
||||||
readonly agent?: string | null
|
|
||||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
|
||||||
readonly files?: ReadonlyArray<{
|
|
||||||
readonly uri: string
|
|
||||||
readonly name?: string
|
|
||||||
readonly description?: string
|
|
||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
|
||||||
}>
|
|
||||||
readonly agents?: ReadonlyArray<{
|
|
||||||
readonly name: string
|
|
||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
|
||||||
}>
|
|
||||||
readonly skills?: ReadonlyArray<{
|
|
||||||
readonly id: string
|
|
||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
|
||||||
}>
|
|
||||||
readonly delivery?: ("steer" | "queue") | null
|
|
||||||
readonly resume?: boolean | null
|
|
||||||
}["id"]
|
|
||||||
readonly command: {
|
readonly command: {
|
||||||
readonly id?: string | null
|
|
||||||
readonly command: string
|
readonly command: string
|
||||||
readonly arguments?: string | null
|
readonly text: string
|
||||||
readonly agent?: string | null
|
|
||||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
@@ -3565,14 +3534,10 @@ export type SessionCommandInput = {
|
|||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
}>
|
}>
|
||||||
readonly delivery?: ("steer" | "queue") | null
|
readonly delivery?: ("steer" | "queue") | null
|
||||||
readonly resume?: boolean | null
|
|
||||||
}["command"]
|
}["command"]
|
||||||
readonly arguments?: {
|
readonly text: {
|
||||||
readonly id?: string | null
|
|
||||||
readonly command: string
|
readonly command: string
|
||||||
readonly arguments?: string | null
|
readonly text: string
|
||||||
readonly agent?: string | null
|
|
||||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
@@ -3588,60 +3553,10 @@ export type SessionCommandInput = {
|
|||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
}>
|
}>
|
||||||
readonly delivery?: ("steer" | "queue") | null
|
readonly delivery?: ("steer" | "queue") | null
|
||||||
readonly resume?: boolean | null
|
}["text"]
|
||||||
}["arguments"]
|
|
||||||
readonly agent?: {
|
|
||||||
readonly id?: string | null
|
|
||||||
readonly command: string
|
|
||||||
readonly arguments?: string | null
|
|
||||||
readonly agent?: string | null
|
|
||||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
|
||||||
readonly files?: ReadonlyArray<{
|
|
||||||
readonly uri: string
|
|
||||||
readonly name?: string
|
|
||||||
readonly description?: string
|
|
||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
|
||||||
}>
|
|
||||||
readonly agents?: ReadonlyArray<{
|
|
||||||
readonly name: string
|
|
||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
|
||||||
}>
|
|
||||||
readonly skills?: ReadonlyArray<{
|
|
||||||
readonly id: string
|
|
||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
|
||||||
}>
|
|
||||||
readonly delivery?: ("steer" | "queue") | null
|
|
||||||
readonly resume?: boolean | null
|
|
||||||
}["agent"]
|
|
||||||
readonly model?: {
|
|
||||||
readonly id?: string | null
|
|
||||||
readonly command: string
|
|
||||||
readonly arguments?: string | null
|
|
||||||
readonly agent?: string | null
|
|
||||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
|
||||||
readonly files?: ReadonlyArray<{
|
|
||||||
readonly uri: string
|
|
||||||
readonly name?: string
|
|
||||||
readonly description?: string
|
|
||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
|
||||||
}>
|
|
||||||
readonly agents?: ReadonlyArray<{
|
|
||||||
readonly name: string
|
|
||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
|
||||||
}>
|
|
||||||
readonly skills?: ReadonlyArray<{
|
|
||||||
readonly id: string
|
|
||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
|
||||||
}>
|
|
||||||
readonly delivery?: ("steer" | "queue") | null
|
|
||||||
readonly resume?: boolean | null
|
|
||||||
}["model"]
|
|
||||||
readonly files?: {
|
readonly files?: {
|
||||||
readonly id?: string | null
|
|
||||||
readonly command: string
|
readonly command: string
|
||||||
readonly arguments?: string | null
|
readonly text: string
|
||||||
readonly agent?: string | null
|
|
||||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
@@ -3657,14 +3572,10 @@ export type SessionCommandInput = {
|
|||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
}>
|
}>
|
||||||
readonly delivery?: ("steer" | "queue") | null
|
readonly delivery?: ("steer" | "queue") | null
|
||||||
readonly resume?: boolean | null
|
|
||||||
}["files"]
|
}["files"]
|
||||||
readonly agents?: {
|
readonly agents?: {
|
||||||
readonly id?: string | null
|
|
||||||
readonly command: string
|
readonly command: string
|
||||||
readonly arguments?: string | null
|
readonly text: string
|
||||||
readonly agent?: string | null
|
|
||||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
@@ -3680,14 +3591,10 @@ export type SessionCommandInput = {
|
|||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
}>
|
}>
|
||||||
readonly delivery?: ("steer" | "queue") | null
|
readonly delivery?: ("steer" | "queue") | null
|
||||||
readonly resume?: boolean | null
|
|
||||||
}["agents"]
|
}["agents"]
|
||||||
readonly skills?: {
|
readonly skills?: {
|
||||||
readonly id?: string | null
|
|
||||||
readonly command: string
|
readonly command: string
|
||||||
readonly arguments?: string | null
|
readonly text: string
|
||||||
readonly agent?: string | null
|
|
||||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
@@ -3703,14 +3610,10 @@ export type SessionCommandInput = {
|
|||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
}>
|
}>
|
||||||
readonly delivery?: ("steer" | "queue") | null
|
readonly delivery?: ("steer" | "queue") | null
|
||||||
readonly resume?: boolean | null
|
|
||||||
}["skills"]
|
}["skills"]
|
||||||
readonly delivery?: {
|
readonly delivery?: {
|
||||||
readonly id?: string | null
|
|
||||||
readonly command: string
|
readonly command: string
|
||||||
readonly arguments?: string | null
|
readonly text: string
|
||||||
readonly agent?: string | null
|
|
||||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
@@ -3726,34 +3629,10 @@ export type SessionCommandInput = {
|
|||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
}>
|
}>
|
||||||
readonly delivery?: ("steer" | "queue") | null
|
readonly delivery?: ("steer" | "queue") | null
|
||||||
readonly resume?: boolean | null
|
|
||||||
}["delivery"]
|
}["delivery"]
|
||||||
readonly resume?: {
|
|
||||||
readonly id?: string | null
|
|
||||||
readonly command: string
|
|
||||||
readonly arguments?: string | null
|
|
||||||
readonly agent?: string | null
|
|
||||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
|
||||||
readonly files?: ReadonlyArray<{
|
|
||||||
readonly uri: string
|
|
||||||
readonly name?: string
|
|
||||||
readonly description?: string
|
|
||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
|
||||||
}>
|
|
||||||
readonly agents?: ReadonlyArray<{
|
|
||||||
readonly name: string
|
|
||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
|
||||||
}>
|
|
||||||
readonly skills?: ReadonlyArray<{
|
|
||||||
readonly id: string
|
|
||||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
|
||||||
}>
|
|
||||||
readonly delivery?: ("steer" | "queue") | null
|
|
||||||
readonly resume?: boolean | null
|
|
||||||
}["resume"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionCommandOutput = { data: SessionInboxUser }["data"]
|
export type SessionCommandOutput = void
|
||||||
|
|
||||||
export type SessionSkillInput = {
|
export type SessionSkillInput = {
|
||||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readFile } from "node:fs/promises"
|
import { readFile, rm } from "node:fs/promises"
|
||||||
import { homedir } from "node:os"
|
import { homedir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "../service-contender.js"
|
} from "../service-contender.js"
|
||||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||||
import { matchesVersion } from "../service-version.js"
|
import { matchesVersion } from "../service-version.js"
|
||||||
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
import type { ServiceHealth } from "./generated/types.js"
|
||||||
|
|
||||||
export * from "../service.js"
|
export * from "../service.js"
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
|||||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||||
if (command === undefined) throw new Error("Missing service command")
|
if (command === undefined) throw new Error("Missing service command")
|
||||||
try {
|
try {
|
||||||
return spawnServiceContender(command, args)
|
return spawnServiceContender(command, args, options.env)
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
throw new Error("Failed to start server", { cause })
|
throw new Error("Failed to start server", { cause })
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
|||||||
}
|
}
|
||||||
if (timeouts.count >= 3) {
|
if (timeouts.count >= 3) {
|
||||||
announce("missing")
|
announce("missing")
|
||||||
await evict(registration.info, options, timing)
|
await terminate(registration.info, options, timing)
|
||||||
timeouts = undefined
|
timeouts = undefined
|
||||||
lastSpawn = Date.now() - spawnDelay
|
lastSpawn = Date.now() - spawnDelay
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
|||||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||||
if (!compatible) {
|
if (!compatible) {
|
||||||
announce("version-mismatch", service.version)
|
announce("version-mismatch", service.version)
|
||||||
await kill(service, options, timing).catch(() => undefined)
|
await terminate(service.info, options, timing).catch(() => undefined)
|
||||||
lastSpawn = 0
|
lastSpawn = 0
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -110,8 +110,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
|||||||
|
|
||||||
/** Stop the registered local service. */
|
/** Stop the registered local service. */
|
||||||
export async function stop(options: StopOptions = {}) {
|
export async function stop(options: StopOptions = {}) {
|
||||||
const existing = await find(options)
|
const info = await read(options.file)
|
||||||
if (existing !== undefined) await kill(existing, options, defaultEnsureTiming)
|
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallback() {
|
function fallback() {
|
||||||
@@ -199,10 +199,6 @@ async function registered(file?: string, allowLegacy = false, timeout?: number)
|
|||||||
return { info, ...(await probeResult(info, allowLegacy, timeout)) }
|
return { info, ...(await probeResult(info, allowLegacy, timeout)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function find(options: { readonly file?: string }) {
|
|
||||||
return (await registered(options.file, true)).service
|
|
||||||
}
|
|
||||||
|
|
||||||
function signal(pid: number, name: NodeJS.Signals) {
|
function signal(pid: number, name: NodeJS.Signals) {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, name)
|
process.kill(pid, name)
|
||||||
@@ -230,47 +226,19 @@ function same(left: Info, right: Info) {
|
|||||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||||
}
|
}
|
||||||
|
|
||||||
async function evict(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
async function terminate(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||||
const current = await read(options.file)
|
const current = await read(options.file)
|
||||||
if (current === undefined || !same(current, info)) return
|
if (current === undefined || !same(current, info)) return
|
||||||
signal(info.pid, "SIGTERM")
|
signal(info.pid, "SIGTERM")
|
||||||
if (await waitUntilStopped(info.pid, timing)) return
|
if (!(await waitUntilStopped(info.pid, timing))) {
|
||||||
|
const latest = await read(options.file)
|
||||||
|
if (latest === undefined || !same(latest, info)) return
|
||||||
|
signal(info.pid, "SIGKILL")
|
||||||
|
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
|
||||||
|
}
|
||||||
const latest = await read(options.file)
|
const latest = await read(options.file)
|
||||||
if (latest === undefined || !same(latest, info)) return
|
if (latest === undefined || !same(latest, info)) return
|
||||||
signal(info.pid, "SIGKILL")
|
await rm(options.file ?? fallback(), { force: true })
|
||||||
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function kill(service: LocalService, options: { readonly file?: string }, timing: EnsureTiming) {
|
|
||||||
const requested = await requestStop(service, timing.requestTimeout)
|
|
||||||
if (requested === "rejected") return
|
|
||||||
if (requested === "unsupported") {
|
|
||||||
const current = await find(options)
|
|
||||||
if (current === undefined || !same(current.info, service.info)) return
|
|
||||||
signal(service.info.pid, "SIGTERM")
|
|
||||||
}
|
|
||||||
if (await waitUntilStopped(service.info.pid, timing)) return
|
|
||||||
|
|
||||||
const latest = await find(options)
|
|
||||||
if (latest === undefined || !same(latest.info, service.info)) return
|
|
||||||
signal(service.info.pid, "SIGKILL")
|
|
||||||
if (!(await waitUntilStopped(service.info.pid, timing)))
|
|
||||||
throw new Error(`Server process ${service.info.pid} is still running`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestStop(service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
|
|
||||||
if (service.info.id === undefined || service.legacy) return "unsupported" as const
|
|
||||||
const response = await fetch(new URL("/api/service/stop", service.info.url), {
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...headers(service.endpoint), "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ instanceID: service.info.id }),
|
|
||||||
signal: AbortSignal.timeout(timeout),
|
|
||||||
}).catch(() => undefined)
|
|
||||||
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
|
|
||||||
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined
|
|
||||||
if (!response.ok || body?.accepted !== true) return "rejected" as const
|
|
||||||
return "accepted" as const
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function delay(milliseconds: number) {
|
function delay(milliseconds: number) {
|
||||||
|
|||||||
@@ -10,8 +10,16 @@ export type ServiceContender = {
|
|||||||
|
|
||||||
const stderrLimit = 8 * 1024
|
const stderrLimit = 8 * 1024
|
||||||
|
|
||||||
export function spawnServiceContender(command: string, args: ReadonlyArray<string>): ServiceContender {
|
export function spawnServiceContender(
|
||||||
const child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] })
|
command: string,
|
||||||
|
args: ReadonlyArray<string>,
|
||||||
|
env?: Readonly<Record<string, string>>,
|
||||||
|
): ServiceContender {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
detached: true,
|
||||||
|
stdio: ["ignore", "ignore", "pipe"],
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
})
|
||||||
let error: Error | undefined
|
let error: Error | undefined
|
||||||
let closed = false
|
let closed = false
|
||||||
let stderr = Buffer.alloc(0)
|
let stderr = Buffer.alloc(0)
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export type EnsureReason = "missing" | "version-mismatch"
|
|||||||
export type EnsureOptions = DiscoverOptions & {
|
export type EnsureOptions = DiscoverOptions & {
|
||||||
/** Service command and arguments. Defaults to `opencode serve --service`. */
|
/** Service command and arguments. Defaults to `opencode serve --service`. */
|
||||||
readonly command?: ReadonlyArray<string>
|
readonly command?: ReadonlyArray<string>
|
||||||
|
/** Environment variables added to the inherited service process environment. */
|
||||||
|
readonly env?: Readonly<Record<string, string>>
|
||||||
/** Called once before spawning a new service process. */
|
/** Called once before spawning a new service process. */
|
||||||
readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void
|
readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ if (mode === "record-start") {
|
|||||||
await writeFile(registration + ".started", "")
|
await writeFile(registration + ".started", "")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
if (mode === "environment")
|
||||||
|
await writeFile(registration + ".environment", process.env.OPENCODE_SERVICE_ENV_TEST ?? "")
|
||||||
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
|
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
|
||||||
|
|
||||||
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
|
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
|
||||||
@@ -28,7 +30,7 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
|
|||||||
|
|
||||||
let requests = 0
|
let requests = 0
|
||||||
let version = "test"
|
let version = "test"
|
||||||
if (mode === "old" || mode === "reject-stop") version = "old"
|
if (mode === "old") version = "old"
|
||||||
if (mode === "incompatible") version = "1.9.0"
|
if (mode === "incompatible") version = "1.9.0"
|
||||||
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
|
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
@@ -36,17 +38,6 @@ const server = Bun.serve({
|
|||||||
port: 0,
|
port: 0,
|
||||||
async fetch(request) {
|
async fetch(request) {
|
||||||
const pathname = new URL(request.url).pathname
|
const pathname = new URL(request.url).pathname
|
||||||
if (pathname === "/api/service/stop" && mode === "reject-stop") {
|
|
||||||
await appendFile(registration + ".stop-attempts", process.pid + "\n")
|
|
||||||
return Response.json({ accepted: false })
|
|
||||||
}
|
|
||||||
if (pathname === "/api/service/stop" && mode === "graceful") {
|
|
||||||
const body = await request.json()
|
|
||||||
if (typeof body !== "object" || body === null || body.instanceID !== id) return Response.json({ accepted: false })
|
|
||||||
await writeFile(registration + ".stop", JSON.stringify(body))
|
|
||||||
setTimeout(shutdown, 25)
|
|
||||||
return Response.json({ accepted: true })
|
|
||||||
}
|
|
||||||
if (pathname !== "/api/health") return new Response(null, { status: 404 })
|
if (pathname !== "/api/health") return new Response(null, { status: 404 })
|
||||||
requests += 1
|
requests += 1
|
||||||
if (mode === "starting") await writeFile(registration + ".health-request", "")
|
if (mode === "starting") await writeFile(registration + ".health-request", "")
|
||||||
@@ -63,7 +54,7 @@ const server = Bun.serve({
|
|||||||
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
|
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
|
||||||
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
|
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
|
||||||
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
|
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
|
||||||
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
|
if (mode === "starting" || mode === "graceful")
|
||||||
return Response.json({ healthy: true, version, pid: process.pid })
|
return Response.json({ healthy: true, version, pid: process.pid })
|
||||||
return Response.json({ healthy: true, version, pid: process.pid })
|
return Response.json({ healthy: true, version, pid: process.pid })
|
||||||
},
|
},
|
||||||
@@ -81,9 +72,10 @@ await writeFile(
|
|||||||
)
|
)
|
||||||
await rename(registration + ".tmp", registration)
|
await rename(registration + ".tmp", registration)
|
||||||
|
|
||||||
function shutdown() {
|
async function shutdown(signal?: NodeJS.Signals) {
|
||||||
|
if (signal !== undefined) await writeFile(registration + ".signal", signal)
|
||||||
server.stop(true)
|
server.stop(true)
|
||||||
process.exit()
|
process.exit()
|
||||||
}
|
}
|
||||||
process.on("SIGTERM", shutdown)
|
process.on("SIGTERM", () => void shutdown("SIGTERM"))
|
||||||
process.on("SIGINT", shutdown)
|
process.on("SIGINT", () => void shutdown("SIGINT"))
|
||||||
|
|||||||
@@ -59,6 +59,26 @@ test("ensures a missing service with native promises", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("adds configured environment variables with native promises", async () => {
|
||||||
|
const directory = await temp()
|
||||||
|
const registration = join(directory, "service.json")
|
||||||
|
const endpoint = await ensure({
|
||||||
|
file: registration,
|
||||||
|
version: "test",
|
||||||
|
command: [process.execPath, fixture, registration, "environment"],
|
||||||
|
env: { OPENCODE_SERVICE_ENV_TEST: "configured" },
|
||||||
|
})
|
||||||
|
const info = await Bun.file(registration).json()
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(endpoint.url).toBe(info.url)
|
||||||
|
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
|
||||||
|
} finally {
|
||||||
|
process.kill(info.pid, "SIGTERM")
|
||||||
|
await waitForExit(info.pid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("waits for a live contender when another native contender fails", async () => {
|
test("waits for a live contender when another native contender fails", async () => {
|
||||||
const directory = await temp()
|
const directory = await temp()
|
||||||
const registration = join(directory, "service.json")
|
const registration = join(directory, "service.json")
|
||||||
@@ -126,13 +146,13 @@ test("evicts an unresponsive registered service before starting its replacement"
|
|||||||
await waitForExit(replacement.pid)
|
await waitForExit(replacement.pid)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("requests graceful stop of the exact service instance", async () => {
|
test("signals the registered service process", async () => {
|
||||||
const registration = await setup("graceful")
|
const registration = await setup("graceful")
|
||||||
const info = await Bun.file(registration).json()
|
|
||||||
|
|
||||||
await Service.stop({ file: registration })
|
await Service.stop({ file: registration })
|
||||||
|
|
||||||
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
|
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||||
|
expect(await Bun.file(registration).exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function setup(mode: string) {
|
async function setup(mode: string) {
|
||||||
|
|||||||
@@ -191,22 +191,6 @@ test("integration connections optionally submit a form answer", async () => {
|
|||||||
expect(await requests[3].json()).toEqual({ methodID: "device" })
|
expect(await requests[3].json()).toEqual({ methodID: "device" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("health.stop sends exact replacement identity", async () => {
|
|
||||||
let request: Request | undefined
|
|
||||||
const client = OpenCode.make({
|
|
||||||
baseUrl: "http://localhost:3000",
|
|
||||||
fetch: async (input, init) => {
|
|
||||||
request = input instanceof Request ? input : new Request(input, init)
|
|
||||||
return Response.json({ accepted: true })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(await client.health.stop({ instanceID: "instance" })).toEqual({ accepted: true })
|
|
||||||
expect(request?.method).toBe("POST")
|
|
||||||
expect(request?.url).toBe("http://localhost:3000/api/service/stop")
|
|
||||||
expect(await request?.json()).toEqual({ instanceID: "instance" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("MCP resource catalog uses the public HTTP contract", async () => {
|
test("MCP resource catalog uses the public HTTP contract", async () => {
|
||||||
let request: Request | undefined
|
let request: Request | undefined
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
|
|||||||
@@ -68,6 +68,28 @@ test("reuses a compatible registered service", async () => {
|
|||||||
expect(existing.exitCode).toBe(null)
|
expect(existing.exitCode).toBe(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("adds configured environment variables when starting a service", async () => {
|
||||||
|
const directory = await temp()
|
||||||
|
const registration = join(directory, "service.json")
|
||||||
|
const endpoint = await run(
|
||||||
|
ensure({
|
||||||
|
file: registration,
|
||||||
|
version: "test",
|
||||||
|
command: [process.execPath, fixture, registration, "environment"],
|
||||||
|
env: { OPENCODE_SERVICE_ENV_TEST: "configured" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const info = await Bun.file(registration).json()
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(endpoint.url).toBe(info.url)
|
||||||
|
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
|
||||||
|
} finally {
|
||||||
|
process.kill(info.pid, "SIGTERM")
|
||||||
|
await waitForExit(info.pid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("replaces an incompatible registered service", async () => {
|
test("replaces an incompatible registered service", async () => {
|
||||||
const directory = await temp()
|
const directory = await temp()
|
||||||
const registration = join(directory, "service.json")
|
const registration = join(directory, "service.json")
|
||||||
@@ -143,40 +165,36 @@ test("evicts an unresponsive registered service before starting its replacement"
|
|||||||
await waitForExit(replacement.pid)
|
await waitForExit(replacement.pid)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("requests graceful stop of the exact service instance", async () => {
|
test("signals an unresponsive registered service process", async () => {
|
||||||
const directory = await temp()
|
const directory = await temp()
|
||||||
const registration = join(directory, "service.json")
|
const registration = join(directory, "service.json")
|
||||||
const process = spawn(registration, "graceful")
|
const process = spawn(registration, "hanging")
|
||||||
await waitForFile(registration)
|
await waitForFile(registration)
|
||||||
const info = await Bun.file(registration).json()
|
|
||||||
|
|
||||||
await run(Service.stop({ file: registration }))
|
await run(Service.stop({ file: registration }))
|
||||||
await process.exited
|
await process.exited
|
||||||
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
|
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||||
|
expect(await Bun.file(registration).exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not spawn contenders while an incompatible service rejects replacement", async () => {
|
test("signals an incompatible service before starting its replacement", async () => {
|
||||||
const directory = await temp()
|
const directory = await temp()
|
||||||
const registration = join(directory, "service.json")
|
const registration = join(directory, "service.json")
|
||||||
const contender = join(directory, "contender.json")
|
const existing = spawn(registration, "old")
|
||||||
const existing = spawn(registration, "reject-stop")
|
|
||||||
await waitForFile(registration)
|
await waitForFile(registration)
|
||||||
const controller = new AbortController()
|
const endpoint = await run(
|
||||||
const starting = Effect.runPromise(
|
|
||||||
ensure({
|
ensure({
|
||||||
file: registration,
|
file: registration,
|
||||||
version: "test",
|
version: "test",
|
||||||
command: [process.execPath, fixture, contender, "record-start"],
|
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
}),
|
||||||
{ signal: controller.signal },
|
|
||||||
)
|
)
|
||||||
|
const replacement = await Bun.file(registration).json()
|
||||||
|
|
||||||
await waitForLines(registration + ".stop-attempts", 2)
|
expect(await existing.exited).toBe(0)
|
||||||
controller.abort()
|
expect(endpoint.url).toBe(replacement.url)
|
||||||
await starting.catch(() => undefined)
|
process.kill(replacement.pid, "SIGTERM")
|
||||||
|
await waitForExit(replacement.pid)
|
||||||
expect(await Bun.file(contender + ".started").exists()).toBe(false)
|
|
||||||
expect(existing.exitCode).toBe(null)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("a legacy health response is still replaced", async () => {
|
test("a legacy health response is still replaced", async () => {
|
||||||
@@ -344,17 +362,6 @@ async function waitForFile(file: string) {
|
|||||||
throw new Error(`Timed out waiting for ${file}`)
|
throw new Error(`Timed out waiting for ${file}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForLines(file: string, count: number) {
|
|
||||||
for (let attempt = 0; attempt < 600; attempt++) {
|
|
||||||
const text = await Bun.file(file)
|
|
||||||
.text()
|
|
||||||
.catch(() => "")
|
|
||||||
if (text.trim().split("\n").length >= count) return
|
|
||||||
await Bun.sleep(5)
|
|
||||||
}
|
|
||||||
throw new Error(`Timed out waiting for ${count} lines in ${file}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function health(url: string) {
|
async function health(url: string) {
|
||||||
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
|
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ const layer = Layer.effect(
|
|||||||
get: Effect.fn("Agent.get")(function* (id) {
|
get: Effect.fn("Agent.get")(function* (id) {
|
||||||
return state.get().agents.get(id)
|
return state.get().agents.get(id)
|
||||||
}),
|
}),
|
||||||
resolve: Effect.fn("Agent.resolve")(function* (id) {
|
resolve: Effect.fnUntraced(function* (id) {
|
||||||
if (id !== undefined) return state.get().agents.get(ID.make(id))
|
if (id !== undefined) return state.get().agents.get(ID.make(id))
|
||||||
return selectedDefault()
|
return selectedDefault()
|
||||||
}),
|
}),
|
||||||
|
|||||||
+69
-241
@@ -1,28 +1,32 @@
|
|||||||
export * as Command from "./command.js"
|
export * as Command from "./command.js"
|
||||||
|
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
|
||||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
|
||||||
import { Command } from "@opencode-ai/schema/command"
|
import { Command } from "@opencode-ai/schema/command"
|
||||||
import { State } from "./state.js"
|
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||||
import { MCP } from "./mcp/index.js"
|
import type { Session } from "@opencode-ai/schema/session"
|
||||||
|
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||||
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Bus } from "./bus.js"
|
import { Bus } from "./bus.js"
|
||||||
import { AppProcess } from "@opencode-ai/util/process"
|
import { State } from "./state.js"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
|
||||||
import { Config } from "./config.js"
|
|
||||||
import { Location } from "./location.js"
|
|
||||||
import { ShellSelect } from "./shell/select.js"
|
|
||||||
import { Global } from "@opencode-ai/util/global"
|
|
||||||
|
|
||||||
export const Info = Command.Info
|
export const Info = Command.Info
|
||||||
export type Info = Command.Info
|
export type Info = Command.Info
|
||||||
export { Event } from "@opencode-ai/schema/command"
|
export { Event } from "@opencode-ai/schema/command"
|
||||||
|
|
||||||
export type Evaluation = {
|
export interface Invocation {
|
||||||
readonly text: string
|
readonly sessionID: Session.ID
|
||||||
|
readonly prompt: PromptInput.Prompt
|
||||||
|
readonly delivery: SessionInbox.Delivery
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Data = {
|
export interface Definition {
|
||||||
commands: Map<string, Types.DeepMutable<Info>>
|
readonly name: string
|
||||||
|
readonly description?: string
|
||||||
|
readonly execute: (input: Invocation) => Effect.Effect<void, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Draft = {
|
||||||
|
add: (definition: Definition) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Command.NotFoundError", {
|
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Command.NotFoundError", {
|
||||||
@@ -30,249 +34,73 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Command.
|
|||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class EvaluationError extends Schema.TaggedError<EvaluationError>()("Command.EvaluationError", {
|
export class ExecutionError extends Schema.TaggedError<ExecutionError>()("Command.ExecutionError", {
|
||||||
command: Schema.String,
|
command: Schema.String,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export type Draft = {
|
|
||||||
list: () => readonly Info[]
|
|
||||||
get: (name: string) => Info | undefined
|
|
||||||
update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void
|
|
||||||
remove: (name: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface extends State.Transformable<Draft> {
|
export interface Interface extends State.Transformable<Draft> {
|
||||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||||
readonly list: () => Effect.Effect<Info[]>
|
readonly list: () => Effect.Effect<Info[]>
|
||||||
readonly evaluate: (input: {
|
readonly execute: (input: {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly arguments?: string
|
readonly invocation: Invocation
|
||||||
}) => Effect.Effect<Evaluation, NotFoundError | EvaluationError>
|
}) => Effect.Effect<void, NotFoundError | ExecutionError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
|
||||||
|
|
||||||
export const layer = (options?: ShellSelect.Options) =>
|
export const layer = Layer.effect(
|
||||||
Layer.effect(
|
Service,
|
||||||
Service,
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
const bus = yield* Bus.Service
|
||||||
const mcp = yield* MCP.Service
|
const state = State.create<Map<string, Definition>, Draft>({
|
||||||
const bus = yield* Bus.Service
|
name: "command",
|
||||||
const processes = yield* AppProcess.Service
|
initial: () => new Map(),
|
||||||
const config = yield* Config.Service
|
draft: (draft) => ({
|
||||||
const location = yield* Location.Service
|
add: (definition) => draft.set(definition.name, definition),
|
||||||
const global = yield* Global.Service
|
}),
|
||||||
const state = State.create<Data, Draft>({
|
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
||||||
name: "command",
|
})
|
||||||
initial: () => ({ commands: new Map() }),
|
const info = (definition: Definition) =>
|
||||||
draft: (draft) => ({
|
Info.make({
|
||||||
list: () => Array.from(draft.commands.values()) as Info[],
|
name: definition.name,
|
||||||
get: (name) => draft.commands.get(name),
|
description: definition.description,
|
||||||
update: (name, update) => {
|
|
||||||
const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable<Info>)
|
|
||||||
if (!draft.commands.has(name)) draft.commands.set(name, current)
|
|
||||||
update(current)
|
|
||||||
current.name = name
|
|
||||||
},
|
|
||||||
remove: (name) => {
|
|
||||||
draft.commands.delete(name)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
|
||||||
})
|
|
||||||
const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined
|
|
||||||
const mcpCommands = Effect.fnUntraced(function* () {
|
|
||||||
return (yield* mcp.prompts()).map((prompt) =>
|
|
||||||
Info.make({
|
|
||||||
name: mcpCommandName(prompt.server, prompt.name),
|
|
||||||
template: "",
|
|
||||||
description: prompt.description,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
reload: state.reload,
|
reload: state.reload,
|
||||||
transform: state.transform,
|
transform: state.transform,
|
||||||
get: Effect.fn("Command.get")(function* (name) {
|
get: Effect.fn("Command.get")((name) =>
|
||||||
const command = staticCommand(name)
|
Effect.sync(() => {
|
||||||
if (command) return command
|
const definition = state.get().get(name)
|
||||||
return (yield* mcpCommands()).find((command) => command.name === name)
|
return definition ? info(definition) : undefined
|
||||||
}),
|
}),
|
||||||
list: Effect.fn("Command.list")(function* () {
|
),
|
||||||
const commands = Array.from(state.get().commands.values()) as Info[]
|
list: Effect.fn("Command.list")(() => Effect.sync(() => Array.from(state.get().values(), info))),
|
||||||
const names = new Set(commands.map((command) => command.name))
|
execute: Effect.fn("Command.execute")(function* (input) {
|
||||||
return [...commands, ...(yield* mcpCommands()).filter((command) => !names.has(command.name))]
|
const definition = state.get().get(input.name)
|
||||||
}),
|
if (!definition)
|
||||||
evaluate: Effect.fn("Command.evaluate")(function* (input) {
|
return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
|
||||||
const command = staticCommand(input.name)
|
return yield* definition.execute(input.invocation).pipe(
|
||||||
if (command)
|
Effect.tapError((error) => Effect.logError("command execution failed", { command: input.name, error })),
|
||||||
return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
|
Effect.mapError((error) => new ExecutionError({ command: input.name, message: errorMessage(error) })),
|
||||||
config,
|
|
||||||
location,
|
|
||||||
processes,
|
|
||||||
shell: options,
|
|
||||||
bin: global.bin,
|
|
||||||
})
|
|
||||||
|
|
||||||
const prompt = (yield* mcp.prompts()).find(
|
|
||||||
(prompt) => mcpCommandName(prompt.server, prompt.name) === input.name,
|
|
||||||
)
|
|
||||||
if (!prompt)
|
|
||||||
return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
|
|
||||||
const result = yield* mcp
|
|
||||||
.prompt({
|
|
||||||
server: prompt.server,
|
|
||||||
name: prompt.name,
|
|
||||||
args: Object.fromEntries(
|
|
||||||
(prompt.arguments ?? []).map((argument, index) => [
|
|
||||||
argument.name,
|
|
||||||
parseArguments(input.arguments ?? "")[index] ?? "",
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
Effect.catchTag("MCP.NotFoundError", () =>
|
|
||||||
Effect.fail(
|
|
||||||
new EvaluationError({
|
|
||||||
command: input.name,
|
|
||||||
message: `MCP server could not be found while evaluating prompt: ${prompt.server}`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!result)
|
|
||||||
return yield* new EvaluationError({
|
|
||||||
command: input.name,
|
|
||||||
message: `MCP prompt could not be evaluated: ${prompt.server}:${prompt.name}`,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
text: result.messages
|
|
||||||
.map((message) => promptMessageText(message.content))
|
|
||||||
.join("\n")
|
|
||||||
.trim(),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
function evaluateTemplate(
|
|
||||||
command: string,
|
|
||||||
template: string,
|
|
||||||
input: string,
|
|
||||||
services: {
|
|
||||||
readonly config: Config.Interface
|
|
||||||
readonly location: Location.Info
|
|
||||||
readonly processes: AppProcess.Interface
|
|
||||||
readonly shell?: ShellSelect.Options
|
|
||||||
readonly bin: string
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const expanded = evaluateArguments(template, input)
|
|
||||||
return { text: yield* evaluateShell(command, expanded, services) }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function evaluateArguments(template: string, input: string) {
|
|
||||||
const args = parseArguments(input)
|
|
||||||
const placeholders = template.match(placeholderRegex) ?? []
|
|
||||||
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
|
|
||||||
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
|
|
||||||
const position = Number(index)
|
|
||||||
const argIndex = position - 1
|
|
||||||
if (argIndex >= args.length) return ""
|
|
||||||
if (position === last) return args.slice(argIndex).join(" ")
|
|
||||||
return args[argIndex]
|
|
||||||
})
|
|
||||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
|
||||||
if (placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim())
|
|
||||||
return `${withArguments}\n\n${input}`.trim()
|
|
||||||
return withArguments.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
const evaluateShell = Effect.fnUntraced(function* (
|
|
||||||
command: string,
|
|
||||||
text: string,
|
|
||||||
services: {
|
|
||||||
readonly config: Config.Interface
|
|
||||||
readonly location: Location.Info
|
|
||||||
readonly processes: AppProcess.Interface
|
|
||||||
readonly shell?: ShellSelect.Options
|
|
||||||
readonly bin: string
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const matches = Array.from(text.matchAll(shellRegex))
|
|
||||||
if (matches.length === 0) return text
|
|
||||||
const shell = ShellSelect.preferred(
|
|
||||||
Config.latest(yield* services.config.entries(), "shell"),
|
|
||||||
services.shell,
|
|
||||||
services.bin,
|
|
||||||
)
|
|
||||||
const outputs = yield* Effect.forEach(
|
|
||||||
matches,
|
|
||||||
(match) => {
|
|
||||||
const source = match[1] ?? ""
|
|
||||||
return services.processes
|
|
||||||
.run(
|
|
||||||
ChildProcess.make(shell, ShellSelect.args(shell, source), {
|
|
||||||
cwd: services.location.directory,
|
|
||||||
stdin: "ignore",
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
combineOutput: true,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.pipe(
|
}),
|
||||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
})
|
||||||
Effect.mapError(
|
}),
|
||||||
(error) =>
|
)
|
||||||
new EvaluationError({
|
|
||||||
command,
|
export const node = makeLocationNode({
|
||||||
message: `Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`,
|
service: Service,
|
||||||
}),
|
layer,
|
||||||
),
|
deps: [Bus.node],
|
||||||
)
|
|
||||||
},
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
const iterator = outputs[Symbol.iterator]()
|
|
||||||
return text.replace(shellRegex, () => iterator.next().value ?? "")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function parseArguments(input: string) {
|
function errorMessage(error: unknown) {
|
||||||
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
|
if (error instanceof Error) return error.message
|
||||||
|
if (typeof error === "string") return error
|
||||||
|
if (error && typeof error === "object" && "message" in error && typeof error.message === "string")
|
||||||
|
return error.message
|
||||||
|
return "Command execution failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
function promptMessageText(content: unknown) {
|
|
||||||
if (typeof content === "string") return content
|
|
||||||
if (!content || typeof content !== "object") return ""
|
|
||||||
if (!("type" in content) || content.type !== "text") return ""
|
|
||||||
if (!("text" in content) || typeof content.text !== "string") return ""
|
|
||||||
return content.text
|
|
||||||
}
|
|
||||||
|
|
||||||
function mcpCommandName(server: string, prompt: string) {
|
|
||||||
return `${sanitize(server)}:${sanitize(prompt)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitize(value: string) {
|
|
||||||
return value.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
|
||||||
const placeholderRegex = /\$(\d+)/g
|
|
||||||
const quoteTrimRegex = /^["']|["']$/g
|
|
||||||
const shellRegex = /!`([^`]+)`/g
|
|
||||||
|
|
||||||
export function configured(options?: ShellSelect.Options) {
|
|
||||||
return makeLocationNode({
|
|
||||||
service: Service,
|
|
||||||
layer: layer(options),
|
|
||||||
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node, Global.node],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const node = configured()
|
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ export const layer = (options?: Options) =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
entries: Effect.fn("Config.entries")(function* () {
|
entries: Effect.fnUntraced(function* () {
|
||||||
return configs
|
return configs
|
||||||
}),
|
}),
|
||||||
update,
|
update,
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export function normalize(input: unknown): Result {
|
|||||||
"agents",
|
"agents",
|
||||||
migratedAgents,
|
migratedAgents,
|
||||||
nativeAgents,
|
nativeAgents,
|
||||||
isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
|
migratedSmallModel !== undefined || isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
|
||||||
diagnostics,
|
diagnostics,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
export * as ConfigCommandPlugin from "./command.js"
|
export * as ConfigCommandPlugin from "./command.js"
|
||||||
|
|
||||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { Info, type Entry } from "@opencode-ai/schema/config"
|
import { Info, type Entry } from "@opencode-ai/schema/config"
|
||||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||||
|
import { Model } from "@opencode-ai/schema/model"
|
||||||
|
import { Provider } from "@opencode-ai/schema/provider"
|
||||||
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Option, Schema, Stream } from "effect"
|
import { Effect, Option, Schema, Stream } from "effect"
|
||||||
import { Command } from "../../command.js"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { Config } from "../../config.js"
|
import { Config } from "../../config.js"
|
||||||
|
import { Location } from "../../location.js"
|
||||||
|
import { Shell } from "../../shell.js"
|
||||||
|
import { ShellSelect } from "../../shell/select.js"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { ConfigMarkdown } from "../markdown.js"
|
import { ConfigMarkdown } from "../markdown.js"
|
||||||
|
|
||||||
@@ -17,6 +24,9 @@ export const Plugin = define({
|
|||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const processes = yield* AppProcess.Service
|
||||||
|
const shell = yield* Shell.Service
|
||||||
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
||||||
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||||
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
||||||
@@ -53,17 +63,41 @@ export const Plugin = define({
|
|||||||
yield* ctx.command.transform((draft) => {
|
yield* ctx.command.transform((draft) => {
|
||||||
for (const document of loaded.documents) {
|
for (const document of loaded.documents) {
|
||||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||||
draft.update(name, (item) => {
|
draft.add({
|
||||||
item.template = command.template
|
name,
|
||||||
if (command.description !== undefined) item.description = command.description
|
description: command.description,
|
||||||
if (command.agent !== undefined) item.agent = command.agent
|
execute: (input) =>
|
||||||
if (command.model !== undefined)
|
Effect.gen(function* () {
|
||||||
item.model = {
|
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
|
||||||
id: command.model.model,
|
const commandAgent = yield* Effect.gen(function* () {
|
||||||
providerID: command.model.providerID,
|
if (agent === undefined) return
|
||||||
...(command.model.variant === undefined ? {} : { variant: command.model.variant }),
|
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||||
}
|
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||||
if (command.subtask !== undefined) item.subtask = command.subtask
|
return (yield* ctx.agent.get({ agentID: agent })).data
|
||||||
|
})
|
||||||
|
const model =
|
||||||
|
command.model === undefined
|
||||||
|
? commandAgent?.model
|
||||||
|
: {
|
||||||
|
id: Model.ID.make(command.model.model),
|
||||||
|
providerID: Provider.ID.make(command.model.providerID),
|
||||||
|
...(command.model.variant === undefined
|
||||||
|
? {}
|
||||||
|
: { variant: Model.VariantID.make(command.model.variant) }),
|
||||||
|
}
|
||||||
|
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
|
||||||
|
yield* ctx.session.prompt({
|
||||||
|
...input.prompt,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
text: yield* evaluateTemplate(command.template, input.prompt.text, {
|
||||||
|
config,
|
||||||
|
location,
|
||||||
|
processes,
|
||||||
|
shell,
|
||||||
|
}),
|
||||||
|
delivery: input.delivery,
|
||||||
|
})
|
||||||
|
}).pipe(Effect.asVoid),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,3 +150,67 @@ function decode(directory: string, filepath: string, content: string) {
|
|||||||
info,
|
info,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function evaluateTemplate(
|
||||||
|
template: string,
|
||||||
|
input: string,
|
||||||
|
services: {
|
||||||
|
readonly config: Config.Interface
|
||||||
|
readonly location: Location.Info
|
||||||
|
readonly processes: AppProcess.Interface
|
||||||
|
readonly shell: Shell.Interface
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const args = parseArguments(input)
|
||||||
|
const placeholders = template.match(placeholderRegex) ?? []
|
||||||
|
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
|
||||||
|
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
|
||||||
|
const position = Number(index)
|
||||||
|
const argIndex = position - 1
|
||||||
|
if (argIndex >= args.length) return ""
|
||||||
|
if (position === last) return args.slice(argIndex).join(" ")
|
||||||
|
return args[argIndex]
|
||||||
|
})
|
||||||
|
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
||||||
|
const text =
|
||||||
|
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
|
||||||
|
? `${withArguments}\n\n${input}`.trim()
|
||||||
|
: withArguments.trim()
|
||||||
|
const matches = Array.from(text.matchAll(shellRegex))
|
||||||
|
if (matches.length === 0) return text
|
||||||
|
const shell = yield* services.shell.name()
|
||||||
|
const outputs = yield* Effect.forEach(
|
||||||
|
matches,
|
||||||
|
(match) => {
|
||||||
|
const source = match[1] ?? ""
|
||||||
|
return services.processes
|
||||||
|
.run(
|
||||||
|
ChildProcess.make(shell, ShellSelect.args(shell, source), {
|
||||||
|
cwd: services.location.directory,
|
||||||
|
stdin: "ignore",
|
||||||
|
}),
|
||||||
|
{ combineOutput: true },
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||||
|
Effect.mapError((error) =>
|
||||||
|
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
const iterator = outputs[Symbol.iterator]()
|
||||||
|
return text.replace(shellRegex, () => iterator.next().value ?? "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArguments(input: string) {
|
||||||
|
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||||
|
const placeholderRegex = /\$(\d+)/g
|
||||||
|
const quoteTrimRegex = /^["']|["']$/g
|
||||||
|
const shellRegex = /!`([^`]+)`/g
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
export * as ConfigFormatterPlugin from "./formatter.js"
|
||||||
|
|
||||||
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
|
import { Global } from "@opencode-ai/util/global"
|
||||||
|
import { Npm } from "@opencode-ai/util/npm"
|
||||||
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
|
import { Effect, Stream } from "effect"
|
||||||
|
import { Config } from "../../config.js"
|
||||||
|
import { Formatter } from "../../formatter.js"
|
||||||
|
import { make, type Info } from "../../formatter/builtins.js"
|
||||||
|
import { Location } from "../../location.js"
|
||||||
|
|
||||||
|
export const Plugin = define({
|
||||||
|
id: "opencode.config.formatter",
|
||||||
|
effect: Effect.fn(function* (ctx) {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const formatter = yield* Formatter.Service
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const global = yield* Global.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const npm = yield* Npm.Service
|
||||||
|
const processes = yield* AppProcess.Service
|
||||||
|
const loaded = { entries: yield* config.entries() }
|
||||||
|
const reload = config.entries().pipe(
|
||||||
|
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||||
|
Effect.andThen(formatter.reload()),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* ctx.event.subscribe().pipe(
|
||||||
|
Stream.filter((event) => event.type === "config.updated"),
|
||||||
|
Stream.runForEach(() => reload),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
// Refetch after subscribing so a config update between the first read and
|
||||||
|
// the live subscription cannot leave the transform on a stale snapshot.
|
||||||
|
loaded.entries = yield* config.entries()
|
||||||
|
|
||||||
|
yield* formatter.transform((draft) => {
|
||||||
|
const configured = Config.latest(loaded.entries, "formatter")
|
||||||
|
if (!configured) return
|
||||||
|
const builtIns = make({
|
||||||
|
directory: location.directory,
|
||||||
|
worktree: location.project.directory,
|
||||||
|
fs,
|
||||||
|
npm,
|
||||||
|
processes,
|
||||||
|
bin: global.bin,
|
||||||
|
})
|
||||||
|
builtIns.forEach(draft.set)
|
||||||
|
if (configured === true) return
|
||||||
|
|
||||||
|
for (const [name, entry] of Object.entries(configured)) {
|
||||||
|
if (entry.disabled) {
|
||||||
|
draft.remove(name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const builtIn = builtIns.find((formatter) => formatter.name === name)
|
||||||
|
const current: Info = {
|
||||||
|
name,
|
||||||
|
extensions: entry.extensions ?? builtIn?.extensions ?? [],
|
||||||
|
environment: { ...builtIn?.environment, ...entry.environment },
|
||||||
|
enabled:
|
||||||
|
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
||||||
|
}
|
||||||
|
draft.set(current)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export * as ConfigImagePlugin from "./image.js"
|
||||||
|
|
||||||
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
|
import { Effect, Stream } from "effect"
|
||||||
|
import { Config } from "../../config.js"
|
||||||
|
import { Image } from "../../image.js"
|
||||||
|
|
||||||
|
export const Plugin = define({
|
||||||
|
id: "opencode.config.image",
|
||||||
|
effect: Effect.fn(function* (ctx) {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const image = yield* Image.Service
|
||||||
|
const loaded = { entries: yield* config.entries() }
|
||||||
|
const reload = config.entries().pipe(
|
||||||
|
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||||
|
Effect.andThen(image.reload()),
|
||||||
|
)
|
||||||
|
yield* ctx.event.subscribe().pipe(
|
||||||
|
Stream.filter((event) => event.type === "config.updated"),
|
||||||
|
Stream.runForEach(() => reload),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
// Refetch after subscribing so a config update between the first read and
|
||||||
|
// the live subscription cannot leave the transform on a stale snapshot.
|
||||||
|
loaded.entries = yield* config.entries()
|
||||||
|
yield* image.transform((draft) => {
|
||||||
|
for (const entry of loaded.entries) {
|
||||||
|
if (entry.type !== "document") continue
|
||||||
|
const configured = entry.info.media?.image
|
||||||
|
if (!configured) continue
|
||||||
|
draft.configure({
|
||||||
|
...(configured.auto_resize === undefined ? {} : { autoResize: configured.auto_resize }),
|
||||||
|
...(configured.max_width === undefined ? {} : { maxWidth: configured.max_width }),
|
||||||
|
...(configured.max_height === undefined ? {} : { maxHeight: configured.max_height }),
|
||||||
|
...(configured.max_base64_bytes === undefined ? {} : { maxBase64Bytes: configured.max_base64_bytes }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -120,9 +120,13 @@ export class EffectSQLiteSession<TRelations extends AnyRelations> extends SQLite
|
|||||||
|
|
||||||
private execute(query: Query, params: unknown[], method: SQLiteExecuteMethod | "values") {
|
private execute(query: Query, params: unknown[], method: SQLiteExecuteMethod | "values") {
|
||||||
const statement = this.client.unsafe(query.sql, params)
|
const statement = this.client.unsafe(query.sql, params)
|
||||||
if (method === "values") return statement.values
|
if (method === "values") return statement.values.pipe(Effect.withTracerEnabled(false))
|
||||||
if (method === "get") return statement.withoutTransform.pipe(Effect.map((rows) => rows[0]))
|
if (method === "get")
|
||||||
return statement.withoutTransform
|
return statement.withoutTransform.pipe(
|
||||||
|
Effect.map((rows) => rows[0]),
|
||||||
|
Effect.withTracerEnabled(false),
|
||||||
|
)
|
||||||
|
return statement.withoutTransform.pipe(Effect.withTracerEnabled(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
private isInTransaction() {
|
private isInTransaction() {
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ import { Context, Effect, Layer } from "effect"
|
|||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
||||||
import { Npm } from "@opencode-ai/util/npm"
|
|
||||||
import { AppProcess } from "@opencode-ai/util/process"
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
|
||||||
import { Config } from "./config.js"
|
|
||||||
import { Location } from "./location.js"
|
import { Location } from "./location.js"
|
||||||
import { make, type Info } from "./formatter/builtins.js"
|
import type { Info } from "./formatter/builtins.js"
|
||||||
|
import { State } from "./state.js"
|
||||||
|
|
||||||
export interface Interface {
|
type Data = {
|
||||||
|
formatters: Info[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Draft = {
|
||||||
|
set: (formatter: Info) => void
|
||||||
|
remove: (name: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface extends State.Transformable<Draft> {
|
||||||
readonly file: (filepath: string) => Effect.Effect<boolean>
|
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,66 +27,36 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
|
||||||
const fs = yield* FSUtil.Service
|
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const npm = yield* Npm.Service
|
|
||||||
const processes = yield* AppProcess.Service
|
const processes = yield* AppProcess.Service
|
||||||
const global = yield* Global.Service
|
const commands = new WeakMap<Info, string[] | false>()
|
||||||
const commands = new Map<string, string[] | false>()
|
const state = State.create<Data, Draft>({
|
||||||
let formatters: Info[] = []
|
name: "formatter",
|
||||||
|
initial: () => ({ formatters: [] }),
|
||||||
const load = yield* Effect.cached(
|
draft: (draft) => ({
|
||||||
Effect.gen(function* () {
|
set: (formatter) => {
|
||||||
const configured = Config.latest(yield* config.entries(), "formatter")
|
const index = draft.formatters.findIndex((item) => item.name === formatter.name)
|
||||||
if (!configured) {
|
if (index === -1) draft.formatters.push(formatter)
|
||||||
yield* Effect.logInfo("all formatters are disabled")
|
else draft.formatters[index] = formatter
|
||||||
return
|
},
|
||||||
}
|
remove: (name) => {
|
||||||
|
draft.formatters = draft.formatters.filter((formatter) => formatter.name !== name)
|
||||||
const builtIns = make({
|
},
|
||||||
directory: location.directory,
|
}),
|
||||||
worktree: location.project.directory,
|
})
|
||||||
fs,
|
|
||||||
npm,
|
|
||||||
processes,
|
|
||||||
bin: global.bin,
|
|
||||||
})
|
|
||||||
formatters = builtIns
|
|
||||||
if (configured === true) return
|
|
||||||
|
|
||||||
for (const [name, entry] of Object.entries(configured)) {
|
|
||||||
const index = formatters.findIndex((formatter) => formatter.name === name)
|
|
||||||
if (entry.disabled) {
|
|
||||||
if (index !== -1) formatters.splice(index, 1)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const builtIn = builtIns.find((formatter) => formatter.name === name)
|
|
||||||
const formatter: Info = {
|
|
||||||
name,
|
|
||||||
extensions: entry.extensions ?? builtIn?.extensions ?? [],
|
|
||||||
environment: { ...builtIn?.environment, ...entry.environment },
|
|
||||||
enabled:
|
|
||||||
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
|
||||||
}
|
|
||||||
if (index === -1) formatters.push(formatter)
|
|
||||||
else formatters[index] = formatter
|
|
||||||
}
|
|
||||||
}).pipe(Effect.withSpan("Formatter.load")),
|
|
||||||
)
|
|
||||||
|
|
||||||
const command = Effect.fnUntraced(function* (formatter: Info) {
|
const command = Effect.fnUntraced(function* (formatter: Info) {
|
||||||
const cached = commands.get(formatter.name)
|
const cached = commands.get(formatter)
|
||||||
if (cached !== undefined) return cached
|
if (cached !== undefined) return cached
|
||||||
const result = yield* formatter.enabled
|
const result = yield* formatter.enabled
|
||||||
if (result !== false) commands.set(formatter.name, result)
|
if (result !== false) commands.set(formatter, result)
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||||
yield* load
|
const matching = state
|
||||||
const matching = formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
|
.get()
|
||||||
|
.formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
|
||||||
|
|
||||||
for (const formatter of matching) {
|
for (const formatter of matching) {
|
||||||
const enabled = yield* command(formatter)
|
const enabled = yield* command(formatter)
|
||||||
@@ -118,12 +94,12 @@ const layer = Layer.effect(
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ file })
|
return Service.of({ transform: state.transform, reload: state.reload, file })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
|
deps: [Location.node, AppProcess.node],
|
||||||
})
|
})
|
||||||
|
|||||||
+33
-17
@@ -2,8 +2,8 @@ export * as Image from "./image.js"
|
|||||||
|
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Config } from "./config.js"
|
|
||||||
import { FileSystem } from "./filesystem.js"
|
import { FileSystem } from "./filesystem.js"
|
||||||
|
import { State } from "./state.js"
|
||||||
|
|
||||||
export class ResizerUnavailableError extends Schema.TaggedError<ResizerUnavailableError>()(
|
export class ResizerUnavailableError extends Schema.TaggedError<ResizerUnavailableError>()(
|
||||||
"Image.ResizerUnavailableError",
|
"Image.ResizerUnavailableError",
|
||||||
@@ -32,7 +32,18 @@ export class SizeError extends Schema.TaggedError<SizeError>()("Image.SizeError"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export type Limits = {
|
||||||
|
autoResize: boolean
|
||||||
|
maxWidth: number
|
||||||
|
maxHeight: number
|
||||||
|
maxBase64Bytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Draft = {
|
||||||
|
configure: (limits: Partial<Limits>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface extends State.Transformable<Draft> {
|
||||||
readonly normalize: (
|
readonly normalize: (
|
||||||
resource: string,
|
resource: string,
|
||||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||||
@@ -47,7 +58,23 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
|
|||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const state = State.create<Limits, Draft>({
|
||||||
|
name: "image",
|
||||||
|
initial: () => ({
|
||||||
|
autoResize: true,
|
||||||
|
maxWidth: 2_000,
|
||||||
|
maxHeight: 2_000,
|
||||||
|
maxBase64Bytes: 5 * 1024 * 1024,
|
||||||
|
}),
|
||||||
|
draft: (draft) => ({
|
||||||
|
configure: (limits) => {
|
||||||
|
if (limits.autoResize !== undefined) draft.autoResize = limits.autoResize
|
||||||
|
if (limits.maxWidth !== undefined) draft.maxWidth = limits.maxWidth
|
||||||
|
if (limits.maxHeight !== undefined) draft.maxHeight = limits.maxHeight
|
||||||
|
if (limits.maxBase64Bytes !== undefined) draft.maxBase64Bytes = limits.maxBase64Bytes
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
const loadAdapter = yield* Effect.cached(
|
const loadAdapter = yield* Effect.cached(
|
||||||
Effect.tryPromise({
|
Effect.tryPromise({
|
||||||
try: () => import("./image/photon.js"),
|
try: () => import("./image/photon.js"),
|
||||||
@@ -58,22 +85,11 @@ const layer = Layer.effect(
|
|||||||
resource: string,
|
resource: string,
|
||||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||||
) {
|
) {
|
||||||
const image = Object.assign(
|
|
||||||
{},
|
|
||||||
...(yield* config.entries()).flatMap((entry) =>
|
|
||||||
entry.type === "document" && entry.info.media?.image ? [entry.info.media.image] : [],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const normalize = yield* loadAdapter
|
const normalize = yield* loadAdapter
|
||||||
return yield* normalize(resource, content, {
|
return yield* normalize(resource, content, state.get())
|
||||||
autoResize: image.auto_resize ?? true,
|
|
||||||
maxWidth: image.max_width ?? 2_000,
|
|
||||||
maxHeight: image.max_height ?? 2_000,
|
|
||||||
maxBase64Bytes: image.max_base64_bytes ?? 5 * 1024 * 1024,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
return Service.of({ normalize })
|
return Service.of({ transform: state.transform, reload: state.reload, normalize })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export const make = Effect.gen(function* () {
|
|||||||
scope: yield* Scope.Scope,
|
scope: yield* Scope.Scope,
|
||||||
}
|
}
|
||||||
|
|
||||||
const settle = Effect.fn("Job.settle")(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
|
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
|
||||||
const completed_at = yield* Clock.currentTimeMillis
|
const completed_at = yield* Clock.currentTimeMillis
|
||||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||||
const job = jobs.get(id)
|
const job = jobs.get(id)
|
||||||
@@ -170,7 +170,7 @@ export const make = Effect.gen(function* () {
|
|||||||
return result.info
|
return result.info
|
||||||
})
|
})
|
||||||
|
|
||||||
const fork = Effect.fn("Job.fork")(function* (
|
const fork = Effect.fnUntraced(function* (
|
||||||
scope: Scope.Scope,
|
scope: Scope.Scope,
|
||||||
id: string,
|
id: string,
|
||||||
token: object,
|
token: object,
|
||||||
@@ -192,7 +192,7 @@ export const make = Effect.gen(function* () {
|
|||||||
return snapshot(job)
|
return snapshot(job)
|
||||||
})
|
})
|
||||||
|
|
||||||
const start: Interface["start"] = Effect.fn("Job.start")(function* (input) {
|
const start: Interface["start"] = Effect.fnUntraced(function* (input) {
|
||||||
return yield* Effect.uninterruptibleMask((restore) =>
|
return yield* Effect.uninterruptibleMask((restore) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = input.id ?? Identifier.ascending("job")
|
const id = input.id ?? Identifier.ascending("job")
|
||||||
@@ -247,7 +247,7 @@ export const make = Effect.gen(function* () {
|
|||||||
return { info: snapshot(job), timedOut: true }
|
return { info: snapshot(job), timedOut: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeBlock = Effect.fn("Job.removeBlock")(function* (input: BlockInput) {
|
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) {
|
||||||
yield* SynchronizedRef.update(state.jobs, (jobs) => {
|
yield* SynchronizedRef.update(state.jobs, (jobs) => {
|
||||||
const job = jobs.get(input.id)
|
const job = jobs.get(input.id)
|
||||||
if (!job || job.info.status !== "running" || job.isBackgrounded) return jobs
|
if (!job || job.info.status !== "running" || job.isBackgrounded) return jobs
|
||||||
@@ -258,7 +258,7 @@ export const make = Effect.gen(function* () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const block: Interface["block"] = Effect.fn("Job.block")(function* (input) {
|
const block: Interface["block"] = Effect.fnUntraced(function* (input) {
|
||||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
|
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
|
||||||
const job = jobs.get(input.id)
|
const job = jobs.get(input.id)
|
||||||
if (!job) return [{ type: "missing" }, jobs]
|
if (!job) return [{ type: "missing" }, jobs]
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const layer = Layer.effect(
|
|||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
|
|
||||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
|
||||||
const absolute = path.resolve(location.directory, input.path)
|
const absolute = path.resolve(location.directory, input.path)
|
||||||
if (FSUtil.contains(location.directory, absolute)) {
|
if (FSUtil.contains(location.directory, absolute)) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export * as MCP from "./index.js"
|
|||||||
|
|
||||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||||
import { Command } from "@opencode-ai/schema/command"
|
import { ephemeral } from "@opencode-ai/schema/event"
|
||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
import { isDeepStrictEqual } from "node:util"
|
import { isDeepStrictEqual } from "node:util"
|
||||||
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream, Types } from "effect"
|
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream, Types } from "effect"
|
||||||
@@ -19,6 +19,7 @@ import { State } from "../state.js"
|
|||||||
import type { MCPClient } from "./client.js"
|
import type { MCPClient } from "./client.js"
|
||||||
|
|
||||||
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
|
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
|
||||||
|
export const PromptsChanged = ephemeral({ type: "mcp.prompts.changed", schema: { server: Schema.String } })
|
||||||
export type ServerName = typeof ServerName.Type
|
export type ServerName = typeof ServerName.Type
|
||||||
|
|
||||||
// The status union is a public wire contract, so it lives in @opencode-ai/schema and is re-exported here.
|
// The status union is a public wire contract, so it lives in @opencode-ai/schema and is re-exported here.
|
||||||
@@ -428,7 +429,7 @@ export const layer = (options?: Options) =>
|
|||||||
Effect.map((defs) => {
|
Effect.map((defs) => {
|
||||||
entry.prompts = defs.map((def) => toPrompt(name, def))
|
entry.prompts = defs.map((def) => toPrompt(name, def))
|
||||||
}),
|
}),
|
||||||
Effect.andThen(bus.publish(Command.Event.Updated, {})),
|
Effect.andThen(bus.publish(PromptsChanged, { server: name })),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Runs a connection callback under the server lock, dropping it if the connection is no longer
|
// Runs a connection callback under the server lock, dropping it if the connection is no longer
|
||||||
@@ -547,7 +548,7 @@ export const layer = (options?: Options) =>
|
|||||||
yield* Scope.close(scope, Exit.void)
|
yield* Scope.close(scope, Exit.void)
|
||||||
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||||
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||||
yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
yield* bus.publish(PromptsChanged, { server: name }).pipe(Effect.ignore)
|
||||||
})
|
})
|
||||||
|
|
||||||
const disposeServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
const disposeServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||||
|
|||||||
@@ -152,14 +152,14 @@ const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const configured = Effect.fn("Permission.configured")(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
|
const configured = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
|
||||||
const session = yield* sessions.get(sessionID)
|
const session = yield* sessions.get(sessionID)
|
||||||
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
|
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
|
||||||
const agent = yield* agents.resolve(agentID ?? session.agent)
|
const agent = yield* agents.resolve(agentID ?? session.agent)
|
||||||
return agent?.permissions ?? missingAgentPermissions
|
return agent?.permissions ?? missingAgentPermissions
|
||||||
})
|
})
|
||||||
|
|
||||||
const allowsAll = Effect.fn("Permission.allowsAll")(function* (input: {
|
const allowsAll = Effect.fnUntraced(function* (input: {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly action: string
|
readonly action: string
|
||||||
readonly agent?: Agent.ID
|
readonly agent?: Agent.ID
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
|
|
||||||
const list = Effect.fn("PermissionSaved.list")(function* (input?: ListInput) {
|
const list = Effect.fnUntraced(function* (input?: ListInput) {
|
||||||
const rows = yield* db
|
const rows = yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(PermissionTable)
|
.from(PermissionTable)
|
||||||
|
|||||||
+47
-13
@@ -1,10 +1,10 @@
|
|||||||
export * as Plugin from "./plugin.js"
|
export * as Plugin from "./plugin.js"
|
||||||
export { Event, ID, Info } from "@opencode-ai/schema/plugin"
|
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
|
||||||
|
|
||||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { App } from "./app.js"
|
import { App } from "./app.js"
|
||||||
import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
|
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
|
||||||
import { Agent } from "./agent.js"
|
import { Agent } from "./agent.js"
|
||||||
import { AISDK } from "./aisdk.js"
|
import { AISDK } from "./aisdk.js"
|
||||||
import { Catalog } from "./catalog.js"
|
import { Catalog } from "./catalog.js"
|
||||||
@@ -23,11 +23,17 @@ import { Tool } from "./tool.js"
|
|||||||
import { PluginHooks } from "./plugin/hooks.js"
|
import { PluginHooks } from "./plugin/hooks.js"
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly activate: (plugins: readonly Versioned[]) => Effect.Effect<void>
|
readonly activate: (
|
||||||
|
plugins: readonly Versioned[],
|
||||||
|
failures?: readonly Extract<Plugin.Info, { readonly status: "failed" }>[],
|
||||||
|
) => Effect.Effect<void>
|
||||||
readonly list: () => Effect.Effect<Plugin.Info[]>
|
readonly list: () => Effect.Effect<Plugin.Info[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & { readonly version: string }
|
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & {
|
||||||
|
readonly version: string
|
||||||
|
readonly source?: Plugin.Source
|
||||||
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||||
|
|
||||||
@@ -38,6 +44,7 @@ const layer = Layer.effect(
|
|||||||
const scope = yield* Scope.make()
|
const scope = yield* Scope.make()
|
||||||
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
|
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
|
||||||
const lock = Semaphore.makeUnsafe(1)
|
const lock = Semaphore.makeUnsafe(1)
|
||||||
|
let inventory: Plugin.Info[] = []
|
||||||
let host: Parameters<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>[0]
|
let host: Parameters<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>[0]
|
||||||
|
|
||||||
const load = Effect.fnUntraced(function* (plugin: Versioned) {
|
const load = Effect.fnUntraced(function* (plugin: Versioned) {
|
||||||
@@ -56,15 +63,18 @@ const layer = Layer.effect(
|
|||||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||||
Effect.exit,
|
Effect.exit,
|
||||||
)
|
)
|
||||||
if (Exit.isSuccess(loaded)) return child
|
if (Exit.isSuccess(loaded)) return { scope: child } as const
|
||||||
yield* Effect.logWarning("failed to load plugin", {
|
yield* Effect.logWarning("failed to load plugin", {
|
||||||
"plugin.id": plugin.id,
|
"plugin.id": plugin.id,
|
||||||
cause: loaded.cause,
|
cause: loaded.cause,
|
||||||
})
|
})
|
||||||
return undefined
|
return { error: Cause.pretty(loaded.cause) } as const
|
||||||
})
|
})
|
||||||
|
|
||||||
const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Versioned[]) {
|
const activate = Effect.fn("Plugin.activate")(function* (
|
||||||
|
plugins: readonly Versioned[],
|
||||||
|
failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[] = [],
|
||||||
|
) {
|
||||||
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
|
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
|
||||||
const ids = new Set<Plugin.ID>()
|
const ids = new Set<Plugin.ID>()
|
||||||
for (const definition of definitions) {
|
for (const definition of definitions) {
|
||||||
@@ -85,26 +95,40 @@ const layer = Layer.effect(
|
|||||||
const candidate = next[index]
|
const candidate = next[index]
|
||||||
return definition.id === candidate?.id && definition.version === candidate.version
|
return definition.id === candidate?.id && definition.version === candidate.version
|
||||||
})
|
})
|
||||||
)
|
) {
|
||||||
|
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
|
||||||
|
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
|
||||||
|
inventory = nextInventory
|
||||||
|
yield* bus.publish(Plugin.Event.Updated, {})
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
yield* State.batch(
|
yield* State.batch(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const nextInventory: Plugin.Info[] = []
|
||||||
for (const definition of definitions) {
|
for (const definition of definitions) {
|
||||||
const previous = active.get(definition.id)
|
const previous = active.get(definition.id)
|
||||||
active.delete(definition.id)
|
active.delete(definition.id)
|
||||||
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
|
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
|
||||||
|
|
||||||
const loaded = yield* load(definition)
|
const loaded = yield* load(definition)
|
||||||
if (loaded) {
|
if (loaded.scope !== undefined) {
|
||||||
active.set(definition.id, { plugin: definition, scope: loaded })
|
active.set(definition.id, { plugin: definition, scope: loaded.scope })
|
||||||
|
nextInventory.push(activeInfo(definition))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
nextInventory.push({
|
||||||
|
id: definition.id,
|
||||||
|
source: definition.source ?? { type: "builtin" },
|
||||||
|
status: "failed",
|
||||||
|
error: loaded.error,
|
||||||
|
tui: definition.tui ?? false,
|
||||||
|
})
|
||||||
|
|
||||||
if (!previous) continue
|
if (!previous) continue
|
||||||
const restored = yield* load(previous.plugin)
|
const restored = yield* load(previous.plugin)
|
||||||
if (restored) {
|
if (restored.scope !== undefined) {
|
||||||
active.set(definition.id, { plugin: previous.plugin, scope: restored })
|
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
yield* Effect.logError("failed to restore plugin; deactivating", {
|
yield* Effect.logError("failed to restore plugin; deactivating", {
|
||||||
@@ -119,6 +143,7 @@ const layer = Layer.effect(
|
|||||||
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
|
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
|
||||||
discard: true,
|
discard: true,
|
||||||
})
|
})
|
||||||
|
inventory = [...nextInventory, ...failures]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* bus.publish(Plugin.Event.Updated, {})
|
yield* bus.publish(Plugin.Event.Updated, {})
|
||||||
@@ -136,7 +161,7 @@ const layer = Layer.effect(
|
|||||||
const service = Service.of({
|
const service = Service.of({
|
||||||
activate,
|
activate,
|
||||||
list: Effect.fn("Plugin.list")(function* () {
|
list: Effect.fn("Plugin.list")(function* () {
|
||||||
return Array.from(active.keys()).map((id) => ({ id }))
|
return inventory
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
host = yield* PluginHost.make(service)
|
host = yield* PluginHost.make(service)
|
||||||
@@ -144,6 +169,15 @@ const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function activeInfo(plugin: Versioned): Plugin.Info {
|
||||||
|
return {
|
||||||
|
id: Plugin.ID.make(plugin.id),
|
||||||
|
source: plugin.source ?? { type: "builtin" },
|
||||||
|
status: "active",
|
||||||
|
tui: plugin.tui ?? false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
export * as CommandPlugin from "./command.js"
|
export * as CommandPlugin from "./command.js"
|
||||||
|
|
||||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { Effect } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
|
import { Bus } from "../bus.js"
|
||||||
import { Location } from "../location.js"
|
import { Location } from "../location.js"
|
||||||
|
import { MCP } from "../mcp/index.js"
|
||||||
import PROMPT_INITIALIZE from "./command/initialize.txt"
|
import PROMPT_INITIALIZE from "./command/initialize.txt"
|
||||||
import PROMPT_REVIEW from "./command/review.txt"
|
import PROMPT_REVIEW from "./command/review.txt"
|
||||||
|
|
||||||
@@ -10,15 +12,104 @@ export const Plugin = define({
|
|||||||
id: "opencode.command",
|
id: "opencode.command",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const loaded = { prompts: [] as MCP.Prompt[] }
|
||||||
|
yield* bus
|
||||||
|
.subscribe(MCP.PromptsChanged)
|
||||||
|
.pipe(
|
||||||
|
Stream.runForEach(() =>
|
||||||
|
mcp.prompts().pipe(
|
||||||
|
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
|
||||||
|
Effect.andThen(ctx.command.reload()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
loaded.prompts = yield* mcp.prompts()
|
||||||
yield* ctx.command.transform((draft) => {
|
yield* ctx.command.transform((draft) => {
|
||||||
draft.update("init", (command) => {
|
draft.add({
|
||||||
command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory)
|
name: "init",
|
||||||
command.description = "guided AGENTS.md setup"
|
description: "guided AGENTS.md setup",
|
||||||
|
execute: (input) =>
|
||||||
|
ctx.session
|
||||||
|
.prompt({
|
||||||
|
...input.prompt,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
text: append(PROMPT_INITIALIZE.replace("${path}", location.project.directory), input.prompt.text),
|
||||||
|
delivery: input.delivery,
|
||||||
|
})
|
||||||
|
.pipe(Effect.asVoid),
|
||||||
})
|
})
|
||||||
draft.update("review", (command) => {
|
draft.add({
|
||||||
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
|
name: "review",
|
||||||
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
|
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||||
|
execute: (input) =>
|
||||||
|
ctx.session
|
||||||
|
.prompt({
|
||||||
|
...input.prompt,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
text: append(PROMPT_REVIEW.replace("${path}", location.project.directory), input.prompt.text),
|
||||||
|
delivery: input.delivery,
|
||||||
|
})
|
||||||
|
.pipe(Effect.asVoid),
|
||||||
})
|
})
|
||||||
|
for (const prompt of loaded.prompts) {
|
||||||
|
draft.add({
|
||||||
|
name: mcpCommandName(prompt.server, prompt.name),
|
||||||
|
description: prompt.description,
|
||||||
|
execute: (input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const result = yield* mcp.prompt({
|
||||||
|
server: prompt.server,
|
||||||
|
name: prompt.name,
|
||||||
|
args: Object.fromEntries(
|
||||||
|
(prompt.arguments ?? []).map((argument, index) => [
|
||||||
|
argument.name,
|
||||||
|
parseArguments(input.prompt.text)[index] ?? "",
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
if (!result) return yield* Effect.fail(new Error(`MCP prompt not found: ${prompt.server}:${prompt.name}`))
|
||||||
|
yield* ctx.session.prompt({
|
||||||
|
...input.prompt,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
text: result.messages
|
||||||
|
.map((message) => promptMessageText(message.content))
|
||||||
|
.join("\n")
|
||||||
|
.trim(),
|
||||||
|
delivery: input.delivery,
|
||||||
|
})
|
||||||
|
}).pipe(Effect.asVoid),
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function append(template: string, input: string) {
|
||||||
|
return [template, input.trim()].filter(Boolean).join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArguments(input: string) {
|
||||||
|
return (input.match(argsRegex) ?? []).map((argument) => argument.replace(quoteTrimRegex, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptMessageText(content: unknown) {
|
||||||
|
if (typeof content === "string") return content
|
||||||
|
if (!content || typeof content !== "object") return ""
|
||||||
|
if (!("type" in content) || content.type !== "text") return ""
|
||||||
|
if (!("text" in content) || typeof content.text !== "string") return ""
|
||||||
|
return content.text
|
||||||
|
}
|
||||||
|
|
||||||
|
function mcpCommandName(server: string, prompt: string) {
|
||||||
|
return `${sanitize(server)}:${sanitize(prompt)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitize(value: string) {
|
||||||
|
return value.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||||
|
const quoteTrimRegex = /^["']|["']$/g
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
|||||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||||
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
||||||
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||||
|
import type { ModelHookOptions } from "@opencode-ai/plugin/effect/registration"
|
||||||
import { Context, Effect, Layer, Scope } from "effect"
|
import { Context, Effect, Layer, Scope } from "effect"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { State } from "../state.js"
|
import { State } from "../state.js"
|
||||||
@@ -26,16 +27,26 @@ interface Failures extends Record<keyof Domains, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
|
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
|
||||||
|
type Entry = { readonly callback: Function; readonly options?: ModelHookOptions }
|
||||||
|
|
||||||
|
const eventProviderID = (event: unknown) => {
|
||||||
|
if (typeof event !== "object" || event === null || !("model" in event)) return undefined
|
||||||
|
const model = event.model
|
||||||
|
if (typeof model !== "object" || model === null || !("providerID" in model)) return undefined
|
||||||
|
return typeof model.providerID === "string" ? model.providerID : undefined
|
||||||
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly has: <Domain extends keyof Domains>(
|
readonly has: <Domain extends keyof Domains>(
|
||||||
domain: Domain,
|
domain: Domain,
|
||||||
name: keyof Domains[Domain] & keyof Failures[Domain],
|
name: keyof Domains[Domain] & keyof Failures[Domain],
|
||||||
|
providerID?: string,
|
||||||
) => Effect.Effect<boolean>
|
) => Effect.Effect<boolean>
|
||||||
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
||||||
domain: Domain,
|
domain: Domain,
|
||||||
name: Name,
|
name: Name,
|
||||||
callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
|
callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
|
||||||
|
options?: ModelHookOptions,
|
||||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||||
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
||||||
domain: Domain,
|
domain: Domain,
|
||||||
@@ -49,36 +60,47 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pl
|
|||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const callbacks = new Map<string, Function[]>()
|
const callbacks = new Map<string, Entry[]>()
|
||||||
const key = (domain: keyof Domains, name: PropertyKey) => `${domain}.${String(name)}`
|
const key = (domain: keyof Domains, name: PropertyKey) => `${domain}.${String(name)}`
|
||||||
|
|
||||||
const register: Interface["register"] = Effect.fn("PluginHooks.register")(function* (domain, name, callback) {
|
const register: Interface["register"] = Effect.fn("PluginHooks.register")(
|
||||||
const scope = yield* Scope.Scope
|
function* (domain, name, callback, options) {
|
||||||
const id = key(domain, name)
|
const scope = yield* Scope.Scope
|
||||||
let active = true
|
const id = key(domain, name)
|
||||||
callbacks.set(id, [...(callbacks.get(id) ?? []), callback])
|
let active = true
|
||||||
const dispose = Effect.sync(() => {
|
const entry = { callback, options }
|
||||||
if (!active) return
|
callbacks.set(id, [...(callbacks.get(id) ?? []), entry])
|
||||||
active = false
|
const dispose = Effect.sync(() => {
|
||||||
const next = (callbacks.get(id) ?? []).filter((item) => item !== callback)
|
if (!active) return
|
||||||
if (next.length === 0) callbacks.delete(id)
|
active = false
|
||||||
else callbacks.set(id, next)
|
const next = (callbacks.get(id) ?? []).filter((item) => item !== entry)
|
||||||
})
|
if (next.length === 0) callbacks.delete(id)
|
||||||
yield* Scope.addFinalizer(scope, dispose)
|
else callbacks.set(id, next)
|
||||||
return { dispose }
|
})
|
||||||
})
|
yield* Scope.addFinalizer(scope, dispose)
|
||||||
|
return { dispose }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
|
const trigger: Interface["trigger"] = Effect.fnUntraced(function* (domain, name, event) {
|
||||||
for (const callback of callbacks.get(key(domain, name)) ?? []) {
|
for (const entry of callbacks.get(key(domain, name)) ?? []) {
|
||||||
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
|
if (entry.options?.providerID !== undefined && entry.options.providerID !== eventProviderID(event)) continue
|
||||||
event,
|
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(
|
||||||
])
|
entry.callback,
|
||||||
|
undefined,
|
||||||
|
[event],
|
||||||
|
)
|
||||||
yield* result
|
yield* result
|
||||||
}
|
}
|
||||||
return event
|
return event
|
||||||
})
|
})
|
||||||
|
|
||||||
const has: Interface["has"] = (domain, name) => Effect.sync(() => callbacks.has(key(domain, name)))
|
const has: Interface["has"] = (domain, name, providerID) =>
|
||||||
|
Effect.sync(() =>
|
||||||
|
(callbacks.get(key(domain, name)) ?? []).some(
|
||||||
|
(entry) => entry.options?.providerID === undefined || entry.options.providerID === providerID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return Service.of({ has, register, trigger })
|
return Service.of({ has, register, trigger })
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -104,9 +104,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
aisdk: {
|
aisdk: {
|
||||||
hook: (name, callback) => {
|
hook: (name, callback, options) => {
|
||||||
if (name === "sdk") {
|
if (name === "sdk") {
|
||||||
return aisdk.hook.sdk((event) => {
|
return aisdk.hook.sdk((event) => {
|
||||||
|
if (options?.providerID !== undefined && options.providerID !== event.model.providerID) return Effect.void
|
||||||
const output = {
|
const output = {
|
||||||
model: mutable(event.model),
|
model: mutable(event.model),
|
||||||
package: event.package,
|
package: event.package,
|
||||||
@@ -119,6 +120,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
return aisdk.hook.language((event) => {
|
return aisdk.hook.language((event) => {
|
||||||
|
if (options?.providerID !== undefined && options.providerID !== event.model.providerID) return Effect.void
|
||||||
const output = {
|
const output = {
|
||||||
model: mutable(event.model),
|
model: mutable(event.model),
|
||||||
options: event.options,
|
options: event.options,
|
||||||
@@ -382,7 +384,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
hook: (name, callback) => hooks.register("session", name, callback),
|
hook: (name, callback, options) => hooks.register("session", name, callback, options),
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
runtime.session.create({
|
runtime.session.create({
|
||||||
id: input?.id,
|
id: input?.id,
|
||||||
@@ -393,6 +395,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
|||||||
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
||||||
}),
|
}),
|
||||||
get: (input) => runtime.session.get(input.sessionID),
|
get: (input) => runtime.session.get(input.sessionID),
|
||||||
|
switchAgent: runtime.session.switchAgent,
|
||||||
|
switchModel: runtime.session.switchModel,
|
||||||
prompt: runtime.session.prompt,
|
prompt: runtime.session.prompt,
|
||||||
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
|
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
|
||||||
command: runtime.session.command,
|
command: runtime.session.command,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * as PluginInternal from "./internal.js"
|
|||||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||||
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
import { Context, Effect, Scope } from "effect"
|
import { Context, Effect, Scope } from "effect"
|
||||||
import { HttpClient } from "effect/unstable/http"
|
import { HttpClient } from "effect/unstable/http"
|
||||||
import { Agent } from "../agent.js"
|
import { Agent } from "../agent.js"
|
||||||
@@ -12,6 +13,8 @@ import { Config } from "../config.js"
|
|||||||
import { Credential } from "../credential.js"
|
import { Credential } from "../credential.js"
|
||||||
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
|
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
|
||||||
import { ConfigCommandPlugin } from "../config/plugin/command.js"
|
import { ConfigCommandPlugin } from "../config/plugin/command.js"
|
||||||
|
import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
|
||||||
|
import { ConfigImagePlugin } from "../config/plugin/image.js"
|
||||||
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
|
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
|
||||||
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
|
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
|
||||||
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
|
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
|
||||||
@@ -77,6 +80,7 @@ import { WellKnownPlugin } from "../wellknown/plugin.js"
|
|||||||
|
|
||||||
const services = Effect.fn("PluginInternal.services")(function* () {
|
const services = Effect.fn("PluginInternal.services")(function* () {
|
||||||
const agent = yield* Agent.Service
|
const agent = yield* Agent.Service
|
||||||
|
const processes = yield* AppProcess.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const command = yield* Command.Service
|
const command = yield* Command.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
@@ -115,6 +119,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||||||
const wellknown = yield* WellKnown.Service
|
const wellknown = yield* WellKnown.Service
|
||||||
return Context.mergeAll(
|
return Context.mergeAll(
|
||||||
Context.make(Agent.Service, agent),
|
Context.make(Agent.Service, agent),
|
||||||
|
Context.make(AppProcess.Service, processes),
|
||||||
Context.make(Catalog.Service, catalog),
|
Context.make(Catalog.Service, catalog),
|
||||||
Context.make(Command.Service, command),
|
Context.make(Command.Service, command),
|
||||||
Context.make(Config.Service, config),
|
Context.make(Config.Service, config),
|
||||||
@@ -160,6 +165,7 @@ export type Requirements = ContextServices<Effect.Success<ReturnType<typeof serv
|
|||||||
|
|
||||||
export const requirements = LayerNode.group([
|
export const requirements = LayerNode.group([
|
||||||
Agent.node,
|
Agent.node,
|
||||||
|
AppProcess.node,
|
||||||
Catalog.node,
|
Catalog.node,
|
||||||
Command.node,
|
Command.node,
|
||||||
Config.node,
|
Config.node,
|
||||||
@@ -232,6 +238,8 @@ const post = [
|
|||||||
ConfigReferencePlugin.Plugin,
|
ConfigReferencePlugin.Plugin,
|
||||||
ConfigAgentPlugin.Plugin,
|
ConfigAgentPlugin.Plugin,
|
||||||
ConfigCommandPlugin.Plugin,
|
ConfigCommandPlugin.Plugin,
|
||||||
|
ConfigFormatterPlugin.Plugin,
|
||||||
|
ConfigImagePlugin.Plugin,
|
||||||
ConfigSkillPlugin.Plugin,
|
ConfigSkillPlugin.Plugin,
|
||||||
ConfigProviderPlugin.Plugin,
|
ConfigProviderPlugin.Plugin,
|
||||||
ConfigWebSearchPlugin.Plugin,
|
ConfigWebSearchPlugin.Plugin,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Effect } from "effect"
|
|||||||
const urls = [/^https:\/\/mcp\.cloudflare\.com\/mcp$/, /^https:\/\/executor\.sh\/[^/]+\/mcp$/]
|
const urls = [/^https:\/\/mcp\.cloudflare\.com\/mcp$/, /^https:\/\/executor\.sh\/[^/]+\/mcp$/]
|
||||||
|
|
||||||
export const Plugin = define({
|
export const Plugin = define({
|
||||||
id: "opencode.mcp.codemode-exclusion",
|
id: "opencode.mcp.codemode.exclusion",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
yield* ctx.mcp.transform((draft) => {
|
yield* ctx.mcp.transform((draft) => {
|
||||||
for (const [, server] of draft.list()) {
|
for (const [, server] of draft.list()) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ModelsDev } from "../models-dev.js"
|
|||||||
import { Provider } from "../provider.js"
|
import { Provider } from "../provider.js"
|
||||||
|
|
||||||
export const ModelsDevPlugin = define({
|
export const ModelsDevPlugin = define({
|
||||||
id: "opencode.models-dev",
|
id: "opencode.models.dev",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const modelsDev = yield* ModelsDev.Service
|
const modelsDev = yield* ModelsDev.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
@@ -55,8 +55,13 @@ export const ModelsDevPlugin = define({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function environmentNames(provider: ModelsDev.Snapshot) {
|
function environmentNames(provider: ModelsDev.Snapshot) {
|
||||||
if (provider.info.id !== Provider.ID.azure) return [...provider.environment]
|
if (provider.info.id === Provider.ID.azure)
|
||||||
return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"]
|
return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"]
|
||||||
|
// models.dev advertises project, location, and the ADC credentials file path for
|
||||||
|
// Vertex. Those configure Google auth rather than carrying a key, so only the
|
||||||
|
// Express Mode key may become a credential; GoogleVertexPlugin handles activation.
|
||||||
|
if (provider.info.id === Provider.ID.googleVertex) return ["GOOGLE_VERTEX_API_KEY"]
|
||||||
|
return [...provider.environment]
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshots(data: readonly ModelsDev.Snapshot[]) {
|
function snapshots(data: readonly ModelsDev.Snapshot[]) {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AmazonBedrockPlugin = define({
|
export const AmazonBedrockPlugin = define({
|
||||||
id: "opencode.provider.amazon-bedrock",
|
id: "opencode.provider.amazon.bedrock",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
yield* ctx.catalog.transform((evt) => {
|
yield* ctx.catalog.transform((evt) => {
|
||||||
for (const item of evt.provider.list()) {
|
for (const item of evt.provider.list()) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { configuredSettings } from "./configured.js"
|
|||||||
const providerID = Provider.ID.make("cloudflare-ai-gateway")
|
const providerID = Provider.ID.make("cloudflare-ai-gateway")
|
||||||
|
|
||||||
export const CloudflareAIGatewayPlugin = define({
|
export const CloudflareAIGatewayPlugin = define({
|
||||||
id: "opencode.provider.cloudflare-ai-gateway",
|
id: "opencode.provider.cloudflare.ai.gateway",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const configured = yield* configuredSettings(providerID)
|
const configured = yield* configuredSettings(providerID)
|
||||||
const form = iife(() => {
|
const form = iife(() => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { configuredSettings } from "./configured.js"
|
|||||||
const providerID = Provider.ID.make("cloudflare-workers-ai")
|
const providerID = Provider.ID.make("cloudflare-workers-ai")
|
||||||
|
|
||||||
export const CloudflareWorkersAIPlugin = define({
|
export const CloudflareWorkersAIPlugin = define({
|
||||||
id: "opencode.provider.cloudflare-workers-ai",
|
id: "opencode.provider.cloudflare.workers.ai",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const configured = yield* configuredSettings(providerID)
|
const configured = yield* configuredSettings(providerID)
|
||||||
const form = iife(() => {
|
const form = iife(() => {
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ const oauth = (app: App.Info) =>
|
|||||||
}) satisfies IntegrationOAuthMethodRegistration
|
}) satisfies IntegrationOAuthMethodRegistration
|
||||||
|
|
||||||
export const GithubCopilotPlugin = define({
|
export const GithubCopilotPlugin = define({
|
||||||
id: "opencode.provider.github-copilot",
|
id: "opencode.provider.github.copilot",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
@@ -241,19 +241,22 @@ export const GithubCopilotPlugin = define({
|
|||||||
evt.sdk = mod.createOpenaiCompatible(evt.options)
|
evt.sdk = mod.createOpenaiCompatible(evt.options)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* ctx.session.hook("http.request", (evt) =>
|
yield* ctx.session.hook(
|
||||||
Effect.gen(function* () {
|
"http.request",
|
||||||
if (evt.model.providerID !== Provider.ID.githubCopilot) return
|
(evt) =>
|
||||||
if (evt.agent === Agent.ID.make("title"))
|
Effect.gen(function* () {
|
||||||
evt.request.headers.set("X-Interaction-Type", "conversation-background")
|
if (evt.model.providerID !== Provider.ID.githubCopilot) return
|
||||||
if (evt.agent === Agent.ID.make("compaction"))
|
if (evt.agent === Agent.ID.make("title"))
|
||||||
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
|
evt.request.headers.set("X-Interaction-Type", "conversation-background")
|
||||||
const token = evt.request.headers.get("x-api-key")
|
if (evt.agent === Agent.ID.make("compaction"))
|
||||||
if (!token) return
|
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
|
||||||
const text = yield* Effect.promise(() => evt.request.clone().text())
|
const token = evt.request.headers.get("x-api-key")
|
||||||
const body = Option.getOrUndefined(decodeBody(text))
|
if (!token) return
|
||||||
applyHeaders(evt.request.headers, token, ctx.app, requestMetadata(evt.request.url, body), true)
|
const text = yield* Effect.promise(() => evt.request.clone().text())
|
||||||
}),
|
const body = Option.getOrUndefined(decodeBody(text))
|
||||||
|
applyHeaders(evt.request.headers, token, ctx.app, requestMetadata(evt.request.url, body), true)
|
||||||
|
}),
|
||||||
|
{ providerID: Provider.ID.githubCopilot },
|
||||||
)
|
)
|
||||||
yield* ctx.aisdk.hook(
|
yield* ctx.aisdk.hook(
|
||||||
"language",
|
"language",
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const GoogleVertexPlugin = define({
|
export const GoogleVertexPlugin = define({
|
||||||
id: "opencode.provider.google-vertex",
|
id: "opencode.provider.google.vertex",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
yield* ctx.catalog.transform((evt) => {
|
yield* ctx.catalog.transform((evt) => {
|
||||||
for (const item of evt.provider.list()) {
|
for (const item of evt.provider.list()) {
|
||||||
@@ -71,6 +71,9 @@ export const GoogleVertexPlugin = define({
|
|||||||
const project = resolveProject(item.provider.settings ?? {})
|
const project = resolveProject(item.provider.settings ?? {})
|
||||||
const location = String(resolveLocation(item.provider.settings ?? {}))
|
const location = String(resolveLocation(item.provider.settings ?? {}))
|
||||||
evt.provider.update(item.provider.id, (provider) => {
|
evt.provider.update(item.provider.id, (provider) => {
|
||||||
|
// Vertex authenticates through ADC rather than a key credential, so a
|
||||||
|
// resolvable project is what makes the provider usable.
|
||||||
|
if (project && provider.activation === "auto") provider.activation = "enabled"
|
||||||
provider.settings = {
|
provider.settings = {
|
||||||
...provider.settings,
|
...provider.settings,
|
||||||
...(project ? { project } : {}),
|
...(project ? { project } : {}),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Effect } from "effect"
|
|||||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
|
|
||||||
export const OpenAICompatiblePlugin = define({
|
export const OpenAICompatiblePlugin = define({
|
||||||
id: "opencode.provider.openai-compatible",
|
id: "opencode.provider.openai.compatible",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
yield* ctx.aisdk.hook(
|
yield* ctx.aisdk.hook(
|
||||||
"sdk",
|
"sdk",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { App } from "../../app.js"
|
|||||||
import { Credential } from "../../credential.js"
|
import { Credential } from "../../credential.js"
|
||||||
import { Bus } from "../../bus.js"
|
import { Bus } from "../../bus.js"
|
||||||
import { Integration } from "../../integration.js"
|
import { Integration } from "../../integration.js"
|
||||||
import { Model } from "../../model.js"
|
|
||||||
import { OauthCallbackPage } from "../../oauth/page.js"
|
import { OauthCallbackPage } from "../../oauth/page.js"
|
||||||
import { Provider } from "../../provider.js"
|
import { Provider } from "../../provider.js"
|
||||||
import type { PluginInternal } from "../internal.js"
|
import type { PluginInternal } from "../internal.js"
|
||||||
@@ -230,15 +229,17 @@ export const OpenAIPlugin = define({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
yield* ctx.session.hook("http.request", (evt) =>
|
yield* ctx.session.hook(
|
||||||
Effect.sync(() => {
|
"model.request",
|
||||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
(evt) =>
|
||||||
const url = new URL(evt.request.url)
|
Effect.sync(() => {
|
||||||
evt.request.headers.set("originator", "opencode")
|
if (!chatgpt) return
|
||||||
evt.request.headers.set("session-id", evt.sessionID)
|
if (evt.baseURL && URL.canParse(evt.baseURL) && new URL(evt.baseURL).origin === "https://api.openai.com")
|
||||||
if (url.origin !== "https://api.openai.com") return
|
evt.baseURL = codexBaseURL
|
||||||
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
|
evt.headers.originator = "opencode"
|
||||||
}),
|
evt.headers["session-id"] = evt.sessionID
|
||||||
|
}),
|
||||||
|
{ providerID: Provider.ID.openai },
|
||||||
)
|
)
|
||||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Provider } from "../../provider.js"
|
|||||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||||
|
|
||||||
export const SapAICorePlugin = define({
|
export const SapAICorePlugin = define({
|
||||||
id: "opencode.provider.sap-ai-core",
|
id: "opencode.provider.sap.ai.core",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const npm = yield* Npm.Service
|
const npm = yield* Npm.Service
|
||||||
yield* ctx.aisdk.hook(
|
yield* ctx.aisdk.hook(
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function cortexFetch(upstream: FetchLike = fetch) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SnowflakeCortexPlugin = define({
|
export const SnowflakeCortexPlugin = define({
|
||||||
id: "opencode.provider.snowflake-cortex",
|
id: "opencode.provider.snowflake.cortex",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
yield* ctx.aisdk.hook(
|
yield* ctx.aisdk.hook(
|
||||||
"sdk",
|
"sdk",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const layer = Layer.effect(
|
|||||||
return Service.of({
|
return Service.of({
|
||||||
register: (plugin) =>
|
register: (plugin) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
plugins.set(plugin.id, { ...plugin, version: String(++revision) })
|
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
|
||||||
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
|
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
|
||||||
all: () => [...plugins.values()],
|
all: () => [...plugins.values()],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export * as PluginSupervisor from "./supervisor-service.js"
|
||||||
|
|
||||||
|
import { Context, Effect } from "effect"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dependency-only supervisor seam. Keep this module free of implementation
|
||||||
|
* imports: the supervisor reaches PluginRuntime, which depends on Session.
|
||||||
|
*/
|
||||||
|
export interface Interface {
|
||||||
|
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||||
|
readonly flush: Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
export * as PluginSupervisor from "./supervisor.js"
|
export * as PluginSupervisor from "./supervisor.js"
|
||||||
|
export { Service, type Interface } from "./supervisor-service.js"
|
||||||
|
|
||||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { Event } from "@opencode-ai/schema/config"
|
import { Event } from "@opencode-ai/schema/config"
|
||||||
import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
|
import { Cause, Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||||
@@ -14,17 +15,20 @@ import { PluginPromise } from "../plugin/promise.js"
|
|||||||
import { PluginInternal } from "./internal.js"
|
import { PluginInternal } from "./internal.js"
|
||||||
import { SdkPlugins } from "./sdk.js"
|
import { SdkPlugins } from "./sdk.js"
|
||||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||||
|
import { Service } from "./supervisor-service.js"
|
||||||
|
|
||||||
const PluginModule = Schema.Struct({
|
const PluginModule = Schema.Struct({
|
||||||
default: Schema.Union([
|
default: Schema.Union([
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
id: Schema.String,
|
id: Schema.String,
|
||||||
|
tui: Schema.optional(Schema.Boolean),
|
||||||
effect: Schema.declare<PluginDefinition["effect"]>(
|
effect: Schema.declare<PluginDefinition["effect"]>(
|
||||||
(input): input is PluginDefinition["effect"] => typeof input === "function",
|
(input): input is PluginDefinition["effect"] => typeof input === "function",
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
id: Schema.String,
|
id: Schema.String,
|
||||||
|
tui: Schema.optional(Schema.Boolean),
|
||||||
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
|
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
|
||||||
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
|
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
|
||||||
),
|
),
|
||||||
@@ -42,10 +46,12 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
|||||||
const definitions = [...pre, ...post]
|
const definitions = [...pre, ...post]
|
||||||
const enabled = new Set(definitions.map((plugin) => plugin.id))
|
const enabled = new Set(definitions.map((plugin) => plugin.id))
|
||||||
const packages = new Map<string, Plugin.Versioned>()
|
const packages = new Map<string, Plugin.Versioned>()
|
||||||
|
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
|
||||||
const plugins = () => [...definitions, ...packages.values()]
|
const plugins = () => [...definitions, ...packages.values()]
|
||||||
|
|
||||||
for (const operation of operations) {
|
for (const operation of operations) {
|
||||||
if (operation.type === "remove") {
|
if (operation.type === "remove") {
|
||||||
|
if (operation.target === "*") failures.clear()
|
||||||
plugins()
|
plugins()
|
||||||
.filter((plugin) => matches(operation.target, plugin.id))
|
.filter((plugin) => matches(operation.target, plugin.id))
|
||||||
.forEach((plugin) => enabled.delete(plugin.id))
|
.forEach((plugin) => enabled.delete(plugin.id))
|
||||||
@@ -65,21 +71,35 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
|||||||
|
|
||||||
const plugin = yield* load(operation).pipe(
|
const plugin = yield* load(operation).pipe(
|
||||||
Effect.catchCause((cause) =>
|
Effect.catchCause((cause) =>
|
||||||
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(Effect.as(undefined)),
|
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
|
||||||
|
Effect.as({ error: Cause.pretty(cause) }),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (!plugin) continue
|
if ("error" in plugin) {
|
||||||
|
failures.set(operation.target, {
|
||||||
|
source: pluginSource(operation.target),
|
||||||
|
status: "failed",
|
||||||
|
error: plugin.error,
|
||||||
|
tui: false,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
failures.delete(operation.target)
|
||||||
const previous = packages.get(operation.target)
|
const previous = packages.get(operation.target)
|
||||||
if (previous) enabled.delete(previous.id)
|
if (previous) enabled.delete(previous.id)
|
||||||
packages.set(operation.target, plugin)
|
packages.set(operation.target, plugin)
|
||||||
enabled.add(plugin.id)
|
enabled.add(plugin.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return {
|
||||||
...pre.filter((plugin) => enabled.has(plugin.id)),
|
plugins: [
|
||||||
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
|
...pre.filter((plugin) => enabled.has(plugin.id)),
|
||||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
|
||||||
]
|
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||||
|
],
|
||||||
|
failures: [...failures.values()],
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const load = Effect.fn("PluginSupervisor.load")(function* (
|
const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||||
@@ -89,7 +109,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
|
|||||||
const entrypoint = path.isAbsolute(operation.target)
|
const entrypoint = path.isAbsolute(operation.target)
|
||||||
? pathToFileURL(operation.target).href
|
? pathToFileURL(operation.target).href
|
||||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
|
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
|
||||||
if (!entrypoint) return
|
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||||
// Bun currently ignores query parameters when caching file:// imports.
|
// Bun currently ignores query parameters when caching file:// imports.
|
||||||
const source =
|
const source =
|
||||||
operation.mtime === undefined
|
operation.mtime === undefined
|
||||||
@@ -103,18 +123,13 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
|
|||||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||||
return {
|
return {
|
||||||
id: plugin.id,
|
id: plugin.id,
|
||||||
|
tui: plugin.tui,
|
||||||
version: JSON.stringify(operation),
|
version: JSON.stringify(operation),
|
||||||
|
source: pluginSource(operation.target),
|
||||||
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||||
} satisfies Plugin.Versioned
|
} satisfies Plugin.Versioned
|
||||||
})
|
})
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
/** Wait for the initial plugin generation and startup updates to settle. */
|
|
||||||
readonly flush: Effect.Effect<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -129,13 +144,20 @@ export const layer = Layer.effect(
|
|||||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||||
const internal = yield* PluginInternal.list()
|
const internal = yield* PluginInternal.list()
|
||||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||||
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
|
const pre = [
|
||||||
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
|
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
|
||||||
|
...sdk.all(),
|
||||||
|
]
|
||||||
|
const post = internal.post.map((plugin) => ({
|
||||||
|
...plugin,
|
||||||
|
version: "internal",
|
||||||
|
source: { type: "builtin" as const },
|
||||||
|
}))
|
||||||
const operations = yield* sources.operations()
|
const operations = yield* sources.operations()
|
||||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||||
const plugins = yield* resolve(pre, post, operations)
|
const resolved = yield* resolve(pre, post, operations)
|
||||||
// Replace the active generation in one scoped, batched activation.
|
// Replace the active generation in one scoped, batched activation.
|
||||||
yield* registry.activate(plugins)
|
yield* registry.activate(resolved.plugins, resolved.failures)
|
||||||
})
|
})
|
||||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||||
// Make accepted work visible to flush before coalescing the burst.
|
// Make accepted work visible to flush before coalescing the burst.
|
||||||
@@ -172,4 +194,9 @@ const nodeDeps = [
|
|||||||
PluginInternal.requirements,
|
PluginInternal.requirements,
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
function pluginSource(target: string): Plugin.Source {
|
||||||
|
if (path.isAbsolute(target)) return { type: "local", path: target }
|
||||||
|
return { type: "package", package: target }
|
||||||
|
}
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
|
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const Plugins = [OpenAIPlugin, GooglePlugin, AnthropicPlugin, KimiPlugin,
|
|||||||
|
|
||||||
function make(id: string, select: (modelID: string) => string | undefined) {
|
function make(id: string, select: (modelID: string) => string | undefined) {
|
||||||
return define({
|
return define({
|
||||||
id: `opencode.system-prompt.${id}`,
|
id: `opencode.prompt.${id}`,
|
||||||
effect: Effect.fn(`SystemPromptPlugin.${id}`)(function* (ctx) {
|
effect: Effect.fn(`SystemPromptPlugin.${id}`)(function* (ctx) {
|
||||||
yield* ctx.session.hook("context", (event) =>
|
yield* ctx.session.hook("context", (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ const builtins = new Map<string, () => Promise<unknown>>([
|
|||||||
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
|
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
|
||||||
["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")],
|
["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")],
|
||||||
["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")],
|
["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")],
|
||||||
|
["@opencode-ai/ai/providers/google-vertex", () => import("@opencode-ai/ai/providers/google-vertex")],
|
||||||
|
["@opencode-ai/ai/providers/google-vertex/gemini", () => import("@opencode-ai/ai/providers/google-vertex/gemini")],
|
||||||
|
["@opencode-ai/ai/providers/google-vertex/chat", () => import("@opencode-ai/ai/providers/google-vertex/chat")],
|
||||||
|
[
|
||||||
|
"@opencode-ai/ai/providers/google-vertex/responses",
|
||||||
|
() => import("@opencode-ai/ai/providers/google-vertex/responses"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||||
|
() => import("@opencode-ai/ai/providers/google-vertex/messages"),
|
||||||
|
],
|
||||||
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
|
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
|
||||||
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
|
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
|
||||||
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
|
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { SessionRevert } from "./session/revert.js"
|
|||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Image } from "./image.js"
|
import { Image } from "./image.js"
|
||||||
|
import { PluginSupervisor } from "./plugin/supervisor-service.js"
|
||||||
import { Mime } from "./mime.js"
|
import { Mime } from "./mime.js"
|
||||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
@@ -234,26 +235,14 @@ export interface Interface {
|
|||||||
prompt: string
|
prompt: string
|
||||||
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
|
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
|
||||||
readonly command: (input: {
|
readonly command: (input: {
|
||||||
id?: SessionMessage.ID
|
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
command: string
|
command: string
|
||||||
arguments?: string
|
text: string
|
||||||
agent?: Agent.ID
|
|
||||||
model?: Model.Ref
|
|
||||||
files?: PromptInput.Prompt["files"]
|
files?: PromptInput.Prompt["files"]
|
||||||
agents?: PromptInput.Prompt["agents"]
|
agents?: PromptInput.Prompt["agents"]
|
||||||
skills?: PromptInput.Prompt["skills"]
|
skills?: PromptInput.Prompt["skills"]
|
||||||
delivery?: SessionInbox.Delivery
|
delivery?: SessionInbox.Delivery
|
||||||
resume?: boolean
|
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
|
||||||
}) => Effect.Effect<
|
|
||||||
SessionInbox.User,
|
|
||||||
| NotFoundError
|
|
||||||
| PromptConflictError
|
|
||||||
| AttachmentError
|
|
||||||
| SkillNotFoundError
|
|
||||||
| Command.NotFoundError
|
|
||||||
| Command.EvaluationError
|
|
||||||
>
|
|
||||||
readonly shell: (input: {
|
readonly shell: (input: {
|
||||||
id?: Event.ID
|
id?: Event.ID
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
@@ -579,7 +568,11 @@ const layer = Layer.effect(
|
|||||||
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
|
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
|
||||||
// Resolved lazily so prompt admission only boots location services when an
|
// Resolved lazily so prompt admission only boots location services when an
|
||||||
// image attachment actually needs the resizer.
|
// image attachment actually needs the resizer.
|
||||||
const image = Image.Service.pipe(Effect.provide(locations.get(session.location)))
|
const image = Effect.gen(function* () {
|
||||||
|
const plugins = yield* PluginSupervisor.Service
|
||||||
|
yield* plugins.flush
|
||||||
|
return yield* Image.Service
|
||||||
|
}).pipe(Effect.provide(locations.get(session.location)))
|
||||||
const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
|
const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||||
const prompt = yield* resolvePrompt(
|
const prompt = yield* resolvePrompt(
|
||||||
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
|
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
|
||||||
@@ -624,35 +617,19 @@ const layer = Layer.effect(
|
|||||||
command: Effect.fn("Session.command")(function* (input) {
|
command: Effect.fn("Session.command")(function* (input) {
|
||||||
const session = yield* result.get(input.sessionID)
|
const session = yield* result.get(input.sessionID)
|
||||||
const commands = yield* Command.Service.pipe(Effect.provide(locations.get(session.location)))
|
const commands = yield* Command.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||||
const command = yield* commands.get(input.command)
|
const delivery = input.delivery ?? "steer"
|
||||||
if (!command)
|
yield* commands.execute({
|
||||||
return yield* new Command.NotFoundError({
|
name: input.command,
|
||||||
command: input.command,
|
invocation: {
|
||||||
message: `Command not found: ${input.command}`,
|
sessionID: input.sessionID,
|
||||||
})
|
prompt: {
|
||||||
const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments })
|
text: input.text,
|
||||||
|
files: input.files,
|
||||||
// TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands.
|
agents: input.agents,
|
||||||
const agent = command.agent ?? input.agent
|
skills: input.skills,
|
||||||
const commandAgent = yield* Effect.gen(function* () {
|
},
|
||||||
if (!command.agent) return undefined
|
delivery,
|
||||||
const agents = yield* Agent.Service.pipe(Effect.provide(locations.get(session.location)))
|
},
|
||||||
return yield* agents.get(Agent.ID.make(command.agent))
|
|
||||||
})
|
|
||||||
const model = command.model ?? commandAgent?.model ?? input.model
|
|
||||||
if (agent !== undefined && session.agent !== Agent.ID.make(agent))
|
|
||||||
yield* result.switchAgent({ sessionID: input.sessionID, agent: Agent.ID.make(agent) })
|
|
||||||
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
|
|
||||||
|
|
||||||
return yield* result.prompt({
|
|
||||||
id: input.id,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
text: evaluated.text,
|
|
||||||
files: input.files,
|
|
||||||
agents: input.agents,
|
|
||||||
skills: input.skills,
|
|
||||||
delivery: input.delivery,
|
|
||||||
resume: input.resume,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
shell: Effect.fn("Session.shell")(function* (input) {
|
shell: Effect.fn("Session.shell")(function* (input) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { llmClient } from "../effect/app-node-platform.js"
|
|||||||
import { SessionEvent } from "./event.js"
|
import { SessionEvent } from "./event.js"
|
||||||
import type { SessionMessage } from "./message.js"
|
import type { SessionMessage } from "./message.js"
|
||||||
import { SessionModelHeaders } from "./model-headers.js"
|
import { SessionModelHeaders } from "./model-headers.js"
|
||||||
|
import { SessionModelHook } from "./model-hook.js"
|
||||||
import { SessionModelHttp } from "./model-http.js"
|
import { SessionModelHttp } from "./model-http.js"
|
||||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||||
import { App } from "../app.js"
|
import { App } from "../app.js"
|
||||||
@@ -270,23 +271,25 @@ const make = (dependencies: Dependencies) => {
|
|||||||
})
|
})
|
||||||
: Effect.void,
|
: Effect.void,
|
||||||
)
|
)
|
||||||
|
const request = yield* SessionModelHook.apply(
|
||||||
|
dependencies.hooks,
|
||||||
|
{ sessionID: plan.session.id, agent: Agent.ID.make("compaction"), model: plan.ref },
|
||||||
|
LLM.request({
|
||||||
|
model: plan.model,
|
||||||
|
promptCacheKey: SessionPromptCacheKey.make(plan.session.id),
|
||||||
|
http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
|
||||||
|
messages: [Message.user(plan.prompt)],
|
||||||
|
tools: [],
|
||||||
|
}),
|
||||||
|
)
|
||||||
yield* dependencies.llm
|
yield* dependencies.llm
|
||||||
.stream(
|
.stream(request, {
|
||||||
LLM.request({
|
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||||
model: plan.model,
|
sessionID: plan.session.id,
|
||||||
promptCacheKey: SessionPromptCacheKey.make(plan.session.id),
|
agent: Agent.ID.make("compaction"),
|
||||||
http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
|
model: plan.ref,
|
||||||
messages: [Message.user(plan.prompt)],
|
|
||||||
tools: [],
|
|
||||||
}),
|
}),
|
||||||
{
|
})
|
||||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
|
||||||
sessionID: plan.session.id,
|
|
||||||
agent: Agent.ID.make("compaction"),
|
|
||||||
model: plan.ref,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.pipe(
|
.pipe(
|
||||||
Stream.runForEach((event) => {
|
Stream.runForEach((event) => {
|
||||||
if (LLMEvent.is.providerError(event))
|
if (LLMEvent.is.providerError(event))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { SessionContext } from "./context.js"
|
|||||||
import { SessionGenerate } from "./generate.js"
|
import { SessionGenerate } from "./generate.js"
|
||||||
import { SessionHistory } from "./history.js"
|
import { SessionHistory } from "./history.js"
|
||||||
import { SessionModelHeaders } from "./model-headers.js"
|
import { SessionModelHeaders } from "./model-headers.js"
|
||||||
|
import { SessionModelHook } from "./model-hook.js"
|
||||||
import { SessionModelHttp } from "./model-http.js"
|
import { SessionModelHttp } from "./model-http.js"
|
||||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||||
import { SessionRunnerModel } from "./runner/model.js"
|
import { SessionRunnerModel } from "./runner/model.js"
|
||||||
@@ -71,7 +72,9 @@ export const layer = Layer.effect(
|
|||||||
providerID: model.ref.providerID,
|
providerID: model.ref.providerID,
|
||||||
modelID: model.ref.id,
|
modelID: model.ref.id,
|
||||||
})
|
})
|
||||||
const response = yield* llm.generate(
|
const request = yield* SessionModelHook.apply(
|
||||||
|
hooks,
|
||||||
|
{ sessionID: selection.session.id, agent: selection.agent.id, model: model.ref },
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model: model.model,
|
model: model.model,
|
||||||
http: { headers: SessionModelHeaders.make(selection.session, app) },
|
http: { headers: SessionModelHeaders.make(selection.session, app) },
|
||||||
@@ -80,14 +83,14 @@ export const layer = Layer.effect(
|
|||||||
messages: contextEvent.messages,
|
messages: contextEvent.messages,
|
||||||
tools: hookedTools,
|
tools: hookedTools,
|
||||||
}),
|
}),
|
||||||
{
|
|
||||||
http: SessionModelHttp.middleware(hooks, {
|
|
||||||
sessionID: selection.session.id,
|
|
||||||
agent: selection.agent.id,
|
|
||||||
model: model.ref,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
const response = yield* llm.generate(request, {
|
||||||
|
http: SessionModelHttp.middleware(hooks, {
|
||||||
|
sessionID: selection.session.id,
|
||||||
|
agent: selection.agent.id,
|
||||||
|
model: model.ref,
|
||||||
|
}),
|
||||||
|
})
|
||||||
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
|
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
|
||||||
return response.text
|
return response.text
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -180,13 +180,27 @@ export const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(functio
|
|||||||
bus: Bus.Interface,
|
bus: Bus.Interface,
|
||||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID; readonly delivery: Delivery },
|
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID; readonly delivery: Delivery },
|
||||||
) {
|
) {
|
||||||
const admitted = yield* admit(db, bus, {
|
return yield* serialized(
|
||||||
id: input.id,
|
input.sessionID,
|
||||||
sessionID: input.sessionID,
|
Effect.gen(function* () {
|
||||||
item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
|
const exact = yield* find(db, input.id)
|
||||||
})
|
if (exact) {
|
||||||
if (admitted.type === "compaction") return admitted
|
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
|
||||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
|
}
|
||||||
|
if (yield* promotedFromMessage(db, input.sessionID, input.id, input.delivery))
|
||||||
|
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
|
const pending = (yield* list(db, input.sessionID)).find((item) => item.type === "compaction")
|
||||||
|
if (pending) return pending
|
||||||
|
const admitted = yield* admit(db, bus, {
|
||||||
|
id: input.id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
|
||||||
|
})
|
||||||
|
if (admitted.type === "compaction") return admitted
|
||||||
|
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(function* (
|
export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(function* (
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export * as SessionModelHook from "./model-hook.js"
|
||||||
|
|
||||||
|
import { HttpOptions, LanguageModel, LLMRequest } from "@opencode-ai/ai"
|
||||||
|
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 { Effect } from "effect"
|
||||||
|
import { PluginHooks } from "../plugin/hooks.js"
|
||||||
|
|
||||||
|
export const apply = (
|
||||||
|
hooks: PluginHooks.Interface,
|
||||||
|
input: { readonly sessionID: Session.ID; readonly agent: Agent.ID; readonly model: Model.Ref },
|
||||||
|
request: LLMRequest,
|
||||||
|
) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const currentBaseURL = request.model.route.endpoint.baseURL
|
||||||
|
const event = yield* hooks.trigger("session", "model.request", {
|
||||||
|
...input,
|
||||||
|
baseURL: typeof currentBaseURL === "string" ? currentBaseURL : undefined,
|
||||||
|
headers: { ...request.http?.headers },
|
||||||
|
})
|
||||||
|
const route =
|
||||||
|
event.baseURL !== undefined && event.baseURL !== currentBaseURL
|
||||||
|
? request.model.route.with({ endpoint: { baseURL: event.baseURL } })
|
||||||
|
: request.model.route
|
||||||
|
return LLMRequest.update(request, {
|
||||||
|
model: route === request.model.route ? request.model : LanguageModel.update(request.model, { route }),
|
||||||
|
http: new HttpOptions({
|
||||||
|
body: request.http?.body,
|
||||||
|
headers: Object.keys(event.headers).length === 0 ? undefined : event.headers,
|
||||||
|
query: request.http?.query,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,6 +14,7 @@ import { QuestionTool } from "../tool/plugin/question.js"
|
|||||||
import { Tool } from "../tool.js"
|
import { Tool } from "../tool.js"
|
||||||
import { SessionContext } from "./context.js"
|
import { SessionContext } from "./context.js"
|
||||||
import { SessionModelHeaders } from "./model-headers.js"
|
import { SessionModelHeaders } from "./model-headers.js"
|
||||||
|
import { SessionModelHook } from "./model-hook.js"
|
||||||
import { SessionModelHttp } from "./model-http.js"
|
import { SessionModelHttp } from "./model-http.js"
|
||||||
import { SessionModelTransport } from "./model-transport.js"
|
import { SessionModelTransport } from "./model-transport.js"
|
||||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||||
@@ -226,19 +227,25 @@ export const layer = Layer.effect(
|
|||||||
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
|
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const request = LLM.request({
|
const request = yield* SessionModelHook.apply(
|
||||||
model,
|
hooks,
|
||||||
http: {
|
{ sessionID: session.id, agent: agent.id, model: resolved.ref },
|
||||||
headers: SessionModelHeaders.make(session, app),
|
LLM.request({
|
||||||
},
|
model,
|
||||||
promptCacheKey: SessionPromptCacheKey.make(session.id),
|
http: {
|
||||||
system: context.system,
|
headers: SessionModelHeaders.make(session, app),
|
||||||
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
},
|
||||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
|
||||||
toolChoice: stepLimitReached ? "none" : undefined,
|
promptCacheKey: SessionPromptCacheKey.make(session.fork?.sessionID ?? session.id),
|
||||||
})
|
system: context.system,
|
||||||
|
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
||||||
|
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||||
|
toolChoice: stepLimitReached ? "none" : undefined,
|
||||||
|
}),
|
||||||
|
)
|
||||||
const webSocketEligible =
|
const webSocketEligible =
|
||||||
!(yield* hooks.has("session", "http.request")) && !(yield* hooks.has("session", "http.response"))
|
!(yield* hooks.has("session", "http.request", resolved.ref.providerID)) &&
|
||||||
|
!(yield* hooks.has("session", "http.response", resolved.ref.providerID))
|
||||||
const http = webSocketEligible
|
const http = webSocketEligible
|
||||||
? undefined
|
? undefined
|
||||||
: SessionModelHttp.middleware(hooks, {
|
: SessionModelHttp.middleware(hooks, {
|
||||||
@@ -251,7 +258,7 @@ export const layer = Layer.effect(
|
|||||||
...(webSocket &&
|
...(webSocket &&
|
||||||
webSocketEligible &&
|
webSocketEligible &&
|
||||||
resolved.ref.providerID === Provider.ID.openai &&
|
resolved.ref.providerID === Provider.ID.openai &&
|
||||||
model.route.id === "openai-responses"
|
request.model.route.id === "openai-responses"
|
||||||
? { webSocket: transport.bind(session.id) }
|
? { webSocket: transport.bind(session.id) }
|
||||||
: {}),
|
: {}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const layer = Layer.effect(
|
|||||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
get: Effect.fn("SessionStore.get")(function* (sessionID) {
|
get: Effect.fnUntraced(function* (sessionID) {
|
||||||
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
|
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
|
||||||
return row ? fromRow(row) : undefined
|
return row ? fromRow(row) : undefined
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { PluginHooks } from "../plugin/hooks.js"
|
|||||||
import { SessionEvent } from "./event.js"
|
import { SessionEvent } from "./event.js"
|
||||||
import { SessionHistory } from "./history.js"
|
import { SessionHistory } from "./history.js"
|
||||||
import { SessionModelHeaders } from "./model-headers.js"
|
import { SessionModelHeaders } from "./model-headers.js"
|
||||||
|
import { SessionModelHook } from "./model-hook.js"
|
||||||
import { SessionModelHttp } from "./model-http.js"
|
import { SessionModelHttp } from "./model-http.js"
|
||||||
import { SessionRunnerModel } from "./runner/model.js"
|
import { SessionRunnerModel } from "./runner/model.js"
|
||||||
import { SessionSchema } from "./schema.js"
|
import { SessionSchema } from "./schema.js"
|
||||||
@@ -80,23 +81,25 @@ const make = (dependencies: Dependencies) => {
|
|||||||
})
|
})
|
||||||
: Effect.void,
|
: Effect.void,
|
||||||
)
|
)
|
||||||
|
const request = yield* SessionModelHook.apply(
|
||||||
|
dependencies.hooks,
|
||||||
|
{ sessionID: session.id, agent: agent.id, model: resolved.ref },
|
||||||
|
LLM.request({
|
||||||
|
model: resolved.model,
|
||||||
|
http: { headers: SessionModelHeaders.make(session, dependencies.app) },
|
||||||
|
system: agent.system,
|
||||||
|
messages: [Message.user(firstUser.text)],
|
||||||
|
tools: [],
|
||||||
|
}),
|
||||||
|
)
|
||||||
const streamed = yield* dependencies.llm
|
const streamed = yield* dependencies.llm
|
||||||
.stream(
|
.stream(request, {
|
||||||
LLM.request({
|
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||||
model: resolved.model,
|
sessionID: session.id,
|
||||||
http: { headers: SessionModelHeaders.make(session, dependencies.app) },
|
agent: agent.id,
|
||||||
system: agent.system,
|
model: resolved.ref,
|
||||||
messages: [Message.user(firstUser.text)],
|
|
||||||
tools: [],
|
|
||||||
}),
|
}),
|
||||||
{
|
})
|
||||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
|
||||||
sessionID: session.id,
|
|
||||||
agent: agent.id,
|
|
||||||
model: resolved.ref,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.pipe(
|
.pipe(
|
||||||
Stream.runForEach((event) => {
|
Stream.runForEach((event) => {
|
||||||
if (LLMEvent.is.providerError(event)) failed = true
|
if (LLMEvent.is.providerError(event)) failed = true
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { Money } from "@opencode-ai/schema/money"
|
|||||||
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
|
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
|
||||||
import type { Model } from "../model.js"
|
import type { Model } from "../model.js"
|
||||||
|
|
||||||
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
|
const finite = (value: number) => (Number.isFinite(value) ? value : 0)
|
||||||
|
const safe = (value: number | undefined) => Math.max(0, finite(value ?? 0))
|
||||||
|
|
||||||
export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
|
export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
|
||||||
input: safe(usage?.nonCachedInputTokens),
|
input: safe(usage?.nonCachedInputTokens),
|
||||||
@@ -26,10 +27,10 @@ export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info)
|
|||||||
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
|
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
|
||||||
if (!cost) return Money.USD.zero
|
if (!cost) return Money.USD.zero
|
||||||
return Money.USD.make(
|
return Money.USD.make(
|
||||||
(usage.input * cost.input +
|
(usage.input * finite(cost.input) +
|
||||||
(usage.output + usage.reasoning) * cost.output +
|
(usage.output + usage.reasoning) * finite(cost.output) +
|
||||||
usage.cache.read * cost.cache.read +
|
usage.cache.read * finite(cost.cache.read) +
|
||||||
usage.cache.write * cost.cache.write) /
|
usage.cache.write * finite(cost.cache.write)) /
|
||||||
1_000_000,
|
1_000_000,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
|
const require = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||||
const session = sessions.get(id)
|
const session = sessions.get(id)
|
||||||
if (!session) return yield* new NotFoundError({ id })
|
if (!session) return yield* new NotFoundError({ id })
|
||||||
return session
|
return session
|
||||||
@@ -153,7 +153,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
|||||||
|
|
||||||
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
||||||
|
|
||||||
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||||
const session = yield* require(id)
|
const session = yield* require(id)
|
||||||
const cursor = input?.cursor ?? 0
|
const cursor = input?.cursor ?? 0
|
||||||
const limit = input?.limit ?? 65536
|
const limit = input?.limit ?? 65536
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ const ARITY: Record<string, number> = {
|
|||||||
"yarn run": 3,
|
"yarn run": 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const scan = Effect.fn("ShellParse.scan")(function* (
|
export const scan = Effect.fnUntraced(function* (
|
||||||
command: string,
|
command: string,
|
||||||
shell: string,
|
shell: string,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
@@ -163,7 +163,7 @@ export const scan = Effect.fn("ShellParse.scan")(function* (
|
|||||||
return yield* scanLegacy(command, shell, cwd)
|
return yield* scanLegacy(command, shell, cwd)
|
||||||
})
|
})
|
||||||
|
|
||||||
const scanLegacy = Effect.fn("ShellParse.scanLegacy")(function* (command: string, shell: string, cwd: string) {
|
const scanLegacy = Effect.fnUntraced(function* (command: string, shell: string, cwd: string) {
|
||||||
const parsers = yield* Effect.promise(load)
|
const parsers = yield* Effect.promise(load)
|
||||||
const powershell = ShellSelect.ps(shell)
|
const powershell = ShellSelect.ps(shell)
|
||||||
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
|
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
|
||||||
|
|||||||
@@ -97,10 +97,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
|||||||
const materialize = Effect.fnUntraced(function* () {
|
const materialize = Effect.fnUntraced(function* () {
|
||||||
const next = options.initial()
|
const next = options.initial()
|
||||||
const api = options.draft(next)
|
const api = options.draft(next)
|
||||||
for (const transform of transforms)
|
for (const transform of transforms) yield* apply(transform.run, api)
|
||||||
yield* apply(transform.run, api).pipe(
|
|
||||||
Effect.withSpan("State.reload.update", { attributes: { state: options.name ?? "anonymous" } }),
|
|
||||||
)
|
|
||||||
yield* commit(next)
|
yield* commit(next)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const layer = Layer.effect(
|
|||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const directory = path.join(global.data, DIRECTORY)
|
const directory = path.join(global.data, DIRECTORY)
|
||||||
|
|
||||||
const truncate = Effect.fn("ToolOutput.truncate")(function* (result: Result) {
|
const truncate = Effect.fnUntraced(function* (result: Result) {
|
||||||
if (result.metadata?.truncated !== undefined) return result
|
if (result.metadata?.truncated !== undefined) return result
|
||||||
const content =
|
const content =
|
||||||
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
|
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const layer = Layer.effect(
|
|||||||
const image = yield* Image.Service
|
const image = yield* Image.Service
|
||||||
|
|
||||||
type NormalizedItem = Tool.Content | "decode" | "size"
|
type NormalizedItem = Tool.Content | "decode" | "size"
|
||||||
const normalizeImages = Effect.fn("Tool.normalizeImages")(function* (content: ReadonlyArray<Tool.Content>) {
|
const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray<Tool.Content>) {
|
||||||
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
|
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
|
||||||
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
|
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
|
||||||
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
|
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export const Plugin = {
|
|||||||
)
|
)
|
||||||
yield* context.progress({ shellID: info.id })
|
yield* context.progress({ shellID: info.id })
|
||||||
|
|
||||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
const captureShell = Effect.fnUntraced(function* () {
|
||||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||||
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
|
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
|
||||||
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
|
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
|
||||||
@@ -228,7 +228,7 @@ export const Plugin = {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
const settleShell = Effect.fnUntraced(function* () {
|
||||||
const final = yield* shell.wait(info.id)
|
const final = yield* shell.wait(info.id)
|
||||||
const capture = yield* captureShell()
|
const capture = yield* captureShell()
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
|||||||
import { ToolFailure } from "@opencode-ai/ai"
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import { Effect, Schema, Semaphore } from "effect"
|
import { Effect, Schema, Semaphore } from "effect"
|
||||||
import { HttpClientError } from "effect/unstable/http"
|
import { HttpClientError } from "effect/unstable/http"
|
||||||
import { Config } from "../../config.js"
|
|
||||||
import { Form } from "../../form.js"
|
import { Form } from "../../form.js"
|
||||||
import { Permission } from "../../permission.js"
|
import { Permission } from "../../permission.js"
|
||||||
import { WebSearch } from "../../websearch.js"
|
import { WebSearch } from "../../websearch.js"
|
||||||
@@ -30,7 +29,6 @@ export const Plugin = {
|
|||||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
const forms = yield* Form.Service
|
const forms = yield* Form.Service
|
||||||
const config = yield* Config.Service
|
|
||||||
const websearch = yield* WebSearch.Service
|
const websearch = yield* WebSearch.Service
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
@@ -97,9 +95,7 @@ export const Plugin = {
|
|||||||
if (response.status === "cancelled")
|
if (response.status === "cancelled")
|
||||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||||
if (response.answer.choice === "disable") {
|
if (response.answer.choice === "disable") {
|
||||||
yield* config.update((draft) => {
|
yield* websearch.select(false)
|
||||||
draft.websearch = false
|
|
||||||
})
|
|
||||||
return yield* new WebSearch.DisabledError()
|
return yield* new WebSearch.DisabledError()
|
||||||
}
|
}
|
||||||
const selection =
|
const selection =
|
||||||
@@ -131,11 +127,7 @@ export const Plugin = {
|
|||||||
(providerID !== "random" && !providers.some((provider) => provider.id === providerID))
|
(providerID !== "random" && !providers.some((provider) => provider.id === providerID))
|
||||||
)
|
)
|
||||||
return yield* new WebSearch.ProviderRequiredError()
|
return yield* new WebSearch.ProviderRequiredError()
|
||||||
yield* config.update((draft) => {
|
yield* websearch.select(providerID === "random" ? "random" : WebSearch.ID.make(providerID))
|
||||||
draft.websearch = {
|
|
||||||
provider: providerID === "random" ? "random" : WebSearch.ID.make(providerID),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (providerID !== "random") return WebSearch.ID.make(providerID)
|
if (providerID !== "random") return WebSearch.ID.make(providerID)
|
||||||
return providers[Math.floor(Math.random() * providers.length)]?.id
|
return providers[Math.floor(Math.random() * providers.length)]?.id
|
||||||
}),
|
}),
|
||||||
@@ -206,7 +198,10 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.session.hook("context", (event) =>
|
yield* ctx.session.hook("context", (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const disabled = Config.latest(yield* config.entries(), "websearch") === false
|
const disabled = yield* websearch.default().pipe(
|
||||||
|
Effect.as(false),
|
||||||
|
Effect.catchTag("WebSearch.Disabled", () => Effect.succeed(true)),
|
||||||
|
)
|
||||||
if (disabled) delete event.tools[name]
|
if (disabled) delete event.tools[name]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
export * as WebSearch from "./websearch.js"
|
export * as WebSearch from "./websearch.js"
|
||||||
|
|
||||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Bus } from "./bus.js"
|
import { Bus } from "./bus.js"
|
||||||
|
import { KV } from "./kv.js"
|
||||||
import { State } from "./state.js"
|
import { State } from "./state.js"
|
||||||
|
|
||||||
export const ID = WebSearch.ID
|
export const ID = WebSearch.ID
|
||||||
@@ -24,6 +25,10 @@ export type Result = WebSearch.Result
|
|||||||
export const Response = WebSearch.Response
|
export const Response = WebSearch.Response
|
||||||
export type Response = WebSearch.Response
|
export type Response = WebSearch.Response
|
||||||
|
|
||||||
|
export const ProviderKey = "websearch:provider"
|
||||||
|
export const Selection = Schema.Union([ID, Schema.Literal("random"), Schema.Literal(false)])
|
||||||
|
export type Selection = typeof Selection.Type
|
||||||
|
|
||||||
export interface ProviderImplementation extends Provider {
|
export interface ProviderImplementation extends Provider {
|
||||||
readonly execute: (input: ProviderInput) => Effect.Effect<readonly Result[], unknown>
|
readonly execute: (input: ProviderInput) => Effect.Effect<readonly Result[], unknown>
|
||||||
}
|
}
|
||||||
@@ -49,6 +54,7 @@ export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledErro
|
|||||||
export interface Interface extends State.Transformable<Draft> {
|
export interface Interface extends State.Transformable<Draft> {
|
||||||
readonly providers: () => Effect.Effect<readonly Provider[]>
|
readonly providers: () => Effect.Effect<readonly Provider[]>
|
||||||
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
|
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
|
||||||
|
readonly select: (selection: Selection) => Effect.Effect<void>
|
||||||
readonly query: (input: Input) => Effect.Effect<Response, Error>
|
readonly query: (input: Input) => Effect.Effect<Response, Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,14 +62,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/We
|
|||||||
|
|
||||||
type Data = {
|
type Data = {
|
||||||
readonly providers: Map<ID, ProviderImplementation>
|
readonly providers: Map<ID, ProviderImplementation>
|
||||||
selection?: ID | "random" | false
|
selection?: Selection
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Draft = {
|
export type Draft = {
|
||||||
add: (provider: ProviderImplementation) => void
|
add: (provider: ProviderImplementation) => void
|
||||||
default: {
|
default: {
|
||||||
get: () => ID | "random" | false | undefined
|
get: () => Selection | undefined
|
||||||
set: (selection: ID | "random" | false) => void
|
set: (selection: Selection) => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +77,7 @@ const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
|
const kv = yield* KV.Service
|
||||||
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
|
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
|
||||||
const state = State.create<Data, Draft>({
|
const state = State.create<Data, Draft>({
|
||||||
initial: () => ({ providers: new Map() }),
|
initial: () => ({ providers: new Map() }),
|
||||||
@@ -91,12 +98,16 @@ const layer = Layer.effect(
|
|||||||
|
|
||||||
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
|
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
|
||||||
const data = state.get()
|
const data = state.get()
|
||||||
if (data.selection === false) return yield* new DisabledError()
|
const stored = data.selection === undefined ? yield* kv.get(ProviderKey) : undefined
|
||||||
if (data.selection === "random") {
|
const decoded = Schema.decodeUnknownOption(Selection)(stored)
|
||||||
|
if (stored !== undefined && Option.isNone(decoded)) yield* kv.remove(ProviderKey)
|
||||||
|
const selection = data.selection ?? Option.getOrUndefined(decoded)
|
||||||
|
if (selection === false) return yield* new DisabledError()
|
||||||
|
if (selection === "random") {
|
||||||
const providers = Array.from(data.providers.values())
|
const providers = Array.from(data.providers.values())
|
||||||
return providers[Math.floor(Math.random() * providers.length)]
|
return providers[Math.floor(Math.random() * providers.length)]
|
||||||
}
|
}
|
||||||
return data.selection ? data.providers.get(data.selection) : undefined
|
return selection ? data.providers.get(selection) : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
|
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
|
||||||
@@ -120,6 +131,9 @@ const layer = Layer.effect(
|
|||||||
const provider = yield* defaultProvider()
|
const provider = yield* defaultProvider()
|
||||||
return provider && { id: provider.id, name: provider.name }
|
return provider && { id: provider.id, name: provider.name }
|
||||||
}),
|
}),
|
||||||
|
select: Effect.fn("WebSearch.select")(function* (selection) {
|
||||||
|
yield* kv.set(ProviderKey, selection)
|
||||||
|
}),
|
||||||
query: Effect.fn("WebSearch.query")(function* (input) {
|
query: Effect.fn("WebSearch.query")(function* (input) {
|
||||||
const provider = yield* resolve(input)
|
const provider = yield* resolve(input)
|
||||||
const results = yield* provider.execute({ query: input.query }).pipe(
|
const results = yield* provider.execute({ query: input.query }).pipe(
|
||||||
@@ -135,5 +149,5 @@ const layer = Layer.effect(
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [Bus.node],
|
deps: [Bus.node, KV.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,79 +1,71 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
|
||||||
import { Command } from "@opencode-ai/core/command"
|
import { Command } from "@opencode-ai/core/command"
|
||||||
import { Config } from "@opencode-ai/core/config"
|
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
import { Effect } from "effect"
|
||||||
import { Model } from "@opencode-ai/core/model"
|
|
||||||
import { Provider } from "@opencode-ai/core/provider"
|
|
||||||
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
|
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const it = testEffect(
|
const it = testEffect(AppNodeBuilder.build(Command.node))
|
||||||
AppNodeBuilder.build(Command.node, [
|
|
||||||
[MCP.node, emptyMcpLayer],
|
|
||||||
[Config.node, emptyConfigLayer],
|
|
||||||
[Location.node, testLocationLayer],
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
describe("Command", () => {
|
describe("Command", () => {
|
||||||
it.effect("applies command transforms and preserves later overrides", () =>
|
it.effect("registers and executes callback commands", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const command = yield* Command.Service
|
const command = yield* Command.Service
|
||||||
yield* command.transform((editor) => {
|
const calls: Command.Invocation[] = []
|
||||||
editor.update("review", (command) => {
|
yield* command.transform((draft) => {
|
||||||
command.template = "First"
|
draft.add({
|
||||||
command.description = "Review code"
|
name: "goal",
|
||||||
})
|
description: "Manage the session goal",
|
||||||
editor.update("review", (command) => {
|
execute: (input) => Effect.sync(() => calls.push(input)),
|
||||||
command.template = "Second"
|
|
||||||
command.model = {
|
|
||||||
id: Model.ID.make("claude"),
|
|
||||||
providerID: Provider.ID.make("anthropic"),
|
|
||||||
variant: Model.VariantID.make("high"),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(yield* command.get("review")).toEqual(
|
expect(yield* command.get("goal")).toEqual(
|
||||||
Command.Info.make({
|
Command.Info.make({ name: "goal", description: "Manage the session goal" }),
|
||||||
name: "review",
|
|
||||||
template: "Second",
|
|
||||||
description: "Review code",
|
|
||||||
model: {
|
|
||||||
id: Model.ID.make("claude"),
|
|
||||||
providerID: Provider.ID.make("anthropic"),
|
|
||||||
variant: Model.VariantID.make("high"),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
expect(yield* command.list()).toEqual([
|
const invocation = {
|
||||||
Command.Info.make({
|
sessionID: Session.ID.make("ses_test"),
|
||||||
name: "review",
|
prompt: { text: "ship it", files: [{ uri: "file:///tmp/plan.md" }] },
|
||||||
template: "Second",
|
delivery: "steer" as const,
|
||||||
description: "Review code",
|
}
|
||||||
model: {
|
yield* command.execute({ name: "goal", invocation })
|
||||||
id: Model.ID.make("claude"),
|
expect(calls).toEqual([invocation])
|
||||||
providerID: Provider.ID.make("anthropic"),
|
|
||||||
variant: Model.VariantID.make("high"),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("evaluates command template shell blocks", () =>
|
it.effect("replaces commands with later definitions", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const command = yield* Command.Service
|
const command = yield* Command.Service
|
||||||
yield* command.transform((editor) => {
|
yield* command.transform((draft) => {
|
||||||
editor.update("review", (command) => {
|
draft.add({ name: "goal", description: "First", execute: () => Effect.void })
|
||||||
command.template = "Output: !`echo command-output`"
|
draft.add({ name: "goal", description: "Second", execute: () => Effect.void })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(yield* command.list()).toEqual([Command.Info.make({ name: "goal", description: "Second" })])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("returns callback error messages without stack traces", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const command = yield* Command.Service
|
||||||
|
yield* command.transform((draft) => {
|
||||||
|
draft.add({
|
||||||
|
name: "fail",
|
||||||
|
execute: () => Effect.fail(new Error("command failed")),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
expect((yield* command.evaluate({ name: "review" })).text.replace(/\r?\n$/, "")).toEqual("Output: command-output")
|
const error = yield* command
|
||||||
|
.execute({
|
||||||
|
name: "fail",
|
||||||
|
invocation: {
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
prompt: { text: "" },
|
||||||
|
delivery: "steer",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.pipe(Effect.flip)
|
||||||
|
expect(error).toMatchObject({ _tag: "Command.ExecutionError", message: "command failed" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
import { DateTime, Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||||
import { advance, drain } from "../lib/clock"
|
import { advance, drain } from "../lib/clock"
|
||||||
import { Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
import { Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||||
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
|
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||||
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { Command } from "@opencode-ai/core/command"
|
import { Command } from "@opencode-ai/core/command"
|
||||||
import { Agent } from "@opencode-ai/core/agent"
|
|
||||||
import { Config } from "@opencode-ai/core/config"
|
import { Config } from "@opencode-ai/core/config"
|
||||||
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
@@ -15,11 +17,11 @@ import { Bus } from "@opencode-ai/core/bus"
|
|||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { WellKnown } from "@opencode-ai/core/wellknown"
|
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||||
import { Model } from "@opencode-ai/core/model"
|
|
||||||
import { Provider } from "@opencode-ai/core/provider"
|
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { Shell } from "@opencode-ai/core/shell"
|
||||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||||
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
|
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
|
||||||
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
|
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
|
||||||
@@ -28,12 +30,30 @@ import { tmpdir } from "../fixture/tmpdir"
|
|||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { host } from "../plugin/host"
|
import { host } from "../plugin/host"
|
||||||
|
|
||||||
|
const shellLayer = Layer.succeed(
|
||||||
|
Shell.Service,
|
||||||
|
Shell.Service.of({
|
||||||
|
name: () => Effect.succeed("sh"),
|
||||||
|
create: () => Effect.die("unused shell.create"),
|
||||||
|
list: () => Effect.die("unused shell.list"),
|
||||||
|
get: () => Effect.die("unused shell.get"),
|
||||||
|
wait: () => Effect.die("unused shell.wait"),
|
||||||
|
timeout: () => Effect.die("unused shell.timeout"),
|
||||||
|
output: () => Effect.die("unused shell.output"),
|
||||||
|
remove: () => Effect.die("unused shell.remove"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
AppNodeBuilder.build(LayerNode.group([Command.node, Bus.node, FSUtil.node]), [
|
AppNodeBuilder.build(
|
||||||
[MCP.node, emptyMcpLayer],
|
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, Shell.node]),
|
||||||
[Config.node, emptyConfigLayer],
|
[
|
||||||
[Location.node, testLocationLayer],
|
[MCP.node, emptyMcpLayer],
|
||||||
]),
|
[Config.node, emptyConfigLayer],
|
||||||
|
[Location.node, testLocationLayer],
|
||||||
|
[Shell.node, shellLayer],
|
||||||
|
],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
const decode = Schema.decodeUnknownSync(Info)
|
const decode = Schema.decodeUnknownSync(Info)
|
||||||
|
|
||||||
@@ -65,6 +85,7 @@ Review files`,
|
|||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const update = yield* bus.publish(Event.Updated, {})
|
const update = yield* bus.publish(Event.Updated, {})
|
||||||
const updates = yield* PubSub.unbounded<typeof update>()
|
const updates = yield* PubSub.unbounded<typeof update>()
|
||||||
|
const prompts: { text: string; files?: readonly { readonly uri: string }[]; delivery?: string }[] = []
|
||||||
yield* ConfigCommandPlugin.Plugin.effect(
|
yield* ConfigCommandPlugin.Plugin.effect(
|
||||||
host({
|
host({
|
||||||
command: {
|
command: {
|
||||||
@@ -73,6 +94,20 @@ Review files`,
|
|||||||
reload: command.reload,
|
reload: command.reload,
|
||||||
},
|
},
|
||||||
event: { subscribe: () => Stream.fromPubSub(updates) },
|
event: { subscribe: () => Stream.fromPubSub(updates) },
|
||||||
|
session: {
|
||||||
|
prompt: (input) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
|
||||||
|
return SessionInbox.User.make({
|
||||||
|
id: SessionMessage.ID.make("msg_test"),
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
timeCreated: DateTime.makeUnsafe(0),
|
||||||
|
type: "user",
|
||||||
|
payload: { text: input.text },
|
||||||
|
delivery: input.delivery ?? "steer",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
@@ -89,28 +124,46 @@ Review files`,
|
|||||||
expect(yield* command.list()).toEqual([
|
expect(yield* command.list()).toEqual([
|
||||||
Command.Info.make({
|
Command.Info.make({
|
||||||
name: "review",
|
name: "review",
|
||||||
template: "Review files",
|
|
||||||
description: "File review",
|
description: "File review",
|
||||||
agent: Agent.ID.make("reviewer"),
|
|
||||||
model: {
|
|
||||||
providerID: Provider.ID.make("anthropic"),
|
|
||||||
id: Model.ID.make("claude"),
|
|
||||||
variant: Model.VariantID.make("high"),
|
|
||||||
},
|
|
||||||
subtask: true,
|
|
||||||
}),
|
}),
|
||||||
Command.Info.make({ name: "empty", template: "" }),
|
Command.Info.make({ name: "empty" }),
|
||||||
Command.Info.make({ name: "nested/docs", template: "Write docs" }),
|
Command.Info.make({ name: "nested/docs" }),
|
||||||
|
])
|
||||||
|
yield* command.execute({
|
||||||
|
name: "nested/docs",
|
||||||
|
invocation: {
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
prompt: { text: "details", files: [{ uri: "file:///tmp/context.md" }] },
|
||||||
|
delivery: "queue",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(prompts).toEqual([
|
||||||
|
{
|
||||||
|
text: "Write docs\n\ndetails",
|
||||||
|
files: [{ uri: "file:///tmp/context.md" }],
|
||||||
|
delivery: "queue",
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again"))
|
yield* Effect.promise(() =>
|
||||||
|
fs.writeFile(path.join(tmp.path, "commands", "review.md"), markdown("Review again", "Review again")),
|
||||||
|
)
|
||||||
yield* Effect.sleep("10 millis")
|
yield* Effect.sleep("10 millis")
|
||||||
yield* PubSub.publish(updates, update)
|
yield* PubSub.publish(updates, update)
|
||||||
for (let attempt = 0; attempt < 100; attempt++) {
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
if ((yield* command.get("review"))?.template === "Review again") break
|
if ((yield* command.get("review"))?.description === "Review again") break
|
||||||
yield* Effect.sleep("10 millis")
|
yield* Effect.sleep("10 millis")
|
||||||
}
|
}
|
||||||
expect((yield* command.get("review"))?.template).toBe("Review again")
|
expect((yield* command.get("review"))?.description).toBe("Review again")
|
||||||
|
yield* command.execute({
|
||||||
|
name: "review",
|
||||||
|
invocation: {
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
prompt: { text: "latest" },
|
||||||
|
delivery: "steer",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(prompts.at(-1)?.text).toBe("Review again\n\nlatest")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -193,11 +246,13 @@ Review files`,
|
|||||||
yield* advance(() => reloads >= 1)
|
yield* advance(() => reloads >= 1)
|
||||||
expect(reloads).toBe(1)
|
expect(reloads).toBe(1)
|
||||||
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review twice"))
|
yield* Effect.promise(() =>
|
||||||
|
fs.writeFile(path.join(directory, "review.md"), markdown("Review twice", "Review twice")),
|
||||||
|
)
|
||||||
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
|
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
|
||||||
yield* advance(() => reloads >= 2)
|
yield* advance(() => reloads >= 2)
|
||||||
expect(reloads).toBe(2)
|
expect(reloads).toBe(2)
|
||||||
expect((yield* command.get("review"))?.template).toBe("Review twice")
|
expect((yield* command.get("review"))?.description).toBe("Review twice")
|
||||||
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
|
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -232,10 +287,12 @@ Review files`,
|
|||||||
expect(reloads).toBe(0)
|
expect(reloads).toBe(0)
|
||||||
|
|
||||||
// The feed stays live after unrelated updates.
|
// The feed stays live after unrelated updates.
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review related"))
|
yield* Effect.promise(() =>
|
||||||
|
fs.writeFile(path.join(directory, "review.md"), markdown("Review related", "Review related")),
|
||||||
|
)
|
||||||
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
|
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
|
||||||
yield* advance(() => reloads >= 1)
|
yield* advance(() => reloads >= 1)
|
||||||
expect((yield* command.get("review"))?.template).toBe("Review related")
|
expect((yield* command.get("review"))?.description).toBe("Review related")
|
||||||
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
|
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -272,28 +329,47 @@ describeNative("ConfigCommandPlugin native watcher", () => {
|
|||||||
yield* watchReady(config, global)
|
yield* watchReady(config, global)
|
||||||
|
|
||||||
const created = yield* nextCommandUpdate(bus)
|
const created = yield* nextCommandUpdate(bus)
|
||||||
yield* fs.writeFileString(path.join(global, "commands", "review.md"), "Review native")
|
yield* fs.writeFileString(
|
||||||
|
path.join(global, "commands", "review.md"),
|
||||||
|
markdown("Review native", "Review native"),
|
||||||
|
)
|
||||||
yield* Fiber.join(created).pipe(Effect.timeout("10 seconds"))
|
yield* Fiber.join(created).pipe(Effect.timeout("10 seconds"))
|
||||||
expect((yield* command.get("review"))?.template).toBe("Review native")
|
expect((yield* command.get("review"))?.description).toBe("Review native")
|
||||||
|
|
||||||
const updated = yield* nextCommandUpdate(bus)
|
const updated = yield* nextCommandUpdate(bus)
|
||||||
yield* fs.writeFileString(path.join(global, "commands", "review.md"), "Review native again")
|
yield* fs.writeFileString(
|
||||||
|
path.join(global, "commands", "review.md"),
|
||||||
|
markdown("Review native again", "Review native again"),
|
||||||
|
)
|
||||||
yield* Fiber.join(updated).pipe(Effect.timeout("10 seconds"))
|
yield* Fiber.join(updated).pipe(Effect.timeout("10 seconds"))
|
||||||
expect((yield* command.get("review"))?.template).toBe("Review native again")
|
expect((yield* command.get("review"))?.description).toBe("Review native again")
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
AppNodeBuilder.build(LayerNode.group([Command.node, Config.node, Bus.node, FSUtil.node]), [
|
AppNodeBuilder.build(
|
||||||
[
|
LayerNode.group([
|
||||||
|
Command.node,
|
||||||
|
Config.node,
|
||||||
|
Bus.node,
|
||||||
|
FSUtil.node,
|
||||||
|
AppProcess.node,
|
||||||
|
Global.node,
|
||||||
Location.node,
|
Location.node,
|
||||||
Layer.succeed(
|
Shell.node,
|
||||||
Location.Service,
|
]),
|
||||||
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
|
[
|
||||||
),
|
[
|
||||||
|
Location.node,
|
||||||
|
Layer.succeed(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
||||||
|
[Shell.node, shellLayer],
|
||||||
|
[Credential.node, emptyCredentialNode],
|
||||||
|
[WellKnown.node, emptyWellknownNode],
|
||||||
],
|
],
|
||||||
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
),
|
||||||
[Credential.node, emptyCredentialNode],
|
|
||||||
[WellKnown.node, emptyWellknownNode],
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
@@ -337,6 +413,10 @@ function directoryEntry(directory: string) {
|
|||||||
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
|
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markdown(description: string, template: string) {
|
||||||
|
return `---\ndescription: ${description}\n---\n${template}`
|
||||||
|
}
|
||||||
|
|
||||||
function sourceCases() {
|
function sourceCases() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -345,33 +425,37 @@ function sourceCases() {
|
|||||||
mutate: (directory: string) =>
|
mutate: (directory: string) =>
|
||||||
Effect.promise(async () => {
|
Effect.promise(async () => {
|
||||||
const file = path.join(directory, "review.md")
|
const file = path.join(directory, "review.md")
|
||||||
await fs.writeFile(file, "Review created")
|
await fs.writeFile(file, markdown("Review created", "Review created"))
|
||||||
return [{ type: "create" as const, path: file }]
|
return [{ type: "create" as const, path: file }]
|
||||||
}),
|
}),
|
||||||
verify: (command: Command.Interface) =>
|
verify: (command: Command.Interface) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
expect((yield* command.get("review"))?.template).toBe("Review created")
|
expect((yield* command.get("review"))?.description).toBe("Review created")
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "updated",
|
name: "updated",
|
||||||
prepare: (directory: string) =>
|
prepare: (directory: string) =>
|
||||||
Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review first")),
|
Effect.promise(() =>
|
||||||
|
fs.writeFile(path.join(directory, "review.md"), markdown("Review first", "Review first")),
|
||||||
|
),
|
||||||
mutate: (directory: string) =>
|
mutate: (directory: string) =>
|
||||||
Effect.promise(async () => {
|
Effect.promise(async () => {
|
||||||
const file = path.join(directory, "review.md")
|
const file = path.join(directory, "review.md")
|
||||||
await fs.writeFile(file, "Review updated")
|
await fs.writeFile(file, markdown("Review updated", "Review updated"))
|
||||||
return [{ type: "update" as const, path: file }]
|
return [{ type: "update" as const, path: file }]
|
||||||
}),
|
}),
|
||||||
verify: (command: Command.Interface) =>
|
verify: (command: Command.Interface) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
expect((yield* command.get("review"))?.template).toBe("Review updated")
|
expect((yield* command.get("review"))?.description).toBe("Review updated")
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "renamed",
|
name: "renamed",
|
||||||
prepare: (directory: string) =>
|
prepare: (directory: string) =>
|
||||||
Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review renamed")),
|
Effect.promise(() =>
|
||||||
|
fs.writeFile(path.join(directory, "review.md"), markdown("Review renamed", "Review renamed")),
|
||||||
|
),
|
||||||
mutate: (directory: string) =>
|
mutate: (directory: string) =>
|
||||||
Effect.promise(async () => {
|
Effect.promise(async () => {
|
||||||
const previous = path.join(directory, "review.md")
|
const previous = path.join(directory, "review.md")
|
||||||
@@ -385,7 +469,7 @@ function sourceCases() {
|
|||||||
verify: (command: Command.Interface) =>
|
verify: (command: Command.Interface) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
expect(yield* command.get("review")).toBeUndefined()
|
expect(yield* command.get("review")).toBeUndefined()
|
||||||
expect((yield* command.get("release"))?.template).toBe("Review renamed")
|
expect((yield* command.get("release"))?.description).toBe("Review renamed")
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Bus } from "@opencode-ai/core/bus"
|
||||||
|
import { Config } from "@opencode-ai/core/config"
|
||||||
|
import { ConfigImagePlugin } from "@opencode-ai/core/config/plugin/image"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { Image } from "@opencode-ai/core/image"
|
||||||
|
import { Plugin } from "@opencode-ai/core/plugin"
|
||||||
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
|
import { Document, Event, Info, type Entry } from "@opencode-ai/schema/config"
|
||||||
|
import { Effect, Layer, Schema, Stream } from "effect"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { PluginTestLayer } from "../plugin/fixture"
|
||||||
|
|
||||||
|
const it = testEffect(Layer.merge(PluginTestLayer, AppNodeBuilder.build(Image.node)))
|
||||||
|
const decode = Schema.decodeUnknownSync(Info)
|
||||||
|
const content = {
|
||||||
|
uri: "file:///pixel.png",
|
||||||
|
content: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||||
|
encoding: "base64" as const,
|
||||||
|
mime: "image/png",
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ConfigImagePlugin.Plugin", () => {
|
||||||
|
it.live("merges image limits and reloads changed config", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const image = yield* Image.Service
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const config = yield* Config.Test
|
||||||
|
const plugins = yield* Plugin.Service
|
||||||
|
yield* ConfigImagePlugin.Plugin.effect(yield* PluginHost.make(plugins))
|
||||||
|
|
||||||
|
expect(yield* limits(image)).toEqual({ maxWidth: 1_200, maxHeight: 900, maxBytes: 1 })
|
||||||
|
|
||||||
|
yield* config.setEntries([document({ auto_resize: false, max_width: 700, max_base64_bytes: 1 })])
|
||||||
|
yield* bus.publish(Event.Updated, {})
|
||||||
|
yield* waitUntil(
|
||||||
|
limits(image).pipe(
|
||||||
|
Effect.map((current) => current.maxWidth === 700 && current.maxHeight === 2_000 && current.maxBytes === 1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
Config.testLayer([
|
||||||
|
document({ auto_resize: false, max_width: 1_200 }),
|
||||||
|
document({ max_height: 900, max_base64_bytes: 1 }),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("refetches config after subscribing to updates", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const image = yield* Image.Service
|
||||||
|
const plugins = yield* Plugin.Service
|
||||||
|
let reads = 0
|
||||||
|
const config = Config.Service.of({
|
||||||
|
entries: () => Effect.sync(() => [document({ max_width: reads++ === 0 ? 1_200 : 700, max_base64_bytes: 1 })]),
|
||||||
|
update: () => Effect.die(new Error("Config update is unavailable")),
|
||||||
|
changes: () => Stream.empty,
|
||||||
|
})
|
||||||
|
yield* ConfigImagePlugin.Plugin.effect(yield* PluginHost.make(plugins)).pipe(
|
||||||
|
Effect.provideService(Config.Service, config),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(yield* limits(image)).toEqual({ maxWidth: 700, maxHeight: 2_000, maxBytes: 1 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function document(image: NonNullable<typeof Info.Encoded.media>["image"]): Entry {
|
||||||
|
return new Document({ type: "document", info: decode({ media: { image } }) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const limits = Effect.fnUntraced(function* (image: Image.Interface) {
|
||||||
|
const error = yield* image.normalize("pixel.png", content).pipe(Effect.flip, Effect.orDie)
|
||||||
|
if (error._tag !== "Image.SizeError") return yield* Effect.die(error)
|
||||||
|
return { maxWidth: error.maxWidth, maxHeight: error.maxHeight, maxBytes: error.maxBytes }
|
||||||
|
})
|
||||||
|
|
||||||
|
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
|
||||||
|
for (let attempt = 0; attempt < 200; attempt++) {
|
||||||
|
if (yield* condition) return
|
||||||
|
yield* Effect.sleep("10 millis")
|
||||||
|
}
|
||||||
|
yield* Effect.die(new Error("Timed out waiting for image config reload"))
|
||||||
|
})
|
||||||
@@ -150,6 +150,16 @@ describe("ConfigNormalize", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("migrates the legacy small model to the title agent", () => {
|
test("migrates the legacy small model to the title agent", () => {
|
||||||
|
const result = normalized({ small_model: "anthropic/claude-haiku-4-5" })
|
||||||
|
expect(result.encoded.agents).toEqual({
|
||||||
|
title: {
|
||||||
|
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.diagnostics).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("merges the legacy small model with the title agent", () => {
|
||||||
const result = normalized({
|
const result = normalized({
|
||||||
small_model: "anthropic/claude-haiku-4-5",
|
small_model: "anthropic/claude-haiku-4-5",
|
||||||
agent: { title: { prompt: "Custom title prompt" } },
|
agent: { title: { prompt: "Custom title prompt" } },
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ describe("PluginSupervisor config", () => {
|
|||||||
const plugins = yield* Plugin.Service
|
const plugins = yield* Plugin.Service
|
||||||
yield* ready()
|
yield* ready()
|
||||||
expect(
|
expect(
|
||||||
(yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")),
|
(yield* plugins.list())
|
||||||
|
.flatMap((plugin) => (plugin.id ? [plugin.id] : []))
|
||||||
|
.filter((id) => id.startsWith("opencode.provider.")),
|
||||||
).toEqual([Plugin.ID.make("opencode.provider.openai")])
|
).toEqual([Plugin.ID.make("opencode.provider.openai")])
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -64,10 +66,20 @@ describe("PluginSupervisor config", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* ready()
|
yield* ready()
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
|
const plugins = yield* Plugin.Service
|
||||||
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
|
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
|
||||||
description: "Loaded from config",
|
description: "Loaded from config",
|
||||||
mode: "subagent",
|
mode: "subagent",
|
||||||
})
|
})
|
||||||
|
expect((yield* plugins.list()).find((plugin) => plugin.id === "config-promise-plugin")).toEqual({
|
||||||
|
id: Plugin.ID.make("config-promise-plugin"),
|
||||||
|
source: {
|
||||||
|
type: "local",
|
||||||
|
path: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
tui: true,
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -143,6 +155,7 @@ describe("PluginSupervisor config", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* ready()
|
yield* ready()
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
|
const plugins = yield* Plugin.Service
|
||||||
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
|
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
|
||||||
description: "Loaded after invalid plugins",
|
description: "Loaded after invalid plugins",
|
||||||
})
|
})
|
||||||
@@ -150,6 +163,12 @@ describe("PluginSupervisor config", () => {
|
|||||||
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
|
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
|
||||||
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
|
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
|
||||||
])
|
])
|
||||||
|
expect(
|
||||||
|
(yield* plugins.list()).filter((plugin) => plugin.status === "failed").map((plugin) => plugin.source),
|
||||||
|
).toEqual([
|
||||||
|
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
|
||||||
|
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts") },
|
||||||
|
])
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.provide(Logger.layer([logger])))
|
).pipe(Effect.provide(Logger.layer([logger])))
|
||||||
})
|
})
|
||||||
@@ -246,10 +265,12 @@ describe("PluginSupervisor config", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* ready()
|
yield* ready()
|
||||||
const plugins = yield* Plugin.Service
|
const plugins = yield* Plugin.Service
|
||||||
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
|
const inventory = yield* plugins.list()
|
||||||
|
const ids = inventory.map((plugin) => String(plugin.id))
|
||||||
expect(ids).toContain("opencode.agent")
|
expect(ids).toContain("opencode.agent")
|
||||||
expect(ids).toContain("static-sdk")
|
expect(ids).toContain("static-sdk")
|
||||||
expect(ids).not.toContain("config-promise-plugin")
|
expect(ids).not.toContain("config-promise-plugin")
|
||||||
|
expect(inventory.find((plugin) => plugin.id === "static-sdk")?.source).toEqual({ type: "sdk" })
|
||||||
|
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
|
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { expect, test } from "bun:test"
|
|||||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||||
import { eq, sql } from "drizzle-orm"
|
import { eq, sql } from "drizzle-orm"
|
||||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||||
import { Effect } from "effect"
|
import { Effect, Tracer } from "effect"
|
||||||
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
|
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
|
||||||
import { isSqlError } from "effect/unstable/sql/SqlError"
|
import { isSqlError } from "effect/unstable/sql/SqlError"
|
||||||
import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
|
import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
|
||||||
@@ -49,6 +49,31 @@ test("selects rows through Effect-yieldable query builders", async () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("suppresses statement spans", async () => {
|
||||||
|
const spans: Tracer.NativeSpan[] = []
|
||||||
|
const tracer = Tracer.make({
|
||||||
|
span(options) {
|
||||||
|
const span = new Tracer.NativeSpan(options)
|
||||||
|
spans.push(span)
|
||||||
|
return span
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = yield* makeDb
|
||||||
|
yield* db.transaction((tx) => tx.insert(users).values({ name: "Grace" }))
|
||||||
|
yield* db.select().from(users)
|
||||||
|
}).pipe(
|
||||||
|
Effect.provideService(Tracer.Tracer, tracer),
|
||||||
|
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
|
||||||
|
Effect.scoped,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(spans.map((span) => span.name)).not.toContain("sql.execute")
|
||||||
|
})
|
||||||
|
|
||||||
test("commits successful transactions", async () => {
|
test("commits successful transactions", async () => {
|
||||||
await run(
|
await run(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -1,41 +1,30 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Deferred, Effect, Fiber } from "effect"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { Bus } from "@opencode-ai/core/bus"
|
||||||
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||||
|
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||||
|
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Npm } from "@opencode-ai/util/npm"
|
import { Info } from "@opencode-ai/schema/config"
|
||||||
import { Document, Info } from "@opencode-ai/schema/config"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { Config } from "../src/config"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Formatter } from "../src/formatter"
|
import { Formatter } from "../src/formatter"
|
||||||
import { Location } from "../src/location"
|
import { Location } from "../src/location"
|
||||||
import { location } from "./fixture/location"
|
import { tempGlobalLayer } from "./fixture/global"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const it = testEffect(Layer.empty)
|
const it = testEffect(
|
||||||
|
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||||
|
[Global.node, tempGlobalLayer],
|
||||||
|
]),
|
||||||
|
)
|
||||||
type ConfigInput = typeof Info.Encoded
|
type ConfigInput = typeof Info.Encoded
|
||||||
|
|
||||||
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
|
|
||||||
const entries =
|
|
||||||
configured === undefined
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
new Document({
|
|
||||||
type: "document",
|
|
||||||
info: Schema.decodeUnknownSync(Info)({ formatter: configured }),
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
return AppNodeBuilder.build(Formatter.node, [
|
|
||||||
[Config.node, Config.testLayer(entries)],
|
|
||||||
[
|
|
||||||
Location.node,
|
|
||||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
|
||||||
],
|
|
||||||
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
|
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
|
||||||
return Effect.acquireUseRelease(
|
return Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
@@ -44,122 +33,208 @@ function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Formatter", () => {
|
function withFormatter<A, E, R>(
|
||||||
it.live("does not run formatters marked as disabled in config", () =>
|
configured: ConfigInput["formatter"],
|
||||||
withTemp((directory) =>
|
body: (formatter: Formatter.Interface, directory: string) => Effect.Effect<A, E, R>,
|
||||||
Effect.gen(function* () {
|
) {
|
||||||
const file = path.join(directory, "test.disabled")
|
return withTemp((directory) =>
|
||||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
|
Effect.promise(() =>
|
||||||
}).pipe(
|
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ formatter: configured })),
|
||||||
Effect.provide(
|
).pipe(
|
||||||
formatterLayer(directory, {
|
Effect.andThen(
|
||||||
disabled: {
|
Effect.gen(function* () {
|
||||||
disabled: true,
|
const plugins = yield* PluginSupervisor.Service
|
||||||
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
yield* plugins.flush
|
||||||
extensions: [".disabled"],
|
return yield* body(yield* Formatter.Service, directory)
|
||||||
},
|
}).pipe(
|
||||||
}),
|
Effect.scoped,
|
||||||
|
Effect.provide(
|
||||||
|
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Formatter", () => {
|
||||||
|
it.live("does not run formatters marked as disabled in config", () =>
|
||||||
|
withFormatter(
|
||||||
|
{
|
||||||
|
disabled: {
|
||||||
|
disabled: true,
|
||||||
|
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||||
|
extensions: [".disabled"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(formatter, directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(directory, "test.disabled")
|
||||||
|
expect(yield* formatter.file(file)).toBe(false)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("file() returns false when no formatter runs", () =>
|
it.live("file() returns false when no formatter runs", () =>
|
||||||
withTemp((directory) =>
|
withFormatter(false, (formatter, directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const file = path.join(directory, "test.txt")
|
const file = path.join(directory, "test.txt")
|
||||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
|
expect(yield* formatter.file(file)).toBe(false)
|
||||||
}).pipe(Effect.provide(formatterLayer(directory, false))),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("loads formatter state per directory", () =>
|
it.live("loads formatter state per directory", () =>
|
||||||
withTemp((off) =>
|
withFormatter(false, (disabledFormatter, off) =>
|
||||||
withTemp((on) =>
|
withFormatter(
|
||||||
Effect.gen(function* () {
|
{
|
||||||
const offFile = path.join(off, "test.isolated")
|
isolated: {
|
||||||
const onFile = path.join(on, "test.isolated")
|
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||||
const disabled = yield* Formatter.Service.use((formatter) => formatter.file(offFile)).pipe(
|
extensions: [".isolated"],
|
||||||
Effect.provide(formatterLayer(off, false)),
|
},
|
||||||
)
|
},
|
||||||
const enabled = yield* Formatter.Service.use((formatter) => formatter.file(onFile)).pipe(
|
(enabledFormatter, on) =>
|
||||||
Effect.provide(
|
Effect.gen(function* () {
|
||||||
formatterLayer(on, {
|
const offFile = path.join(off, "test.isolated")
|
||||||
isolated: {
|
const onFile = path.join(on, "test.isolated")
|
||||||
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
const disabled = yield* disabledFormatter.file(offFile)
|
||||||
extensions: [".isolated"],
|
const enabled = yield* enabledFormatter.file(onFile)
|
||||||
},
|
expect(disabled).toBe(false)
|
||||||
}),
|
expect(enabled).toBe(true)
|
||||||
),
|
}),
|
||||||
)
|
|
||||||
expect(disabled).toBe(false)
|
|
||||||
expect(enabled).toBe(true)
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("stops after the first matching formatter succeeds", () =>
|
it.live("stops after the first matching formatter succeeds", () =>
|
||||||
withTemp((directory) =>
|
withFormatter(
|
||||||
Effect.gen(function* () {
|
{
|
||||||
const file = path.join(directory, "test.seq")
|
first: {
|
||||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
command: [
|
||||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
|
process.execPath,
|
||||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
|
"-e",
|
||||||
}).pipe(
|
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
|
||||||
Effect.provide(
|
"$FILE",
|
||||||
formatterLayer(directory, {
|
],
|
||||||
first: {
|
extensions: [".seq"],
|
||||||
command: [
|
},
|
||||||
process.execPath,
|
second: {
|
||||||
"-e",
|
command: [
|
||||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
|
process.execPath,
|
||||||
"$FILE",
|
"-e",
|
||||||
],
|
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
||||||
extensions: [".seq"],
|
"$FILE",
|
||||||
},
|
],
|
||||||
second: {
|
extensions: [".seq"],
|
||||||
command: [
|
},
|
||||||
process.execPath,
|
},
|
||||||
"-e",
|
(formatter, directory) =>
|
||||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
Effect.gen(function* () {
|
||||||
"$FILE",
|
const file = path.join(directory, "test.seq")
|
||||||
],
|
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||||
extensions: [".seq"],
|
expect(yield* formatter.file(file)).toBe(true)
|
||||||
},
|
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("tries the next matching formatter when the first fails", () =>
|
it.live("tries the next matching formatter when the first fails", () =>
|
||||||
withTemp((directory) =>
|
withFormatter(
|
||||||
|
{
|
||||||
|
first: {
|
||||||
|
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
|
||||||
|
extensions: [".fallback"],
|
||||||
|
},
|
||||||
|
second: {
|
||||||
|
command: [
|
||||||
|
process.execPath,
|
||||||
|
"-e",
|
||||||
|
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
||||||
|
"$FILE",
|
||||||
|
],
|
||||||
|
extensions: [".fallback"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(formatter, directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(directory, "test.fallback")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||||
|
expect(yield* formatter.file(file)).toBe(true)
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("rebuilds formatter state and clears resolved commands", () =>
|
||||||
|
withFormatter(false, (formatter, directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const file = path.join(directory, "test.fallback")
|
const command = { suffix: "A" }
|
||||||
|
yield* formatter.transform((draft) => {
|
||||||
|
const suffix = command.suffix
|
||||||
|
draft.set({
|
||||||
|
name: "reload",
|
||||||
|
extensions: [".reload"],
|
||||||
|
enabled: Effect.succeed([
|
||||||
|
process.execPath,
|
||||||
|
"-e",
|
||||||
|
`const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, '${suffix}')`,
|
||||||
|
"$FILE",
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const file = path.join(directory, "test.reload")
|
||||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
|
expect(yield* formatter.file(file)).toBe(true)
|
||||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
|
|
||||||
}).pipe(
|
command.suffix = "B"
|
||||||
Effect.provide(
|
yield* formatter.reload()
|
||||||
formatterLayer(directory, {
|
|
||||||
first: {
|
expect(yield* formatter.file(file)).toBe(true)
|
||||||
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
|
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xAB")
|
||||||
extensions: [".fallback"],
|
}),
|
||||||
},
|
),
|
||||||
second: {
|
)
|
||||||
command: [
|
|
||||||
process.execPath,
|
it.live("does not cache a command resolved before reload", () =>
|
||||||
"-e",
|
withFormatter(false, (formatter, directory) =>
|
||||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
Effect.gen(function* () {
|
||||||
"$FILE",
|
const resolving = yield* Deferred.make<void>()
|
||||||
],
|
const release = yield* Deferred.make<void>()
|
||||||
extensions: [".fallback"],
|
const command = { suffix: "A" }
|
||||||
},
|
yield* formatter.transform((draft) => {
|
||||||
}),
|
const suffix = command.suffix
|
||||||
),
|
const resolved = [
|
||||||
),
|
process.execPath,
|
||||||
|
"-e",
|
||||||
|
`const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, '${suffix}')`,
|
||||||
|
"$FILE",
|
||||||
|
]
|
||||||
|
draft.set({
|
||||||
|
name: "reload-race",
|
||||||
|
extensions: [".race"],
|
||||||
|
enabled:
|
||||||
|
suffix === "A"
|
||||||
|
? Deferred.succeed(resolving, undefined).pipe(
|
||||||
|
Effect.andThen(Deferred.await(release)),
|
||||||
|
Effect.as(resolved),
|
||||||
|
)
|
||||||
|
: Effect.succeed(resolved),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const file = path.join(directory, "test.race")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||||
|
const first = yield* formatter.file(file).pipe(Effect.forkChild({ startImmediately: true }))
|
||||||
|
yield* Deferred.await(resolving)
|
||||||
|
|
||||||
|
command.suffix = "B"
|
||||||
|
yield* formatter.reload()
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
expect(yield* Fiber.join(first)).toBe(true)
|
||||||
|
|
||||||
|
expect(yield* formatter.file(file)).toBe(true)
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xAB")
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -428,10 +428,18 @@ describe("LocationServiceMap", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
for (let attempt = 0; attempt < 100; attempt++) {
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
if ((yield* registry.list()).length === 0) break
|
if ((yield* registry.list()).some((plugin) => plugin.status === "failed")) break
|
||||||
yield* Effect.sleep("20 millis")
|
yield* Effect.sleep("20 millis")
|
||||||
}
|
}
|
||||||
expect(yield* registry.list()).toEqual([])
|
expect(yield* registry.list()).toEqual([
|
||||||
|
{
|
||||||
|
id: Plugin.ID.make("failing-plugin"),
|
||||||
|
source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts") },
|
||||||
|
status: "failed",
|
||||||
|
error: expect.stringContaining("plugin failed"),
|
||||||
|
tui: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
|
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
|
||||||
for (let attempt = 0; attempt < 100; attempt++) {
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
|||||||
@@ -89,13 +89,7 @@ describe("Plugin", () => {
|
|||||||
yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie)
|
yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie)
|
||||||
yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie)
|
yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie)
|
||||||
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
|
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
|
||||||
expect(routed).toEqual([
|
expect(routed).toEqual(["add:/target", "remove:/target", "connect:/target", "disconnect:/target", "list:/target"])
|
||||||
"add:/target",
|
|
||||||
"remove:/target",
|
|
||||||
"connect:/target",
|
|
||||||
"disconnect:/target",
|
|
||||||
"list:/target",
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,9 +132,22 @@ describe("Plugin", () => {
|
|||||||
expect(updates).toBe(2)
|
expect(updates).toBe(2)
|
||||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
|
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
|
||||||
|
|
||||||
|
yield* plugins.activate(
|
||||||
|
[versioned(managed(), "2")],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
source: { type: "package", package: "broken" },
|
||||||
|
status: "failed",
|
||||||
|
error: "failed to resolve",
|
||||||
|
tui: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
expect(updates).toBe(3)
|
||||||
|
|
||||||
yield* plugins.activate([])
|
yield* plugins.activate([])
|
||||||
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
|
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
|
||||||
expect(updates).toBe(3)
|
expect(updates).toBe(4)
|
||||||
yield* unsubscribe
|
yield* unsubscribe
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -160,7 +167,7 @@ describe("Plugin", () => {
|
|||||||
.pipe(Effect.exit)
|
.pipe(Effect.exit)
|
||||||
|
|
||||||
expect(Exit.isFailure(result)).toBe(true)
|
expect(Exit.isFailure(result)).toBe(true)
|
||||||
expect(yield* plugins.list()).toEqual([{ id: active }])
|
expect(yield* plugins.list()).toEqual([{ id: active, source: { type: "builtin" }, status: "active", tui: false }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -189,12 +196,24 @@ describe("Plugin", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
yield* plugins.activate([versioned(good), versioned(bad)])
|
yield* plugins.activate([versioned(good), versioned(bad)])
|
||||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
|
expect(yield* plugins.list()).toEqual([
|
||||||
|
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
|
||||||
|
{
|
||||||
|
id: Plugin.ID.make("bad"),
|
||||||
|
source: { type: "builtin" },
|
||||||
|
status: "failed",
|
||||||
|
error: expect.stringContaining("materialization failed"),
|
||||||
|
tui: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
|
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
|
||||||
|
|
||||||
fail = false
|
fail = false
|
||||||
yield* plugins.activate([versioned(good), versioned(bad, "2")])
|
yield* plugins.activate([versioned(good), versioned(bad, "2")])
|
||||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }, { id: Plugin.ID.make("bad") }])
|
expect(yield* plugins.list()).toEqual([
|
||||||
|
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
|
||||||
|
{ id: Plugin.ID.make("bad"), source: { type: "builtin" }, status: "active", tui: false },
|
||||||
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -229,7 +248,15 @@ describe("Plugin", () => {
|
|||||||
yield* plugins.activate([versioned(previous)])
|
yield* plugins.activate([versioned(previous)])
|
||||||
yield* plugins.activate([versioned(replacement, "2")])
|
yield* plugins.activate([versioned(replacement, "2")])
|
||||||
|
|
||||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }])
|
expect(yield* plugins.list()).toEqual([
|
||||||
|
{
|
||||||
|
id: Plugin.ID.make("managed"),
|
||||||
|
source: { type: "builtin" },
|
||||||
|
status: "failed",
|
||||||
|
error: expect.stringContaining("replacement failed"),
|
||||||
|
tui: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
|
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -261,7 +288,15 @@ describe("Plugin", () => {
|
|||||||
yield* plugins.activate([versioned(previous)])
|
yield* plugins.activate([versioned(previous)])
|
||||||
yield* plugins.activate([versioned(replacement, "2")])
|
yield* plugins.activate([versioned(replacement, "2")])
|
||||||
|
|
||||||
expect(yield* plugins.list()).toEqual([])
|
expect(yield* plugins.list()).toEqual([
|
||||||
|
{
|
||||||
|
id: Plugin.ID.make("managed"),
|
||||||
|
source: { type: "builtin" },
|
||||||
|
status: "failed",
|
||||||
|
error: expect.stringContaining("replacement failed"),
|
||||||
|
tui: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
|
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user