Skip to content
Open
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"

variant:
description: "Model variant for provider-specific reasoning effort (e.g., high, max, minimal)"
required: false
Expand Down Expand Up @@ -77,3 +82,4 @@ runs:
MENTIONS: ${{ inputs.mentions }}
VARIANT: ${{ inputs.variant }}
OIDC_BASE_URL: ${{ inputs.oidc_base_url }}
OPENCODE_EMIT_SUBAGENT_EVENTS: ${{ inputs.emit_subagent_events }}
100 changes: 74 additions & 26 deletions packages/opencode/src/cli/cmd/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,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 @@ -481,6 +483,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 @@ -532,7 +535,7 @@ export const GithubRunCommand = cmd({
},
],
})
subscribeSessionEvents()
unsubscribeEvents = subscribeSessionEvents(session.id)
shareId = await (async () => {
if (share === false) return
if (!share && repoData.data.private) return
Expand Down Expand Up @@ -670,6 +673,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 @@ -843,7 +847,7 @@ export const GithubRunCommand = cmd({
return { userPrompt: prompt, promptFiles: imgData }
}

function subscribeSessionEvents() {
function subscribeSessionEvents(sessionID: string) {
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
Expand All @@ -866,34 +870,78 @@ export const GithubRunCommand = cmd({
)
}

// Track sessions: main session + subagent sessions when OPENCODE_EMIT_SUBAGENT_EVENTS
const trackedSessions = new Set<string>([sessionID])
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 === sessionID ||
(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 === sessionID ||
(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
1 change: 1 addition & 0 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export namespace Flag {
export declare const OPENCODE_CLIENT: string
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
export const OPENCODE_EMIT_SUBAGENT_EVENTS = truthy("OPENCODE_EMIT_SUBAGENT_EVENTS")
export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL")

// Experimental
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 @@ -6,6 +6,7 @@ import { Instance } from "@/project/instance"
import { Database, eq } from "@/storage/db"
import { PermissionTable } from "@/session/session.sql"
import { fn } from "@/util/fn"
import { isInteractive } from "@/util/interactive"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
import os from "os"
Expand Down Expand Up @@ -142,11 +143,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
11 changes: 11 additions & 0 deletions packages/opencode/src/util/interactive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Flag } from "@/flag/flag"

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
if (Flag.OPENCODE_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 @@ const cacheDir = path.join(dir, "cache", "opencode")
await fs.mkdir(cacheDir, { recursive: true })
await fs.writeFile(path.join(cacheDir, "version"), "14")

// 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
84 changes: 84 additions & 0 deletions packages/opencode/test/util/interactive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { isInteractive } from "../../src/util/interactive"

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]
}
}

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", () => {
process.env["OPENCODE_FORCE_INTERACTIVE"] = "true"
process.env["CI"] = "true" // Should be overridden
expect(isInteractive()).toBe(true)
})

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

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

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

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

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

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

test("falls through to TTY check when OPENCODE_CLIENT=cli", () => {
process.env["OPENCODE_CLIENT"] = "cli"
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", () => {
const expected = process.stdin.isTTY === true && process.stdout.isTTY === true
expect(isInteractive()).toBe(expected)
})
})
Loading