summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-21 20:21:41 -0400
committerGitHub <[email protected]>2026-03-22 00:21:41 +0000
commit0e0e7a4a4b6a3fe0969e183e91b3cd3012993f75 (patch)
tree5ccd026ca6b3db24ab21bac549cefdc1c886c8e0
parent10a3d6c54e403ec68c0ef150c2f109462199df23 (diff)
downloadopencode-0e0e7a4a4b6a3fe0969e183e91b3cd3012993f75.tar.gz
opencode-0e0e7a4a4b6a3fe0969e183e91b3cd3012993f75.zip
effectify Command service (#18568)
-rw-r--r--packages/opencode/specs/effect-migration.md2
-rw-r--r--packages/opencode/src/command/index.ts208
-rw-r--r--packages/opencode/src/session/prompt.ts3
-rw-r--r--packages/opencode/test/file/watcher.test.ts20
4 files changed, 132 insertions, 101 deletions
diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md
index 2a5b289ca..d2ac9ed98 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:
- [ ] `Pty`
- [ ] `Worktree`
- [ ] `Bus`
-- [ ] `Command`
+- [x] `Command`
- [ ] `Config`
- [ ] `Session`
- [ ] `SessionProcessor`
diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts
index 2c47984fd..ff9382610 100644
--- a/packages/opencode/src/command/index.ts
+++ b/packages/opencode/src/command/index.ts
@@ -1,15 +1,23 @@
import { BusEvent } from "@/bus/bus-event"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
import { SessionID, MessageID } from "@/session/schema"
+import { Effect, 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"
+import PROMPT_INITIALIZE from "./template/initialize.txt"
+import PROMPT_REVIEW from "./template/review.txt"
export namespace Command {
+ const log = Log.create({ service: "command" })
+
+ type State = {
+ commands: Record<string, Info>
+ }
+
export const Event = {
Executed: BusEvent.define(
"command.executed",
@@ -42,7 +50,7 @@ export namespace Command {
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
- export function hints(template: string): string[] {
+ export function hints(template: string) {
const result: string[] = []
const numbered = template.match(/\$\d+/g)
if (numbered) {
@@ -57,95 +65,121 @@ export namespace Command {
REVIEW: "review",
} as const
- const state = Instance.state(async () => {
- const cfg = await Config.get()
-
- const result: Record<string, Info> = {
- [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<Info | undefined>
+ readonly list: () => Effect.Effect<Info[]>
+ }
- 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<string>(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<Service, Interface>()("@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 init = Effect.fn("Command.state")(function* (ctx) {
+ const cfg = yield* Effect.promise(() => Config.get())
+ const commands: Record<string, Info> = {}
- return result
- })
+ commands[Default.INIT] = {
+ name: Default.INIT,
+ description: "create/update AGENTS.md",
+ source: "command",
+ get template() {
+ return PROMPT_INITIALIZE.replace("${path}", ctx.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}", ctx.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(yield* Effect.promise(() => MCP.prompts()))) {
+ commands[name] = {
+ name,
+ source: "mcp",
+ description: prompt.description,
+ get template() {
+ return new Promise<string>(async (resolve, reject) => {
+ const template = await MCP.getPrompt(
+ prompt.client,
+ prompt.name,
+ prompt.arguments
+ ? 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}`) ?? [],
+ }
+ }
+
+ for (const skill of yield* Effect.promise(() => Skill.all())) {
+ if (commands[skill.name]) continue
+ commands[skill.name] = {
+ name: skill.name,
+ description: skill.description,
+ source: "skill",
+ get template() {
+ return skill.content
+ },
+ hints: [],
+ }
+ }
+
+ return {
+ commands,
+ }
+ })
+
+ const cache = yield* InstanceState.make<State>((ctx) => init(ctx))
+
+ const get = Effect.fn("Command.get")(function* (name: string) {
+ const state = yield* InstanceState.get(cache)
+ return state.commands[name]
+ })
+
+ const list = Effect.fn("Command.list")(function* () {
+ const state = yield* InstanceState.get(cache)
+ return Object.values(state.commands)
+ })
+
+ return Service.of({ get, list })
+ }),
+ )
+
+ const runPromise = makeRunPromise(Service, layer)
export async function get(name: string) {
- return state().then((x) => x[name])
+ return runPromise((svc) => svc.get(name))
}
export async function list() {
- return state().then((x) => Object.values(x))
+ return runPromise((svc) => svc.list())
}
}
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 8a1081748..dca8085c5 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 f4f0c1c7d..8cbd478cb 100644
--- a/packages/opencode/test/file/watcher.test.ts
+++ b/packages/opencode/test/file/watcher.test.ts
@@ -5,9 +5,9 @@ import path from "path"
import { Deferred, Effect, Option } from "effect"
import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance"
+import { Bus } from "../../src/bus"
import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance"
-import { GlobalBus } from "../../src/bus/global"
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@@ -16,7 +16,6 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
// Helpers
// ---------------------------------------------------------------------------
-type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
/** Run `body` with a live FileWatcher service. */
@@ -36,22 +35,17 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
let done = false
- function on(evt: BusUpdate) {
+ const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
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)
- }
+ if (!check(evt.properties)) return
+ hit(evt.properties)
+ })
- function cleanup() {
+ return () => {
if (done) return
done = true
- GlobalBus.off("event", on)
+ unsub()
}
-
- GlobalBus.on("event", on)
- return cleanup
}
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {