diff --git a/github/action.yml b/github/action.yml index 3d983a160995..0a044d98f067 100644 --- a/github/action.yml +++ b/github/action.yml @@ -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 @@ -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 }} diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 672e73d49a97..ba6005630cdc 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -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" @@ -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>["promptFiles"] const triggerCommentId = isCommentEvent @@ -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 @@ -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() @@ -843,7 +847,7 @@ export const GithubRunCommand = cmd({ return { userPrompt: prompt, promptFiles: imgData } } - function subscribeSessionEvents() { + function subscribeSessionEvents(sessionID: string) { const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD], @@ -866,34 +870,78 @@ export const GithubRunCommand = cmd({ ) } + // Track sessions: main session + subagent sessions when OPENCODE_EMIT_SUBAGENT_EVENTS + const trackedSessions = new Set([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) { diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 0049d716d095..19272e154f1b 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -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 diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 1e1df62a3ce9..669999d51f18 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -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" @@ -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((resolve, reject) => { - const info: Request = { - id, - ...request, - } s.pending[id] = { info, resolve, diff --git a/packages/opencode/src/util/interactive.ts b/packages/opencode/src/util/interactive.ts new file mode 100644 index 000000000000..40b0648e5f06 --- /dev/null +++ b/packages/opencode/src/util/interactive.ts @@ -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 +} diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index dee7045707ea..e6ba2e808db2 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -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"] diff --git a/packages/opencode/test/util/interactive.test.ts b/packages/opencode/test/util/interactive.test.ts new file mode 100644 index 000000000000..4f4f77f2cc71 --- /dev/null +++ b/packages/opencode/test/util/interactive.test.ts @@ -0,0 +1,84 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { isInteractive } from "../../src/util/interactive" + +const originalEnv: Record = {} + +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) + }) +})