Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion packages/opencode/src/bus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@ export namespace Bus {
if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(state.wildcard, payload)

const dir = yield* InstanceState.directory
GlobalBus.emit("event", {
directory: Instance.directory,
directory: dir,
payload,
})
})
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1486,7 +1486,8 @@ export namespace Config {
})

const update = Effect.fn("Config.update")(function* (config: Info) {
const file = path.join(Instance.directory, "config.json")
const dir = yield* InstanceState.directory
const file = path.join(dir, "config.json")
const existing = yield* loadFile(file)
yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
yield* Effect.promise(() => Instance.dispose())
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/effect/instance-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ServiceMap } from "effect"
import type { InstanceContext } from "@/project/instance"

export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
defaultValue: () => undefined,
})
45 changes: 40 additions & 5 deletions packages/opencode/src/effect/instance-state.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Effect, ScopedCache, Scope } from "effect"
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
import { Instance, type InstanceContext } from "@/project/instance"
import { Context } from "@/util/context"
import { InstanceRef } from "./instance-ref"
import { registerDisposer } from "./instance-registry"

const TypeId = "~opencode/InstanceState"
Expand All @@ -10,13 +12,34 @@ export interface InstanceState<A, E = never, R = never> {
}

export namespace InstanceState {
export const bind = <F extends (...args: any[]) => any>(fn: F): F => {
try {
return Instance.bind(fn)
} catch (err) {
if (!(err instanceof Context.NotFound)) throw err
}
const fiber = Fiber.getCurrent()
const ctx = fiber ? ServiceMap.getReferenceUnsafe(fiber.services, InstanceRef) : undefined
if (!ctx) return fn
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
}

export const context = Effect.fnUntraced(function* () {
return (yield* InstanceRef) ?? Instance.current
})()

export const directory = Effect.map(context, (ctx) => ctx.directory)

export const make = <A, E = never, R = never>(
init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>,
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
Effect.gen(function* () {
const cache = yield* ScopedCache.make<string, A, E, R>({
capacity: Number.POSITIVE_INFINITY,
lookup: () => init(Instance.current),
lookup: () =>
Effect.fnUntraced(function* () {
return yield* init(yield* context)
})(),
})

const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
Expand All @@ -29,7 +52,9 @@ export namespace InstanceState {
})

export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
Effect.gen(function* () {
return yield* ScopedCache.get(self.cache, yield* directory)
})

export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
Effect.map(get(self), select)
Expand All @@ -40,8 +65,18 @@ export namespace InstanceState {
) => Effect.flatMap(get(self), select)

export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
Effect.gen(function* () {
return yield* ScopedCache.has(self.cache, yield* directory)
})

export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
Effect.gen(function* () {
return yield* ScopedCache.invalidate(self.cache, yield* directory)
})

/**
* Effect finalizers run on the fiber scheduler after the original async
* boundary, so ALS reads like Instance.directory can be gone by then.
*/
export const withALS = <T>(fn: () => T) => Effect.map(context, (ctx) => Instance.restore(ctx, fn))
}
24 changes: 19 additions & 5 deletions packages/opencode/src/effect/run-service.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import * as ServiceMap from "effect/ServiceMap"
import { Instance } from "@/project/instance"
import { Context } from "@/util/context"
import { InstanceRef } from "./instance-ref"

export const memoMap = Layer.makeMemoMapUnsafe()

function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try {
const ctx = Instance.current
return Effect.provideService(effect, InstanceRef, ctx)
} catch (err) {
if (!(err instanceof Context.NotFound)) throw err
}
return effect
}

export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap }))

