diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md index 4f195917fde2..6180fe0932f7 100644 --- a/packages/opencode/specs/effect-migration.md +++ b/packages/opencode/specs/effect-migration.md @@ -132,7 +132,7 @@ Still open and likely worth migrating: - [ ] `Worktree` - [ ] `Installation` - [ ] `Bus` -- [ ] `Command` +- [x] `Command` - [ ] `Config` - [ ] `Session` - [ ] `SessionProcessor` diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index 7fe13833c862..1d9a31d4a271 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -1,5 +1,5 @@ import z from "zod" -import type { ZodType } from "zod" +import type { ZodObject, ZodRawShape } from "zod" import { Log } from "../util/log" export namespace BusEvent { @@ -9,7 +9,7 @@ export namespace BusEvent { const registry = new Map() - export function define(type: Type, properties: Properties) { + export function define>(type: Type, properties: Properties) { const result = { type, properties, diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index 43386dd6b20f..dcc7664007e4 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -4,7 +4,7 @@ export const GlobalBus = new EventEmitter<{ event: [ { directory?: string - payload: any + payload: { type: string; properties: Record } }, ] }>() diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 2c47984fdd8f..3ef6974f0947 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,15 +1,18 @@ import { BusEvent } from "@/bus/bus-event" +import { InstanceContext } from "@/effect/instance-context" +import { runPromiseInstance } from "@/effect/runtime" import { SessionID, MessageID } from "@/session/schema" +import { Effect, Fiber, Layer, ServiceMap } from "effect" import z from "zod" import { Config } from "../config/config" -import { Instance } from "../project/instance" -import { Identifier } from "../id/id" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" import { MCP } from "../mcp" import { Skill } from "../skill" +import { Log } from "../util/log" export namespace Command { + const log = Log.create({ service: "command" }) export const Event = { Executed: BusEvent.define( "command.executed", @@ -57,95 +60,126 @@ export namespace Command { REVIEW: "review", } as const - const state = Instance.state(async () => { - const cfg = await Config.get() - - const result: Record = { - [Default.INIT]: { - name: Default.INIT, - description: "create/update AGENTS.md", - source: "command", - get template() { - return PROMPT_INITIALIZE.replace("${path}", Instance.worktree) - }, - hints: hints(PROMPT_INITIALIZE), - }, - [Default.REVIEW]: { - name: Default.REVIEW, - description: "review changes [commit|branch|pr], defaults to uncommitted", - source: "command", - get template() { - return PROMPT_REVIEW.replace("${path}", Instance.worktree) - }, - subtask: true, - hints: hints(PROMPT_REVIEW), - }, - } + export interface Interface { + readonly get: (name: string) => Effect.Effect + readonly list: () => Effect.Effect + } - for (const [name, command] of Object.entries(cfg.command ?? {})) { - result[name] = { - name, - agent: command.agent, - model: command.model, - description: command.description, - source: "command", - get template() { - return command.template - }, - subtask: command.subtask, - hints: hints(command.template), - } - } - for (const [name, prompt] of Object.entries(await MCP.prompts())) { - result[name] = { - name, - source: "mcp", - description: prompt.description, - get template() { - // since a getter can't be async we need to manually return a promise here - return new Promise(async (resolve, reject) => { - const template = await MCP.getPrompt( - prompt.client, - prompt.name, - prompt.arguments - ? // substitute each argument with $1, $2, etc. - Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`])) - : {}, - ).catch(reject) - resolve( - template?.messages - .map((message) => (message.content.type === "text" ? message.content.text : "")) - .join("\n") || "", - ) - }) - }, - hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [], - } - } + export class Service extends ServiceMap.Service()("@opencode/Command") {} - // Add skills as invokable commands - for (const skill of await Skill.all()) { - // Skip if a command with this name already exists - if (result[skill.name]) continue - result[skill.name] = { - name: skill.name, - description: skill.description, - source: "skill", - get template() { - return skill.content - }, - hints: [], - } - } + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const instance = yield* InstanceContext - return result - }) + const commands: Record = {} + + const load = Effect.fn("Command.load")(function* () { + yield* Effect.promise(async () => { + const cfg = await Config.get() + + commands[Default.INIT] = { + name: Default.INIT, + description: "create/update AGENTS.md", + source: "command", + get template() { + return PROMPT_INITIALIZE.replace("${path}", instance.worktree) + }, + hints: hints(PROMPT_INITIALIZE), + } + commands[Default.REVIEW] = { + name: Default.REVIEW, + description: "review changes [commit|branch|pr], defaults to uncommitted", + source: "command", + get template() { + return PROMPT_REVIEW.replace("${path}", instance.worktree) + }, + subtask: true, + hints: hints(PROMPT_REVIEW), + } + + for (const [name, command] of Object.entries(cfg.command ?? {})) { + commands[name] = { + name, + agent: command.agent, + model: command.model, + description: command.description, + source: "command", + get template() { + return command.template + }, + subtask: command.subtask, + hints: hints(command.template), + } + } + for (const [name, prompt] of Object.entries(await MCP.prompts())) { + commands[name] = { + name, + source: "mcp", + description: prompt.description, + get template() { + // since a getter can't be async we need to manually return a promise here + return new Promise(async (resolve, reject) => { + const template = await MCP.getPrompt( + prompt.client, + prompt.name, + prompt.arguments + ? // substitute each argument with $1, $2, etc. + Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`])) + : {}, + ).catch(reject) + resolve( + template?.messages + .map((message) => (message.content.type === "text" ? message.content.text : "")) + .join("\n") || "", + ) + }) + }, + hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [], + } + } + + // Add skills as invokable commands + for (const skill of await Skill.all()) { + // Skip if a command with this name already exists + if (commands[skill.name]) continue + commands[skill.name] = { + name: skill.name, + description: skill.description, + source: "skill", + get template() { + return skill.content + }, + hints: [], + } + } + }) + }) + + const loadFiber = yield* load().pipe( + Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))), + Effect.forkScoped, + ) + + const get = Effect.fn("Command.get")(function* (name: string) { + yield* Fiber.join(loadFiber) + return commands[name] + }) + + const list = Effect.fn("Command.list")(function* () { + yield* Fiber.join(loadFiber) + return Object.values(commands) + }) + + return Service.of({ get, list }) + }), + ) export async function get(name: string) { - return state().then((x) => x[name]) + return runPromiseInstance(Service.use((svc) => svc.get(name))) } export async function list() { - return state().then((x) => Object.values(x)) + return runPromiseInstance(Service.use((svc) => svc.list())) } } diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index e5294844b148..b519895b2fc5 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -124,7 +124,7 @@ export namespace Workspace { await parseSSE(res.body, stop, (event) => { GlobalBus.emit("event", { directory: space.id, - payload: event, + payload: event as { type: string; properties: Record }, }) }) // Wait 250ms and retry if SSE connection fails diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 6fcfddb24f58..63908632b89a 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -1,4 +1,5 @@ import { Effect, Layer, LayerMap, ServiceMap } from "effect" +import { Command } from "@/command" import { File } from "@/file/service" import { FileTime } from "@/file/time-service" import { FileWatcher } from "@/file/watcher" @@ -16,6 +17,7 @@ import { registerDisposer } from "./instance-registry" export { InstanceContext } from "./instance-context" export type InstanceServices = + | Command.Service | Question.Service | Permission.Service | ProviderAuth.Service @@ -36,6 +38,7 @@ export type InstanceServices = function lookup(_key: string) { const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current)) return Layer.mergeAll( + Command.layer, Question.layer, Permission.layer, ProviderAuth.defaultLayer, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bac958ec1033..fdeacc1d12f7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1782,6 +1782,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the export async function command(input: CommandInput) { log.info("command", input) const command = await Command.get(input.command) + if (!command) { + throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` }) + } const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) const raw = input.arguments.match(argsRegex) ?? [] diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 2cd27643e88c..d4893e963e97 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -16,7 +16,7 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc // Helpers // --------------------------------------------------------------------------- -type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } } +type BusUpdate = { directory?: string; payload: { type: string; properties: Record } } type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } /** Run `body` with a live FileWatcher service. */ @@ -40,18 +40,18 @@ function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: ( if (done) return if (evt.directory !== directory) return if (evt.payload.type !== FileWatcher.Event.Updated.type) return - if (!check(evt.payload.properties)) return - hit(evt.payload.properties) + const props = evt.payload.properties as WatcherEvent + if (!check(props)) return + hit(props) } - function cleanup() { + GlobalBus.on("event", on) + + return () => { if (done) return done = true GlobalBus.off("event", on) } - - GlobalBus.on("event", on) - return cleanup } function wait(directory: string, check: (evt: WatcherEvent) => boolean) {