From 6f9c7812d385c81914953b12073625e6e39a1d9b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 18 Mar 2026 20:57:08 -0400 Subject: [PATCH 01/10] fix(bus): tighten GlobalBus payload and BusEvent.define types Constrain BusEvent.define to ZodObject instead of ZodType so TS knows event properties are always a record. Type GlobalBus payload as { type: string; properties: Record } instead of any. Refactor watcher test to use Bus.subscribe instead of raw GlobalBus listener, removing hand-rolled event types and unnecessary casts. --- packages/opencode/src/bus/bus-event.ts | 4 ++-- packages/opencode/src/bus/global.ts | 2 +- .../opencode/src/control-plane/workspace.ts | 2 +- packages/opencode/test/file/watcher.test.ts | 20 +++++++------------ 4 files changed, 11 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index 7fe13833c86..1d9a31d4a27 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 43386dd6b20..dcc7664007e 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/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index e5294844b14..b519895b2fc 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/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index f4f0c1c7d65..8cbd478cba0 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(directory: string, body: Effect.Effect) { 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) { From 3b2a1e84157c2a250b8a47924bc67292d5449fbd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 15:14:54 -0400 Subject: [PATCH 02/10] effectify Command service: migrate from Instance.state to Effect service pattern --- packages/opencode/specs/effect-migration.md | 2 +- packages/opencode/src/command/index.ts | 221 ++++++++++++-------- 2 files changed, 135 insertions(+), 88 deletions(-) diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md index 80c906fcc82..2760d100203 100644 --- a/packages/opencode/specs/effect-migration.md +++ b/packages/opencode/specs/effect-migration.md @@ -131,7 +131,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 2c47984fdd8..5ab0a0fe645 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,15 +1,24 @@ 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" export namespace Command { + const log = Log.create({ service: "command" }) + + type State = { + commands: Record + ensure: () => Promise + } + export const Event = { Executed: BusEvent.define( "command.executed", @@ -29,8 +38,6 @@ export namespace Command { agent: z.string().optional(), model: z.string().optional(), source: z.enum(["command", "mcp", "skill"]).optional(), - // workaround for zod not supporting async functions natively so we use getters - // https://zod.dev/v4/changelog?id=zfunction template: z.promise(z.string()).or(z.string()), subtask: z.boolean().optional(), hints: z.array(z.string()), @@ -39,7 +46,6 @@ export namespace Command { ref: "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, "template"> & { template: Promise | string } export function hints(template: string): string[] { @@ -57,95 +63,136 @@ 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 cache = yield* InstanceState.make( + Effect.fn("Command.state")(function* (ctx) { + const commands: Record = {} + let task: Promise | undefined - return result - }) + async function load() { + 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}", 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(await MCP.prompts())) { + commands[name] = { + name, + source: "mcp", + description: prompt.description, + get template() { + return new Promise(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 await 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, + ensure: () => { + task ??= Effect.runPromise( + Effect.tryPromise({ + try: load, + catch: (cause) => cause, + }).pipe(Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause })))), + ) + return task + }, + } + }), + ) + + const get = Effect.fn("Command.get")(function* (name: string) { + const state = yield* InstanceState.get(cache) + yield* Effect.promise(() => state.ensure()) + return state.commands[name] + }) + + const list = Effect.fn("Command.list")(function* () { + const state = yield* InstanceState.get(cache) + yield* Effect.promise(() => state.ensure()) + 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()) } } From df260fee45d5e03abb64ab7014636a9b7f8d8389 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 15:17:00 -0400 Subject: [PATCH 03/10] handle undefined command in session prompt --- packages/opencode/src/session/prompt.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5625c571cee..ea07bd6205d 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) ?? [] From 59cc1ebc27819558bef10cee55affbc4578b562e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 16:01:48 -0400 Subject: [PATCH 04/10] use forkScoped + Fiber.join for lazy init (match old Instance.state behavior) --- packages/opencode/src/command/index.ts | 168 ++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 5ab0a0fe645..65c0925d72a 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -2,7 +2,7 @@ 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 { Effect, Fiber, Layer, ServiceMap } from "effect" import z from "zod" import { Config } from "../config/config" import PROMPT_INITIALIZE from "./template/initialize.txt" @@ -78,9 +78,17 @@ export namespace Command { const commands: Record = {} let task: Promise | undefined +<<<<<<< HEAD async function load() { const cfg = await Config.get() +||||||| parent of 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) + const commands = yield* Effect.promise(async () => { + const cfg = await Config.get() +======= + const commands: Record = {} +>>>>>>> 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) +<<<<<<< HEAD commands[Default.INIT] = { name: Default.INIT, description: "create/update AGENTS.md", @@ -153,8 +161,68 @@ export namespace Command { hints: [], } } +||||||| parent of 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) + 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), + }, + } + + 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), +======= + 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), +>>>>>>> 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) } +<<<<<<< HEAD return { commands, ensure: () => { @@ -166,20 +234,118 @@ export namespace Command { ) return task }, +||||||| parent of 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) + // 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: [], +======= + 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}`) ?? [], + } +>>>>>>> 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) + } +<<<<<<< HEAD }), ) +||||||| parent of 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) + } + + return result + }) +======= + + // 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: [], + } + } + }) + }) +>>>>>>> 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) + + const loadFiber = yield* load().pipe( + Effect.catchCause(() => Effect.void), + Effect.forkScoped, + ) const get = Effect.fn("Command.get")(function* (name: string) { +<<<<<<< HEAD const state = yield* InstanceState.get(cache) yield* Effect.promise(() => state.ensure()) return state.commands[name] +||||||| parent of 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) + return commands[name] +======= + yield* Fiber.join(loadFiber) + return commands[name] +>>>>>>> 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) }) const list = Effect.fn("Command.list")(function* () { +<<<<<<< HEAD const state = yield* InstanceState.get(cache) yield* Effect.promise(() => state.ensure()) return Object.values(state.commands) +||||||| parent of 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) + return Object.values(commands) +======= + yield* Fiber.join(loadFiber) + return Object.values(commands) +>>>>>>> 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) }) return Service.of({ get, list }) From a277fb4049f566c7269163ff988fd9aa6dce4a3b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 16:23:07 -0400 Subject: [PATCH 05/10] log errors in catchCause instead of silently swallowing --- packages/opencode/src/command/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 65c0925d72a..2f2f73f51b5 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -12,6 +12,7 @@ import { Skill } from "../skill" import { Log } from "../util/log" export namespace Command { +<<<<<<< HEAD const log = Log.create({ service: "command" }) type State = { @@ -19,6 +20,10 @@ export namespace Command { ensure: () => Promise } +||||||| parent of b53a95fd8 (log errors in catchCause instead of silently swallowing) +======= + const log = Log.create({ service: "command" }) +>>>>>>> b53a95fd8 (log errors in catchCause instead of silently swallowing) export const Event = { Executed: BusEvent.define( "command.executed", @@ -318,7 +323,7 @@ export namespace Command { >>>>>>> 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) const loadFiber = yield* load().pipe( - Effect.catchCause(() => Effect.void), + Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))), Effect.forkScoped, ) From 7053c58d35293aaec07d2302ce6a99d2b13e6940 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 21 Mar 2026 19:54:09 -0400 Subject: [PATCH 06/10] fix(command): resolve rebase artifacts --- packages/opencode/src/command/index.ts | 179 +------------------------ 1 file changed, 4 insertions(+), 175 deletions(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 2f2f73f51b5..5d46a08e8de 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -2,17 +2,16 @@ 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, Fiber, Layer, ServiceMap } from "effect" +import { Effect, Layer, ServiceMap } from "effect" import z from "zod" import { Config } from "../config/config" -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 { -<<<<<<< HEAD const log = Log.create({ service: "command" }) type State = { @@ -20,10 +19,6 @@ export namespace Command { ensure: () => Promise } -||||||| parent of b53a95fd8 (log errors in catchCause instead of silently swallowing) -======= - const log = Log.create({ service: "command" }) ->>>>>>> b53a95fd8 (log errors in catchCause instead of silently swallowing) export const Event = { Executed: BusEvent.define( "command.executed", @@ -53,7 +48,7 @@ export namespace Command { export type Info = Omit, "template"> & { template: Promise | string } - export function hints(template: string): string[] { + export function hints(template: string) { const result: string[] = [] const numbered = template.match(/\$\d+/g) if (numbered) { @@ -83,17 +78,9 @@ export namespace Command { const commands: Record = {} let task: Promise | undefined -<<<<<<< HEAD async function load() { const cfg = await Config.get() -||||||| parent of 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) - const commands = yield* Effect.promise(async () => { - const cfg = await Config.get() -======= - const commands: Record = {} ->>>>>>> 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) -<<<<<<< HEAD commands[Default.INIT] = { name: Default.INIT, description: "create/update AGENTS.md", @@ -166,68 +153,8 @@ export namespace Command { hints: [], } } -||||||| parent of 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) - 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), - }, - } - - 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), -======= - 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), ->>>>>>> 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) } -<<<<<<< HEAD return { commands, ensure: () => { @@ -239,118 +166,20 @@ export namespace Command { ) return task }, -||||||| parent of 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) - // 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: [], -======= - 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}`) ?? [], - } ->>>>>>> 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) } -<<<<<<< HEAD }), ) -||||||| parent of 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) - } - - return result - }) -======= - - // 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: [], - } - } - }) - }) ->>>>>>> 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) - - 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) { -<<<<<<< HEAD const state = yield* InstanceState.get(cache) yield* Effect.promise(() => state.ensure()) return state.commands[name] -||||||| parent of 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) - return commands[name] -======= - yield* Fiber.join(loadFiber) - return commands[name] ->>>>>>> 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) }) const list = Effect.fn("Command.list")(function* () { -<<<<<<< HEAD const state = yield* InstanceState.get(cache) yield* Effect.promise(() => state.ensure()) return Object.values(state.commands) -||||||| parent of 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) - return Object.values(commands) -======= - yield* Fiber.join(loadFiber) - return Object.values(commands) ->>>>>>> 8e11a46fe (use forkScoped + Fiber.join for lazy init (match old Instance.state behavior)) }) return Service.of({ get, list }) From addc33212b4d7e05d68b5552dbfc97bddebad92f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 21 Mar 2026 19:58:38 -0400 Subject: [PATCH 07/10] docs(command): restore zod async comments --- packages/opencode/src/command/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 5d46a08e8de..927660fa7d1 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -38,6 +38,8 @@ export namespace Command { agent: z.string().optional(), model: z.string().optional(), source: z.enum(["command", "mcp", "skill"]).optional(), + // workaround for zod not supporting async functions natively so we use getters + // https://zod.dev/v4/changelog?id=zfunction template: z.promise(z.string()).or(z.string()), subtask: z.boolean().optional(), hints: z.array(z.string()), @@ -46,6 +48,7 @@ export namespace Command { ref: "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, "template"> & { template: Promise | string } export function hints(template: string) { From b1e6ea3e886887bb8c7e1625500892ed5dee1970 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 21 Mar 2026 20:01:17 -0400 Subject: [PATCH 08/10] refactor(command): track lazy init with fiber --- packages/opencode/src/command/index.ts | 161 ++++++++++++------------- 1 file changed, 78 insertions(+), 83 deletions(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 927660fa7d1..f4bbdb48f23 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -2,7 +2,7 @@ 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 { Effect, Fiber, Layer, ServiceMap } from "effect" import z from "zod" import { Config } from "../config/config" import { MCP } from "../mcp" @@ -16,7 +16,7 @@ export namespace Command { type State = { commands: Record - ensure: () => Promise + load: Fiber.Fiber } export const Event = { @@ -79,109 +79,104 @@ export namespace Command { const cache = yield* InstanceState.make( Effect.fn("Command.state")(function* (ctx) { const commands: Record = {} - let task: Promise | undefined - - async function load() { - 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}", 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, + const load = yield* 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 command.template + return PROMPT_INITIALIZE.replace("${path}", ctx.worktree) }, - subtask: command.subtask, - hints: hints(command.template), + hints: hints(PROMPT_INITIALIZE), } - } - - for (const [name, prompt] of Object.entries(await MCP.prompts())) { - commands[name] = { - name, - source: "mcp", - description: prompt.description, + commands[Default.REVIEW] = { + name: Default.REVIEW, + description: "review changes [commit|branch|pr], defaults to uncommitted", + source: "command", get template() { - return new Promise(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") || "", - ) - }) + return PROMPT_REVIEW.replace("${path}", ctx.worktree) }, - hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [], + subtask: true, + hints: hints(PROMPT_REVIEW), } - } - - for (const skill of await Skill.all()) { - if (commands[skill.name]) continue - commands[skill.name] = { - name: skill.name, - description: skill.description, - source: "skill", - get template() { - return skill.content - }, - hints: [], + + 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() { + return new Promise(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 await Skill.all()) { + if (commands[skill.name]) continue + commands[skill.name] = { + name: skill.name, + description: skill.description, + source: "skill", + get template() { + return skill.content + }, + hints: [], + } + } + }) + })().pipe( + Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))), + Effect.forkScoped, + ) return { commands, - ensure: () => { - task ??= Effect.runPromise( - Effect.tryPromise({ - try: load, - catch: (cause) => cause, - }).pipe(Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause })))), - ) - return task - }, + load, } }), ) const get = Effect.fn("Command.get")(function* (name: string) { const state = yield* InstanceState.get(cache) - yield* Effect.promise(() => state.ensure()) + yield* Fiber.join(state.load) return state.commands[name] }) const list = Effect.fn("Command.list")(function* () { const state = yield* InstanceState.get(cache) - yield* Effect.promise(() => state.ensure()) + yield* Fiber.join(state.load) return Object.values(state.commands) }) From d1f7df0b976e64d323366d47bedf8b9b027b5464 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 21 Mar 2026 20:09:44 -0400 Subject: [PATCH 09/10] refactor(command): initialize state on first access --- packages/opencode/src/command/index.ts | 177 ++++++++++++------------- 1 file changed, 83 insertions(+), 94 deletions(-) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index f4bbdb48f23..ff938261032 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -2,7 +2,7 @@ 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, Fiber, Layer, ServiceMap } from "effect" +import { Effect, Layer, ServiceMap } from "effect" import z from "zod" import { Config } from "../config/config" import { MCP } from "../mcp" @@ -16,7 +16,6 @@ export namespace Command { type State = { commands: Record - load: Fiber.Fiber } export const Event = { @@ -76,107 +75,97 @@ export namespace Command { export const layer = Layer.effect( Service, Effect.gen(function* () { - const cache = yield* InstanceState.make( - Effect.fn("Command.state")(function* (ctx) { - const commands: Record = {} - const load = yield* 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}", 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(await MCP.prompts())) { - commands[name] = { - name, - source: "mcp", - description: prompt.description, - get template() { - return new Promise(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 await Skill.all()) { - if (commands[skill.name]) continue - commands[skill.name] = { - name: skill.name, - description: skill.description, - source: "skill", - get template() { - return skill.content - }, - hints: [], - } - } - }) - })().pipe( - Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))), - Effect.forkScoped, - ) - - return { - commands, - load, + const init = Effect.fn("Command.state")(function* (ctx) { + const cfg = yield* Effect.promise(() => Config.get()) + const commands: Record = {} + + 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(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((ctx) => init(ctx)) const get = Effect.fn("Command.get")(function* (name: string) { const state = yield* InstanceState.get(cache) - yield* Fiber.join(state.load) return state.commands[name] }) const list = Effect.fn("Command.list")(function* () { const state = yield* InstanceState.get(cache) - yield* Fiber.join(state.load) return Object.values(state.commands) }) From 68409c947106a0c977f7bf889e40b8cc9459a0f0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 21 Mar 2026 20:11:01 -0400 Subject: [PATCH 10/10] revert unrelated bus type tightening from command PR --- packages/opencode/src/bus/bus-event.ts | 4 ++-- packages/opencode/src/bus/global.ts | 2 +- packages/opencode/src/control-plane/workspace.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index 1d9a31d4a27..7fe13833c86 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 { ZodObject, ZodRawShape } from "zod" +import type { ZodType } 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 dcc7664007e..43386dd6b20 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: { type: string; properties: Record } + payload: any }, ] }>() diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index b519895b2fc..e5294844b14 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 as { type: string; properties: Record }, + payload: event, }) }) // Wait 250ms and retry if SSE connection fails