diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index ed0a11acd3d0..6b3af176f13d 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -47,6 +47,21 @@ export const AttachCommand = cmd({ describe: "start the minimal interactive interface", default: false, }) + .option("auto", { + type: "boolean", + describe: "auto-approve permissions that are not explicitly denied (dangerous!)", + default: false, + }) + .option("yolo", { + type: "boolean", + hidden: true, + default: false, + }) + .option("dangerously-skip-permissions", { + type: "boolean", + hidden: true, + default: false, + }) .option("replay", { type: "boolean", hidden: true, @@ -90,6 +105,7 @@ export const AttachCommand = cmd({ fork: args.fork, replay: noReplay ? false : undefined, replayLimit: args.replayLimit, + auto: args.auto || args.yolo || args["dangerously-skip-permissions"], }) return } @@ -139,6 +155,7 @@ export const AttachCommand = cmd({ continue: args.continue, sessionID: args.session, fork: args.fork, + auto: args.auto || args.yolo || args["dangerously-skip-permissions"], }, directory, headers, diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index f8cf885d0bd7..4245fb775e02 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -892,6 +892,7 @@ export const RunCommand = effectCmd({ thinking, backgroundSubagents: flags.experimentalBackgroundSubagents, demo: args.demo, + auto, }) } catch (error) { dieInteractive(error) @@ -929,6 +930,7 @@ export const RunCommand = effectCmd({ thinking, backgroundSubagents: flags.experimentalBackgroundSubagents, demo: args.demo, + auto, }) } catch (error) { dieInteractive(error) @@ -972,6 +974,7 @@ type MiniCommandInput = { replay?: boolean replayLimit?: number demo?: boolean + auto?: boolean } export async function runMini(input: MiniCommandInput) { @@ -1002,7 +1005,7 @@ export async function runMini(input: MiniCommandInput) { replay: input.replay ?? true, "replay-limit": input.replayLimit, replayLimit: input.replayLimit, - auto: false, + auto: input.auto ?? false, yolo: false, "dangerously-skip-permissions": false, dangerouslySkipPermissions: false, diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 90cddffa2222..24269cf60d74 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -55,6 +55,7 @@ type RunRuntimeInput = { replay?: boolean replayLimit?: number demo?: RunInput["demo"] + auto?: boolean } type RunLocalInput = { @@ -74,6 +75,7 @@ type RunLocalInput = { replay?: boolean replayLimit?: number demo?: RunInput["demo"] + auto?: boolean } type StreamTransportModule = Pick< @@ -483,6 +485,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep providers: () => state.providers, footer, trace: log, + auto: input.auto, }) if (footer.isClosed) { await handle.close() @@ -748,6 +751,7 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise { if (session) { return session @@ -797,6 +801,7 @@ export async function runInteractiveMode( replay: input.replay, replayLimit: input.replayLimit, demo: input.demo, + auto: input.auto, boot: async () => ({ sdk: input.sdk, directory: input.directory, diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index 4c3f2a589a1b..ed2d4373a2ff 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -78,6 +78,9 @@ type StreamInput = { footer: FooterApi trace?: Trace signal?: AbortSignal + // Auto-approve (YOLO): reply "once" to every permission request instead of + // surfacing a prompt. Questions still block; explicit deny rules still win. + auto?: boolean } type Wait = { @@ -522,6 +525,14 @@ function createLayer(input: StreamInput) { return } + // YOLO: auto-approve permission requests without surfacing a prompt. + // Questions are left to the user; explicit deny rules already reject + // server-side before a permission.asked is ever emitted. + if (input.auto && event.type === "permission.asked") { + void input.sdk.permission.reply({ requestID: event.properties.id, reply: "once" }).catch(() => {}) + return + } + seedBlocker(event.properties.id) } diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index a914922e4870..76f10645702e 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -70,6 +70,7 @@ export type RunInput = { thinking: boolean backgroundSubagents: boolean demo?: boolean + auto?: boolean } // The semantic role of a scrollback entry. Maps 1:1 to theme colors. diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index a2ca62e075e7..85f6c8861586 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -171,6 +171,7 @@ export const TuiThreadCommand = cmd({ replay: noReplay ? false : undefined, replayLimit: args.replayLimit, demo: args.demo, + auto: args.auto || args.yolo || args["dangerously-skip-permissions"], }) return } diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index 6962df27aefb..3a4ededb85aa 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -63,6 +63,8 @@ Options: -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode') [string] --mini start the minimal interactive interface [boolean] [default: false] + --auto auto-approve permissions that are not explicitly denied (dangerous!) + [boolean] [default: false] --no-replay disable mini session history replay on resume and after resize [boolean] --replay-limit cap visible mini replay to the newest N messages [number]" `; diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index 320e72ccf9b4..6c102953f95e 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -745,6 +745,69 @@ describe("run stream transport", () => { } }) + test("auto mode replies once to permission requests without prompting", async () => { + const src = eventFeed() + const ui = footer() + const client = sdk({ stream: src.stream }) + const replySpy = spyOn(client.permission, "reply").mockImplementation(() => ok(undefined) as never) + const transport = await createSessionTransport({ + sdk: client, + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + auto: true, + }) + + try { + // Processed in order: a foreign-session permission and a question are pushed + // BEFORE the real request, so by the time the real reply fires they've already + // been handled — proving neither was auto-approved. + src.push({ + id: "evt-perm-foreign", + type: "permission.asked", + properties: { + id: "perm-foreign", + sessionID: "session-other", + permission: "edit", + patterns: ["src/other.ts"], + metadata: {}, + always: [], + }, + } satisfies SdkEvent) + src.push({ + id: "evt-question-1", + type: "question.asked", + properties: { + id: "que-1", + sessionID: "session-1", + questions: [{ question: "Proceed?", header: "confirm", options: [{ label: "Yes", description: "go" }] }], + }, + } satisfies SdkEvent) + src.push({ + id: "evt-perm-1", + type: "permission.asked", + properties: { + id: "perm-1", + sessionID: "session-1", + permission: "edit", + patterns: ["src/foo.ts"], + metadata: {}, + always: [], + }, + } satisfies SdkEvent) + + await waitFor(() => (replySpy.mock.calls.length > 0 ? true : undefined)) + // Only the in-session permission is auto-approved; questions still block and a + // foreign session is never auto-replied. + expect(replySpy).toHaveBeenCalledTimes(1) + expect(replySpy).toHaveBeenCalledWith({ requestID: "perm-1", reply: "once" }) + } finally { + src.close() + await transport.close() + } + }) + test("rebuilds session output on resize and continues live deltas from replayed state", async () => { const src = eventFeed() const ui = footer()