Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions packages/opencode/script/seed-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ const seed = async () => {
await prepareConfigDependencies()

const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config")
const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema")
Expand All @@ -66,7 +65,6 @@ const seed = async () => {
try {
await Instance.provide({
directory: dir,
init: InstanceBootstrap,
fn: async () => {
await Config.waitForDependencies()

Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/src/bus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { GlobalBus } from "./global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { LocalContext } from "@/util/local-context"

export namespace Bus {
const log = Log.create({ service: "bus" })
Expand Down Expand Up @@ -177,7 +178,15 @@ export namespace Bus {
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return runPromise((svc) => svc.publish(def, properties))
try {
return await runPromise((svc) => svc.publish(def, properties))
} catch (error) {
if (!(error instanceof LocalContext.NotFound) || error.name !== "instance") throw error
GlobalBus.emit("event", {
directory: "global",
payload: { type: def.type, properties },
})
}
}

export function subscribe<D extends BusEvent.Definition>(
Expand Down
3 changes: 0 additions & 3 deletions packages/opencode/src/cli/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceBootstrap } from "../project/bootstrap"
import { Instance } from "../project/instance"

export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return Instance.provide({
directory,
init: InstanceBootstrap,
fn: async () => {
try {
const result = await cb()
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/cmd.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CommandModule } from "yargs"

type WithDoubleDash<T> = T & { "--"?: string[] }
export type WithDoubleDash<T> = T & { "--"?: string[] }

export function cmd<T, U>(input: CommandModule<T, WithDoubleDash<U>>) {
return input
Expand Down
77 changes: 35 additions & 42 deletions packages/opencode/src/cli/cmd/models.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { Argv } from "yargs"
import { Instance } from "../../project/instance"
import { Effect } from "effect"
import { Provider } from "../../provider/provider"
import { ProviderID } from "../../provider/schema"
import { ModelsDev } from "../../provider/models"
import { cmd } from "./cmd"
import { effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
import { EOL } from "os"

export const ModelsCommand = cmd({
export const ModelsCommand = effectCmd({
command: "models [provider]",
describe: "list all available models",
builder: (yargs: Argv) => {
Expand All @@ -26,53 +26,46 @@ export const ModelsCommand = cmd({
type: "boolean",
})
},
handler: async (args) => {
handler: Effect.fn("Cli.models")(function* (args) {
if (args.refresh) {
await ModelsDev.refresh(true)
yield* Effect.promise(() => ModelsDev.refresh(true))
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
}

await Instance.provide({
directory: process.cwd(),
async fn() {
const providers = await Provider.list()
const provider = yield* Provider.Service
const providers = yield* provider.list()

function printModels(providerID: ProviderID, verbose?: boolean) {
const provider = providers[providerID]
const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sortedModels) {
process.stdout.write(`${providerID}/${modelID}`)
process.stdout.write(EOL)
if (verbose) {
process.stdout.write(JSON.stringify(model, null, 2))
process.stdout.write(EOL)
}
}
function printModels(providerID: ProviderID, verbose?: boolean) {
const provider = providers[providerID]
const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sortedModels) {
process.stdout.write(`${providerID}/${modelID}`)
process.stdout.write(EOL)
if (verbose) {
process.stdout.write(JSON.stringify(model, null, 2))
process.stdout.write(EOL)
}
}
}

if (args.provider) {
const provider = providers[ProviderID.make(args.provider)]
if (!provider) {
UI.error(`Provider not found: ${args.provider}`)
return
}

printModels(ProviderID.make(args.provider), args.verbose)
return
}
if (args.provider) {
const provider = providers[ProviderID.make(args.provider)]
if (!provider) return yield* fail(`Provider not found: ${args.provider}`)

const providerIDs = Object.keys(providers).sort((a, b) => {
const aIsOpencode = a.startsWith("opencode")
const bIsOpencode = b.startsWith("opencode")
if (aIsOpencode && !bIsOpencode) return -1
if (!aIsOpencode && bIsOpencode) return 1
return a.localeCompare(b)
})
printModels(ProviderID.make(args.provider), args.verbose)
return
}

for (const providerID of providerIDs) {
printModels(ProviderID.make(providerID), args.verbose)
}
},
const providerIDs = Object.keys(providers).sort((a, b) => {
const aIsOpencode = a.startsWith("opencode")
const bIsOpencode = b.startsWith("opencode")
if (aIsOpencode && !bIsOpencode) return -1
if (!aIsOpencode && bIsOpencode) return 1
return a.localeCompare(b)
})
},

for (const providerID of providerIDs) {
printModels(ProviderID.make(providerID), args.verbose)
}
}),
})
53 changes: 53 additions & 0 deletions packages/opencode/src/cli/effect-cmd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { Argv } from "yargs"
import { Effect, Schema } from "effect"
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
import { InstanceRef } from "@/effect/instance-ref"
import { Instance } from "@/project/instance"
import { InstanceStore } from "@/project/instance-store"
import { cmd, type WithDoubleDash } from "./cmd/cmd"

export class CliError extends Schema.TaggedErrorClass<CliError>()("CliError", {
message: Schema.String,
exitCode: Schema.optional(Schema.Number),
}) {}

export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode }))

interface EffectCmdOpts<Args, A> {
command: string | readonly string[]
aliases?: string | readonly string[]
describe: string | false
builder?: (yargs: Argv) => Argv<Args>
instance?: boolean | ((args: WithDoubleDash<Args>) => boolean)
directory?: (args: WithDoubleDash<Args>) => string
handler: (args: WithDoubleDash<Args>) => Effect.Effect<A, CliError, AppServices | InstanceStore.Service>
}

export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
cmd<{}, Args>({
command: opts.command,
aliases: opts.aliases,
describe: opts.describe,
builder: opts.builder as never,
async handler(rawArgs) {
const args = rawArgs as unknown as WithDoubleDash<Args>
const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false
if (!useInstance) {
await AppRuntime.runPromise(opts.handler(args))
return
}

const directory = opts.directory?.(args) ?? process.cwd()
const { store, ctx } = await AppRuntime.runPromise(
InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),
)

try {
await Instance.restore(ctx, () =>
AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))),
)
} finally {
await AppRuntime.runPromise(store.dispose(ctx))
}
Comment thread
Astro-Han marked this conversation as resolved.
},
})
6 changes: 6 additions & 0 deletions packages/opencode/src/cli/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ function isTaggedError(error: unknown, tag: string): boolean {
}

export function FormatError(input: unknown) {
if (isTaggedError(input, "CliError")) {
const data = input as ErrorLike & { exitCode?: number }
if (data.exitCode != null) process.exitCode = data.exitCode
return data.message ?? ""
}

// MCPFailed: { name: string }
if (hasName(input, "MCPFailed")) {
return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.`
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/effect/app-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
import { ShareRuntime } from "@/share/runtime"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { InstanceLayer } from "@/project/instance-layer"

export const AppLayer = Layer.mergeAll(
Observability.layer,
Expand Down Expand Up @@ -102,10 +103,11 @@ export const AppLayer = Layer.mergeAll(
ShareNext.defaultLayer,
SessionShare.defaultLayer,
ShareRuntime.cloudShareGateDefaultLayer,
)
).pipe(Layer.provideMerge(InstanceLayer.layer))

const rt = ManagedRuntime.make(AppLayer, { memoMap })
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
export type AppServices = ManagedRuntime.ManagedRuntime.Services<typeof rt>
const wrap = (effect: Parameters<typeof rt.runSync>[0]) => attach(effect as never) as never

export const AppRuntime: Runtime = {
Expand Down
25 changes: 15 additions & 10 deletions packages/opencode/src/effect/run-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import { Effect, Fiber, Layer, ManagedRuntime } from "effect"
import * as Context from "effect/Context"
import { Instance } from "@/project/instance"
import { LocalContext } from "@/util/local-context"
Expand All @@ -24,15 +24,20 @@ export function attachWith<A, E, R>(effect: Effect.Effect<A, E, R>, refs: Refs):
}

export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try {
return attachWith(effect, {
instance: Instance.current,
workspace: WorkspaceContext.workspaceID,
})
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
return effect
const workspace = WorkspaceContext.workspaceID
const instance = (() => {
try {
return Instance.current
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
})()
if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace })
const fiber = Fiber.getCurrent()
return attachWith(effect, {
instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined),
workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined),
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
Expand Down
35 changes: 18 additions & 17 deletions packages/opencode/src/file/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,35 +74,36 @@ export namespace FileWatcher {

const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")(
function* () {
function* (ctx) {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return

log.info("init", { directory: Instance.directory })
log.info("init", { directory: ctx.directory })

const backend = getBackend()
if (!backend) {
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
log.error("watcher backend not supported", { directory: ctx.directory, platform: process.platform })
return
}

const w = watcher()
if (!w) return

log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend })

const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
)

const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
if (err) return
for (const evt of evts) {
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
}
})
const cb: ParcelWatcher.SubscribeCallback = (err, evts) =>
Instance.restore(ctx, () => {
if (err) return
for (const evt of evts) {
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
}
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const subscribe = (dir: string, ignore: string[]) => {
const pending = w.subscribe(dir, cb, { ignore, backend })
Expand All @@ -123,19 +124,19 @@ export namespace FileWatcher {
const cfgIgnores = cfg.watcher?.ignore ?? []

if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* subscribe(Instance.directory, [
yield* subscribe(ctx.directory, [
...FileIgnore.PATTERNS,
...cfgIgnores,
...protecteds(Instance.directory),
...protecteds(ctx.directory),
])
}

if (Instance.project.vcs === "git") {
if (ctx.project.vcs === "git") {
const result = yield* git.run(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree,
cwd: ctx.project.worktree,
})
const vcsDir =
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
result.exitCode === 0 ? path.resolve(ctx.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/project/bootstrap-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Context, Effect } from "effect"

export interface Interface {
readonly run: Effect.Effect<void>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export * as InstanceBootstrap from "./bootstrap-service"
Loading
Loading