Compare commits

...

10 Commits

Author SHA1 Message Date
Kit Langton 68409c9471 revert unrelated bus type tightening from command PR 2026-03-21 20:11:01 -04:00
Kit Langton d1f7df0b97 refactor(command): initialize state on first access 2026-03-21 20:09:44 -04:00
Kit Langton b1e6ea3e88 refactor(command): track lazy init with fiber 2026-03-21 20:01:17 -04:00
Kit Langton addc33212b docs(command): restore zod async comments 2026-03-21 19:58:38 -04:00
Kit Langton 7053c58d35 fix(command): resolve rebase artifacts 2026-03-21 19:54:09 -04:00
Kit Langton a277fb4049 log errors in catchCause instead of silently swallowing 2026-03-21 14:26:01 -04:00
Kit Langton 59cc1ebc27 use forkScoped + Fiber.join for lazy init (match old Instance.state behavior) 2026-03-21 14:25:57 -04:00
Kit Langton df260fee45 handle undefined command in session prompt 2026-03-21 14:25:50 -04:00
Kit Langton 3b2a1e8415 effectify Command service: migrate from Instance.state to Effect service pattern 2026-03-21 14:25:50 -04:00
Kit Langton 6f9c7812d3 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<string, unknown> } instead of any.

Refactor watcher test to use Bus.subscribe instead of raw GlobalBus
listener, removing hand-rolled event types and unnecessary casts.
2026-03-21 14:24:51 -04:00
4 changed files with 131 additions and 100 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ Still open and likely worth migrating:
- [ ] `Pty` - [ ] `Pty`
- [ ] `Worktree` - [ ] `Worktree`
- [ ] `Bus` - [ ] `Bus`
- [ ] `Command` - [x] `Command`
- [ ] `Config` - [ ] `Config`
- [ ] `Session` - [ ] `Session`
- [ ] `SessionProcessor` - [ ] `SessionProcessor`
+62 -28
View File
@@ -1,15 +1,23 @@
import { BusEvent } from "@/bus/bus-event" 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 { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, ServiceMap } from "effect"
import z from "zod" import z from "zod"
import { Config } from "../config/config" 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 { MCP } from "../mcp"
import { Skill } from "../skill" 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 { export namespace Command {
const log = Log.create({ service: "command" })
type State = {
commands: Record<string, Info>
}
export const Event = { export const Event = {
Executed: BusEvent.define( Executed: BusEvent.define(
"command.executed", "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 // 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 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 result: string[] = []
const numbered = template.match(/\$\d+/g) const numbered = template.match(/\$\d+/g)
if (numbered) { if (numbered) {
@@ -57,33 +65,42 @@ export namespace Command {
REVIEW: "review", REVIEW: "review",
} as const } as const
const state = Instance.state(async () => { export interface Interface {
const cfg = await Config.get() readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
const result: Record<string, Info> = { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Command") {}
[Default.INIT]: {
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> = {}
commands[Default.INIT] = {
name: Default.INIT, name: Default.INIT,
description: "create/update AGENTS.md", description: "create/update AGENTS.md",
source: "command", source: "command",
get template() { get template() {
return PROMPT_INITIALIZE.replace("${path}", Instance.worktree) return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
}, },
hints: hints(PROMPT_INITIALIZE), hints: hints(PROMPT_INITIALIZE),
}, }
[Default.REVIEW]: { commands[Default.REVIEW] = {
name: Default.REVIEW, name: Default.REVIEW,
description: "review changes [commit|branch|pr], defaults to uncommitted", description: "review changes [commit|branch|pr], defaults to uncommitted",
source: "command", source: "command",
get template() { get template() {
return PROMPT_REVIEW.replace("${path}", Instance.worktree) return PROMPT_REVIEW.replace("${path}", ctx.worktree)
}, },
subtask: true, subtask: true,
hints: hints(PROMPT_REVIEW), hints: hints(PROMPT_REVIEW),
},
} }
for (const [name, command] of Object.entries(cfg.command ?? {})) { for (const [name, command] of Object.entries(cfg.command ?? {})) {
result[name] = { commands[name] = {
name, name,
agent: command.agent, agent: command.agent,
model: command.model, model: command.model,
@@ -96,20 +113,19 @@ export namespace Command {
hints: hints(command.template), hints: hints(command.template),
} }
} }
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
result[name] = { for (const [name, prompt] of Object.entries(yield* Effect.promise(() => MCP.prompts()))) {
commands[name] = {
name, name,
source: "mcp", source: "mcp",
description: prompt.description, description: prompt.description,
get template() { get template() {
// since a getter can't be async we need to manually return a promise here
return new Promise<string>(async (resolve, reject) => { return new Promise<string>(async (resolve, reject) => {
const template = await MCP.getPrompt( const template = await MCP.getPrompt(
prompt.client, prompt.client,
prompt.name, prompt.name,
prompt.arguments prompt.arguments
? // substitute each argument with $1, $2, etc. ? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`]))
Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
: {}, : {},
).catch(reject) ).catch(reject)
resolve( resolve(
@@ -123,11 +139,9 @@ export namespace Command {
} }
} }
// Add skills as invokable commands for (const skill of yield* Effect.promise(() => Skill.all())) {
for (const skill of await Skill.all()) { if (commands[skill.name]) continue
// Skip if a command with this name already exists commands[skill.name] = {
if (result[skill.name]) continue
result[skill.name] = {
name: skill.name, name: skill.name,
description: skill.description, description: skill.description,
source: "skill", source: "skill",
@@ -138,14 +152,34 @@ export namespace Command {
} }
} }
return result 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) { export async function get(name: string) {
return state().then((x) => x[name]) return runPromise((svc) => svc.get(name))
} }
export async function list() { export async function list() {
return state().then((x) => Object.values(x)) return runPromise((svc) => svc.list())
} }
} }
+3
View File
@@ -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) { export async function command(input: CommandInput) {
log.info("command", input) log.info("command", input)
const command = await Command.get(input.command) 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 agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())
const raw = input.arguments.match(argsRegex) ?? [] const raw = input.arguments.match(argsRegex) ?? []
+7 -13
View File
@@ -5,9 +5,9 @@ import path from "path"
import { Deferred, Effect, Option } from "effect" import { Deferred, Effect, Option } from "effect"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance" import { watcherConfigLayer, withServices } from "../fixture/instance"
import { Bus } from "../../src/bus"
import { FileWatcher } from "../../src/file/watcher" import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance" 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) // 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 const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@@ -16,7 +16,6 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
/** Run `body` with a live FileWatcher service. */ /** 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) { function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
let done = false let done = false
function on(evt: BusUpdate) { const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
if (done) return if (done) return
if (evt.directory !== directory) return if (!check(evt.properties)) return
if (evt.payload.type !== FileWatcher.Event.Updated.type) return hit(evt.properties)
if (!check(evt.payload.properties)) return })
hit(evt.payload.properties)
}
function cleanup() { return () => {
if (done) return if (done) return
done = true done = true
GlobalBus.off("event", on) unsub()
} }
GlobalBus.on("event", on)
return cleanup
} }
function wait(directory: string, check: (evt: WatcherEvent) => boolean) { function wait(directory: string, check: (evt: WatcherEvent) => boolean) {