diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 06be5dfbefbf..84239885cc21 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -153,7 +153,7 @@ export function Session() { }) const dimensions = useTerminalDimensions() - const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto") + const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar_visibility", "hide") const [sidebarOpen, setSidebarOpen] = createSignal(false) const [conceal, setConceal] = createSignal(true) const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 55684fc70dfb..54a2c2b532c1 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -196,6 +196,9 @@ const InfoSchema = Schema.Struct({ url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }), }), ), + yolo: Schema.optional(Schema.Boolean).annotate({ + description: "Enable YOLO mode - auto-approve all permission prompts (except explicit deny rules)", + }), compaction: Schema.optional( Schema.Struct({ auto: Schema.optional(Schema.Boolean).annotate({ diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 72c8931f5b71..1a23fbbe4b3c 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -89,6 +89,9 @@ export const Flag = { get OPENCODE_DISABLE_PROJECT_CONFIG() { return truthy("OPENCODE_DISABLE_PROJECT_CONFIG") }, + get OPENCODE_YOLO() { + return truthy("OPENCODE_YOLO") + }, get OPENCODE_TUI_CONFIG() { return process.env["OPENCODE_TUI_CONFIG"] }, diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 0a3a927b46ed..5c86b72e58d1 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -87,10 +87,17 @@ const cli = yargs(args) describe: "run without external plugins", type: "boolean", }) + .option("yolo", { + describe: "auto-approve ask permission prompts", + type: "boolean", + }) .middleware(async (opts) => { if (opts.pure) { process.env.OPENCODE_PURE = "1" } + if (opts.yolo) { + process.env.OPENCODE_YOLO = "1" + } await Log.init({ print: process.argv.includes("--print-logs"), diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index b9a221155c9f..044333b7d7b8 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -10,6 +10,7 @@ import { zod } from "@/util/effect-zod" import { Log } from "@/util" import { withStatics } from "@/util/schema" import { Wildcard } from "@/util" +import { Yolo } from "@/yolo" import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" import { evaluate as evalRule } from "./evaluate" @@ -180,7 +181,9 @@ export const layer = Layer.effect( const ask = Effect.fn("Permission.ask")(function* (input: AskInput) { const { approved, pending } = yield* InstanceState.get(state) const { ruleset, ...request } = input + const yolo = Yolo.isEnabled() let needsAsk = false + let auto = false for (const pattern of request.patterns) { const rule = evaluate(request.permission, pattern, ruleset, approved) @@ -191,9 +194,16 @@ export const layer = Layer.effect( }) } if (rule.action === "allow") continue + if (yolo) { + auto = true + continue + } needsAsk = true } + if (auto) { + log.warn("auto-approved by yolo", { permission: request.permission, patterns: request.patterns }) + } if (!needsAsk) return const id = request.id ?? PermissionID.ascending() diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a7c071a9f80b..ea9b1c0fc5dc 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -13,11 +13,14 @@ import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share" import * as Effect from "effect/Effect" import { Config } from "@/config" +import { Yolo } from "../yolo" export const InstanceBootstrap = Effect.gen(function* () { Log.Default.info("bootstrapping", { directory: Instance.directory }) // everything depends on config so eager load it for nice traces yield* Config.Service.use((svc) => svc.get()) + // Yolo depends on config so init it right after config is ready. + yield* Effect.promise(() => Yolo.init()) // Plugin can mutate config so it has to be initialized before anything else. yield* Plugin.Service.use((svc) => svc.init()) yield* Effect.all( diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d643f25373af..a088feaca6fb 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1472,6 +1472,29 @@ const layer: Layer.Layer< } } + // Kimi's coding API requires a thinking block on every assistant message + // that contains tool_use when thinking is enabled. The @ai-sdk/anthropic SDK + // drops thinking blocks from multi-turn messages because Kimi doesn't return + // Anthropic-style signatures. We inject an empty thinking block here so Kimi + // doesn't reject the request with 'reasoning_content is missing'. + if (model.providerID === "kimi-for-coding" && opts.body && opts.method === "POST") { + const body = JSON.parse(opts.body as string) + if (Array.isArray(body.messages)) { + for (const msg of body.messages) { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue + const hasToolUse = msg.content.some((b: any) => b.type === "tool_use") + const hasThinking = msg.content.some( + (b: any) => b.type === "thinking" || b.type === "redacted_thinking", + ) + if (hasToolUse && !hasThinking) { + msg.content.unshift({ type: "thinking", thinking: ".", signature: "placeholder" }) + msg.reasoning_content = "." + } + } + opts.body = JSON.stringify(body) + } + } + const res = await fetchFn(input, { ...opts, // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts index 7f368cd31c22..6337aa0f7f4c 100644 --- a/packages/opencode/src/server/routes/instance/config.ts +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -1,12 +1,34 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" +import path from "path" import { Config } from "@/config" import { Provider } from "@/provider" +import { Yolo } from "@/yolo" +import { Global } from "@/global" import { errors } from "../../error" +import { Log } from "@/util" import { lazy } from "@/util/lazy" import { jsonRequest } from "./trace" +const log = Log.create({ service: "server" }) + +// Helper to read/write global config for YOLO persistence (uses config.json, not opencode.jsonc) +async function readGlobalConfig(): Promise> { + const filepath = path.join(Global.Path.config, "config.json") + try { + const text = await Bun.file(filepath).text() + return JSON.parse(text) + } catch { + return {} + } +} + +async function writeGlobalConfig(config: Record): Promise { + const filepath = path.join(Global.Path.config, "config.json") + await Bun.write(filepath, JSON.stringify(config, null, 2)) +} + export const ConfigRoutes = lazy(() => new Hono() .get( @@ -85,5 +107,79 @@ export const ConfigRoutes = lazy(() => default: Provider.defaultModelIDs(providers), } }), + ) + .get( + "/yolo", + describeRoute({ + summary: "Get YOLO mode status", + description: + "Check if YOLO mode is enabled. When enabled, all permission prompts are auto-approved (except explicit deny rules).", + operationId: "config.yolo.get", + responses: { + 200: { + description: "YOLO mode status", + content: { + "application/json": { + schema: resolver(z.object({ enabled: z.boolean(), persisted: z.boolean() })), + }, + }, + }, + }, + }), + async (c) => { + const globalConfig = await readGlobalConfig() + return c.json({ + enabled: Yolo.isEnabled(), + persisted: globalConfig.yolo === true, + }) + }, + ) + .post( + "/yolo", + describeRoute({ + summary: "Set YOLO mode", + description: + "Enable or disable YOLO mode. When enabled, all permission prompts are auto-approved (except explicit deny rules). Use with caution. Set persist=true to save to config file.", + operationId: "config.yolo.set", + responses: { + 200: { + description: "YOLO mode updated", + content: { + "application/json": { + schema: resolver(z.object({ enabled: z.boolean(), persisted: z.boolean() })), + }, + }, + }, + }, + }), + validator("json", z.object({ enabled: z.boolean(), persist: z.boolean().optional() })), + async (c) => { + const { enabled, persist } = c.req.valid("json") + Yolo.set(enabled) + + try { + const globalConfig = await readGlobalConfig() + const wasPersisted = globalConfig.yolo === true + + if (persist) { + if (enabled) { + globalConfig.yolo = true + } else { + delete globalConfig.yolo + } + await writeGlobalConfig(globalConfig) + log.info("YOLO mode config updated", { enabled, path: Global.Path.config }) + } else if (wasPersisted && enabled) { + delete globalConfig.yolo + await writeGlobalConfig(globalConfig) + log.info("YOLO mode downgraded to session-only", { path: Global.Path.config }) + } + } catch (e) { + log.error("Failed to update YOLO config", { error: e }) + } + + const finalConfig = await readGlobalConfig() + return c.json({ enabled: Yolo.isEnabled(), persisted: finalConfig.yolo === true }) + }, ), ) diff --git a/packages/opencode/src/yolo/index.ts b/packages/opencode/src/yolo/index.ts new file mode 100644 index 000000000000..612c6856ec73 --- /dev/null +++ b/packages/opencode/src/yolo/index.ts @@ -0,0 +1,67 @@ +import path from "path" +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Flag } from "@/flag/flag" +import { Global } from "@/global" +import { Log } from "@/util" +import z from "zod" + +export namespace Yolo { + const log = Log.create({ service: "yolo" }) + + let enabled = Flag.OPENCODE_YOLO + + export const Event = { + Changed: BusEvent.define( + "yolo.changed", + z.object({ + enabled: z.boolean(), + }), + ), + } + + async function readGlobalYolo(): Promise { + const filepath = path.join(Global.Path.config, "config.json") + try { + const text = await Bun.file(filepath).text() + const parsed = JSON.parse(text) + return parsed?.yolo === true + } catch { + return false + } + } + + export async function init() { + if (await readGlobalYolo()) { + enabled = true + log.warn("YOLO mode enabled via config") + } + if (Flag.OPENCODE_YOLO) { + enabled = true + log.warn("YOLO mode enabled via OPENCODE_YOLO env var") + } + if (enabled) { + log.warn("YOLO mode is ACTIVE - all permission prompts will be auto-approved") + } + } + + export function isEnabled(): boolean { + return enabled + } + + export function set(value: boolean) { + const previous = enabled + enabled = value + if (previous !== value) { + log.warn(`YOLO mode ${value ? "ENABLED" : "DISABLED"}`) + void Bus.publish(Event.Changed, { enabled: value }).catch((err) => { + log.debug("failed to publish yolo.changed", { err: String(err) }) + }) + } + } + + export function toggle(): boolean { + set(!enabled) + return enabled + } +} diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index d654d4b876b8..8c3d78c94e61 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -9,6 +9,7 @@ import { Instance } from "../../src/project/instance" import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" +import { Yolo } from "../../src/yolo" const bus = Bus.layer const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer) @@ -1078,3 +1079,67 @@ it.live("ask - abort should clear pending request", () => if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) }), ) + +it.live("ask - auto-approves ask when yolo is enabled", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const run = withProvided(dir) + + const previous = Yolo.isEnabled() + Yolo.set(true) + try { + const result = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + }).pipe(run) + expect(result).toBeUndefined() + expect(yield* list().pipe(run)).toHaveLength(0) + } finally { + Yolo.set(previous) + } + }), +) + +it.live("ask - yolo still respects deny rules", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const run = withProvided(dir) + + const previous = Yolo.isEnabled() + Yolo.set(true) + try { + const err = yield* fail( + ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["rm -rf /"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "deny" }], + }).pipe(run), + ) + expect(err).toBeInstanceOf(Permission.DeniedError) + } finally { + Yolo.set(previous) + } + }), +) + +test("yolo - init reads OPENCODE_YOLO at runtime", async () => { + const previous = Yolo.isEnabled() + const prevEnv = process.env.OPENCODE_YOLO + Yolo.set(false) + process.env.OPENCODE_YOLO = "1" + try { + await Yolo.init() + expect(Yolo.isEnabled()).toBe(true) + } finally { + Yolo.set(previous) + if (prevEnv === undefined) delete process.env.OPENCODE_YOLO + else process.env.OPENCODE_YOLO = prevEnv + } +})