Compare commits

...

31 Commits

Author SHA1 Message Date
Dax Raad ed3154df2a fix(app): submit callback commands as actions 2026-08-21 16:36:02 -04:00
Dax Raad 34c29df60d fix(command): simplify callback consumers 2026-08-19 20:13:52 -04:00
Dax Raad 2efe5d1034 feat(core): execute command callbacks 2026-08-19 20:02:28 -04:00
Dax Raad 64f84c1475 feat(core): register command callbacks 2026-08-19 18:30:16 -04:00
Dax Raad 2ada79018e feat(plugin): register command callbacks 2026-08-19 18:19:03 -04:00
Kyle Altendorf 044d04df06 fix(cli): preserve clean build manifest (#43287) 2026-08-18 16:32:10 -05:00
Shoubhit Dash 4b9d89e943 refactor(core): move image config into state (#43090) 2026-08-18 21:07:27 +00:00
Kit Langton 5ff6bb87cf fix(core): coalesce queued compactions (#43292) 2026-08-18 21:04:02 +00:00
opencode-agent[bot] 511b4556a2 fix(tui): navigate nested subagents (#43290)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-18 23:02:45 +02:00
Shoubhit Dash cb39ea1136 fix(core): prefer websocket with request hooks (#43234) 2026-08-19 02:20:57 +05:30
Shoubhit Dash ff9452bf03 refactor(core): move formatter config into state (#43087) 2026-08-19 02:20:24 +05:30
Aiden Cline 97265f8ac5 fix(ai): ignore orphaned Anthropic tool deltas (#43289) 2026-08-18 15:42:31 -05:00
Dax Raad 56e66656b8 fix(ci): limit cli publish build time 2026-08-18 15:43:47 -04:00
opencode-agent[bot] 02f3f3cb3e test(core): remove flaky webfetch checks (#43278)
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
2026-08-18 20:26:26 +02:00
Dax b0c3a16ead feat(plugin): expose server plugin status 2026-08-18 14:22:32 -04:00
Filip 594c395576 fix(tui): restore MCP sign-in flow (#43274) 2026-08-18 20:02:10 +02:00
Dax b7402c264d fix(core): reduce noisy tracing spans
Reduce low-value tracing emitted during parallel tool execution and suppress per-statement SQL spans.
2026-08-18 13:42:50 -04:00
Dax 3f79699bce feat(core): persist websearch provider selection (#43268) 2026-08-18 17:32:08 +00:00
Dax 67fe76057e feat(cli): configure service environment (#43269) 2026-08-18 13:27:44 -04:00
opencode-agent[bot] bac474aaa0 fix(tui): scope session picker to active location (#43264)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-18 17:01:15 +00:00
Aiden Cline 98c717cb5b fix(core): migrate standalone small model (#43260) 2026-08-18 11:14:36 -05:00
Major Hayden 5c8d46ab4b fix(core): make Google Vertex models work with ADC credentials (#43077)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-18 10:47:13 -05:00
Dax Raad d5e83fefda fix(core): reuse prompt cache for forks 2026-08-18 11:38:11 -04:00
Dax Raad 46378dda50 refactor(core): standardize builtin plugin ids 2026-08-18 11:07:11 -04:00
Dax b38d9d812f refactor(client): move service shutdown client-side (#43252) 2026-08-18 15:02:37 +00:00
Dax Raad 8df039d261 fix(cli): limit source maps to development channels 2026-08-18 10:53:42 -04:00
Dax Raad c92fb2d41b fix(cli): improve process failure logging 2026-08-18 10:49:52 -04:00
Shoubhit Dash 958308c913 fix(core): ignore malformed model costs (#43251) 2026-08-18 19:59:47 +05:30
Dax Raad 16390ca47d fix(cli): log background service startup 2026-08-18 10:15:36 -04:00
Dax Raad 643eed300d fix(cli): log managed service lease loss 2026-08-18 10:06:59 -04:00
Dax Raad c3a6721de2 refactor(tui): standardize builtin plugin ids 2026-08-18 10:06:59 -04:00
164 changed files with 5369 additions and 2688 deletions
+6
View File
@@ -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.
+1
View File
@@ -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"))
+8
View File
@@ -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
}
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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,
+7 -12
View File
@@ -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")
+6
View File
@@ -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) {
+2 -2
View File
@@ -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",
}, },
+24 -3
View File
@@ -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,
),
},
}), }),
], ],
}), }),
+11 -1
View File
@@ -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))
}), }),
) )
+21
View File
@@ -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),
+24 -7
View File
@@ -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,
+37 -8
View File
@@ -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
}
} }
}) })
+39
View File
@@ -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)
}
})
-1
View File
@@ -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 = {
+38
View File
@@ -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")
+2 -11
View File
@@ -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 = {
+3 -17
View File
@@ -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) =>
+14 -58
View File
@@ -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>(
{ {
+24 -145
View File
@@ -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"]
+15 -47
View File
@@ -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 -2
View File
@@ -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)
+2
View File
@@ -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
} }
+8 -16
View File
@@ -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"))
+23 -3
View File
@@ -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) {
-16
View File
@@ -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({
+36 -29
View File
@@ -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())
} }
+1 -1
View File
@@ -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
View File
@@ -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()
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
) )
+110 -12
View File
@@ -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)
}
})
}),
})
+40
View File
@@ -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() {
+34 -58
View File
@@ -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
View File
@@ -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: [] })
+5 -5
View File
@@ -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]
+1 -1
View File
@@ -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 {
+4 -3
View File
@@ -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) {
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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,
+98 -7
View File
@@ -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
+44 -22
View File
@@ -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 })
}), }),
+6 -2
View File
@@ -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,
+8
View File
@@ -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()) {
+8 -3
View File
@@ -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",
+11 -10
View File
@@ -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",
+1 -1
View File
@@ -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") {}
+47 -20
View File
@@ -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 })
+1 -1
View File
@@ -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* () {
+11
View File
@@ -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")],
+21 -44
View File
@@ -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) {
+18 -15
View File
@@ -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 -8
View File
@@ -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
}), }),
+21 -7
View File
@@ -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* (
+34
View File
@@ -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,
}),
})
})
+20 -13
View File
@@ -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) }
: {}), : {}),
} }
+1 -1
View File
@@ -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
}), }),
+18 -15
View File
@@ -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
+6 -5
View File
@@ -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,
) )
} }
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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)
+1 -4
View File
@@ -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)
}) })
+1 -1
View File
@@ -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 ?? [])
+1 -1
View File
@@ -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]
+2 -2
View File
@@ -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()
+6 -11
View File
@@ -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]
}), }),
) )
+22 -8
View File
@@ -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],
}) })
+47 -55
View File
@@ -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" })
}), }),
) )
}) })
+131 -47
View File
@@ -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")
}), }),
}, },
{ {
+86
View File
@@ -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" } },
+23 -2
View File
@@ -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()
+26 -1
View File
@@ -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* () {
+193 -118
View File
@@ -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")
}),
), ),
) )
}) })
+10 -2
View File
@@ -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++) {
+48 -13
View File
@@ -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