Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
},
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
10 changes: 10 additions & 0 deletions packages/opencode/src/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/project/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
23 changes: 23 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions packages/opencode/src/server/routes/instance/config.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> {
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<string, unknown>): Promise<void> {
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(
Expand Down Expand Up @@ -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 })
},
),
)
67 changes: 67 additions & 0 deletions packages/opencode/src/yolo/index.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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
}
}
65 changes: 65 additions & 0 deletions packages/opencode/test/permission/next.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
})
Loading