From 4cb29967f6e09828daab404ad4c14274bae2bb97 Mon Sep 17 00:00:00 2001 From: DS <78942835+Tarquinen@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:32:53 -0400 Subject: [PATCH 001/112] fix(opencode): apply message transforms during compaction (#17823) --- packages/opencode/src/session/compaction.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 8d934c05da..072ea1d574 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -200,6 +200,8 @@ When constructing the summary, try to stick to this template: ---` const promptText = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n") + const msgs = structuredClone(messages) + await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const result = await processor.process({ user: userMessage, agent, @@ -208,7 +210,7 @@ When constructing the summary, try to stick to this template: tools: {}, system: [], messages: [ - ...MessageV2.toModelMessages(messages, model, { stripMedia: true }), + ...MessageV2.toModelMessages(msgs, model, { stripMedia: true }), { role: "user", content: [ From 469c3a4204310aa3b87f2355122d392baad312df Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 16 Mar 2026 12:55:14 -0400 Subject: [PATCH 002/112] refactor(instance): move scoped services to LayerMap (#17544) --- .../opencode/src/effect/instance-registry.ts | 12 + packages/opencode/src/effect/instances.ts | 52 ++++ packages/opencode/src/effect/runtime.ts | 13 +- packages/opencode/src/permission/next.ts | 35 +-- packages/opencode/src/permission/service.ts | 56 ++-- packages/opencode/src/project/instance.ts | 14 +- .../opencode/src/provider/auth-service.ts | 57 ++-- packages/opencode/src/provider/auth.ts | 26 +- packages/opencode/src/question/index.ts | 15 +- packages/opencode/src/question/service.ts | 11 +- packages/opencode/src/util/instance-state.ts | 63 ----- .../opencode/test/permission/next.test.ts | 10 +- packages/opencode/test/provider/auth.test.ts | 20 -- .../opencode/test/question/question.test.ts | 6 +- .../opencode/test/util/instance-state.test.ts | 261 ------------------ 15 files changed, 154 insertions(+), 497 deletions(-) create mode 100644 packages/opencode/src/effect/instance-registry.ts create mode 100644 packages/opencode/src/effect/instances.ts delete mode 100644 packages/opencode/src/util/instance-state.ts delete mode 100644 packages/opencode/test/provider/auth.test.ts delete mode 100644 packages/opencode/test/util/instance-state.test.ts diff --git a/packages/opencode/src/effect/instance-registry.ts b/packages/opencode/src/effect/instance-registry.ts new file mode 100644 index 0000000000..59c556e044 --- /dev/null +++ b/packages/opencode/src/effect/instance-registry.ts @@ -0,0 +1,12 @@ +const disposers = new Set<(directory: string) => Promise>() + +export function registerDisposer(disposer: (directory: string) => Promise) { + disposers.add(disposer) + return () => { + disposers.delete(disposer) + } +} + +export async function disposeInstance(directory: string) { + await Promise.allSettled([...disposers].map((disposer) => disposer(directory))) +} diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts new file mode 100644 index 0000000000..02d4bf4823 --- /dev/null +++ b/packages/opencode/src/effect/instances.ts @@ -0,0 +1,52 @@ +import { Effect, Layer, LayerMap, ServiceMap } from "effect" +import { registerDisposer } from "./instance-registry" +import { ProviderAuthService } from "@/provider/auth-service" +import { QuestionService } from "@/question/service" +import { PermissionService } from "@/permission/service" +import { Instance } from "@/project/instance" +import type { Project } from "@/project/project" + +export declare namespace InstanceContext { + export interface Shape { + readonly directory: string + readonly project: Project.Info + } +} + +export class InstanceContext extends ServiceMap.Service()( + "opencode/InstanceContext", +) {} + +export type InstanceServices = QuestionService | PermissionService | ProviderAuthService + +function lookup(directory: string) { + const project = Instance.project + const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project })) + return Layer.mergeAll( + Layer.fresh(QuestionService.layer), + Layer.fresh(PermissionService.layer), + Layer.fresh(ProviderAuthService.layer), + ).pipe(Layer.provide(ctx)) +} + +export class Instances extends ServiceMap.Service>()( + "opencode/Instances", +) { + static readonly layer = Layer.effect( + Instances, + Effect.gen(function* () { + const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity }) + const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory))) + yield* Effect.addFinalizer(() => Effect.sync(unregister)) + return Instances.of(layerMap) + }), + ) + + static get(directory: string): Layer.Layer { + return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory)))) + } + + static invalidate(directory: string): Effect.Effect { + return Instances.use((map) => map.invalidate(directory)) + } +} diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index 4aec46befa..02a7391d44 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,9 +1,14 @@ -import { Layer, ManagedRuntime } from "effect" +import { Effect, Layer, ManagedRuntime } from "effect" import { AccountService } from "@/account/service" import { AuthService } from "@/auth/service" -import { PermissionService } from "@/permission/service" -import { QuestionService } from "@/question/service" +import { Instances } from "@/effect/instances" +import type { InstanceServices } from "@/effect/instances" +import { Instance } from "@/project/instance" export const runtime = ManagedRuntime.make( - Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer, PermissionService.layer, QuestionService.layer), + Layer.mergeAll(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)), ) + +export function runPromiseInstance(effect: Effect.Effect) { + return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory)))) +} diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 7fcd40eea0..6a65a6f2e9 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -1,18 +1,9 @@ -import { runtime } from "@/effect/runtime" +import { runPromiseInstance } from "@/effect/runtime" import { Config } from "@/config/config" import { fn } from "@/util/fn" import { Wildcard } from "@/util/wildcard" -import { Effect } from "effect" import os from "os" import * as S from "./service" -import type { - Action as ActionType, - PermissionError, - Reply as ReplyType, - Request as RequestType, - Rule as RuleType, - Ruleset as RulesetType, -} from "./service" export namespace PermissionNext { function expand(pattern: string): string { @@ -23,20 +14,16 @@ export namespace PermissionNext { return pattern } - function runPromise(f: (service: S.PermissionService.Api) => Effect.Effect) { - return runtime.runPromise(S.PermissionService.use(f)) - } - export const Action = S.Action - export type Action = ActionType + export type Action = S.Action export const Rule = S.Rule - export type Rule = RuleType + export type Rule = S.Rule export const Ruleset = S.Ruleset - export type Ruleset = RulesetType + export type Ruleset = S.Ruleset export const Request = S.Request - export type Request = RequestType + export type Request = S.Request export const Reply = S.Reply - export type Reply = ReplyType + export type Reply = S.Reply export const Approval = S.Approval export const Event = S.Event export const Service = S.PermissionService @@ -66,12 +53,16 @@ export namespace PermissionNext { return rulesets.flat() } - export const ask = fn(S.AskInput, async (input) => runPromise((service) => service.ask(input))) + export const ask = fn(S.AskInput, async (input) => + runPromiseInstance(S.PermissionService.use((service) => service.ask(input))), + ) - export const reply = fn(S.ReplyInput, async (input) => runPromise((service) => service.reply(input))) + export const reply = fn(S.ReplyInput, async (input) => + runPromiseInstance(S.PermissionService.use((service) => service.reply(input))), + ) export async function list() { - return runPromise((service) => service.list()) + return runPromiseInstance(S.PermissionService.use((service) => service.list())) } export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts index 2782c0aba1..b790158d16 100644 --- a/packages/opencode/src/permission/service.ts +++ b/packages/opencode/src/permission/service.ts @@ -1,11 +1,10 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" -import { Instance } from "@/project/instance" +import { InstanceContext } from "@/effect/instances" import { ProjectID } from "@/project/schema" import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { Database, eq } from "@/storage/db" -import { InstanceState } from "@/util/instance-state" import { Log } from "@/util/log" import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" @@ -104,11 +103,6 @@ interface PendingEntry { deferred: Deferred.Deferred } -type State = { - pending: Map - approved: Ruleset -} - export const AskInput = Request.partial({ id: true }).extend({ ruleset: Ruleset, }) @@ -133,25 +127,19 @@ export class PermissionService extends ServiceMap.Service(() => - Effect.sync(() => { - const row = Database.use((db) => - db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Instance.project.id)).get(), - ) - return { - pending: new Map(), - approved: row?.data ?? [], - } - }), + const { project } = yield* InstanceContext + const row = Database.use((db) => + db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(), ) + const pending = new Map() + const approved: Ruleset = row?.data ?? [] const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer) { - const state = yield* InstanceState.get(instanceState) const { ruleset, ...request } = input - let pending = false + let needsAsk = false for (const pattern of request.patterns) { - const rule = evaluate(request.permission, pattern, ruleset, state.approved) + const rule = evaluate(request.permission, pattern, ruleset, approved) log.info("evaluated", { permission: request.permission, pattern, action: rule }) if (rule.action === "deny") { return yield* new DeniedError({ @@ -159,10 +147,10 @@ export class PermissionService extends ServiceMap.Service() - state.pending.set(id, { info, deferred }) + pending.set(id, { info, deferred }) void Bus.publish(Event.Asked, info) return yield* Effect.ensuring( Deferred.await(deferred), Effect.sync(() => { - state.pending.delete(id) + pending.delete(id) }), ) }) const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer) { - const state = yield* InstanceState.get(instanceState) - const existing = state.pending.get(input.requestID) + const existing = pending.get(input.requestID) if (!existing) return - state.pending.delete(input.requestID) + pending.delete(input.requestID) void Bus.publish(Event.Replied, { sessionID: existing.info.sessionID, requestID: existing.info.id, @@ -200,9 +187,9 @@ export class PermissionService extends ServiceMap.Service evaluate(item.info.permission, pattern, state.approved).action === "allow", + (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow", ) if (!ok) continue - state.pending.delete(id) + pending.delete(id) void Bus.publish(Event.Replied, { sessionID: item.info.sessionID, requestID: item.info.id, @@ -246,8 +233,7 @@ export class PermissionService extends ServiceMap.Service item.info) + return Array.from(pending.values(), (item) => item.info) }) return PermissionService.of({ ask, reply, list }) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index dac5e71ba1..fd3cc640a3 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,4 +1,3 @@ -import { Effect } from "effect" import { Log } from "@/util/log" import { Context } from "../util/context" import { Project } from "./project" @@ -6,7 +5,7 @@ import { State } from "./state" import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" import { Filesystem } from "@/util/filesystem" -import { InstanceState } from "@/util/instance-state" +import { disposeInstance } from "@/effect/instance-registry" interface Context { directory: string @@ -108,17 +107,18 @@ export const Instance = { async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { const directory = Filesystem.resolve(input.directory) Log.Default.info("reloading instance", { directory }) - await Promise.all([State.dispose(directory), Effect.runPromise(InstanceState.dispose(directory))]) + await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(directory) const next = track(directory, boot({ ...input, directory })) emit(directory) return await next }, async dispose() { - Log.Default.info("disposing instance", { directory: Instance.directory }) - await Promise.all([State.dispose(Instance.directory), Effect.runPromise(InstanceState.dispose(Instance.directory))]) - cache.delete(Instance.directory) - emit(Instance.directory) + const directory = Instance.directory + Log.Default.info("disposing instance", { directory }) + await Promise.all([State.dispose(directory), disposeInstance(directory)]) + cache.delete(directory) + emit(directory) }, async disposeAll() { if (disposal.all) return disposal.all diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts index 2d9cec5cd8..2e99859398 100644 --- a/packages/opencode/src/provider/auth-service.ts +++ b/packages/opencode/src/provider/auth-service.ts @@ -1,12 +1,9 @@ -import { Effect, Layer, Record, ServiceMap, Struct } from "effect" -import { Instance } from "@/project/instance" -import { Plugin } from "../plugin" -import { filter, fromEntries, map, pipe } from "remeda" import type { AuthOuathResult } from "@opencode-ai/plugin" import { NamedError } from "@opencode-ai/util/error" import * as Auth from "@/auth/service" -import { InstanceState } from "@/util/instance-state" import { ProviderID } from "./schema" +import { Effect, Layer, Record, ServiceMap, Struct } from "effect" +import { filter, fromEntries, map, pipe } from "remeda" import z from "zod" export const Method = z @@ -54,21 +51,13 @@ export type ProviderAuthError = export namespace ProviderAuthService { export interface Service { - /** Get available auth methods for each provider (e.g. OAuth, API key). */ readonly methods: () => Effect.Effect> - - /** Start an OAuth authorization flow for a provider. Returns the URL to redirect to. */ readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect - - /** Complete an OAuth flow after the user has authorized. Exchanges the code/callback for credentials. */ readonly callback: (input: { providerID: ProviderID method: number code?: string }) => Effect.Effect - - /** Set an API key directly for a provider (no OAuth flow). */ - readonly api: (input: { providerID: ProviderID; key: string }) => Effect.Effect } } @@ -79,32 +68,29 @@ export class ProviderAuthService extends ServiceMap.Service - Effect.promise(async () => { - const methods = pipe( - await Plugin.list(), - filter((x) => x.auth?.provider !== undefined), - map((x) => [x.auth!.provider, x.auth!] as const), - fromEntries(), - ) - return { methods, pending: new Map() } - }), - ) + const hooks = yield* Effect.promise(async () => { + const mod = await import("../plugin") + return pipe( + await mod.Plugin.list(), + filter((x) => x.auth?.provider !== undefined), + map((x) => [x.auth!.provider, x.auth!] as const), + fromEntries(), + ) + }) + const pending = new Map() const methods = Effect.fn("ProviderAuthService.methods")(function* () { - const x = yield* InstanceState.get(state) - return Record.map(x.methods, (y) => y.methods.map((z): Method => Struct.pick(z, ["type", "label"]))) + return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"]))) }) const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: { providerID: ProviderID method: number }) { - const s = yield* InstanceState.get(state) - const method = s.methods[input.providerID].methods[input.method] + const method = hooks[input.providerID].methods[input.method] if (method.type !== "oauth") return const result = yield* Effect.promise(() => method.authorize()) - s.pending.set(input.providerID, result) + pending.set(input.providerID, result) return { url: result.url, method: result.method, @@ -117,17 +103,14 @@ export class ProviderAuthService extends ServiceMap.Service match.method === "code" ? match.callback(input.code!) : match.callback(), ) - if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({})) if ("key" in result) { @@ -148,18 +131,10 @@ export class ProviderAuthService extends ServiceMap.Service(f: (service: S.ProviderAuthService.Service) => Effect.Effect) { - return rt.runPromise(S.ProviderAuthService.use(f)) -} - export namespace ProviderAuth { export const Method = S.Method export type Method = S.Method export async function methods() { - return runPromise((service) => service.methods()) + return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods())) } export const Authorization = S.Authorization @@ -30,7 +21,8 @@ export namespace ProviderAuth { providerID: ProviderID.zod, method: z.number(), }), - async (input): Promise => runPromise((service) => service.authorize(input)), + async (input): Promise => + runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))), ) export const callback = fn( @@ -39,15 +31,7 @@ export namespace ProviderAuth { method: z.number(), code: z.string().optional(), }), - async (input) => runPromise((service) => service.callback(input)), - ) - - export const api = fn( - z.object({ - providerID: ProviderID.zod, - key: z.string(), - }), - async (input) => runPromise((service) => service.api(input)), + async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))), ) export import OauthMissing = S.OauthMissing diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 6ace981a9f..7fffc0c877 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,13 +1,8 @@ -import { Effect } from "effect" -import { runtime } from "@/effect/runtime" +import { runPromiseInstance } from "@/effect/runtime" import * as S from "./service" import type { QuestionID } from "./schema" import type { SessionID, MessageID } from "@/session/schema" -function runPromise(f: (service: S.QuestionService.Service) => Effect.Effect) { - return runtime.runPromise(S.QuestionService.use(f)) -} - export namespace Question { export const Option = S.Option export type Option = S.Option @@ -27,18 +22,18 @@ export namespace Question { questions: Info[] tool?: { messageID: MessageID; callID: string } }): Promise { - return runPromise((service) => service.ask(input)) + return runPromiseInstance(S.QuestionService.use((service) => service.ask(input))) } export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise { - return runPromise((service) => service.reply(input)) + return runPromiseInstance(S.QuestionService.use((service) => service.reply(input))) } export async function reject(requestID: QuestionID): Promise { - return runPromise((service) => service.reject(requestID)) + return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID))) } export async function list(): Promise { - return runPromise((service) => service.list()) + return runPromiseInstance(S.QuestionService.use((service) => service.list())) } } diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts index 30a47bee9f..3df8286e6d 100644 --- a/packages/opencode/src/question/service.ts +++ b/packages/opencode/src/question/service.ts @@ -2,7 +2,6 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { SessionID, MessageID } from "@/session/schema" -import { InstanceState } from "@/util/instance-state" import { Log } from "@/util/log" import z from "zod" import { QuestionID } from "./schema" @@ -104,18 +103,13 @@ export class QuestionService extends ServiceMap.Service>(() => - Effect.succeed(new Map()), - ) - - const getPending = InstanceState.get(instanceState) + const pending = new Map() const ask = Effect.fn("QuestionService.ask")(function* (input: { sessionID: SessionID questions: Info[] tool?: { messageID: MessageID; callID: string } }) { - const pending = yield* getPending const id = QuestionID.ascending() log.info("asking", { id, questions: input.questions.length }) @@ -138,7 +132,6 @@ export class QuestionService extends ServiceMap.Service x.info) }) diff --git a/packages/opencode/src/util/instance-state.ts b/packages/opencode/src/util/instance-state.ts deleted file mode 100644 index 4e5d36cf48..0000000000 --- a/packages/opencode/src/util/instance-state.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Effect, ScopedCache, Scope } from "effect" - -import { Instance } from "@/project/instance" - -type Disposer = (directory: string) => Effect.Effect -const disposers = new Set() - -const TypeId = "~opencode/InstanceState" - -/** - * Effect version of `Instance.state` — lazily-initialized, per-directory - * cached state for Effect services. - * - * Values are created on first access for a given directory and cached for - * subsequent reads. Concurrent access shares a single initialization — - * no duplicate work or races. Use `Effect.acquireRelease` in `init` if - * the value needs cleanup on disposal. - */ -export interface InstanceState { - readonly [TypeId]: typeof TypeId - readonly cache: ScopedCache.ScopedCache -} - -export namespace InstanceState { - /** Create a new InstanceState with the given initializer. */ - export const make = ( - init: (directory: string) => Effect.Effect, - ): Effect.Effect>, never, R | Scope.Scope> => - Effect.gen(function* () { - const cache = yield* ScopedCache.make({ - capacity: Number.POSITIVE_INFINITY, - lookup: init, - }) - - const disposer: Disposer = (directory) => ScopedCache.invalidate(cache, directory) - disposers.add(disposer) - yield* Effect.addFinalizer(() => Effect.sync(() => void disposers.delete(disposer))) - - return { - [TypeId]: TypeId, - cache, - } - }) - - /** Get the cached value for the current directory, initializing it if needed. */ - export const get = (self: InstanceState) => - Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory)) - - /** Check whether a value exists for the current directory. */ - export const has = (self: InstanceState) => - Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory)) - - /** Invalidate the cached value for the current directory. */ - export const invalidate = (self: InstanceState) => - Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory)) - - /** Invalidate the given directory across all InstanceState caches. */ - export const dispose = (directory: string) => - Effect.all( - [...disposers].map((disposer) => disposer(directory)), - { concurrency: "unbounded" }, - ) -} diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index cd4775acea..2e9195c288 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,7 +1,9 @@ -import { test, expect } from "bun:test" +import { afterEach, test, expect } from "bun:test" import os from "os" +import { Effect } from "effect" import { Bus } from "../../src/bus" import { runtime } from "../../src/effect/runtime" +import { Instances } from "../../src/effect/instances" import { PermissionNext } from "../../src/permission/next" import * as S from "../../src/permission/service" import { PermissionID } from "../../src/permission/schema" @@ -9,6 +11,10 @@ import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import { MessageID, SessionID } from "../../src/session/schema" +afterEach(async () => { + await Instance.disposeAll() +}) + async function rejectAll(message?: string) { for (const req of await PermissionNext.list()) { await PermissionNext.reply({ @@ -1020,7 +1026,7 @@ test("ask - abort should clear pending request", async () => { always: [], ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], }), - ), + ).pipe(Effect.provide(Instances.get(Instance.directory))), { signal: ctl.signal }, ) diff --git a/packages/opencode/test/provider/auth.test.ts b/packages/opencode/test/provider/auth.test.ts deleted file mode 100644 index 99babd44a6..0000000000 --- a/packages/opencode/test/provider/auth.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { afterEach, expect, test } from "bun:test" -import { Auth } from "../../src/auth" -import { ProviderAuth } from "../../src/provider/auth" -import { ProviderID } from "../../src/provider/schema" - -afterEach(async () => { - await Auth.remove("test-provider-auth") -}) - -test("ProviderAuth.api persists auth via AuthService", async () => { - await ProviderAuth.api({ - providerID: ProviderID.make("test-provider-auth"), - key: "sk-test", - }) - - expect(await Auth.get("test-provider-auth")).toEqual({ - type: "api", - key: "sk-test", - }) -}) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index ab5bc1d99e..45e0d3c318 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,10 +1,14 @@ -import { test, expect } from "bun:test" +import { afterEach, test, expect } from "bun:test" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" import { QuestionID } from "../../src/question/schema" import { tmpdir } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" +afterEach(async () => { + await Instance.disposeAll() +}) + /** Reject all pending questions so dangling Deferred fibers don't hang the test. */ async function rejectAll() { const pending = await Question.list() diff --git a/packages/opencode/test/util/instance-state.test.ts b/packages/opencode/test/util/instance-state.test.ts deleted file mode 100644 index 976b7d07ec..0000000000 --- a/packages/opencode/test/util/instance-state.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { afterEach, expect, test } from "bun:test" -import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect" - -import { Instance } from "../../src/project/instance" -import { InstanceState } from "../../src/util/instance-state" -import { tmpdir } from "../fixture/fixture" - -async function access(state: InstanceState, dir: string) { - return Instance.provide({ - directory: dir, - fn: () => Effect.runPromise(InstanceState.get(state)), - }) -} - -afterEach(async () => { - await Instance.disposeAll() -}) - -test("InstanceState caches values for the same instance", async () => { - await using tmp = await tmpdir() - let n = 0 - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n }))) - - const a = yield* Effect.promise(() => access(state, tmp.path)) - const b = yield* Effect.promise(() => access(state, tmp.path)) - - expect(a).toBe(b) - expect(n).toBe(1) - }), - ), - ) -}) - -test("InstanceState isolates values by directory", async () => { - await using a = await tmpdir() - await using b = await tmpdir() - let n = 0 - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n }))) - - const x = yield* Effect.promise(() => access(state, a.path)) - const y = yield* Effect.promise(() => access(state, b.path)) - const z = yield* Effect.promise(() => access(state, a.path)) - - expect(x).toBe(z) - expect(x).not.toBe(y) - expect(n).toBe(2) - }), - ), - ) -}) - -test("InstanceState is disposed on instance reload", async () => { - await using tmp = await tmpdir() - const seen: string[] = [] - let n = 0 - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make(() => - Effect.acquireRelease( - Effect.sync(() => ({ n: ++n })), - (value) => - Effect.sync(() => { - seen.push(String(value.n)) - }), - ), - ) - - const a = yield* Effect.promise(() => access(state, tmp.path)) - yield* Effect.promise(() => Instance.reload({ directory: tmp.path })) - const b = yield* Effect.promise(() => access(state, tmp.path)) - - expect(a).not.toBe(b) - expect(seen).toEqual(["1"]) - }), - ), - ) -}) - -test("InstanceState is disposed on disposeAll", async () => { - await using a = await tmpdir() - await using b = await tmpdir() - const seen: string[] = [] - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make((dir) => - Effect.acquireRelease( - Effect.sync(() => ({ dir })), - (value) => - Effect.sync(() => { - seen.push(value.dir) - }), - ), - ) - - yield* Effect.promise(() => access(state, a.path)) - yield* Effect.promise(() => access(state, b.path)) - yield* Effect.promise(() => Instance.disposeAll()) - - expect(seen.sort()).toEqual([a.path, b.path].sort()) - }), - ), - ) -}) - -test("InstanceState.get reads correct directory per-evaluation (not captured once)", async () => { - await using a = await tmpdir() - await using b = await tmpdir() - - // Regression: InstanceState.get must be lazy (Effect.suspend) so the - // directory is read per-evaluation, not captured once at the call site. - // Without this, a service built inside a ManagedRuntime Layer would - // freeze to whichever directory triggered the first layer build. - - interface TestApi { - readonly getDir: () => Effect.Effect - } - - class TestService extends ServiceMap.Service()("@test/ALS-lazy") { - static readonly layer = Layer.effect( - TestService, - Effect.gen(function* () { - const state = yield* InstanceState.make((dir) => Effect.sync(() => dir)) - // `get` is created once during layer build — must be lazy - const get = InstanceState.get(state) - - const getDir = Effect.fn("TestService.getDir")(function* () { - return yield* get - }) - - return TestService.of({ getDir }) - }), - ) - } - - const rt = ManagedRuntime.make(TestService.layer) - - try { - const resultA = await Instance.provide({ - directory: a.path, - fn: () => rt.runPromise(TestService.use((s) => s.getDir())), - }) - expect(resultA).toBe(a.path) - - // Second call with different directory must NOT return A's directory - const resultB = await Instance.provide({ - directory: b.path, - fn: () => rt.runPromise(TestService.use((s) => s.getDir())), - }) - expect(resultB).toBe(b.path) - } finally { - await rt.dispose() - } -}) - -test("InstanceState.get isolates concurrent fibers across real delays, yields, and timer callbacks", async () => { - await using a = await tmpdir() - await using b = await tmpdir() - await using c = await tmpdir() - - // Adversarial: concurrent fibers with real timer delays (macrotask - // boundaries via setTimeout/Bun.sleep), explicit scheduler yields, - // and many async steps. If ALS context leaks or gets lost at any - // point, a fiber will see the wrong directory. - - interface TestApi { - readonly getDir: () => Effect.Effect - } - - class TestService extends ServiceMap.Service()("@test/ALS-adversarial") { - static readonly layer = Layer.effect( - TestService, - Effect.gen(function* () { - const state = yield* InstanceState.make((dir) => Effect.sync(() => dir)) - - const getDir = Effect.fn("TestService.getDir")(function* () { - // Mix of async boundary types to maximise interleaving: - // 1. Real timer delay (macrotask — setTimeout under the hood) - yield* Effect.promise(() => Bun.sleep(1)) - // 2. Effect.sleep (Effect's own timer, uses its internal scheduler) - yield* Effect.sleep(Duration.millis(1)) - // 3. Explicit scheduler yields - for (let i = 0; i < 100; i++) { - yield* Effect.yieldNow - } - // 4. Microtask boundaries - for (let i = 0; i < 100; i++) { - yield* Effect.promise(() => Promise.resolve()) - } - // 5. Another Effect.sleep - yield* Effect.sleep(Duration.millis(2)) - // 6. Another real timer to force a second macrotask hop - yield* Effect.promise(() => Bun.sleep(1)) - // NOW read the directory — ALS must still be correct - return yield* InstanceState.get(state) - }) - - return TestService.of({ getDir }) - }), - ) - } - - const rt = ManagedRuntime.make(TestService.layer) - - try { - const [resultA, resultB, resultC] = await Promise.all([ - Instance.provide({ - directory: a.path, - fn: () => rt.runPromise(TestService.use((s) => s.getDir())), - }), - Instance.provide({ - directory: b.path, - fn: () => rt.runPromise(TestService.use((s) => s.getDir())), - }), - Instance.provide({ - directory: c.path, - fn: () => rt.runPromise(TestService.use((s) => s.getDir())), - }), - ]) - - expect(resultA).toBe(a.path) - expect(resultB).toBe(b.path) - expect(resultC).toBe(c.path) - } finally { - await rt.dispose() - } -}) - -test("InstanceState dedupes concurrent lookups for the same directory", async () => { - await using tmp = await tmpdir() - let n = 0 - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make(() => - Effect.promise(async () => { - n += 1 - await Bun.sleep(10) - return { n } - }), - ) - - const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)])) - expect(a).toBe(b) - expect(n).toBe(1) - }), - ), - ) -}) From d4694d058cc590b0f05261a04460034d2fa8541d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 16 Mar 2026 16:56:12 +0000 Subject: [PATCH 003/112] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 62 ++++---- packages/sdk/openapi.json | 192 ++++++++++++------------ 2 files changed, 127 insertions(+), 127 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9ab71bd8f5..ff06a2c6cf 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -54,35 +54,6 @@ export type EventServerInstanceDisposed = { } } -export type PermissionRequest = { - id: string - sessionID: string - permission: string - patterns: Array - metadata: { - [key: string]: unknown - } - always: Array - tool?: { - messageID: string - callID: string - } -} - -export type EventPermissionAsked = { - type: "permission.asked" - properties: PermissionRequest -} - -export type EventPermissionReplied = { - type: "permission.replied" - properties: { - sessionID: string - requestID: string - reply: "once" | "always" | "reject" - } -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -154,6 +125,35 @@ export type EventQuestionRejected = { } } +export type PermissionRequest = { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } +} + +export type EventPermissionAsked = { + type: "permission.asked" + properties: PermissionRequest +} + +export type EventPermissionReplied = { + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + export type EventServerConnected = { type: "server.connected" properties: { @@ -962,11 +962,11 @@ export type Event = | EventInstallationUpdateAvailable | EventProjectUpdated | EventServerInstanceDisposed - | EventPermissionAsked - | EventPermissionReplied | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected + | EventPermissionAsked + | EventPermissionReplied | EventServerConnected | EventGlobalDisposed | EventLspClientDiagnostics diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 2933b530f4..303969d716 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7062,96 +7062,6 @@ }, "required": ["type", "properties"] }, - "PermissionRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^per.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "permission": { - "type": "string" - }, - "patterns": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "always": { - "type": "array", - "items": { - "type": "string" - } - }, - "tool": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "callID": { - "type": "string" - } - }, - "required": ["messageID", "callID"] - } - }, - "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] - }, - "Event.permission.asked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.asked" - }, - "properties": { - "$ref": "#/components/schemas/PermissionRequest" - } - }, - "required": ["type", "properties"] - }, - "Event.permission.replied": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.replied" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^per.*" - }, - "reply": { - "type": "string", - "enum": ["once", "always", "reject"] - } - }, - "required": ["sessionID", "requestID", "reply"] - } - }, - "required": ["type", "properties"] - }, "QuestionOption": { "type": "object", "properties": { @@ -7302,6 +7212,96 @@ }, "required": ["type", "properties"] }, + "PermissionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] + }, + "Event.permission.asked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "permission.asked" + }, + "properties": { + "$ref": "#/components/schemas/PermissionRequest" + } + }, + "required": ["type", "properties"] + }, + "Event.permission.replied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "permission.replied" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^per.*" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"] + } + }, + "required": ["type", "properties"] + }, "Event.server.connected": { "type": "object", "properties": { @@ -9611,12 +9611,6 @@ { "$ref": "#/components/schemas/Event.server.instance.disposed" }, - { - "$ref": "#/components/schemas/Event.permission.asked" - }, - { - "$ref": "#/components/schemas/Event.permission.replied" - }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -9626,6 +9620,12 @@ { "$ref": "#/components/schemas/Event.question.rejected" }, + { + "$ref": "#/components/schemas/Event.permission.asked" + }, + { + "$ref": "#/components/schemas/Event.permission.replied" + }, { "$ref": "#/components/schemas/Event.server.connected" }, From 9e740d9947e6a4c61680c8dd00cb1fd11adf12af Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 16 Mar 2026 13:18:40 -0400 Subject: [PATCH 004/112] stack: effectify-file-watcher-service (#17827) --- packages/opencode/AGENTS.md | 35 +++ packages/opencode/src/effect/instances.ts | 4 +- packages/opencode/src/file/watcher.ts | 220 ++++++++------- packages/opencode/src/flag/flag.ts | 10 +- packages/opencode/src/project/bootstrap.ts | 5 +- packages/opencode/src/project/instance.ts | 9 + packages/opencode/src/pty/index.ts | 66 ++--- packages/opencode/test/file/watcher.test.ts | 250 ++++++++++++++++++ .../opencode/test/permission/next.test.ts | 24 +- .../opencode/test/pty/pty-session.test.ts | 10 +- 10 files changed, 477 insertions(+), 156 deletions(-) create mode 100644 packages/opencode/test/file/watcher.test.ts diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index 930297baa9..f281506220 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -34,6 +34,7 @@ Instructions to follow when writing Effect. - Use `Effect.gen(function* () { ... })` for composition. - Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers. - `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers. +- **`Effect.callback`** (not `Effect.async`) for callback-based APIs. The classic `Effect.async` was renamed to `Effect.callback` in effect-smol/v4. ## Time @@ -42,3 +43,37 @@ Instructions to follow when writing Effect. ## Errors - In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. + +## Instance-scoped Effect services + +Services that need per-directory lifecycle (created/destroyed per instance) go through the `Instances` LayerMap: + +1. Define a `ServiceMap.Service` with a `static readonly layer` (see `FileWatcherService`, `QuestionService`, `PermissionService`, `ProviderAuthService`). +2. Add it to `InstanceServices` union and `Layer.mergeAll(...)` in `src/effect/instances.ts`. +3. Use `InstanceContext` inside the layer to read `directory` and `project` instead of `Instance.*` globals. +4. Call from legacy code via `runPromiseInstance(MyService.use((s) => s.method()))`. + +### Instance.bind — ALS context for native callbacks + +`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and returns a wrapper that restores it synchronously when called. + +**Use it** when passing callbacks to native C/C++ addons (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`. + +**Don't need it** for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers — Node.js ALS propagates through those automatically. + +```typescript +// Native addon callback — needs Instance.bind +const cb = Instance.bind((err, evts) => { + Bus.publish(MyEvent, { ... }) +}) +nativeAddon.subscribe(dir, cb) +``` + +## Flag → Effect.Config migration + +Flags in `src/flag/flag.ts` are being migrated from static `truthy(...)` reads to `Config.boolean(...).pipe(Config.withDefault(false))` as their consumers get effectified. + +- Effectful flags return `Config` and are read with `yield*` inside `Effect.gen`. +- The default `ConfigProvider` reads from `process.env`, so env vars keep working. +- Tests can override via `ConfigProvider.layer(ConfigProvider.fromUnknown({ ... }))`. +- Keep all flags in `flag.ts` as the single registry — just change the implementation from `truthy()` to `Config.boolean()` when the consumer moves to Effect. diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 02d4bf4823..d60d793558 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -3,6 +3,7 @@ import { registerDisposer } from "./instance-registry" import { ProviderAuthService } from "@/provider/auth-service" import { QuestionService } from "@/question/service" import { PermissionService } from "@/permission/service" +import { FileWatcherService } from "@/file/watcher" import { Instance } from "@/project/instance" import type { Project } from "@/project/project" @@ -17,7 +18,7 @@ export class InstanceContext extends ServiceMap.Service { + try { + const binding = require( + `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`, + ) + return createWrapper(binding) as typeof import("@parcel/watcher") + } catch (error) { + log.error("failed to load watcher binding", { error }) + return } +}) - const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { - try { - const binding = require( - `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`, - ) - return createWrapper(binding) as typeof import("@parcel/watcher") - } catch (error) { - log.error("failed to load watcher binding", { error }) - return - } - }) +function getBackend() { + if (process.platform === "win32") return "windows" + if (process.platform === "darwin") return "fs-events" + if (process.platform === "linux") return "inotify" +} - const state = Instance.state( - async () => { - log.info("init") - const cfg = await Config.get() - const backend = (() => { - if (process.platform === "win32") return "windows" - if (process.platform === "darwin") return "fs-events" - if (process.platform === "linux") return "inotify" - })() - if (!backend) { - log.error("watcher backend not supported", { platform: process.platform }) - return {} - } - log.info("watcher backend", { platform: process.platform, backend }) +export namespace FileWatcher { + export const Event = event + /** Whether the native @parcel/watcher binding is available on this platform. */ + export const hasNativeBinding = () => !!watcher() +} - const w = watcher() - if (!w) return {} +const init = Effect.fn("FileWatcherService.init")(function* () {}) - const subscribe: ParcelWatcher.SubscribeCallback = (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 subs: ParcelWatcher.AsyncSubscription[] = [] - const cfgIgnores = cfg.watcher?.ignore ?? [] - - if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { - const pending = w.subscribe(Instance.directory, subscribe, { - ignore: [...FileIgnore.PATTERNS, ...cfgIgnores, ...Protected.paths()], - backend, - }) - const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { - log.error("failed to subscribe to Instance.directory", { error: err }) - pending.then((s) => s.unsubscribe()).catch(() => {}) - return undefined - }) - if (sub) subs.push(sub) - } - - if (Instance.project.vcs === "git") { - const result = await git(["rev-parse", "--git-dir"], { - cwd: Instance.worktree, - }) - const vcsDir = result.exitCode === 0 ? path.resolve(Instance.worktree, result.text().trim()) : undefined - if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { - const gitDirContents = await readdir(vcsDir).catch(() => []) - const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD") - const pending = w.subscribe(vcsDir, subscribe, { - ignore: ignoreList, - backend, - }) - const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { - log.error("failed to subscribe to vcsDir", { error: err }) - pending.then((s) => s.unsubscribe()).catch(() => {}) - return undefined - }) - if (sub) subs.push(sub) - } - } - - return { subs } - }, - async (state) => { - if (!state.subs) return - await Promise.all(state.subs.map((sub) => sub?.unsubscribe())) - }, - ) - - export function init() { - if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) { - return - } - state() +export namespace FileWatcherService { + export interface Service { + readonly init: () => Effect.Effect } } + +export class FileWatcherService extends ServiceMap.Service()( + "@opencode/FileWatcher", +) { + static readonly layer = Layer.effect( + FileWatcherService, + Effect.gen(function* () { + const instance = yield* InstanceContext + if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return FileWatcherService.of({ init }) + + log.info("init", { directory: instance.directory }) + + const backend = getBackend() + if (!backend) { + log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform }) + return FileWatcherService.of({ init }) + } + + const w = watcher() + if (!w) return FileWatcherService.of({ init }) + + log.info("watcher backend", { directory: instance.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 subscribe = (dir: string, ignore: string[]) => { + const pending = w.subscribe(dir, cb, { ignore, backend }) + return Effect.gen(function* () { + const sub = yield* Effect.promise(() => pending) + subs.push(sub) + }).pipe( + Effect.timeout(SUBSCRIBE_TIMEOUT_MS), + Effect.catchCause((cause) => { + log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) }) + // Clean up a subscription that resolves after timeout + pending.then((s) => s.unsubscribe()).catch(() => {}) + return Effect.void + }), + ) + } + + const cfg = yield* Effect.promise(() => Config.get()) + const cfgIgnores = cfg.watcher?.ignore ?? [] + + if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { + yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...Protected.paths()]) + } + + if (instance.project.vcs === "git") { + const result = yield* Effect.promise(() => + git(["rev-parse", "--git-dir"], { + cwd: instance.project.worktree, + }), + ) + const vcsDir = result.exitCode === 0 ? path.resolve(instance.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", + ) + yield* subscribe(vcsDir, ignore) + } + } + + return FileWatcherService.of({ init }) + }).pipe( + Effect.catchCause((cause) => { + log.error("failed to init watcher service", { cause: Cause.pretty(cause) }) + return Effect.succeed(FileWatcherService.of({ init })) + }), + ), + ) +} diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index f1688a1b40..a1cfd862b7 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -1,3 +1,5 @@ +import { Config } from "effect" + function truthy(key: string) { const value = process.env[key]?.toLowerCase() return value === "true" || value === "1" @@ -40,8 +42,12 @@ export namespace Flag { // Experimental export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") - export const OPENCODE_EXPERIMENTAL_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_FILEWATCHER") - export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER") + export const OPENCODE_EXPERIMENTAL_FILEWATCHER = Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe( + Config.withDefault(false), + ) + export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = Config.boolean( + "OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER", + ).pipe(Config.withDefault(false)) export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY") diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a2be3733f8..bd819dc280 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -1,7 +1,7 @@ import { Plugin } from "../plugin" import { Format } from "../format" import { LSP } from "../lsp" -import { FileWatcher } from "../file/watcher" +import { FileWatcherService } from "../file/watcher" import { File } from "../file" import { Project } from "./project" import { Bus } from "../bus" @@ -12,6 +12,7 @@ import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" import { Snapshot } from "../snapshot" import { Truncate } from "../tool/truncation" +import { runPromiseInstance } from "@/effect/runtime" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) @@ -19,7 +20,7 @@ export async function InstanceBootstrap() { ShareNext.init() Format.init() await LSP.init() - FileWatcher.init() + await runPromiseInstance(FileWatcherService.use((service) => service.init())) File.init() Vcs.init() Snapshot.init() diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index fd3cc640a3..c16801a7a1 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -101,6 +101,15 @@ export const Instance = { if (Instance.worktree === "/") return false return Filesystem.contains(Instance.worktree, filepath) }, + /** + * Captures the current instance ALS context and returns a wrapper that + * restores it when called. Use this for callbacks that fire outside the + * instance async context (native addons, event emitters, timers, etc.). + */ + bind any>(fn: F): F { + const ctx = context.use() + return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F + }, state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { return State.create(() => Instance.directory, init, dispose) }, diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index d6bc4973a0..7436abec9f 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -167,40 +167,44 @@ export namespace Pty { subscribers: new Map(), } state().set(id, session) - ptyProcess.onData((chunk) => { - session.cursor += chunk.length + ptyProcess.onData( + Instance.bind((chunk) => { + session.cursor += chunk.length - for (const [key, ws] of session.subscribers.entries()) { - if (ws.readyState !== 1) { - session.subscribers.delete(key) - continue + for (const [key, ws] of session.subscribers.entries()) { + if (ws.readyState !== 1) { + session.subscribers.delete(key) + continue + } + + if (ws.data !== key) { + session.subscribers.delete(key) + continue + } + + try { + ws.send(chunk) + } catch { + session.subscribers.delete(key) + } } - if (ws.data !== key) { - session.subscribers.delete(key) - continue - } - - try { - ws.send(chunk) - } catch { - session.subscribers.delete(key) - } - } - - session.buffer += chunk - if (session.buffer.length <= BUFFER_LIMIT) return - const excess = session.buffer.length - BUFFER_LIMIT - session.buffer = session.buffer.slice(excess) - session.bufferCursor += excess - }) - ptyProcess.onExit(({ exitCode }) => { - if (session.info.status === "exited") return - log.info("session exited", { id, exitCode }) - session.info.status = "exited" - Bus.publish(Event.Exited, { id, exitCode }) - remove(id) - }) + session.buffer += chunk + if (session.buffer.length <= BUFFER_LIMIT) return + const excess = session.buffer.length - BUFFER_LIMIT + session.buffer = session.buffer.slice(excess) + session.bufferCursor += excess + }), + ) + ptyProcess.onExit( + Instance.bind(({ exitCode }) => { + if (session.info.status === "exited") return + log.info("session exited", { id, exitCode }) + session.info.status = "exited" + Bus.publish(Event.Exited, { id, exitCode }) + remove(id) + }), + ) Bus.publish(Event.Created, { info }) return info } diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts new file mode 100644 index 0000000000..7fe53612d9 --- /dev/null +++ b/packages/opencode/test/file/watcher.test.ts @@ -0,0 +1,250 @@ +import { $ } from "bun" +import { afterEach, describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { ConfigProvider, Deferred, Effect, Fiber, Layer, ManagedRuntime, Option } from "effect" +import { tmpdir } from "../fixture/fixture" +import { FileWatcher, FileWatcherService } from "../../src/file/watcher" +import { InstanceContext } from "../../src/effect/instances" +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 + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const configLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", + }), +) + +type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } } +type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } + +/** Run `body` with a live FileWatcherService. Runtime is acquired/released via Effect.scoped. */ +function withWatcher(directory: string, body: Effect.Effect) { + return Instance.provide({ + directory, + fn: () => + Effect.gen(function* () { + const ctx = Layer.sync(InstanceContext, () => + InstanceContext.of({ directory: Instance.directory, project: Instance.project }), + ) + const layer = Layer.fresh(FileWatcherService.layer).pipe(Layer.provide(ctx), Layer.provide(configLayer)) + const rt = yield* Effect.acquireRelease( + Effect.sync(() => ManagedRuntime.make(layer)), + (rt) => Effect.promise(() => rt.dispose()), + ) + yield* Effect.promise(() => rt.runPromise(FileWatcherService.use((s) => s.init()))) + yield* ready(directory) + yield* body + }).pipe(Effect.scoped, Effect.runPromise), + }) +} + +function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) { + let done = false + + function on(evt: BusUpdate) { + 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) + } + + function cleanup() { + if (done) return + done = true + GlobalBus.off("event", on) + } + + GlobalBus.on("event", on) + return cleanup +} + +function wait(directory: string, check: (evt: WatcherEvent) => boolean) { + return Effect.callback((resume) => { + const cleanup = listen(directory, check, (evt) => { + cleanup() + resume(Effect.succeed(evt)) + }) + return Effect.sync(cleanup) + }).pipe(Effect.timeout("5 seconds")) +} + +function nextUpdate(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect) { + return Effect.acquireUseRelease( + wait(directory, check).pipe(Effect.forkChild({ startImmediately: true })), + (fiber) => + Effect.gen(function* () { + yield* trigger + return yield* Fiber.join(fiber) + }), + Fiber.interrupt, + ) +} + +/** Effect that asserts no matching event arrives within `ms`. */ +function noUpdate( + directory: string, + check: (evt: WatcherEvent) => boolean, + trigger: Effect.Effect, + ms = 500, +) { + return Effect.gen(function* () { + const deferred = yield* Deferred.make() + + yield* Effect.acquireUseRelease( + Effect.sync(() => + listen(directory, check, (evt) => { + Effect.runSync(Deferred.succeed(deferred, evt)) + }), + ), + () => + Effect.gen(function* () { + yield* trigger + expect(yield* Deferred.await(deferred).pipe(Effect.timeoutOption(`${ms} millis`))).toEqual(Option.none()) + }), + (cleanup) => Effect.sync(cleanup), + ) + }) +} + +function ready(directory: string) { + const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`) + const head = path.join(directory, ".git", "HEAD") + + return Effect.gen(function* () { + yield* nextUpdate( + directory, + (evt) => evt.file === file && evt.event === "add", + Effect.promise(() => fs.writeFile(file, "ready")), + ).pipe(Effect.ensuring(Effect.promise(() => fs.rm(file, { force: true }).catch(() => undefined))), Effect.asVoid) + + const git = yield* Effect.promise(() => + fs + .stat(head) + .then(() => true) + .catch(() => false), + ) + if (!git) return + + const branch = `watch-${Math.random().toString(36).slice(2)}` + const hash = yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(directory).quiet().text()) + yield* nextUpdate( + directory, + (evt) => evt.file === head && evt.event !== "unlink", + Effect.promise(async () => { + await fs.writeFile(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n") + await fs.writeFile(head, `ref: refs/heads/${branch}\n`) + }), + ).pipe(Effect.asVoid) + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describeWatcher("FileWatcherService", () => { + afterEach(() => Instance.disposeAll()) + + test("publishes root create, update, and delete events", async () => { + await using tmp = await tmpdir({ git: true }) + const file = path.join(tmp.path, "watch.txt") + const dir = tmp.path + const cases = [ + { event: "add" as const, trigger: Effect.promise(() => fs.writeFile(file, "a")) }, + { event: "change" as const, trigger: Effect.promise(() => fs.writeFile(file, "b")) }, + { event: "unlink" as const, trigger: Effect.promise(() => fs.unlink(file)) }, + ] + + await withWatcher( + dir, + Effect.forEach(cases, ({ event, trigger }) => + nextUpdate(dir, (evt) => evt.file === file && evt.event === event, trigger).pipe( + Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))), + ), + ), + ) + }) + + test("watches non-git roots", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "plain.txt") + const dir = tmp.path + + await withWatcher( + dir, + nextUpdate( + dir, + (e) => e.file === file && e.event === "add", + Effect.promise(() => fs.writeFile(file, "plain")), + ).pipe(Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" })))), + ) + }) + + test("cleanup stops publishing events", async () => { + await using tmp = await tmpdir({ git: true }) + const file = path.join(tmp.path, "after-dispose.txt") + + // Start and immediately stop the watcher (withWatcher disposes on exit) + await withWatcher(tmp.path, Effect.void) + + // Now write a file — no watcher should be listening + await Effect.runPromise( + noUpdate( + tmp.path, + (e) => e.file === file, + Effect.promise(() => fs.writeFile(file, "gone")), + ), + ) + }) + + test("ignores .git/index changes", async () => { + await using tmp = await tmpdir({ git: true }) + const gitIndex = path.join(tmp.path, ".git", "index") + const edit = path.join(tmp.path, "tracked.txt") + + await withWatcher( + tmp.path, + noUpdate( + tmp.path, + (e) => e.file === gitIndex, + Effect.promise(async () => { + await fs.writeFile(edit, "a") + await $`git add .`.cwd(tmp.path).quiet().nothrow() + }), + ), + ) + }) + + test("publishes .git/HEAD events", async () => { + await using tmp = await tmpdir({ git: true }) + const head = path.join(tmp.path, ".git", "HEAD") + const branch = `watch-${Math.random().toString(36).slice(2)}` + await $`git branch ${branch}`.cwd(tmp.path).quiet() + + await withWatcher( + tmp.path, + nextUpdate( + tmp.path, + (evt) => evt.file === head && evt.event !== "unlink", + Effect.promise(() => fs.writeFile(head, `ref: refs/heads/${branch}\n`)), + ).pipe( + Effect.tap((evt) => + Effect.sync(() => { + expect(evt.file).toBe(head) + expect(["add", "change"]).toContain(evt.event) + }), + ), + ), + ) + }) +}) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 2e9195c288..7f7e5e1f1f 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -977,7 +977,7 @@ test("ask - should deny even when an earlier pattern is ask", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const ask = PermissionNext.ask({ + const err = await PermissionNext.ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["echo hello", "rm -rf /"], @@ -987,24 +987,12 @@ test("ask - should deny even when an earlier pattern is ask", async () => { { permission: "bash", pattern: "echo *", action: "ask" }, { permission: "bash", pattern: "rm *", action: "deny" }, ], - }) + }).then( + () => undefined, + (err) => err, + ) - const out = await Promise.race([ - ask.then( - () => ({ ok: true as const, err: undefined }), - (err) => ({ ok: false as const, err }), - ), - Bun.sleep(100).then(() => "timeout" as const), - ]) - - if (out === "timeout") { - await rejectAll() - await ask.catch(() => {}) - throw new Error("ask timed out instead of denying immediately") - } - - expect(out.ok).toBe(false) - expect(out.err).toBeInstanceOf(PermissionNext.DeniedError) + expect(err).toBeInstanceOf(PermissionNext.DeniedError) expect(await PermissionNext.list()).toHaveLength(0) }, }) diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 9063af872d..f7a949c921 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -6,7 +6,7 @@ import type { PtyID } from "../../src/pty/schema" import { tmpdir } from "../fixture/fixture" import { setTimeout as sleep } from "node:timers/promises" -const wait = async (fn: () => boolean, ms = 2000) => { +const wait = async (fn: () => boolean, ms = 5000) => { const end = Date.now() + ms while (Date.now() < end) { if (fn()) return @@ -20,7 +20,7 @@ const pick = (log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }>, } describe("pty", () => { - test("publishes created, exited, deleted in order for /bin/ls + remove", async () => { + test("publishes created, exited, deleted in order for a short-lived process", async () => { if (process.platform === "win32") return await using dir = await tmpdir({ git: true }) @@ -37,7 +37,11 @@ describe("pty", () => { let id: PtyID | undefined try { - const info = await Pty.create({ command: "/bin/ls", title: "ls" }) + const info = await Pty.create({ + command: "/usr/bin/env", + args: ["sh", "-c", "sleep 0.1"], + title: "sleep", + }) id = info.id await wait(() => pick(log, id!).includes("exited")) From ca3af5dc6a73a52b225c36376fd51b153fd6ad95 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 16 Mar 2026 17:19:44 +0000 Subject: [PATCH 005/112] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 18 +++--- packages/sdk/openapi.json | 76 ++++++++++++------------- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ff06a2c6cf..fe2ae1ca04 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -154,6 +154,14 @@ export type EventPermissionReplied = { } } +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type EventServerConnected = { type: "server.connected" properties: { @@ -685,14 +693,6 @@ export type EventSessionCompacted = { } } -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - export type Todo = { /** * Brief description of the task @@ -967,6 +967,7 @@ export type Event = | EventQuestionRejected | EventPermissionAsked | EventPermissionReplied + | EventFileWatcherUpdated | EventServerConnected | EventGlobalDisposed | EventLspClientDiagnostics @@ -980,7 +981,6 @@ export type Event = | EventSessionStatus | EventSessionIdle | EventSessionCompacted - | EventFileWatcherUpdated | EventTodoUpdated | EventTuiPromptAppend | EventTuiCommandExecute diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 303969d716..374eec01d2 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7302,6 +7302,41 @@ }, "required": ["type", "properties"] }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "anyOf": [ + { + "type": "string", + "const": "add" + }, + { + "type": "string", + "const": "change" + }, + { + "type": "string", + "const": "unlink" + } + ] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "Event.server.connected": { "type": "object", "properties": { @@ -8866,41 +8901,6 @@ }, "required": ["type", "properties"] }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "anyOf": [ - { - "type": "string", - "const": "add" - }, - { - "type": "string", - "const": "change" - }, - { - "type": "string", - "const": "unlink" - } - ] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, "Todo": { "type": "object", "properties": { @@ -9626,6 +9626,9 @@ { "$ref": "#/components/schemas/Event.permission.replied" }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -9665,9 +9668,6 @@ { "$ref": "#/components/schemas/Event.session.compacted" }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.todo.updated" }, From e5cbecf17c09efc84049473679aec537d30a40ab Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 16 Mar 2026 13:59:11 -0400 Subject: [PATCH 006/112] fix+refactor(vcs): fix HEAD filter bug and effectify VcsService (#17829) --- .../opencode/src/effect/instance-context.ts | 13 ++ packages/opencode/src/effect/instances.ts | 22 ++-- packages/opencode/src/file/watcher.ts | 2 +- packages/opencode/src/permission/service.ts | 2 +- packages/opencode/src/project/bootstrap.ts | 4 +- packages/opencode/src/project/vcs.ts | 98 ++++++++------- packages/opencode/src/server/server.ts | 5 +- packages/opencode/test/file/watcher.test.ts | 38 ++---- packages/opencode/test/fixture/instance.ts | 47 +++++++ packages/opencode/test/project/vcs.test.ts | 117 ++++++++++++++++++ 10 files changed, 259 insertions(+), 89 deletions(-) create mode 100644 packages/opencode/src/effect/instance-context.ts create mode 100644 packages/opencode/test/fixture/instance.ts create mode 100644 packages/opencode/test/project/vcs.test.ts diff --git a/packages/opencode/src/effect/instance-context.ts b/packages/opencode/src/effect/instance-context.ts new file mode 100644 index 0000000000..583b52d562 --- /dev/null +++ b/packages/opencode/src/effect/instance-context.ts @@ -0,0 +1,13 @@ +import { ServiceMap } from "effect" +import type { Project } from "@/project/project" + +export declare namespace InstanceContext { + export interface Shape { + readonly directory: string + readonly project: Project.Info + } +} + +export class InstanceContext extends ServiceMap.Service()( + "opencode/InstanceContext", +) {} diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index d60d793558..2e6fbe167a 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -1,24 +1,21 @@ import { Effect, Layer, LayerMap, ServiceMap } from "effect" import { registerDisposer } from "./instance-registry" +import { InstanceContext } from "./instance-context" import { ProviderAuthService } from "@/provider/auth-service" import { QuestionService } from "@/question/service" import { PermissionService } from "@/permission/service" import { FileWatcherService } from "@/file/watcher" +import { VcsService } from "@/project/vcs" import { Instance } from "@/project/instance" -import type { Project } from "@/project/project" -export declare namespace InstanceContext { - export interface Shape { - readonly directory: string - readonly project: Project.Info - } -} +export { InstanceContext } from "./instance-context" -export class InstanceContext extends ServiceMap.Service()( - "opencode/InstanceContext", -) {} - -export type InstanceServices = QuestionService | PermissionService | ProviderAuthService | FileWatcherService +export type InstanceServices = + | QuestionService + | PermissionService + | ProviderAuthService + | FileWatcherService + | VcsService function lookup(directory: string) { const project = Instance.project @@ -28,6 +25,7 @@ function lookup(directory: string) { Layer.fresh(PermissionService.layer), Layer.fresh(ProviderAuthService.layer), Layer.fresh(FileWatcherService.layer), + Layer.fresh(VcsService.layer), ).pipe(Layer.provide(ctx)) } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 651f15f840..16ee8f27c8 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import { InstanceContext } from "@/effect/instances" +import { InstanceContext } from "@/effect/instance-context" import { Instance } from "@/project/instance" import z from "zod" import { Log } from "../util/log" diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts index b790158d16..f20b19acf3 100644 --- a/packages/opencode/src/permission/service.ts +++ b/packages/opencode/src/permission/service.ts @@ -1,6 +1,6 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" -import { InstanceContext } from "@/effect/instances" +import { InstanceContext } from "@/effect/instance-context" import { ProjectID } from "@/project/schema" import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index bd819dc280..da4a67dba7 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -7,7 +7,7 @@ import { Project } from "./project" import { Bus } from "../bus" import { Command } from "../command" import { Instance } from "./instance" -import { Vcs } from "./vcs" +import { VcsService } from "./vcs" import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" import { Snapshot } from "../snapshot" @@ -22,7 +22,7 @@ export async function InstanceBootstrap() { await LSP.init() await runPromiseInstance(FileWatcherService.use((service) => service.init())) File.init() - Vcs.init() + await runPromiseInstance(VcsService.use((s) => s.init())) Snapshot.init() Truncate.init() diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 6eada6b675..4d1f7b766b 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,11 +1,12 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import path from "path" import z from "zod" import { Log } from "@/util/log" import { Instance } from "./instance" +import { InstanceContext } from "@/effect/instance-context" import { FileWatcher } from "@/file/watcher" import { git } from "@/util/git" +import { Effect, Layer, ServiceMap } from "effect" const log = Log.create({ service: "vcs" }) @@ -27,50 +28,57 @@ export namespace Vcs { ref: "VcsInfo", }) export type Info = z.infer +} - async function currentBranch() { - const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { - cwd: Instance.worktree, - }) - if (result.exitCode !== 0) return - const text = result.text().trim() - if (!text) return - return text - } - - const state = Instance.state( - async () => { - if (Instance.project.vcs !== "git") { - return { branch: async () => undefined, unsubscribe: undefined } - } - let current = await currentBranch() - log.info("initialized", { branch: current }) - - const unsubscribe = Bus.subscribe(FileWatcher.Event.Updated, async (evt) => { - if (!evt.properties.file.endsWith("HEAD")) return - const next = await currentBranch() - if (next !== current) { - log.info("branch changed", { from: current, to: next }) - current = next - Bus.publish(Event.BranchUpdated, { branch: next }) - } - }) - - return { - branch: async () => current, - unsubscribe, - } - }, - async (state) => { - state.unsubscribe?.() - }, - ) - - export async function init() { - return state() - } - - export async function branch() { - return await state().then((s) => s.branch()) +export namespace VcsService { + export interface Service { + readonly init: () => Effect.Effect + readonly branch: () => Effect.Effect } } + +export class VcsService extends ServiceMap.Service()("@opencode/Vcs") { + static readonly layer = Layer.effect( + VcsService, + Effect.gen(function* () { + const instance = yield* InstanceContext + let current: string | undefined + + if (instance.project.vcs === "git") { + const currentBranch = async () => { + const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { + cwd: instance.project.worktree, + }) + if (result.exitCode !== 0) return undefined + const text = result.text().trim() + return text || undefined + } + + current = yield* Effect.promise(() => currentBranch()) + log.info("initialized", { branch: current }) + + const unsubscribe = Bus.subscribe( + FileWatcher.Event.Updated, + Instance.bind(async (evt) => { + if (!evt.properties.file.endsWith("HEAD")) return + const next = await currentBranch() + if (next !== current) { + log.info("branch changed", { from: current, to: next }) + current = next + Bus.publish(Vcs.Event.BranchUpdated, { branch: next }) + } + }), + ) + + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) + } + + return VcsService.of({ + init: Effect.fn("VcsService.init")(function* () {}), + branch: Effect.fn("VcsService.branch")(function* () { + return current + }), + }) + }), + ) +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 55bcf2dfce..677af4da87 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -14,7 +14,8 @@ import { LSP } from "../lsp" import { Format } from "../format" import { TuiRoutes } from "./routes/tui" import { Instance } from "../project/instance" -import { Vcs } from "../project/vcs" +import { Vcs, VcsService } from "../project/vcs" +import { runPromiseInstance } from "@/effect/runtime" import { Agent } from "../agent/agent" import { Skill } from "../skill/skill" import { Auth } from "../auth" @@ -330,7 +331,7 @@ export namespace Server { }, }), async (c) => { - const branch = await Vcs.branch() + const branch = await runPromiseInstance(VcsService.use((s) => s.branch())) return c.json({ branch, }) diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 7fe53612d9..a2de617334 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -2,10 +2,10 @@ import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" -import { ConfigProvider, Deferred, Effect, Fiber, Layer, ManagedRuntime, Option } from "effect" +import { Deferred, Effect, Fiber, Option } from "effect" import { tmpdir } from "../fixture/fixture" +import { watcherConfigLayer, withServices } from "../fixture/instance" import { FileWatcher, FileWatcherService } from "../../src/file/watcher" -import { InstanceContext } from "../../src/effect/instances" import { Instance } from "../../src/project/instance" import { GlobalBus } from "../../src/bus/global" @@ -16,35 +16,21 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc // Helpers // --------------------------------------------------------------------------- -const configLayer = ConfigProvider.layer( - ConfigProvider.fromUnknown({ - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", - }), -) - type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } } type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } -/** Run `body` with a live FileWatcherService. Runtime is acquired/released via Effect.scoped. */ +/** Run `body` with a live FileWatcherService. */ function withWatcher(directory: string, body: Effect.Effect) { - return Instance.provide({ + return withServices( directory, - fn: () => - Effect.gen(function* () { - const ctx = Layer.sync(InstanceContext, () => - InstanceContext.of({ directory: Instance.directory, project: Instance.project }), - ) - const layer = Layer.fresh(FileWatcherService.layer).pipe(Layer.provide(ctx), Layer.provide(configLayer)) - const rt = yield* Effect.acquireRelease( - Effect.sync(() => ManagedRuntime.make(layer)), - (rt) => Effect.promise(() => rt.dispose()), - ) - yield* Effect.promise(() => rt.runPromise(FileWatcherService.use((s) => s.init()))) - yield* ready(directory) - yield* body - }).pipe(Effect.scoped, Effect.runPromise), - }) + FileWatcherService.layer, + async (rt) => { + await rt.runPromise(FileWatcherService.use((s) => s.init())) + await Effect.runPromise(ready(directory)) + await Effect.runPromise(body) + }, + { provide: [watcherConfigLayer] }, + ) } function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) { diff --git a/packages/opencode/test/fixture/instance.ts b/packages/opencode/test/fixture/instance.ts new file mode 100644 index 0000000000..d322e1d9fb --- /dev/null +++ b/packages/opencode/test/fixture/instance.ts @@ -0,0 +1,47 @@ +import { ConfigProvider, Layer, ManagedRuntime } from "effect" +import { InstanceContext } from "../../src/effect/instance-context" +import { Instance } from "../../src/project/instance" + +/** ConfigProvider that enables the experimental file watcher. */ +export const watcherConfigLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", + }), +) + +/** + * Boot an Instance with the given service layers and run `body` with + * the ManagedRuntime. Cleanup is automatic — the runtime is disposed + * and Instance context is torn down when `body` completes. + * + * Layers may depend on InstanceContext (provided automatically). + * Pass extra layers via `options.provide` (e.g. ConfigProvider.layer). + */ +export function withServices( + directory: string, + layer: Layer.Layer, + body: (rt: ManagedRuntime.ManagedRuntime) => Promise, + options?: { provide?: Layer.Layer[] }, +) { + return Instance.provide({ + directory, + fn: async () => { + const ctx = Layer.sync(InstanceContext, () => + InstanceContext.of({ directory: Instance.directory, project: Instance.project }), + ) + let resolved: Layer.Layer = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any + if (options?.provide) { + for (const l of options.provide) { + resolved = resolved.pipe(Layer.provide(l)) as any + } + } + const rt = ManagedRuntime.make(resolved) + try { + await body(rt) + } finally { + await rt.dispose() + } + }, + }) +} diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts new file mode 100644 index 0000000000..b5100585f5 --- /dev/null +++ b/packages/opencode/test/project/vcs.test.ts @@ -0,0 +1,117 @@ +import { $ } from "bun" +import { afterEach, describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Layer, ManagedRuntime } from "effect" +import { tmpdir } from "../fixture/fixture" +import { watcherConfigLayer, withServices } from "../fixture/instance" +import { FileWatcher, FileWatcherService } from "../../src/file/watcher" +import { Instance } from "../../src/project/instance" +import { GlobalBus } from "../../src/bus/global" +import { Vcs, VcsService } from "../../src/project/vcs" + +// Skip in CI — native @parcel/watcher binding needed +const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function withVcs( + directory: string, + body: (rt: ManagedRuntime.ManagedRuntime) => Promise, +) { + return withServices( + directory, + Layer.merge(FileWatcherService.layer, VcsService.layer), + async (rt) => { + await rt.runPromise(FileWatcherService.use((s) => s.init())) + await rt.runPromise(VcsService.use((s) => s.init())) + await Bun.sleep(200) + await body(rt) + }, + { provide: [watcherConfigLayer] }, + ) +} + +type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } } + +/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus */ +function nextBranchUpdate(directory: string, timeout = 5000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + GlobalBus.off("event", on) + reject(new Error("timed out waiting for BranchUpdated event")) + }, timeout) + + function on(evt: BranchEvent) { + if (evt.directory !== directory) return + if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return + clearTimeout(timer) + GlobalBus.off("event", on) + resolve(evt.payload.properties.branch) + } + + GlobalBus.on("event", on) + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describeVcs("Vcs", () => { + afterEach(() => Instance.disposeAll()) + + test("branch() returns current branch name", async () => { + await using tmp = await tmpdir({ git: true }) + + await withVcs(tmp.path, async (rt) => { + const branch = await rt.runPromise(VcsService.use((s) => s.branch())) + expect(branch).toBeDefined() + expect(typeof branch).toBe("string") + }) + }) + + test("branch() returns undefined for non-git directories", async () => { + await using tmp = await tmpdir() + + await withVcs(tmp.path, async (rt) => { + const branch = await rt.runPromise(VcsService.use((s) => s.branch())) + expect(branch).toBeUndefined() + }) + }) + + test("publishes BranchUpdated when .git/HEAD changes", async () => { + await using tmp = await tmpdir({ git: true }) + const branch = `test-${Math.random().toString(36).slice(2)}` + await $`git branch ${branch}`.cwd(tmp.path).quiet() + + await withVcs(tmp.path, async () => { + const pending = nextBranchUpdate(tmp.path) + + const head = path.join(tmp.path, ".git", "HEAD") + await fs.writeFile(head, `ref: refs/heads/${branch}\n`) + + const updated = await pending + expect(updated).toBe(branch) + }) + }) + + test("branch() reflects the new branch after HEAD change", async () => { + await using tmp = await tmpdir({ git: true }) + const branch = `test-${Math.random().toString(36).slice(2)}` + await $`git branch ${branch}`.cwd(tmp.path).quiet() + + await withVcs(tmp.path, async (rt) => { + const pending = nextBranchUpdate(tmp.path) + + const head = path.join(tmp.path, ".git", "HEAD") + await fs.writeFile(head, `ref: refs/heads/${branch}\n`) + + await pending + const current = await rt.runPromise(VcsService.use((s) => s.branch())) + expect(current).toBe(branch) + }) + }) +}) From 410fbd8a00fad793e980cab7efe2b99b3963dc5b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 16 Mar 2026 18:00:18 +0000 Subject: [PATCH 007/112] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 16 +++++----- packages/sdk/openapi.json | 42 ++++++++++++------------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fe2ae1ca04..b851e9ecbf 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -162,6 +162,13 @@ export type EventFileWatcherUpdated = { } } +export type EventVcsBranchUpdated = { + type: "vcs.branch.updated" + properties: { + branch?: string + } +} + export type EventServerConnected = { type: "server.connected" properties: { @@ -882,13 +889,6 @@ export type EventSessionError = { } } -export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" - properties: { - branch?: string - } -} - export type EventWorkspaceReady = { type: "workspace.ready" properties: { @@ -968,6 +968,7 @@ export type Event = | EventPermissionAsked | EventPermissionReplied | EventFileWatcherUpdated + | EventVcsBranchUpdated | EventServerConnected | EventGlobalDisposed | EventLspClientDiagnostics @@ -994,7 +995,6 @@ export type Event = | EventSessionDeleted | EventSessionDiff | EventSessionError - | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed | EventPtyCreated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 374eec01d2..1a440d12c7 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7337,6 +7337,24 @@ }, "required": ["type", "properties"] }, + "Event.vcs.branch.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "vcs.branch.updated" + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + } + } + }, + "required": ["type", "properties"] + }, "Event.server.connected": { "type": "object", "properties": { @@ -9387,24 +9405,6 @@ }, "required": ["type", "properties"] }, - "Event.vcs.branch.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "vcs.branch.updated" - }, - "properties": { - "type": "object", - "properties": { - "branch": { - "type": "string" - } - } - } - }, - "required": ["type", "properties"] - }, "Event.workspace.ready": { "type": "object", "properties": { @@ -9629,6 +9629,9 @@ { "$ref": "#/components/schemas/Event.file.watcher.updated" }, + { + "$ref": "#/components/schemas/Event.vcs.branch.updated" + }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -9707,9 +9710,6 @@ { "$ref": "#/components/schemas/Event.session.error" }, - { - "$ref": "#/components/schemas/Event.vcs.branch.updated" - }, { "$ref": "#/components/schemas/Event.workspace.ready" }, From 2cbdf04ec9970c297acdfdebb4511329fc429639 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 16 Mar 2026 14:23:13 -0400 Subject: [PATCH 008/112] refactor(file-time): effectify FileTimeService with Semaphore locks (#17835) --- packages/opencode/src/effect/instances.ts | 5 +- packages/opencode/src/file/time.ts | 164 ++++++++++++++-------- packages/opencode/src/file/watcher.ts | 3 +- packages/opencode/src/flag/flag.ts | 4 +- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/tool/edit.ts | 4 +- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/src/tool/write.ts | 2 +- packages/opencode/test/file/time.test.ts | 113 ++++----------- 9 files changed, 148 insertions(+), 151 deletions(-) diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 2e6fbe167a..78b340e775 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -6,6 +6,7 @@ import { QuestionService } from "@/question/service" import { PermissionService } from "@/permission/service" import { FileWatcherService } from "@/file/watcher" import { VcsService } from "@/project/vcs" +import { FileTimeService } from "@/file/time" import { Instance } from "@/project/instance" export { InstanceContext } from "./instance-context" @@ -16,6 +17,7 @@ export type InstanceServices = | ProviderAuthService | FileWatcherService | VcsService + | FileTimeService function lookup(directory: string) { const project = Instance.project @@ -24,8 +26,9 @@ function lookup(directory: string) { Layer.fresh(QuestionService.layer), Layer.fresh(PermissionService.layer), Layer.fresh(ProviderAuthService.layer), - Layer.fresh(FileWatcherService.layer), + Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), Layer.fresh(VcsService.layer), + Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), ).pipe(Layer.provide(ctx)) } diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index efb1c43764..c956cdfdbd 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -1,71 +1,115 @@ -import { Instance } from "../project/instance" import { Log } from "../util/log" -import { Flag } from "../flag/flag" +import { Flag } from "@/flag/flag" import { Filesystem } from "../util/filesystem" +import { Effect, Layer, ServiceMap, Semaphore } from "effect" +import { runPromiseInstance } from "@/effect/runtime" +import type { SessionID } from "@/session/schema" + +const log = Log.create({ service: "file.time" }) + +export namespace FileTimeService { + export interface Service { + readonly read: (sessionID: SessionID, file: string) => Effect.Effect + readonly get: (sessionID: SessionID, file: string) => Effect.Effect + readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect + readonly withLock: (filepath: string, fn: () => Promise) => Effect.Effect + } +} + +type Stamp = { + readonly read: Date + readonly mtime: number | undefined + readonly ctime: number | undefined + readonly size: number | undefined +} + +function stamp(file: string): Stamp { + const stat = Filesystem.stat(file) + const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size + return { + read: new Date(), + mtime: stat?.mtime?.getTime(), + ctime: stat?.ctime?.getTime(), + size, + } +} + +function session(reads: Map>, sessionID: SessionID) { + let value = reads.get(sessionID) + if (!value) { + value = new Map() + reads.set(sessionID, value) + } + return value +} + +export class FileTimeService extends ServiceMap.Service()( + "@opencode/FileTime", +) { + static readonly layer = Layer.effect( + FileTimeService, + Effect.gen(function* () { + const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK + const reads = new Map>() + const locks = new Map() + + function getLock(filepath: string) { + let lock = locks.get(filepath) + if (!lock) { + lock = Semaphore.makeUnsafe(1) + locks.set(filepath, lock) + } + return lock + } + + return FileTimeService.of({ + read: Effect.fn("FileTimeService.read")(function* (sessionID: SessionID, file: string) { + log.info("read", { sessionID, file }) + session(reads, sessionID).set(file, stamp(file)) + }), + + get: Effect.fn("FileTimeService.get")(function* (sessionID: SessionID, file: string) { + return reads.get(sessionID)?.get(file)?.read + }), + + assert: Effect.fn("FileTimeService.assert")(function* (sessionID: SessionID, filepath: string) { + if (disableCheck) return + + const time = reads.get(sessionID)?.get(filepath) + if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`) + const next = stamp(filepath) + const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size + + if (changed) { + throw new Error( + `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`, + ) + } + }), + + withLock: Effect.fn("FileTimeService.withLock")(function* (filepath: string, fn: () => Promise) { + const lock = getLock(filepath) + return yield* Effect.promise(fn).pipe(lock.withPermits(1)) + }), + }) + }), + ) +} export namespace FileTime { - const log = Log.create({ service: "file.time" }) - // Per-session read times plus per-file write locks. - // All tools that overwrite existing files should run their - // assert/read/write/update sequence inside withLock(filepath, ...) - // so concurrent writes to the same file are serialized. - export const state = Instance.state(() => { - const read: { - [sessionID: string]: { - [path: string]: Date | undefined - } - } = {} - const locks = new Map>() - return { - read, - locks, - } - }) - - export function read(sessionID: string, file: string) { - log.info("read", { sessionID, file }) - const { read } = state() - read[sessionID] = read[sessionID] || {} - read[sessionID][file] = new Date() + export function read(sessionID: SessionID, file: string) { + return runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file))) } - export function get(sessionID: string, file: string) { - return state().read[sessionID]?.[file] + export function get(sessionID: SessionID, file: string) { + return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file))) + } + + export async function assert(sessionID: SessionID, filepath: string) { + return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath))) } export async function withLock(filepath: string, fn: () => Promise): Promise { - const current = state() - const currentLock = current.locks.get(filepath) ?? Promise.resolve() - let release: () => void = () => {} - const nextLock = new Promise((resolve) => { - release = resolve - }) - const chained = currentLock.then(() => nextLock) - current.locks.set(filepath, chained) - await currentLock - try { - return await fn() - } finally { - release() - if (current.locks.get(filepath) === chained) { - current.locks.delete(filepath) - } - } - } - - export async function assert(sessionID: string, filepath: string) { - if (Flag.OPENCODE_DISABLE_FILETIME_CHECK === true) { - return - } - - const time = get(sessionID, filepath) - if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`) - const mtime = Filesystem.stat(filepath)?.mtime - // Allow a 50ms tolerance for Windows NTFS timestamp fuzziness / async flushing - if (mtime && mtime.getTime() > time.getTime() + 50) { - throw new Error( - `File ${filepath} has been modified since it was last read.\nLast modification: ${mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`, - ) - } + return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn))) } } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 16ee8f27c8..1a3a4f742f 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -72,7 +72,8 @@ export class FileWatcherService extends ServiceMap.Service 0) { output += `\n\n\n${instructions.map((i) => i.content).join("\n\n")}\n` diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 8c1e53ccaf..83474a543c 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -49,7 +49,7 @@ export const WriteTool = Tool.define("write", { file: filepath, event: exists ? "change" : "add", }) - FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, filepath) let output = "Wrote file successfully." await LSP.touchFile(filepath, true) diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts index e46d5229b4..9eedffd76f 100644 --- a/packages/opencode/test/file/time.test.ts +++ b/packages/opencode/test/file/time.test.ts @@ -1,13 +1,16 @@ -import { describe, test, expect, beforeEach } from "bun:test" +import { describe, test, expect, afterEach } from "bun:test" import path from "path" import fs from "fs/promises" import { FileTime } from "../../src/file/time" import { Instance } from "../../src/project/instance" +import { SessionID } from "../../src/session/schema" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" +afterEach(() => Instance.disposeAll()) + describe("file/time", () => { - const sessionID = "test-session-123" + const sessionID = SessionID.make("ses_00000000000000000000000001") describe("read() and get()", () => { test("stores read timestamp", async () => { @@ -18,12 +21,13 @@ describe("file/time", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const before = FileTime.get(sessionID, filepath) + const before = await FileTime.get(sessionID, filepath) expect(before).toBeUndefined() - FileTime.read(sessionID, filepath) + await FileTime.read(sessionID, filepath) + await Bun.sleep(10) - const after = FileTime.get(sessionID, filepath) + const after = await FileTime.get(sessionID, filepath) expect(after).toBeInstanceOf(Date) expect(after!.getTime()).toBeGreaterThan(0) }, @@ -38,11 +42,12 @@ describe("file/time", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read("session1", filepath) - FileTime.read("session2", filepath) + await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath) + await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath) + await Bun.sleep(10) - const time1 = FileTime.get("session1", filepath) - const time2 = FileTime.get("session2", filepath) + const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath) + const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath) expect(time1).toBeDefined() expect(time2).toBeDefined() @@ -59,14 +64,16 @@ describe("file/time", () => { directory: tmp.path, fn: async () => { FileTime.read(sessionID, filepath) - const first = FileTime.get(sessionID, filepath)! + await Bun.sleep(10) + const first = await FileTime.get(sessionID, filepath) - await new Promise((resolve) => setTimeout(resolve, 10)) + await Bun.sleep(10) FileTime.read(sessionID, filepath) - const second = FileTime.get(sessionID, filepath)! + await Bun.sleep(10) + const second = await FileTime.get(sessionID, filepath) - expect(second.getTime()).toBeGreaterThanOrEqual(first.getTime()) + expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime()) }, }) }) @@ -82,8 +89,7 @@ describe("file/time", () => { directory: tmp.path, fn: async () => { FileTime.read(sessionID, filepath) - - // Should not throw + await Bun.sleep(10) await FileTime.assert(sessionID, filepath) }, }) @@ -111,13 +117,8 @@ describe("file/time", () => { directory: tmp.path, fn: async () => { FileTime.read(sessionID, filepath) - - // Wait to ensure different timestamps - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Modify file after reading + await Bun.sleep(100) await fs.writeFile(filepath, "modified content", "utf-8") - await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read") }, }) @@ -132,7 +133,7 @@ describe("file/time", () => { directory: tmp.path, fn: async () => { FileTime.read(sessionID, filepath) - await new Promise((resolve) => setTimeout(resolve, 100)) + await Bun.sleep(100) await fs.writeFile(filepath, "modified", "utf-8") let error: Error | undefined @@ -147,28 +148,6 @@ describe("file/time", () => { }, }) }) - - test("skips check when OPENCODE_DISABLE_FILETIME_CHECK is true", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const { Flag } = await import("../../src/flag/flag") - const original = Flag.OPENCODE_DISABLE_FILETIME_CHECK - ;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = true - - try { - // Should not throw even though file wasn't read - await FileTime.assert(sessionID, filepath) - } finally { - ;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = original - } - }, - }) - }) }) describe("withLock()", () => { @@ -215,7 +194,7 @@ describe("file/time", () => { const op1 = FileTime.withLock(filepath, async () => { order.push(1) - await new Promise((resolve) => setTimeout(resolve, 10)) + await Bun.sleep(50) order.push(2) }) @@ -225,12 +204,7 @@ describe("file/time", () => { }) await Promise.all([op1, op2]) - - // Operations should be serialized - expect(order).toContain(1) - expect(order).toContain(2) - expect(order).toContain(3) - expect(order).toContain(4) + expect(order).toEqual([1, 2, 3, 4]) }, }) }) @@ -248,8 +222,8 @@ describe("file/time", () => { const op1 = FileTime.withLock(filepath1, async () => { started1 = true - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(started2).toBe(true) // op2 should have started while op1 is running + await Bun.sleep(50) + expect(started2).toBe(true) }) const op2 = FileTime.withLock(filepath2, async () => { @@ -257,7 +231,6 @@ describe("file/time", () => { }) await Promise.all([op1, op2]) - expect(started1).toBe(true) expect(started2).toBe(true) }, @@ -277,7 +250,6 @@ describe("file/time", () => { }), ).rejects.toThrow("Test error") - // Lock should be released, subsequent operations should work let executed = false await FileTime.withLock(filepath, async () => { executed = true @@ -286,31 +258,6 @@ describe("file/time", () => { }, }) }) - - test("deadlocks on nested locks (expected behavior)", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - // Nested locks on same file cause deadlock - this is expected - // The outer lock waits for inner to complete, but inner waits for outer to release - const timeout = new Promise((_, reject) => - setTimeout(() => reject(new Error("Deadlock detected")), 100), - ) - - const nestedLock = FileTime.withLock(filepath, async () => { - return FileTime.withLock(filepath, async () => { - return "inner" - }) - }) - - // Should timeout due to deadlock - await expect(Promise.race([nestedLock, timeout])).rejects.toThrow("Deadlock detected") - }, - }) - }) }) describe("stat() Filesystem.stat pattern", () => { @@ -323,12 +270,12 @@ describe("file/time", () => { directory: tmp.path, fn: async () => { FileTime.read(sessionID, filepath) + await Bun.sleep(10) const stats = Filesystem.stat(filepath) expect(stats?.mtime).toBeInstanceOf(Date) expect(stats!.mtime.getTime()).toBeGreaterThan(0) - // FileTime.assert uses this stat internally await FileTime.assert(sessionID, filepath) }, }) @@ -343,11 +290,11 @@ describe("file/time", () => { directory: tmp.path, fn: async () => { FileTime.read(sessionID, filepath) + await Bun.sleep(10) const originalStat = Filesystem.stat(filepath) - // Wait and modify - await new Promise((resolve) => setTimeout(resolve, 100)) + await Bun.sleep(100) await fs.writeFile(filepath, "modified", "utf-8") const newStat = Filesystem.stat(filepath) From 03d84f49c2b947d94ac8bcb5f8ba7c2fc4907e8d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 16 Mar 2026 18:24:21 +0000 Subject: [PATCH 009/112] chore: generate --- packages/opencode/src/file/watcher.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 1a3a4f742f..16ee8f27c8 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -72,8 +72,7 @@ export class FileWatcherService extends ServiceMap.Service Date: Mon, 16 Mar 2026 15:58:36 -0400 Subject: [PATCH 010/112] refactor(format): effectify FormatService as scoped service (#17675) --- packages/opencode/src/effect/instances.ts | 3 + packages/opencode/src/format/index.ts | 243 ++++++++++--------- packages/opencode/src/project/bootstrap.ts | 2 +- packages/opencode/test/file/time.test.ts | 2 +- packages/opencode/test/format/format.test.ts | 64 +++++ 5 files changed, 201 insertions(+), 113 deletions(-) create mode 100644 packages/opencode/test/format/format.test.ts diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 78b340e775..f5d9ac94a1 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -7,6 +7,7 @@ import { PermissionService } from "@/permission/service" import { FileWatcherService } from "@/file/watcher" import { VcsService } from "@/project/vcs" import { FileTimeService } from "@/file/time" +import { FormatService } from "@/format" import { Instance } from "@/project/instance" export { InstanceContext } from "./instance-context" @@ -18,6 +19,7 @@ export type InstanceServices = | FileWatcherService | VcsService | FileTimeService + | FormatService function lookup(directory: string) { const project = Instance.project @@ -29,6 +31,7 @@ function lookup(directory: string) { Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), Layer.fresh(VcsService.layer), Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), + Layer.fresh(FormatService.layer), ).pipe(Layer.provide(ctx)) } diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index b849f778ec..cb71fc363d 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -9,10 +9,13 @@ import { Config } from "../config/config" import { mergeDeep } from "remeda" import { Instance } from "../project/instance" import { Process } from "../util/process" +import { InstanceContext } from "@/effect/instance-context" +import { Effect, Layer, ServiceMap } from "effect" +import { runPromiseInstance } from "@/effect/runtime" + +const log = Log.create({ service: "format" }) export namespace Format { - const log = Log.create({ service: "format" }) - export const Status = z .object({ name: z.string(), @@ -24,117 +27,135 @@ export namespace Format { }) export type Status = z.infer - const state = Instance.state(async () => { - const enabled: Record = {} - const cfg = await Config.get() - - const formatters: Record = {} - if (cfg.formatter === false) { - log.info("all formatters are disabled") - return { - enabled, - formatters, - } - } - - for (const item of Object.values(Formatter)) { - formatters[item.name] = item - } - for (const [name, item] of Object.entries(cfg.formatter ?? {})) { - if (item.disabled) { - delete formatters[name] - continue - } - const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, { - command: [], - extensions: [], - ...item, - }) - - if (result.command.length === 0) continue - - result.enabled = async () => true - result.name = name - formatters[name] = result - } - - return { - enabled, - formatters, - } - }) - - async function isEnabled(item: Formatter.Info) { - const s = await state() - let status = s.enabled[item.name] - if (status === undefined) { - status = await item.enabled() - s.enabled[item.name] = status - } - return status - } - - async function getFormatter(ext: string) { - const formatters = await state().then((x) => x.formatters) - const result = [] - for (const item of Object.values(formatters)) { - log.info("checking", { name: item.name, ext }) - if (!item.extensions.includes(ext)) continue - if (!(await isEnabled(item))) continue - log.info("enabled", { name: item.name, ext }) - result.push(item) - } - return result + export async function init() { + return runPromiseInstance(FormatService.use((s) => s.init())) } export async function status() { - const s = await state() - const result: Status[] = [] - for (const formatter of Object.values(s.formatters)) { - const enabled = await isEnabled(formatter) - result.push({ - name: formatter.name, - extensions: formatter.extensions, - enabled, - }) - } - return result - } - - export function init() { - log.info("init") - Bus.subscribe(File.Event.Edited, async (payload) => { - const file = payload.properties.file - log.info("formatting", { file }) - const ext = path.extname(file) - - for (const item of await getFormatter(ext)) { - log.info("running", { command: item.command }) - try { - const proc = Process.spawn( - item.command.map((x) => x.replace("$FILE", file)), - { - cwd: Instance.directory, - env: { ...process.env, ...item.environment }, - stdout: "ignore", - stderr: "ignore", - }, - ) - const exit = await proc.exited - if (exit !== 0) - log.error("failed", { - command: item.command, - ...item.environment, - }) - } catch (error) { - log.error("failed to format file", { - error, - command: item.command, - ...item.environment, - file, - }) - } - } - }) + return runPromiseInstance(FormatService.use((s) => s.status())) } } + +export namespace FormatService { + export interface Service { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + } +} + +export class FormatService extends ServiceMap.Service()("@opencode/Format") { + static readonly layer = Layer.effect( + FormatService, + Effect.gen(function* () { + const instance = yield* InstanceContext + + const enabled: Record = {} + const formatters: Record = {} + + const cfg = yield* Effect.promise(() => Config.get()) + + if (cfg.formatter !== false) { + for (const item of Object.values(Formatter)) { + formatters[item.name] = item + } + for (const [name, item] of Object.entries(cfg.formatter ?? {})) { + if (item.disabled) { + delete formatters[name] + continue + } + const result = mergeDeep(formatters[name] ?? {}, { + command: [], + extensions: [], + ...item, + }) as Formatter.Info + + if (result.command.length === 0) continue + + result.enabled = async () => true + result.name = name + formatters[name] = result + } + } else { + log.info("all formatters are disabled") + } + + async function isEnabled(item: Formatter.Info) { + let status = enabled[item.name] + if (status === undefined) { + status = await item.enabled() + enabled[item.name] = status + } + return status + } + + async function getFormatter(ext: string) { + const result = [] + for (const item of Object.values(formatters)) { + log.info("checking", { name: item.name, ext }) + if (!item.extensions.includes(ext)) continue + if (!(await isEnabled(item))) continue + log.info("enabled", { name: item.name, ext }) + result.push(item) + } + return result + } + + const unsubscribe = Bus.subscribe( + File.Event.Edited, + Instance.bind(async (payload) => { + const file = payload.properties.file + log.info("formatting", { file }) + const ext = path.extname(file) + + for (const item of await getFormatter(ext)) { + log.info("running", { command: item.command }) + try { + const proc = Process.spawn( + item.command.map((x) => x.replace("$FILE", file)), + { + cwd: instance.directory, + env: { ...process.env, ...item.environment }, + stdout: "ignore", + stderr: "ignore", + }, + ) + const exit = await proc.exited + if (exit !== 0) + log.error("failed", { + command: item.command, + ...item.environment, + }) + } catch (error) { + log.error("failed to format file", { + error, + command: item.command, + ...item.environment, + file, + }) + } + } + }), + ) + + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) + log.info("init") + + const init = Effect.fn("FormatService.init")(function* () {}) + + const status = Effect.fn("FormatService.status")(function* () { + const result: Format.Status[] = [] + for (const formatter of Object.values(formatters)) { + const isOn = yield* Effect.promise(() => isEnabled(formatter)) + result.push({ + name: formatter.name, + extensions: formatter.extensions, + enabled: isOn, + }) + } + return result + }) + + return FormatService.of({ init, status }) + }), + ) +} diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index da4a67dba7..00ced358d7 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -18,7 +18,7 @@ export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) await Plugin.init() ShareNext.init() - Format.init() + await Format.init() await LSP.init() await runPromiseInstance(FileWatcherService.use((service) => service.init())) File.init() diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts index 9eedffd76f..2a3c56b2c5 100644 --- a/packages/opencode/test/file/time.test.ts +++ b/packages/opencode/test/file/time.test.ts @@ -132,7 +132,7 @@ describe("file/time", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(sessionID, filepath) + await FileTime.read(sessionID, filepath) await Bun.sleep(100) await fs.writeFile(filepath, "modified", "utf-8") diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts new file mode 100644 index 0000000000..610850d47a --- /dev/null +++ b/packages/opencode/test/format/format.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { tmpdir } from "../fixture/fixture" +import { withServices } from "../fixture/instance" +import { FormatService } from "../../src/format" +import { Instance } from "../../src/project/instance" + +describe("FormatService", () => { + afterEach(() => Instance.disposeAll()) + + test("status() returns built-in formatters when no config overrides", async () => { + await using tmp = await tmpdir() + + await withServices(tmp.path, FormatService.layer, async (rt) => { + const statuses = await rt.runPromise(FormatService.use((s) => s.status())) + expect(Array.isArray(statuses)).toBe(true) + expect(statuses.length).toBeGreaterThan(0) + + for (const s of statuses) { + expect(typeof s.name).toBe("string") + expect(Array.isArray(s.extensions)).toBe(true) + expect(typeof s.enabled).toBe("boolean") + } + + const gofmt = statuses.find((s) => s.name === "gofmt") + expect(gofmt).toBeDefined() + expect(gofmt!.extensions).toContain(".go") + }) + }) + + test("status() returns empty list when formatter is disabled", async () => { + await using tmp = await tmpdir({ + config: { formatter: false }, + }) + + await withServices(tmp.path, FormatService.layer, async (rt) => { + const statuses = await rt.runPromise(FormatService.use((s) => s.status())) + expect(statuses).toEqual([]) + }) + }) + + test("status() excludes formatters marked as disabled in config", async () => { + await using tmp = await tmpdir({ + config: { + formatter: { + gofmt: { disabled: true }, + }, + }, + }) + + await withServices(tmp.path, FormatService.layer, async (rt) => { + const statuses = await rt.runPromise(FormatService.use((s) => s.status())) + const gofmt = statuses.find((s) => s.name === "gofmt") + expect(gofmt).toBeUndefined() + }) + }) + + test("init() completes without error", async () => { + await using tmp = await tmpdir() + + await withServices(tmp.path, FormatService.layer, async (rt) => { + await rt.runPromise(FormatService.use((s) => s.init())) + }) + }) +}) From df6508530f1f7b22787ac931bf93a1b530b98c33 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 16 Mar 2026 19:59:49 +0000 Subject: [PATCH 011/112] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 16 ++++----- packages/sdk/openapi.json | 44 ++++++++++++------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b851e9ecbf..4ee369421e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -169,6 +169,13 @@ export type EventVcsBranchUpdated = { } } +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + export type EventServerConnected = { type: "server.connected" properties: { @@ -198,13 +205,6 @@ export type EventLspUpdated = { } } -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - export type OutputFormatText = { type: "text" } @@ -969,11 +969,11 @@ export type Event = | EventPermissionReplied | EventFileWatcherUpdated | EventVcsBranchUpdated + | EventFileEdited | EventServerConnected | EventGlobalDisposed | EventLspClientDiagnostics | EventLspUpdated - | EventFileEdited | EventMessageUpdated | EventMessageRemoved | EventMessagePartUpdated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1a440d12c7..6d691eaee7 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7355,6 +7355,25 @@ }, "required": ["type", "properties"] }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, "Event.server.connected": { "type": "object", "properties": { @@ -7419,25 +7438,6 @@ }, "required": ["type", "properties"] }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, "OutputFormatText": { "type": "object", "properties": { @@ -9632,6 +9632,9 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -9644,9 +9647,6 @@ { "$ref": "#/components/schemas/Event.lsp.updated" }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, { "$ref": "#/components/schemas/Event.message.updated" }, From 69381f6aea7cec16b469e6242137f0262aac24c5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 16 Mar 2026 16:18:39 -0400 Subject: [PATCH 012/112] refactor(file): effectify FileService as scoped service (#17845) --- packages/opencode/src/effect/instances.ts | 3 + packages/opencode/src/file/index.ts | 1186 +++++++++++---------- packages/opencode/test/file/index.test.ts | 466 ++++++++ 3 files changed, 1098 insertions(+), 557 deletions(-) diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index f5d9ac94a1..240f8ee66a 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -8,6 +8,7 @@ import { FileWatcherService } from "@/file/watcher" import { VcsService } from "@/project/vcs" import { FileTimeService } from "@/file/time" import { FormatService } from "@/format" +import { FileService } from "@/file" import { Instance } from "@/project/instance" export { InstanceContext } from "./instance-context" @@ -20,6 +21,7 @@ export type InstanceServices = | VcsService | FileTimeService | FormatService + | FileService function lookup(directory: string) { const project = Instance.project @@ -32,6 +34,7 @@ function lookup(directory: string) { Layer.fresh(VcsService.layer), Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), Layer.fresh(FormatService.layer), + Layer.fresh(FileService.layer), ).pipe(Layer.provide(ctx)) } diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index e03fc8a9f3..09bcf92901 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -12,10 +12,263 @@ import fuzzysort from "fuzzysort" import { Global } from "../global" import { git } from "@/util/git" import { Protected } from "./protected" +import { InstanceContext } from "@/effect/instance-context" +import { Effect, Layer, ServiceMap } from "effect" +import { runPromiseInstance } from "@/effect/runtime" + +const log = Log.create({ service: "file" }) + +const binaryExtensions = new Set([ + "exe", + "dll", + "pdb", + "bin", + "so", + "dylib", + "o", + "a", + "lib", + "wav", + "mp3", + "ogg", + "oga", + "ogv", + "ogx", + "flac", + "aac", + "wma", + "m4a", + "weba", + "mp4", + "avi", + "mov", + "wmv", + "flv", + "webm", + "mkv", + "zip", + "tar", + "gz", + "gzip", + "bz", + "bz2", + "bzip", + "bzip2", + "7z", + "rar", + "xz", + "lz", + "z", + "pdf", + "doc", + "docx", + "ppt", + "pptx", + "xls", + "xlsx", + "dmg", + "iso", + "img", + "vmdk", + "ttf", + "otf", + "woff", + "woff2", + "eot", + "sqlite", + "db", + "mdb", + "apk", + "ipa", + "aab", + "xapk", + "app", + "pkg", + "deb", + "rpm", + "snap", + "flatpak", + "appimage", + "msi", + "msp", + "jar", + "war", + "ear", + "class", + "kotlin_module", + "dex", + "vdex", + "odex", + "oat", + "art", + "wasm", + "wat", + "bc", + "ll", + "s", + "ko", + "sys", + "drv", + "efi", + "rom", + "com", + "cmd", + "ps1", + "sh", + "bash", + "zsh", + "fish", +]) + +const imageExtensions = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "bmp", + "webp", + "ico", + "tif", + "tiff", + "svg", + "svgz", + "avif", + "apng", + "jxl", + "heic", + "heif", + "raw", + "cr2", + "nef", + "arw", + "dng", + "orf", + "raf", + "pef", + "x3f", +]) + +const textExtensions = new Set([ + "ts", + "tsx", + "mts", + "cts", + "mtsx", + "ctsx", + "js", + "jsx", + "mjs", + "cjs", + "sh", + "bash", + "zsh", + "fish", + "ps1", + "psm1", + "cmd", + "bat", + "json", + "jsonc", + "json5", + "yaml", + "yml", + "toml", + "md", + "mdx", + "txt", + "xml", + "html", + "htm", + "css", + "scss", + "sass", + "less", + "graphql", + "gql", + "sql", + "ini", + "cfg", + "conf", + "env", +]) + +const textNames = new Set([ + "dockerfile", + "makefile", + ".gitignore", + ".gitattributes", + ".editorconfig", + ".npmrc", + ".nvmrc", + ".prettierrc", + ".eslintrc", +]) + +function isImageByExtension(filepath: string): boolean { + const ext = path.extname(filepath).toLowerCase().slice(1) + return imageExtensions.has(ext) +} + +function isTextByExtension(filepath: string): boolean { + const ext = path.extname(filepath).toLowerCase().slice(1) + return textExtensions.has(ext) +} + +function isTextByName(filepath: string): boolean { + const name = path.basename(filepath).toLowerCase() + return textNames.has(name) +} + +function getImageMimeType(filepath: string): string { + const ext = path.extname(filepath).toLowerCase().slice(1) + const mimeTypes: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + bmp: "image/bmp", + webp: "image/webp", + ico: "image/x-icon", + tif: "image/tiff", + tiff: "image/tiff", + svg: "image/svg+xml", + svgz: "image/svg+xml", + avif: "image/avif", + apng: "image/apng", + jxl: "image/jxl", + heic: "image/heic", + heif: "image/heif", + } + return mimeTypes[ext] || "image/" + ext +} + +function isBinaryByExtension(filepath: string): boolean { + const ext = path.extname(filepath).toLowerCase().slice(1) + return binaryExtensions.has(ext) +} + +function isImage(mimeType: string): boolean { + return mimeType.startsWith("image/") +} + +function shouldEncode(mimeType: string): boolean { + const type = mimeType.toLowerCase() + log.info("shouldEncode", { type }) + if (!type) return false + + if (type.startsWith("text/")) return false + if (type.includes("charset=")) return false + + const parts = type.split("/", 2) + const top = parts[0] + + const tops = ["image", "audio", "video", "font", "model", "multipart"] + if (tops.includes(top)) return true + + return false +} export namespace File { - const log = Log.create({ service: "file" }) - export const Info = z .object({ path: z.string(), @@ -73,256 +326,6 @@ export namespace File { }) export type Content = z.infer - const binaryExtensions = new Set([ - "exe", - "dll", - "pdb", - "bin", - "so", - "dylib", - "o", - "a", - "lib", - "wav", - "mp3", - "ogg", - "oga", - "ogv", - "ogx", - "flac", - "aac", - "wma", - "m4a", - "weba", - "mp4", - "avi", - "mov", - "wmv", - "flv", - "webm", - "mkv", - "zip", - "tar", - "gz", - "gzip", - "bz", - "bz2", - "bzip", - "bzip2", - "7z", - "rar", - "xz", - "lz", - "z", - "pdf", - "doc", - "docx", - "ppt", - "pptx", - "xls", - "xlsx", - "dmg", - "iso", - "img", - "vmdk", - "ttf", - "otf", - "woff", - "woff2", - "eot", - "sqlite", - "db", - "mdb", - "apk", - "ipa", - "aab", - "xapk", - "app", - "pkg", - "deb", - "rpm", - "snap", - "flatpak", - "appimage", - "msi", - "msp", - "jar", - "war", - "ear", - "class", - "kotlin_module", - "dex", - "vdex", - "odex", - "oat", - "art", - "wasm", - "wat", - "bc", - "ll", - "s", - "ko", - "sys", - "drv", - "efi", - "rom", - "com", - "cmd", - "ps1", - "sh", - "bash", - "zsh", - "fish", - ]) - - const imageExtensions = new Set([ - "png", - "jpg", - "jpeg", - "gif", - "bmp", - "webp", - "ico", - "tif", - "tiff", - "svg", - "svgz", - "avif", - "apng", - "jxl", - "heic", - "heif", - "raw", - "cr2", - "nef", - "arw", - "dng", - "orf", - "raf", - "pef", - "x3f", - ]) - - const textExtensions = new Set([ - "ts", - "tsx", - "mts", - "cts", - "mtsx", - "ctsx", - "js", - "jsx", - "mjs", - "cjs", - "sh", - "bash", - "zsh", - "fish", - "ps1", - "psm1", - "cmd", - "bat", - "json", - "jsonc", - "json5", - "yaml", - "yml", - "toml", - "md", - "mdx", - "txt", - "xml", - "html", - "htm", - "css", - "scss", - "sass", - "less", - "graphql", - "gql", - "sql", - "ini", - "cfg", - "conf", - "env", - ]) - - const textNames = new Set([ - "dockerfile", - "makefile", - ".gitignore", - ".gitattributes", - ".editorconfig", - ".npmrc", - ".nvmrc", - ".prettierrc", - ".eslintrc", - ]) - - function isImageByExtension(filepath: string): boolean { - const ext = path.extname(filepath).toLowerCase().slice(1) - return imageExtensions.has(ext) - } - - function isTextByExtension(filepath: string): boolean { - const ext = path.extname(filepath).toLowerCase().slice(1) - return textExtensions.has(ext) - } - - function isTextByName(filepath: string): boolean { - const name = path.basename(filepath).toLowerCase() - return textNames.has(name) - } - - function getImageMimeType(filepath: string): string { - const ext = path.extname(filepath).toLowerCase().slice(1) - const mimeTypes: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - bmp: "image/bmp", - webp: "image/webp", - ico: "image/x-icon", - tif: "image/tiff", - tiff: "image/tiff", - svg: "image/svg+xml", - svgz: "image/svg+xml", - avif: "image/avif", - apng: "image/apng", - jxl: "image/jxl", - heic: "image/heic", - heif: "image/heif", - } - return mimeTypes[ext] || "image/" + ext - } - - function isBinaryByExtension(filepath: string): boolean { - const ext = path.extname(filepath).toLowerCase().slice(1) - return binaryExtensions.has(ext) - } - - function isImage(mimeType: string): boolean { - return mimeType.startsWith("image/") - } - - async function shouldEncode(mimeType: string): Promise { - const type = mimeType.toLowerCase() - log.info("shouldEncode", { type }) - if (!type) return false - - if (type.startsWith("text/")) return false - if (type.includes("charset=")) return false - - const parts = type.split("/", 2) - const top = parts[0] - - const tops = ["image", "audio", "video", "font", "model", "multipart"] - if (tops.includes(top)) return true - - return false - } - export const Event = { Edited: BusEvent.define( "file.edited", @@ -332,323 +335,392 @@ export namespace File { ), } - const state = Instance.state(async () => { - type Entry = { files: string[]; dirs: string[] } - let cache: Entry = { files: [], dirs: [] } - let fetching = false - - const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global" - - const fn = async (result: Entry) => { - // Disable scanning if in root of file system - if (Instance.directory === path.parse(Instance.directory).root) return - fetching = true - - if (isGlobalHome) { - const dirs = new Set() - const ignore = Protected.names() - - const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) - const shouldIgnore = (name: string) => name.startsWith(".") || ignore.has(name) - const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) - - const top = await fs.promises - .readdir(Instance.directory, { withFileTypes: true }) - .catch(() => [] as fs.Dirent[]) - - for (const entry of top) { - if (!entry.isDirectory()) continue - if (shouldIgnore(entry.name)) continue - dirs.add(entry.name + "/") - - const base = path.join(Instance.directory, entry.name) - const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) - for (const child of children) { - if (!child.isDirectory()) continue - if (shouldIgnoreNested(child.name)) continue - dirs.add(entry.name + "/" + child.name + "/") - } - } - - result.dirs = Array.from(dirs).toSorted() - cache = result - fetching = false - return - } - - const set = new Set() - for await (const file of Ripgrep.files({ cwd: Instance.directory })) { - result.files.push(file) - let current = file - while (true) { - const dir = path.dirname(current) - if (dir === ".") break - if (dir === current) break - current = dir - if (set.has(dir)) continue - set.add(dir) - result.dirs.push(dir + "/") - } - } - cache = result - fetching = false - } - fn(cache) - - return { - async files() { - if (!fetching) { - fn({ - files: [], - dirs: [], - }) - } - return cache - }, - } - }) - export function init() { - state() + return runPromiseInstance(FileService.use((s) => s.init())) } export async function status() { - const project = Instance.project - if (project.vcs !== "git") return [] - - const diffOutput = ( - await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { - cwd: Instance.directory, - }) - ).text() - - const changedFiles: Info[] = [] - - if (diffOutput.trim()) { - const lines = diffOutput.trim().split("\n") - for (const line of lines) { - const [added, removed, filepath] = line.split("\t") - changedFiles.push({ - path: filepath, - added: added === "-" ? 0 : parseInt(added, 10), - removed: removed === "-" ? 0 : parseInt(removed, 10), - status: "modified", - }) - } - } - - const untrackedOutput = ( - await git( - ["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard"], - { - cwd: Instance.directory, - }, - ) - ).text() - - if (untrackedOutput.trim()) { - const untrackedFiles = untrackedOutput.trim().split("\n") - for (const filepath of untrackedFiles) { - try { - const content = await Filesystem.readText(path.join(Instance.directory, filepath)) - const lines = content.split("\n").length - changedFiles.push({ - path: filepath, - added: lines, - removed: 0, - status: "added", - }) - } catch { - continue - } - } - } - - // Get deleted files - const deletedOutput = ( - await git( - ["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--name-only", "--diff-filter=D", "HEAD"], - { - cwd: Instance.directory, - }, - ) - ).text() - - if (deletedOutput.trim()) { - const deletedFiles = deletedOutput.trim().split("\n") - for (const filepath of deletedFiles) { - changedFiles.push({ - path: filepath, - added: 0, - removed: 0, // Could get original line count but would require another git command - status: "deleted", - }) - } - } - - return changedFiles.map((x) => { - const full = path.isAbsolute(x.path) ? x.path : path.join(Instance.directory, x.path) - return { - ...x, - path: path.relative(Instance.directory, full), - } - }) + return runPromiseInstance(FileService.use((s) => s.status())) } export async function read(file: string): Promise { - using _ = log.time("read", { file }) - const project = Instance.project - const full = path.join(Instance.directory, file) - - // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. - // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. - if (!Instance.containsPath(full)) { - throw new Error(`Access denied: path escapes project directory`) - } - - // Fast path: check extension before any filesystem operations - if (isImageByExtension(file)) { - if (await Filesystem.exists(full)) { - const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) - const content = buffer.toString("base64") - const mimeType = getImageMimeType(file) - return { type: "text", content, mimeType, encoding: "base64" } - } - return { type: "text", content: "" } - } - - const text = isTextByExtension(file) || isTextByName(file) - - if (isBinaryByExtension(file) && !text) { - return { type: "binary", content: "" } - } - - if (!(await Filesystem.exists(full))) { - return { type: "text", content: "" } - } - - const mimeType = Filesystem.mimeType(full) - const encode = text ? false : await shouldEncode(mimeType) - - if (encode && !isImage(mimeType)) { - return { type: "binary", content: "", mimeType } - } - - if (encode) { - const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) - const content = buffer.toString("base64") - return { type: "text", content, mimeType, encoding: "base64" } - } - - const content = (await Filesystem.readText(full).catch(() => "")).trim() - - if (project.vcs === "git") { - let diff = (await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })).text() - if (!diff.trim()) { - diff = ( - await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: Instance.directory }) - ).text() - } - if (diff.trim()) { - const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text() - const patch = structuredPatch(file, file, original, content, "old", "new", { - context: Infinity, - ignoreWhitespace: true, - }) - const diff = formatPatch(patch) - return { type: "text", content, patch, diff } - } - } - return { type: "text", content } + return runPromiseInstance(FileService.use((s) => s.read(file))) } export async function list(dir?: string) { - const exclude = [".git", ".DS_Store"] - const project = Instance.project - let ignored = (_: string) => false - if (project.vcs === "git") { - const ig = ignore() - const gitignorePath = path.join(Instance.worktree, ".gitignore") - if (await Filesystem.exists(gitignorePath)) { - ig.add(await Filesystem.readText(gitignorePath)) - } - const ignorePath = path.join(Instance.worktree, ".ignore") - if (await Filesystem.exists(ignorePath)) { - ig.add(await Filesystem.readText(ignorePath)) - } - ignored = ig.ignores.bind(ig) - } - const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory - - // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. - // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. - if (!Instance.containsPath(resolved)) { - throw new Error(`Access denied: path escapes project directory`) - } - - const nodes: Node[] = [] - for (const entry of await fs.promises - .readdir(resolved, { - withFileTypes: true, - }) - .catch(() => [])) { - if (exclude.includes(entry.name)) continue - const fullPath = path.join(resolved, entry.name) - const relativePath = path.relative(Instance.directory, fullPath) - const type = entry.isDirectory() ? "directory" : "file" - nodes.push({ - name: entry.name, - path: relativePath, - absolute: fullPath, - type, - ignored: ignored(type === "directory" ? relativePath + "/" : relativePath), - }) - } - return nodes.sort((a, b) => { - if (a.type !== b.type) { - return a.type === "directory" ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + return runPromiseInstance(FileService.use((s) => s.list(dir))) } export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { - const query = input.query.trim() - const limit = input.limit ?? 100 - const kind = input.type ?? (input.dirs === false ? "file" : "all") - log.info("search", { query, kind }) - - const result = await state().then((x) => x.files()) - - const hidden = (item: string) => { - const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") - return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1) - } - const preferHidden = query.startsWith(".") || query.includes("/.") - const sortHiddenLast = (items: string[]) => { - if (preferHidden) return items - const visible: string[] = [] - const hiddenItems: string[] = [] - for (const item of items) { - const isHidden = hidden(item) - if (isHidden) hiddenItems.push(item) - if (!isHidden) visible.push(item) - } - return [...visible, ...hiddenItems] - } - if (!query) { - if (kind === "file") return result.files.slice(0, limit) - return sortHiddenLast(result.dirs.toSorted()).slice(0, limit) - } - - const items = - kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] - - const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit - const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target) - const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted - - log.info("search", { query, kind, results: output.length }) - return output + return runPromiseInstance(FileService.use((s) => s.search(input))) } } + +export namespace FileService { + export interface Service { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + readonly read: (file: string) => Effect.Effect + readonly list: (dir?: string) => Effect.Effect + readonly search: (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + }) => Effect.Effect + } +} + +export class FileService extends ServiceMap.Service()("@opencode/File") { + static readonly layer = Layer.effect( + FileService, + Effect.gen(function* () { + const instance = yield* InstanceContext + + // File cache state + type Entry = { files: string[]; dirs: string[] } + let cache: Entry = { files: [], dirs: [] } + let task: Promise | undefined + + const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global" + + function kick() { + if (task) return task + task = (async () => { + // Disable scanning if in root of file system + if (instance.directory === path.parse(instance.directory).root) return + const next: Entry = { files: [], dirs: [] } + try { + if (isGlobalHome) { + const dirs = new Set() + const protectedNames = Protected.names() + + const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) + const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) + const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) + + const top = await fs.promises + .readdir(instance.directory, { withFileTypes: true }) + .catch(() => [] as fs.Dirent[]) + + for (const entry of top) { + if (!entry.isDirectory()) continue + if (shouldIgnoreName(entry.name)) continue + dirs.add(entry.name + "/") + + const base = path.join(instance.directory, entry.name) + const children = await fs.promises + .readdir(base, { withFileTypes: true }) + .catch(() => [] as fs.Dirent[]) + for (const child of children) { + if (!child.isDirectory()) continue + if (shouldIgnoreNested(child.name)) continue + dirs.add(entry.name + "/" + child.name + "/") + } + } + + next.dirs = Array.from(dirs).toSorted() + } else { + const set = new Set() + for await (const file of Ripgrep.files({ cwd: instance.directory })) { + next.files.push(file) + let current = file + while (true) { + const dir = path.dirname(current) + if (dir === ".") break + if (dir === current) break + current = dir + if (set.has(dir)) continue + set.add(dir) + next.dirs.push(dir + "/") + } + } + } + cache = next + } finally { + task = undefined + } + })() + return task + } + + const getFiles = async () => { + void kick() + return cache + } + + const init = Effect.fn("FileService.init")(function* () { + void kick() + }) + + const status = Effect.fn("FileService.status")(function* () { + if (instance.project.vcs !== "git") return [] + + return yield* Effect.promise(async () => { + const diffOutput = ( + await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { + cwd: instance.directory, + }) + ).text() + + const changedFiles: File.Info[] = [] + + if (diffOutput.trim()) { + const lines = diffOutput.trim().split("\n") + for (const line of lines) { + const [added, removed, filepath] = line.split("\t") + changedFiles.push({ + path: filepath, + added: added === "-" ? 0 : parseInt(added, 10), + removed: removed === "-" ? 0 : parseInt(removed, 10), + status: "modified", + }) + } + } + + const untrackedOutput = ( + await git( + [ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "ls-files", + "--others", + "--exclude-standard", + ], + { + cwd: instance.directory, + }, + ) + ).text() + + if (untrackedOutput.trim()) { + const untrackedFiles = untrackedOutput.trim().split("\n") + for (const filepath of untrackedFiles) { + try { + const content = await Filesystem.readText(path.join(instance.directory, filepath)) + const lines = content.split("\n").length + changedFiles.push({ + path: filepath, + added: lines, + removed: 0, + status: "added", + }) + } catch { + continue + } + } + } + + // Get deleted files + const deletedOutput = ( + await git( + [ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "diff", + "--name-only", + "--diff-filter=D", + "HEAD", + ], + { + cwd: instance.directory, + }, + ) + ).text() + + if (deletedOutput.trim()) { + const deletedFiles = deletedOutput.trim().split("\n") + for (const filepath of deletedFiles) { + changedFiles.push({ + path: filepath, + added: 0, + removed: 0, // Could get original line count but would require another git command + status: "deleted", + }) + } + } + + return changedFiles.map((x) => { + const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path) + return { + ...x, + path: path.relative(instance.directory, full), + } + }) + }) + }) + + const read = Effect.fn("FileService.read")(function* (file: string) { + return yield* Effect.promise(async (): Promise => { + using _ = log.time("read", { file }) + const full = path.join(instance.directory, file) + + if (!Instance.containsPath(full)) { + throw new Error(`Access denied: path escapes project directory`) + } + + // Fast path: check extension before any filesystem operations + if (isImageByExtension(file)) { + if (await Filesystem.exists(full)) { + const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) + const content = buffer.toString("base64") + const mimeType = getImageMimeType(file) + return { type: "text", content, mimeType, encoding: "base64" } + } + return { type: "text", content: "" } + } + + const text = isTextByExtension(file) || isTextByName(file) + + if (isBinaryByExtension(file) && !text) { + return { type: "binary", content: "" } + } + + if (!(await Filesystem.exists(full))) { + return { type: "text", content: "" } + } + + const mimeType = Filesystem.mimeType(full) + const encode = text ? false : shouldEncode(mimeType) + + if (encode && !isImage(mimeType)) { + return { type: "binary", content: "", mimeType } + } + + if (encode) { + const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) + const content = buffer.toString("base64") + return { type: "text", content, mimeType, encoding: "base64" } + } + + const content = (await Filesystem.readText(full).catch(() => "")).trim() + + if (instance.project.vcs === "git") { + let diff = ( + await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory }) + ).text() + if (!diff.trim()) { + diff = ( + await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory }) + ).text() + } + if (diff.trim()) { + const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text() + const patch = structuredPatch(file, file, original, content, "old", "new", { + context: Infinity, + ignoreWhitespace: true, + }) + const diff = formatPatch(patch) + return { type: "text", content, patch, diff } + } + } + return { type: "text", content } + }) + }) + + const list = Effect.fn("FileService.list")(function* (dir?: string) { + return yield* Effect.promise(async () => { + const exclude = [".git", ".DS_Store"] + let ignored = (_: string) => false + if (instance.project.vcs === "git") { + const ig = ignore() + const gitignorePath = path.join(instance.project.worktree, ".gitignore") + if (await Filesystem.exists(gitignorePath)) { + ig.add(await Filesystem.readText(gitignorePath)) + } + const ignorePath = path.join(instance.project.worktree, ".ignore") + if (await Filesystem.exists(ignorePath)) { + ig.add(await Filesystem.readText(ignorePath)) + } + ignored = ig.ignores.bind(ig) + } + const resolved = dir ? path.join(instance.directory, dir) : instance.directory + + if (!Instance.containsPath(resolved)) { + throw new Error(`Access denied: path escapes project directory`) + } + + const nodes: File.Node[] = [] + for (const entry of await fs.promises + .readdir(resolved, { + withFileTypes: true, + }) + .catch(() => [])) { + if (exclude.includes(entry.name)) continue + const fullPath = path.join(resolved, entry.name) + const relativePath = path.relative(instance.directory, fullPath) + const type = entry.isDirectory() ? "directory" : "file" + nodes.push({ + name: entry.name, + path: relativePath, + absolute: fullPath, + type, + ignored: ignored(type === "directory" ? relativePath + "/" : relativePath), + }) + } + return nodes.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + }) + }) + + const search = Effect.fn("FileService.search")(function* (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + }) { + return yield* Effect.promise(async () => { + const query = input.query.trim() + const limit = input.limit ?? 100 + const kind = input.type ?? (input.dirs === false ? "file" : "all") + log.info("search", { query, kind }) + + const result = await getFiles() + + const hidden = (item: string) => { + const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") + return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1) + } + const preferHidden = query.startsWith(".") || query.includes("/.") + const sortHiddenLast = (items: string[]) => { + if (preferHidden) return items + const visible: string[] = [] + const hiddenItems: string[] = [] + for (const item of items) { + const isHidden = hidden(item) + if (isHidden) hiddenItems.push(item) + if (!isHidden) visible.push(item) + } + return [...visible, ...hiddenItems] + } + if (!query) { + if (kind === "file") return result.files.slice(0, limit) + return sortHiddenLast(result.dirs.toSorted()).slice(0, limit) + } + + const items = + kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] + + const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit + const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target) + const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted + + log.info("search", { query, kind, results: output.length }) + return output + }) + }) + + log.info("init") + + return FileService.of({ init, status, read, list, search }) + }), + ) +} diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index f269926b53..89de5b571b 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect } from "bun:test" +import { $ } from "bun" import path from "path" import fs from "fs/promises" import { File } from "../../src/file" @@ -391,4 +392,469 @@ describe("file/index Filesystem patterns", () => { }) }) }) + + describe("File.status()", () => { + test("detects modified file", async () => { + await using tmp = await tmpdir({ git: true }) + const filepath = path.join(tmp.path, "file.txt") + await fs.writeFile(filepath, "original\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(filepath, "modified\nextra line\n", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.status() + const entry = result.find((f) => f.path === "file.txt") + expect(entry).toBeDefined() + expect(entry!.status).toBe("modified") + expect(entry!.added).toBeGreaterThan(0) + expect(entry!.removed).toBeGreaterThan(0) + }, + }) + }) + + test("detects untracked file as added", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.status() + const entry = result.find((f) => f.path === "new.txt") + expect(entry).toBeDefined() + expect(entry!.status).toBe("added") + expect(entry!.added).toBe(4) // 3 lines + trailing newline splits to 4 + expect(entry!.removed).toBe(0) + }, + }) + }) + + test("detects deleted file", async () => { + await using tmp = await tmpdir({ git: true }) + const filepath = path.join(tmp.path, "gone.txt") + await fs.writeFile(filepath, "content\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.rm(filepath) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.status() + // Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted") + const entries = result.filter((f) => f.path === "gone.txt") + expect(entries.some((e) => e.status === "deleted")).toBe(true) + }, + }) + }) + + test("detects mixed changes", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "keep.txt"), "keep\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "remove.txt"), "remove\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "initial"`.cwd(tmp.path).quiet() + + // Modify one, delete one, add one + await fs.writeFile(path.join(tmp.path, "keep.txt"), "changed\n", "utf-8") + await fs.rm(path.join(tmp.path, "remove.txt")) + await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.status() + expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true) + expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true) + expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true) + }, + }) + }) + + test("returns empty for non-git project", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.status() + expect(result).toEqual([]) + }, + }) + }) + + test("returns empty for clean repo", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.status() + expect(result).toEqual([]) + }, + }) + }) + + test("parses binary numstat as 0", async () => { + await using tmp = await tmpdir({ git: true }) + const filepath = path.join(tmp.path, "data.bin") + // Write content with null bytes so git treats it as binary + const binaryData = Buffer.alloc(256) + for (let i = 0; i < 256; i++) binaryData[i] = i + await fs.writeFile(filepath, binaryData) + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet() + // Modify the binary + const modified = Buffer.alloc(512) + for (let i = 0; i < 512; i++) modified[i] = i % 256 + await fs.writeFile(filepath, modified) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.status() + const entry = result.find((f) => f.path === "data.bin") + expect(entry).toBeDefined() + expect(entry!.status).toBe("modified") + expect(entry!.added).toBe(0) + expect(entry!.removed).toBe(0) + }, + }) + }) + }) + + describe("File.list()", () => { + test("returns files and directories with correct shape", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.mkdir(path.join(tmp.path, "subdir")) + await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8") + await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const nodes = await File.list() + expect(nodes.length).toBeGreaterThanOrEqual(2) + for (const node of nodes) { + expect(node).toHaveProperty("name") + expect(node).toHaveProperty("path") + expect(node).toHaveProperty("absolute") + expect(node).toHaveProperty("type") + expect(node).toHaveProperty("ignored") + expect(["file", "directory"]).toContain(node.type) + } + }, + }) + }) + + test("sorts directories before files, alphabetical within each", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.mkdir(path.join(tmp.path, "beta")) + await fs.mkdir(path.join(tmp.path, "alpha")) + await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8") + await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const nodes = await File.list() + const dirs = nodes.filter((n) => n.type === "directory") + const files = nodes.filter((n) => n.type === "file") + // Dirs come first + const firstFile = nodes.findIndex((n) => n.type === "file") + const lastDir = nodes.findLastIndex((n) => n.type === "directory") + if (lastDir >= 0 && firstFile >= 0) { + expect(lastDir).toBeLessThan(firstFile) + } + // Alphabetical within dirs + expect(dirs.map((d) => d.name)).toEqual(dirs.map((d) => d.name).toSorted()) + // Alphabetical within files + expect(files.map((f) => f.name)).toEqual(files.map((f) => f.name).toSorted()) + }, + }) + }) + + test("excludes .git and .DS_Store", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8") + await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const nodes = await File.list() + const names = nodes.map((n) => n.name) + expect(names).not.toContain(".git") + expect(names).not.toContain(".DS_Store") + expect(names).toContain("visible.txt") + }, + }) + }) + + test("marks gitignored files as ignored", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, ".gitignore"), "*.log\nbuild/\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "app.log"), "log data", "utf-8") + await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8") + await fs.mkdir(path.join(tmp.path, "build")) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const nodes = await File.list() + const logNode = nodes.find((n) => n.name === "app.log") + const tsNode = nodes.find((n) => n.name === "main.ts") + const buildNode = nodes.find((n) => n.name === "build") + expect(logNode?.ignored).toBe(true) + expect(tsNode?.ignored).toBe(false) + expect(buildNode?.ignored).toBe(true) + }, + }) + }) + + test("lists subdirectory contents", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.mkdir(path.join(tmp.path, "sub")) + await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8") + await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const nodes = await File.list("sub") + expect(nodes.length).toBe(2) + expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"]) + // Paths should be relative to project root (normalize for Windows) + expect(nodes[0].path.replaceAll("\\", "/").startsWith("sub/")).toBe(true) + }, + }) + }) + + test("throws for paths outside project directory", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(File.list("../outside")).rejects.toThrow("Access denied") + }, + }) + }) + + test("works without git", async () => { + await using tmp = await tmpdir() + await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const nodes = await File.list() + expect(nodes.length).toBeGreaterThanOrEqual(1) + // Without git, ignored should be false for all + for (const node of nodes) { + expect(node.ignored).toBe(false) + } + }, + }) + }) + }) + + describe("File.search()", () => { + async function setupSearchableRepo() { + const tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8") + await fs.writeFile(path.join(tmp.path, "utils.ts"), "utils", "utf-8") + await fs.writeFile(path.join(tmp.path, "readme.md"), "readme", "utf-8") + await fs.mkdir(path.join(tmp.path, "src")) + await fs.mkdir(path.join(tmp.path, ".hidden")) + await fs.writeFile(path.join(tmp.path, "src", "main.ts"), "main", "utf-8") + await fs.writeFile(path.join(tmp.path, ".hidden", "secret.ts"), "secret", "utf-8") + return tmp + } + + test("empty query returns files", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + File.init() + // Give the background scan time to populate + await new Promise((r) => setTimeout(r, 500)) + + const result = await File.search({ query: "", type: "file" }) + expect(result.length).toBeGreaterThan(0) + }, + }) + }) + + test("empty query returns dirs sorted with hidden last", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + File.init() + await new Promise((r) => setTimeout(r, 500)) + + const result = await File.search({ query: "", type: "directory" }) + expect(result.length).toBeGreaterThan(0) + // Find first hidden dir index + const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1)) + const lastVisible = result.findLastIndex((d) => !d.split("/").some((p) => p.startsWith(".") && p.length > 1)) + if (firstHidden >= 0 && lastVisible >= 0) { + expect(firstHidden).toBeGreaterThan(lastVisible) + } + }, + }) + }) + + test("fuzzy matches file names", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + File.init() + await new Promise((r) => setTimeout(r, 500)) + + const result = await File.search({ query: "main", type: "file" }) + expect(result.some((f) => f.includes("main"))).toBe(true) + }, + }) + }) + + test("type filter returns only files", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + File.init() + await new Promise((r) => setTimeout(r, 500)) + + const result = await File.search({ query: "", type: "file" }) + // Files don't end with / + for (const f of result) { + expect(f.endsWith("/")).toBe(false) + } + }, + }) + }) + + test("type filter returns only directories", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + File.init() + await new Promise((r) => setTimeout(r, 500)) + + const result = await File.search({ query: "", type: "directory" }) + // Directories end with / + for (const d of result) { + expect(d.endsWith("/")).toBe(true) + } + }, + }) + }) + + test("respects limit", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + File.init() + await new Promise((r) => setTimeout(r, 500)) + + const result = await File.search({ query: "", type: "file", limit: 2 }) + expect(result.length).toBeLessThanOrEqual(2) + }, + }) + }) + + test("query starting with dot prefers hidden files", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + File.init() + await new Promise((r) => setTimeout(r, 500)) + + const result = await File.search({ query: ".hidden", type: "directory" }) + expect(result.length).toBeGreaterThan(0) + expect(result[0]).toContain(".hidden") + }, + }) + }) + }) + + describe("File.read() - diff/patch", () => { + test("returns diff and patch for modified tracked file", async () => { + await using tmp = await tmpdir({ git: true }) + const filepath = path.join(tmp.path, "file.txt") + await fs.writeFile(filepath, "original content\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(filepath, "modified content\n", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.read("file.txt") + expect(result.type).toBe("text") + expect(result.content).toBe("modified content") + expect(result.diff).toBeDefined() + expect(result.diff).toContain("original content") + expect(result.diff).toContain("modified content") + expect(result.patch).toBeDefined() + expect(result.patch!.hunks.length).toBeGreaterThan(0) + }, + }) + }) + + test("returns diff for staged changes", async () => { + await using tmp = await tmpdir({ git: true }) + const filepath = path.join(tmp.path, "staged.txt") + await fs.writeFile(filepath, "before\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(filepath, "after\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.read("staged.txt") + expect(result.diff).toBeDefined() + expect(result.patch).toBeDefined() + }, + }) + }) + + test("returns no diff for unmodified file", async () => { + await using tmp = await tmpdir({ git: true }) + const filepath = path.join(tmp.path, "clean.txt") + await fs.writeFile(filepath, "unchanged\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.read("clean.txt") + expect(result.type).toBe("text") + expect(result.content).toBe("unchanged") + expect(result.diff).toBeUndefined() + expect(result.patch).toBeUndefined() + }, + }) + }) + }) }) From 8da511dfa825fdfbdac08b2738ce38b64f0af46b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 16 Mar 2026 20:19:50 +0000 Subject: [PATCH 013/112] chore: generate --- packages/opencode/src/file/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 09bcf92901..44c04e9e43 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -409,9 +409,7 @@ export class FileService extends ServiceMap.Service [] as fs.Dirent[]) + const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) for (const child of children) { if (!child.isDirectory()) continue if (shouldIgnoreNested(child.name)) continue From 68809365dfe32dd13ad9b3a8c2f555b2e8fa049b Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:05:14 -0500 Subject: [PATCH 014/112] fix: github copilot enterprise integration (#17847) --- packages/opencode/src/plugin/copilot.ts | 5 +- packages/opencode/src/provider/provider.ts | 56 +--------------------- packages/opencode/src/provider/schema.ts | 1 - 3 files changed, 2 insertions(+), 60 deletions(-) diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index ddb4d9046a..31d84532c4 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -185,12 +185,10 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { const deploymentType = inputs.deploymentType || "github.com" let domain = "github.com" - let actualProvider = "github-copilot" if (deploymentType === "enterprise") { const enterpriseUrl = inputs.enterpriseUrl domain = normalizeDomain(enterpriseUrl!) - actualProvider = "github-copilot-enterprise" } const urls = getUrls(domain) @@ -262,8 +260,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { expires: 0, } - if (actualProvider === "github-copilot-enterprise") { - result.provider = "github-copilot-enterprise" + if (deploymentType === "enterprise") { result.enterpriseUrl = domain } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 349073197d..2537f89493 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -197,16 +197,6 @@ export namespace Provider { options: {}, } }, - "github-copilot-enterprise": async () => { - return { - autoload: false, - async getModel(sdk: any, modelID: string, _options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID) - }, - options: {}, - } - }, azure: async (provider) => { const resource = iife(() => { const name = provider.options?.resourceName @@ -863,20 +853,6 @@ export namespace Provider { const configProviders = Object.entries(config.provider ?? {}) - // Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot - if (database["github-copilot"]) { - const githubCopilot = database["github-copilot"] - database["github-copilot-enterprise"] = { - ...githubCopilot, - id: ProviderID.githubCopilotEnterprise, - name: "GitHub Copilot Enterprise", - models: mapValues(githubCopilot.models, (model) => ({ - ...model, - providerID: ProviderID.githubCopilotEnterprise, - })), - } - } - function mergeProvider(providerID: ProviderID, provider: Partial) { const existing = providers[providerID] if (existing) { @@ -1003,46 +979,16 @@ export namespace Provider { const providerID = ProviderID.make(plugin.auth.provider) if (disabled.has(providerID)) continue - // For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise - let hasAuth = false const auth = await Auth.get(providerID) - if (auth) hasAuth = true - - // Special handling for github-copilot: also check for enterprise auth - if (providerID === ProviderID.githubCopilot && !hasAuth) { - const enterpriseAuth = await Auth.get("github-copilot-enterprise") - if (enterpriseAuth) hasAuth = true - } - - if (!hasAuth) continue + if (!auth) continue if (!plugin.auth.loader) continue - // Load for the main provider if auth exists if (auth) { const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) const opts = options ?? {} const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } mergeProvider(providerID, patch) } - - // If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists - if (providerID === ProviderID.githubCopilot) { - const enterpriseProviderID = ProviderID.githubCopilotEnterprise - if (!disabled.has(enterpriseProviderID)) { - const enterpriseAuth = await Auth.get(enterpriseProviderID) - if (enterpriseAuth) { - const enterpriseOptions = await plugin.auth.loader( - () => Auth.get(enterpriseProviderID) as any, - database[enterpriseProviderID], - ) - const opts = enterpriseOptions ?? {} - const patch: Partial = providers[enterpriseProviderID] - ? { options: opts } - : { source: "custom", options: opts } - mergeProvider(enterpriseProviderID, patch) - } - } - } } for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) { diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index 9eac235ceb..15a919d8ea 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -18,7 +18,6 @@ export const ProviderID = providerIdSchema.pipe( google: schema.makeUnsafe("google"), googleVertex: schema.makeUnsafe("google-vertex"), githubCopilot: schema.makeUnsafe("github-copilot"), - githubCopilotEnterprise: schema.makeUnsafe("github-copilot-enterprise"), amazonBedrock: schema.makeUnsafe("amazon-bedrock"), azure: schema.makeUnsafe("azure"), openrouter: schema.makeUnsafe("openrouter"), From e9a17e4480c8295ae3f72d4ea68c55ae07bfdc41 Mon Sep 17 00:00:00 2001 From: AbigailJixiangyuyu <129409676+AbigailJixiangyuyu@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:11:02 +0800 Subject: [PATCH 015/112] fix(windows): restore /editor support on Windows (#17146) --- .../opencode/src/cli/cmd/tui/util/editor.ts | 28 +++++++++++-------- packages/opencode/src/util/process.ts | 3 ++ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/util/editor.ts b/packages/opencode/src/cli/cmd/tui/util/editor.ts index 6d32c63c00..9eaae99fce 100644 --- a/packages/opencode/src/cli/cmd/tui/util/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/util/editor.ts @@ -17,17 +17,21 @@ export namespace Editor { await Filesystem.write(filepath, opts.value) opts.renderer.suspend() opts.renderer.currentRenderBuffer.clear() - const parts = editor.split(" ") - const proc = Process.spawn([...parts, filepath], { - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - }) - await proc.exited - const content = await Filesystem.readText(filepath) - opts.renderer.currentRenderBuffer.clear() - opts.renderer.resume() - opts.renderer.requestRender() - return content || undefined + try { + const parts = editor.split(" ") + const proc = Process.spawn([...parts, filepath], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + shell: process.platform === "win32", + }) + await proc.exited + const content = await Filesystem.readText(filepath) + return content || undefined + } finally { + opts.renderer.currentRenderBuffer.clear() + opts.renderer.resume() + opts.renderer.requestRender() + } } } diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts index 0490969370..9b37432c39 100644 --- a/packages/opencode/src/util/process.ts +++ b/packages/opencode/src/util/process.ts @@ -3,6 +3,7 @@ import { buffer } from "node:stream/consumers" export namespace Process { export type Stdio = "inherit" | "pipe" | "ignore" + export type Shell = boolean | string export interface Options { cwd?: string @@ -10,6 +11,7 @@ export namespace Process { stdin?: Stdio stdout?: Stdio stderr?: Stdio + shell?: Shell abort?: AbortSignal kill?: NodeJS.Signals | number timeout?: number @@ -60,6 +62,7 @@ export namespace Process { cwd: opts.cwd, env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined, stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"], + shell: opts.shell, windowsHide: process.platform === "win32", }) From 38498227697e2ef12a2e073b9617e25031ffbf08 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 16 Mar 2026 19:51:07 -0400 Subject: [PATCH 016/112] refactor(skill): effectify SkillService as scoped service (#17849) --- packages/opencode/package.json | 1 + packages/opencode/src/effect/instances.ts | 3 + packages/opencode/src/skill/discovery.ts | 202 ++++++----- packages/opencode/src/skill/skill.ts | 333 ++++++++++-------- .../opencode/test/skill/discovery.test.ts | 30 +- 5 files changed, 324 insertions(+), 245 deletions(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c462b1761d..0463cc6d25 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -95,6 +95,7 @@ "@openrouter/ai-sdk-provider": "1.5.4", "@opentui/core": "0.1.87", "@opentui/solid": "0.1.87", + "@effect/platform-node": "4.0.0-beta.31", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 240f8ee66a..eabf198688 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -9,6 +9,7 @@ import { VcsService } from "@/project/vcs" import { FileTimeService } from "@/file/time" import { FormatService } from "@/format" import { FileService } from "@/file" +import { SkillService } from "@/skill/skill" import { Instance } from "@/project/instance" export { InstanceContext } from "./instance-context" @@ -22,6 +23,7 @@ export type InstanceServices = | FileTimeService | FormatService | FileService + | SkillService function lookup(directory: string) { const project = Instance.project @@ -35,6 +37,7 @@ function lookup(directory: string) { Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), Layer.fresh(FormatService.layer), Layer.fresh(FileService.layer), + Layer.fresh(SkillService.layer), ).pipe(Layer.provide(ctx)) } diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index 846002cdae..fe03dccefa 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -1,98 +1,118 @@ -import path from "path" -import { mkdir } from "fs/promises" -import { Log } from "../util/log" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Global } from "../global" -import { Filesystem } from "../util/filesystem" +import { Log } from "../util/log" +import { withTransientReadRetry } from "@/util/effect-http-client" -export namespace Discovery { - const log = Log.create({ service: "skill-discovery" }) +class IndexSkill extends Schema.Class("IndexSkill")({ + name: Schema.String, + files: Schema.Array(Schema.String), +}) {} - type Index = { - skills: Array<{ - name: string - description: string - files: string[] - }> - } +class Index extends Schema.Class("Index")({ + skills: Schema.Array(IndexSkill), +}) {} - export function dir() { - return path.join(Global.Path.cache, "skills") - } +const skillConcurrency = 4 +const fileConcurrency = 8 - async function get(url: string, dest: string): Promise { - if (await Filesystem.exists(dest)) return true - return fetch(url) - .then(async (response) => { - if (!response.ok) { - log.error("failed to download", { url, status: response.status }) - return false - } - if (response.body) await Filesystem.writeStream(dest, response.body) - return true - }) - .catch((err) => { - log.error("failed to download", { url, err }) - return false - }) - } - - export async function pull(url: string): Promise { - const result: string[] = [] - const base = url.endsWith("/") ? url : `${url}/` - const index = new URL("index.json", base).href - const cache = dir() - const host = base.slice(0, -1) - - log.info("fetching index", { url: index }) - const data = await fetch(index) - .then(async (response) => { - if (!response.ok) { - log.error("failed to fetch index", { url: index, status: response.status }) - return undefined - } - return response - .json() - .then((json) => json as Index) - .catch((err) => { - log.error("failed to parse index", { url: index, err }) - return undefined - }) - }) - .catch((err) => { - log.error("failed to fetch index", { url: index, err }) - return undefined - }) - - if (!data?.skills || !Array.isArray(data.skills)) { - log.warn("invalid index format", { url: index }) - return result - } - - const list = data.skills.filter((skill) => { - if (!skill?.name || !Array.isArray(skill.files)) { - log.warn("invalid skill entry", { url: index, skill }) - return false - } - return true - }) - - await Promise.all( - list.map(async (skill) => { - const root = path.join(cache, skill.name) - await Promise.all( - skill.files.map(async (file) => { - const link = new URL(file, `${host}/${skill.name}/`).href - const dest = path.join(root, file) - await mkdir(path.dirname(dest), { recursive: true }) - await get(link, dest) - }), - ) - - const md = path.join(root, "SKILL.md") - if (await Filesystem.exists(md)) result.push(root) - }), - ) - - return result +export namespace DiscoveryService { + export interface Service { + readonly pull: (url: string) => Effect.Effect } } + +export class DiscoveryService extends ServiceMap.Service()( + "@opencode/SkillDiscovery", +) { + static readonly layer = Layer.effect( + DiscoveryService, + Effect.gen(function* () { + const log = Log.create({ service: "skill-discovery" }) + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) + const cache = path.join(Global.Path.cache, "skills") + + const download = Effect.fn("DiscoveryService.download")(function* (url: string, dest: string) { + if (yield* fs.exists(dest).pipe(Effect.orDie)) return true + + return yield* HttpClientRequest.get(url).pipe( + http.execute, + Effect.flatMap((res) => res.arrayBuffer), + Effect.flatMap((body) => + fs + .makeDirectory(path.dirname(dest), { recursive: true }) + .pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))), + ), + Effect.as(true), + Effect.catch((err) => + Effect.sync(() => { + log.error("failed to download", { url, err }) + return false + }), + ), + ) + }) + + const pull: DiscoveryService.Service["pull"] = Effect.fn("DiscoveryService.pull")(function* (url: string) { + const base = url.endsWith("/") ? url : `${url}/` + const index = new URL("index.json", base).href + const host = base.slice(0, -1) + + log.info("fetching index", { url: index }) + + const data = yield* HttpClientRequest.get(index).pipe( + HttpClientRequest.acceptJson, + http.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)), + Effect.catch((err) => + Effect.sync(() => { + log.error("failed to fetch index", { url: index, err }) + return null + }), + ), + ) + + if (!data) return [] + + const list = data.skills.filter((skill) => { + if (!skill.files.includes("SKILL.md")) { + log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name }) + return false + } + return true + }) + + const dirs = yield* Effect.forEach( + list, + (skill) => + Effect.gen(function* () { + const root = path.join(cache, skill.name) + + yield* Effect.forEach( + skill.files, + (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)), + { concurrency: fileConcurrency }, + ) + + const md = path.join(root, "SKILL.md") + return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null + }), + { concurrency: skillConcurrency }, + ) + + return dirs.filter((dir): dir is string => dir !== null) + }) + + return DiscoveryService.of({ pull }) + }), + ) + + static readonly defaultLayer = DiscoveryService.layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(NodeFileSystem.layer), + Layer.provide(NodePath.layer), + ) +} diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index fa984b3e11..3a544d90a0 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -10,15 +10,25 @@ import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { Flag } from "@/flag/flag" import { Bus } from "@/bus" -import { Session } from "@/session" -import { Discovery } from "./discovery" +import { DiscoveryService } from "./discovery" import { Glob } from "../util/glob" import { pathToFileURL } from "url" import type { Agent } from "@/agent/agent" import { PermissionNext } from "@/permission/next" +import { InstanceContext } from "@/effect/instance-context" +import { Effect, Layer, ServiceMap } from "effect" +import { runPromiseInstance } from "@/effect/runtime" + +const log = Log.create({ service: "skill" }) + +// External skill directories to search for (project-level and global) +// These follow the directory layout used by Claude Code and other agents. +const EXTERNAL_DIRS = [".claude", ".agents"] +const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" +const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" +const SKILL_PATTERN = "**/SKILL.md" export namespace Skill { - const log = Log.create({ service: "skill" }) export const Info = z.object({ name: z.string(), description: z.string(), @@ -45,155 +55,20 @@ export namespace Skill { }), ) - // External skill directories to search for (project-level and global) - // These follow the directory layout used by Claude Code and other agents. - const EXTERNAL_DIRS = [".claude", ".agents"] - const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" - const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" - const SKILL_PATTERN = "**/SKILL.md" - - export const state = Instance.state(async () => { - const skills: Record = {} - const dirs = new Set() - - const addSkill = async (match: string) => { - const md = await ConfigMarkdown.parse(match).catch((err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse skill ${match}` - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load skill", { skill: match, err }) - return undefined - }) - - if (!md) return - - const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) - if (!parsed.success) return - - // Warn on duplicate skill names - if (skills[parsed.data.name]) { - log.warn("duplicate skill name", { - name: parsed.data.name, - existing: skills[parsed.data.name].location, - duplicate: match, - }) - } - - dirs.add(path.dirname(match)) - - skills[parsed.data.name] = { - name: parsed.data.name, - description: parsed.data.description, - location: match, - content: md.content, - } - } - - const scanExternal = async (root: string, scope: "global" | "project") => { - return Glob.scan(EXTERNAL_SKILL_PATTERN, { - cwd: root, - absolute: true, - include: "file", - dot: true, - symlink: true, - }) - .then((matches) => Promise.all(matches.map(addSkill))) - .catch((error) => { - log.error(`failed to scan ${scope} skills`, { dir: root, error }) - }) - } - - // Scan external skill directories (.claude/skills/, .agents/skills/, etc.) - // Load global (home) first, then project-level (so project-level overwrites) - if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { - for (const dir of EXTERNAL_DIRS) { - const root = path.join(Global.Path.home, dir) - if (!(await Filesystem.isDir(root))) continue - await scanExternal(root, "global") - } - - for await (const root of Filesystem.up({ - targets: EXTERNAL_DIRS, - start: Instance.directory, - stop: Instance.worktree, - })) { - await scanExternal(root, "project") - } - } - - // Scan .opencode/skill/ directories - for (const dir of await Config.directories()) { - const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, { - cwd: dir, - absolute: true, - include: "file", - symlink: true, - }) - for (const match of matches) { - await addSkill(match) - } - } - - // Scan additional skill paths from config - const config = await Config.get() - for (const skillPath of config.skills?.paths ?? []) { - const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath - const resolved = path.isAbsolute(expanded) ? expanded : path.join(Instance.directory, expanded) - if (!(await Filesystem.isDir(resolved))) { - log.warn("skill path not found", { path: resolved }) - continue - } - const matches = await Glob.scan(SKILL_PATTERN, { - cwd: resolved, - absolute: true, - include: "file", - symlink: true, - }) - for (const match of matches) { - await addSkill(match) - } - } - - // Download and load skills from URLs - for (const url of config.skills?.urls ?? []) { - const list = await Discovery.pull(url) - for (const dir of list) { - dirs.add(dir) - const matches = await Glob.scan(SKILL_PATTERN, { - cwd: dir, - absolute: true, - include: "file", - symlink: true, - }) - for (const match of matches) { - await addSkill(match) - } - } - } - - return { - skills, - dirs: Array.from(dirs), - } - }) - export async function get(name: string) { - return state().then((x) => x.skills[name]) + return runPromiseInstance(SkillService.use((s) => s.get(name))) } export async function all() { - return state().then((x) => Object.values(x.skills)) + return runPromiseInstance(SkillService.use((s) => s.all())) } export async function dirs() { - return state().then((x) => x.dirs) + return runPromiseInstance(SkillService.use((s) => s.dirs())) } export async function available(agent?: Agent.Info) { - const list = await all() - if (!agent) return list - return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny") + return runPromiseInstance(SkillService.use((s) => s.available(agent))) } export function fmt(list: Info[], opts: { verbose: boolean }) { @@ -216,3 +91,177 @@ export namespace Skill { return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") } } + +export namespace SkillService { + export interface Service { + readonly get: (name: string) => Effect.Effect + readonly all: () => Effect.Effect + readonly dirs: () => Effect.Effect + readonly available: (agent?: Agent.Info) => Effect.Effect + } +} + +export class SkillService extends ServiceMap.Service()("@opencode/Skill") { + static readonly layer = Layer.effect( + SkillService, + Effect.gen(function* () { + const instance = yield* InstanceContext + const discovery = yield* DiscoveryService + + const skills: Record = {} + const skillDirs = new Set() + let task: Promise | undefined + + const addSkill = async (match: string) => { + const md = await ConfigMarkdown.parse(match).catch(async (err) => { + const message = ConfigMarkdown.FrontmatterError.isInstance(err) + ? err.data.message + : `Failed to parse skill ${match}` + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + log.error("failed to load skill", { skill: match, err }) + return undefined + }) + + if (!md) return + + const parsed = Skill.Info.pick({ name: true, description: true }).safeParse(md.data) + if (!parsed.success) return + + // Warn on duplicate skill names + if (skills[parsed.data.name]) { + log.warn("duplicate skill name", { + name: parsed.data.name, + existing: skills[parsed.data.name].location, + duplicate: match, + }) + } + + skillDirs.add(path.dirname(match)) + + skills[parsed.data.name] = { + name: parsed.data.name, + description: parsed.data.description, + location: match, + content: md.content, + } + } + + const scanExternal = async (root: string, scope: "global" | "project") => { + return Glob.scan(EXTERNAL_SKILL_PATTERN, { + cwd: root, + absolute: true, + include: "file", + dot: true, + symlink: true, + }) + .then((matches) => Promise.all(matches.map(addSkill))) + .catch((error) => { + log.error(`failed to scan ${scope} skills`, { dir: root, error }) + }) + } + + function ensureScanned() { + if (task) return task + task = (async () => { + // Scan external skill directories (.claude/skills/, .agents/skills/, etc.) + // Load global (home) first, then project-level (so project-level overwrites) + if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { + for (const dir of EXTERNAL_DIRS) { + const root = path.join(Global.Path.home, dir) + if (!(await Filesystem.isDir(root))) continue + await scanExternal(root, "global") + } + + for await (const root of Filesystem.up({ + targets: EXTERNAL_DIRS, + start: instance.directory, + stop: instance.project.worktree, + })) { + await scanExternal(root, "project") + } + } + + // Scan .opencode/skill/ directories + for (const dir of await Config.directories()) { + const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, { + cwd: dir, + absolute: true, + include: "file", + symlink: true, + }) + for (const match of matches) { + await addSkill(match) + } + } + + // Scan additional skill paths from config + const config = await Config.get() + for (const skillPath of config.skills?.paths ?? []) { + const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath + const resolved = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded) + if (!(await Filesystem.isDir(resolved))) { + log.warn("skill path not found", { path: resolved }) + continue + } + const matches = await Glob.scan(SKILL_PATTERN, { + cwd: resolved, + absolute: true, + include: "file", + symlink: true, + }) + for (const match of matches) { + await addSkill(match) + } + } + + // Download and load skills from URLs + for (const url of config.skills?.urls ?? []) { + const list = await Effect.runPromise(discovery.pull(url)) + for (const dir of list) { + skillDirs.add(dir) + const matches = await Glob.scan(SKILL_PATTERN, { + cwd: dir, + absolute: true, + include: "file", + symlink: true, + }) + for (const match of matches) { + await addSkill(match) + } + } + } + + log.info("init", { count: Object.keys(skills).length }) + })().catch((err) => { + task = undefined + throw err + }) + return task + } + + return SkillService.of({ + get: Effect.fn("SkillService.get")(function* (name: string) { + yield* Effect.promise(() => ensureScanned()) + return skills[name] + }), + all: Effect.fn("SkillService.all")(function* () { + yield* Effect.promise(() => ensureScanned()) + return Object.values(skills) + }), + dirs: Effect.fn("SkillService.dirs")(function* () { + yield* Effect.promise(() => ensureScanned()) + return Array.from(skillDirs) + }), + available: Effect.fn("SkillService.available")(function* (agent?: Agent.Info) { + yield* Effect.promise(() => ensureScanned()) + const list = Object.values(skills) + if (!agent) return list + return list.filter( + (skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny", + ) + }), + }) + }), + ).pipe(Layer.provide(DiscoveryService.defaultLayer)) +} diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index 5664fa32b8..5cbb3ada09 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -1,5 +1,7 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test" -import { Discovery } from "../../src/skill/discovery" +import { Effect } from "effect" +import { DiscoveryService } from "../../src/skill/discovery" +import { Global } from "../../src/global" import { Filesystem } from "../../src/util/filesystem" import { rm } from "fs/promises" import path from "path" @@ -9,9 +11,10 @@ let server: ReturnType let downloadCount = 0 const fixturePath = path.join(import.meta.dir, "../fixture/skills") +const cacheDir = path.join(Global.Path.cache, "skills") beforeAll(async () => { - await rm(Discovery.dir(), { recursive: true, force: true }) + await rm(cacheDir, { recursive: true, force: true }) server = Bun.serve({ port: 0, @@ -40,22 +43,25 @@ beforeAll(async () => { afterAll(async () => { server?.stop() - await rm(Discovery.dir(), { recursive: true, force: true }) + await rm(cacheDir, { recursive: true, force: true }) }) describe("Discovery.pull", () => { + const pull = (url: string) => + Effect.runPromise(DiscoveryService.use((s) => s.pull(url)).pipe(Effect.provide(DiscoveryService.defaultLayer))) + test("downloads skills from cloudflare url", async () => { - const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL) + const dirs = await pull(CLOUDFLARE_SKILLS_URL) expect(dirs.length).toBeGreaterThan(0) for (const dir of dirs) { - expect(dir).toStartWith(Discovery.dir()) + expect(dir).toStartWith(cacheDir) const md = path.join(dir, "SKILL.md") expect(await Filesystem.exists(md)).toBe(true) } }) test("url without trailing slash works", async () => { - const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, "")) + const dirs = await pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, "")) expect(dirs.length).toBeGreaterThan(0) for (const dir of dirs) { const md = path.join(dir, "SKILL.md") @@ -64,18 +70,18 @@ describe("Discovery.pull", () => { }) test("returns empty array for invalid url", async () => { - const dirs = await Discovery.pull(`http://localhost:${server.port}/invalid-url/`) + const dirs = await pull(`http://localhost:${server.port}/invalid-url/`) expect(dirs).toEqual([]) }) test("returns empty array for non-json response", async () => { // any url not explicitly handled in server returns 404 text "Not Found" - const dirs = await Discovery.pull(`http://localhost:${server.port}/some-other-path/`) + const dirs = await pull(`http://localhost:${server.port}/some-other-path/`) expect(dirs).toEqual([]) }) test("downloads reference files alongside SKILL.md", async () => { - const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL) + const dirs = await pull(CLOUDFLARE_SKILLS_URL) // find a skill dir that should have reference files (e.g. agents-sdk) const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk")) expect(agentsSdk).toBeDefined() @@ -90,17 +96,17 @@ describe("Discovery.pull", () => { test("caches downloaded files on second pull", async () => { // clear dir and downloadCount - await rm(Discovery.dir(), { recursive: true, force: true }) + await rm(cacheDir, { recursive: true, force: true }) downloadCount = 0 // first pull to populate cache - const first = await Discovery.pull(CLOUDFLARE_SKILLS_URL) + const first = await pull(CLOUDFLARE_SKILLS_URL) expect(first.length).toBeGreaterThan(0) const firstCount = downloadCount expect(firstCount).toBeGreaterThan(0) // second pull should return same results from cache - const second = await Discovery.pull(CLOUDFLARE_SKILLS_URL) + const second = await pull(CLOUDFLARE_SKILLS_URL) expect(second.length).toBe(first.length) expect(second.sort()).toEqual(first.sort()) From 1cdc558ac06af73fad9d5023ffbed7d5d6811e8c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 16 Mar 2026 23:52:10 +0000 Subject: [PATCH 017/112] chore: generate --- bun.lock | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 35841622b7..2aa72ea94a 100644 --- a/bun.lock +++ b/bun.lock @@ -324,6 +324,7 @@ "@ai-sdk/xai": "2.0.51", "@aws-sdk/credential-providers": "3.993.0", "@clack/prompts": "1.0.0-alpha.1", + "@effect/platform-node": "4.0.0-beta.31", "@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/opencode-gitlab-auth": "1.3.3", "@hono/standard-validator": "0.1.5", @@ -972,6 +973,10 @@ "@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.31", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.31", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.31", "ioredis": "^5.7.0" } }, "sha512-KmVZwGsQRBMZZYPJwpL2vj6sxjBzfXhyA8RgsH5/cmckDTsZpVTyqODQ/FFzmCnMWuYjZoJGPghTDrVVDn/6ZA=="], + + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.33", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.33" } }, "sha512-jaJnvYz1IiPZyN//fCJsvwnmujJS5KD8noCVVLhb4ZGCWKhQpt0x2iuax6HFzMlPEQSfl04GLU+PVKh0nkzPyA=="], + "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], "@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], @@ -1168,6 +1173,8 @@ "@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="], + "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], + "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="], @@ -2536,6 +2543,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], @@ -3176,6 +3185,8 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -3408,10 +3419,14 @@ "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], + "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], @@ -3598,7 +3613,7 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], @@ -4022,6 +4037,10 @@ "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], + + "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], @@ -4280,6 +4299,8 @@ "stage-js": ["stage-js@1.0.1", "", {}, "sha512-cz14aPp/wY0s3bkb/B93BPP5ZAEhgBbRmAT3CCDqert8eCAqIpQ0RB2zpK8Ksxf+Pisl5oTzvPHtL4CVzzeHcw=="], + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -4986,12 +5007,16 @@ "@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], + "@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -5032,6 +5057,8 @@ "@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], From 60af44790856b6217ccd4ec2f0481f9650e36c7d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 16 Mar 2026 23:54:30 +0000 Subject: [PATCH 018/112] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 06f54dc950..1fe3266c35 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=", - "aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=", - "aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=", - "x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI=" + "x86_64-linux": "sha256-TnrYykX8Mf/Ugtkix6V", + "aarch64-linux": "sha256-TnrYykX8Mf/Ugtkix6V", + "aarch64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V", + "x86_64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V" } } From d7093abf61853a94a53f979a879dea4822b83b0f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 17 Mar 2026 00:05:19 +0000 Subject: [PATCH 019/112] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 1fe3266c35..d71f544ff9 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-TnrYykX8Mf/Ugtkix6V", - "aarch64-linux": "sha256-TnrYykX8Mf/Ugtkix6V", - "aarch64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V", - "x86_64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V" + "x86_64-linux": "sha256-VF3rXpIz9XbTTfM8YB98DJJOs4Sotaq5cSwIBUfbNDA=", + "aarch64-linux": "sha256-cIE10+0xhb5u0TQedaDbEu6e40ypHnSBmh8unnhCDZE=", + "aarch64-darwin": "sha256-d/l7g/4angRw/oxoSGpcYL0i9pNphgRChJwhva5Kypo=", + "x86_64-darwin": "sha256-WQyuUKMfHpO1rpWsjhCXuG99iX2jEdSe3AVltxvt+1Y=" } } From a64f604d54264f640807473f153ed95d704dcedf Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 16 Mar 2026 20:25:03 -0400 Subject: [PATCH 020/112] fix(tui): check for selected text instead of any selection in dialog escape handler (#16779) --- packages/opencode/src/cli/cmd/tui/ui/dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 8cebd9cba5..43f1a1ff58 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -70,7 +70,7 @@ function init() { useKeyboard((evt) => { if (store.stack.length === 0) return if (evt.defaultPrevented) return - if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return + if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) { const current = store.stack.at(-1)! current.onClose?.() From cb69501098c603ccd7d3e3dbe6655d401c1d815c Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:49:04 +1000 Subject: [PATCH 021/112] test(opencode): deflake file and tool timing (#17859) --- packages/opencode/src/file/index.ts | 2 +- packages/opencode/test/file/index.test.ts | 22 +++----- packages/opencode/test/file/time.test.ts | 62 +++++++++++++++-------- packages/opencode/test/preload.ts | 1 + packages/opencode/test/tool/edit.test.ts | 36 +++++++------ packages/opencode/test/tool/write.test.ts | 6 +-- 6 files changed, 74 insertions(+), 55 deletions(-) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 44c04e9e43..cee03e0915 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -448,7 +448,7 @@ export class FileService extends ServiceMap.Service kick()) }) const status = Effect.fn("FileService.status")(function* () { diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 89de5b571b..8f4cbe8688 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -681,9 +681,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - File.init() - // Give the background scan time to populate - await new Promise((r) => setTimeout(r, 500)) + await File.init() const result = await File.search({ query: "", type: "file" }) expect(result.length).toBeGreaterThan(0) @@ -697,8 +695,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - File.init() - await new Promise((r) => setTimeout(r, 500)) + await File.init() const result = await File.search({ query: "", type: "directory" }) expect(result.length).toBeGreaterThan(0) @@ -718,8 +715,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - File.init() - await new Promise((r) => setTimeout(r, 500)) + await File.init() const result = await File.search({ query: "main", type: "file" }) expect(result.some((f) => f.includes("main"))).toBe(true) @@ -733,8 +729,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - File.init() - await new Promise((r) => setTimeout(r, 500)) + await File.init() const result = await File.search({ query: "", type: "file" }) // Files don't end with / @@ -751,8 +746,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - File.init() - await new Promise((r) => setTimeout(r, 500)) + await File.init() const result = await File.search({ query: "", type: "directory" }) // Directories end with / @@ -769,8 +763,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - File.init() - await new Promise((r) => setTimeout(r, 500)) + await File.init() const result = await File.search({ query: "", type: "file", limit: 2 }) expect(result.length).toBeLessThanOrEqual(2) @@ -784,8 +777,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - File.init() - await new Promise((r) => setTimeout(r, 500)) + await File.init() const result = await File.search({ query: ".hidden", type: "directory" }) expect(result.length).toBeGreaterThan(0) diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts index 2a3c56b2c5..fbf8d5cd1e 100644 --- a/packages/opencode/test/file/time.test.ts +++ b/packages/opencode/test/file/time.test.ts @@ -9,6 +9,19 @@ import { tmpdir } from "../fixture/fixture" afterEach(() => Instance.disposeAll()) +async function touch(file: string, time: number) { + const date = new Date(time) + await fs.utimes(file, date, date) +} + +function gate() { + let open!: () => void + const wait = new Promise((resolve) => { + open = resolve + }) + return { open, wait } +} + describe("file/time", () => { const sessionID = SessionID.make("ses_00000000000000000000000001") @@ -25,7 +38,6 @@ describe("file/time", () => { expect(before).toBeUndefined() await FileTime.read(sessionID, filepath) - await Bun.sleep(10) const after = await FileTime.get(sessionID, filepath) expect(after).toBeInstanceOf(Date) @@ -44,7 +56,6 @@ describe("file/time", () => { fn: async () => { await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath) await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath) - await Bun.sleep(10) const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath) const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath) @@ -63,14 +74,10 @@ describe("file/time", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(sessionID, filepath) - await Bun.sleep(10) + await FileTime.read(sessionID, filepath) const first = await FileTime.get(sessionID, filepath) - await Bun.sleep(10) - - FileTime.read(sessionID, filepath) - await Bun.sleep(10) + await FileTime.read(sessionID, filepath) const second = await FileTime.get(sessionID, filepath) expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime()) @@ -84,12 +91,12 @@ describe("file/time", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "content", "utf-8") + await touch(filepath, 1_000) await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(sessionID, filepath) - await Bun.sleep(10) + await FileTime.read(sessionID, filepath) await FileTime.assert(sessionID, filepath) }, }) @@ -112,13 +119,14 @@ describe("file/time", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "content", "utf-8") + await touch(filepath, 1_000) await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(sessionID, filepath) - await Bun.sleep(100) + await FileTime.read(sessionID, filepath) await fs.writeFile(filepath, "modified content", "utf-8") + await touch(filepath, 2_000) await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read") }, }) @@ -128,13 +136,14 @@ describe("file/time", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "content", "utf-8") + await touch(filepath, 1_000) await Instance.provide({ directory: tmp.path, fn: async () => { await FileTime.read(sessionID, filepath) - await Bun.sleep(100) await fs.writeFile(filepath, "modified", "utf-8") + await touch(filepath, 2_000) let error: Error | undefined try { @@ -191,18 +200,25 @@ describe("file/time", () => { directory: tmp.path, fn: async () => { const order: number[] = [] + const hold = gate() + const ready = gate() const op1 = FileTime.withLock(filepath, async () => { order.push(1) - await Bun.sleep(50) + ready.open() + await hold.wait order.push(2) }) + await ready.wait + const op2 = FileTime.withLock(filepath, async () => { order.push(3) order.push(4) }) + hold.open() + await Promise.all([op1, op2]) expect(order).toEqual([1, 2, 3, 4]) }, @@ -219,15 +235,21 @@ describe("file/time", () => { fn: async () => { let started1 = false let started2 = false + const hold = gate() + const ready = gate() const op1 = FileTime.withLock(filepath1, async () => { started1 = true - await Bun.sleep(50) + ready.open() + await hold.wait expect(started2).toBe(true) }) + await ready.wait + const op2 = FileTime.withLock(filepath2, async () => { started2 = true + hold.open() }) await Promise.all([op1, op2]) @@ -265,12 +287,12 @@ describe("file/time", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "content", "utf-8") + await touch(filepath, 1_000) await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(sessionID, filepath) - await Bun.sleep(10) + await FileTime.read(sessionID, filepath) const stats = Filesystem.stat(filepath) expect(stats?.mtime).toBeInstanceOf(Date) @@ -285,17 +307,17 @@ describe("file/time", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "original", "utf-8") + await touch(filepath, 1_000) await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(sessionID, filepath) - await Bun.sleep(10) + await FileTime.read(sessionID, filepath) const originalStat = Filesystem.stat(filepath) - await Bun.sleep(100) await fs.writeFile(filepath, "modified", "utf-8") + await touch(filepath, 2_000) const newStat = Filesystem.stat(filepath) expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime()) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 1ebd273d26..e253183d8d 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -44,6 +44,7 @@ process.env["OPENCODE_TEST_HOME"] = testHome // Set test managed config directory to isolate tests from system managed settings const testManagedConfigDir = path.join(dir, "managed") process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir +process.env["OPENCODE_DISABLE_DEFAULT_PLUGINS"] = "true" // Write the cache version file to prevent global/index.ts from clearing the cache const cacheDir = path.join(dir, "cache", "opencode") diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index b0ee95ff6f..7b6784cf49 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -18,6 +18,11 @@ const ctx = { ask: async () => {}, } +async function touch(file: string, time: number) { + const date = new Date(time) + await fs.utimes(file, date, date) +} + describe("tool.edit", () => { describe("creating new files", () => { test("creates new file when oldString is empty", async () => { @@ -111,7 +116,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, filepath) const edit = await EditTool.init() const result = await edit.execute( @@ -138,7 +143,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, filepath) const edit = await EditTool.init() await expect( @@ -186,7 +191,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, filepath) const edit = await EditTool.init() await expect( @@ -230,18 +235,17 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "original content", "utf-8") + await touch(filepath, 1_000) await Instance.provide({ directory: tmp.path, fn: async () => { // Read first - FileTime.read(ctx.sessionID, filepath) - - // Wait a bit to ensure different timestamps - await new Promise((resolve) => setTimeout(resolve, 100)) + await FileTime.read(ctx.sessionID, filepath) // Simulate external modification await fs.writeFile(filepath, "modified externally", "utf-8") + await touch(filepath, 2_000) // Try to edit with the new content const edit = await EditTool.init() @@ -267,7 +271,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, filepath) const edit = await EditTool.init() await edit.execute( @@ -294,7 +298,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, filepath) const { Bus } = await import("../../src/bus") const { File } = await import("../../src/file") @@ -332,7 +336,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, filepath) const edit = await EditTool.init() await edit.execute( @@ -358,7 +362,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, filepath) const edit = await EditTool.init() await edit.execute( @@ -407,7 +411,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(ctx.sessionID, dirpath) + await FileTime.read(ctx.sessionID, dirpath) const edit = await EditTool.init() await expect( @@ -432,7 +436,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, filepath) const edit = await EditTool.init() const result = await edit.execute( @@ -503,7 +507,7 @@ describe("tool.edit", () => { fn: async () => { const edit = await EditTool.init() const filePath = path.join(tmp.path, "test.txt") - FileTime.read(ctx.sessionID, filePath) + await FileTime.read(ctx.sessionID, filePath) await edit.execute( { filePath, @@ -644,7 +648,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, filepath) const edit = await EditTool.init() @@ -659,7 +663,7 @@ describe("tool.edit", () => { ) // Need to read again since FileTime tracks per-session - FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, filepath) const promise2 = edit.execute( { diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index b93ab4e853..af002a3910 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -99,7 +99,7 @@ describe("tool.write", () => { directory: tmp.path, fn: async () => { const { FileTime } = await import("../../src/file/time") - FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, filepath) const write = await WriteTool.init() const result = await write.execute( @@ -128,7 +128,7 @@ describe("tool.write", () => { directory: tmp.path, fn: async () => { const { FileTime } = await import("../../src/file/time") - FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, filepath) const write = await WriteTool.init() const result = await write.execute( @@ -306,7 +306,7 @@ describe("tool.write", () => { directory: tmp.path, fn: async () => { const { FileTime } = await import("../../src/file/time") - FileTime.read(ctx.sessionID, readonlyPath) + await FileTime.read(ctx.sessionID, readonlyPath) const write = await WriteTool.init() await expect( From e416e59ea69f7600acbdb593ba68ac0fb1ee2633 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:55:58 +1000 Subject: [PATCH 022/112] test(app): deflake slash terminal toggle flow (#17881) --- packages/app/e2e/AGENTS.md | 10 +++ packages/app/e2e/actions.ts | 70 +++++++++++++++++++ packages/app/e2e/fixtures.ts | 3 + .../e2e/prompt/prompt-slash-terminal.spec.ts | 25 ++----- .../e2e/settings/settings-keybinds.spec.ts | 4 +- .../app/e2e/terminal/terminal-init.spec.ts | 4 +- packages/app/src/components/prompt-input.tsx | 16 +++++ .../app/src/pages/session/terminal-panel.tsx | 9 ++- packages/app/src/testing/prompt.ts | 56 +++++++++++++++ packages/app/src/testing/terminal.ts | 15 ++++ 10 files changed, 186 insertions(+), 26 deletions(-) create mode 100644 packages/app/src/testing/prompt.ts diff --git a/packages/app/e2e/AGENTS.md b/packages/app/e2e/AGENTS.md index 4b62634f0b..f263e49a02 100644 --- a/packages/app/e2e/AGENTS.md +++ b/packages/app/e2e/AGENTS.md @@ -174,6 +174,8 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings - In terminal tests, type through the browser. Do not write to the PTY through the SDK. - Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`. - These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles. +- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters. +- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts. - Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks. ### Wait on state @@ -182,6 +184,9 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings - Avoid race-prone flows that assume work is finished after an action - Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers - Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state +- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops +- Do not treat a visible element as proof that the app will route the next action to it +- When fixing a flake, validate with `--repeat-each` and multiple workers when practical ### Add hooks @@ -189,11 +194,16 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings - Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts` - Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony - When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI +- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable +- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states ### Prefer helpers - Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise - Use direct locators when the interaction is simple and a helper would not add clarity +- Prefer helpers that both perform an action and verify the app consumed it +- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state +- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions ## Writing New Tests diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 8e21579e21..aa047fb287 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -16,6 +16,7 @@ import { listItemSelector, listItemKeySelector, listItemKeyStartsWithSelector, + promptSelector, terminalSelector, workspaceItemSelector, workspaceMenuTriggerSelector, @@ -61,6 +62,15 @@ async function terminalReady(page: Page, term?: Locator) { }, id) } +async function terminalFocusIdle(page: Page, term?: Locator) { + const next = term ?? page.locator(terminalSelector).first() + const id = await terminalID(next) + return page.evaluate((id) => { + const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id] + return (state?.focusing ?? 0) === 0 + }, id) +} + async function terminalHas(page: Page, input: { term?: Locator; token: string }) { const next = input.term ?? page.locator(terminalSelector).first() const id = await terminalID(next) @@ -73,6 +83,29 @@ async function terminalHas(page: Page, input: { term?: Locator; token: string }) ) } +async function promptSlashActive(page: Page, id: string) { + return page.evaluate((id) => { + const state = (window as E2EWindow).__opencode_e2e?.prompt?.current + if (state?.popover !== "slash") return false + if (!state.slash.ids.includes(id)) return false + return state.slash.active === id + }, id) +} + +async function promptSlashSelects(page: Page) { + return page.evaluate(() => { + return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0 + }) +} + +async function promptSlashSelected(page: Page, input: { id: string; count: number }) { + return page.evaluate((input) => { + const state = (window as E2EWindow).__opencode_e2e?.prompt?.current + if (!state) return false + return state.selected === input.id && state.selects >= input.count + }, input) +} + export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) { const term = input?.term ?? page.locator(terminalSelector).first() const timeout = input?.timeout ?? 10_000 @@ -81,6 +114,43 @@ export async function waitTerminalReady(page: Page, input?: { term?: Locator; ti await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true) } +export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) { + const term = input?.term ?? page.locator(terminalSelector).first() + const timeout = input?.timeout ?? 10_000 + await waitTerminalReady(page, { term, timeout }) + await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true) +} + +export async function showPromptSlash( + page: Page, + input: { id: string; text: string; prompt?: Locator; timeout?: number }, +) { + const prompt = input.prompt ?? page.locator(promptSelector) + const timeout = input.timeout ?? 10_000 + await expect + .poll( + async () => { + await prompt.click().catch(() => false) + await prompt.fill(input.text).catch(() => false) + return promptSlashActive(page, input.id).catch(() => false) + }, + { timeout }, + ) + .toBe(true) +} + +export async function runPromptSlash( + page: Page, + input: { id: string; text: string; prompt?: Locator; timeout?: number }, +) { + const prompt = input.prompt ?? page.locator(promptSelector) + const timeout = input.timeout ?? 10_000 + const count = await promptSlashSelects(page) + await showPromptSlash(page, input) + await prompt.press("Enter") + await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true) +} + export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) { const term = input.term ?? page.locator(terminalSelector).first() const timeout = input.timeout ?? 10_000 diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index efefd479ef..7bc994e507 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -98,6 +98,9 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin model: { enabled: true, }, + prompt: { + enabled: true, + }, terminal: { enabled: true, terminals: {}, diff --git a/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts b/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts index 100d1878ab..466b3ba1bb 100644 --- a/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts +++ b/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { waitTerminalReady } from "../actions" +import { runPromptSlash, waitTerminalFocusIdle } from "../actions" import { promptSelector, terminalSelector } from "../selectors" test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => { @@ -7,29 +7,12 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => { const prompt = page.locator(promptSelector) const terminal = page.locator(terminalSelector) - const slash = page.locator('[data-slash-id="terminal.toggle"]').first() await expect(terminal).not.toBeVisible() - await prompt.fill("/terminal") - await expect(slash).toBeVisible() - await page.keyboard.press("Enter") - await waitTerminalReady(page, { term: terminal }) + await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" }) + await waitTerminalFocusIdle(page, { term: terminal }) - // Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening, - // which can steal focus from the prompt and prevent fill() from triggering - // the slash popover. Re-attempt click+fill until all retries are exhausted - // and the popover appears. - await expect - .poll( - async () => { - await prompt.click().catch(() => false) - await prompt.fill("/terminal").catch(() => false) - return slash.isVisible().catch(() => false) - }, - { timeout: 10_000 }, - ) - .toBe(true) - await page.keyboard.press("Enter") + await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" }) await expect(terminal).not.toBeVisible() }) diff --git a/packages/app/e2e/settings/settings-keybinds.spec.ts b/packages/app/e2e/settings/settings-keybinds.spec.ts index 9fc2a50ad3..5789dc0eb0 100644 --- a/packages/app/e2e/settings/settings-keybinds.spec.ts +++ b/packages/app/e2e/settings/settings-keybinds.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions" +import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions" import { keybindButtonSelector, terminalSelector } from "../selectors" import { modKey } from "../utils" @@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) => await expect(terminal).not.toBeVisible() await page.keyboard.press(`${modKey}+Y`) - await waitTerminalReady(page, { term: terminal }) + await waitTerminalFocusIdle(page, { term: terminal }) await page.keyboard.press(`${modKey}+Y`) await expect(terminal).not.toBeVisible() diff --git a/packages/app/e2e/terminal/terminal-init.spec.ts b/packages/app/e2e/terminal/terminal-init.spec.ts index d9bbfa2bed..689d0436a5 100644 --- a/packages/app/e2e/terminal/terminal-init.spec.ts +++ b/packages/app/e2e/terminal/terminal-init.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { waitTerminalReady } from "../actions" +import { waitTerminalFocusIdle, waitTerminalReady } from "../actions" import { promptSelector, terminalSelector } from "../selectors" import { terminalToggleKey } from "../utils" @@ -14,7 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes await page.keyboard.press(terminalToggleKey) } - await waitTerminalReady(page, { term: terminals.first() }) + await waitTerminalFocusIdle(page, { term: terminals.first() }) await expect(terminals).toHaveCount(1) // Ghostty captures a lot of keybinds when focused; move focus back diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index b2553e4c02..4fbc82a706 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -36,6 +36,7 @@ import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" +import { promptEnabled, promptProbe } from "@/testing/prompt" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments } from "./prompt-input/attachments" import { ACCEPTED_FILE_TYPES } from "./prompt-input/files" @@ -604,6 +605,7 @@ export const PromptInput: Component = (props) => { const handleSlashSelect = (cmd: SlashCommand | undefined) => { if (!cmd) return + promptProbe.select(cmd.id) closePopover() if (cmd.type === "custom") { @@ -692,6 +694,20 @@ export const PromptInput: Component = (props) => { }) }) + if (promptEnabled()) { + createEffect(() => { + promptProbe.set({ + popover: store.popover, + slash: { + active: slashActive() ?? null, + ids: slashFlat().map((cmd) => cmd.id), + }, + }) + }) + + onCleanup(() => promptProbe.clear()) + } + const selectPopoverActive = () => { if (store.popover === "at") { const items = atFlat() diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index e78ebecfc4..d62d91c197 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -18,8 +18,10 @@ import { terminalTabLabel } from "@/pages/session/terminal-label" import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" +import { terminalProbe } from "@/testing/terminal" export function TerminalPanel() { + const delays = [120, 240] const layout = useLayout() const terminal = useTerminal() const language = useLanguage() @@ -79,16 +81,20 @@ export function TerminalPanel() { ) const focus = (id: string) => { + const probe = terminalProbe(id) + probe.focus(delays.length + 1) focusTerminalById(id) const frame = requestAnimationFrame(() => { + probe.step() if (!opened()) return if (terminal.active() !== id) return focusTerminalById(id) }) - const timers = [120, 240].map((ms) => + const timers = delays.map((ms) => window.setTimeout(() => { + probe.step() if (!opened()) return if (terminal.active() !== id) return focusTerminalById(id) @@ -96,6 +102,7 @@ export function TerminalPanel() { ) return () => { + probe.focus(0) cancelAnimationFrame(frame) for (const timer of timers) clearTimeout(timer) } diff --git a/packages/app/src/testing/prompt.ts b/packages/app/src/testing/prompt.ts new file mode 100644 index 0000000000..e11462f301 --- /dev/null +++ b/packages/app/src/testing/prompt.ts @@ -0,0 +1,56 @@ +import type { E2EWindow } from "./terminal" + +export type PromptProbeState = { + popover: "at" | "slash" | null + slash: { + active: string | null + ids: string[] + } + selected: string | null + selects: number +} + +export const promptEnabled = () => { + if (typeof window === "undefined") return false + return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true +} + +const root = () => { + if (!promptEnabled()) return + return (window as E2EWindow).__opencode_e2e?.prompt +} + +export const promptProbe = { + set(input: Omit) { + const state = root() + if (!state) return + state.current = { + popover: input.popover, + slash: { + active: input.slash.active, + ids: [...input.slash.ids], + }, + selected: state.current?.selected ?? null, + selects: state.current?.selects ?? 0, + } + }, + select(id: string) { + const state = root() + if (!state) return + const prev = state.current + state.current = { + popover: prev?.popover ?? null, + slash: { + active: prev?.slash.active ?? null, + ids: [...(prev?.slash.ids ?? [])], + }, + selected: id, + selects: (prev?.selects ?? 0) + 1, + } + }, + clear() { + const state = root() + if (!state) return + state.current = undefined + }, +} diff --git a/packages/app/src/testing/terminal.ts b/packages/app/src/testing/terminal.ts index af1c333092..2bca39b31c 100644 --- a/packages/app/src/testing/terminal.ts +++ b/packages/app/src/testing/terminal.ts @@ -7,6 +7,7 @@ export type TerminalProbeState = { connects: number rendered: string settled: number + focusing: number } type TerminalProbeControl = { @@ -19,6 +20,10 @@ export type E2EWindow = Window & { enabled?: boolean current?: ModelProbeState } + prompt?: { + enabled?: boolean + current?: import("./prompt").PromptProbeState + } terminal?: { enabled?: boolean terminals?: Record @@ -32,6 +37,7 @@ const seed = (): TerminalProbeState => ({ connects: 0, rendered: "", settled: 0, + focusing: 0, }) const root = () => { @@ -88,6 +94,15 @@ export const terminalProbe = (id: string) => { const prev = state[id] ?? seed() state[id] = { ...prev, settled: prev.settled + 1 } }, + focus(count: number) { + set({ focusing: Math.max(0, count) }) + }, + step() { + const state = terms() + if (!state) return + const prev = state[id] ?? seed() + state[id] = { ...prev, focusing: Math.max(0, prev.focusing - 1) } + }, control(next: Partial) { const state = controls() if (!state) return From f13da808ffb98bc0e582e227f3cf8f74d090b7d8 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:38:15 -0500 Subject: [PATCH 023/112] chore: denounce ai spammer (#17901) --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 28535b5779..ddfa7fd161 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -21,3 +21,4 @@ r44vc0rp rekram1-node -spider-yamet clawdbot/llm psychosis, spam pinging the team thdxr +-OpenCode2026 From 544315dff74beef8f854bb1e658e6f429ad7cfdc Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 16 Mar 2026 23:46:09 -0400 Subject: [PATCH 024/112] docs: add describe annotation to snapshot config field (#17861) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/config/config.ts | 7 ++++++- packages/web/src/content/docs/config.mdx | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 27ba4e1867..47afdfd7d0 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1052,7 +1052,12 @@ export namespace Config { }) .optional(), plugin: z.string().array().optional(), - snapshot: z.boolean().optional(), + snapshot: z + .boolean() + .optional() + .describe( + "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", + ), share: z .enum(["manual", "auto", "disabled"]) .optional() diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index d2770ee209..a9c39bd59f 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -424,6 +424,23 @@ Customize keybinds in `tui.json`. --- +### Snapshot + +OpenCode uses snapshots to track file changes during agent operations, enabling you to undo and revert changes within a session. Snapshots are enabled by default. + +For large repositories or projects with many submodules, the snapshot system can cause slow indexing and significant disk usage as it tracks all changes using an internal git repository. You can disable snapshots using the `snapshot` option. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "snapshot": false +} +``` + +Note that disabling snapshots means changes made by the agent cannot be rolled back through the UI. + +--- + ### Autoupdate OpenCode will automatically download any new updates when it starts up. You can disable this with the `autoupdate` option. From e14e874e513178ac056cec7be5bac4ff5fd842ef Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 17 Mar 2026 03:47:33 +0000 Subject: [PATCH 025/112] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 3 +++ packages/sdk/openapi.json | 1 + 2 files changed, 4 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4ee369421e..fd80a51a21 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1343,6 +1343,9 @@ export type Config = { ignore?: Array } plugin?: Array + /** + * Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true. + */ snapshot?: boolean /** * Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6d691eaee7..2f7e9952ed 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10405,6 +10405,7 @@ } }, "snapshot": { + "description": "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", "type": "boolean" }, "share": { From dbbe931a18378215765706ef77750d1ca961d7d4 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:14:30 +1000 Subject: [PATCH 026/112] fix(app): avoid prompt tooltip Switch on startup (#17857) --- packages/app/src/components/prompt-input.tsx | 36 +++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 4fbc82a706..42b5df9b30 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1,6 +1,6 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" import { useSpring } from "@opencode-ai/ui/motion-spring" -import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" +import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" @@ -244,6 +244,23 @@ export const PromptInput: Component = (props) => { }, ) const working = createMemo(() => status()?.type !== "idle") + const tip = () => { + if (working()) { + return ( +
+ {language.t("prompt.action.stop")} + {language.t("common.key.esc")} +
+ ) + } + + return ( +
+ {language.t("prompt.action.send")} + +
+ ) + } const imageAttachments = createMemo(() => prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) @@ -1365,22 +1382,7 @@ export const PromptInput: Component = (props) => { - -
- {language.t("prompt.action.stop")} - {language.t("common.key.esc")} -
-
- -
- {language.t("prompt.action.send")} - -
-
- - } + value={tip()} > Date: Tue, 17 Mar 2026 11:15:35 +0000 Subject: [PATCH 027/112] chore: generate --- packages/app/src/components/prompt-input.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 42b5df9b30..5c25235c65 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1379,11 +1379,7 @@ export const PromptInput: Component = (props) => { />
- + Date: Tue, 17 Mar 2026 19:47:06 +0800 Subject: [PATCH 028/112] app: inherit owner when creating prompt session --- packages/app/src/context/prompt.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 4aeb05e618..831fdbca83 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,10 +1,10 @@ -import { createStore, type SetStoreFunction } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createMemo, createRoot, onCleanup } from "solid-js" +import { checksum } from "@opencode-ai/util/encode" import { useParams } from "@solidjs/router" +import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" +import { createStore, type SetStoreFunction } from "solid-js/store" import type { FileSelection } from "@/context/file" import { Persist, persisted } from "@/utils/persist" -import { checksum } from "@opencode-ai/util/encode" interface PartBase { content: string @@ -250,6 +250,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( } } + const owner = getOwner() const load = (dir: string, id: string | undefined) => { const key = `${dir}:${id ?? WORKSPACE_KEY}` const existing = cache.get(key) @@ -259,10 +260,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( return existing.value } - const entry = createRoot((dispose) => ({ - value: createPromptSession(dir, id), - dispose, - })) + const entry = createRoot( + (dispose) => ({ + value: createPromptSession(dir, id), + dispose, + }), + owner, + ) cache.set(key, entry) prune() From ba2297656877f26c50d28977b0e7164868d6868c Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 17 Mar 2026 19:54:20 +0530 Subject: [PATCH 029/112] fix: inline review comment submit and layout (#17948) --- .../app/e2e/session/session-review.spec.ts | 95 +++++++++++++++++++ .../ui/src/components/line-comment-styles.ts | 27 +++++- packages/ui/src/components/line-comment.tsx | 16 +++- 3 files changed, 131 insertions(+), 7 deletions(-) diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index 28c85edb0b..c0421f0283 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -123,6 +123,101 @@ async function spot(page: Parameters[0]["page"], file: string) { }, file) } +async function comment(page: Parameters[0]["page"], file: string, note: string) { + const row = page.locator(`[data-file="${file}"]`).first() + await expect(row).toBeVisible() + + const line = row.locator('diffs-container [data-line="2"]').first() + await expect(line).toBeVisible() + await line.hover() + + const add = row.getByRole("button", { name: /^Comment$/ }).first() + await expect(add).toBeVisible() + await add.click() + + const area = row.locator('[data-slot="line-comment-textarea"]').first() + await expect(area).toBeVisible() + await area.fill(note) + + const submit = row.locator('[data-slot="line-comment-action"][data-variant="primary"]').first() + await expect(submit).toBeEnabled() + await submit.click() + + await expect(row.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible() + await expect(row.locator('[data-slot="line-comment-tools"]').first()).toBeVisible() +} + +async function overflow(page: Parameters[0]["page"], file: string) { + const row = page.locator(`[data-file="${file}"]`).first() + const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first() + const pop = row.locator('[data-slot="line-comment-popover"][data-inline-body]').first() + const tools = row.locator('[data-slot="line-comment-tools"]').first() + + const [width, viewBox, popBox, toolsBox] = await Promise.all([ + view.evaluate((el) => el.scrollWidth - el.clientWidth), + view.boundingBox(), + pop.boundingBox(), + tools.boundingBox(), + ]) + + if (!viewBox || !popBox || !toolsBox) return null + + return { + width, + pop: popBox.x + popBox.width - (viewBox.x + viewBox.width), + tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width), + } +} + +test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => { + test.setTimeout(180_000) + + const tag = `review-comment-${Date.now()}` + const file = `review-comment-${tag}.txt` + const note = `comment ${tag}` + + await page.setViewportSize({ width: 1280, height: 900 }) + + await withProject(async (project) => { + const sdk = createSdk(project.directory) + + await withSession(sdk, `e2e review comment ${tag}`, async (session) => { + await patch(sdk, session.id, seed([{ file, mark: tag }])) + + await expect + .poll( + async () => { + const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) + return diff.length + }, + { timeout: 60_000 }, + ) + .toBe(1) + + await project.gotoSession(session.id) + await show(page) + + const tab = page.getByRole("tab", { name: /Review/i }).first() + await expect(tab).toBeVisible() + await tab.click() + + await expand(page) + await waitMark(page, file, tag) + await comment(page, file, note) + + await expect + .poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) + await expect + .poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) + await expect + .poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) + }) + }) +}) + test("review keeps scroll position after a live diff update", async ({ page, withProject }) => { test.skip(Boolean(process.env.CI), "Flaky in CI for now.") test.setTimeout(180_000) diff --git a/packages/ui/src/components/line-comment-styles.ts b/packages/ui/src/components/line-comment-styles.ts index d5be675541..8fd02f0881 100644 --- a/packages/ui/src/components/line-comment-styles.ts +++ b/packages/ui/src/components/line-comment-styles.ts @@ -15,6 +15,7 @@ export const lineCommentStyles = ` right: auto; display: flex; width: 100%; + min-width: 0; align-items: flex-start; } @@ -64,6 +65,7 @@ export const lineCommentStyles = ` z-index: var(--line-comment-popover-z, 40); min-width: 200px; max-width: none; + box-sizing: border-box; border-radius: 8px; background: var(--surface-raised-stronger-non-alpha); box-shadow: var(--shadow-xxs-border); @@ -75,9 +77,10 @@ export const lineCommentStyles = ` top: auto; right: auto; margin-left: 8px; - flex: 0 1 600px; - width: min(100%, 600px); - max-width: min(100%, 600px); + flex: 1 1 0%; + width: auto; + max-width: 100%; + min-width: 0; } [data-component="line-comment"][data-inline] [data-slot="line-comment-popover"][data-inline-body] { @@ -96,23 +99,27 @@ export const lineCommentStyles = ` } [data-component="line-comment"][data-inline][data-variant="editor"] [data-slot="line-comment-popover"] { - flex-basis: 600px; + width: 100%; } [data-component="line-comment"] [data-slot="line-comment-content"] { display: flex; flex-direction: column; gap: 6px; + width: 100%; + min-width: 0; } [data-component="line-comment"] [data-slot="line-comment-head"] { display: flex; align-items: flex-start; gap: 8px; + min-width: 0; } [data-component="line-comment"] [data-slot="line-comment-text"] { flex: 1; + min-width: 0; font-family: var(--font-family-sans); font-size: var(--font-size-base); font-weight: var(--font-weight-regular); @@ -120,6 +127,7 @@ export const lineCommentStyles = ` letter-spacing: var(--letter-spacing-normal); color: var(--text-strong); white-space: pre-wrap; + overflow-wrap: anywhere; } [data-component="line-comment"] [data-slot="line-comment-tools"] { @@ -127,6 +135,7 @@ export const lineCommentStyles = ` display: flex; align-items: center; justify-content: flex-end; + min-width: 0; } [data-component="line-comment"] [data-slot="line-comment-label"], @@ -137,17 +146,22 @@ export const lineCommentStyles = ` line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); color: var(--text-weak); - white-space: nowrap; + min-width: 0; + white-space: normal; + overflow-wrap: anywhere; } [data-component="line-comment"] [data-slot="line-comment-editor"] { display: flex; flex-direction: column; gap: 8px; + width: 100%; + min-width: 0; } [data-component="line-comment"] [data-slot="line-comment-textarea"] { width: 100%; + box-sizing: border-box; resize: vertical; padding: 8px; border-radius: var(--radius-md); @@ -167,11 +181,14 @@ export const lineCommentStyles = ` [data-component="line-comment"] [data-slot="line-comment-actions"] { display: flex; align-items: center; + flex-wrap: wrap; gap: 8px; padding-left: 8px; + min-width: 0; } [data-component="line-comment"] [data-slot="line-comment-editor-label"] { + flex: 1 1 220px; margin-right: auto; } diff --git a/packages/ui/src/components/line-comment.tsx b/packages/ui/src/components/line-comment.tsx index ff5d1df007..bc47ad9405 100644 --- a/packages/ui/src/components/line-comment.tsx +++ b/packages/ui/src/components/line-comment.tsx @@ -206,6 +206,16 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { const [text, setText] = createSignal(split.value) const focus = () => refs.textarea?.focus() + const hold: JSX.EventHandlerUnion = (e) => { + e.preventDefault() + e.stopPropagation() + } + const click = + (fn: VoidFunction): JSX.EventHandlerUnion => + (e) => { + e.stopPropagation() + fn() + } createEffect(() => { setText(split.value) @@ -268,7 +278,8 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { type="button" data-slot="line-comment-action" data-variant="ghost" - on:click={split.onCancel as any} + on:mousedown={hold as any} + on:click={click(split.onCancel) as any} > {split.cancelLabel ?? i18n.t("ui.common.cancel")} @@ -277,7 +288,8 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { data-slot="line-comment-action" data-variant="primary" disabled={text().trim().length === 0} - on:click={submit as any} + on:mousedown={hold as any} + on:click={click(submit) as any} > {split.submitLabel ?? i18n.t("ui.lineComment.submit")} From 6708c3f6cf2a61d47d7c4b81d1bf6184c70b9b98 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:07:35 +0100 Subject: [PATCH 030/112] docs: mark tools config as deprecated (#17951) --- packages/web/src/content/docs/agents.mdx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 8764642124..5522f77aae 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -366,9 +366,11 @@ The model ID in your OpenCode config uses the format `provider/model-id`. For ex --- -### Tools +### Tools (deprecated) -Control which tools are available in this agent with the `tools` config. You can enable or disable specific tools by setting them to `true` or `false`. +`tools` is **deprecated**. Prefer the agent's [`permission`](#permissions) field for new configs, updates and more fine-grained control. + +Allows you to control which tools are available in this agent. You can enable or disable specific tools by setting them to `true` or `false`. In an agent's `tools` config, `true` is equivalent to `{"*": "allow"}` permission and `false` is equivalent to `{"*": "deny"}` permission. ```json title="opencode.json" {3-6,9-12} { @@ -392,7 +394,7 @@ Control which tools are available in this agent with the `tools` config. You can The agent-specific config overrides the global config. ::: -You can also use wildcards to control multiple tools at once. For example, to disable all tools from an MCP server: +You can also use wildcards in legacy `tools` entries to control multiple tools at once. For example, to disable all tools from an MCP server: ```json title="opencode.json" { From dadddc9c8c35ecb80b287ab729cfed5869b86d56 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 17 Mar 2026 02:24:55 -0400 Subject: [PATCH 031/112] zen: deprecate gemini 3 pro --- packages/web/src/content/docs/zen.mdx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index abbd8058ea..4a4691d4fe 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -86,7 +86,6 @@ You can also access our models through the following API endpoints. | Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | -| Gemini 3 Pro | gemini-3-pro | `https://opencode.ai/zen/v1/models/gemini-3-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -95,8 +94,6 @@ You can also access our models through the following API endpoints. | GLM 4.7 | glm-4.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 4.6 | glm-4.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2 Thinking | kimi-k2-thinking | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo V2 Flash Free | mimo-v2-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -134,8 +131,6 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | GLM 4.7 | $0.60 | $2.20 | $0.10 | - | | GLM 4.6 | $0.60 | $2.20 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | -| Kimi K2 Thinking | $0.40 | $2.50 | - | - | -| Kimi K2 | $0.40 | $2.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | @@ -149,8 +144,6 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | Claude Haiku 3.5 | $0.80 | $4.00 | $0.08 | $1.00 | | Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | -| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | -| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | | GPT 5.4 Pro | $30.00 | $180.00 | $30.00 | - | | GPT 5.4 | $2.50 | $15.00 | $0.25 | - | @@ -206,12 +199,13 @@ charging you more than $20 if your balance goes below $5. | Model | Deprecation date | | ---------------- | ---------------- | -| Qwen3 Coder 480B | Feb 6, 2026 | -| Kimi K2 Thinking | March 6, 2026 | -| Kimi K2 | March 6, 2026 | | MiniMax M2.1 | March 15, 2026 | | GLM 4.7 | March 15, 2026 | | GLM 4.6 | March 15, 2026 | +| Gemini 3 Pro | March 9, 2026 | +| Kimi K2 Thinking | March 6, 2026 | +| Kimi K2 | March 6, 2026 | +| Qwen3 Coder 480B | Feb 6, 2026 | --- From 0772a9591807d15c369c37edc01b9018bdc6e7d1 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 17 Mar 2026 15:00:44 -0400 Subject: [PATCH 032/112] wip: zen --- packages/console/core/script/lookup-user.ts | 23 +++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/console/core/script/lookup-user.ts b/packages/console/core/script/lookup-user.ts index 0dfda24116..8b7bbc1caa 100644 --- a/packages/console/core/script/lookup-user.ts +++ b/packages/console/core/script/lookup-user.ts @@ -3,6 +3,7 @@ import { AuthTable } from "../src/schema/auth.sql.js" import { UserTable } from "../src/schema/user.sql.js" import { BillingTable, PaymentTable, SubscriptionTable, BlackPlans, UsageTable } from "../src/schema/billing.sql.js" import { WorkspaceTable } from "../src/schema/workspace.sql.js" +import { KeyTable } from "../src/schema/key.sql.js" import { BlackData } from "../src/black.js" import { centsToMicroCents } from "../src/util/price.js" import { getWeekBounds } from "../src/util/date.js" @@ -10,13 +11,31 @@ import { getWeekBounds } from "../src/util/date.js" // get input from command line const identifier = process.argv[2] if (!identifier) { - console.error("Usage: bun lookup-user.ts ") + console.error("Usage: bun lookup-user.ts ") process.exit(1) } +// loop up by workspace ID if (identifier.startsWith("wrk_")) { await printWorkspace(identifier) -} else { +} +// lookup by API key +else if (identifier.startsWith("key_")) { + const key = await Database.use((tx) => + tx + .select() + .from(KeyTable) + .where(eq(KeyTable.id, identifier)) + .then((rows) => rows[0]), + ) + if (!key) { + console.error("API key not found") + process.exit(1) + } + await printWorkspace(key.workspaceID) +} +// lookup by email +else { const authData = await Database.use(async (tx) => tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)), ) From 7daea69e13e5a17278fe244273fdeb141b0369d6 Mon Sep 17 00:00:00 2001 From: David Hill <1879069+iamdavidhill@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:54:14 +0000 Subject: [PATCH 033/112] tweak(ui): add an empty state to the sidebar when no projects (#17971) Co-authored-by: Shoubhit Dash --- packages/app/e2e/app/home.spec.ts | 3 ++ packages/app/src/components/titlebar.tsx | 63 +++++++++++++----------- packages/app/src/i18n/en.ts | 2 + packages/app/src/pages/layout.tsx | 30 ++++++++--- 4 files changed, 60 insertions(+), 38 deletions(-) diff --git a/packages/app/e2e/app/home.spec.ts b/packages/app/e2e/app/home.spec.ts index a3cedf7cb6..5deba4300c 100644 --- a/packages/app/e2e/app/home.spec.ts +++ b/packages/app/e2e/app/home.spec.ts @@ -3,8 +3,11 @@ import { serverNamePattern } from "../utils" test("home renders and shows core entrypoints", async ({ page }) => { await page.goto("/") + const nav = page.locator('[data-component="sidebar-nav-desktop"]') await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible() + await expect(nav.getByText("No projects open")).toBeVisible() + await expect(nav.getByText("Open a project to get started")).toBeVisible() await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible() }) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 345903420c..77de1a73ce 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -77,6 +77,7 @@ export function Titlebar() { const canBack = createMemo(() => history.index > 0) const canForward = createMemo(() => history.index < history.stack.length - 1) + const hasProjects = createMemo(() => layout.projects.list().length > 0) const back = () => { const next = backPath(history) @@ -251,36 +252,38 @@ export function Titlebar() {
-
- -
+ +
+ +
+
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index ad12e1e0de..7f6816de9e 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -674,6 +674,8 @@ export const dict = { "sidebar.project.recentSessions": "Recent sessions", "sidebar.project.viewAllSessions": "View all sessions", "sidebar.project.clearNotifications": "Clear notifications", + "sidebar.empty.title": "No projects open", + "sidebar.empty.description": "Open a project to get started", "debugBar.ariaLabel": "Development performance diagnostics", "debugBar.na": "n/a", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index ab2687dcab..a694ce0941 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1959,6 +1959,7 @@ export default function Layout(props: ParentProps) { const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened())) const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened()) const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened()) + const empty = createMemo(() => !params.dir && layout.projects.list().length === 0) const projectName = createMemo(() => { const item = project() if (!item) return "" @@ -2011,7 +2012,26 @@ export default function Layout(props: ParentProps) { width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`, }} > - + +
+
+
+
{language.t("sidebar.empty.title")}
+
+ {language.t("sidebar.empty.description")} +
+
+ +
+
+
+ } + > <>
@@ -2260,13 +2280,7 @@ export default function Layout(props: ParentProps) { helpLabel={() => language.t("sidebar.help")} onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} renderPanel={() => - mobile ? ( - - ) : ( - - - - ) + mobile ? : } /> ) From fbabc97c4c320b26f732d8e0b10aea0574c8920a Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 17 Mar 2026 16:53:08 -0400 Subject: [PATCH 034/112] zen: error logging --- packages/console/app/src/routes/zen/util/handler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 90a5e2e003..3446307bd2 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -330,6 +330,7 @@ export async function handler( logger.metric({ "error.type": error.constructor.name, "error.message": error.message, + "error.cause": error.cause?.toString(), }) // Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message. From 350df0b26130bb4873234697e3a9d3bde3bfce44 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 17 Mar 2026 18:41:38 -0400 Subject: [PATCH 035/112] zen: add missing model lab names --- packages/console/app/src/component/icon.tsx | 27 +++++++++++++++++++ .../routes/workspace/[id]/model-section.tsx | 12 +++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx index fc50b489b9..df7e067c28 100644 --- a/packages/console/app/src/component/icon.tsx +++ b/packages/console/app/src/component/icon.tsx @@ -191,6 +191,33 @@ export function IconGemini(props: JSX.SvgSVGAttributes) { ) } +export function IconXiaomi(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconNvidia(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconArcee(props: JSX.SvgSVGAttributes) { + return ( + + + + + + + ) +} + export function IconStealth(props: JSX.SvgSVGAttributes) { return ( diff --git a/packages/console/app/src/routes/workspace/[id]/model-section.tsx b/packages/console/app/src/routes/workspace/[id]/model-section.tsx index a4b64889ca..bf19f81cd2 100644 --- a/packages/console/app/src/routes/workspace/[id]/model-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/model-section.tsx @@ -8,12 +8,15 @@ import { querySessionInfo } from "../common" import { IconAlibaba, IconAnthropic, + IconArcee, IconGemini, IconMiniMax, IconMoonshotAI, + IconNvidia, IconOpenAI, IconStealth, IconXai, + IconXiaomi, IconZai, } from "~/component/icon" import { useI18n } from "~/context/i18n" @@ -29,6 +32,9 @@ const getModelLab = (modelId: string) => { if (modelId.startsWith("qwen")) return "Alibaba" if (modelId.startsWith("minimax")) return "MiniMax" if (modelId.startsWith("grok")) return "xAI" + if (modelId.startsWith("mimo")) return "Xiaomi" + if (modelId.startsWith("nemotron")) return "NVIDIA" + if (modelId.startsWith("trinity")) return "Arcee" return "Stealth" } @@ -139,6 +145,12 @@ export function ModelSection() { return case "MiniMax": return + case "Xiaomi": + return + case "NVIDIA": + return + case "Arcee": + return default: return } From 6c047391bb4b9cf2bcb1d104a512ecf225e6d6fd Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 17 Mar 2026 19:06:22 -0400 Subject: [PATCH 036/112] wip: zen --- .../console/app/src/routes/zen/util/handler.ts | 18 ++++++++++-------- .../src/routes/zen/util/provider/anthropic.ts | 3 ++- .../app/src/routes/zen/util/trialLimiter.ts | 6 +++--- packages/console/core/src/model.ts | 2 +- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 3446307bd2..8895cdcf1e 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -97,8 +97,8 @@ export async function handler( const zenData = ZenData.list(opts.modelList) const modelInfo = validateModel(zenData, model) const dataDumper = createDataDumper(sessionId, requestId, projectId) - const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip) - const trialProvider = await trialLimiter?.check() + const trialLimiter = createTrialLimiter(modelInfo.trialProviders, ip) + const trialProviders = await trialLimiter?.check() const rateLimiter = createRateLimiter( modelInfo.id, modelInfo.allowAnonymous, @@ -120,7 +120,7 @@ export async function handler( authInfo, modelInfo, sessionId, - trialProvider, + trialProviders, retry, stickyProvider, ) @@ -402,7 +402,7 @@ export async function handler( authInfo: AuthInfo, modelInfo: ModelInfo, sessionId: string, - trialProvider: string | undefined, + trialProviders: string[] | undefined, retry: RetryOptions, stickyProvider: string | undefined, ) { @@ -411,15 +411,17 @@ export async function handler( return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider) } - if (trialProvider) { - return modelInfo.providers.find((provider) => provider.id === trialProvider) - } - if (stickyProvider) { const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider) if (provider) return provider } + if (trialProviders) { + const trialProvider = trialProviders[Math.floor(Math.random() * trialProviders.length)] + const provider = modelInfo.providers.find((provider) => provider.id === trialProvider) + if (provider) return provider + } + if (retry.retryCount !== MAX_FAILOVER_RETRIES) { const providers = modelInfo.providers .filter((provider) => !provider.disabled) diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts index 95c50fbdbf..15fe75b848 100644 --- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts +++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts @@ -175,7 +175,8 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) => outputTokens: usage.output_tokens ?? 0, reasoningTokens: undefined, cacheReadTokens: usage.cache_read_input_tokens ?? undefined, - cacheWrite5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens ?? undefined, + cacheWrite5mTokens: + usage.cache_creation?.ephemeral_5m_input_tokens ?? usage.cache_creation_input_tokens ?? undefined, cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? undefined, }), } diff --git a/packages/console/app/src/routes/zen/util/trialLimiter.ts b/packages/console/app/src/routes/zen/util/trialLimiter.ts index 1ae0ab3292..319825dd79 100644 --- a/packages/console/app/src/routes/zen/util/trialLimiter.ts +++ b/packages/console/app/src/routes/zen/util/trialLimiter.ts @@ -3,8 +3,8 @@ import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js" import { UsageInfo } from "./provider/provider" import { Subscription } from "@opencode-ai/console-core/subscription.js" -export function createTrialLimiter(trialProvider: string | undefined, ip: string) { - if (!trialProvider) return +export function createTrialLimiter(trialProviders: string[] | undefined, ip: string) { + if (!trialProviders) return if (!ip) return const limit = Subscription.getFreeLimits().promoTokens @@ -24,7 +24,7 @@ export function createTrialLimiter(trialProvider: string | undefined, ip: string ) _isTrial = (data?.usage ?? 0) < limit - return _isTrial ? trialProvider : undefined + return _isTrial ? trialProviders : undefined }, track: async (usageInfo: UsageInfo) => { if (!_isTrial) return diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index f859f0d3cf..c47e4a6d83 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -26,7 +26,7 @@ export namespace ZenData { allowAnonymous: z.boolean().optional(), byokProvider: z.enum(["openai", "anthropic", "google"]).optional(), stickyProvider: z.enum(["strict", "prefer"]).optional(), - trialProvider: z.string().optional(), + trialProviders: z.array(z.string()).optional(), fallbackProvider: z.string().optional(), rateLimit: z.number().optional(), providers: z.array( From fee3c196c51329dec4a93095fc01a85d9e7b5dbb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 17 Mar 2026 19:18:16 -0400 Subject: [PATCH 037/112] add prompt schema validation debug logs (#17812) --- packages/opencode/src/session/prompt.ts | 25 +++++++++++++++++++++++++ packages/opencode/src/util/fn.ts | 3 +++ 2 files changed, 28 insertions(+) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bf939c7e27..27a379daa5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1318,6 +1318,31 @@ export namespace SessionPrompt { }, ) + const parsedInfo = MessageV2.Info.safeParse(info) + if (!parsedInfo.success) { + log.error("invalid user message before save", { + sessionID: input.sessionID, + messageID: info.id, + agent: info.agent, + model: info.model, + issues: parsedInfo.error.issues, + }) + } + + parts.forEach((part, index) => { + const parsedPart = MessageV2.Part.safeParse(part) + if (parsedPart.success) return + log.error("invalid user part before save", { + sessionID: input.sessionID, + messageID: info.id, + partID: part.id, + partType: part.type, + index, + issues: parsedPart.error.issues, + part, + }) + }) + await Session.updateMessage(info) for (const part of parts) { await Session.updatePart(part) diff --git a/packages/opencode/src/util/fn.ts b/packages/opencode/src/util/fn.ts index 19c60265bb..c75fc1bb54 100644 --- a/packages/opencode/src/util/fn.ts +++ b/packages/opencode/src/util/fn.ts @@ -7,6 +7,9 @@ export function fn(schema: T, cb: (input: z.infer Date: Tue, 17 Mar 2026 21:04:16 -0400 Subject: [PATCH 038/112] refactor(snapshot): effectify SnapshotService (#17878) --- packages/opencode/script/seed-e2e.ts | 72 +- .../opencode/src/effect/instance-context.ts | 20 +- packages/opencode/src/effect/instances.ts | 129 +-- packages/opencode/src/effect/runtime.ts | 4 + packages/opencode/src/project/instance.ts | 306 +++--- packages/opencode/src/snapshot/index.ts | 908 ++++++++++-------- packages/opencode/test/fixture/instance.ts | 70 +- 7 files changed, 834 insertions(+), 675 deletions(-) diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts index f34dd051db..fc3573548d 100644 --- a/packages/opencode/script/seed-e2e.ts +++ b/packages/opencode/script/seed-e2e.ts @@ -11,46 +11,52 @@ const seed = async () => { const { Instance } = await import("../src/project/instance") const { InstanceBootstrap } = await import("../src/project/bootstrap") const { Config } = await import("../src/config/config") + const { disposeRuntime } = await import("../src/effect/runtime") const { Session } = await import("../src/session") const { MessageID, PartID } = await import("../src/session/schema") const { Project } = await import("../src/project/project") const { ModelID, ProviderID } = await import("../src/provider/schema") const { ToolRegistry } = await import("../src/tool/registry") - await Instance.provide({ - directory: dir, - init: InstanceBootstrap, - fn: async () => { - await Config.waitForDependencies() - await ToolRegistry.ids() + try { + await Instance.provide({ + directory: dir, + init: InstanceBootstrap, + fn: async () => { + await Config.waitForDependencies() + await ToolRegistry.ids() - const session = await Session.create({ title }) - const messageID = MessageID.ascending() - const partID = PartID.ascending() - const message = { - id: messageID, - sessionID: session.id, - role: "user" as const, - time: { created: now }, - agent: "build", - model: { - providerID: ProviderID.make(providerID), - modelID: ModelID.make(modelID), - }, - } - const part = { - id: partID, - sessionID: session.id, - messageID, - type: "text" as const, - text, - time: { start: now }, - } - await Session.updateMessage(message) - await Session.updatePart(part) - await Project.update({ projectID: Instance.project.id, name: "E2E Project" }) - }, - }) + const session = await Session.create({ title }) + const messageID = MessageID.ascending() + const partID = PartID.ascending() + const message = { + id: messageID, + sessionID: session.id, + role: "user" as const, + time: { created: now }, + agent: "build", + model: { + providerID: ProviderID.make(providerID), + modelID: ModelID.make(modelID), + }, + } + const part = { + id: partID, + sessionID: session.id, + messageID, + type: "text" as const, + text, + time: { start: now }, + } + await Session.updateMessage(message) + await Session.updatePart(part) + await Project.update({ projectID: Instance.project.id, name: "E2E Project" }) + }, + }) + } finally { + await Instance.disposeAll().catch(() => {}) + await disposeRuntime().catch(() => {}) + } } await seed() diff --git a/packages/opencode/src/effect/instance-context.ts b/packages/opencode/src/effect/instance-context.ts index 583b52d562..af5f9236fc 100644 --- a/packages/opencode/src/effect/instance-context.ts +++ b/packages/opencode/src/effect/instance-context.ts @@ -1,13 +1,15 @@ -import { ServiceMap } from "effect" -import type { Project } from "@/project/project" +import { ServiceMap } from "effect"; +import type { Project } from "@/project/project"; export declare namespace InstanceContext { - export interface Shape { - readonly directory: string - readonly project: Project.Info - } + export interface Shape { + readonly directory: string; + readonly worktree: string; + readonly project: Project.Info; + } } -export class InstanceContext extends ServiceMap.Service()( - "opencode/InstanceContext", -) {} +export class InstanceContext extends ServiceMap.Service< + InstanceContext, + InstanceContext.Shape +>()("opencode/InstanceContext") {} diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index eabf198688..075663f080 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -1,64 +1,83 @@ -import { Effect, Layer, LayerMap, ServiceMap } from "effect" -import { registerDisposer } from "./instance-registry" -import { InstanceContext } from "./instance-context" -import { ProviderAuthService } from "@/provider/auth-service" -import { QuestionService } from "@/question/service" -import { PermissionService } from "@/permission/service" -import { FileWatcherService } from "@/file/watcher" -import { VcsService } from "@/project/vcs" -import { FileTimeService } from "@/file/time" -import { FormatService } from "@/format" -import { FileService } from "@/file" -import { SkillService } from "@/skill/skill" -import { Instance } from "@/project/instance" +import { Effect, Layer, LayerMap, ServiceMap } from "effect"; +import { FileService } from "@/file"; +import { FileTimeService } from "@/file/time"; +import { FileWatcherService } from "@/file/watcher"; +import { FormatService } from "@/format"; +import { PermissionService } from "@/permission/service"; +import { Instance } from "@/project/instance"; +import { VcsService } from "@/project/vcs"; +import { ProviderAuthService } from "@/provider/auth-service"; +import { QuestionService } from "@/question/service"; +import { SkillService } from "@/skill/skill"; +import { SnapshotService } from "@/snapshot"; +import { InstanceContext } from "./instance-context"; +import { registerDisposer } from "./instance-registry"; -export { InstanceContext } from "./instance-context" +export { InstanceContext } from "./instance-context"; export type InstanceServices = - | QuestionService - | PermissionService - | ProviderAuthService - | FileWatcherService - | VcsService - | FileTimeService - | FormatService - | FileService - | SkillService + | QuestionService + | PermissionService + | ProviderAuthService + | FileWatcherService + | VcsService + | FileTimeService + | FormatService + | FileService + | SkillService + | SnapshotService; -function lookup(directory: string) { - const project = Instance.project - const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project })) - return Layer.mergeAll( - Layer.fresh(QuestionService.layer), - Layer.fresh(PermissionService.layer), - Layer.fresh(ProviderAuthService.layer), - Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), - Layer.fresh(VcsService.layer), - Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), - Layer.fresh(FormatService.layer), - Layer.fresh(FileService.layer), - Layer.fresh(SkillService.layer), - ).pipe(Layer.provide(ctx)) +// NOTE: LayerMap only passes the key (directory string) to lookup, but we need +// the full instance context (directory, worktree, project). We read from the +// legacy Instance ALS here, which is safe because lookup is only triggered via +// runPromiseInstance -> Instances.get, which always runs inside Instance.provide. +// This should go away once the old Instance type is removed and lookup can load +// the full context directly. +function lookup(_key: string) { + const ctx = Layer.sync(InstanceContext, () => + InstanceContext.of(Instance.current), + ); + return Layer.mergeAll( + Layer.fresh(QuestionService.layer), + Layer.fresh(PermissionService.layer), + Layer.fresh(ProviderAuthService.layer), + Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), + Layer.fresh(VcsService.layer), + Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), + Layer.fresh(FormatService.layer), + Layer.fresh(FileService.layer), + Layer.fresh(SkillService.layer), + Layer.fresh(SnapshotService.layer), + ).pipe(Layer.provide(ctx)); } -export class Instances extends ServiceMap.Service>()( - "opencode/Instances", -) { - static readonly layer = Layer.effect( - Instances, - Effect.gen(function* () { - const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity }) - const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory))) - yield* Effect.addFinalizer(() => Effect.sync(unregister)) - return Instances.of(layerMap) - }), - ) +export class Instances extends ServiceMap.Service< + Instances, + LayerMap.LayerMap +>()("opencode/Instances") { + static readonly layer = Layer.effect( + Instances, + Effect.gen(function* () { + const layerMap = yield* LayerMap.make(lookup, { + idleTimeToLive: Infinity, + }); + const unregister = registerDisposer((directory) => + Effect.runPromise(layerMap.invalidate(directory)), + ); + yield* Effect.addFinalizer(() => Effect.sync(unregister)); + return Instances.of(layerMap); + }), + ); - static get(directory: string): Layer.Layer { - return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory)))) - } + static get( + directory: string, + ): Layer.Layer { + return Layer.unwrap( + Instances.use((map) => Effect.succeed(map.get(directory))), + ); + } - static invalidate(directory: string): Effect.Effect { - return Instances.use((map) => map.invalidate(directory)) - } + static invalidate(directory: string): Effect.Effect { + return Instances.use((map) => map.invalidate(directory)); + } } diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index 02a7391d44..cf7d73f776 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -12,3 +12,7 @@ export const runtime = ManagedRuntime.make( export function runPromiseInstance(effect: Effect.Effect) { return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory)))) } + +export function disposeRuntime() { + return runtime.dispose() +} diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index c16801a7a1..61f6dd7931 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,163 +1,185 @@ -import { Log } from "@/util/log" -import { Context } from "../util/context" -import { Project } from "./project" -import { State } from "./state" -import { iife } from "@/util/iife" -import { GlobalBus } from "@/bus/global" -import { Filesystem } from "@/util/filesystem" -import { disposeInstance } from "@/effect/instance-registry" +import { GlobalBus } from "@/bus/global"; +import { disposeInstance } from "@/effect/instance-registry"; +import { Filesystem } from "@/util/filesystem"; +import { iife } from "@/util/iife"; +import { Log } from "@/util/log"; +import { Context } from "../util/context"; +import { Project } from "./project"; +import { State } from "./state"; interface Context { - directory: string - worktree: string - project: Project.Info + directory: string; + worktree: string; + project: Project.Info; } -const context = Context.create("instance") -const cache = new Map>() +const context = Context.create("instance"); +const cache = new Map>(); const disposal = { - all: undefined as Promise | undefined, -} + all: undefined as Promise | undefined, +}; function emit(directory: string) { - GlobalBus.emit("event", { - directory, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) + GlobalBus.emit("event", { + directory, + payload: { + type: "server.instance.disposed", + properties: { + directory, + }, + }, + }); } -function boot(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - return iife(async () => { - const ctx = - input.project && input.worktree - ? { - directory: input.directory, - worktree: input.worktree, - project: input.project, - } - : await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({ - directory: input.directory, - worktree: sandbox, - project, - })) - await context.provide(ctx, async () => { - await input.init?.() - }) - return ctx - }) +function boot(input: { + directory: string; + init?: () => Promise; + project?: Project.Info; + worktree?: string; +}) { + return iife(async () => { + const ctx = + input.project && input.worktree + ? { + directory: input.directory, + worktree: input.worktree, + project: input.project, + } + : await Project.fromDirectory(input.directory).then( + ({ project, sandbox }) => ({ + directory: input.directory, + worktree: sandbox, + project, + }), + ); + await context.provide(ctx, async () => { + await input.init?.(); + }); + return ctx; + }); } function track(directory: string, next: Promise) { - const task = next.catch((error) => { - if (cache.get(directory) === task) cache.delete(directory) - throw error - }) - cache.set(directory, task) - return task + const task = next.catch((error) => { + if (cache.get(directory) === task) cache.delete(directory); + throw error; + }); + cache.set(directory, task); + return task; } export const Instance = { - async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - const directory = Filesystem.resolve(input.directory) - let existing = cache.get(directory) - if (!existing) { - Log.Default.info("creating instance", { directory }) - existing = track( - directory, - boot({ - directory, - init: input.init, - }), - ) - } - const ctx = await existing - return context.provide(ctx, async () => { - return input.fn() - }) - }, - get directory() { - return context.use().directory - }, - get worktree() { - return context.use().worktree - }, - get project() { - return context.use().project - }, - /** - * Check if a path is within the project boundary. - * Returns true if path is inside Instance.directory OR Instance.worktree. - * Paths within the worktree but outside the working directory should not trigger external_directory permission. - */ - containsPath(filepath: string) { - if (Filesystem.contains(Instance.directory, filepath)) return true - // Non-git projects set worktree to "/" which would match ANY absolute path. - // Skip worktree check in this case to preserve external_directory permissions. - if (Instance.worktree === "/") return false - return Filesystem.contains(Instance.worktree, filepath) - }, - /** - * Captures the current instance ALS context and returns a wrapper that - * restores it when called. Use this for callbacks that fire outside the - * instance async context (native addons, event emitters, timers, etc.). - */ - bind any>(fn: F): F { - const ctx = context.use() - return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F - }, - state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { - return State.create(() => Instance.directory, init, dispose) - }, - async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - const directory = Filesystem.resolve(input.directory) - Log.Default.info("reloading instance", { directory }) - await Promise.all([State.dispose(directory), disposeInstance(directory)]) - cache.delete(directory) - const next = track(directory, boot({ ...input, directory })) - emit(directory) - return await next - }, - async dispose() { - const directory = Instance.directory - Log.Default.info("disposing instance", { directory }) - await Promise.all([State.dispose(directory), disposeInstance(directory)]) - cache.delete(directory) - emit(directory) - }, - async disposeAll() { - if (disposal.all) return disposal.all + async provide(input: { + directory: string; + init?: () => Promise; + fn: () => R; + }): Promise { + const directory = Filesystem.resolve(input.directory); + let existing = cache.get(directory); + if (!existing) { + Log.Default.info("creating instance", { directory }); + existing = track( + directory, + boot({ + directory, + init: input.init, + }), + ); + } + const ctx = await existing; + return context.provide(ctx, async () => { + return input.fn(); + }); + }, + get current() { + return context.use(); + }, + get directory() { + return context.use().directory; + }, + get worktree() { + return context.use().worktree; + }, + get project() { + return context.use().project; + }, + /** + * Check if a path is within the project boundary. + * Returns true if path is inside Instance.directory OR Instance.worktree. + * Paths within the worktree but outside the working directory should not trigger external_directory permission. + */ + containsPath(filepath: string) { + if (Filesystem.contains(Instance.directory, filepath)) return true; + // Non-git projects set worktree to "/" which would match ANY absolute path. + // Skip worktree check in this case to preserve external_directory permissions. + if (Instance.worktree === "/") return false; + return Filesystem.contains(Instance.worktree, filepath); + }, + /** + * Captures the current instance ALS context and returns a wrapper that + * restores it when called. Use this for callbacks that fire outside the + * instance async context (native addons, event emitters, timers, etc.). + */ + bind any>(fn: F): F { + const ctx = context.use(); + return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F; + }, + state( + init: () => S, + dispose?: (state: Awaited) => Promise, + ): () => S { + return State.create(() => Instance.directory, init, dispose); + }, + async reload(input: { + directory: string; + init?: () => Promise; + project?: Project.Info; + worktree?: string; + }) { + const directory = Filesystem.resolve(input.directory); + Log.Default.info("reloading instance", { directory }); + await Promise.all([State.dispose(directory), disposeInstance(directory)]); + cache.delete(directory); + const next = track(directory, boot({ ...input, directory })); + emit(directory); + return await next; + }, + async dispose() { + const directory = Instance.directory; + Log.Default.info("disposing instance", { directory }); + await Promise.all([State.dispose(directory), disposeInstance(directory)]); + cache.delete(directory); + emit(directory); + }, + async disposeAll() { + if (disposal.all) return disposal.all; - disposal.all = iife(async () => { - Log.Default.info("disposing all instances") - const entries = [...cache.entries()] - for (const [key, value] of entries) { - if (cache.get(key) !== value) continue + disposal.all = iife(async () => { + Log.Default.info("disposing all instances"); + const entries = [...cache.entries()]; + for (const [key, value] of entries) { + if (cache.get(key) !== value) continue; - const ctx = await value.catch((error) => { - Log.Default.warn("instance dispose failed", { key, error }) - return undefined - }) + const ctx = await value.catch((error) => { + Log.Default.warn("instance dispose failed", { key, error }); + return undefined; + }); - if (!ctx) { - if (cache.get(key) === value) cache.delete(key) - continue - } + if (!ctx) { + if (cache.get(key) === value) cache.delete(key); + continue; + } - if (cache.get(key) !== value) continue + if (cache.get(key) !== value) continue; - await context.provide(ctx, async () => { - await Instance.dispose() - }) - } - }).finally(() => { - disposal.all = undefined - }) + await context.provide(ctx, async () => { + await Instance.dispose(); + }); + } + }).finally(() => { + disposal.all = undefined; + }); - return disposal.all - }, -} + return disposal.all; + }, +}; diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 72252b7b4c..ccba830b81 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,416 +1,516 @@ -import path from "path" -import fs from "fs/promises" -import { Filesystem } from "../util/filesystem" -import { Log } from "../util/log" -import { Flag } from "../flag/flag" -import { Global } from "../global" -import z from "zod" -import { Config } from "../config/config" -import { Instance } from "../project/instance" -import { Scheduler } from "../scheduler" -import { Process } from "@/util/process" +import { + NodeChildProcessSpawner, + NodeFileSystem, + NodePath, +} from "@effect/platform-node"; +import { + Cause, + Duration, + Effect, + FileSystem, + Layer, + Schedule, + ServiceMap, + Stream, +} from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import path from "path"; +import z from "zod"; +import { InstanceContext } from "@/effect/instance-context"; +import { runPromiseInstance } from "@/effect/runtime"; +import { Config } from "../config/config"; +import { Global } from "../global"; +import { Log } from "../util/log"; + +const log = Log.create({ service: "snapshot" }); +const PRUNE = "7.days"; + +// Common git config flags shared across snapshot operations +const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]; +const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE]; +const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"]; + +interface GitResult { + readonly code: ChildProcessSpawner.ExitCode; + readonly text: string; + readonly stderr: string; +} export namespace Snapshot { - const log = Log.create({ service: "snapshot" }) - const hour = 60 * 60 * 1000 - const prune = "7.days" + export const Patch = z.object({ + hash: z.string(), + files: z.string().array(), + }); + export type Patch = z.infer; - function args(git: string, cmd: string[]) { - return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd] - } + export const FileDiff = z + .object({ + file: z.string(), + before: z.string(), + after: z.string(), + additions: z.number(), + deletions: z.number(), + status: z.enum(["added", "deleted", "modified"]).optional(), + }) + .meta({ + ref: "FileDiff", + }); + export type FileDiff = z.infer; - export function init() { - Scheduler.register({ - id: "snapshot.cleanup", - interval: hour, - run: cleanup, - scope: "instance", - }) - } + // Promise facade — existing callers use these + export function init() { + void runPromiseInstance(SnapshotService.use((s) => s.init())); + } - export async function cleanup() { - if (Instance.project.vcs !== "git") return - const cfg = await Config.get() - if (cfg.snapshot === false) return - const git = gitdir() - const exists = await fs - .stat(git) - .then(() => true) - .catch(() => false) - if (!exists) return - const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], { - cwd: Instance.directory, - nothrow: true, - }) - if (result.code !== 0) { - log.warn("cleanup failed", { - exitCode: result.code, - stderr: result.stderr.toString(), - stdout: result.stdout.toString(), - }) - return - } - log.info("cleanup", { prune }) - } + export async function cleanup() { + return runPromiseInstance(SnapshotService.use((s) => s.cleanup())); + } - export async function track() { - if (Instance.project.vcs !== "git") return - const cfg = await Config.get() - if (cfg.snapshot === false) return - const git = gitdir() - if (await fs.mkdir(git, { recursive: true })) { - await Process.run(["git", "init"], { - env: { - ...process.env, - GIT_DIR: git, - GIT_WORK_TREE: Instance.worktree, - }, - nothrow: true, - }) + export async function track() { + return runPromiseInstance(SnapshotService.use((s) => s.track())); + } - // Configure git to not convert line endings on Windows - await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true }) - await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true }) - await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true }) - await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true }) - log.info("initialized") - } - await add(git) - const hash = await Process.text(["git", ...args(git, ["write-tree"])], { - cwd: Instance.directory, - nothrow: true, - }).then((x) => x.text) - log.info("tracking", { hash, cwd: Instance.directory, git }) - return hash.trim() - } + export async function patch(hash: string) { + return runPromiseInstance(SnapshotService.use((s) => s.patch(hash))); + } - export const Patch = z.object({ - hash: z.string(), - files: z.string().array(), - }) - export type Patch = z.infer + export async function restore(snapshot: string) { + return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot))); + } - export async function patch(hash: string): Promise { - const git = gitdir() - await add(git) - const result = await Process.text( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]), - ], - { - cwd: Instance.directory, - nothrow: true, - }, - ) + export async function revert(patches: Patch[]) { + return runPromiseInstance(SnapshotService.use((s) => s.revert(patches))); + } - // If git diff fails, return empty patch - if (result.code !== 0) { - log.warn("failed to get diff", { hash, exitCode: result.code }) - return { hash, files: [] } - } + export async function diff(hash: string) { + return runPromiseInstance(SnapshotService.use((s) => s.diff(hash))); + } - const files = result.text - return { - hash, - files: files - .trim() - .split("\n") - .map((x) => x.trim()) - .filter(Boolean) - .map((x) => path.join(Instance.worktree, x).replaceAll("\\", "/")), - } - } - - export async function restore(snapshot: string) { - log.info("restore", { commit: snapshot }) - const git = gitdir() - const result = await Process.run( - ["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])], - { - cwd: Instance.worktree, - nothrow: true, - }, - ) - if (result.code === 0) { - const checkout = await Process.run( - ["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])], - { - cwd: Instance.worktree, - nothrow: true, - }, - ) - if (checkout.code === 0) return - log.error("failed to restore snapshot", { - snapshot, - exitCode: checkout.code, - stderr: checkout.stderr.toString(), - stdout: checkout.stdout.toString(), - }) - return - } - - log.error("failed to restore snapshot", { - snapshot, - exitCode: result.code, - stderr: result.stderr.toString(), - stdout: result.stdout.toString(), - }) - } - - export async function revert(patches: Patch[]) { - const files = new Set() - const git = gitdir() - for (const item of patches) { - for (const file of item.files) { - if (files.has(file)) continue - log.info("reverting", { file, hash: item.hash }) - const result = await Process.run( - [ - "git", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - ...args(git, ["checkout", item.hash, "--", file]), - ], - { - cwd: Instance.worktree, - nothrow: true, - }, - ) - if (result.code !== 0) { - const relativePath = path.relative(Instance.worktree, file) - const checkTree = await Process.text( - [ - "git", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - ...args(git, ["ls-tree", item.hash, "--", relativePath]), - ], - { - cwd: Instance.worktree, - nothrow: true, - }, - ) - if (checkTree.code === 0 && checkTree.text.trim()) { - log.info("file existed in snapshot but checkout failed, keeping", { - file, - }) - } else { - log.info("file did not exist in snapshot, deleting", { file }) - await fs.unlink(file).catch(() => {}) - } - } - files.add(file) - } - } - } - - export async function diff(hash: string) { - const git = gitdir() - await add(git) - const result = await Process.text( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ...args(git, ["diff", "--no-ext-diff", hash, "--", "."]), - ], - { - cwd: Instance.worktree, - nothrow: true, - }, - ) - - if (result.code !== 0) { - log.warn("failed to get diff", { - hash, - exitCode: result.code, - stderr: result.stderr.toString(), - stdout: result.stdout.toString(), - }) - return "" - } - - return result.text.trim() - } - - export const FileDiff = z - .object({ - file: z.string(), - before: z.string(), - after: z.string(), - additions: z.number(), - deletions: z.number(), - status: z.enum(["added", "deleted", "modified"]).optional(), - }) - .meta({ - ref: "FileDiff", - }) - export type FileDiff = z.infer - export async function diffFull(from: string, to: string): Promise { - const git = gitdir() - const result: FileDiff[] = [] - const status = new Map() - - const statuses = await Process.text( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]), - ], - { - cwd: Instance.directory, - nothrow: true, - }, - ).then((x) => x.text) - - for (const line of statuses.trim().split("\n")) { - if (!line) continue - const [code, file] = line.split("\t") - if (!code || !file) continue - const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified" - status.set(file, kind) - } - - for (const line of await Process.lines( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]), - ], - { - cwd: Instance.directory, - nothrow: true, - }, - )) { - if (!line) continue - const [additions, deletions, file] = line.split("\t") - const isBinaryFile = additions === "-" && deletions === "-" - const before = isBinaryFile - ? "" - : await Process.text( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - ...args(git, ["show", `${from}:${file}`]), - ], - { nothrow: true }, - ).then((x) => x.text) - const after = isBinaryFile - ? "" - : await Process.text( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - ...args(git, ["show", `${to}:${file}`]), - ], - { nothrow: true }, - ).then((x) => x.text) - const added = isBinaryFile ? 0 : parseInt(additions) - const deleted = isBinaryFile ? 0 : parseInt(deletions) - result.push({ - file, - before, - after, - additions: Number.isFinite(added) ? added : 0, - deletions: Number.isFinite(deleted) ? deleted : 0, - status: status.get(file) ?? "modified", - }) - } - return result - } - - function gitdir() { - const project = Instance.project - return path.join(Global.Path.data, "snapshot", project.id) - } - - async function add(git: string) { - await syncExclude(git) - await Process.run( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - ...args(git, ["add", "."]), - ], - { - cwd: Instance.directory, - nothrow: true, - }, - ) - } - - async function syncExclude(git: string) { - const file = await excludes() - const target = path.join(git, "info", "exclude") - await fs.mkdir(path.join(git, "info"), { recursive: true }) - if (!file) { - await Filesystem.write(target, "") - return - } - const text = await Filesystem.readText(file).catch(() => "") - - await Filesystem.write(target, text) - } - - async function excludes() { - const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { - cwd: Instance.worktree, - nothrow: true, - }).then((x) => x.text) - if (!file.trim()) return - const exists = await fs - .stat(file.trim()) - .then(() => true) - .catch(() => false) - if (!exists) return - return file.trim() - } + export async function diffFull(from: string, to: string) { + return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to))); + } +} + +export namespace SnapshotService { + export interface Service { + readonly init: () => Effect.Effect; + readonly cleanup: () => Effect.Effect; + readonly track: () => Effect.Effect; + readonly patch: (hash: string) => Effect.Effect; + readonly restore: (snapshot: string) => Effect.Effect; + readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect; + readonly diff: (hash: string) => Effect.Effect; + readonly diffFull: ( + from: string, + to: string, + ) => Effect.Effect; + } +} + +export class SnapshotService extends ServiceMap.Service< + SnapshotService, + SnapshotService.Service +>()("@opencode/Snapshot") { + static readonly layer = Layer.effect( + SnapshotService, + Effect.gen(function* () { + const ctx = yield* InstanceContext; + const fileSystem = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const { directory, worktree, project } = ctx; + const isGit = project.vcs === "git"; + const snapshotGit = path.join(Global.Path.data, "snapshot", project.id); + + const gitArgs = (cmd: string[]) => [ + "--git-dir", + snapshotGit, + "--work-tree", + worktree, + ...cmd, + ]; + + // Run git with nothrow semantics — always returns a result, never fails + const git = ( + args: string[], + opts?: { cwd?: string; env?: Record }, + ): Effect.Effect => + Effect.gen(function* () { + const command = ChildProcess.make("git", args, { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }); + const handle = yield* spawner.spawn(command); + const [text, stderr] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + ], + { concurrency: 2 }, + ); + const code = yield* handle.exitCode; + return { code, text, stderr }; + }).pipe( + Effect.scoped, + Effect.catch((err) => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(1), + text: "", + stderr: String(err), + }), + ), + ); + + // FileSystem helpers — orDie converts PlatformError to defects + const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie); + const mkdir = (p: string) => + fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie); + const writeFile = (p: string, content: string) => + fileSystem.writeFileString(p, content).pipe(Effect.orDie); + const readFile = (p: string) => + fileSystem + .readFileString(p) + .pipe(Effect.catch(() => Effect.succeed(""))); + const removeFile = (p: string) => + fileSystem.remove(p).pipe(Effect.catch(() => Effect.void)); + + // --- internal Effect helpers --- + + const isEnabled = Effect.gen(function* () { + if (!isGit) return false; + const cfg = yield* Effect.promise(() => Config.get()); + return cfg.snapshot !== false; + }); + + const excludesPath = Effect.gen(function* () { + const result = yield* git( + ["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], + { + cwd: worktree, + }, + ); + const file = result.text.trim(); + if (!file) return undefined; + if (!(yield* exists(file))) return undefined; + return file; + }); + + const syncExclude = Effect.gen(function* () { + const file = yield* excludesPath; + const target = path.join(snapshotGit, "info", "exclude"); + yield* mkdir(path.join(snapshotGit, "info")); + if (!file) { + yield* writeFile(target, ""); + return; + } + const text = yield* readFile(file); + yield* writeFile(target, text); + }); + + const add = Effect.gen(function* () { + yield* syncExclude; + yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory }); + }); + + // --- service methods --- + + const cleanup = Effect.fn("SnapshotService.cleanup")(function* () { + if (!(yield* isEnabled)) return; + if (!(yield* exists(snapshotGit))) return; + const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), { + cwd: directory, + }); + if (result.code !== 0) { + log.warn("cleanup failed", { + exitCode: result.code, + stderr: result.stderr, + }); + return; + } + log.info("cleanup", { prune: PRUNE }); + }); + + const track = Effect.fn("SnapshotService.track")(function* () { + if (!(yield* isEnabled)) return undefined; + const existed = yield* exists(snapshotGit); + yield* mkdir(snapshotGit); + if (!existed) { + yield* git(["init"], { + env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree }, + }); + yield* git([ + "--git-dir", + snapshotGit, + "config", + "core.autocrlf", + "false", + ]); + yield* git([ + "--git-dir", + snapshotGit, + "config", + "core.longpaths", + "true", + ]); + yield* git([ + "--git-dir", + snapshotGit, + "config", + "core.symlinks", + "true", + ]); + yield* git([ + "--git-dir", + snapshotGit, + "config", + "core.fsmonitor", + "false", + ]); + log.info("initialized"); + } + yield* add; + const result = yield* git(gitArgs(["write-tree"]), { cwd: directory }); + const hash = result.text.trim(); + log.info("tracking", { hash, cwd: directory, git: snapshotGit }); + return hash; + }); + + const patch = Effect.fn("SnapshotService.patch")(function* ( + hash: string, + ) { + yield* add; + const result = yield* git( + [ + ...GIT_CFG_QUOTE, + ...gitArgs([ + "diff", + "--no-ext-diff", + "--name-only", + hash, + "--", + ".", + ]), + ], + { cwd: directory }, + ); + + if (result.code !== 0) { + log.warn("failed to get diff", { hash, exitCode: result.code }); + return { hash, files: [] } as Snapshot.Patch; + } + + return { + hash, + files: result.text + .trim() + .split("\n") + .map((x: string) => x.trim()) + .filter(Boolean) + .map((x: string) => path.join(worktree, x).replaceAll("\\", "/")), + } as Snapshot.Patch; + }); + + const restore = Effect.fn("SnapshotService.restore")(function* ( + snapshot: string, + ) { + log.info("restore", { commit: snapshot }); + const result = yield* git( + [...GIT_CORE, ...gitArgs(["read-tree", snapshot])], + { cwd: worktree }, + ); + if (result.code === 0) { + const checkout = yield* git( + [...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], + { cwd: worktree }, + ); + if (checkout.code === 0) return; + log.error("failed to restore snapshot", { + snapshot, + exitCode: checkout.code, + stderr: checkout.stderr, + }); + return; + } + log.error("failed to restore snapshot", { + snapshot, + exitCode: result.code, + stderr: result.stderr, + }); + }); + + const revert = Effect.fn("SnapshotService.revert")(function* ( + patches: Snapshot.Patch[], + ) { + const seen = new Set(); + for (const item of patches) { + for (const file of item.files) { + if (seen.has(file)) continue; + log.info("reverting", { file, hash: item.hash }); + const result = yield* git( + [...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], + { + cwd: worktree, + }, + ); + if (result.code !== 0) { + const relativePath = path.relative(worktree, file); + const checkTree = yield* git( + [ + ...GIT_CORE, + ...gitArgs(["ls-tree", item.hash, "--", relativePath]), + ], + { + cwd: worktree, + }, + ); + if (checkTree.code === 0 && checkTree.text.trim()) { + log.info( + "file existed in snapshot but checkout failed, keeping", + { file }, + ); + } else { + log.info("file did not exist in snapshot, deleting", { file }); + yield* removeFile(file); + } + } + seen.add(file); + } + } + }); + + const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) { + yield* add; + const result = yield* git( + [ + ...GIT_CFG_QUOTE, + ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."]), + ], + { + cwd: worktree, + }, + ); + + if (result.code !== 0) { + log.warn("failed to get diff", { + hash, + exitCode: result.code, + stderr: result.stderr, + }); + return ""; + } + + return result.text.trim(); + }); + + const diffFull = Effect.fn("SnapshotService.diffFull")(function* ( + from: string, + to: string, + ) { + const result: Snapshot.FileDiff[] = []; + const status = new Map(); + + const statuses = yield* git( + [ + ...GIT_CFG_QUOTE, + ...gitArgs([ + "diff", + "--no-ext-diff", + "--name-status", + "--no-renames", + from, + to, + "--", + ".", + ]), + ], + { cwd: directory }, + ); + + for (const line of statuses.text.trim().split("\n")) { + if (!line) continue; + const [code, file] = line.split("\t"); + if (!code || !file) continue; + const kind = code.startsWith("A") + ? "added" + : code.startsWith("D") + ? "deleted" + : "modified"; + status.set(file, kind); + } + + const numstat = yield* git( + [ + ...GIT_CFG_QUOTE, + ...gitArgs([ + "diff", + "--no-ext-diff", + "--no-renames", + "--numstat", + from, + to, + "--", + ".", + ]), + ], + { cwd: directory }, + ); + + for (const line of numstat.text.trim().split("\n")) { + if (!line) continue; + const [additions, deletions, file] = line.split("\t"); + const isBinaryFile = additions === "-" && deletions === "-"; + const [before, after] = isBinaryFile + ? ["", ""] + : yield* Effect.all( + [ + git([ + ...GIT_CFG, + ...gitArgs(["show", `${from}:${file}`]), + ]).pipe(Effect.map((r) => r.text)), + git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe( + Effect.map((r) => r.text), + ), + ], + { concurrency: 2 }, + ); + const added = isBinaryFile ? 0 : parseInt(additions!); + const deleted = isBinaryFile ? 0 : parseInt(deletions!); + result.push({ + file: file!, + before, + after, + additions: Number.isFinite(added) ? added : 0, + deletions: Number.isFinite(deleted) ? deleted : 0, + status: status.get(file!) ?? "modified", + }); + } + return result; + }); + + // Start hourly cleanup fiber — scoped to instance lifetime + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("cleanup loop failed", { cause: Cause.pretty(cause) }); + return Effect.void; + }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.forkScoped, + ); + + return SnapshotService.of({ + init: Effect.fn("SnapshotService.init")(function* () {}), + cleanup, + track, + patch, + restore, + revert, + diff, + diffFull, + }); + }), + ).pipe( + Layer.provide(NodeChildProcessSpawner.layer), + Layer.provide(NodeFileSystem.layer), + Layer.provide(NodePath.layer), + ); } diff --git a/packages/opencode/test/fixture/instance.ts b/packages/opencode/test/fixture/instance.ts index d322e1d9fb..1a7096b635 100644 --- a/packages/opencode/test/fixture/instance.ts +++ b/packages/opencode/test/fixture/instance.ts @@ -1,14 +1,14 @@ -import { ConfigProvider, Layer, ManagedRuntime } from "effect" -import { InstanceContext } from "../../src/effect/instance-context" -import { Instance } from "../../src/project/instance" +import { ConfigProvider, Layer, ManagedRuntime } from "effect"; +import { InstanceContext } from "../../src/effect/instance-context"; +import { Instance } from "../../src/project/instance"; /** ConfigProvider that enables the experimental file watcher. */ export const watcherConfigLayer = ConfigProvider.layer( - ConfigProvider.fromUnknown({ - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", - }), -) + ConfigProvider.fromUnknown({ + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", + }), +); /** * Boot an Instance with the given service layers and run `body` with @@ -19,29 +19,35 @@ export const watcherConfigLayer = ConfigProvider.layer( * Pass extra layers via `options.provide` (e.g. ConfigProvider.layer). */ export function withServices( - directory: string, - layer: Layer.Layer, - body: (rt: ManagedRuntime.ManagedRuntime) => Promise, - options?: { provide?: Layer.Layer[] }, + directory: string, + layer: Layer.Layer, + body: (rt: ManagedRuntime.ManagedRuntime) => Promise, + options?: { provide?: Layer.Layer[] }, ) { - return Instance.provide({ - directory, - fn: async () => { - const ctx = Layer.sync(InstanceContext, () => - InstanceContext.of({ directory: Instance.directory, project: Instance.project }), - ) - let resolved: Layer.Layer = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any - if (options?.provide) { - for (const l of options.provide) { - resolved = resolved.pipe(Layer.provide(l)) as any - } - } - const rt = ManagedRuntime.make(resolved) - try { - await body(rt) - } finally { - await rt.dispose() - } - }, - }) + return Instance.provide({ + directory, + fn: async () => { + const ctx = Layer.sync(InstanceContext, () => + InstanceContext.of({ + directory: Instance.directory, + worktree: Instance.worktree, + project: Instance.project, + }), + ); + let resolved: Layer.Layer = Layer.fresh(layer).pipe( + Layer.provide(ctx), + ) as any; + if (options?.provide) { + for (const l of options.provide) { + resolved = resolved.pipe(Layer.provide(l)) as any; + } + } + const rt = ManagedRuntime.make(resolved); + try { + await body(rt); + } finally { + await rt.dispose(); + } + }, + }); } From bc949af6235703225161d65b286fa9ecdbe27f1c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 18 Mar 2026 01:05:16 +0000 Subject: [PATCH 039/112] chore: generate --- .../opencode/src/effect/instance-context.ts | 21 +- packages/opencode/src/effect/instances.ts | 125 ++- packages/opencode/src/project/instance.ts | 313 ++++--- packages/opencode/src/snapshot/index.ts | 799 ++++++++---------- packages/opencode/test/fixture/instance.ts | 74 +- packages/sdk/js/src/v2/gen/types.gen.ts | 112 +-- packages/sdk/openapi.json | 354 ++++---- 7 files changed, 816 insertions(+), 982 deletions(-) diff --git a/packages/opencode/src/effect/instance-context.ts b/packages/opencode/src/effect/instance-context.ts index af5f9236fc..fd45901904 100644 --- a/packages/opencode/src/effect/instance-context.ts +++ b/packages/opencode/src/effect/instance-context.ts @@ -1,15 +1,14 @@ -import { ServiceMap } from "effect"; -import type { Project } from "@/project/project"; +import { ServiceMap } from "effect" +import type { Project } from "@/project/project" export declare namespace InstanceContext { - export interface Shape { - readonly directory: string; - readonly worktree: string; - readonly project: Project.Info; - } + export interface Shape { + readonly directory: string + readonly worktree: string + readonly project: Project.Info + } } -export class InstanceContext extends ServiceMap.Service< - InstanceContext, - InstanceContext.Shape ->()("opencode/InstanceContext") {} +export class InstanceContext extends ServiceMap.Service()( + "opencode/InstanceContext", +) {} diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 075663f080..16186f7295 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -1,31 +1,31 @@ -import { Effect, Layer, LayerMap, ServiceMap } from "effect"; -import { FileService } from "@/file"; -import { FileTimeService } from "@/file/time"; -import { FileWatcherService } from "@/file/watcher"; -import { FormatService } from "@/format"; -import { PermissionService } from "@/permission/service"; -import { Instance } from "@/project/instance"; -import { VcsService } from "@/project/vcs"; -import { ProviderAuthService } from "@/provider/auth-service"; -import { QuestionService } from "@/question/service"; -import { SkillService } from "@/skill/skill"; -import { SnapshotService } from "@/snapshot"; -import { InstanceContext } from "./instance-context"; -import { registerDisposer } from "./instance-registry"; +import { Effect, Layer, LayerMap, ServiceMap } from "effect" +import { FileService } from "@/file" +import { FileTimeService } from "@/file/time" +import { FileWatcherService } from "@/file/watcher" +import { FormatService } from "@/format" +import { PermissionService } from "@/permission/service" +import { Instance } from "@/project/instance" +import { VcsService } from "@/project/vcs" +import { ProviderAuthService } from "@/provider/auth-service" +import { QuestionService } from "@/question/service" +import { SkillService } from "@/skill/skill" +import { SnapshotService } from "@/snapshot" +import { InstanceContext } from "./instance-context" +import { registerDisposer } from "./instance-registry" -export { InstanceContext } from "./instance-context"; +export { InstanceContext } from "./instance-context" export type InstanceServices = - | QuestionService - | PermissionService - | ProviderAuthService - | FileWatcherService - | VcsService - | FileTimeService - | FormatService - | FileService - | SkillService - | SnapshotService; + | QuestionService + | PermissionService + | ProviderAuthService + | FileWatcherService + | VcsService + | FileTimeService + | FormatService + | FileService + | SkillService + | SnapshotService // NOTE: LayerMap only passes the key (directory string) to lookup, but we need // the full instance context (directory, worktree, project). We read from the @@ -34,50 +34,41 @@ export type InstanceServices = // This should go away once the old Instance type is removed and lookup can load // the full context directly. function lookup(_key: string) { - const ctx = Layer.sync(InstanceContext, () => - InstanceContext.of(Instance.current), - ); - return Layer.mergeAll( - Layer.fresh(QuestionService.layer), - Layer.fresh(PermissionService.layer), - Layer.fresh(ProviderAuthService.layer), - Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), - Layer.fresh(VcsService.layer), - Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), - Layer.fresh(FormatService.layer), - Layer.fresh(FileService.layer), - Layer.fresh(SkillService.layer), - Layer.fresh(SnapshotService.layer), - ).pipe(Layer.provide(ctx)); + const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current)) + return Layer.mergeAll( + Layer.fresh(QuestionService.layer), + Layer.fresh(PermissionService.layer), + Layer.fresh(ProviderAuthService.layer), + Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), + Layer.fresh(VcsService.layer), + Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), + Layer.fresh(FormatService.layer), + Layer.fresh(FileService.layer), + Layer.fresh(SkillService.layer), + Layer.fresh(SnapshotService.layer), + ).pipe(Layer.provide(ctx)) } -export class Instances extends ServiceMap.Service< - Instances, - LayerMap.LayerMap ->()("opencode/Instances") { - static readonly layer = Layer.effect( - Instances, - Effect.gen(function* () { - const layerMap = yield* LayerMap.make(lookup, { - idleTimeToLive: Infinity, - }); - const unregister = registerDisposer((directory) => - Effect.runPromise(layerMap.invalidate(directory)), - ); - yield* Effect.addFinalizer(() => Effect.sync(unregister)); - return Instances.of(layerMap); - }), - ); +export class Instances extends ServiceMap.Service>()( + "opencode/Instances", +) { + static readonly layer = Layer.effect( + Instances, + Effect.gen(function* () { + const layerMap = yield* LayerMap.make(lookup, { + idleTimeToLive: Infinity, + }) + const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory))) + yield* Effect.addFinalizer(() => Effect.sync(unregister)) + return Instances.of(layerMap) + }), + ) - static get( - directory: string, - ): Layer.Layer { - return Layer.unwrap( - Instances.use((map) => Effect.succeed(map.get(directory))), - ); - } + static get(directory: string): Layer.Layer { + return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory)))) + } - static invalidate(directory: string): Effect.Effect { - return Instances.use((map) => map.invalidate(directory)); - } + static invalidate(directory: string): Effect.Effect { + return Instances.use((map) => map.invalidate(directory)) + } } diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 61f6dd7931..6075540161 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,185 +1,166 @@ -import { GlobalBus } from "@/bus/global"; -import { disposeInstance } from "@/effect/instance-registry"; -import { Filesystem } from "@/util/filesystem"; -import { iife } from "@/util/iife"; -import { Log } from "@/util/log"; -import { Context } from "../util/context"; -import { Project } from "./project"; -import { State } from "./state"; +import { GlobalBus } from "@/bus/global" +import { disposeInstance } from "@/effect/instance-registry" +import { Filesystem } from "@/util/filesystem" +import { iife } from "@/util/iife" +import { Log } from "@/util/log" +import { Context } from "../util/context" +import { Project } from "./project" +import { State } from "./state" interface Context { - directory: string; - worktree: string; - project: Project.Info; + directory: string + worktree: string + project: Project.Info } -const context = Context.create("instance"); -const cache = new Map>(); +const context = Context.create("instance") +const cache = new Map>() const disposal = { - all: undefined as Promise | undefined, -}; - -function emit(directory: string) { - GlobalBus.emit("event", { - directory, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }); + all: undefined as Promise | undefined, } -function boot(input: { - directory: string; - init?: () => Promise; - project?: Project.Info; - worktree?: string; -}) { - return iife(async () => { - const ctx = - input.project && input.worktree - ? { - directory: input.directory, - worktree: input.worktree, - project: input.project, - } - : await Project.fromDirectory(input.directory).then( - ({ project, sandbox }) => ({ - directory: input.directory, - worktree: sandbox, - project, - }), - ); - await context.provide(ctx, async () => { - await input.init?.(); - }); - return ctx; - }); +function emit(directory: string) { + GlobalBus.emit("event", { + directory, + payload: { + type: "server.instance.disposed", + properties: { + directory, + }, + }, + }) +} + +function boot(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { + return iife(async () => { + const ctx = + input.project && input.worktree + ? { + directory: input.directory, + worktree: input.worktree, + project: input.project, + } + : await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({ + directory: input.directory, + worktree: sandbox, + project, + })) + await context.provide(ctx, async () => { + await input.init?.() + }) + return ctx + }) } function track(directory: string, next: Promise) { - const task = next.catch((error) => { - if (cache.get(directory) === task) cache.delete(directory); - throw error; - }); - cache.set(directory, task); - return task; + const task = next.catch((error) => { + if (cache.get(directory) === task) cache.delete(directory) + throw error + }) + cache.set(directory, task) + return task } export const Instance = { - async provide(input: { - directory: string; - init?: () => Promise; - fn: () => R; - }): Promise { - const directory = Filesystem.resolve(input.directory); - let existing = cache.get(directory); - if (!existing) { - Log.Default.info("creating instance", { directory }); - existing = track( - directory, - boot({ - directory, - init: input.init, - }), - ); - } - const ctx = await existing; - return context.provide(ctx, async () => { - return input.fn(); - }); - }, - get current() { - return context.use(); - }, - get directory() { - return context.use().directory; - }, - get worktree() { - return context.use().worktree; - }, - get project() { - return context.use().project; - }, - /** - * Check if a path is within the project boundary. - * Returns true if path is inside Instance.directory OR Instance.worktree. - * Paths within the worktree but outside the working directory should not trigger external_directory permission. - */ - containsPath(filepath: string) { - if (Filesystem.contains(Instance.directory, filepath)) return true; - // Non-git projects set worktree to "/" which would match ANY absolute path. - // Skip worktree check in this case to preserve external_directory permissions. - if (Instance.worktree === "/") return false; - return Filesystem.contains(Instance.worktree, filepath); - }, - /** - * Captures the current instance ALS context and returns a wrapper that - * restores it when called. Use this for callbacks that fire outside the - * instance async context (native addons, event emitters, timers, etc.). - */ - bind any>(fn: F): F { - const ctx = context.use(); - return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F; - }, - state( - init: () => S, - dispose?: (state: Awaited) => Promise, - ): () => S { - return State.create(() => Instance.directory, init, dispose); - }, - async reload(input: { - directory: string; - init?: () => Promise; - project?: Project.Info; - worktree?: string; - }) { - const directory = Filesystem.resolve(input.directory); - Log.Default.info("reloading instance", { directory }); - await Promise.all([State.dispose(directory), disposeInstance(directory)]); - cache.delete(directory); - const next = track(directory, boot({ ...input, directory })); - emit(directory); - return await next; - }, - async dispose() { - const directory = Instance.directory; - Log.Default.info("disposing instance", { directory }); - await Promise.all([State.dispose(directory), disposeInstance(directory)]); - cache.delete(directory); - emit(directory); - }, - async disposeAll() { - if (disposal.all) return disposal.all; + async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { + const directory = Filesystem.resolve(input.directory) + let existing = cache.get(directory) + if (!existing) { + Log.Default.info("creating instance", { directory }) + existing = track( + directory, + boot({ + directory, + init: input.init, + }), + ) + } + const ctx = await existing + return context.provide(ctx, async () => { + return input.fn() + }) + }, + get current() { + return context.use() + }, + get directory() { + return context.use().directory + }, + get worktree() { + return context.use().worktree + }, + get project() { + return context.use().project + }, + /** + * Check if a path is within the project boundary. + * Returns true if path is inside Instance.directory OR Instance.worktree. + * Paths within the worktree but outside the working directory should not trigger external_directory permission. + */ + containsPath(filepath: string) { + if (Filesystem.contains(Instance.directory, filepath)) return true + // Non-git projects set worktree to "/" which would match ANY absolute path. + // Skip worktree check in this case to preserve external_directory permissions. + if (Instance.worktree === "/") return false + return Filesystem.contains(Instance.worktree, filepath) + }, + /** + * Captures the current instance ALS context and returns a wrapper that + * restores it when called. Use this for callbacks that fire outside the + * instance async context (native addons, event emitters, timers, etc.). + */ + bind any>(fn: F): F { + const ctx = context.use() + return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F + }, + state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { + return State.create(() => Instance.directory, init, dispose) + }, + async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { + const directory = Filesystem.resolve(input.directory) + Log.Default.info("reloading instance", { directory }) + await Promise.all([State.dispose(directory), disposeInstance(directory)]) + cache.delete(directory) + const next = track(directory, boot({ ...input, directory })) + emit(directory) + return await next + }, + async dispose() { + const directory = Instance.directory + Log.Default.info("disposing instance", { directory }) + await Promise.all([State.dispose(directory), disposeInstance(directory)]) + cache.delete(directory) + emit(directory) + }, + async disposeAll() { + if (disposal.all) return disposal.all - disposal.all = iife(async () => { - Log.Default.info("disposing all instances"); - const entries = [...cache.entries()]; - for (const [key, value] of entries) { - if (cache.get(key) !== value) continue; + disposal.all = iife(async () => { + Log.Default.info("disposing all instances") + const entries = [...cache.entries()] + for (const [key, value] of entries) { + if (cache.get(key) !== value) continue - const ctx = await value.catch((error) => { - Log.Default.warn("instance dispose failed", { key, error }); - return undefined; - }); + const ctx = await value.catch((error) => { + Log.Default.warn("instance dispose failed", { key, error }) + return undefined + }) - if (!ctx) { - if (cache.get(key) === value) cache.delete(key); - continue; - } + if (!ctx) { + if (cache.get(key) === value) cache.delete(key) + continue + } - if (cache.get(key) !== value) continue; + if (cache.get(key) !== value) continue - await context.provide(ctx, async () => { - await Instance.dispose(); - }); - } - }).finally(() => { - disposal.all = undefined; - }); + await context.provide(ctx, async () => { + await Instance.dispose() + }) + } + }).finally(() => { + disposal.all = undefined + }) - return disposal.all; - }, -}; + return disposal.all + }, +} diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index ccba830b81..a9489451c4 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,516 +1,381 @@ -import { - NodeChildProcessSpawner, - NodeFileSystem, - NodePath, -} from "@effect/platform-node"; -import { - Cause, - Duration, - Effect, - FileSystem, - Layer, - Schedule, - ServiceMap, - Stream, -} from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import path from "path"; -import z from "zod"; -import { InstanceContext } from "@/effect/instance-context"; -import { runPromiseInstance } from "@/effect/runtime"; -import { Config } from "../config/config"; -import { Global } from "../global"; -import { Log } from "../util/log"; +import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import path from "path" +import z from "zod" +import { InstanceContext } from "@/effect/instance-context" +import { runPromiseInstance } from "@/effect/runtime" +import { Config } from "../config/config" +import { Global } from "../global" +import { Log } from "../util/log" -const log = Log.create({ service: "snapshot" }); -const PRUNE = "7.days"; +const log = Log.create({ service: "snapshot" }) +const PRUNE = "7.days" // Common git config flags shared across snapshot operations -const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]; -const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE]; -const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"]; +const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] +const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE] +const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"] interface GitResult { - readonly code: ChildProcessSpawner.ExitCode; - readonly text: string; - readonly stderr: string; + readonly code: ChildProcessSpawner.ExitCode + readonly text: string + readonly stderr: string } export namespace Snapshot { - export const Patch = z.object({ - hash: z.string(), - files: z.string().array(), - }); - export type Patch = z.infer; + export const Patch = z.object({ + hash: z.string(), + files: z.string().array(), + }) + export type Patch = z.infer - export const FileDiff = z - .object({ - file: z.string(), - before: z.string(), - after: z.string(), - additions: z.number(), - deletions: z.number(), - status: z.enum(["added", "deleted", "modified"]).optional(), - }) - .meta({ - ref: "FileDiff", - }); - export type FileDiff = z.infer; + export const FileDiff = z + .object({ + file: z.string(), + before: z.string(), + after: z.string(), + additions: z.number(), + deletions: z.number(), + status: z.enum(["added", "deleted", "modified"]).optional(), + }) + .meta({ + ref: "FileDiff", + }) + export type FileDiff = z.infer - // Promise facade — existing callers use these - export function init() { - void runPromiseInstance(SnapshotService.use((s) => s.init())); - } + // Promise facade — existing callers use these + export function init() { + void runPromiseInstance(SnapshotService.use((s) => s.init())) + } - export async function cleanup() { - return runPromiseInstance(SnapshotService.use((s) => s.cleanup())); - } + export async function cleanup() { + return runPromiseInstance(SnapshotService.use((s) => s.cleanup())) + } - export async function track() { - return runPromiseInstance(SnapshotService.use((s) => s.track())); - } + export async function track() { + return runPromiseInstance(SnapshotService.use((s) => s.track())) + } - export async function patch(hash: string) { - return runPromiseInstance(SnapshotService.use((s) => s.patch(hash))); - } + export async function patch(hash: string) { + return runPromiseInstance(SnapshotService.use((s) => s.patch(hash))) + } - export async function restore(snapshot: string) { - return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot))); - } + export async function restore(snapshot: string) { + return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot))) + } - export async function revert(patches: Patch[]) { - return runPromiseInstance(SnapshotService.use((s) => s.revert(patches))); - } + export async function revert(patches: Patch[]) { + return runPromiseInstance(SnapshotService.use((s) => s.revert(patches))) + } - export async function diff(hash: string) { - return runPromiseInstance(SnapshotService.use((s) => s.diff(hash))); - } + export async function diff(hash: string) { + return runPromiseInstance(SnapshotService.use((s) => s.diff(hash))) + } - export async function diffFull(from: string, to: string) { - return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to))); - } + export async function diffFull(from: string, to: string) { + return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to))) + } } export namespace SnapshotService { - export interface Service { - readonly init: () => Effect.Effect; - readonly cleanup: () => Effect.Effect; - readonly track: () => Effect.Effect; - readonly patch: (hash: string) => Effect.Effect; - readonly restore: (snapshot: string) => Effect.Effect; - readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect; - readonly diff: (hash: string) => Effect.Effect; - readonly diffFull: ( - from: string, - to: string, - ) => Effect.Effect; - } + export interface Service { + readonly init: () => Effect.Effect + readonly cleanup: () => Effect.Effect + readonly track: () => Effect.Effect + readonly patch: (hash: string) => Effect.Effect + readonly restore: (snapshot: string) => Effect.Effect + readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect + readonly diff: (hash: string) => Effect.Effect + readonly diffFull: (from: string, to: string) => Effect.Effect + } } -export class SnapshotService extends ServiceMap.Service< - SnapshotService, - SnapshotService.Service ->()("@opencode/Snapshot") { - static readonly layer = Layer.effect( - SnapshotService, - Effect.gen(function* () { - const ctx = yield* InstanceContext; - const fileSystem = yield* FileSystem.FileSystem; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const { directory, worktree, project } = ctx; - const isGit = project.vcs === "git"; - const snapshotGit = path.join(Global.Path.data, "snapshot", project.id); +export class SnapshotService extends ServiceMap.Service()( + "@opencode/Snapshot", +) { + static readonly layer = Layer.effect( + SnapshotService, + Effect.gen(function* () { + const ctx = yield* InstanceContext + const fileSystem = yield* FileSystem.FileSystem + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const { directory, worktree, project } = ctx + const isGit = project.vcs === "git" + const snapshotGit = path.join(Global.Path.data, "snapshot", project.id) - const gitArgs = (cmd: string[]) => [ - "--git-dir", - snapshotGit, - "--work-tree", - worktree, - ...cmd, - ]; + const gitArgs = (cmd: string[]) => ["--git-dir", snapshotGit, "--work-tree", worktree, ...cmd] - // Run git with nothrow semantics — always returns a result, never fails - const git = ( - args: string[], - opts?: { cwd?: string; env?: Record }, - ): Effect.Effect => - Effect.gen(function* () { - const command = ChildProcess.make("git", args, { - cwd: opts?.cwd, - env: opts?.env, - extendEnv: true, - }); - const handle = yield* spawner.spawn(command); - const [text, stderr] = yield* Effect.all( - [ - Stream.mkString(Stream.decodeText(handle.stdout)), - Stream.mkString(Stream.decodeText(handle.stderr)), - ], - { concurrency: 2 }, - ); - const code = yield* handle.exitCode; - return { code, text, stderr }; - }).pipe( - Effect.scoped, - Effect.catch((err) => - Effect.succeed({ - code: ChildProcessSpawner.ExitCode(1), - text: "", - stderr: String(err), - }), - ), - ); + // Run git with nothrow semantics — always returns a result, never fails + const git = (args: string[], opts?: { cwd?: string; env?: Record }): Effect.Effect => + Effect.gen(function* () { + const command = ChildProcess.make("git", args, { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }) + const handle = yield* spawner.spawn(command) + const [text, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, text, stderr } + }).pipe( + Effect.scoped, + Effect.catch((err) => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(1), + text: "", + stderr: String(err), + }), + ), + ) - // FileSystem helpers — orDie converts PlatformError to defects - const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie); - const mkdir = (p: string) => - fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie); - const writeFile = (p: string, content: string) => - fileSystem.writeFileString(p, content).pipe(Effect.orDie); - const readFile = (p: string) => - fileSystem - .readFileString(p) - .pipe(Effect.catch(() => Effect.succeed(""))); - const removeFile = (p: string) => - fileSystem.remove(p).pipe(Effect.catch(() => Effect.void)); + // FileSystem helpers — orDie converts PlatformError to defects + const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie) + const mkdir = (p: string) => fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie) + const writeFile = (p: string, content: string) => fileSystem.writeFileString(p, content).pipe(Effect.orDie) + const readFile = (p: string) => fileSystem.readFileString(p).pipe(Effect.catch(() => Effect.succeed(""))) + const removeFile = (p: string) => fileSystem.remove(p).pipe(Effect.catch(() => Effect.void)) - // --- internal Effect helpers --- + // --- internal Effect helpers --- - const isEnabled = Effect.gen(function* () { - if (!isGit) return false; - const cfg = yield* Effect.promise(() => Config.get()); - return cfg.snapshot !== false; - }); + const isEnabled = Effect.gen(function* () { + if (!isGit) return false + const cfg = yield* Effect.promise(() => Config.get()) + return cfg.snapshot !== false + }) - const excludesPath = Effect.gen(function* () { - const result = yield* git( - ["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], - { - cwd: worktree, - }, - ); - const file = result.text.trim(); - if (!file) return undefined; - if (!(yield* exists(file))) return undefined; - return file; - }); + const excludesPath = Effect.gen(function* () { + const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { + cwd: worktree, + }) + const file = result.text.trim() + if (!file) return undefined + if (!(yield* exists(file))) return undefined + return file + }) - const syncExclude = Effect.gen(function* () { - const file = yield* excludesPath; - const target = path.join(snapshotGit, "info", "exclude"); - yield* mkdir(path.join(snapshotGit, "info")); - if (!file) { - yield* writeFile(target, ""); - return; - } - const text = yield* readFile(file); - yield* writeFile(target, text); - }); + const syncExclude = Effect.gen(function* () { + const file = yield* excludesPath + const target = path.join(snapshotGit, "info", "exclude") + yield* mkdir(path.join(snapshotGit, "info")) + if (!file) { + yield* writeFile(target, "") + return + } + const text = yield* readFile(file) + yield* writeFile(target, text) + }) - const add = Effect.gen(function* () { - yield* syncExclude; - yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory }); - }); + const add = Effect.gen(function* () { + yield* syncExclude + yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory }) + }) - // --- service methods --- + // --- service methods --- - const cleanup = Effect.fn("SnapshotService.cleanup")(function* () { - if (!(yield* isEnabled)) return; - if (!(yield* exists(snapshotGit))) return; - const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), { - cwd: directory, - }); - if (result.code !== 0) { - log.warn("cleanup failed", { - exitCode: result.code, - stderr: result.stderr, - }); - return; - } - log.info("cleanup", { prune: PRUNE }); - }); + const cleanup = Effect.fn("SnapshotService.cleanup")(function* () { + if (!(yield* isEnabled)) return + if (!(yield* exists(snapshotGit))) return + const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), { + cwd: directory, + }) + if (result.code !== 0) { + log.warn("cleanup failed", { + exitCode: result.code, + stderr: result.stderr, + }) + return + } + log.info("cleanup", { prune: PRUNE }) + }) - const track = Effect.fn("SnapshotService.track")(function* () { - if (!(yield* isEnabled)) return undefined; - const existed = yield* exists(snapshotGit); - yield* mkdir(snapshotGit); - if (!existed) { - yield* git(["init"], { - env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree }, - }); - yield* git([ - "--git-dir", - snapshotGit, - "config", - "core.autocrlf", - "false", - ]); - yield* git([ - "--git-dir", - snapshotGit, - "config", - "core.longpaths", - "true", - ]); - yield* git([ - "--git-dir", - snapshotGit, - "config", - "core.symlinks", - "true", - ]); - yield* git([ - "--git-dir", - snapshotGit, - "config", - "core.fsmonitor", - "false", - ]); - log.info("initialized"); - } - yield* add; - const result = yield* git(gitArgs(["write-tree"]), { cwd: directory }); - const hash = result.text.trim(); - log.info("tracking", { hash, cwd: directory, git: snapshotGit }); - return hash; - }); + const track = Effect.fn("SnapshotService.track")(function* () { + if (!(yield* isEnabled)) return undefined + const existed = yield* exists(snapshotGit) + yield* mkdir(snapshotGit) + if (!existed) { + yield* git(["init"], { + env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree }, + }) + yield* git(["--git-dir", snapshotGit, "config", "core.autocrlf", "false"]) + yield* git(["--git-dir", snapshotGit, "config", "core.longpaths", "true"]) + yield* git(["--git-dir", snapshotGit, "config", "core.symlinks", "true"]) + yield* git(["--git-dir", snapshotGit, "config", "core.fsmonitor", "false"]) + log.info("initialized") + } + yield* add + const result = yield* git(gitArgs(["write-tree"]), { cwd: directory }) + const hash = result.text.trim() + log.info("tracking", { hash, cwd: directory, git: snapshotGit }) + return hash + }) - const patch = Effect.fn("SnapshotService.patch")(function* ( - hash: string, - ) { - yield* add; - const result = yield* git( - [ - ...GIT_CFG_QUOTE, - ...gitArgs([ - "diff", - "--no-ext-diff", - "--name-only", - hash, - "--", - ".", - ]), - ], - { cwd: directory }, - ); + const patch = Effect.fn("SnapshotService.patch")(function* (hash: string) { + yield* add + const result = yield* git( + [...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], + { cwd: directory }, + ) - if (result.code !== 0) { - log.warn("failed to get diff", { hash, exitCode: result.code }); - return { hash, files: [] } as Snapshot.Patch; - } + if (result.code !== 0) { + log.warn("failed to get diff", { hash, exitCode: result.code }) + return { hash, files: [] } as Snapshot.Patch + } - return { - hash, - files: result.text - .trim() - .split("\n") - .map((x: string) => x.trim()) - .filter(Boolean) - .map((x: string) => path.join(worktree, x).replaceAll("\\", "/")), - } as Snapshot.Patch; - }); + return { + hash, + files: result.text + .trim() + .split("\n") + .map((x: string) => x.trim()) + .filter(Boolean) + .map((x: string) => path.join(worktree, x).replaceAll("\\", "/")), + } as Snapshot.Patch + }) - const restore = Effect.fn("SnapshotService.restore")(function* ( - snapshot: string, - ) { - log.info("restore", { commit: snapshot }); - const result = yield* git( - [...GIT_CORE, ...gitArgs(["read-tree", snapshot])], - { cwd: worktree }, - ); - if (result.code === 0) { - const checkout = yield* git( - [...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], - { cwd: worktree }, - ); - if (checkout.code === 0) return; - log.error("failed to restore snapshot", { - snapshot, - exitCode: checkout.code, - stderr: checkout.stderr, - }); - return; - } - log.error("failed to restore snapshot", { - snapshot, - exitCode: result.code, - stderr: result.stderr, - }); - }); + const restore = Effect.fn("SnapshotService.restore")(function* (snapshot: string) { + log.info("restore", { commit: snapshot }) + const result = yield* git([...GIT_CORE, ...gitArgs(["read-tree", snapshot])], { cwd: worktree }) + if (result.code === 0) { + const checkout = yield* git([...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], { cwd: worktree }) + if (checkout.code === 0) return + log.error("failed to restore snapshot", { + snapshot, + exitCode: checkout.code, + stderr: checkout.stderr, + }) + return + } + log.error("failed to restore snapshot", { + snapshot, + exitCode: result.code, + stderr: result.stderr, + }) + }) - const revert = Effect.fn("SnapshotService.revert")(function* ( - patches: Snapshot.Patch[], - ) { - const seen = new Set(); - for (const item of patches) { - for (const file of item.files) { - if (seen.has(file)) continue; - log.info("reverting", { file, hash: item.hash }); - const result = yield* git( - [...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], - { - cwd: worktree, - }, - ); - if (result.code !== 0) { - const relativePath = path.relative(worktree, file); - const checkTree = yield* git( - [ - ...GIT_CORE, - ...gitArgs(["ls-tree", item.hash, "--", relativePath]), - ], - { - cwd: worktree, - }, - ); - if (checkTree.code === 0 && checkTree.text.trim()) { - log.info( - "file existed in snapshot but checkout failed, keeping", - { file }, - ); - } else { - log.info("file did not exist in snapshot, deleting", { file }); - yield* removeFile(file); - } - } - seen.add(file); - } - } - }); + const revert = Effect.fn("SnapshotService.revert")(function* (patches: Snapshot.Patch[]) { + const seen = new Set() + for (const item of patches) { + for (const file of item.files) { + if (seen.has(file)) continue + log.info("reverting", { file, hash: item.hash }) + const result = yield* git([...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], { + cwd: worktree, + }) + if (result.code !== 0) { + const relativePath = path.relative(worktree, file) + const checkTree = yield* git([...GIT_CORE, ...gitArgs(["ls-tree", item.hash, "--", relativePath])], { + cwd: worktree, + }) + if (checkTree.code === 0 && checkTree.text.trim()) { + log.info("file existed in snapshot but checkout failed, keeping", { file }) + } else { + log.info("file did not exist in snapshot, deleting", { file }) + yield* removeFile(file) + } + } + seen.add(file) + } + } + }) - const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) { - yield* add; - const result = yield* git( - [ - ...GIT_CFG_QUOTE, - ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."]), - ], - { - cwd: worktree, - }, - ); + const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) { + yield* add + const result = yield* git([...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."])], { + cwd: worktree, + }) - if (result.code !== 0) { - log.warn("failed to get diff", { - hash, - exitCode: result.code, - stderr: result.stderr, - }); - return ""; - } + if (result.code !== 0) { + log.warn("failed to get diff", { + hash, + exitCode: result.code, + stderr: result.stderr, + }) + return "" + } - return result.text.trim(); - }); + return result.text.trim() + }) - const diffFull = Effect.fn("SnapshotService.diffFull")(function* ( - from: string, - to: string, - ) { - const result: Snapshot.FileDiff[] = []; - const status = new Map(); + const diffFull = Effect.fn("SnapshotService.diffFull")(function* (from: string, to: string) { + const result: Snapshot.FileDiff[] = [] + const status = new Map() - const statuses = yield* git( - [ - ...GIT_CFG_QUOTE, - ...gitArgs([ - "diff", - "--no-ext-diff", - "--name-status", - "--no-renames", - from, - to, - "--", - ".", - ]), - ], - { cwd: directory }, - ); + const statuses = yield* git( + [ + ...GIT_CFG_QUOTE, + ...gitArgs(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]), + ], + { cwd: directory }, + ) - for (const line of statuses.text.trim().split("\n")) { - if (!line) continue; - const [code, file] = line.split("\t"); - if (!code || !file) continue; - const kind = code.startsWith("A") - ? "added" - : code.startsWith("D") - ? "deleted" - : "modified"; - status.set(file, kind); - } + for (const line of statuses.text.trim().split("\n")) { + if (!line) continue + const [code, file] = line.split("\t") + if (!code || !file) continue + const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified" + status.set(file, kind) + } - const numstat = yield* git( - [ - ...GIT_CFG_QUOTE, - ...gitArgs([ - "diff", - "--no-ext-diff", - "--no-renames", - "--numstat", - from, - to, - "--", - ".", - ]), - ], - { cwd: directory }, - ); + const numstat = yield* git( + [...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], + { cwd: directory }, + ) - for (const line of numstat.text.trim().split("\n")) { - if (!line) continue; - const [additions, deletions, file] = line.split("\t"); - const isBinaryFile = additions === "-" && deletions === "-"; - const [before, after] = isBinaryFile - ? ["", ""] - : yield* Effect.all( - [ - git([ - ...GIT_CFG, - ...gitArgs(["show", `${from}:${file}`]), - ]).pipe(Effect.map((r) => r.text)), - git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe( - Effect.map((r) => r.text), - ), - ], - { concurrency: 2 }, - ); - const added = isBinaryFile ? 0 : parseInt(additions!); - const deleted = isBinaryFile ? 0 : parseInt(deletions!); - result.push({ - file: file!, - before, - after, - additions: Number.isFinite(added) ? added : 0, - deletions: Number.isFinite(deleted) ? deleted : 0, - status: status.get(file!) ?? "modified", - }); - } - return result; - }); + for (const line of numstat.text.trim().split("\n")) { + if (!line) continue + const [additions, deletions, file] = line.split("\t") + const isBinaryFile = additions === "-" && deletions === "-" + const [before, after] = isBinaryFile + ? ["", ""] + : yield* Effect.all( + [ + git([...GIT_CFG, ...gitArgs(["show", `${from}:${file}`])]).pipe(Effect.map((r) => r.text)), + git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(Effect.map((r) => r.text)), + ], + { concurrency: 2 }, + ) + const added = isBinaryFile ? 0 : parseInt(additions!) + const deleted = isBinaryFile ? 0 : parseInt(deletions!) + result.push({ + file: file!, + before, + after, + additions: Number.isFinite(added) ? added : 0, + deletions: Number.isFinite(deleted) ? deleted : 0, + status: status.get(file!) ?? "modified", + }) + } + return result + }) - // Start hourly cleanup fiber — scoped to instance lifetime - yield* cleanup().pipe( - Effect.catchCause((cause) => { - log.error("cleanup loop failed", { cause: Cause.pretty(cause) }); - return Effect.void; - }), - Effect.repeat(Schedule.spaced(Duration.hours(1))), - Effect.forkScoped, - ); + // Start hourly cleanup fiber — scoped to instance lifetime + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) + return Effect.void + }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.forkScoped, + ) - return SnapshotService.of({ - init: Effect.fn("SnapshotService.init")(function* () {}), - cleanup, - track, - patch, - restore, - revert, - diff, - diffFull, - }); - }), - ).pipe( - Layer.provide(NodeChildProcessSpawner.layer), - Layer.provide(NodeFileSystem.layer), - Layer.provide(NodePath.layer), - ); + return SnapshotService.of({ + init: Effect.fn("SnapshotService.init")(function* () {}), + cleanup, + track, + patch, + restore, + revert, + diff, + diffFull, + }) + }), + ).pipe( + Layer.provide(NodeChildProcessSpawner.layer), + Layer.provide(NodeFileSystem.layer), + Layer.provide(NodePath.layer), + ) } diff --git a/packages/opencode/test/fixture/instance.ts b/packages/opencode/test/fixture/instance.ts index 1a7096b635..ce880d70d9 100644 --- a/packages/opencode/test/fixture/instance.ts +++ b/packages/opencode/test/fixture/instance.ts @@ -1,14 +1,14 @@ -import { ConfigProvider, Layer, ManagedRuntime } from "effect"; -import { InstanceContext } from "../../src/effect/instance-context"; -import { Instance } from "../../src/project/instance"; +import { ConfigProvider, Layer, ManagedRuntime } from "effect" +import { InstanceContext } from "../../src/effect/instance-context" +import { Instance } from "../../src/project/instance" /** ConfigProvider that enables the experimental file watcher. */ export const watcherConfigLayer = ConfigProvider.layer( - ConfigProvider.fromUnknown({ - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", - }), -); + ConfigProvider.fromUnknown({ + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", + }), +) /** * Boot an Instance with the given service layers and run `body` with @@ -19,35 +19,33 @@ export const watcherConfigLayer = ConfigProvider.layer( * Pass extra layers via `options.provide` (e.g. ConfigProvider.layer). */ export function withServices( - directory: string, - layer: Layer.Layer, - body: (rt: ManagedRuntime.ManagedRuntime) => Promise, - options?: { provide?: Layer.Layer[] }, + directory: string, + layer: Layer.Layer, + body: (rt: ManagedRuntime.ManagedRuntime) => Promise, + options?: { provide?: Layer.Layer[] }, ) { - return Instance.provide({ - directory, - fn: async () => { - const ctx = Layer.sync(InstanceContext, () => - InstanceContext.of({ - directory: Instance.directory, - worktree: Instance.worktree, - project: Instance.project, - }), - ); - let resolved: Layer.Layer = Layer.fresh(layer).pipe( - Layer.provide(ctx), - ) as any; - if (options?.provide) { - for (const l of options.provide) { - resolved = resolved.pipe(Layer.provide(l)) as any; - } - } - const rt = ManagedRuntime.make(resolved); - try { - await body(rt); - } finally { - await rt.dispose(); - } - }, - }); + return Instance.provide({ + directory, + fn: async () => { + const ctx = Layer.sync(InstanceContext, () => + InstanceContext.of({ + directory: Instance.directory, + worktree: Instance.worktree, + project: Instance.project, + }), + ) + let resolved: Layer.Layer = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any + if (options?.provide) { + for (const l of options.provide) { + resolved = resolved.pipe(Layer.provide(l)) as any + } + } + const rt = ManagedRuntime.make(resolved) + try { + await body(rt) + } finally { + await rt.dispose() + } + }, + }) } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fd80a51a21..9c5ca274ed 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -47,6 +47,13 @@ export type EventProjectUpdated = { properties: Project } +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + export type EventServerInstanceDisposed = { type: "server.instance.disposed" properties: { @@ -54,6 +61,50 @@ export type EventServerInstanceDisposed = { } } +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type PermissionRequest = { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } +} + +export type EventPermissionAsked = { + type: "permission.asked" + properties: PermissionRequest +} + +export type EventPermissionReplied = { + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + +export type EventVcsBranchUpdated = { + type: "vcs.branch.updated" + properties: { + branch?: string + } +} + export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -125,57 +176,6 @@ export type EventQuestionRejected = { } } -export type PermissionRequest = { - id: string - sessionID: string - permission: string - patterns: Array - metadata: { - [key: string]: unknown - } - always: Array - tool?: { - messageID: string - callID: string - } -} - -export type EventPermissionAsked = { - type: "permission.asked" - properties: PermissionRequest -} - -export type EventPermissionReplied = { - type: "permission.replied" - properties: { - sessionID: string - requestID: string - reply: "once" | "always" | "reject" - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - -export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" - properties: { - branch?: string - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - export type EventServerConnected = { type: "server.connected" properties: { @@ -961,15 +961,15 @@ export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable | EventProjectUpdated + | EventFileEdited | EventServerInstanceDisposed + | EventFileWatcherUpdated + | EventPermissionAsked + | EventPermissionReplied + | EventVcsBranchUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected - | EventPermissionAsked - | EventPermissionReplied - | EventFileWatcherUpdated - | EventVcsBranchUpdated - | EventFileEdited | EventServerConnected | EventGlobalDisposed | EventLspClientDiagnostics diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 2f7e9952ed..c6d79b11e8 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7043,6 +7043,25 @@ }, "required": ["type", "properties"] }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, "Event.server.instance.disposed": { "type": "object", "properties": { @@ -7062,6 +7081,149 @@ }, "required": ["type", "properties"] }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "anyOf": [ + { + "type": "string", + "const": "add" + }, + { + "type": "string", + "const": "change" + }, + { + "type": "string", + "const": "unlink" + } + ] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, + "PermissionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] + }, + "Event.permission.asked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "permission.asked" + }, + "properties": { + "$ref": "#/components/schemas/PermissionRequest" + } + }, + "required": ["type", "properties"] + }, + "Event.permission.replied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "permission.replied" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^per.*" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"] + } + }, + "required": ["type", "properties"] + }, + "Event.vcs.branch.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "vcs.branch.updated" + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + } + } + }, + "required": ["type", "properties"] + }, "QuestionOption": { "type": "object", "properties": { @@ -7212,168 +7374,6 @@ }, "required": ["type", "properties"] }, - "PermissionRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^per.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "permission": { - "type": "string" - }, - "patterns": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "always": { - "type": "array", - "items": { - "type": "string" - } - }, - "tool": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "callID": { - "type": "string" - } - }, - "required": ["messageID", "callID"] - } - }, - "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] - }, - "Event.permission.asked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.asked" - }, - "properties": { - "$ref": "#/components/schemas/PermissionRequest" - } - }, - "required": ["type", "properties"] - }, - "Event.permission.replied": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.replied" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^per.*" - }, - "reply": { - "type": "string", - "enum": ["once", "always", "reject"] - } - }, - "required": ["sessionID", "requestID", "reply"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "anyOf": [ - { - "type": "string", - "const": "add" - }, - { - "type": "string", - "const": "change" - }, - { - "type": "string", - "const": "unlink" - } - ] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, - "Event.vcs.branch.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "vcs.branch.updated" - }, - "properties": { - "type": "object", - "properties": { - "branch": { - "type": "string" - } - } - } - }, - "required": ["type", "properties"] - }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, "Event.server.connected": { "type": "object", "properties": { @@ -9608,9 +9608,24 @@ { "$ref": "#/components/schemas/Event.project.updated" }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, { "$ref": "#/components/schemas/Event.server.instance.disposed" }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, + { + "$ref": "#/components/schemas/Event.permission.asked" + }, + { + "$ref": "#/components/schemas/Event.permission.replied" + }, + { + "$ref": "#/components/schemas/Event.vcs.branch.updated" + }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -9620,21 +9635,6 @@ { "$ref": "#/components/schemas/Event.question.rejected" }, - { - "$ref": "#/components/schemas/Event.permission.asked" - }, - { - "$ref": "#/components/schemas/Event.permission.replied" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, - { - "$ref": "#/components/schemas/Event.vcs.branch.updated" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, { "$ref": "#/components/schemas/Event.server.connected" }, From 4b4dd2b882380919609340c404bdcb221cf457a3 Mon Sep 17 00:00:00 2001 From: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:11:42 -0400 Subject: [PATCH 040/112] fix: Add apply_patch to EDIT_TOOLS filter (#18009) --- packages/opencode/src/permission/next.ts | 2 +- packages/opencode/test/permission/next.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 6a65a6f2e9..a6db552222 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -69,7 +69,7 @@ export namespace PermissionNext { return S.evaluate(permission, pattern, ...rulesets) } - const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"] + const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"] export function disabled(tools: string[], ruleset: Ruleset): Set { const result = new Set() diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 7f7e5e1f1f..b9845ae267 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -395,9 +395,9 @@ test("disabled - disables tool when denied", () => { expect(result.has("read")).toBe(false) }) -test("disabled - disables edit/write/patch/multiedit when edit denied", () => { +test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => { const result = PermissionNext.disabled( - ["edit", "write", "patch", "multiedit", "bash"], + ["edit", "write", "apply_patch", "multiedit", "bash"], [ { permission: "*", pattern: "*", action: "allow" }, { permission: "edit", pattern: "*", action: "deny" }, @@ -405,7 +405,7 @@ test("disabled - disables edit/write/patch/multiedit when edit denied", () => { ) expect(result.has("edit")).toBe(true) expect(result.has("write")).toBe(true) - expect(result.has("patch")).toBe(true) + expect(result.has("apply_patch")).toBe(true) expect(result.has("multiedit")).toBe(true) expect(result.has("bash")).toBe(false) }) From 5dfe86dcb17632cd717288500120a61843d1b071 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 17 Mar 2026 21:59:54 -0400 Subject: [PATCH 041/112] refactor(truncation): effectify TruncateService, delete Scheduler (#17957) --- packages/opencode/src/agent/agent.ts | 4 +- packages/opencode/src/cli/cmd/debug/agent.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 2 +- packages/opencode/src/effect/instances.ts | 10 +- packages/opencode/src/effect/runtime.ts | 7 +- .../src/permission/{next.ts => index.ts} | 12 +- packages/opencode/src/permission/service.ts | 211 +++++++++--------- packages/opencode/src/project/bootstrap.ts | 4 - packages/opencode/src/scheduler/index.ts | 61 ----- .../opencode/src/server/routes/permission.ts | 2 +- .../opencode/src/server/routes/session.ts | 2 +- packages/opencode/src/session/index.ts | 2 +- packages/opencode/src/session/llm.ts | 2 +- packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/session/session.sql.ts | 2 +- packages/opencode/src/session/system.ts | 2 +- packages/opencode/src/skill/skill.ts | 2 +- packages/opencode/src/tool/bash.ts | 2 +- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/tool/task.ts | 2 +- packages/opencode/src/tool/tool.ts | 4 +- packages/opencode/src/tool/truncate-effect.ts | 140 ++++++++++++ packages/opencode/src/tool/truncate.ts | 19 ++ packages/opencode/src/tool/truncation-dir.ts | 4 + packages/opencode/src/tool/truncation.ts | 108 --------- packages/opencode/test/account/repo.test.ts | 38 ++-- .../opencode/test/account/service.test.ts | 32 ++- packages/opencode/test/agent/agent.test.ts | 10 +- packages/opencode/test/fixture/effect.ts | 7 - packages/opencode/test/lib/effect.ts | 37 +++ packages/opencode/test/lib/filesystem.ts | 10 + .../opencode/test/permission-task.test.ts | 2 +- .../opencode/test/permission/next.test.ts | 4 +- packages/opencode/test/scheduler.test.ts | 73 ------ packages/opencode/test/tool/bash.test.ts | 4 +- .../test/tool/external-directory.test.ts | 2 +- packages/opencode/test/tool/read.test.ts | 2 +- packages/opencode/test/tool/skill.test.ts | 2 +- .../opencode/test/tool/truncation.test.ts | 50 ++--- 40 files changed, 405 insertions(+), 482 deletions(-) rename packages/opencode/src/permission/{next.ts => index.ts} (86%) delete mode 100644 packages/opencode/src/scheduler/index.ts create mode 100644 packages/opencode/src/tool/truncate-effect.ts create mode 100644 packages/opencode/src/tool/truncate.ts create mode 100644 packages/opencode/src/tool/truncation-dir.ts delete mode 100644 packages/opencode/src/tool/truncation.ts delete mode 100644 packages/opencode/test/fixture/effect.ts create mode 100644 packages/opencode/test/lib/effect.ts create mode 100644 packages/opencode/test/lib/filesystem.ts delete mode 100644 packages/opencode/test/scheduler.test.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b247bb7fa2..b2dae0402c 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -5,7 +5,7 @@ import { ModelID, ProviderID } from "../provider/schema" import { generateObject, streamObject, type ModelMessage } from "ai" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" -import { Truncate } from "../tool/truncation" +import { Truncate } from "../tool/truncate" import { Auth } from "../auth" import { ProviderTransform } from "../provider/transform" @@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" -import { PermissionNext } from "@/permission/next" +import { PermissionNext } from "@/permission" import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@/global" import path from "path" diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index ef075d732a..f33dcc5582 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2" import { MessageID, PartID } from "../../../session/schema" import { ToolRegistry } from "../../../tool/registry" import { Instance } from "../../../project/instance" -import { PermissionNext } from "../../../permission/next" +import { PermissionNext } from "../../../permission" import { iife } from "../../../util/iife" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index f92d3305bf..85b5689daa 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart import { Server } from "../../server/server" import { Provider } from "../../provider/provider" import { Agent } from "../../agent/agent" -import { PermissionNext } from "../../permission/next" +import { PermissionNext } from "../../permission" import { Tool } from "../../tool/tool" import { GlobTool } from "../../tool/glob" import { GrepTool } from "../../tool/grep" diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 16186f7295..3a1fb0cdf9 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -3,7 +3,7 @@ import { FileService } from "@/file" import { FileTimeService } from "@/file/time" import { FileWatcherService } from "@/file/watcher" import { FormatService } from "@/format" -import { PermissionService } from "@/permission/service" +import { PermissionEffect } from "@/permission/service" import { Instance } from "@/project/instance" import { VcsService } from "@/project/vcs" import { ProviderAuthService } from "@/provider/auth-service" @@ -17,7 +17,7 @@ export { InstanceContext } from "./instance-context" export type InstanceServices = | QuestionService - | PermissionService + | PermissionEffect.Service | ProviderAuthService | FileWatcherService | VcsService @@ -37,7 +37,7 @@ function lookup(_key: string) { const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current)) return Layer.mergeAll( Layer.fresh(QuestionService.layer), - Layer.fresh(PermissionService.layer), + Layer.fresh(PermissionEffect.layer), Layer.fresh(ProviderAuthService.layer), Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), Layer.fresh(VcsService.layer), @@ -67,8 +67,4 @@ export class Instances extends ServiceMap.Service { return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory)))) } - - static invalidate(directory: string): Effect.Effect { - return Instances.use((map) => map.invalidate(directory)) - } } diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index cf7d73f776..a55956bfd9 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -3,10 +3,15 @@ import { AccountService } from "@/account/service" import { AuthService } from "@/auth/service" import { Instances } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances" +import { TruncateEffect } from "@/tool/truncate-effect" import { Instance } from "@/project/instance" export const runtime = ManagedRuntime.make( - Layer.mergeAll(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)), + Layer.mergeAll( + AccountService.defaultLayer, // + TruncateEffect.defaultLayer, + Instances.layer, + ).pipe(Layer.provideMerge(AuthService.defaultLayer)), ) export function runPromiseInstance(effect: Effect.Effect) { diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/index.ts similarity index 86% rename from packages/opencode/src/permission/next.ts rename to packages/opencode/src/permission/index.ts index a6db552222..e7eb0eea6c 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/index.ts @@ -3,7 +3,7 @@ import { Config } from "@/config/config" import { fn } from "@/util/fn" import { Wildcard } from "@/util/wildcard" import os from "os" -import * as S from "./service" +import { PermissionEffect as S } from "./service" export namespace PermissionNext { function expand(pattern: string): string { @@ -26,7 +26,7 @@ export namespace PermissionNext { export type Reply = S.Reply export const Approval = S.Approval export const Event = S.Event - export const Service = S.PermissionService + export const Service = S.Service export const RejectedError = S.RejectedError export const CorrectedError = S.CorrectedError export const DeniedError = S.DeniedError @@ -53,16 +53,14 @@ export namespace PermissionNext { return rulesets.flat() } - export const ask = fn(S.AskInput, async (input) => - runPromiseInstance(S.PermissionService.use((service) => service.ask(input))), - ) + export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((service) => service.ask(input)))) export const reply = fn(S.ReplyInput, async (input) => - runPromiseInstance(S.PermissionService.use((service) => service.reply(input))), + runPromiseInstance(S.Service.use((service) => service.reply(input))), ) export async function list() { - return runPromiseInstance(S.PermissionService.use((service) => service.list())) + return runPromiseInstance(S.Service.use((service) => service.list())) } export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts index f20b19acf3..4335aa4cd8 100644 --- a/packages/opencode/src/permission/service.ts +++ b/packages/opencode/src/permission/service.ts @@ -11,121 +11,128 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" import z from "zod" import { PermissionID } from "./schema" -const log = Log.create({ service: "permission" }) +export namespace PermissionEffect { + const log = Log.create({ service: "permission" }) -export const Action = z.enum(["allow", "deny", "ask"]).meta({ - ref: "PermissionAction", -}) -export type Action = z.infer - -export const Rule = z - .object({ - permission: z.string(), - pattern: z.string(), - action: Action, + export const Action = z.enum(["allow", "deny", "ask"]).meta({ + ref: "PermissionAction", }) - .meta({ - ref: "PermissionRule", + export type Action = z.infer + + export const Rule = z + .object({ + permission: z.string(), + pattern: z.string(), + action: Action, + }) + .meta({ + ref: "PermissionRule", + }) + export type Rule = z.infer + + export const Ruleset = Rule.array().meta({ + ref: "PermissionRuleset", }) -export type Rule = z.infer + export type Ruleset = z.infer -export const Ruleset = Rule.array().meta({ - ref: "PermissionRuleset", -}) -export type Ruleset = z.infer - -export const Request = z - .object({ - id: PermissionID.zod, - sessionID: SessionID.zod, - permission: z.string(), - patterns: z.string().array(), - metadata: z.record(z.string(), z.any()), - always: z.string().array(), - tool: z - .object({ - messageID: MessageID.zod, - callID: z.string(), - }) - .optional(), - }) - .meta({ - ref: "PermissionRequest", - }) -export type Request = z.infer - -export const Reply = z.enum(["once", "always", "reject"]) -export type Reply = z.infer - -export const Approval = z.object({ - projectID: ProjectID.zod, - patterns: z.string().array(), -}) - -export const Event = { - Asked: BusEvent.define("permission.asked", Request), - Replied: BusEvent.define( - "permission.replied", - z.object({ + export const Request = z + .object({ + id: PermissionID.zod, sessionID: SessionID.zod, - requestID: PermissionID.zod, - reply: Reply, - }), - ), -} + permission: z.string(), + patterns: z.string().array(), + metadata: z.record(z.string(), z.any()), + always: z.string().array(), + tool: z + .object({ + messageID: MessageID.zod, + callID: z.string(), + }) + .optional(), + }) + .meta({ + ref: "PermissionRequest", + }) + export type Request = z.infer -export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { - override get message() { - return "The user rejected permission to use this specific tool call." + export const Reply = z.enum(["once", "always", "reject"]) + export type Reply = z.infer + + export const Approval = z.object({ + projectID: ProjectID.zod, + patterns: z.string().array(), + }) + + export const Event = { + Asked: BusEvent.define("permission.asked", Request), + Replied: BusEvent.define( + "permission.replied", + z.object({ + sessionID: SessionID.zod, + requestID: PermissionID.zod, + reply: Reply, + }), + ), } -} -export class CorrectedError extends Schema.TaggedErrorClass()("PermissionCorrectedError", { - feedback: Schema.String, -}) { - override get message() { - return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}` + export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { + override get message() { + return "The user rejected permission to use this specific tool call." + } } -} -export class DeniedError extends Schema.TaggedErrorClass()("PermissionDeniedError", { - ruleset: Schema.Any, -}) { - override get message() { - return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}` + export class CorrectedError extends Schema.TaggedErrorClass()("PermissionCorrectedError", { + feedback: Schema.String, + }) { + override get message() { + return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}` + } } -} -export type PermissionError = DeniedError | RejectedError | CorrectedError + export class DeniedError extends Schema.TaggedErrorClass()("PermissionDeniedError", { + ruleset: Schema.Any, + }) { + override get message() { + return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}` + } + } -interface PendingEntry { - info: Request - deferred: Deferred.Deferred -} + export type Error = DeniedError | RejectedError | CorrectedError -export const AskInput = Request.partial({ id: true }).extend({ - ruleset: Ruleset, -}) + export const AskInput = Request.partial({ id: true }).extend({ + ruleset: Ruleset, + }) -export const ReplyInput = z.object({ - requestID: PermissionID.zod, - reply: Reply, - message: z.string().optional(), -}) + export const ReplyInput = z.object({ + requestID: PermissionID.zod, + reply: Reply, + message: z.string().optional(), + }) -export declare namespace PermissionService { export interface Api { - readonly ask: (input: z.infer) => Effect.Effect + readonly ask: (input: z.infer) => Effect.Effect readonly reply: (input: z.infer) => Effect.Effect readonly list: () => Effect.Effect } -} -export class PermissionService extends ServiceMap.Service()( - "@opencode/PermissionNext", -) { - static readonly layer = Layer.effect( - PermissionService, + interface PendingEntry { + info: Request + deferred: Deferred.Deferred + } + + export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { + const rules = rulesets.flat() + log.info("evaluate", { permission, pattern, ruleset: rules }) + const match = rules.findLast( + (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), + ) + return match ?? { action: "ask", permission, pattern: "*" } + } + + export class Service extends ServiceMap.Service()("@opencode/PermissionNext") {} + + export const layer = Layer.effect( + Service, Effect.gen(function* () { const { project } = yield* InstanceContext const row = Database.use((db) => @@ -225,27 +232,13 @@ export class PermissionService extends ServiceMap.Service item.info) }) - return PermissionService.of({ ask, reply, list }) + return Service.of({ ask, reply, list }) }), ) } - -export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - const merged = rulesets.flat() - log.info("evaluate", { permission, pattern, ruleset: merged }) - const match = merged.findLast( - (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), - ) - return match ?? { action: "ask", permission, pattern: "*" } -} diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 00ced358d7..40a4ce9ccd 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -10,8 +10,6 @@ import { Instance } from "./instance" import { VcsService } from "./vcs" import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" -import { Snapshot } from "../snapshot" -import { Truncate } from "../tool/truncation" import { runPromiseInstance } from "@/effect/runtime" export async function InstanceBootstrap() { @@ -23,8 +21,6 @@ export async function InstanceBootstrap() { await runPromiseInstance(FileWatcherService.use((service) => service.init())) File.init() await runPromiseInstance(VcsService.use((s) => s.init())) - Snapshot.init() - Truncate.init() Bus.subscribe(Command.Event.Executed, async (payload) => { if (payload.properties.name === Command.Default.INIT) { diff --git a/packages/opencode/src/scheduler/index.ts b/packages/opencode/src/scheduler/index.ts deleted file mode 100644 index cfafa7b9ce..0000000000 --- a/packages/opencode/src/scheduler/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Instance } from "../project/instance" -import { Log } from "../util/log" - -export namespace Scheduler { - const log = Log.create({ service: "scheduler" }) - - export type Task = { - id: string - interval: number - run: () => Promise - scope?: "instance" | "global" - } - - type Timer = ReturnType - type Entry = { - tasks: Map - timers: Map - } - - const create = (): Entry => { - const tasks = new Map() - const timers = new Map() - return { tasks, timers } - } - - const shared = create() - - const state = Instance.state( - () => create(), - async (entry) => { - for (const timer of entry.timers.values()) { - clearInterval(timer) - } - entry.tasks.clear() - entry.timers.clear() - }, - ) - - export function register(task: Task) { - const scope = task.scope ?? "instance" - const entry = scope === "global" ? shared : state() - const current = entry.timers.get(task.id) - if (current && scope === "global") return - if (current) clearInterval(current) - - entry.tasks.set(task.id, task) - void run(task) - const timer = setInterval(() => { - void run(task) - }, task.interval) - timer.unref() - entry.timers.set(task.id, timer) - } - - async function run(task: Task) { - log.info("run", { id: task.id }) - await task.run().catch((error) => { - log.error("run failed", { id: task.id, error }) - }) - } -} diff --git a/packages/opencode/src/server/routes/permission.ts b/packages/opencode/src/server/routes/permission.ts index 6d86703c66..cc6c26d435 100644 --- a/packages/opencode/src/server/routes/permission.ts +++ b/packages/opencode/src/server/routes/permission.ts @@ -1,7 +1,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" -import { PermissionNext } from "@/permission/next" +import { PermissionNext } from "@/permission" import { PermissionID } from "@/permission/schema" import { errors } from "../error" import { lazy } from "../../util/lazy" diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index b8fafd3367..613c8b05c1 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -14,7 +14,7 @@ import { Todo } from "../../session/todo" import { Agent } from "../../agent/agent" import { Snapshot } from "@/snapshot" import { Log } from "../../util/log" -import { PermissionNext } from "@/permission/next" +import { PermissionNext } from "@/permission" import { PermissionID } from "@/permission/schema" import { ModelID, ProviderID } from "@/provider/schema" import { errors } from "../error" diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 0879fe87fd..01fd214e0a 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" -import { PermissionNext } from "@/permission/next" +import { PermissionNext } from "@/permission" import { Global } from "@/global" import type { LanguageModelV2Usage } from "@ai-sdk/provider" import { iife } from "@/util/iife" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 88841a30a8..bcf1b3e6af 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -20,7 +20,7 @@ import type { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" import { SystemPrompt } from "./system" import { Flag } from "@/flag/flag" -import { PermissionNext } from "@/permission/next" +import { PermissionNext } from "@/permission" import { Auth } from "@/auth" export namespace LLM { diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 38dac41b05..158b83865d 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -12,7 +12,7 @@ import type { Provider } from "@/provider/provider" import { LLM } from "./llm" import { Config } from "@/config/config" import { SessionCompaction } from "./compaction" -import { PermissionNext } from "@/permission/next" +import { PermissionNext } from "@/permission" import { Question } from "@/question" import { PartID } from "./schema" import type { SessionID, MessageID } from "./schema" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 27a379daa5..36162656aa 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -41,12 +41,12 @@ import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" import { Tool } from "@/tool/tool" -import { PermissionNext } from "@/permission/next" +import { PermissionNext } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" -import { Truncate } from "@/tool/truncation" +import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" // @ts-ignore diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index b3229edd13..ea1c4dafb9 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlit import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" import type { Snapshot } from "../snapshot" -import type { PermissionNext } from "../permission/next" +import type { PermissionNext } from "../permission" import type { ProjectID } from "../project/schema" import type { SessionID, MessageID, PartID } from "./schema" import type { WorkspaceID } from "../control-plane/schema" diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index a4c4684ffe..d74f58beff 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex_header.txt" import PROMPT_TRINITY from "./prompt/trinity.txt" import type { Provider } from "@/provider/provider" import type { Agent } from "@/agent/agent" -import { PermissionNext } from "@/permission/next" +import { PermissionNext } from "@/permission" import { Skill } from "@/skill" export namespace SystemPrompt { diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 3a544d90a0..79be9f779c 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -14,7 +14,7 @@ import { DiscoveryService } from "./discovery" import { Glob } from "../util/glob" import { pathToFileURL } from "url" import type { Agent } from "@/agent/agent" -import { PermissionNext } from "@/permission/next" +import { PermissionNext } from "@/permission" import { InstanceContext } from "@/effect/instance-context" import { Effect, Layer, ServiceMap } from "effect" import { runPromiseInstance } from "@/effect/runtime" diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 109a665363..50ae4abac8 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -15,7 +15,7 @@ import { Flag } from "@/flag/flag.ts" import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" -import { Truncate } from "./truncation" +import { Truncate } from "./truncate" import { Plugin } from "@/plugin" const MAX_METADATA_LENGTH = 30_000 diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 3ea242a29d..da9a897905 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -26,7 +26,7 @@ import { CodeSearchTool } from "./codesearch" import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { LspTool } from "./lsp" -import { Truncate } from "./truncation" +import { Truncate } from "./truncate" import { ApplyPatchTool } from "./apply_patch" import { Glob } from "../util/glob" diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 68e44eb97e..14ecea1075 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt" import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" -import { PermissionNext } from "@/permission/next" +import { PermissionNext } from "@/permission" const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 8cc7b57d85..d29af86f83 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,9 +1,9 @@ import z from "zod" import type { MessageV2 } from "../session/message-v2" import type { Agent } from "../agent/agent" -import type { PermissionNext } from "../permission/next" +import type { PermissionNext } from "../permission" import type { SessionID, MessageID } from "../session/schema" -import { Truncate } from "./truncation" +import { Truncate } from "./truncate" export namespace Tool { interface Metadata { diff --git a/packages/opencode/src/tool/truncate-effect.ts b/packages/opencode/src/tool/truncate-effect.ts new file mode 100644 index 0000000000..4d0ed8168f --- /dev/null +++ b/packages/opencode/src/tool/truncate-effect.ts @@ -0,0 +1,140 @@ +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect" +import path from "path" +import type { Agent } from "../agent/agent" +import { PermissionEffect } from "../permission/service" +import { Identifier } from "../id/id" +import { Log } from "../util/log" +import { ToolID } from "./schema" +import { TRUNCATION_DIR } from "./truncation-dir" + +export namespace TruncateEffect { + const log = Log.create({ service: "truncation" }) + const RETENTION = Duration.days(7) + + export const MAX_LINES = 2000 + export const MAX_BYTES = 50 * 1024 + export const DIR = TRUNCATION_DIR + export const GLOB = path.join(TRUNCATION_DIR, "*") + + export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } + + export interface Options { + maxLines?: number + maxBytes?: number + direction?: "head" | "tail" + } + + function hasTaskTool(agent?: Agent.Info) { + if (!agent?.permission) return false + return PermissionEffect.evaluate("task", "*", agent.permission).action !== "deny" + } + + export interface Api { + readonly cleanup: () => Effect.Effect + /** + * Returns output unchanged when it fits within the limits, otherwise writes the full text + * to the truncation directory and returns a preview plus a hint to inspect the saved file. + */ + readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Truncate") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + + const cleanup = Effect.fn("TruncateEffect.cleanup")(function* () { + const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION))) + const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe( + Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), + Effect.catch(() => Effect.succeed([])), + ) + for (const entry of entries) { + if (Identifier.timestamp(entry) >= cutoff) continue + yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void)) + } + }) + + const output = Effect.fn("TruncateEffect.output")(function* ( + text: string, + options: Options = {}, + agent?: Agent.Info, + ) { + const maxLines = options.maxLines ?? MAX_LINES + const maxBytes = options.maxBytes ?? MAX_BYTES + const direction = options.direction ?? "head" + const lines = text.split("\n") + const totalBytes = Buffer.byteLength(text, "utf-8") + + if (lines.length <= maxLines && totalBytes <= maxBytes) { + return { content: text, truncated: false } as const + } + + const out: string[] = [] + let i = 0 + let bytes = 0 + let hitBytes = false + + if (direction === "head") { + for (i = 0; i < lines.length && i < maxLines; i++) { + const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.push(lines[i]) + bytes += size + } + } else { + for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { + const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.unshift(lines[i]) + bytes += size + } + } + + const removed = hitBytes ? totalBytes - bytes : lines.length - out.length + const unit = hitBytes ? "bytes" : "lines" + const preview = out.join("\n") + const file = path.join(TRUNCATION_DIR, ToolID.ascending()) + + yield* fs.makeDirectory(TRUNCATION_DIR, { recursive: true }).pipe(Effect.orDie) + yield* fs.writeFileString(file, text).pipe(Effect.orDie) + + const hint = hasTaskTool(agent) + ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` + : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` + + return { + content: + direction === "head" + ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` + : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`, + truncated: true, + outputPath: file, + } as const + }) + + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("truncation cleanup failed", { cause: Cause.pretty(cause) }) + return Effect.void + }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), + Effect.forkScoped, + ) + + return Service.of({ cleanup, output }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) +} diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts new file mode 100644 index 0000000000..622052fd4a --- /dev/null +++ b/packages/opencode/src/tool/truncate.ts @@ -0,0 +1,19 @@ +import type { Agent } from "../agent/agent" +import { runtime } from "@/effect/runtime" +import { TruncateEffect as S } from "./truncate-effect" + + +export namespace Truncate { + export const MAX_LINES = S.MAX_LINES + export const MAX_BYTES = S.MAX_BYTES + export const DIR = S.DIR + export const GLOB = S.GLOB + + export type Result = S.Result + + export type Options = S.Options + + export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise { + return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent))) + } +} diff --git a/packages/opencode/src/tool/truncation-dir.ts b/packages/opencode/src/tool/truncation-dir.ts new file mode 100644 index 0000000000..d6d5d013d7 --- /dev/null +++ b/packages/opencode/src/tool/truncation-dir.ts @@ -0,0 +1,4 @@ +import path from "path" +import { Global } from "../global" + +export const TRUNCATION_DIR = path.join(Global.Path.data, "tool-output") diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts deleted file mode 100644 index 7c6a362a37..0000000000 --- a/packages/opencode/src/tool/truncation.ts +++ /dev/null @@ -1,108 +0,0 @@ -import fs from "fs/promises" -import path from "path" -import { Global } from "../global" -import { Identifier } from "../id/id" -import { PermissionNext } from "../permission/next" -import type { Agent } from "../agent/agent" -import { Scheduler } from "../scheduler" -import { Filesystem } from "../util/filesystem" -import { Glob } from "../util/glob" -import { ToolID } from "./schema" - -export namespace Truncate { - export const MAX_LINES = 2000 - export const MAX_BYTES = 50 * 1024 - export const DIR = path.join(Global.Path.data, "tool-output") - export const GLOB = path.join(DIR, "*") - const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days - const HOUR_MS = 60 * 60 * 1000 - - export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } - - export interface Options { - maxLines?: number - maxBytes?: number - direction?: "head" | "tail" - } - - export function init() { - Scheduler.register({ - id: "tool.truncation.cleanup", - interval: HOUR_MS, - run: cleanup, - scope: "global", - }) - } - - export async function cleanup() { - const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS)) - const entries = await Glob.scan("tool_*", { cwd: DIR, include: "file" }).catch(() => [] as string[]) - for (const entry of entries) { - if (Identifier.timestamp(entry) >= cutoff) continue - await fs.unlink(path.join(DIR, entry)).catch(() => {}) - } - } - - function hasTaskTool(agent?: Agent.Info): boolean { - if (!agent?.permission) return false - const rule = PermissionNext.evaluate("task", "*", agent.permission) - return rule.action !== "deny" - } - - export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise { - const maxLines = options.maxLines ?? MAX_LINES - const maxBytes = options.maxBytes ?? MAX_BYTES - const direction = options.direction ?? "head" - const lines = text.split("\n") - const totalBytes = Buffer.byteLength(text, "utf-8") - - if (lines.length <= maxLines && totalBytes <= maxBytes) { - return { content: text, truncated: false } - } - - const out: string[] = [] - let i = 0 - let bytes = 0 - let hitBytes = false - - if (direction === "head") { - for (i = 0; i < lines.length && i < maxLines; i++) { - const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.push(lines[i]) - bytes += size - } - } else { - for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { - const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.unshift(lines[i]) - bytes += size - } - } - - const removed = hitBytes ? totalBytes - bytes : lines.length - out.length - const unit = hitBytes ? "bytes" : "lines" - const preview = out.join("\n") - - const id = ToolID.ascending() - const filepath = path.join(DIR, id) - await Filesystem.write(filepath, text) - - const hint = hasTaskTool(agent) - ? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` - : `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` - const message = - direction === "head" - ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` - : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}` - - return { content: message, truncated: true, outputPath: filepath } - } -} diff --git a/packages/opencode/test/account/repo.test.ts b/packages/opencode/test/account/repo.test.ts index 74a6d7a570..fb12ddf701 100644 --- a/packages/opencode/test/account/repo.test.ts +++ b/packages/opencode/test/account/repo.test.ts @@ -4,7 +4,7 @@ import { Effect, Layer, Option } from "effect" import { AccountRepo } from "../../src/account/repo" import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema" import { Database } from "../../src/storage/db" -import { testEffect } from "../fixture/effect" +import { testEffect } from "../lib/effect" const truncate = Layer.effectDiscard( Effect.sync(() => { @@ -16,24 +16,21 @@ const truncate = Layer.effectDiscard( const it = testEffect(Layer.merge(AccountRepo.layer, truncate)) -it.effect( - "list returns empty when no accounts exist", +it.effect("list returns empty when no accounts exist", () => Effect.gen(function* () { const accounts = yield* AccountRepo.use((r) => r.list()) expect(accounts).toEqual([]) }), ) -it.effect( - "active returns none when no accounts exist", +it.effect("active returns none when no accounts exist", () => Effect.gen(function* () { const active = yield* AccountRepo.use((r) => r.active()) expect(Option.isNone(active)).toBe(true) }), ) -it.effect( - "persistAccount inserts and getRow retrieves", +it.effect("persistAccount inserts and getRow retrieves", () => Effect.gen(function* () { const id = AccountID.make("user-1") yield* AccountRepo.use((r) => @@ -59,8 +56,7 @@ it.effect( }), ) -it.effect( - "persistAccount sets the active account and org", +it.effect("persistAccount sets the active account and org", () => Effect.gen(function* () { const id1 = AccountID.make("user-1") const id2 = AccountID.make("user-2") @@ -97,8 +93,7 @@ it.effect( }), ) -it.effect( - "list returns all accounts", +it.effect("list returns all accounts", () => Effect.gen(function* () { const id1 = AccountID.make("user-1") const id2 = AccountID.make("user-2") @@ -133,8 +128,7 @@ it.effect( }), ) -it.effect( - "remove deletes an account", +it.effect("remove deletes an account", () => Effect.gen(function* () { const id = AccountID.make("user-1") @@ -157,8 +151,7 @@ it.effect( }), ) -it.effect( - "use stores the selected org and marks the account active", +it.effect("use stores the selected org and marks the account active", () => Effect.gen(function* () { const id1 = AccountID.make("user-1") const id2 = AccountID.make("user-2") @@ -198,8 +191,7 @@ it.effect( }), ) -it.effect( - "persistToken updates token fields", +it.effect("persistToken updates token fields", () => Effect.gen(function* () { const id = AccountID.make("user-1") @@ -233,8 +225,7 @@ it.effect( }), ) -it.effect( - "persistToken with no expiry sets token_expiry to null", +it.effect("persistToken with no expiry sets token_expiry to null", () => Effect.gen(function* () { const id = AccountID.make("user-1") @@ -264,8 +255,7 @@ it.effect( }), ) -it.effect( - "persistAccount upserts on conflict", +it.effect("persistAccount upserts on conflict", () => Effect.gen(function* () { const id = AccountID.make("user-1") @@ -305,8 +295,7 @@ it.effect( }), ) -it.effect( - "remove clears active state when deleting the active account", +it.effect("remove clears active state when deleting the active account", () => Effect.gen(function* () { const id = AccountID.make("user-1") @@ -329,8 +318,7 @@ it.effect( }), ) -it.effect( - "getRow returns none for nonexistent account", +it.effect("getRow returns none for nonexistent account", () => Effect.gen(function* () { const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope"))) expect(Option.isNone(row)).toBe(true) diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index 5caa33235a..ca244c2d94 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -1,12 +1,12 @@ import { expect } from "bun:test" -import { Duration, Effect, Layer, Option, Ref, Schema } from "effect" +import { Duration, Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AccountRepo } from "../../src/account/repo" import { AccountService } from "../../src/account/service" import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema" import { Database } from "../../src/storage/db" -import { testEffect } from "../fixture/effect" +import { testEffect } from "../lib/effect" const truncate = Layer.effectDiscard( Effect.sync(() => { @@ -34,8 +34,7 @@ const encodeOrg = Schema.encodeSync(Org) const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name })) -it.effect( - "orgsByAccount groups orgs per account", +it.effect("orgsByAccount groups orgs per account", () => Effect.gen(function* () { yield* AccountRepo.use((r) => r.persistAccount({ @@ -61,10 +60,10 @@ it.effect( }), ) - const seen = yield* Ref.make([]) + const seen: Array = [] const client = HttpClient.make((req) => Effect.gen(function* () { - yield* Ref.update(seen, (xs) => [...xs, `${req.method} ${req.url}`]) + seen.push(`${req.method} ${req.url}`) if (req.url === "https://one.example.com/api/orgs") { return json(req, [org("org-1", "One")]) @@ -84,15 +83,14 @@ it.effect( [AccountID.make("user-1"), [OrgID.make("org-1")]], [AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]], ]) - expect(yield* Ref.get(seen)).toEqual([ + expect(seen).toEqual([ "GET https://one.example.com/api/orgs", "GET https://two.example.com/api/orgs", ]) }), ) -it.effect( - "token refresh persists the new token", +it.effect("token refresh persists the new token", () => Effect.gen(function* () { const id = AccountID.make("user-1") @@ -133,8 +131,7 @@ it.effect( }), ) -it.effect( - "config sends the selected org header", +it.effect("config sends the selected org header", () => Effect.gen(function* () { const id = AccountID.make("user-1") @@ -150,13 +147,11 @@ it.effect( }), ) - const seen = yield* Ref.make<{ auth?: string; org?: string }>({}) + const seen: { auth?: string; org?: string } = {} const client = HttpClient.make((req) => Effect.gen(function* () { - yield* Ref.set(seen, { - auth: req.headers.authorization, - org: req.headers["x-org-id"], - }) + seen.auth = req.headers.authorization + seen.org = req.headers["x-org-id"] if (req.url === "https://one.example.com/api/config") { return json(req, { config: { theme: "light", seats: 5 } }) @@ -169,15 +164,14 @@ it.effect( const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client))) expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 }) - expect(yield* Ref.get(seen)).toEqual({ + expect(seen).toEqual({ auth: "Bearer at_1", org: "org-9", }) }), ) -it.effect( - "poll stores the account and first org on success", +it.effect("poll stores the account and first org on success", () => Effect.gen(function* () { const login = new Login({ code: DeviceCode.make("device-code"), diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 497b6019d3..d6b6ebb33b 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -3,7 +3,7 @@ import path from "path" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" -import { PermissionNext } from "../../src/permission/next" +import { PermissionNext } from "../../src/permission" // Helper to evaluate permission for a tool with wildcard pattern function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined { @@ -76,7 +76,7 @@ test("explore agent denies edit and write", async () => { }) test("explore agent asks for external directories and allows Truncate.GLOB", async () => { - const { Truncate } = await import("../../src/tool/truncation") + const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -463,7 +463,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a }) test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => { - const { Truncate } = await import("../../src/tool/truncation") + const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir({ config: { permission: { @@ -483,7 +483,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally }) test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => { - const { Truncate } = await import("../../src/tool/truncation") + const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir({ config: { agent: { @@ -507,7 +507,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen }) test("explicit Truncate.GLOB deny is respected", async () => { - const { Truncate } = await import("../../src/tool/truncation") + const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir({ config: { permission: { diff --git a/packages/opencode/test/fixture/effect.ts b/packages/opencode/test/fixture/effect.ts deleted file mode 100644 index b75610139f..0000000000 --- a/packages/opencode/test/fixture/effect.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { test } from "bun:test" -import { Effect, Layer } from "effect" - -export const testEffect = (layer: Layer.Layer) => ({ - effect: (name: string, value: Effect.Effect) => - test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))), -}) diff --git a/packages/opencode/test/lib/effect.ts b/packages/opencode/test/lib/effect.ts new file mode 100644 index 0000000000..4162ba0924 --- /dev/null +++ b/packages/opencode/test/lib/effect.ts @@ -0,0 +1,37 @@ +import { test, type TestOptions } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import type * as Scope from "effect/Scope" +import * as TestConsole from "effect/testing/TestConsole" + +type Body = Effect.Effect | (() => Effect.Effect) +const env = TestConsole.layer + +const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) + +const run = (value: Body, layer: Layer.Layer) => + Effect.gen(function* () { + const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit) + if (Exit.isFailure(exit)) { + for (const err of Cause.prettyErrors(exit.cause)) { + yield* Effect.logError(err) + } + } + return yield* exit + }).pipe(Effect.runPromise) + +const make = (layer: Layer.Layer) => { + const effect = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, layer), opts) + + effect.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, layer), opts) + + effect.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, layer), opts) + + return { effect } +} + +export const it = make(env) + +export const testEffect = (layer: Layer.Layer) => make(Layer.provideMerge(layer, env)) diff --git a/packages/opencode/test/lib/filesystem.ts b/packages/opencode/test/lib/filesystem.ts new file mode 100644 index 0000000000..66f702ec3d --- /dev/null +++ b/packages/opencode/test/lib/filesystem.ts @@ -0,0 +1,10 @@ +import path from "path" +import { Effect, FileSystem } from "effect" + +export const writeFileStringScoped = Effect.fn("test.writeFileStringScoped")(function* (file: string, text: string) { + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(path.dirname(file), { recursive: true }) + yield* fs.writeFileString(file, text) + yield* Effect.addFinalizer(() => fs.remove(file, { force: true }).pipe(Effect.orDie)) + return file +}) diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index 3d592a3d98..c78da6e6a5 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { PermissionNext } from "../src/permission/next" +import { PermissionNext } from "../src/permission" import { Config } from "../src/config/config" import { Instance } from "../src/project/instance" import { tmpdir } from "./fixture/fixture" diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index b9845ae267..6fa782b05e 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -4,7 +4,7 @@ import { Effect } from "effect" import { Bus } from "../../src/bus" import { runtime } from "../../src/effect/runtime" import { Instances } from "../../src/effect/instances" -import { PermissionNext } from "../../src/permission/next" +import { PermissionNext } from "../../src/permission" import * as S from "../../src/permission/service" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" @@ -1005,7 +1005,7 @@ test("ask - abort should clear pending request", async () => { fn: async () => { const ctl = new AbortController() const ask = runtime.runPromise( - S.PermissionService.use((svc) => + S.PermissionEffect.Service.use((svc) => svc.ask({ sessionID: SessionID.make("session_test"), permission: "bash", diff --git a/packages/opencode/test/scheduler.test.ts b/packages/opencode/test/scheduler.test.ts deleted file mode 100644 index 328daad9b8..0000000000 --- a/packages/opencode/test/scheduler.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Scheduler } from "../src/scheduler" -import { Instance } from "../src/project/instance" -import { tmpdir } from "./fixture/fixture" - -describe("Scheduler.register", () => { - const hour = 60 * 60 * 1000 - - test("defaults to instance scope per directory", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) - const runs = { count: 0 } - const id = "scheduler.instance." + Math.random().toString(36).slice(2) - const task = { - id, - interval: hour, - run: async () => { - runs.count += 1 - }, - } - - await Instance.provide({ - directory: one.path, - fn: async () => { - Scheduler.register(task) - await Instance.dispose() - }, - }) - expect(runs.count).toBe(1) - - await Instance.provide({ - directory: two.path, - fn: async () => { - Scheduler.register(task) - await Instance.dispose() - }, - }) - expect(runs.count).toBe(2) - }) - - test("global scope runs once across instances", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) - const runs = { count: 0 } - const id = "scheduler.global." + Math.random().toString(36).slice(2) - const task = { - id, - interval: hour, - run: async () => { - runs.count += 1 - }, - scope: "global" as const, - } - - await Instance.provide({ - directory: one.path, - fn: async () => { - Scheduler.register(task) - await Instance.dispose() - }, - }) - expect(runs.count).toBe(1) - - await Instance.provide({ - directory: two.path, - fn: async () => { - Scheduler.register(task) - await Instance.dispose() - }, - }) - expect(runs.count).toBe(1) - }) -}) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index f947398b37..a5c7cec917 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -5,8 +5,8 @@ import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" -import type { PermissionNext } from "../../src/permission/next" -import { Truncate } from "../../src/tool/truncation" +import type { PermissionNext } from "../../src/permission" +import { Truncate } from "../../src/tool/truncate" import { SessionID, MessageID } from "../../src/session/schema" const ctx = { diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 58e53e5839..229901a722 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -3,7 +3,7 @@ import path from "path" import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { assertExternalDirectory } from "../../src/tool/external-directory" -import type { PermissionNext } from "../../src/permission/next" +import type { PermissionNext } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" const baseCtx: Omit = { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 0761a93044..cfeb597fce 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -4,7 +4,7 @@ import { ReadTool } from "../../src/tool/read" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" -import { PermissionNext } from "../../src/permission/next" +import { PermissionNext } from "../../src/permission" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 5bcdb6c2b9..7cfaee1353 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import { pathToFileURL } from "url" -import type { PermissionNext } from "../../src/permission/next" +import type { PermissionNext } from "../../src/permission" import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { SkillTool } from "../../src/tool/skill" diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 9e141b205d..71439f7604 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,9 +1,13 @@ -import { describe, test, expect, afterAll } from "bun:test" -import { Truncate } from "../../src/tool/truncation" +import { describe, test, expect } from "bun:test" +import { NodeFileSystem } from "@effect/platform-node" +import { Effect, FileSystem, Layer } from "effect" +import { Truncate } from "../../src/tool/truncate" +import { TruncateEffect } from "../../src/tool/truncate-effect" import { Identifier } from "../../src/id/id" import { Filesystem } from "../../src/util/filesystem" -import fs from "fs/promises" import path from "path" +import { testEffect } from "../lib/effect" +import { writeFileStringScoped } from "../lib/filesystem" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") @@ -125,36 +129,24 @@ describe("Truncate", () => { describe("cleanup", () => { const DAY_MS = 24 * 60 * 60 * 1000 - let oldFile: string - let recentFile: string + const it = testEffect(Layer.mergeAll(TruncateEffect.defaultLayer, NodeFileSystem.layer)) - afterAll(async () => { - await fs.unlink(oldFile).catch(() => {}) - await fs.unlink(recentFile).catch(() => {}) - }) + it.effect("deletes files older than 7 days and preserves recent files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem - test("deletes files older than 7 days and preserves recent files", async () => { - await fs.mkdir(Truncate.DIR, { recursive: true }) + yield* fs.makeDirectory(Truncate.DIR, { recursive: true }) - // Create an old file (10 days ago) - const oldTimestamp = Date.now() - 10 * DAY_MS - const oldId = Identifier.create("tool", false, oldTimestamp) - oldFile = path.join(Truncate.DIR, oldId) - await Filesystem.write(oldFile, "old content") + const old = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 10 * DAY_MS)) + const recent = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 3 * DAY_MS)) - // Create a recent file (3 days ago) - const recentTimestamp = Date.now() - 3 * DAY_MS - const recentId = Identifier.create("tool", false, recentTimestamp) - recentFile = path.join(Truncate.DIR, recentId) - await Filesystem.write(recentFile, "recent content") + yield* writeFileStringScoped(old, "old content") + yield* writeFileStringScoped(recent, "recent content") + yield* TruncateEffect.Service.use((s) => s.cleanup()) - await Truncate.cleanup() - - // Old file should be deleted - expect(await Filesystem.exists(oldFile)).toBe(false) - - // Recent file should still exist - expect(await Filesystem.exists(recentFile)).toBe(true) - }) + expect(yield* fs.exists(old)).toBe(false) + expect(yield* fs.exists(recent)).toBe(true) + }), + ) }) }) From 0292f1b5596db954e3811f91a9fafcfad650ead1 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 18 Mar 2026 02:01:02 +0000 Subject: [PATCH 042/112] chore: generate --- packages/opencode/src/tool/truncate.ts | 1 - packages/opencode/test/account/service.test.ts | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 622052fd4a..159b2d1d5b 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -2,7 +2,6 @@ import type { Agent } from "../agent/agent" import { runtime } from "@/effect/runtime" import { TruncateEffect as S } from "./truncate-effect" - export namespace Truncate { export const MAX_LINES = S.MAX_LINES export const MAX_BYTES = S.MAX_BYTES diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index ca244c2d94..d7239bfbf9 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -83,10 +83,7 @@ it.effect("orgsByAccount groups orgs per account", () => [AccountID.make("user-1"), [OrgID.make("org-1")]], [AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]], ]) - expect(seen).toEqual([ - "GET https://one.example.com/api/orgs", - "GET https://two.example.com/api/orgs", - ]) + expect(seen).toEqual(["GET https://one.example.com/api/orgs", "GET https://two.example.com/api/orgs"]) }), ) From a849a17e9329f0b4b0dbb85abf1366b51f935f8c Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Tue, 17 Mar 2026 22:43:43 -0400 Subject: [PATCH 043/112] =?UTF-8?q?feat(enterprise):=20contact=20form=20no?= =?UTF-8?q?w=20pushes=20to=20salesforce=20=F0=9F=99=84=20=20(#17964)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: slickstef11 Co-authored-by: Frank --- infra/console.ts | 7 ++ package.json | 1 + packages/console/app/src/i18n/ar.ts | 4 + packages/console/app/src/i18n/br.ts | 4 + packages/console/app/src/i18n/da.ts | 4 + packages/console/app/src/i18n/de.ts | 4 + packages/console/app/src/i18n/en.ts | 4 + packages/console/app/src/i18n/es.ts | 4 + packages/console/app/src/i18n/fr.ts | 4 + packages/console/app/src/i18n/it.ts | 4 + packages/console/app/src/i18n/ja.ts | 4 + packages/console/app/src/i18n/ko.ts | 4 + packages/console/app/src/i18n/no.ts | 4 + packages/console/app/src/i18n/pl.ts | 4 + packages/console/app/src/i18n/ru.ts | 4 + packages/console/app/src/i18n/th.ts | 4 + packages/console/app/src/i18n/tr.ts | 4 + packages/console/app/src/i18n/zh.ts | 4 + packages/console/app/src/i18n/zht.ts | 4 + packages/console/app/src/lib/salesforce.ts | 81 +++++++++++++++++++ .../console/app/src/routes/api/enterprise.ts | 49 ++++++++--- .../app/src/routes/enterprise/index.css | 9 +++ .../app/src/routes/enterprise/index.tsx | 50 ++++++++++++ 23 files changed, 254 insertions(+), 11 deletions(-) create mode 100644 packages/console/app/src/lib/salesforce.ts diff --git a/infra/console.ts b/infra/console.ts index c7889c587f..7b6f21001e 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -201,6 +201,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew") const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID") const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY") +const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID") +const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET") +const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL") + const logProcessor = new sst.cloudflare.Worker("LogProcessor", { handler: "packages/console/function/src/log-processor.ts", link: [new sst.Secret("HONEYCOMB_API_KEY")], @@ -219,6 +223,9 @@ new sst.cloudflare.x.SolidStart("Console", { EMAILOCTOPUS_API_KEY, AWS_SES_ACCESS_KEY_ID, AWS_SES_SECRET_ACCESS_KEY, + SALESFORCE_CLIENT_ID, + SALESFORCE_CLIENT_SECRET, + SALESFORCE_INSTANCE_URL, ZEN_BLACK_PRICE, ZEN_LITE_PRICE, new sst.Secret("ZEN_LIMITS"), diff --git a/package.json b/package.json index 97087c0e76..00e251f500 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop tauri dev", "dev:web": "bun --cwd packages/app dev", + "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "typecheck": "bun turbo typecheck", "prepare": "husky", diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index b59315aef1..9c6ba326d1 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -688,8 +688,12 @@ export const dict = { "enterprise.form.name.placeholder": "جيف بيزوس", "enterprise.form.role.label": "المنصب", "enterprise.form.role.placeholder": "رئيس مجلس الإدارة التنفيذي", + "enterprise.form.company.label": "الشركة", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "البريد الإلكتروني للشركة", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "رقم الهاتف", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "ما المشكلة التي تحاول حلها؟", "enterprise.form.message.placeholder": "نحتاج مساعدة في...", "enterprise.form.send": "إرسال", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index a18f3e4011..e654bdade2 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -700,8 +700,12 @@ export const dict = { "enterprise.form.name.placeholder": "Jeff Bezos", "enterprise.form.role.label": "Cargo", "enterprise.form.role.placeholder": "Presidente Executivo", + "enterprise.form.company.label": "Empresa", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "E-mail corporativo", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "Telefone", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "Qual problema você está tentando resolver?", "enterprise.form.message.placeholder": "Precisamos de ajuda com...", "enterprise.form.send": "Enviar", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index ca3231648c..99bc7ec145 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -694,8 +694,12 @@ export const dict = { "enterprise.form.name.placeholder": "Jeff Bezos", "enterprise.form.role.label": "Rolle", "enterprise.form.role.placeholder": "Bestyrelsesformand", + "enterprise.form.company.label": "Virksomhed", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "Firma-e-mail", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "Telefonnummer", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "Hvilket problem prøver du at løse?", "enterprise.form.message.placeholder": "Vi har brug for hjælp med...", "enterprise.form.send": "Send", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index d7ed88e361..c366411c33 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -699,8 +699,12 @@ export const dict = { "enterprise.form.name.placeholder": "Jeff Bezos", "enterprise.form.role.label": "Rolle", "enterprise.form.role.placeholder": "Executive Chairman", + "enterprise.form.company.label": "Unternehmen", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "Firmen-E-Mail", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "Telefonnummer", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "Welches Problem versuchen Sie zu lösen?", "enterprise.form.message.placeholder": "Wir brauchen Hilfe bei...", "enterprise.form.send": "Senden", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 8b410bb610..fec02f15c1 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -689,8 +689,12 @@ export const dict = { "enterprise.form.name.placeholder": "Jeff Bezos", "enterprise.form.role.label": "Role", "enterprise.form.role.placeholder": "Executive Chairman", + "enterprise.form.company.label": "Company", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "Company email", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "Phone number", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "What problem are you trying to solve?", "enterprise.form.message.placeholder": "We need help with...", "enterprise.form.send": "Send", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index bb466568e1..2c811a2d38 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -699,8 +699,12 @@ export const dict = { "enterprise.form.name.placeholder": "Jeff Bezos", "enterprise.form.role.label": "Rol", "enterprise.form.role.placeholder": "Presidente Ejecutivo", + "enterprise.form.company.label": "Empresa", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "Correo de empresa", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "Teléfono", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "¿Qué problema estás intentando resolver?", "enterprise.form.message.placeholder": "Necesitamos ayuda con...", "enterprise.form.send": "Enviar", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 8ac20c47c2..49a418cafe 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -706,8 +706,12 @@ export const dict = { "enterprise.form.name.placeholder": "Jeff Bezos", "enterprise.form.role.label": "Poste", "enterprise.form.role.placeholder": "Président exécutif", + "enterprise.form.company.label": "Entreprise", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "E-mail professionnel", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "Téléphone", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "Quel problème essayez-vous de résoudre ?", "enterprise.form.message.placeholder": "Nous avons besoin d'aide pour...", "enterprise.form.send": "Envoyer", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index bd8e17a5f3..7f6a109e45 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -696,8 +696,12 @@ export const dict = { "enterprise.form.name.placeholder": "Jeff Bezos", "enterprise.form.role.label": "Ruolo", "enterprise.form.role.placeholder": "Presidente Esecutivo", + "enterprise.form.company.label": "Azienda", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "Email aziendale", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "Numero di telefono", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "Quale problema stai cercando di risolvere?", "enterprise.form.message.placeholder": "Abbiamo bisogno di aiuto con...", "enterprise.form.send": "Invia", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index e1979041cd..d54af67bf7 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -697,8 +697,12 @@ export const dict = { "enterprise.form.name.placeholder": "ジェフ・ベゾス", "enterprise.form.role.label": "役職", "enterprise.form.role.placeholder": "会長", + "enterprise.form.company.label": "会社名", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "会社メールアドレス", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "電話番号", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "どのような課題を解決したいですか?", "enterprise.form.message.placeholder": "これについて支援が必要です...", "enterprise.form.send": "送信", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index bf90e9c4e8..9434150d51 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -688,8 +688,12 @@ export const dict = { "enterprise.form.name.placeholder": "홍길동", "enterprise.form.role.label": "직책", "enterprise.form.role.placeholder": "CTO / 개발 팀장", + "enterprise.form.company.label": "회사", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "회사 이메일", "enterprise.form.email.placeholder": "name@company.com", + "enterprise.form.phone.label": "전화번호", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "어떤 문제를 해결하고 싶으신가요?", "enterprise.form.message.placeholder": "도움이 필요한 부분은...", "enterprise.form.send": "전송", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 0aef49f0d8..d31ec1aeef 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -695,8 +695,12 @@ export const dict = { "enterprise.form.name.placeholder": "Jeff Bezos", "enterprise.form.role.label": "Rolle", "enterprise.form.role.placeholder": "Styreleder", + "enterprise.form.company.label": "Selskap", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "Bedrifts-e-post", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "Telefonnummer", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "Hvilket problem prøver dere å løse?", "enterprise.form.message.placeholder": "Vi trenger hjelp med...", "enterprise.form.send": "Send", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 19aa503df5..5c7005459e 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -698,8 +698,12 @@ export const dict = { "enterprise.form.name.placeholder": "Jeff Bezos", "enterprise.form.role.label": "Rola", "enterprise.form.role.placeholder": "Prezes Zarządu", + "enterprise.form.company.label": "Firma", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "E-mail firmowy", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "Numer telefonu", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "Jaki problem próbujesz rozwiązać?", "enterprise.form.message.placeholder": "Potrzebujemy pomocy z...", "enterprise.form.send": "Wyślij", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index e5dee8303a..779b4c9ac7 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -703,8 +703,12 @@ export const dict = { "enterprise.form.name.placeholder": "Джефф Безос", "enterprise.form.role.label": "Роль", "enterprise.form.role.placeholder": "Исполнительный председатель", + "enterprise.form.company.label": "Компания", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "Корпоративная почта", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "Номер телефона", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "Какую проблему вы пытаетесь решить?", "enterprise.form.message.placeholder": "Нам нужна помощь с...", "enterprise.form.send": "Отправить", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index c765a18133..7b69e5736a 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -691,8 +691,12 @@ export const dict = { "enterprise.form.name.placeholder": "Jeff Bezos", "enterprise.form.role.label": "ตำแหน่ง", "enterprise.form.role.placeholder": "ประธานกรรมการบริหาร", + "enterprise.form.company.label": "บริษัท", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "อีเมลบริษัท", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "หมายเลขโทรศัพท์", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "คุณกำลังพยายามแก้ปัญหาอะไร?", "enterprise.form.message.placeholder": "เราต้องการความช่วยเหลือเรื่อง...", "enterprise.form.send": "ส่ง", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 561153755b..f700d90dd1 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -700,8 +700,12 @@ export const dict = { "enterprise.form.name.placeholder": "Jeff Bezos", "enterprise.form.role.label": "Rol", "enterprise.form.role.placeholder": "Yönetim Kurulu Başkanı", + "enterprise.form.company.label": "Şirket", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "Şirket e-postası", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "Telefon numarası", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "Hangi problemi çözmeye çalışıyorsunuz?", "enterprise.form.message.placeholder": "Şu konuda yardıma ihtiyacımız var...", "enterprise.form.send": "Gönder", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 1a5fb0ff20..27fd284809 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -669,8 +669,12 @@ export const dict = { "enterprise.form.name.placeholder": "Jeff Bezos", "enterprise.form.role.label": "角色", "enterprise.form.role.placeholder": "执行主席", + "enterprise.form.company.label": "公司", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "公司邮箱", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "电话号码", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "您想解决什么问题?", "enterprise.form.message.placeholder": "我们需要帮助...", "enterprise.form.send": "发送", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index 44f3ebee00..55895747fb 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -668,8 +668,12 @@ export const dict = { "enterprise.form.name.placeholder": "傑夫·貝佐斯", "enterprise.form.role.label": "職稱", "enterprise.form.role.placeholder": "執行董事長", + "enterprise.form.company.label": "公司", + "enterprise.form.company.placeholder": "Acme Inc", "enterprise.form.email.label": "公司 Email", "enterprise.form.email.placeholder": "jeff@amazon.com", + "enterprise.form.phone.label": "電話號碼", + "enterprise.form.phone.placeholder": "+1 234 567 8900", "enterprise.form.message.label": "你想解決什麼問題?", "enterprise.form.message.placeholder": "我們需要幫助來...", "enterprise.form.send": "傳送", diff --git a/packages/console/app/src/lib/salesforce.ts b/packages/console/app/src/lib/salesforce.ts new file mode 100644 index 0000000000..48e0caee7d --- /dev/null +++ b/packages/console/app/src/lib/salesforce.ts @@ -0,0 +1,81 @@ +import { Resource } from "@opencode-ai/console-resource" + +async function login() { + const url = Resource.SALESFORCE_INSTANCE_URL.value.replace(/\/$/, "") + const clientId = Resource.SALESFORCE_CLIENT_ID.value + const clientSecret = Resource.SALESFORCE_CLIENT_SECRET.value + + const params = new URLSearchParams({ + grant_type: "client_credentials", + client_id: clientId, + client_secret: clientSecret, + }) + + const res = await fetch(`${url}/services/oauth2/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }).catch((err) => { + console.error("Failed to fetch Salesforce access token:", err) + }) + + if (!res) return + + if (!res.ok) { + console.error("Failed to fetch Salesforce access token:", res.status, await res.text()) + return + } + + const data = (await res.json()) as { access_token?: string; instance_url?: string } + if (!data.access_token) { + console.error("Salesforce auth response did not include an access token") + return + } + + return { + token: data.access_token, + url: data.instance_url ?? url, + } +} + +export interface SalesforceLeadInput { + name: string + role: string + company?: string + email: string + phone?: string + message: string +} + +export async function createLead(input: SalesforceLeadInput): Promise { + const auth = await login() + if (!auth) return false + + const res = await fetch(`${auth.url}/services/data/v59.0/sobjects/Lead`, { + method: "POST", + headers: { + Authorization: `Bearer ${auth.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + LastName: input.name, + Company: input.company?.trim() || "Website", + Email: input.email, + Phone: input.phone ?? null, + Title: input.role, + Description: input.message, + LeadSource: "Website", + }), + }).catch((err) => { + console.error("Failed to create Salesforce lead:", err) + }) + + if (!res) return false + + if (!res.ok) { + console.error("Failed to create Salesforce lead:", res.status, await res.text()) + return false + } + + return true +} diff --git a/packages/console/app/src/routes/api/enterprise.ts b/packages/console/app/src/routes/api/enterprise.ts index 27e2dc4938..1bc4d0eb29 100644 --- a/packages/console/app/src/routes/api/enterprise.ts +++ b/packages/console/app/src/routes/api/enterprise.ts @@ -2,11 +2,15 @@ import type { APIEvent } from "@solidjs/start/server" import { AWS } from "@opencode-ai/console-core/aws.js" import { i18n } from "~/i18n" import { localeFromRequest } from "~/lib/language" +import { createLead } from "~/lib/salesforce" interface EnterpriseFormData { name: string role: string + company?: string email: string + phone?: string + alias?: string message: string } @@ -14,33 +18,56 @@ export async function POST(event: APIEvent) { const dict = i18n(localeFromRequest(event.request)) try { const body = (await event.request.json()) as EnterpriseFormData + const trap = typeof body.alias === "string" ? body.alias.trim() : "" + + if (trap) { + return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 }) + } - // Validate required fields if (!body.name || !body.role || !body.email || !body.message) { return Response.json({ error: dict["enterprise.form.error.allFieldsRequired"] }, { status: 400 }) } - // Validate email format const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ if (!emailRegex.test(body.email)) { return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 }) } - // Create email content const emailContent = ` ${body.message}

--
${body.name}
${body.role}
-${body.email}`.trim() +${body.company ? `${body.company}
` : ""}${body.email}
+${body.phone ? `${body.phone}
` : ""}`.trim() - // Send email using AWS SES - await AWS.sendEmail({ - to: "contact@anoma.ly", - subject: `Enterprise Inquiry from ${body.name}`, - body: emailContent, - replyTo: body.email, - }) + const [lead, mail] = await Promise.all([ + createLead({ + name: body.name, + role: body.role, + company: body.company, + email: body.email, + phone: body.phone, + message: body.message, + }), + AWS.sendEmail({ + to: "contact@anoma.ly", + subject: `Enterprise Inquiry from ${body.name}`, + body: emailContent, + replyTo: body.email, + }).then( + () => true, + (err) => { + console.error("Failed to send enterprise email:", err) + return false + }, + ), + ]) + + if (!lead && !mail) { + console.error("Enterprise inquiry delivery failed", { email: body.email }) + return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 }) + } return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 }) } catch (error) { diff --git a/packages/console/app/src/routes/enterprise/index.css b/packages/console/app/src/routes/enterprise/index.css index 584c94fa54..5c594bb51b 100644 --- a/packages/console/app/src/routes/enterprise/index.css +++ b/packages/console/app/src/routes/enterprise/index.css @@ -23,6 +23,7 @@ --color-text-strong: hsl(0, 5%, 12%); --color-text-inverted: hsl(0, 20%, 99%); --color-text-success: hsl(119, 100%, 35%); + --color-text-error: hsl(4, 72%, 45%); --color-border: hsl(30, 2%, 81%); --color-border-weak: hsl(0, 1%, 85%); @@ -50,6 +51,7 @@ --color-text-strong: hsl(0, 15%, 94%); --color-text-inverted: hsl(0, 9%, 7%); --color-text-success: hsl(119, 60%, 72%); + --color-text-error: hsl(4, 76%, 72%); --color-border: hsl(0, 3%, 28%); --color-border-weak: hsl(0, 4%, 23%); @@ -454,6 +456,13 @@ color: var(--color-text-success); text-align: left; } + + [data-component="error-message"] { + margin-top: 1rem; + padding: 1rem 0; + color: var(--color-text-error); + text-align: left; + } } } diff --git a/packages/console/app/src/routes/enterprise/index.tsx b/packages/console/app/src/routes/enterprise/index.tsx index ee323ff826..9e3d034738 100644 --- a/packages/console/app/src/routes/enterprise/index.tsx +++ b/packages/console/app/src/routes/enterprise/index.tsx @@ -13,11 +13,15 @@ export default function Enterprise() { const [formData, setFormData] = createSignal({ name: "", role: "", + company: "", email: "", + phone: "", + alias: "", message: "", }) const [isSubmitting, setIsSubmitting] = createSignal(false) const [showSuccess, setShowSuccess] = createSignal(false) + const [error, setError] = createSignal("") const handleInputChange = (field: string) => (e: Event) => { const target = e.target as HTMLInputElement | HTMLTextAreaElement @@ -26,6 +30,8 @@ export default function Enterprise() { const handleSubmit = async (e: Event) => { e.preventDefault() + setError("") + setShowSuccess(false) setIsSubmitting(true) try { @@ -42,13 +48,21 @@ export default function Enterprise() { setFormData({ name: "", role: "", + company: "", email: "", + phone: "", + alias: "", message: "", }) setTimeout(() => setShowSuccess(false), 5000) + return } + + const data = (await response.json().catch(() => null)) as { error?: string } | null + setError(data?.error ?? i18n.t("enterprise.form.error.internalServer")) } catch (error) { console.error("Failed to submit form:", error) + setError(i18n.t("enterprise.form.error.internalServer")) } finally { setIsSubmitting(false) } @@ -147,6 +161,19 @@ export default function Enterprise() {
+ +
+
+ + +
+
+
+ + +
+