return {
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(attach(service.use(fn))),
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
getRuntime().runPromiseExit(service.use(fn), options),
getRuntime().runPromiseExit(attach(service.use(fn)), options),
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
getRuntime().runPromise(service.use(fn), options),
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
getRuntime().runPromise(attach(service.use(fn)), options),
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(attach(service.use(fn))),
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) =>
getRuntime().runCallback(attach(service.use(fn))),
}
}
3 changes: 2 additions & 1 deletion packages/opencode/src/format/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,11 @@ export namespace Format {
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
log.info("running", { command: item.command })
const cmd = item.command.map((x) => x.replace("$FILE", filepath))
const dir = yield* InstanceState.directory
const code = yield* spawner
.spawn(
ChildProcess.make(cmd[0]!, cmd.slice(1), {
cwd: Instance.directory,
cwd: dir,
env: item.environment,
extendEnv: true,
}),
Expand Down
10 changes: 3 additions & 7 deletions packages/opencode/src/installation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,7 @@ import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { Flag } from "../flag/flag"
import { Log } from "../util/log"

declare global {
const OPENCODE_VERSION: string
const OPENCODE_CHANNEL: string
}
import { CHANNEL as channel, VERSION as version } from "./meta"

import semver from "semver"

Expand Down Expand Up @@ -60,8 +56,8 @@ export namespace Installation {
})
export type Info = z.infer<typeof Info>

export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
export const VERSION = version
export const CHANNEL = channel
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`

export function isPreview() {
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/installation/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
declare global {
const OPENCODE_VERSION: string
const OPENCODE_CHANNEL: string
}

export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
8 changes: 8 additions & 0 deletions packages/opencode/src/project/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ export const Instance = {
const ctx = context.use()
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
},
/**
* Run a synchronous function within the given instance context ALS.
* Use this to bridge from Effect (where InstanceRef carries context)
* back to sync code that reads Instance.directory from ALS.
*/
restore<R>(ctx: InstanceContext, fn: () => R): R {
return context.provide(ctx, fn)
},
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
return State.create(() => Instance.directory, init, dispose)
},
Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { NotFoundError } from "@/storage/db"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow } from "./overflow"

export namespace SessionCompaction {
Expand Down Expand Up @@ -213,6 +214,7 @@ When constructing the summary, try to stick to this template:
const msgs = structuredClone(messages)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model, { stripMedia: true }))
const ctx = yield* InstanceState.context
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
Expand All @@ -223,8 +225,8 @@ When constructing the summary, try to stick to this template:
variant: userMessage.variant,
summary: true,
path: {
cwd: Instance.directory,
root: Instance.worktree,
cwd: ctx.directory,
root: ctx.worktree,
},
cost: 0,
tokens: {
Expand Down
14 changes: 9 additions & 5 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Log } from "../util/log"
import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"
import { InstanceState } from "@/effect/instance-state"
import { SessionPrompt } from "./prompt"
import { fn } from "@/util/fn"
import { Command } from "../command"
Expand Down Expand Up @@ -382,11 +383,12 @@ export namespace Session {
directory: string
permission?: Permission.Ruleset
}) {
const ctx = yield* InstanceState.context
const result: Info = {
id: SessionID.descending(input.id),
slug: Slug.create(),
version: Installation.VERSION,
projectID: Instance.project.id,
projectID: ctx.project.id,
directory: input.directory,
workspaceID: input.workspaceID,
parentID: input.parentID,
Expand Down Expand Up @@ -444,12 +446,12 @@ export namespace Session {
})

const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
const project = Instance.project
const ctx = yield* InstanceState.context
const rows = yield* db((d) =>
d
.select()
.from(SessionTable)
.where(and(eq(SessionTable.project_id, project.id), eq(SessionTable.parent_id, parentID)))
.where(and(eq(SessionTable.project_id, ctx.project.id), eq(SessionTable.parent_id, parentID)))
.all(),
)
return rows.map(fromRow)
Expand Down Expand Up @@ -496,20 +498,22 @@ export namespace Session {
permission?: Permission.Ruleset
workspaceID?: WorkspaceID
}) {
const directory = yield* InstanceState.directory
return yield* createNext({
parentID: input?.parentID,
directory: Instance.directory,
directory,
title: input?.title,
permission: input?.permission,
workspaceID: input?.workspaceID,
})
})

const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
const directory = yield* InstanceState.directory
const original = yield* get(input.sessionID)
const title = getForkedTitle(original.title)
const session = yield* createNext({
directory: Instance.directory,
directory,
workspaceID: original.workspaceID,
title,
})
Expand Down
18 changes: 11 additions & 7 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export namespace SessionPrompt {
})

const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
const ctx = yield* InstanceState.context
const parts: PromptInput["parts"] = [{ type: "text", text: template }]
const files = ConfigMarkdown.files(template)
const seen = new Set<string>()
Expand All @@ -159,7 +160,7 @@ export namespace SessionPrompt {
seen.add(name)
const filepath = name.startsWith("~/")
? path.join(os.homedir(), name.slice(2))
: path.resolve(Instance.worktree, name)
: path.resolve(ctx.worktree, name)

const info = yield* fsys.stat(filepath).pipe(Effect.option)
if (Option.isNone(info)) {
Expand Down Expand Up @@ -553,6 +554,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
msgs: MessageV2.WithParts[]
}) {
const { task, model, lastUser, sessionID, session, msgs } = input
const ctx = yield* InstanceState.context
const taskTool = yield* Effect.promise(() => TaskTool.init())
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
Expand All @@ -563,7 +565,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
mode: task.agent,
agent: task.agent,
variant: lastUser.variant,
path: { cwd: Instance.directory, root: Instance.worktree },
path: { cwd: ctx.directory, root: ctx.worktree },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: taskModel.id,
Expand Down Expand Up @@ -734,6 +736,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})

const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, signal: AbortSignal) {
const ctx = yield* InstanceState.context
const session = yield* sessions.get(input.sessionID)
if (session.revert) {
yield* Effect.promise(() => SessionRevert.cleanup(session))
Expand Down Expand Up @@ -773,7 +776,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
mode: input.agent,
agent: input.agent,
cost: 0,
path: { cwd: Instance.directory, root: Instance.worktree },
path: { cwd: ctx.directory, root: ctx.worktree },
time: { created: Date.now() },
role: "assistant",
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
Expand Down Expand Up @@ -832,7 +835,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}

const args = (invocations[shellName] ?? invocations[""]).args
const cwd = Instance.directory
const cwd = ctx.directory
const shellEnv = yield* plugin.trigger(
"shell.env",
{ cwd, sessionID: input.sessionID, callID: part.callID },
Expand Down Expand Up @@ -976,7 +979,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
variant,
}

yield* Effect.addFinalizer(() => Effect.sync(() => InstructionPrompt.clear(info.id)))
yield* Effect.addFinalizer(() => InstanceState.withALS(() => InstructionPrompt.clear(info.id)))

type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
Expand Down Expand Up @@ -1330,6 +1333,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the

const runLoop: (sessionID: SessionID) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.run")(
function* (sessionID: SessionID) {
const ctx = yield* InstanceState.context
let structured: unknown | undefined
let step = 0
const session = yield* sessions.get(sessionID)
Expand Down Expand Up @@ -1421,7 +1425,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
mode: agent.name,
agent: agent.name,
variant: lastUser.variant,
path: { cwd: Instance.directory, root: Instance.worktree },
path: { cwd: ctx.directory, root: ctx.worktree },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: model.id,
Expand Down Expand Up @@ -1538,7 +1542,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}),
Effect.fnUntraced(function* (exit) {
if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort()
InstructionPrompt.clear(handle.message.id)
yield* InstanceState.withALS(() => InstructionPrompt.clear(handle.message.id))
}),
)
if (outcome === "break") break
Expand Down
Loading
Loading