From 8c53b2b47033c579b46b02b1ba9638004de0154f Mon Sep 17 00:00:00 2001 From: James Long Date: Sat, 14 Mar 2026 10:27:06 -0400 Subject: [PATCH 01/31] fix(core): increase default chunk timeout from 2 min to 5 min (#17490) --- packages/opencode/src/provider/provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 92b001a6f69..349073197d7 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -47,7 +47,7 @@ import { ProviderTransform } from "./transform" import { Installation } from "../installation" import { ModelID, ProviderID } from "./schema" -const DEFAULT_CHUNK_TIMEOUT = 120_000 +const DEFAULT_CHUNK_TIMEOUT = 300_000 export namespace Provider { const log = Log.create({ service: "provider" }) From 88226f30610d6038a431796a8ae5917199d49c74 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 14 Mar 2026 10:46:24 -0500 Subject: [PATCH 02/31] tweak: ensure that compaction message is tracked as agent initiated (#17431) --- packages/opencode/src/plugin/copilot.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 3945c63ce28..ddb4d9046a6 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -309,6 +309,24 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14" } + const parts = await sdk.session + .message({ + path: { + id: incoming.message.sessionID, + messageID: incoming.message.id, + }, + query: { + directory: input.directory, + }, + throwOnError: true, + }) + .catch(() => undefined) + + if (parts?.data.parts?.some((part) => part.type === "compaction")) { + output.headers["x-initiator"] = "agent" + return + } + const session = await sdk.session .get({ path: { From cec1255b36e3f2c615082ad71d90eed338a47325 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 14 Mar 2026 11:58:00 -0400 Subject: [PATCH 03/31] refactor(question): effectify QuestionService (#17432) --- packages/opencode/src/effect/runtime.ts | 5 +- .../opencode/src/provider/auth-service.ts | 23 ++- packages/opencode/src/question/index.ts | 175 +++-------------- packages/opencode/src/question/schema.ts | 20 +- packages/opencode/src/question/service.ts | 181 ++++++++++++++++++ packages/opencode/src/util/instance-state.ts | 62 +++--- packages/opencode/src/util/schema.ts | 37 ++++ .../opencode/test/question/question.test.ts | 24 ++- .../opencode/test/util/instance-state.test.ts | 51 +++-- 9 files changed, 347 insertions(+), 231 deletions(-) create mode 100644 packages/opencode/src/question/service.ts diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index 23acff73379..de4bc3dda24 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,5 +1,8 @@ import { Layer, ManagedRuntime } from "effect" import { AccountService } from "@/account/service" import { AuthService } from "@/auth/service" +import { QuestionService } from "@/question/service" -export const runtime = ManagedRuntime.make(Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer)) +export const runtime = ManagedRuntime.make( + Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer, QuestionService.layer), +) diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts index 4b5ac1777a8..2d9cec5cd85 100644 --- a/packages/opencode/src/provider/auth-service.ts +++ b/packages/opencode/src/provider/auth-service.ts @@ -79,18 +79,17 @@ 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 state = yield* InstanceState.make(() => + 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 methods = Effect.fn("ProviderAuthService.methods")(function* () { const x = yield* InstanceState.get(state) diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index cf52979fc88..fc0c7dd41b7 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,167 +1,44 @@ -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" -import { SessionID, MessageID } from "@/session/schema" -import { Instance } from "@/project/instance" -import { Log } from "@/util/log" -import z from "zod" -import { QuestionID } from "./schema" +import { Effect } from "effect" +import { runtime } 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 { - const log = Log.create({ service: "question" }) - - export const Option = z - .object({ - label: z.string().describe("Display text (1-5 words, concise)"), - description: z.string().describe("Explanation of choice"), - }) - .meta({ - ref: "QuestionOption", - }) - export type Option = z.infer - - export const Info = z - .object({ - question: z.string().describe("Complete question"), - header: z.string().describe("Very short label (max 30 chars)"), - options: z.array(Option).describe("Available choices"), - multiple: z.boolean().optional().describe("Allow selecting multiple choices"), - custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"), - }) - .meta({ - ref: "QuestionInfo", - }) - export type Info = z.infer - - export const Request = z - .object({ - id: QuestionID.zod, - sessionID: SessionID.zod, - questions: z.array(Info).describe("Questions to ask"), - tool: z - .object({ - messageID: MessageID.zod, - callID: z.string(), - }) - .optional(), - }) - .meta({ - ref: "QuestionRequest", - }) - export type Request = z.infer - - export const Answer = z.array(z.string()).meta({ - ref: "QuestionAnswer", - }) - export type Answer = z.infer - - export const Reply = z.object({ - answers: z - .array(Answer) - .describe("User answers in order of questions (each answer is an array of selected labels)"), - }) - export type Reply = z.infer - - export const Event = { - Asked: BusEvent.define("question.asked", Request), - Replied: BusEvent.define( - "question.replied", - z.object({ - sessionID: SessionID.zod, - requestID: QuestionID.zod, - answers: z.array(Answer), - }), - ), - Rejected: BusEvent.define( - "question.rejected", - z.object({ - sessionID: SessionID.zod, - requestID: QuestionID.zod, - }), - ), - } - - interface PendingEntry { - info: Request - resolve: (answers: Answer[]) => void - reject: (e: any) => void - } - - const state = Instance.state(async () => ({ - pending: new Map(), - })) + export const Option = S.Option + export type Option = S.Option + export const Info = S.Info + export type Info = S.Info + export const Request = S.Request + export type Request = S.Request + export const Answer = S.Answer + export type Answer = S.Answer + export const Reply = S.Reply + export type Reply = S.Reply + export const Event = S.Event + export const RejectedError = S.RejectedError export async function ask(input: { sessionID: SessionID questions: Info[] tool?: { messageID: MessageID; callID: string } }): Promise { - const s = await state() - const id = QuestionID.ascending() - - log.info("asking", { id, questions: input.questions.length }) - - return new Promise((resolve, reject) => { - const info: Request = { - id, - sessionID: input.sessionID, - questions: input.questions, - tool: input.tool, - } - s.pending.set(id, { - info, - resolve, - reject, - }) - Bus.publish(Event.Asked, info) - }) + return runPromise((service) => service.ask(input)) } export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise { - const s = await state() - const existing = s.pending.get(input.requestID) - if (!existing) { - log.warn("reply for unknown request", { requestID: input.requestID }) - return - } - s.pending.delete(input.requestID) - - log.info("replied", { requestID: input.requestID, answers: input.answers }) - - Bus.publish(Event.Replied, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - answers: input.answers, - }) - - existing.resolve(input.answers) + return runPromise((service) => service.reply(input)) } export async function reject(requestID: QuestionID): Promise { - const s = await state() - const existing = s.pending.get(requestID) - if (!existing) { - log.warn("reject for unknown request", { requestID }) - return - } - s.pending.delete(requestID) - - log.info("rejected", { requestID }) - - Bus.publish(Event.Rejected, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - }) - - existing.reject(new RejectedError()) - } - - export class RejectedError extends Error { - constructor() { - super("The user dismissed this question") - } + return runPromise((service) => service.reject(requestID)) } - export async function list() { - return state().then((x) => Array.from(x.pending.values(), (x) => x.info)) + export async function list(): Promise { + return runPromise((service) => service.list()) } } diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index 65e9ad07cbb..38b930af11d 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -2,16 +2,16 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" -import { withStatics } from "@/util/schema" +import { Newtype } from "@/util/schema" -const questionIdSchema = Schema.String.pipe(Schema.brand("QuestionID")) +export class QuestionID extends Newtype()("QuestionID", Schema.String) { + static make(id: string): QuestionID { + return this.makeUnsafe(id) + } -export type QuestionID = typeof questionIdSchema.Type + static ascending(id?: string): QuestionID { + return this.makeUnsafe(Identifier.ascending("question", id)) + } -export const QuestionID = questionIdSchema.pipe( - withStatics((schema: typeof questionIdSchema) => ({ - make: (id: string) => schema.makeUnsafe(id), - ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("question", id)), - zod: Identifier.schema("question").pipe(z.custom()), - })), -) + static readonly zod = Identifier.schema("question") as unknown as z.ZodType +} diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts new file mode 100644 index 00000000000..6b353c7f105 --- /dev/null +++ b/packages/opencode/src/question/service.ts @@ -0,0 +1,181 @@ +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" + +const log = Log.create({ service: "question" }) + +// --- Zod schemas (re-exported by facade) --- + +export const Option = z + .object({ + label: z.string().describe("Display text (1-5 words, concise)"), + description: z.string().describe("Explanation of choice"), + }) + .meta({ ref: "QuestionOption" }) +export type Option = z.infer + +export const Info = z + .object({ + question: z.string().describe("Complete question"), + header: z.string().describe("Very short label (max 30 chars)"), + options: z.array(Option).describe("Available choices"), + multiple: z.boolean().optional().describe("Allow selecting multiple choices"), + custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"), + }) + .meta({ ref: "QuestionInfo" }) +export type Info = z.infer + +export const Request = z + .object({ + id: QuestionID.zod, + sessionID: SessionID.zod, + questions: z.array(Info).describe("Questions to ask"), + tool: z + .object({ + messageID: MessageID.zod, + callID: z.string(), + }) + .optional(), + }) + .meta({ ref: "QuestionRequest" }) +export type Request = z.infer + +export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) +export type Answer = z.infer + +export const Reply = z.object({ + answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"), +}) +export type Reply = z.infer + +export const Event = { + Asked: BusEvent.define("question.asked", Request), + Replied: BusEvent.define( + "question.replied", + z.object({ + sessionID: SessionID.zod, + requestID: QuestionID.zod, + answers: z.array(Answer), + }), + ), + Rejected: BusEvent.define( + "question.rejected", + z.object({ + sessionID: SessionID.zod, + requestID: QuestionID.zod, + }), + ), +} + +export class RejectedError extends Error { + constructor() { + super("The user dismissed this question") + } +} + +// --- Effect service --- + +export class QuestionServiceError extends Schema.TaggedErrorClass()("QuestionServiceError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +interface PendingEntry { + info: Request + deferred: Deferred.Deferred +} + +export namespace QuestionService { + export interface Service { + readonly ask: (input: { + sessionID: SessionID + questions: Info[] + tool?: { messageID: MessageID; callID: string } + }) => Effect.Effect + readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect + readonly reject: (requestID: QuestionID) => Effect.Effect + readonly list: () => Effect.Effect + } +} + +export class QuestionService extends ServiceMap.Service()( + "@opencode/Question", +) { + static readonly layer = Layer.effect( + QuestionService, + Effect.gen(function* () { + const instanceState = yield* InstanceState.make, QuestionServiceError>(() => + Effect.succeed(new Map()), + ) + + const getPending = InstanceState.get(instanceState) + + 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 }) + + const deferred = yield* Deferred.make() + const info: Request = { + id, + sessionID: input.sessionID, + questions: input.questions, + tool: input.tool, + } + pending.set(id, { info, deferred }) + Bus.publish(Event.Asked, info) + + return yield* Deferred.await(deferred) + }) + + const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) { + const pending = yield* getPending + const existing = pending.get(input.requestID) + if (!existing) { + log.warn("reply for unknown request", { requestID: input.requestID }) + return + } + pending.delete(input.requestID) + log.info("replied", { requestID: input.requestID, answers: input.answers }) + Bus.publish(Event.Replied, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + answers: input.answers, + }) + yield* Deferred.succeed(existing.deferred, input.answers) + }) + + const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) { + const pending = yield* getPending + const existing = pending.get(requestID) + if (!existing) { + log.warn("reject for unknown request", { requestID }) + return + } + pending.delete(requestID) + log.info("rejected", { requestID }) + Bus.publish(Event.Rejected, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + }) + yield* Deferred.die(existing.deferred, new RejectedError()) + }) + + const list = Effect.fn("QuestionService.list")(function* () { + const pending = yield* getPending + return Array.from(pending.values(), (x) => x.info) + }) + + return QuestionService.of({ ask, reply, reject, list }) + }), + ) +} diff --git a/packages/opencode/src/util/instance-state.ts b/packages/opencode/src/util/instance-state.ts index 5d0ffbf7930..15cc3b714cf 100644 --- a/packages/opencode/src/util/instance-state.ts +++ b/packages/opencode/src/util/instance-state.ts @@ -2,34 +2,39 @@ import { Effect, ScopedCache, Scope } from "effect" import { Instance } from "@/project/instance" -const TypeId = Symbol.for("@opencode/InstanceState") - -type Task = (key: string) => Effect.Effect - -const tasks = new Set() +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 { - export interface State { - readonly [TypeId]: typeof TypeId - readonly cache: ScopedCache.ScopedCache - } - - export const make = (input: { - lookup: (key: string) => Effect.Effect - release?: (value: A, key: string) => Effect.Effect - }): Effect.Effect, never, R | Scope.Scope> => + /** 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: (key) => - Effect.acquireRelease(input.lookup(key), (value) => - input.release ? input.release(value, key) : Effect.void, - ), + lookup: init, }) - const task: Task = (key) => ScopedCache.invalidate(cache, key) - tasks.add(task) - yield* Effect.addFinalizer(() => Effect.sync(() => void tasks.delete(task))) + const disposer: Disposer = (directory) => ScopedCache.invalidate(cache, directory) + disposers.add(disposer) + yield* Effect.addFinalizer(() => Effect.sync(() => void disposers.delete(disposer))) return { [TypeId]: TypeId, @@ -37,15 +42,20 @@ export namespace InstanceState { } }) - export const get = (self: State) => ScopedCache.get(self.cache, Instance.directory) + /** Get the cached value for the current directory, initializing it if needed. */ + export const get = (self: InstanceState) => ScopedCache.get(self.cache, Instance.directory) - export const has = (self: State) => ScopedCache.has(self.cache, Instance.directory) + /** Check whether a value exists for the current directory. */ + export const has = (self: InstanceState) => ScopedCache.has(self.cache, Instance.directory) - export const invalidate = (self: State) => ScopedCache.invalidate(self.cache, Instance.directory) + /** Invalidate the cached value for the current directory. */ + export const invalidate = (self: InstanceState) => + ScopedCache.invalidate(self.cache, Instance.directory) - export const dispose = (key: string) => + /** Invalidate the given directory across all InstanceState caches. */ + export const dispose = (directory: string) => Effect.all( - [...tasks].map((task) => task(key)), + [...disposers].map((disposer) => disposer(directory)), { concurrency: "unbounded" }, ) } diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts index 180f952d731..944b7ffcb9c 100644 --- a/packages/opencode/src/util/schema.ts +++ b/packages/opencode/src/util/schema.ts @@ -15,3 +15,40 @@ export const withStatics = >(methods: (schema: S) => M) => (schema: S): S & M => Object.assign(schema, methods(schema)) + +declare const NewtypeBrand: unique symbol +type NewtypeBrand = { readonly [NewtypeBrand]: Tag } + +/** + * Nominal wrapper for scalar types. The class itself is a valid schema — + * pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc. + * + * @example + * class QuestionID extends Newtype()("QuestionID", Schema.String) { + * static make(id: string): QuestionID { + * return this.makeUnsafe(id) + * } + * } + * + * Schema.decodeEffect(QuestionID)(input) + */ +export function Newtype() { + return (tag: Tag, schema: S) => { + type Branded = NewtypeBrand + + abstract class Base { + declare readonly [NewtypeBrand]: Tag + + static makeUnsafe(value: Schema.Schema.Type): Self { + return value as unknown as Self + } + } + + Object.setPrototypeOf(Base, schema) + + return Base as unknown as + & (abstract new (_: never) => Branded) + & { readonly makeUnsafe: (value: Schema.Schema.Type) => Self } + & Omit, "makeUnsafe"> + } +} diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index f00afb09fd5..ab5bc1d99eb 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -5,6 +5,14 @@ import { QuestionID } from "../../src/question/schema" import { tmpdir } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" +/** Reject all pending questions so dangling Deferred fibers don't hang the test. */ +async function rejectAll() { + const pending = await Question.list() + for (const req of pending) { + await Question.reject(req.id) + } +} + test("ask - returns pending promise", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ @@ -24,6 +32,8 @@ test("ask - returns pending promise", async () => { ], }) expect(promise).toBeInstanceOf(Promise) + await rejectAll() + await promise.catch(() => {}) }, }) }) @@ -44,7 +54,7 @@ test("ask - adds to pending list", async () => { }, ] - Question.ask({ + const askPromise = Question.ask({ sessionID: SessionID.make("ses_test"), questions, }) @@ -52,6 +62,8 @@ test("ask - adds to pending list", async () => { const pending = await Question.list() expect(pending.length).toBe(1) expect(pending[0].questions).toEqual(questions) + await rejectAll() + await askPromise.catch(() => {}) }, }) }) @@ -98,7 +110,7 @@ test("reply - removes from pending list", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - Question.ask({ + const askPromise = Question.ask({ sessionID: SessionID.make("ses_test"), questions: [ { @@ -119,6 +131,7 @@ test("reply - removes from pending list", async () => { requestID: pending[0].id, answers: [["Option 1"]], }) + await askPromise const pendingAfter = await Question.list() expect(pendingAfter.length).toBe(0) @@ -262,7 +275,7 @@ test("list - returns all pending requests", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - Question.ask({ + const p1 = Question.ask({ sessionID: SessionID.make("ses_test1"), questions: [ { @@ -273,7 +286,7 @@ test("list - returns all pending requests", async () => { ], }) - Question.ask({ + const p2 = Question.ask({ sessionID: SessionID.make("ses_test2"), questions: [ { @@ -286,6 +299,9 @@ test("list - returns all pending requests", async () => { const pending = await Question.list() expect(pending.length).toBe(2) + await rejectAll() + p1.catch(() => {}) + p2.catch(() => {}) }, }) }) diff --git a/packages/opencode/test/util/instance-state.test.ts b/packages/opencode/test/util/instance-state.test.ts index e5d2129fb07..19e051f3821 100644 --- a/packages/opencode/test/util/instance-state.test.ts +++ b/packages/opencode/test/util/instance-state.test.ts @@ -5,7 +5,7 @@ import { Instance } from "../../src/project/instance" import { InstanceState } from "../../src/util/instance-state" import { tmpdir } from "../fixture/fixture" -async function access(state: InstanceState.State, dir: string) { +async function access(state: InstanceState, dir: string) { return Instance.provide({ directory: dir, fn: () => Effect.runPromise(InstanceState.get(state)), @@ -23,9 +23,7 @@ test("InstanceState caches values for the same instance", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const state = yield* InstanceState.make({ - lookup: () => Effect.sync(() => ({ n: ++n })), - }) + 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)) @@ -45,9 +43,7 @@ test("InstanceState isolates values by directory", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const state = yield* InstanceState.make({ - lookup: (dir) => Effect.sync(() => ({ dir, n: ++n })), - }) + 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)) @@ -69,13 +65,12 @@ test("InstanceState is disposed on instance reload", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const state = yield* InstanceState.make({ - lookup: () => Effect.sync(() => ({ n: ++n })), - release: (value) => - Effect.sync(() => { - seen.push(String(value.n)) - }), - }) + 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 })) @@ -96,13 +91,12 @@ test("InstanceState is disposed on disposeAll", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const state = yield* InstanceState.make({ - lookup: (dir) => Effect.sync(() => ({ dir })), - release: (value) => - Effect.sync(() => { - seen.push(value.dir) - }), - }) + 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)) @@ -121,14 +115,13 @@ test("InstanceState dedupes concurrent lookups for the same directory", async () await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const state = yield* InstanceState.make({ - lookup: () => - Effect.promise(async () => { - n += 1 - await Bun.sleep(10) - return { n } - }), - }) + 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) From b698f14e5550c18ab6de1a8171ffcf3200d9653b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 14 Mar 2026 15:59:01 +0000 Subject: [PATCH 04/31] chore: generate --- packages/opencode/src/util/schema.ts | 7 +- .../opencode/test/util/instance-state.test.ts | 10 +- packages/sdk/js/src/v2/gen/types.gen.ts | 148 ++++---- packages/sdk/openapi.json | 318 +++++++++--------- 4 files changed, 244 insertions(+), 239 deletions(-) diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts index 944b7ffcb9c..6a88dba539b 100644 --- a/packages/opencode/src/util/schema.ts +++ b/packages/opencode/src/util/schema.ts @@ -46,9 +46,8 @@ export function Newtype() { Object.setPrototypeOf(Base, schema) - return Base as unknown as - & (abstract new (_: never) => Branded) - & { readonly makeUnsafe: (value: Schema.Schema.Type) => Self } - & Omit, "makeUnsafe"> + return Base as unknown as (abstract new (_: never) => Branded) & { + readonly makeUnsafe: (value: Schema.Schema.Type) => Self + } & Omit, "makeUnsafe"> } } diff --git a/packages/opencode/test/util/instance-state.test.ts b/packages/opencode/test/util/instance-state.test.ts index 19e051f3821..29d0738c11c 100644 --- a/packages/opencode/test/util/instance-state.test.ts +++ b/packages/opencode/test/util/instance-state.test.ts @@ -68,7 +68,10 @@ test("InstanceState is disposed on instance reload", async () => { const state = yield* InstanceState.make(() => Effect.acquireRelease( Effect.sync(() => ({ n: ++n })), - (value) => Effect.sync(() => { seen.push(String(value.n)) }), + (value) => + Effect.sync(() => { + seen.push(String(value.n)) + }), ), ) @@ -94,7 +97,10 @@ test("InstanceState is disposed on disposeAll", async () => { const state = yield* InstanceState.make((dir) => Effect.acquireRelease( Effect.sync(() => ({ dir })), - (value) => Effect.sync(() => { seen.push(value.dir) }), + (value) => + Effect.sync(() => { + seen.push(value.dir) + }), ), ) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2629015eb34..b2ee46670b1 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -54,6 +54,77 @@ export type EventServerInstanceDisposed = { } } +export type QuestionOption = { + /** + * Display text (1-5 words, concise) + */ + label: string + /** + * Explanation of choice + */ + description: string +} + +export type QuestionInfo = { + /** + * Complete question + */ + question: string + /** + * Very short label (max 30 chars) + */ + header: string + /** + * Available choices + */ + options: Array + /** + * Allow selecting multiple choices + */ + multiple?: boolean + /** + * Allow typing a custom answer (default: true) + */ + custom?: boolean +} + +export type QuestionRequest = { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: { + messageID: string + callID: string + } +} + +export type EventQuestionAsked = { + type: "question.asked" + properties: QuestionRequest +} + +export type QuestionAnswer = Array + +export type EventQuestionReplied = { + type: "question.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } +} + +export type EventQuestionRejected = { + type: "question.rejected" + properties: { + sessionID: string + requestID: string + } +} + export type EventServerConnected = { type: "server.connected" properties: { @@ -607,77 +678,6 @@ export type EventSessionIdle = { } } -export type QuestionOption = { - /** - * Display text (1-5 words, concise) - */ - label: string - /** - * Explanation of choice - */ - description: string -} - -export type QuestionInfo = { - /** - * Complete question - */ - question: string - /** - * Very short label (max 30 chars) - */ - header: string - /** - * Available choices - */ - options: Array - /** - * Allow selecting multiple choices - */ - multiple?: boolean - /** - * Allow typing a custom answer (default: true) - */ - custom?: boolean -} - -export type QuestionRequest = { - id: string - sessionID: string - /** - * Questions to ask - */ - questions: Array - tool?: { - messageID: string - callID: string - } -} - -export type EventQuestionAsked = { - type: "question.asked" - properties: QuestionRequest -} - -export type QuestionAnswer = Array - -export type EventQuestionReplied = { - type: "question.replied" - properties: { - sessionID: string - requestID: string - answers: Array - } -} - -export type EventQuestionRejected = { - type: "question.rejected" - properties: { - sessionID: string - requestID: string - } -} - export type EventSessionCompacted = { type: "session.compacted" properties: { @@ -962,6 +962,9 @@ export type Event = | EventInstallationUpdateAvailable | EventProjectUpdated | EventServerInstanceDisposed + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected | EventServerConnected | EventGlobalDisposed | EventLspClientDiagnostics @@ -976,9 +979,6 @@ export type Event = | EventPermissionReplied | EventSessionStatus | EventSessionIdle - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected | EventSessionCompacted | EventFileWatcherUpdated | EventTodoUpdated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index e2a1eebb0a8..d79de78e7aa 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7062,6 +7062,156 @@ }, "required": ["type", "properties"] }, + "QuestionOption": { + "type": "object", + "properties": { + "label": { + "description": "Display text (1-5 words, concise)", + "type": "string" + }, + "description": { + "description": "Explanation of choice", + "type": "string" + } + }, + "required": ["label", "description"] + }, + "QuestionInfo": { + "type": "object", + "properties": { + "question": { + "description": "Complete question", + "type": "string" + }, + "header": { + "description": "Very short label (max 30 chars)", + "type": "string" + }, + "options": { + "description": "Available choices", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionOption" + } + }, + "multiple": { + "description": "Allow selecting multiple choices", + "type": "boolean" + }, + "custom": { + "description": "Allow typing a custom answer (default: true)", + "type": "boolean" + } + }, + "required": ["question", "header", "options"] + }, + "QuestionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "questions": { + "description": "Questions to ask", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "questions"] + }, + "Event.question.asked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.asked" + }, + "properties": { + "$ref": "#/components/schemas/QuestionRequest" + } + }, + "required": ["type", "properties"] + }, + "QuestionAnswer": { + "type": "array", + "items": { + "type": "string" + } + }, + "Event.question.replied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.replied" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^que.*" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"] + } + }, + "required": ["type", "properties"] + }, + "Event.question.rejected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.rejected" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^que.*" + } + }, + "required": ["sessionID", "requestID"] + } + }, + "required": ["type", "properties"] + }, "Event.server.connected": { "type": "object", "properties": { @@ -8696,156 +8846,6 @@ }, "required": ["type", "properties"] }, - "QuestionOption": { - "type": "object", - "properties": { - "label": { - "description": "Display text (1-5 words, concise)", - "type": "string" - }, - "description": { - "description": "Explanation of choice", - "type": "string" - } - }, - "required": ["label", "description"] - }, - "QuestionInfo": { - "type": "object", - "properties": { - "question": { - "description": "Complete question", - "type": "string" - }, - "header": { - "description": "Very short label (max 30 chars)", - "type": "string" - }, - "options": { - "description": "Available choices", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionOption" - } - }, - "multiple": { - "description": "Allow selecting multiple choices", - "type": "boolean" - }, - "custom": { - "description": "Allow typing a custom answer (default: true)", - "type": "boolean" - } - }, - "required": ["question", "header", "options"] - }, - "QuestionRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^que.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "questions": { - "description": "Questions to ask", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionInfo" - } - }, - "tool": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "callID": { - "type": "string" - } - }, - "required": ["messageID", "callID"] - } - }, - "required": ["id", "sessionID", "questions"] - }, - "Event.question.asked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.asked" - }, - "properties": { - "$ref": "#/components/schemas/QuestionRequest" - } - }, - "required": ["type", "properties"] - }, - "QuestionAnswer": { - "type": "array", - "items": { - "type": "string" - } - }, - "Event.question.replied": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.replied" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^que.*" - }, - "answers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } - } - }, - "required": ["sessionID", "requestID", "answers"] - } - }, - "required": ["type", "properties"] - }, - "Event.question.rejected": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.rejected" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^que.*" - } - }, - "required": ["sessionID", "requestID"] - } - }, - "required": ["type", "properties"] - }, "Event.session.compacted": { "type": "object", "properties": { @@ -9611,6 +9611,15 @@ { "$ref": "#/components/schemas/Event.server.instance.disposed" }, + { + "$ref": "#/components/schemas/Event.question.asked" + }, + { + "$ref": "#/components/schemas/Event.question.replied" + }, + { + "$ref": "#/components/schemas/Event.question.rejected" + }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -9653,15 +9662,6 @@ { "$ref": "#/components/schemas/Event.session.idle" }, - { - "$ref": "#/components/schemas/Event.question.asked" - }, - { - "$ref": "#/components/schemas/Event.question.replied" - }, - { - "$ref": "#/components/schemas/Event.question.rejected" - }, { "$ref": "#/components/schemas/Event.session.compacted" }, From 66e8c57ed1077814c9a150b858a53fdd7c758c0f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 14 Mar 2026 12:14:46 -0400 Subject: [PATCH 05/31] refactor(schema): inline branded ID schemas (#17504) --- packages/opencode/src/session/schema.ts | 49 ++++++++++++------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index b37fefae69d..540643c4918 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,41 +1,38 @@ import { Schema } from "effect" import z from "zod" -import { withStatics } from "@/util/schema" import { Identifier } from "@/id/id" +import { withStatics } from "@/util/schema" -const sessionIdSchema = Schema.String.pipe(Schema.brand("SessionID")) - -export type SessionID = typeof sessionIdSchema.Type - -export const SessionID = sessionIdSchema.pipe( - withStatics((schema: typeof sessionIdSchema) => ({ - make: (id: string) => schema.makeUnsafe(id), - descending: (id?: string) => schema.makeUnsafe(Identifier.descending("session", id)), - zod: Identifier.schema("session").pipe(z.custom()), +export const SessionID = Schema.String.pipe( + Schema.brand("SessionID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + descending: (id?: string) => s.makeUnsafe(Identifier.descending("session", id)), + zod: Identifier.schema("session").pipe(z.custom>()), })), ) -const messageIdSchema = Schema.String.pipe(Schema.brand("MessageID")) +export type SessionID = Schema.Schema.Type -export type MessageID = typeof messageIdSchema.Type - -export const MessageID = messageIdSchema.pipe( - withStatics((schema: typeof messageIdSchema) => ({ - make: (id: string) => schema.makeUnsafe(id), - ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("message", id)), - zod: Identifier.schema("message").pipe(z.custom()), +export const MessageID = Schema.String.pipe( + Schema.brand("MessageID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("message", id)), + zod: Identifier.schema("message").pipe(z.custom>()), })), ) -const partIdSchema = Schema.String.pipe(Schema.brand("PartID")) - -export type PartID = typeof partIdSchema.Type +export type MessageID = Schema.Schema.Type -export const PartID = partIdSchema.pipe( - withStatics((schema: typeof partIdSchema) => ({ - make: (id: string) => schema.makeUnsafe(id), - ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("part", id)), - zod: Identifier.schema("part").pipe(z.custom()), +export const PartID = Schema.String.pipe( + Schema.brand("PartID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("part", id)), + zod: Identifier.schema("part").pipe(z.custom>()), })), ) + +export type PartID = Schema.Schema.Type From 689d9e14eade9001568c46c602092eb01fe7e746 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 14 Mar 2026 22:51:45 +0530 Subject: [PATCH 06/31] fix(app): handle multiline web paste in prompt composer (#17509) --- packages/app/src/components/prompt-input.tsx | 3 -- .../prompt-input/attachments.test.ts | 20 +++++++++++ .../components/prompt-input/attachments.ts | 33 ++++++++----------- .../app/src/components/prompt-input/paste.ts | 24 ++++++++++++++ 4 files changed, 57 insertions(+), 23 deletions(-) create mode 100644 packages/app/src/components/prompt-input/paste.ts diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 9048fa895ad..b2553e4c024 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -2,7 +2,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 { createStore } from "solid-js/store" -import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { @@ -411,7 +410,6 @@ export const PromptInput: Component = (props) => { } } - const isFocused = createFocusSignal(() => editorRef) const escBlur = () => platform.platform === "desktop" && platform.os === "macos" const pick = () => fileInputRef?.click() @@ -1014,7 +1012,6 @@ export const PromptInput: Component = (props) => { const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({ editor: () => editorRef, - isFocused, isDialogActive: () => !!dialog.active, setDraggingType: (type) => setStore("draggingType", type), focusEditor: () => { diff --git a/packages/app/src/components/prompt-input/attachments.test.ts b/packages/app/src/components/prompt-input/attachments.test.ts index d8ae43d13b3..43f7d425bd1 100644 --- a/packages/app/src/components/prompt-input/attachments.test.ts +++ b/packages/app/src/components/prompt-input/attachments.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { attachmentMime } from "./files" +import { pasteMode } from "./paste" describe("attachmentMime", () => { test("keeps PDFs when the browser reports the mime", async () => { @@ -22,3 +23,22 @@ describe("attachmentMime", () => { expect(await attachmentMime(file)).toBeUndefined() }) }) + +describe("pasteMode", () => { + test("uses native paste for short single-line text", () => { + expect(pasteMode("hello world")).toBe("native") + }) + + test("uses manual paste for multiline text", () => { + expect( + pasteMode(`{ + "ok": true +}`), + ).toBe("manual") + expect(pasteMode("a\r\nb")).toBe("manual") + }) + + test("uses manual paste for large text", () => { + expect(pasteMode("x".repeat(8000))).toBe("manual") + }) +}) diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index b465ea5db8c..eca508c6ce6 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -5,8 +5,7 @@ import { useLanguage } from "@/context/language" import { uuid } from "@/utils/uuid" import { getCursorPosition } from "./editor-dom" import { attachmentMime } from "./files" -const LARGE_PASTE_CHARS = 8000 -const LARGE_PASTE_BREAKS = 120 +import { normalizePaste, pasteMode } from "./paste" function dataUrl(file: File, mime: string) { return new Promise((resolve) => { @@ -25,20 +24,8 @@ function dataUrl(file: File, mime: string) { }) } -function largePaste(text: string) { - if (text.length >= LARGE_PASTE_CHARS) return true - let breaks = 0 - for (const char of text) { - if (char !== "\n") continue - breaks += 1 - if (breaks >= LARGE_PASTE_BREAKS) return true - } - return false -} - type PromptAttachmentsInput = { editor: () => HTMLDivElement | undefined - isFocused: () => boolean isDialogActive: () => boolean setDraggingType: (type: "image" | "@mention" | null) => void focusEditor: () => void @@ -91,7 +78,6 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { } const handlePaste = async (event: ClipboardEvent) => { - if (!input.isFocused()) return const clipboardData = event.clipboardData if (!clipboardData) return @@ -126,16 +112,23 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { if (!plainText) return - if (largePaste(plainText)) { - if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return + const text = normalizePaste(plainText) + + const put = () => { + if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true input.focusEditor() - if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return + return input.addPart({ type: "text", content: text, start: 0, end: 0 }) + } + + if (pasteMode(text) === "manual") { + put() + return } - const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText) + const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text) if (inserted) return - input.addPart({ type: "text", content: plainText, start: 0, end: 0 }) + put() } const handleGlobalDragOver = (event: DragEvent) => { diff --git a/packages/app/src/components/prompt-input/paste.ts b/packages/app/src/components/prompt-input/paste.ts new file mode 100644 index 00000000000..6787d503090 --- /dev/null +++ b/packages/app/src/components/prompt-input/paste.ts @@ -0,0 +1,24 @@ +const LARGE_PASTE_CHARS = 8000 +const LARGE_PASTE_BREAKS = 120 + +function largePaste(text: string) { + if (text.length >= LARGE_PASTE_CHARS) return true + let breaks = 0 + for (const char of text) { + if (char !== "\n") continue + breaks += 1 + if (breaks >= LARGE_PASTE_BREAKS) return true + } + return false +} + +export function normalizePaste(text: string) { + if (!text.includes("\r")) return text + return text.replace(/\r\n?/g, "\n") +} + +export function pasteMode(text: string) { + if (largePaste(text)) return "manual" + if (text.includes("\n") || text.includes("\r")) return "manual" + return "native" +} From f015154314e4b8b8ef6b0454e566561ee91bbefc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 14 Mar 2026 14:28:00 -0400 Subject: [PATCH 07/31] refactor(permission): effectify PermissionNext + fix InstanceState ALS bug (#17511) --- packages/opencode/src/cli/cmd/debug/agent.ts | 2 +- packages/opencode/src/effect/runtime.ts | 3 +- packages/opencode/src/permission/index.ts | 2 +- packages/opencode/src/permission/next.ts | 264 ++----------- packages/opencode/src/permission/schema.ts | 20 +- packages/opencode/src/permission/service.ts | 265 +++++++++++++ packages/opencode/src/question/index.ts | 2 +- packages/opencode/src/question/service.ts | 27 +- packages/opencode/src/util/instance-state.ts | 8 +- .../opencode/test/permission/next.test.ts | 349 +++++++++++++++++- packages/opencode/test/tool/read.test.ts | 2 +- .../opencode/test/util/instance-state.test.ts | 125 ++++++- 12 files changed, 805 insertions(+), 264 deletions(-) create mode 100644 packages/opencode/src/permission/service.ts diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 297a7ec0212..ef075d732a6 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -159,7 +159,7 @@ async function createToolContext(agent: Agent.Info) { for (const pattern of req.patterns) { const rule = PermissionNext.evaluate(req.permission, pattern, ruleset) if (rule.action === "deny") { - throw new PermissionNext.DeniedError(ruleset) + throw new PermissionNext.DeniedError({ ruleset }) } } }, diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index de4bc3dda24..4aec46befac 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,8 +1,9 @@ import { Layer, ManagedRuntime } from "effect" import { AccountService } from "@/account/service" import { AuthService } from "@/auth/service" +import { PermissionService } from "@/permission/service" import { QuestionService } from "@/question/service" export const runtime = ManagedRuntime.make( - Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer, QuestionService.layer), + Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer, PermissionService.layer, QuestionService.layer), ) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 565ccf20d1a..9cdaf313bf8 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -87,7 +87,7 @@ export namespace Permission { result.push(item.info) } } - return result.sort((a, b) => a.id.localeCompare(b.id)) + return result.sort((a, b) => String(a.id).localeCompare(String(b.id))) } export async function ask(input: { diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 3ef3a02304d..7fcd40eea0b 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -1,21 +1,20 @@ -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" +import { runtime } from "@/effect/runtime" import { Config } from "@/config/config" -import { SessionID, MessageID } from "@/session/schema" -import { PermissionID } from "./schema" -import { Instance } from "@/project/instance" -import { Database, eq } from "@/storage/db" -import { PermissionTable } from "@/session/session.sql" import { fn } from "@/util/fn" -import { Log } from "@/util/log" -import { ProjectID } from "@/project/schema" import { Wildcard } from "@/util/wildcard" +import { Effect } from "effect" import os from "os" -import z from "zod" +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 { - const log = Log.create({ service: "permission" }) - function expand(pattern: string): string { if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1) if (pattern === "~") return os.homedir() @@ -24,26 +23,26 @@ export namespace PermissionNext { return pattern } - 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, - }) - .meta({ - ref: "PermissionRule", - }) - export type Rule = z.infer + function runPromise(f: (service: S.PermissionService.Api) => Effect.Effect) { + return runtime.runPromise(S.PermissionService.use(f)) + } - export const Ruleset = Rule.array().meta({ - ref: "PermissionRuleset", - }) - export type Ruleset = z.infer + export const Action = S.Action + export type Action = ActionType + export const Rule = S.Rule + export type Rule = RuleType + export const Ruleset = S.Ruleset + export type Ruleset = RulesetType + export const Request = S.Request + export type Request = RequestType + export const Reply = S.Reply + export type Reply = ReplyType + export const Approval = S.Approval + export const Event = S.Event + export const Service = S.PermissionService + export const RejectedError = S.RejectedError + export const CorrectedError = S.CorrectedError + export const DeniedError = S.DeniedError export function fromConfig(permission: Config.Permission) { const ruleset: Ruleset = [] @@ -67,178 +66,16 @@ export namespace PermissionNext { return rulesets.flat() } - 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 ask = fn(S.AskInput, async (input) => runPromise((service) => service.ask(input))) - export const Reply = z.enum(["once", "always", "reject"]) - export type Reply = z.infer + export const reply = fn(S.ReplyInput, async (input) => runPromise((service) => service.reply(input))) - 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, - }), - ), - } - - interface PendingEntry { - info: Request - resolve: () => void - reject: (e: any) => void + export async function list() { + return runPromise((service) => service.list()) } - const state = Instance.state(() => { - const projectID = Instance.project.id - const row = Database.use((db) => - db.select().from(PermissionTable).where(eq(PermissionTable.project_id, projectID)).get(), - ) - const stored = row?.data ?? ([] as Ruleset) - - return { - pending: new Map(), - approved: stored, - } - }) - - export const ask = fn( - Request.partial({ id: true }).extend({ - ruleset: Ruleset, - }), - async (input) => { - const s = await state() - const { ruleset, ...request } = input - for (const pattern of request.patterns ?? []) { - const rule = evaluate(request.permission, pattern, ruleset, s.approved) - log.info("evaluated", { permission: request.permission, pattern, action: rule }) - if (rule.action === "deny") - throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission))) - if (rule.action === "ask") { - const id = input.id ?? PermissionID.ascending() - return new Promise((resolve, reject) => { - const info: Request = { - id, - ...request, - } - s.pending.set(id, { - info, - resolve, - reject, - }) - Bus.publish(Event.Asked, info) - }) - } - if (rule.action === "allow") continue - } - }, - ) - - export const reply = fn( - z.object({ - requestID: PermissionID.zod, - reply: Reply, - message: z.string().optional(), - }), - async (input) => { - const s = await state() - const existing = s.pending.get(input.requestID) - if (!existing) return - s.pending.delete(input.requestID) - Bus.publish(Event.Replied, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - reply: input.reply, - }) - if (input.reply === "reject") { - existing.reject(input.message ? new CorrectedError(input.message) : new RejectedError()) - // Reject all other pending permissions for this session - const sessionID = existing.info.sessionID - for (const [id, pending] of s.pending) { - if (pending.info.sessionID === sessionID) { - s.pending.delete(id) - Bus.publish(Event.Replied, { - sessionID: pending.info.sessionID, - requestID: pending.info.id, - reply: "reject", - }) - pending.reject(new RejectedError()) - } - } - return - } - if (input.reply === "once") { - existing.resolve() - return - } - if (input.reply === "always") { - for (const pattern of existing.info.always) { - s.approved.push({ - permission: existing.info.permission, - pattern, - action: "allow", - }) - } - - existing.resolve() - - const sessionID = existing.info.sessionID - for (const [id, pending] of s.pending) { - if (pending.info.sessionID !== sessionID) continue - const ok = pending.info.patterns.every( - (pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow", - ) - if (!ok) continue - s.pending.delete(id) - Bus.publish(Event.Replied, { - sessionID: pending.info.sessionID, - requestID: pending.info.id, - reply: "always", - }) - pending.resolve() - } - - // TODO: we don't save the permission ruleset to disk yet until there's - // UI to manage it - // db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved }) - // .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run() - return - } - }, - ) - export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - const merged = merge(...rulesets) - 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: "*" } + return S.evaluate(permission, pattern, ...rulesets) } const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"] @@ -247,39 +84,10 @@ export namespace PermissionNext { const result = new Set() for (const tool of tools) { const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool - - const rule = ruleset.findLast((r) => Wildcard.match(permission, r.permission)) + const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) if (!rule) continue if (rule.pattern === "*" && rule.action === "deny") result.add(tool) } return result } - - /** User rejected without message - halts execution */ - export class RejectedError extends Error { - constructor() { - super(`The user rejected permission to use this specific tool call.`) - } - } - - /** User rejected with message - continues with guidance */ - export class CorrectedError extends Error { - constructor(message: string) { - super(`The user rejected permission to use this specific tool call with the following feedback: ${message}`) - } - } - - /** Auto-rejected by config rule - halts execution */ - export class DeniedError extends Error { - constructor(public readonly ruleset: Ruleset) { - super( - `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(ruleset)}`, - ) - } - } - - export async function list() { - const s = await state() - return Array.from(s.pending.values(), (x) => x.info) - } } diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index c3242b714a9..bfa2b495779 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -2,16 +2,16 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" -import { withStatics } from "@/util/schema" +import { Newtype } from "@/util/schema" -const permissionIdSchema = Schema.String.pipe(Schema.brand("PermissionID")) +export class PermissionID extends Newtype()("PermissionID", Schema.String) { + static make(id: string): PermissionID { + return this.makeUnsafe(id) + } -export type PermissionID = typeof permissionIdSchema.Type + static ascending(id?: string): PermissionID { + return this.makeUnsafe(Identifier.ascending("permission", id)) + } -export const PermissionID = permissionIdSchema.pipe( - withStatics((schema: typeof permissionIdSchema) => ({ - make: (id: string) => schema.makeUnsafe(id), - ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("permission", id)), - zod: Identifier.schema("permission").pipe(z.custom()), - })), -) + static readonly zod = Identifier.schema("permission") as unknown as z.ZodType +} diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts new file mode 100644 index 00000000000..2782c0aba18 --- /dev/null +++ b/packages/opencode/src/permission/service.ts @@ -0,0 +1,265 @@ +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Instance } from "@/project/instance" +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" +import z from "zod" +import { PermissionID } from "./schema" + +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, + }) + .meta({ + ref: "PermissionRule", + }) +export type Rule = 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({ + sessionID: SessionID.zod, + requestID: PermissionID.zod, + reply: Reply, + }), + ), +} + +export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { + override get message() { + return "The user rejected permission to use this specific tool call." + } +} + +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 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 type PermissionError = DeniedError | RejectedError | CorrectedError + +interface PendingEntry { + info: Request + deferred: Deferred.Deferred +} + +type State = { + pending: Map + approved: 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 declare namespace PermissionService { + export interface Api { + 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, + Effect.gen(function* () { + const instanceState = yield* InstanceState.make(() => + 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 ask = Effect.fn("PermissionService.ask")(function* (input: z.infer) { + const state = yield* InstanceState.get(instanceState) + const { ruleset, ...request } = input + let pending = false + + for (const pattern of request.patterns) { + const rule = evaluate(request.permission, pattern, ruleset, state.approved) + log.info("evaluated", { permission: request.permission, pattern, action: rule }) + if (rule.action === "deny") { + return yield* new DeniedError({ + ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), + }) + } + if (rule.action === "allow") continue + pending = true + } + + if (!pending) return + + const id = request.id ?? PermissionID.ascending() + const info: Request = { + id, + ...request, + } + log.info("asking", { id, permission: info.permission, patterns: info.patterns }) + + const deferred = yield* Deferred.make() + state.pending.set(id, { info, deferred }) + void Bus.publish(Event.Asked, info) + return yield* Effect.ensuring( + Deferred.await(deferred), + Effect.sync(() => { + state.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) + if (!existing) return + + state.pending.delete(input.requestID) + void Bus.publish(Event.Replied, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + reply: input.reply, + }) + + if (input.reply === "reject") { + yield* Deferred.fail( + existing.deferred, + input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(), + ) + + for (const [id, item] of state.pending.entries()) { + if (item.info.sessionID !== existing.info.sessionID) continue + state.pending.delete(id) + void Bus.publish(Event.Replied, { + sessionID: item.info.sessionID, + requestID: item.info.id, + reply: "reject", + }) + yield* Deferred.fail(item.deferred, new RejectedError()) + } + return + } + + yield* Deferred.succeed(existing.deferred, undefined) + if (input.reply === "once") return + + for (const pattern of existing.info.always) { + state.approved.push({ + permission: existing.info.permission, + pattern, + action: "allow", + }) + } + + for (const [id, item] of state.pending.entries()) { + if (item.info.sessionID !== existing.info.sessionID) continue + const ok = item.info.patterns.every( + (pattern) => evaluate(item.info.permission, pattern, state.approved).action === "allow", + ) + if (!ok) continue + state.pending.delete(id) + void Bus.publish(Event.Replied, { + sessionID: item.info.sessionID, + requestID: item.info.id, + reply: "always", + }) + yield* Deferred.succeed(item.deferred, undefined) + } + + // TODO: we don't save the permission ruleset to disk yet until there's + // UI to manage it + // db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved }) + // .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run() + }) + + const list = Effect.fn("PermissionService.list")(function* () { + const state = yield* InstanceState.get(instanceState) + return Array.from(state.pending.values(), (item) => item.info) + }) + + return PermissionService.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/question/index.ts b/packages/opencode/src/question/index.ts index fc0c7dd41b7..6ace981a9f1 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -4,7 +4,7 @@ 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) { +function runPromise(f: (service: S.QuestionService.Service) => Effect.Effect) { return runtime.runPromise(S.QuestionService.use(f)) } diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts index 6b353c7f105..4a81946e83e 100644 --- a/packages/opencode/src/question/service.ts +++ b/packages/opencode/src/question/service.ts @@ -72,22 +72,17 @@ export const Event = { ), } -export class RejectedError extends Error { - constructor() { - super("The user dismissed this question") +export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { + override get message() { + return "The user dismissed this question" } } // --- Effect service --- -export class QuestionServiceError extends Schema.TaggedErrorClass()("QuestionServiceError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} - interface PendingEntry { info: Request - deferred: Deferred.Deferred + deferred: Deferred.Deferred } export namespace QuestionService { @@ -96,10 +91,10 @@ export namespace QuestionService { sessionID: SessionID questions: Info[] tool?: { messageID: MessageID; callID: string } - }) => Effect.Effect - readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect - readonly reject: (requestID: QuestionID) => Effect.Effect - readonly list: () => Effect.Effect + }) => Effect.Effect + readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect + readonly reject: (requestID: QuestionID) => Effect.Effect + readonly list: () => Effect.Effect } } @@ -109,7 +104,7 @@ export class QuestionService extends ServiceMap.Service, QuestionServiceError>(() => + const instanceState = yield* InstanceState.make>(() => Effect.succeed(new Map()), ) @@ -124,7 +119,7 @@ export class QuestionService extends ServiceMap.Service() + const deferred = yield* Deferred.make() const info: Request = { id, sessionID: input.sessionID, @@ -167,7 +162,7 @@ export class QuestionService extends ServiceMap.Service(self: InstanceState) => ScopedCache.get(self.cache, Instance.directory) + 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) => ScopedCache.has(self.cache, Instance.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) => - ScopedCache.invalidate(self.cache, Instance.directory) + Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory)) /** Invalidate the given directory across all InstanceState caches. */ export const dispose = (directory: string) => diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 7fd08189919..cd4775acea9 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,10 +1,32 @@ import { test, expect } from "bun:test" import os from "os" +import { Bus } from "../../src/bus" +import { runtime } from "../../src/effect/runtime" import { PermissionNext } from "../../src/permission/next" +import * as S from "../../src/permission/service" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" -import { SessionID } from "../../src/session/schema" +import { MessageID, SessionID } from "../../src/session/schema" + +async function rejectAll(message?: string) { + for (const req of await PermissionNext.list()) { + await PermissionNext.reply({ + requestID: req.id, + reply: "reject", + message, + }) + } +} + +async function waitForPending(count: number) { + for (let i = 0; i < 20; i++) { + const list = await PermissionNext.list() + if (list.length === count) return list + await Bun.sleep(0) + } + return PermissionNext.list() +} // fromConfig tests @@ -511,6 +533,84 @@ test("ask - returns pending promise when action is ask", async () => { // Promise should be pending, not resolved expect(promise).toBeInstanceOf(Promise) // Don't await - just verify it returns a promise + await rejectAll() + await promise.catch(() => {}) + }, + }) +}) + +test("ask - adds request to pending list", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ask = PermissionNext.ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: { cmd: "ls" }, + always: ["ls"], + tool: { + messageID: MessageID.make("msg_test"), + callID: "call_test", + }, + ruleset: [], + }) + + const list = await PermissionNext.list() + expect(list).toHaveLength(1) + expect(list[0]).toMatchObject({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: { cmd: "ls" }, + always: ["ls"], + tool: { + messageID: MessageID.make("msg_test"), + callID: "call_test", + }, + }) + + await rejectAll() + await ask.catch(() => {}) + }, + }) +}) + +test("ask - publishes asked event", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + let seen: PermissionNext.Request | undefined + const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => { + seen = event.properties + }) + + const ask = PermissionNext.ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: { cmd: "ls" }, + always: ["ls"], + tool: { + messageID: MessageID.make("msg_test"), + callID: "call_test", + }, + ruleset: [], + }) + + expect(await PermissionNext.list()).toHaveLength(1) + expect(seen).toBeDefined() + expect(seen).toMatchObject({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + }) + + unsub() + await rejectAll() + await ask.catch(() => {}) }, }) }) @@ -532,6 +632,8 @@ test("reply - once resolves the pending ask", async () => { ruleset: [], }) + await waitForPending(1) + await PermissionNext.reply({ requestID: PermissionID.make("per_test1"), reply: "once", @@ -557,6 +659,8 @@ test("reply - reject throws RejectedError", async () => { ruleset: [], }) + await waitForPending(1) + await PermissionNext.reply({ requestID: PermissionID.make("per_test2"), reply: "reject", @@ -567,6 +671,36 @@ test("reply - reject throws RejectedError", async () => { }) }) +test("reply - reject with message throws CorrectedError", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ask = PermissionNext.ask({ + id: PermissionID.make("per_test2b"), + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + await waitForPending(1) + + await PermissionNext.reply({ + requestID: PermissionID.make("per_test2b"), + reply: "reject", + message: "Use a safer command", + }) + + const err = await ask.catch((err) => err) + expect(err).toBeInstanceOf(PermissionNext.CorrectedError) + expect(err.message).toContain("Use a safer command") + }, + }) +}) + test("reply - always persists approval and resolves", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ @@ -582,6 +716,8 @@ test("reply - always persists approval and resolves", async () => { ruleset: [], }) + await waitForPending(1) + await PermissionNext.reply({ requestID: PermissionID.make("per_test3"), reply: "always", @@ -633,6 +769,8 @@ test("reply - reject cancels all pending for same session", async () => { ruleset: [], }) + await waitForPending(2) + // Catch rejections before they become unhandled const result1 = askPromise1.catch((e) => e) const result2 = askPromise2.catch((e) => e) @@ -650,6 +788,144 @@ test("reply - reject cancels all pending for same session", async () => { }) }) +test("reply - always resolves matching pending requests in same session", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const a = PermissionNext.ask({ + id: PermissionID.make("per_test5a"), + sessionID: SessionID.make("session_same"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: ["ls"], + ruleset: [], + }) + + const b = PermissionNext.ask({ + id: PermissionID.make("per_test5b"), + sessionID: SessionID.make("session_same"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + await waitForPending(2) + + await PermissionNext.reply({ + requestID: PermissionID.make("per_test5a"), + reply: "always", + }) + + await expect(a).resolves.toBeUndefined() + await expect(b).resolves.toBeUndefined() + expect(await PermissionNext.list()).toHaveLength(0) + }, + }) +}) + +test("reply - always keeps other session pending", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const a = PermissionNext.ask({ + id: PermissionID.make("per_test6a"), + sessionID: SessionID.make("session_a"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: ["ls"], + ruleset: [], + }) + + const b = PermissionNext.ask({ + id: PermissionID.make("per_test6b"), + sessionID: SessionID.make("session_b"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + await waitForPending(2) + + await PermissionNext.reply({ + requestID: PermissionID.make("per_test6a"), + reply: "always", + }) + + await expect(a).resolves.toBeUndefined() + expect((await PermissionNext.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")]) + + await rejectAll() + await b.catch(() => {}) + }, + }) +}) + +test("reply - publishes replied event", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ask = PermissionNext.ask({ + id: PermissionID.make("per_test7"), + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + await waitForPending(1) + + let seen: + | { + sessionID: SessionID + requestID: PermissionID + reply: PermissionNext.Reply + } + | undefined + const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => { + seen = event.properties + }) + + await PermissionNext.reply({ + requestID: PermissionID.make("per_test7"), + reply: "once", + }) + + await expect(ask).resolves.toBeUndefined() + expect(seen).toEqual({ + sessionID: SessionID.make("session_test"), + requestID: PermissionID.make("per_test7"), + reply: "once", + }) + unsub() + }, + }) +}) + +test("reply - does nothing for unknown requestID", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await PermissionNext.reply({ + requestID: PermissionID.make("per_unknown"), + reply: "once", + }) + expect(await PermissionNext.list()).toHaveLength(0) + }, + }) +}) + test("ask - checks all patterns and stops on first deny", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ @@ -689,3 +965,74 @@ test("ask - allows all patterns when all match allow rules", async () => { }, }) }) + +test("ask - should deny even when an earlier pattern is ask", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ask = PermissionNext.ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["echo hello", "rm -rf /"], + metadata: {}, + always: [], + ruleset: [ + { permission: "bash", pattern: "echo *", action: "ask" }, + { permission: "bash", pattern: "rm *", action: "deny" }, + ], + }) + + 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(await PermissionNext.list()).toHaveLength(0) + }, + }) +}) + +test("ask - abort should clear pending request", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ctl = new AbortController() + const ask = runtime.runPromise( + S.PermissionService.use((svc) => + svc.ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + }), + ), + { signal: ctl.signal }, + ) + + await waitForPending(1) + ctl.abort() + await ask.catch(() => {}) + + try { + expect(await PermissionNext.list()).toHaveLength(0) + } finally { + await rejectAll() + } + }, + }) +}) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 7659d690c3a..0761a930445 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -183,7 +183,7 @@ describe("tool.read env file permissions", () => { askedForEnv = true } if (rule.action === "deny") { - throw new PermissionNext.DeniedError(agent.permission) + throw new PermissionNext.DeniedError({ ruleset: agent.permission }) } } }, diff --git a/packages/opencode/test/util/instance-state.test.ts b/packages/opencode/test/util/instance-state.test.ts index 29d0738c11c..976b7d07ec7 100644 --- a/packages/opencode/test/util/instance-state.test.ts +++ b/packages/opencode/test/util/instance-state.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test" -import { Effect } from "effect" +import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect" import { Instance } from "../../src/project/instance" import { InstanceState } from "../../src/util/instance-state" @@ -114,6 +114,129 @@ test("InstanceState is disposed on disposeAll", async () => { ) }) +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 From 0befa1e57e2b5ec2cd7b0fcfce7e572866393154 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 14 Mar 2026 18:29:06 +0000 Subject: [PATCH 08/31] chore: generate --- packages/opencode/src/question/service.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 62 +++---- packages/sdk/openapi.json | 192 +++++++++++----------- 3 files changed, 128 insertions(+), 128 deletions(-) diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts index 4a81946e83e..4a151f90881 100644 --- a/packages/opencode/src/question/service.ts +++ b/packages/opencode/src/question/service.ts @@ -162,7 +162,7 @@ export class QuestionService extends ServiceMap.Service + 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) @@ -620,35 +649,6 @@ export type EventMessagePartRemoved = { } } -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 SessionStatus = | { type: "idle" @@ -962,6 +962,8 @@ export type Event = | EventInstallationUpdateAvailable | EventProjectUpdated | EventServerInstanceDisposed + | EventPermissionAsked + | EventPermissionReplied | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -975,8 +977,6 @@ export type Event = | EventMessagePartUpdated | EventMessagePartDelta | EventMessagePartRemoved - | EventPermissionAsked - | EventPermissionReplied | EventSessionStatus | EventSessionIdle | EventSessionCompacted diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index d79de78e7aa..2933b530f40 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7062,6 +7062,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"] + }, "QuestionOption": { "type": "object", "properties": { @@ -8670,96 +8760,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"] - }, "SessionStatus": { "anyOf": [ { @@ -9611,6 +9611,12 @@ { "$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" }, @@ -9650,12 +9656,6 @@ { "$ref": "#/components/schemas/Event.message.part.removed" }, - { - "$ref": "#/components/schemas/Event.permission.asked" - }, - { - "$ref": "#/components/schemas/Event.permission.replied" - }, { "$ref": "#/components/schemas/Event.session.status" }, From 8f957b8f90a4faa6afcb299127d320e1eb538c25 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 15 Mar 2026 00:52:28 +0100 Subject: [PATCH 09/31] remove sighup exit (#17254) --- packages/opencode/src/index.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index fe7fd63ef12..b3d1db7eb0c 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -47,11 +47,6 @@ process.on("uncaughtException", (e) => { }) }) -// Ensure the process exits on terminal hangup (eg. closing the terminal tab). -// Without this, long-running commands like `serve` block on a never-resolving -// promise and survive as orphaned processes. -process.on("SIGHUP", () => process.exit()) - let cli = yargs(hideBin(process.argv)) .parserConfiguration({ "populate--": true }) .scriptName("opencode") From 52877d876503b140001873f8350d83964b7ae1df Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 14 Mar 2026 20:49:49 -0400 Subject: [PATCH 10/31] fix(question): clean up pending entry on abort (#17533) --- packages/opencode/src/question/service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts index 4a151f90881..30a47bee9ff 100644 --- a/packages/opencode/src/question/service.ts +++ b/packages/opencode/src/question/service.ts @@ -129,7 +129,12 @@ export class QuestionService extends ServiceMap.Service { + pending.delete(id) + }), + ) }) const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) { From 2fc06c5a179b53d041a77093564bbee937e7b70c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 14 Mar 2026 21:56:52 -0400 Subject: [PATCH 11/31] chore(permission): delete legacy permission module (#17534) --- packages/opencode/src/permission/index.ts | 210 ---------------------- 1 file changed, 210 deletions(-) delete mode 100644 packages/opencode/src/permission/index.ts diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts deleted file mode 100644 index 9cdaf313bf8..00000000000 --- a/packages/opencode/src/permission/index.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import { SessionID, MessageID } from "@/session/schema" -import z from "zod" -import { Log } from "../util/log" -import { Plugin } from "../plugin" -import { Instance } from "../project/instance" -import { Wildcard } from "../util/wildcard" -import { PermissionID } from "./schema" - -export namespace Permission { - const log = Log.create({ service: "permission" }) - - function toKeys(pattern: Info["pattern"], type: string): string[] { - return pattern === undefined ? [type] : Array.isArray(pattern) ? pattern : [pattern] - } - - function covered(keys: string[], approved: Map): boolean { - return keys.every((k) => { - for (const p of approved.keys()) { - if (Wildcard.match(k, p)) return true - } - return false - }) - } - - export const Info = z - .object({ - id: PermissionID.zod, - type: z.string(), - pattern: z.union([z.string(), z.array(z.string())]).optional(), - sessionID: SessionID.zod, - messageID: MessageID.zod, - callID: z.string().optional(), - message: z.string(), - metadata: z.record(z.string(), z.any()), - time: z.object({ - created: z.number(), - }), - }) - .meta({ - ref: "Permission", - }) - export type Info = z.infer - - interface PendingEntry { - info: Info - resolve: () => void - reject: (e: any) => void - } - - export const Event = { - Updated: BusEvent.define("permission.updated", Info), - Replied: BusEvent.define( - "permission.replied", - z.object({ - sessionID: SessionID.zod, - permissionID: PermissionID.zod, - response: z.string(), - }), - ), - } - - const state = Instance.state( - () => ({ - pending: new Map>(), - approved: new Map>(), - }), - async (state) => { - for (const session of state.pending.values()) { - for (const item of session.values()) { - item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.callID, item.info.metadata)) - } - } - }, - ) - - export function pending() { - return state().pending - } - - export function list() { - const { pending } = state() - const result: Info[] = [] - for (const session of pending.values()) { - for (const item of session.values()) { - result.push(item.info) - } - } - return result.sort((a, b) => String(a.id).localeCompare(String(b.id))) - } - - export async function ask(input: { - type: Info["type"] - message: Info["message"] - pattern?: Info["pattern"] - callID?: Info["callID"] - sessionID: Info["sessionID"] - messageID: Info["messageID"] - metadata: Info["metadata"] - }) { - const { pending, approved } = state() - log.info("asking", { - sessionID: input.sessionID, - messageID: input.messageID, - toolCallID: input.callID, - pattern: input.pattern, - }) - const approvedForSession = approved.get(input.sessionID) - const keys = toKeys(input.pattern, input.type) - if (approvedForSession && covered(keys, approvedForSession)) return - const info: Info = { - id: PermissionID.ascending(), - type: input.type, - pattern: input.pattern, - sessionID: input.sessionID, - messageID: input.messageID, - callID: input.callID, - message: input.message, - metadata: input.metadata, - time: { - created: Date.now(), - }, - } - - switch ( - await Plugin.trigger("permission.ask", info, { - status: "ask", - }).then((x) => x.status) - ) { - case "deny": - throw new RejectedError(info.sessionID, info.id, info.callID, info.metadata) - case "allow": - return - } - - if (!pending.has(input.sessionID)) pending.set(input.sessionID, new Map()) - return new Promise((resolve, reject) => { - pending.get(input.sessionID)!.set(info.id, { - info, - resolve, - reject, - }) - Bus.publish(Event.Updated, info) - }) - } - - export const Response = z.enum(["once", "always", "reject"]) - export type Response = z.infer - - export function respond(input: { sessionID: Info["sessionID"]; permissionID: Info["id"]; response: Response }) { - log.info("response", input) - const { pending, approved } = state() - const session = pending.get(input.sessionID) - const match = session?.get(input.permissionID) - if (!session || !match) return - session.delete(input.permissionID) - if (session.size === 0) pending.delete(input.sessionID) - Bus.publish(Event.Replied, { - sessionID: input.sessionID, - permissionID: input.permissionID, - response: input.response, - }) - if (input.response === "reject") { - match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata)) - return - } - match.resolve() - if (input.response === "always") { - if (!approved.has(input.sessionID)) approved.set(input.sessionID, new Map()) - const approvedSession = approved.get(input.sessionID)! - const approveKeys = toKeys(match.info.pattern, match.info.type) - for (const k of approveKeys) { - approvedSession.set(k, true) - } - const items = pending.get(input.sessionID) - if (!items) return - const toRespond: Info[] = [] - for (const item of items.values()) { - const itemKeys = toKeys(item.info.pattern, item.info.type) - if (covered(itemKeys, approvedSession)) { - toRespond.push(item.info) - } - } - for (const item of toRespond) { - respond({ - sessionID: item.sessionID, - permissionID: item.id, - response: input.response, - }) - } - } - } - - export class RejectedError extends Error { - constructor( - public readonly sessionID: SessionID, - public readonly permissionID: PermissionID, - public readonly toolCallID?: string, - public readonly metadata?: Record, - public readonly reason?: string, - ) { - super( - reason !== undefined - ? reason - : `The user rejected permission to use this specific tool call. You may try again with different parameters.`, - ) - } - } -} From ad06d8f4962d602ece98b51596dac35b8fa61d11 Mon Sep 17 00:00:00 2001 From: Orlando Ascanio <155546018+Gojer16@users.noreply.github.com> Date: Sun, 15 Mar 2026 09:22:32 -0400 Subject: [PATCH 12/31] docs(es): fix Spanish intro page translation, grammar, and terminology (#17563) --- packages/web/src/content/docs/es/index.mdx | 140 ++++++++++----------- 1 file changed, 63 insertions(+), 77 deletions(-) diff --git a/packages/web/src/content/docs/es/index.mdx b/packages/web/src/content/docs/es/index.mdx index b4d3f95b5cf..c1481138259 100644 --- a/packages/web/src/content/docs/es/index.mdx +++ b/packages/web/src/content/docs/es/index.mdx @@ -1,23 +1,23 @@ --- title: Introducción -description: Comience con OpenCode. +description: Comience a usar OpenCode. --- import { Tabs, TabItem } from "@astrojs/starlight/components" import config from "../../../../config.mjs" export const console = config.console -[**OpenCode**](/) es un agente de codificación de IA de código abierto. Está disponible como interfaz basada en terminal, aplicación de escritorio o extensión IDE. +[**OpenCode**](/) es un agente de codigo de IA de código abierto. Está disponible como interfaz basada en terminal, aplicación de escritorio o extensión IDE. ![OpenCode TUI con el tema opencode](../../../assets/lander/screenshot.png) -Empecemos. +Comencemos. --- #### Requisitos previos -Para usar OpenCode en su terminal, necesitará: +Para usar OpenCode en la terminal, necesitará: 1. Un emulador de terminal moderno como: - [WezTerm](https://wezterm.org), multiplataforma @@ -25,7 +25,7 @@ Para usar OpenCode en su terminal, necesitará: - [Ghostty](https://ghostty.org), Linux y macOS - [Kitty](https://sw.kovidgoyal.net/kitty/), Linux y macOS -2. Claves API para los LLM proveedores que desea utilizar. +2. Claves de API de los proveedores de LLM que quiera usar. --- @@ -37,7 +37,7 @@ La forma más sencilla de instalar OpenCode es mediante el script de instalació curl -fsSL https://opencode.ai/install | bash ``` -También puedes instalarlo con los siguientes comandos: +También puedes instalarlo con alguno de los siguientes métodos: - **Usando Node.js** @@ -91,7 +91,7 @@ También puedes instalarlo con los siguientes comandos: #### Windows :::tip[Recomendado: Usar WSL] -Para obtener la mejor experiencia en Windows, recomendamos utilizar [Windows Subsystem for Linux (WSL)](/docs/windows-wsl). Proporciona un mejor rendimiento y compatibilidad total con las funciones de OpenCode. +Para obtener la mejor experiencia en Windows, recomendamos utilizar [Windows Subsystem for Linux (WSL)](/docs/windows-wsl). Ofrece mejor rendimiento y compatibilidad total con las funciones de OpenCode. ::: - **Usando Chocolatey** @@ -124,28 +124,28 @@ Para obtener la mejor experiencia en Windows, recomendamos utilizar [Windows Sub docker run -it --rm ghcr.io/anomalyco/opencode ``` -Actualmente se encuentra en progreso el soporte para instalar OpenCode en Windows usando Bun. +El soporte para instalar OpenCode en Windows usando Bun todavía está en desarrollo. -También puede obtener el binario de [Versiones](https://github.com/anomalyco/opencode/releases). +También puede obtener el binario desde [Versiones](https://github.com/anomalyco/opencode/releases). --- ## Configuración -Con OpenCode puedes usar cualquier proveedor LLM configurando sus claves API. +Con OpenCode, puede usar cualquier proveedor de LLM configurando sus claves de API. -Si es nuevo en el uso de proveedores LLM, le recomendamos usar [OpenCode Zen](/docs/zen). -Es una lista seleccionada de modelos que han sido probados y verificados por el equipo de OpenCode. +Si es nuevo en el uso de proveedores de LLM, le recomendamos usar [OpenCode Zen](/docs/zen). +Es una selección de modelos probados y verificados por el equipo de OpenCode. -1. Ejecute el comando `/connect` en TUI, seleccione opencode y diríjase a [opencode.ai/auth](https://opencode.ai/auth). +1. Ejecute el comando `/connect` en la TUI, seleccione opencode y diríjase a [opencode.ai/auth](https://opencode.ai/auth). ```txt /connect ``` -2. Inicie sesión, agregue sus datos de facturación y copie su clave API. +2. Inicie sesión, agregue sus datos de facturación y copie su clave de API. -3. Pega tu clave API. +3. Pega tu clave de API. ```txt ┌ API key @@ -154,50 +154,45 @@ Es una lista seleccionada de modelos que han sido probados y verificados por el └ enter ``` -Alternativamente, puede seleccionar uno de los otros proveedores. [Más información](/docs/providers#directory). +También puede seleccionar otro proveedor. [Más información](/docs/providers#directory). --- ## Inicializar -Ahora que ha configurado un proveedor, puede navegar a un proyecto que -quieres trabajar. +Ahora que ya configuró un proveedor, vaya al proyecto en el que quiera trabajar. ```bash cd /path/to/project ``` -Y ejecute OpenCode. +Luego, ejecute OpenCode. ```bash opencode ``` -A continuación, inicialice OpenCode para el proyecto ejecutando el siguiente comando. +A continuación, inicialice OpenCode para el proyecto con el siguiente comando: ```bash frame="none" /init ``` -Esto hará que OpenCode analice su proyecto y cree un archivo `AGENTS.md` en -la raíz del proyecto. +OpenCode analizará su proyecto y creará un archivo AGENTS.md en la raíz. :::tip -Debes enviar el archivo `AGENTS.md` de tu proyecto a Git. +Asegúrese de versionar en Git el archivo AGENTS.md de su proyecto. ::: -Esto ayuda a OpenCode a comprender la estructura del proyecto y los patrones de codificación. -usado. +Esto ayuda a OpenCode a comprender la estructura del proyecto y los patrones de código que se usan en él. --- ## Usar -Ahora está listo para usar OpenCode para trabajar en su proyecto. No dudes en preguntarle -¡cualquier cosa! +Ahora ya está listo para usar OpenCode en su proyecto. Puede pedirle desde explicaciones del código hasta cambios concretos. -Si es nuevo en el uso de un agente de codificación de IA, aquí hay algunos ejemplos que podrían -ayuda. +Si es la primera vez que usa un agente de codigo con IA, estos ejemplos pueden servirle como punto de partida. --- @@ -206,126 +201,117 @@ ayuda. Puede pedirle a OpenCode que le explique el código base. :::tip -Utilice la tecla `@` para realizar una búsqueda aproximada de archivos en el proyecto. +Utilice la tecla `@` para realizar una búsqueda aproximada de archivos dentro del proyecto. ::: ```txt frame="none" "@packages/functions/src/api/index.ts" -How is authentication handled in @packages/functions/src/api/index.ts +¿Cómo se maneja la autenticación en @packages/functions/src/api/index.ts ``` -Esto es útil si hay una parte del código base en la que no trabajaste. +Esto resulta útil cuando hay una parte del código base en la que usted no ha trabajado. --- -### Agregar funciones +### Agregar funcionalidades -Puede pedirle a OpenCode que agregue nuevas funciones a su proyecto. Aunque primero recomendamos pedirle que cree un plan. +Puede pedirle a OpenCode que agregue nuevas funcionalidades a su proyecto. Aun así, primero recomendamos pedirle que cree un plan. -1. **Crea un plan** +1. **Crear un plan** - OpenCode tiene un _Modo Plan_ que desactiva su capacidad para realizar cambios y - en su lugar, sugiera _cómo_ implementará la función. + OpenCode tiene un modo Plan que desactiva temporalmente su capacidad de hacer cambios y, en su lugar, propone _cómo_ implementará la funcionalidad. - Cambie a él usando la tecla **Tab**. Verás un indicador para esto en la esquina inferior derecha. + Cambie a este modo con la tecla **Tab.** Verá un indicador en la esquina inferior derecha. ```bash frame="none" title="Switch to Plan mode" ``` - Ahora describamos lo que queremos que haga. + Ahora describa lo que quiere que haga. ```txt frame="none" - When a user deletes a note, we'd like to flag it as deleted in the database. - Then create a screen that shows all the recently deleted notes. - From this screen, the user can undelete a note or permanently delete it. + Cuando un usuario elimine una nota, queremos marcarla como eliminada en la base de datos. + Luego, cree una pantalla que muestre todas las notas eliminadas recientemente. + Desde esa pantalla, el usuario podrá restaurar una nota o eliminarla de forma permanente. ``` - Quiere darle a OpenCode suficientes detalles para entender lo que quiere. ayuda - hablar con él como si estuviera hablando con un desarrollador junior de su equipo. + Procure darle a OpenCode suficiente contexto para que entienda exactamente lo que necesita. Ayuda hablarle como si estuviera hablando con un desarrollador junior de su equipo. :::tip - Dale a OpenCode mucho contexto y ejemplos para ayudarlo a comprender lo que - desear. + Déle a OpenCode todo el contexto y los ejemplos que pueda para ayudarle a comprender lo que desea. ::: -2. **Repetir el plan** +2. **Iterar sobre el plan** - Una vez que le proporcione un plan, puede enviarle comentarios o agregar más detalles. + Una vez que OpenCode le proponga un plan, puede darle comentarios o agregar más detalles. ```txt frame="none" - We'd like to design this new screen using a design I've used before. - [Image #1] Take a look at this image and use it as a reference. + Queremos diseñar esta nueva pantalla usando un diseño que ya hemos usado antes. + [Imagen #1] Revise esta imagen y úsela como referencia. ``` :::tip Arrastre y suelte imágenes en la terminal para agregarlas al mensaje. ::: - OpenCode puede escanear cualquier imagen que le proporcione y agregarla al mensaje. Puede - Haga esto arrastrando y soltando una imagen en la terminal. + OpenCode puede analizar cualquier imagen que usted le proporcione y añadirla al contexto del mensaje. Puede hacerlo arrastrando y soltando una imagen en la terminal. -3. **Crea la función** +3. **Implementar la funcionalidad** - Una vez que se sienta cómodo con el plan, vuelva al _Modo Build_ - presionando la tecla **Tab** nuevamente. + Cuando esté conforme con el plan, vuelva al modo _Build_ presionando de nuevo la tecla Tab. ```bash frame="none" ``` - Y pidiéndole que haga los cambios. + Luego, pídale que haga los cambios. ```bash frame="none" - Sounds good! Go ahead and make the changes. + Perfecto. Continúe y realice los cambios. ``` --- ### Realizar cambios -Para cambios más sencillos, puede pedirle a OpenCode que lo construya directamente. -sin tener que revisar el plan primero. +Para cambios más sencillos, puede pedirle a OpenCode que los implemente directamente, sin revisar antes un plan. ```txt frame="none" "@packages/functions/src/settings.ts" "@packages/functions/src/notes.ts" -We need to add authentication to the /settings route. Take a look at how this is -handled in the /notes route in @packages/functions/src/notes.ts and implement -the same logic in @packages/functions/src/settings.ts +Necesitamos agregar autenticación a la ruta /settings. Revise cómo se maneja esto +en la ruta /notes en @packages/functions/src/notes.ts e implemente +la misma lógica en @packages/functions/src/settings.ts. ``` -Desea asegurarse de proporcionar una buena cantidad de detalles para que OpenCode tome la decisión correcta. -cambios. +Procure dar suficientes detalles para que OpenCode pueda tomar las decisiones correctas al hacer los cambios --- ### Deshacer cambios -Digamos que le pides a OpenCode que haga algunos cambios. +Supongamos que le pide a OpenCode que haga algunos cambios. ```txt frame="none" "@packages/functions/src/api/index.ts" -Can you refactor the function in @packages/functions/src/api/index.ts? +¿Puede refactorizar la función en @packages/functions/src/api/index.ts? ``` -Pero te das cuenta de que no es lo que querías. Puedes **deshacer** los cambios -usando el comando `/undo`. +Pero luego se da cuenta de que no era lo que quería. Puede **deshacer** los cambios usando el comando `/undo`. ```bash frame="none" /undo ``` -OpenCode ahora revertirá los cambios que realizó y mostrará su mensaje original -de nuevo. +OpenCode revertirá los cambios que hizo y volverá a mostrar su mensaje original. ```txt frame="none" "@packages/functions/src/api/index.ts" -Can you refactor the function in @packages/functions/src/api/index.ts? +¿Puede refactorizar la función en @packages/functions/src/api/index.ts? ``` -Desde aquí puedes modificar el mensaje y pedirle a OpenCode que vuelva a intentarlo. +Desde ahí, puede modificar el mensaje y pedirle a OpenCode que lo intente de nuevo. :::tip Puede ejecutar `/undo` varias veces para deshacer varios cambios. ::: -O **puedes rehacer** los cambios usando el comando `/redo`. +También puede rehacer los cambios usando el comando `/redo.` ```bash frame="none" /redo @@ -335,7 +321,7 @@ O **puedes rehacer** los cambios usando el comando `/redo`. ## Compartir -Las conversaciones que tengas con OpenCode pueden ser [compartidas con tu +Las conversaciones que tenga con OpenCode pueden [compartirse con su equipo](/docs/share). ```bash frame="none" @@ -348,12 +334,12 @@ Esto creará un enlace a la conversación actual y lo copiará en su portapapele Las conversaciones no se comparten de forma predeterminada. ::: -Aquí hay una [conversación de ejemplo](https://opencode.ai/s/4XP1fce5) con OpenCode. +Aquí tiene una [conversación de ejemplo](https://opencode.ai/s/4XP1fce5) con OpenCode. --- ## Personalizar -¡Y eso es todo! Ahora eres un profesional en el uso de OpenCode. +Y eso es todo. Ya conoce lo básico para empezar a usar OpenCode. -Para personalizarlo, recomendamos [elegir un tema](/docs/themes), [personalizar las combinaciones de teclas](/docs/keybinds), [configurar formateadores de código](/docs/formatters), [crear comandos personalizados](/docs/commands) o jugar con la [configuración OpenCode](/docs/config). +Para personalizarlo, recomendamos [elegir un tema](/docs/themes), [personalizar las combinaciones de teclas](/docs/keybinds), [configurar formateadores de código](/docs/formatters), [crear comandos personalizados](/docs/commands) o explorar la [configuración OpenCode](/docs/config). From b9f6b40e3a317b1881d7901bf221c186ffae4dfd Mon Sep 17 00:00:00 2001 From: David Hill <1879069+iamdavidhill@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:25:40 +0000 Subject: [PATCH 13/31] tweak(ui): remove open label (#17512) --- packages/app/src/components/session/session-header.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 4c9e30e4323..4947ad06a9b 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -326,7 +326,7 @@ export function SessionHeader() {
- {language.t("common.open")} Date: Sun, 15 Mar 2026 10:54:40 -0400 Subject: [PATCH 14/31] zen: update claude prices --- packages/web/src/content/docs/zen.mdx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index ed2be57e7c8..abbd8058ea0 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -137,12 +137,10 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | Kimi K2 Thinking | $0.40 | $2.50 | - | - | | Kimi K2 | $0.40 | $2.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - | -| Claude Opus 4.6 (≤ 200K tokens) | $5.00 | $25.00 | $0.50 | $6.25 | -| Claude Opus 4.6 (> 200K tokens) | $10.00 | $37.50 | $1.00 | $12.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 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | -| Claude Sonnet 4.6 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 | -| Claude Sonnet 4.6 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 | +| Claude Sonnet 4.6 | $3.00 | $15.00 | $0.30 | $3.75 | | Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 | | Claude Sonnet 4.5 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 | | Claude Sonnet 4 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 | From aedbecedf7ebf9b6e456562732e56a734acd121c Mon Sep 17 00:00:00 2001 From: Erik Engervall Date: Mon, 16 Mar 2026 08:56:24 +0900 Subject: [PATCH 15/31] docs: Add opencode-firecrawl to ecosystem documentation (#17672) --- packages/web/src/content/docs/ecosystem.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index d5a1e853dfc..30b53eeca72 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -49,6 +49,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Bundled multi-agent orchestration harness – 16 components, one install | | [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode | | [opencode-sentry-monitor](https://github.com/stolinski/opencode-sentry-monitor) | Trace and debug your AI agents with Sentry AI Monitoring | +| [opencode-firecrawl](https://github.com/firecrawl/opencode-firecrawl) | Web scraping, crawling, and search via the Firecrawl CLI | --- From 510374207dcca69a6f7d2a4bae606822662925a2 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:20:39 -0500 Subject: [PATCH 16/31] fix: vcs watcher if statement (#17673) --- packages/opencode/src/project/vcs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 34d59054314..6eada6b675d 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -47,7 +47,7 @@ export namespace Vcs { log.info("initialized", { branch: current }) const unsubscribe = Bus.subscribe(FileWatcher.Event.Updated, async (evt) => { - if (evt.properties.file.endsWith("HEAD")) return + if (!evt.properties.file.endsWith("HEAD")) return const next = await currentBranch() if (next !== current) { log.info("branch changed", { from: current, to: next }) From 4ee426ba549131c4903a71dfb6259200467aca81 Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 16 Mar 2026 02:33:48 +0000 Subject: [PATCH 17/31] release: v1.2.27 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index e06beaa4d75..35841622b7a 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -77,7 +77,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -111,7 +111,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -138,7 +138,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -162,7 +162,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -186,7 +186,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -219,7 +219,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -250,7 +250,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -279,7 +279,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -295,7 +295,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.2.26", + "version": "1.2.27", "bin": { "opencode": "./bin/opencode", }, @@ -416,7 +416,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -440,7 +440,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.2.26", + "version": "1.2.27", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -451,7 +451,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -486,7 +486,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -532,7 +532,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "zod": "catalog:", }, @@ -543,7 +543,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index d06ac25d5c5..878cfb8e314 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.2.26", + "version": "1.2.27", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index a7b5ddcf911..cb0f91a64f3 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.2.26", + "version": "1.2.27", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index fc45fc4fec5..1a75caea2fa 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.2.26", + "version": "1.2.27", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index b8b4e5f4d3e..fe327a56397 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.2.26", + "version": "1.2.27", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 982d0c15bd2..0525ffc21c4 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.2.26", + "version": "1.2.27", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index b16e4222b6c..2af3196e108 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.2.26", + "version": "1.2.27", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 858e00fbef5..73ec5278f6e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.2.26", + "version": "1.2.27", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 81406865de1..e61c75d0ec9 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.2.26", + "version": "1.2.27", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index ec58eb680d2..654e1dce754 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.2.26" +version = "1.2.27" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 2fe359410b4..a20a61f74d4 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.2.26", + "version": "1.2.27", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 99fa1295c46..c462b1761d2 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.2.26", + "version": "1.2.27", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index faf721a7c5a..5dedb464c4f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.2.26", + "version": "1.2.27", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 9713c7efa69..8ae3dae9c3f 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.2.26", + "version": "1.2.27", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 497d190146d..a29ffc40000 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.2.26", + "version": "1.2.27", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index b572ba03811..3f422968892 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.2.26", + "version": "1.2.27", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index c217401b30e..b48b755f3c8 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.2.26", + "version": "1.2.27", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index e5bd6a214be..6a2e48f7d36 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.2.26", + "version": "1.2.27", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index e2ee5c4771e..2adf05f419c 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.2.26", + "version": "1.2.27", "publisher": "sst-dev", "repository": { "type": "git", From c2ca1494e5f2b21655982d694b6bafd4526f147e Mon Sep 17 00:00:00 2001 From: Jason Quense Date: Mon, 16 Mar 2026 00:01:46 -0400 Subject: [PATCH 18/31] fix(opencode): preserve prompt tool enables with empty agent permissions (#17064) Co-authored-by: jquense --- packages/opencode/src/session/llm.ts | 8 +- packages/opencode/src/session/prompt.ts | 1 + packages/opencode/test/session/llm.test.ts | 92 +++++++++++++++++++++- 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4e42fb0d2ec..88841a30a8c 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -32,6 +32,7 @@ export namespace LLM { sessionID: string model: Provider.Model agent: Agent.Info + permission?: PermissionNext.Ruleset system: string[] abort: AbortSignal messages: ModelMessage[] @@ -255,8 +256,11 @@ export namespace LLM { }) } - async function resolveTools(input: Pick) { - const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission) + async function resolveTools(input: Pick) { + const disabled = PermissionNext.disabled( + Object.keys(input.tools), + PermissionNext.merge(input.agent.permission, input.permission ?? []), + ) for (const tool of Object.keys(input.tools)) { if (input.user.tools?.[tool] === false || disabled.has(tool)) { delete input.tools[tool] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 743537f5987..5bde2608f0b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -666,6 +666,7 @@ export namespace SessionPrompt { const result = await processor.process({ user: lastUser, agent, + permission: session.permission, abort, sessionID, system, diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 64e73e0def2..b44191b6ba2 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import path from "path" -import type { ModelMessage } from "ai" +import { tool, type ModelMessage } from "ai" +import z from "zod" import { LLM } from "../../src/session/llm" import { Global } from "../../src/global" import { Instance } from "../../src/project/instance" @@ -325,6 +326,95 @@ describe("session.llm.stream", () => { }) }) + test("keeps tools enabled by prompt permissions", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const providerID = "alibaba" + const modelID = "qwen-plus" + const fixture = await loadFixture(providerID, modelID) + const model = fixture.model + + const request = waitRequest( + "/chat/completions", + new Response(createChatStream("Hello"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel(providerID, model.id) + const sessionID = "session-test-tools" + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "question", pattern: "*", action: "deny" }], + } satisfies Agent.Info + + const user = { + id: "user-tools", + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID, modelID: resolved.id }, + tools: { question: true }, + } satisfies MessageV2.User + + const stream = await LLM.stream({ + user, + sessionID, + model: resolved, + agent, + permission: [{ permission: "question", pattern: "*", action: "allow" }], + system: ["You are a helpful assistant."], + abort: new AbortController().signal, + messages: [{ role: "user", content: "Hello" }], + tools: { + question: tool({ + description: "Ask a question", + inputSchema: z.object({}), + execute: async () => ({ output: "" }), + }), + }, + }) + + for await (const _ of stream.fullStream) { + } + + const capture = await request + const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined + expect(tools?.some((item) => item.function?.name === "question")).toBe(true) + }, + }) + }) + test("sends responses API payload for OpenAI models", async () => { const server = state.server if (!server) { From 59c530cc6cac6992f89d6b8387acd8dd74efc0ca Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:08:27 +1000 Subject: [PATCH 19/31] fix(opencode): teach Kit's test what an ID is (#17745) --- packages/opencode/test/session/llm.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index b44191b6ba2..5202c06dd93 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -368,8 +368,8 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(providerID, model.id) - const sessionID = "session-test-tools" + const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-tools") const agent = { name: "test", mode: "primary", @@ -378,12 +378,12 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: "user-tools", + id: MessageID.make("user-tools"), sessionID, role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID, modelID: resolved.id }, + model: { providerID: ProviderID.make(providerID), modelID: resolved.id }, tools: { question: true }, } satisfies MessageV2.User From 4d7cbdcbef92bb69613fe98ba64e832b5adddd79 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:39:06 +1000 Subject: [PATCH 20/31] fix(ci): workaround by using hoisted Bun linker on Windows (#17751) --- .github/actions/setup-bun/action.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index f53f20fcdb9..d1e3bfc25d0 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -41,5 +41,13 @@ runs: shell: bash - name: Install dependencies - run: bun install + run: | + # Workaround for patched peer variants + # e.g. ./patches/ for standard-openapi + # https://github.com/oven-sh/bun/issues/28147 + if [ "$RUNNER_OS" = "Windows" ]; then + bun install --linker hoisted + else + bun install + fi shell: bash From 51fcd04a70258e40f04ec1b5ca165aab6a6dfc32 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 16 Mar 2026 16:59:18 +0530 Subject: [PATCH 21/31] Wrap question option descriptions instead of truncating (#17782) --- packages/ui/src/components/message-part.css | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 5a325693bd7..8031bf2631d 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -1050,18 +1050,8 @@ line-height: var(--line-height-large); color: var(--text-base); min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - [data-slot="question-option"][data-custom="true"] { - [data-slot="option-description"] { - overflow: visible; - text-overflow: clip; - white-space: normal; - overflow-wrap: anywhere; - } + overflow-wrap: anywhere; + white-space: normal; } [data-slot="question-custom"] { From c523aac586b5b5d3d59143f19cad65c935090e4e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 16 Mar 2026 10:01:42 -0400 Subject: [PATCH 22/31] fix(cli): scope active org labels to the active account (#16957) --- packages/opencode/src/cli/cmd/account.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index 8ad42c5eb1a..b2256837dc3 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -11,6 +11,11 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => const println = (msg: string) => Effect.sync(() => UI.println(msg)) +const isActiveOrgChoice = ( + active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>, + choice: { accountID: AccountID; orgID: OrgID }, +) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID + const loginEffect = Effect.fn("login")(function* (url: string) { const service = yield* AccountService @@ -99,11 +104,10 @@ const switchEffect = Effect.fn("switch")(function* () { if (groups.length === 0) return yield* println("Not logged in") const active = yield* service.active() - const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id)) const opts = groups.flatMap((group) => group.orgs.map((org) => { - const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id + const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id }) return { value: { orgID: org.id, accountID: group.account.id, label: org.name }, label: isActive @@ -132,11 +136,10 @@ const orgsEffect = Effect.fn("orgs")(function* () { if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found") const active = yield* service.active() - const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id)) for (const group of groups) { for (const org of group.orgs) { - const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id + const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id }) const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " " const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL From 15b27e0d182b0b5db0eae45d2cc9ac1c670dd381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=A0les=C3=A1r?= Date: Mon, 16 Mar 2026 15:13:29 +0100 Subject: [PATCH 23/31] fix(app): agent switch should not reset thinking level (#17470) --- .../session/session-model-persistence.spec.ts | 22 +++++++++++++++++++ packages/app/src/context/local.tsx | 5 +++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts index 4b09a528734..933d5e6f96d 100644 --- a/packages/app/e2e/session/session-model-persistence.spec.ts +++ b/packages/app/e2e/session/session-model-persistence.spec.ts @@ -349,3 +349,25 @@ test("session model restore across workspaces", async ({ page, withProject }) => await waitFooter(page, firstState) }) }) + +test("variant preserved when switching agent modes", async ({ page, withProject }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + + await withProject(async ({ directory, gotoSession }) => { + await gotoSession() + + await ensureVariant(page, directory) + const updated = await chooseDifferentVariant(page) + + const available = await agents(page) + const other = available.find((name) => name !== updated.agent) + test.skip(!other, "only one agent available") + if (!other) return + + await choose(page, promptAgentSelector, other) + await waitFooter(page, { agent: other, variant: updated.variant }) + + await choose(page, promptAgentSelector, updated.agent) + await waitFooter(page, { agent: updated.agent, variant: updated.variant }) + }) +}) diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index bed7ecd15c2..76d337c82b4 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -192,10 +192,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ model: item.model, variant: item.variant ?? null, }) + const prev = scope() const next = { agent: item.name, - model: item.model, - variant: item.variant, + model: item.model ?? prev?.model, + variant: item.variant ?? prev?.variant, } satisfies State const session = id() if (session) { From e718db624fd0694cbdd051c8d27b577560cb057c Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Mon, 16 Mar 2026 16:25:24 +0100 Subject: [PATCH 24/31] fix(core): consider code: context_length_exceeded as context overflow in API call errors (#17748) --- packages/opencode/src/provider/error.ts | 3 ++- .../opencode/test/session/message-v2.test.ts | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index c9f83cd8c14..dd255448999 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -167,7 +167,8 @@ export namespace ProviderError { export function parseAPICallError(input: { providerID: ProviderID; error: APICallError }): ParsedAPICallError { const m = message(input.providerID, input.error) - if (isOverflow(m) || input.error.statusCode === 413) { + const body = json(input.error.responseBody) + if (isOverflow(m) || input.error.statusCode === 413 || body?.error?.code === "context_length_exceeded") { return { type: "context_overflow", message: m, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index e9c6cb729bb..86c9254f1da 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -869,6 +869,26 @@ describe("session.message-v2.fromError", () => { }) }) + test("detects context overflow from context_length_exceeded code in response body", () => { + const error = new APICallError({ + message: "Request failed", + url: "https://example.com", + requestBodyValues: {}, + statusCode: 422, + responseHeaders: { "content-type": "application/json" }, + responseBody: JSON.stringify({ + error: { + message: "Some message", + type: "invalid_request_error", + code: "context_length_exceeded", + }, + }), + isRetryable: false, + }) + const result = MessageV2.fromError(error, { providerID }) + expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true) + }) + test("does not classify 429 no body as context overflow", () => { const result = MessageV2.fromError( new APICallError({ 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 25/31] 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 8d934c05dab..072ea1d574e 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 26/31] 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 00000000000..59c556e0447 --- /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 00000000000..02d4bf48236 --- /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 4aec46befac..02a7391d44c 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 7fcd40eea0b..6a65a6f2e97 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 2782c0aba18..b790158d163 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 dac5e71ba13..fd3cc640a33 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 2d9cec5cd85..2e998593984 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 6ace981a9f1..7fffc0c877d 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 30a47bee9ff..3df8286e6d9 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 4e5d36cf488..00000000000 --- 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 cd4775acea9..2e9195c288e 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 99babd44a69..00000000000 --- 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 ab5bc1d99eb..45e0d3c318c 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 976b7d07ec7..00000000000 --- 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 27/31] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 62 ++++---- packages/sdk/openapi.json | 190 ++++++++++++------------ 2 files changed, 126 insertions(+), 126 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9ab71bd8f58..ff06a2c6cf6 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 2933b530f40..303969d7162 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": { @@ -9612,19 +9612,19 @@ "$ref": "#/components/schemas/Event.server.instance.disposed" }, { - "$ref": "#/components/schemas/Event.permission.asked" + "$ref": "#/components/schemas/Event.question.asked" }, { - "$ref": "#/components/schemas/Event.permission.replied" + "$ref": "#/components/schemas/Event.question.replied" }, { - "$ref": "#/components/schemas/Event.question.asked" + "$ref": "#/components/schemas/Event.question.rejected" }, { - "$ref": "#/components/schemas/Event.question.replied" + "$ref": "#/components/schemas/Event.permission.asked" }, { - "$ref": "#/components/schemas/Event.question.rejected" + "$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 28/31] stack: effectify-file-watcher-service (#17827) --- packages/opencode/AGENTS.md | 35 +++ packages/opencode/src/effect/instances.ts | 4 +- packages/opencode/src/file/watcher.ts | 190 +++++++------ 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 | 62 +++-- 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, 460 insertions(+), 139 deletions(-) create mode 100644 packages/opencode/test/file/watcher.test.ts diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index 930297baa9f..f281506220e 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 02d4bf48236..d60d7935589 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 + } +}) + +function getBackend() { + if (process.platform === "win32") return "windows" + if (process.platform === "darwin") return "fs-events" + if (process.platform === "linux") return "inotify" +} + export namespace FileWatcher { - const log = Log.create({ service: "file.watcher" }) - - export const Event = { - Updated: BusEvent.define( - "file.watcher.updated", - z.object({ - file: z.string(), - event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]), - }), - ), + export const Event = event + /** Whether the native @parcel/watcher binding is available on this platform. */ + export const hasNativeBinding = () => !!watcher() +} + +const init = Effect.fn("FileWatcherService.init")(function* () {}) + +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 }) - 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 - } - }) - - 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" - })() + log.info("init", { directory: instance.directory }) + + const backend = getBackend() if (!backend) { - log.error("watcher backend not supported", { platform: process.platform }) - return {} + log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform }) + return FileWatcherService.of({ init }) } - log.info("watcher backend", { platform: process.platform, backend }) const w = watcher() - if (!w) return {} + if (!w) return FileWatcherService.of({ init }) + + log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend }) - const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => { + 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" }) + 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 subs: ParcelWatcher.AsyncSubscription[] = [] + const cfg = yield* Effect.promise(() => Config.get()) 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 (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { + yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...Protected.paths()]) } - 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 (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 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) + const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( + (entry) => entry !== "HEAD", + ) + yield* subscribe(vcsDir, ignore) } } - return { subs } - }, - async (state) => { - if (!state.subs) return - await Promise.all(state.subs.map((sub) => sub?.unsubscribe())) - }, + 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 })) + }), + ), ) - - export function init() { - if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) { - return - } - state() - } } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index f1688a1b40a..a1cfd862b7a 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 a2be3733f85..bd819dc280a 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 fd3cc640a33..c16801a7a12 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 d6bc4973a06..7436abec9f5 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 - } + if (ws.data !== key) { + session.subscribers.delete(key) + continue + } - try { - ws.send(chunk) - } catch { - session.subscribers.delete(key) + 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 00000000000..7fe53612d9f --- /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 2e9195c288e..7f7e5e1f1ff 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" }, ], - }) - - 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") - } + }).then( + () => undefined, + (err) => err, + ) - 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 9063af872d4..f7a949c921f 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 29/31] 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 ff06a2c6cf6..fe2ae1ca044 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 303969d7162..374eec01d2e 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 30/31] 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 | 86 +++++++------ 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, 253 insertions(+), 83 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 00000000000..583b52d5621 --- /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 d60d7935589..2e6fbe167a3 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 class InstanceContext extends ServiceMap.Service()( - "opencode/InstanceContext", -) {} +export { InstanceContext } from "./instance-context" -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 651f15f8403..16ee8f27c84 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 b790158d163..f20b19acf3e 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 bd819dc280a..da4a67dba70 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 6eada6b675d..4d1f7b766b1 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 +export namespace VcsService { + export interface Service { + readonly init: () => Effect.Effect + readonly branch: () => Effect.Effect } +} - 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 }) +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 - 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 }) + 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 } - }) - return { - branch: async () => current, - unsubscribe, - } - }, - async (state) => { - state.unsubscribe?.() - }, - ) + current = yield* Effect.promise(() => currentBranch()) + log.info("initialized", { branch: current }) - export async function init() { - return state() - } + 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 }) + } + }), + ) - export async function branch() { - return await state().then((s) => s.branch()) - } + 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 55bcf2dfce1..677af4da87f 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 7fe53612d9f..a2de6173346 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 00000000000..d322e1d9fbe --- /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 00000000000..b5100585f5f --- /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 31/31] 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 fe2ae1ca044..b851e9ecbfe 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 374eec01d2e..1a440d12c79 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" },