Skip to content
Open
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
6 changes: 6 additions & 0 deletions github/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ inputs:
description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/opencode,/oc'"
required: false

emit_subagent_events:
description: "Emit subagent tool calls and events to workflow logs (verbose output)"
required: false
default: "false"

oidc_base_url:
description: "Base URL for OIDC token exchange API. Only required when running a custom GitHub App install. Defaults to https://api.opencode.ai"
required: false
Expand Down Expand Up @@ -72,3 +77,4 @@ runs:
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
MENTIONS: ${{ inputs.mentions }}
OIDC_BASE_URL: ${{ inputs.oidc_base_url }}
OPENCODE_EMIT_SUBAGENT_EVENTS: ${{ inputs.emit_subagent_events }}
98 changes: 73 additions & 25 deletions packages/opencode/src/cli/cmd/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ import { Session } from "../../session"
import { Identifier } from "../../id/id"
import { Provider } from "../../provider/provider"
import { Bus } from "../../bus"
import { Flag } from "../../flag/flag"
import { MessageV2 } from "../../session/message-v2"
import { PermissionNext } from "../../permission/next"
import { SessionPrompt } from "@/session/prompt"
import { $ } from "bun"

Expand Down Expand Up @@ -475,6 +477,7 @@ export const GithubRunCommand = cmd({
let gitConfig: string
let session: { id: string; title: string; version: string }
let shareId: string | undefined
let unsubscribeEvents: (() => void) | undefined
let exitCode = 0
type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
const triggerCommentId = isCommentEvent
Expand Down Expand Up @@ -526,7 +529,7 @@ export const GithubRunCommand = cmd({
},
],
})
subscribeSessionEvents()
unsubscribeEvents = subscribeSessionEvents()
shareId = await (async () => {
if (share === false) return
if (!share && repoData.data.private) return
Expand Down Expand Up @@ -641,6 +644,7 @@ export const GithubRunCommand = cmd({
// Also output the clean error message for the action to capture
//core.setOutput("prepare_error", e.message);
} finally {
unsubscribeEvents?.()
if (!useGithubToken) {
await restoreGitConfig()
await revokeAppToken()
Expand Down Expand Up @@ -836,34 +840,78 @@ export const GithubRunCommand = cmd({
)
}

// Track sessions: main session + subagent sessions when OPENCODE_EMIT_SUBAGENT_EVENTS
const trackedSessions = new Set<string>([session.id])
const unsubscribes: Array<() => void> = []

if (Flag.OPENCODE_EMIT_SUBAGENT_EVENTS) {
unsubscribes.push(
Bus.subscribe(Session.Event.Created, (evt) => {
const info = evt.properties.info
if (info.parentID && trackedSessions.has(info.parentID)) {
trackedSessions.add(info.id)
console.log()
printEvent(UI.Style.TEXT_INFO_BOLD, "Agent", info.title ?? "Subagent started")
}
}),
)
}

let text = ""
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
if (evt.properties.part.sessionID !== session.id) return
//if (evt.properties.part.messageID === messageID) return
const part = evt.properties.part

if (part.type === "tool" && part.state.status === "completed") {
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
const title =
part.state.title || Object.keys(part.state.input).length > 0
? JSON.stringify(part.state.input)
: "Unknown"
console.log()
printEvent(color, tool, title)
}
unsubscribes.push(
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
const shouldTrack =
evt.properties.part.sessionID === session.id ||
(Flag.OPENCODE_EMIT_SUBAGENT_EVENTS && trackedSessions.has(evt.properties.part.sessionID))
if (!shouldTrack) return

const part = evt.properties.part

if (part.type === "tool" && part.state.status === "completed") {
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
const title =
part.state.title || Object.keys(part.state.input).length > 0
? JSON.stringify(part.state.input)
: "Unknown"
console.log()
printEvent(color, tool, title)
}

if (part.type === "text") {
text = part.text
if (part.type === "text") {
text = part.text

if (part.time?.end) {
UI.empty()
UI.println(UI.markdown(text))
UI.empty()
text = ""
return
if (part.time?.end) {
UI.empty()
UI.println(UI.markdown(text))
UI.empty()
text = ""
return
}
}
}
})
}),
)

// Subscribe to permission events (auto-denied in non-interactive mode)
unsubscribes.push(
Bus.subscribe(PermissionNext.Event.Asked, async (evt) => {
const shouldTrack =
evt.properties.sessionID === session.id ||
(Flag.OPENCODE_EMIT_SUBAGENT_EVENTS && trackedSessions.has(evt.properties.sessionID))
if (!shouldTrack) return

console.log()
printEvent(
UI.Style.TEXT_WARNING_BOLD,
"Denied",
`${evt.properties.permission}: ${evt.properties.patterns.join(", ")}`,
)
console.log(
` To allow, add to opencode.json: { "permission": { "${evt.properties.permission}": "allow" } }`,
)
}),
)

return () => unsubscribes.forEach((unsub) => unsub())
}

async function summarize(response: string) {
Expand Down
13 changes: 13 additions & 0 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent"
import { isInteractive } from "../../util/interactive"

const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
Expand Down Expand Up @@ -209,6 +210,18 @@ export const RunCommand = cmd({
if (event.type === "permission.asked") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue

// In non-interactive mode, permission was already auto-denied by the server
// Just log what was denied so users can configure permissions
if (!isInteractive()) {
if (outputJsonEvent("permission_denied", { permission })) continue
UI.println()
UI.println(UI.Style.TEXT_WARNING_BOLD + "Permission denied:", permission.permission)
UI.println(` Patterns: ${permission.patterns.join(", ")}`)
UI.println(` To allow: add { "permission": { "${permission.permission}": "allow" } } to opencode.json`)
continue
}

const result = await select({
message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`,
options: [
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export namespace Flag {
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli"
export const OPENCODE_EMIT_SUBAGENT_EVENTS = truthy("OPENCODE_EMIT_SUBAGENT_EVENTS")
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]

Expand Down
20 changes: 16 additions & 4 deletions packages/opencode/src/permission/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Identifier } from "@/id/id"
import { Instance } from "@/project/instance"
import { Storage } from "@/storage/storage"
import { fn } from "@/util/fn"
import { isInteractive } from "@/util/interactive"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
import z from "zod"
Expand Down Expand Up @@ -127,11 +128,22 @@ export namespace PermissionNext {
throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
if (rule.action === "ask") {
const id = input.id ?? Identifier.ascending("permission")
const info: Request = {
id,
...request,
}

// Non-interactive mode: no one to approve, auto-deny
if (!isInteractive()) {
Bus.publish(Event.Asked, info)
log.warn("auto-denied permission in non-interactive mode", {
permission: request.permission,
patterns: request.patterns,
})
throw new DeniedError([{ permission: request.permission, pattern: "*", action: "deny" }])
}

return new Promise<void>((resolve, reject) => {
const info: Request = {
id,
...request,
}
s.pending[id] = {
info,
resolve,
Expand Down
10 changes: 10 additions & 0 deletions packages/opencode/src/util/interactive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function isInteractive(): boolean {
// Allow tests to override interactive detection
if (process.env["OPENCODE_FORCE_INTERACTIVE"] === "true") return true
const ci = process.env["CI"]?.toLowerCase()
if (ci === "true" || ci === "1") return false
// Desktop and other GUI clients handle permissions through their own UI
const client = process.env["OPENCODE_CLIENT"]
if (client && client !== "cli") return true
return process.stdin.isTTY === true && process.stdout.isTTY === true
}
3 changes: 3 additions & 0 deletions packages/opencode/test/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ if (response.ok) {
// Disable models.dev refresh to avoid race conditions during tests
process.env["OPENCODE_DISABLE_MODELS_FETCH"] = "true"

// Force interactive mode for tests that test permission prompts
process.env["OPENCODE_FORCE_INTERACTIVE"] = "true"

// Clear provider env vars to ensure clean test state
delete process.env["ANTHROPIC_API_KEY"]
delete process.env["OPENAI_API_KEY"]
Expand Down
103 changes: 103 additions & 0 deletions packages/opencode/test/util/interactive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"

// Store original env values
const originalEnv: Record<string, string | undefined> = {}

function saveEnv(...keys: string[]) {
for (const key of keys) {
originalEnv[key] = process.env[key]
}
}

function restoreEnv() {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}

function clearEnv(...keys: string[]) {
for (const key of keys) {
delete process.env[key]
}
}

// Dynamic import to get fresh module state
async function getIsInteractive() {
// Clear module cache to get fresh evaluation
const path = "../../src/util/interactive"
delete require.cache[require.resolve(path)]
const { isInteractive } = await import(path)
return isInteractive
}

describe("isInteractive", () => {
beforeEach(() => {
saveEnv("OPENCODE_FORCE_INTERACTIVE", "CI", "OPENCODE_CLIENT")
clearEnv("OPENCODE_FORCE_INTERACTIVE", "CI", "OPENCODE_CLIENT")
})

afterEach(() => {
restoreEnv()
})

test("returns true when OPENCODE_FORCE_INTERACTIVE=true", async () => {
process.env["OPENCODE_FORCE_INTERACTIVE"] = "true"
process.env["CI"] = "true" // Should be overridden
const isInteractive = await getIsInteractive()
expect(isInteractive()).toBe(true)
})

test("returns false when CI=true", async () => {
process.env["CI"] = "true"
const isInteractive = await getIsInteractive()
expect(isInteractive()).toBe(false)
})

test("returns false when CI=1", async () => {
process.env["CI"] = "1"
const isInteractive = await getIsInteractive()
expect(isInteractive()).toBe(false)
})

test("returns false when CI=TRUE (case insensitive)", async () => {
process.env["CI"] = "TRUE"
const isInteractive = await getIsInteractive()
expect(isInteractive()).toBe(false)
})

test("returns false when CI=True (case insensitive)", async () => {
process.env["CI"] = "True"
const isInteractive = await getIsInteractive()
expect(isInteractive()).toBe(false)
})

test("returns true when OPENCODE_CLIENT=desktop", async () => {
process.env["OPENCODE_CLIENT"] = "desktop"
const isInteractive = await getIsInteractive()
expect(isInteractive()).toBe(true)
})

test("returns true when OPENCODE_CLIENT=vscode", async () => {
process.env["OPENCODE_CLIENT"] = "vscode"
const isInteractive = await getIsInteractive()
expect(isInteractive()).toBe(true)
})

test("falls through to TTY check when OPENCODE_CLIENT=cli", async () => {
process.env["OPENCODE_CLIENT"] = "cli"
const isInteractive = await getIsInteractive()
// In test environment, TTY is typically false
const expected = process.stdin.isTTY === true && process.stdout.isTTY === true
expect(isInteractive()).toBe(expected)
})

test("falls through to TTY check when no env vars set", async () => {
const isInteractive = await getIsInteractive()
const expected = process.stdin.isTTY === true && process.stdout.isTTY === true
expect(isInteractive()).toBe(expected)
})
})