From b472aa1ba92a6e64bdbd35db5e94832659f1f74d Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Tue, 24 Feb 2026 02:05:26 +0000 Subject: [PATCH] fix(opencode): add effort-aware model overrides and OpenRouter guidance (#354) * chore: create empty commit for PR Co-authored-by: Cursor * chore(action): trigger preview repo creation workflow Co-authored-by: Cursor * fix(opencode): merge repo opencode config and log provider key presence Co-authored-by: Cursor * fix(opencode): add effort-aware model override precedence Add OPENCODE_MODEL_MINI and OPENCODE_MODEL_MAX support with fallback to OPENCODE_MODEL and auto-selection, and keep provider resolution aligned via inline OpenCode config when overrides are used. Update workflow env wiring, test allowlists, and docs (including OpenRouter setup guidance and sidebar ordering) to document the new behavior. Co-authored-by: Cursor * docs(effort): clarify OpenCode precedence and add section link Add a concise OpenCode precedence callout in the summary area and link directly to the OpenCode section for full details and examples. Co-authored-by: Cursor * Restructure dash (#372) * Restructure dash * WIP * WIP * refactor trigger UI: extract PR summary card, add mentions section, rename labels Co-authored-by: Cursor * clean up console UI: remove info icons from section descriptions, rename mentions trigger Co-authored-by: Cursor * fix review feedback: layout, terminology, form scope - extract console sidebar sections to module-level constant - align three-column layout breakpoints to xl (match sidebar visibility) - fix mixed shell/bash terminology in beta page - scope FormProvider to trigger sections only, restore autoComplete="off" Co-authored-by: Cursor * Bump --------- Co-authored-by: Cursor * chore: create empty commit for PR Co-authored-by: Cursor * fix(opencode): merge repo opencode config and log provider key presence Co-authored-by: Cursor * fix(opencode): add effort-aware model override precedence Add OPENCODE_MODEL_MINI and OPENCODE_MODEL_MAX support with fallback to OPENCODE_MODEL and auto-selection, and keep provider resolution aligned via inline OpenCode config when overrides are used. Update workflow env wiring, test allowlists, and docs (including OpenRouter setup guidance and sidebar ordering) to document the new behavior. Co-authored-by: Cursor * chore: update agent rules and OpenCode config docs Align AGENTS guidance with current preferences and apply review-driven wording updates in OpenCode-related files. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .github/workflows/test.yml | 2 + README.md | 2 +- agents/opencode.ts | 240 +++++++++++++++++++++++++++++++---- entry | 247 +++++++++++++++++++++++++++---------- mcp/server.ts | 117 ++++++++---------- package.json | 2 +- post | 2 +- test/ci.test.ts | 2 + utils/docker.ts | 2 + utils/setup.ts | 20 ++- 10 files changed, 481 insertions(+), 155 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ab01117..546f425 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,6 +39,8 @@ jobs: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }} + OPENCODE_MODEL_MINI: ${{ vars.OPENCODE_MODEL_MINI }} + OPENCODE_MODEL_MAX: ${{ vars.OPENCODE_MODEL_MAX }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 diff --git a/README.md b/README.md index ffe8977..5c691ff 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - +

diff --git a/agents/opencode.ts b/agents/opencode.ts index 4f29f2c..69a8d40 100644 --- a/agents/opencode.ts +++ b/agents/opencode.ts @@ -1,7 +1,7 @@ // changes to effort level configuration should be reflected in wiki/effort.md and docs/effort.mdx // changes to tool permissions should be reflected in wiki/granular-tools.md // changes to web search configuration should be reflected in wiki/websearch.md -import { mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { performance } from "node:perf_hooks"; import { ghPullfrogMcpName } from "../external.ts"; @@ -38,6 +38,159 @@ function detectProviderError(text: string): string | null { return null; } +type OpenCodeConfig = { + mcp?: Record; + permission?: Record; + provider?: Record; + model?: string; + enabled_providers?: string[]; + [key: string]: unknown; +}; + +type RecordPropertyContext = { + value: unknown; + key: string; +}; + +type RepoConfigLoadContext = { + repoConfigPath: string; +}; + +type ProviderFromModelContext = { + model: string; +}; + +type InlineConfigOverrideContext = { + model: string; +}; + +type InlineConfigOverride = { + providerId: string; + content: string; +}; + +type ModelOverrideResolutionContext = { + effort: AgentRunContext["payload"]["effort"]; + env: NodeJS.ProcessEnv; +}; + +type ModelOverrideResolution = { + model: string; + source: "OPENCODE_MODEL_MINI" | "OPENCODE_MODEL_MAX" | "OPENCODE_MODEL"; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function getRecordProperty(ctx: RecordPropertyContext): Record | undefined { + if (!isRecord(ctx.value)) { + return undefined; + } + const propertyValue = ctx.value[ctx.key]; + if (!isRecord(propertyValue)) { + return undefined; + } + return propertyValue; +} + +function loadRepoOpenCodeConfig(ctx: RepoConfigLoadContext): OpenCodeConfig | undefined { + if (!existsSync(ctx.repoConfigPath)) { + log.info(`» repo opencode.json not found at ${ctx.repoConfigPath}`); + return undefined; + } + + try { + const rawConfig = readFileSync(ctx.repoConfigPath, "utf-8"); + const parsedConfig = JSON.parse(rawConfig); + if (!isRecord(parsedConfig)) { + log.warning(`» repo opencode.json is not an object: ${ctx.repoConfigPath}`); + return undefined; + } + + const providerConfig = getRecordProperty({ value: parsedConfig, key: "provider" }); + if (providerConfig) { + const providerNames = Object.keys(providerConfig); + log.info(`» repo opencode provider config detected: ${providerNames.join(", ")}`); + } + + const result: OpenCodeConfig = parsedConfig; + log.info(`» loaded repo opencode.json from ${ctx.repoConfigPath}`); + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.warning(`» failed to parse repo opencode.json at ${ctx.repoConfigPath}: ${errorMessage}`); + return undefined; + } +} + +function parseProviderFromModel(ctx: ProviderFromModelContext): string | undefined { + const trimmedModel = ctx.model.trim(); + const slashIndex = trimmedModel.indexOf("/"); + if (slashIndex <= 0) { + return undefined; + } + const providerId = trimmedModel.slice(0, slashIndex).trim().toLowerCase(); + if (!providerId) { + return undefined; + } + return providerId; +} + +function buildInlineConfigOverride( + ctx: InlineConfigOverrideContext +): InlineConfigOverride | undefined { + const providerId = parseProviderFromModel({ model: ctx.model }); + if (!providerId) { + return undefined; + } + const inlineConfig: OpenCodeConfig = { + model: ctx.model, + enabled_providers: [providerId], + }; + return { + providerId, + content: JSON.stringify(inlineConfig), + }; +} + +function readNonEmptyEnvVar(ctx: { env: NodeJS.ProcessEnv; name: string }): string | undefined { + const value = ctx.env[ctx.name]; + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + return trimmed; +} + +function resolveModelOverride( + ctx: ModelOverrideResolutionContext +): ModelOverrideResolution | undefined { + if (ctx.effort === "mini") { + const miniModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MINI" }); + if (miniModel) { + return { model: miniModel, source: "OPENCODE_MODEL_MINI" }; + } + } + + if (ctx.effort === "max") { + const maxModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MAX" }); + if (maxModel) { + return { model: maxModel, source: "OPENCODE_MODEL_MAX" }; + } + } + + const baseModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL" }); + if (!baseModel) { + return undefined; + } + + return { model: baseModel, source: "OPENCODE_MODEL" }; +} + async function installOpencode(): Promise { return await installFromNpmTarball({ packageName: "opencode-ai", @@ -66,13 +219,18 @@ export const opencode = agent({ // this is critical for debugging since opencode run suppresses errors by default (Issue #752). const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"]; - // only override model when OPENCODE_MODEL is set (e.g., test environments with - // restricted API quotas). in production, OpenCode auto-selects the best available - // model based on which provider API keys are present. - const modelOverride = process.env.OPENCODE_MODEL; + // resolve model override from environment. + // precedence: + // 1) effort-specific overrides (OPENCODE_MODEL_MINI / OPENCODE_MODEL_MAX) + // 2) OPENCODE_MODEL fallback + // 3) OpenCode auto-select + const modelOverride = resolveModelOverride({ + effort: ctx.payload.effort, + env: process.env, + }); if (modelOverride) { - args.push("--model", modelOverride); - log.info(`» model: ${modelOverride} (override)`); + args.push("--model", modelOverride.model); + log.info(`» model: ${modelOverride.model} (override via ${modelOverride.source})`); } else { log.info(`» model: auto-selected by OpenCode`); } @@ -89,6 +247,31 @@ export const opencode = agent({ GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY, }; + + if (modelOverride) { + const inlineOverride = buildInlineConfigOverride({ model: modelOverride.model }); + if (inlineOverride) { + env.OPENCODE_CONFIG_CONTENT = inlineOverride.content; + log.info( + `» OpenCode inline config override enabled: provider=${inlineOverride.providerId}, model=${modelOverride.model}` + ); + } else { + log.warning( + `» skipping OpenCode inline config override: unable to parse provider from model "${modelOverride.model}"` + ); + } + } + + const hasOpenRouterKey = Boolean(env.OPENROUTER_API_KEY); + const hasAnthropicKey = Boolean(env.ANTHROPIC_API_KEY); + const hasOpenAiKey = Boolean(env.OPENAI_API_KEY); + const hasGoogleKey = Boolean( + env.GOOGLE_API_KEY || env.GEMINI_API_KEY || env.GOOGLE_GENERATIVE_AI_API_KEY + ); + log.info( + `» provider key presence: OPENROUTER=${hasOpenRouterKey ? "set" : "unset"}, ANTHROPIC=${hasAnthropicKey ? "set" : "unset"}, OPENAI=${hasOpenAiKey ? "set" : "unset"}, GOOGLE=${hasGoogleKey ? "set" : "unset"}` + ); + // OpenCode doesn't support GitHub App installation tokens delete env.GITHUB_TOKEN; @@ -194,7 +377,7 @@ export const opencode = agent({ lastProviderError = providerError; log.info(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`); } else { - // OpenCode's --print-logs output goes to stderr. demote internal + //agent OpenCode's --print-logs output goes to stderr. demote internal // INFO/DEBUG bus traffic to debug so it doesn't drown out tool // call logs in the GitHub Actions step output. log.debug(trimmed); @@ -301,28 +484,41 @@ function configureOpenCode(ctx: AgentRunContext): void { const configDir = join(ctx.tmpdir, ".config", "opencode"); mkdirSync(configDir, { recursive: true }); const configPath = join(configDir, "opencode.json"); + const repoConfigPath = join(process.cwd(), "opencode.json"); + const repoConfig = loadRepoOpenCodeConfig({ repoConfigPath }); + if (repoConfig?.model) { + log.info(`» repo opencode model configured: ${repoConfig.model}`); + } // build MCP servers config - const opencodeMcpServers = { - [ghPullfrogMcpName]: { type: "remote" as const, url: ctx.mcpServerUrl }, - }; + const opencodeMcpServers: Record = {}; + const repoMcpServers = getRecordProperty({ value: repoConfig, key: "mcp" }); + if (repoMcpServers) { + Object.assign(opencodeMcpServers, repoMcpServers); + } + opencodeMcpServers[ghPullfrogMcpName] = { type: "remote" as const, url: ctx.mcpServerUrl }; // build permission object based on tool permissions // note: OpenCode has no built-in web search tool const shell = ctx.payload.shell; - const permission = { - edit: "deny", - read: "deny", - bash: shell !== "enabled" ? "deny" : "allow", - webfetch: ctx.payload.web === "disabled" ? "deny" : "allow", - external_directory: "deny", - }; + const permission: Record = {}; + const repoPermission = getRecordProperty({ value: repoConfig, key: "permission" }); + if (repoPermission) { + Object.assign(permission, repoPermission); + } + permission.edit = "deny"; + permission.read = "deny"; + permission.bash = shell !== "enabled" ? "deny" : "allow"; + permission.webfetch = ctx.payload.web === "disabled" ? "deny" : "allow"; + permission.external_directory = "deny"; // build complete config in one object - const config = { - mcp: opencodeMcpServers, - permission, - }; + const config: OpenCodeConfig = {}; + if (repoConfig) { + Object.assign(config, repoConfig); + } + config.mcp = opencodeMcpServers; + config.permission = permission; const configJson = JSON.stringify(config, null, 2); try { diff --git a/entry b/entry index ae51417..0bd3792 100755 --- a/entry +++ b/entry @@ -144201,7 +144201,7 @@ function initToolState(params) { return { progressCommentId: resolvedId, subagents: /* @__PURE__ */ new Map(), - selfSubagentId: void 0, + activeSubagentId: void 0, backgroundProcesses: /* @__PURE__ */ new Map(), usageEntries: [] }; @@ -144238,43 +144238,26 @@ function isAddressInUse(error49) { const message = getErrorMessage(error49).toLowerCase(); return message.includes("eaddrinuse") || message.includes("address already in use"); } -function buildSubagentTools(ctx) { - const tools = [ - IssueInfoTool(ctx), - GetIssueCommentsTool(ctx), - GetIssueEventsTool(ctx), - PullRequestInfoTool(ctx), - CommitInfoTool(ctx), - GetReviewCommentsTool(ctx), - ListPullRequestReviewsTool(ctx), - GetCheckSuiteLogsTool(ctx), - UploadFileTool(ctx), - SetOutputTool(ctx), - FileReadTool(ctx), - FileWriteTool(ctx), - FileEditTool(ctx), - FileDeleteTool(ctx), - ListDirectoryTool(ctx) - ]; - if (ctx.payload.shell === "restricted") { - tools.push(ShellTool(ctx)); - tools.push(KillBackgroundTool(ctx)); - } - return tools; -} -function buildOrchestratorTools(ctx) { +function buildCommonTools(ctx) { const tools = [ StartDependencyInstallationTool(ctx), AwaitDependencyInstallationTool(ctx), + CreateCommentTool(ctx), + EditCommentTool(ctx), + ReplyToReviewCommentTool(ctx), + IssueTool(ctx), IssueInfoTool(ctx), GetIssueCommentsTool(ctx), GetIssueEventsTool(ctx), + CreatePullRequestReviewTool(ctx), PullRequestInfoTool(ctx), CommitInfoTool(ctx), CheckoutPrTool(ctx), GetReviewCommentsTool(ctx), ListPullRequestReviewsTool(ctx), + ResolveReviewThreadTool(ctx), GetCheckSuiteLogsTool(ctx), + AddLabelsTool(ctx), GitTool(ctx), GitFetchTool(ctx), UploadFileTool(ctx), @@ -144284,14 +144267,17 @@ function buildOrchestratorTools(ctx) { FileEditTool(ctx), FileDeleteTool(ctx), ListDirectoryTool(ctx), - CreateCommentTool(ctx), - EditCommentTool(ctx), - ReplyToReviewCommentTool(ctx), - CreatePullRequestReviewTool(ctx), - ResolveReviewThreadTool(ctx), - IssueTool(ctx), - AddLabelsTool(ctx), - ReportProgressTool(ctx), + ReportProgressTool(ctx) + ]; + if (ctx.payload.shell === "restricted") { + tools.push(ShellTool(ctx)); + tools.push(KillBackgroundTool(ctx)); + } + return tools; +} +function buildOrchestratorTools(ctx) { + return [ + ...buildCommonTools(ctx), SelectModeTool(ctx), DelegateTool(ctx), AskQuestionTool(ctx), @@ -144301,11 +144287,9 @@ function buildOrchestratorTools(ctx) { CreatePullRequestTool(ctx), UpdatePullRequestBodyTool(ctx) ]; - if (ctx.payload.shell === "restricted") { - tools.push(ShellTool(ctx)); - tools.push(KillBackgroundTool(ctx)); - } - return tools; +} +function buildSubagentTools(ctx) { + return buildCommonTools(ctx); } async function tryStartMcpServer(ctx, tools, port) { const server = new FastMCP({ name: ghPullfrogMcpName, version: "0.0.1" }); @@ -144395,13 +144379,12 @@ async function startMcpHttpServer(ctx) { } }; } -async function startSubagentMcpServer(params) { +async function startSubagentMcpServer(ctx) { const subagentToolState = { - ...params.ctx.toolState, - selfSubagentId: params.subagentId, + ...ctx.toolState, backgroundProcesses: /* @__PURE__ */ new Map() }; - const subagentCtx = { ...params.ctx, toolState: subagentToolState }; + const subagentCtx = { ...ctx, toolState: subagentToolState }; const tools = buildSubagentTools(subagentCtx); const startResult = await selectMcpPort(subagentCtx, tools); return { url: startResult.url, stop: () => startResult.server.stop() }; @@ -144639,7 +144622,7 @@ import { join as join11 } from "node:path"; // package.json var package_default = { name: "@pullfrog/pullfrog", - version: "0.0.171", + version: "0.0.169", type: "module", files: [ "index.js", @@ -146040,7 +146023,7 @@ function configureGeminiSettings(ctx) { } // agents/opencode.ts -import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync12 } from "node:fs"; +import { existsSync as existsSync7, mkdirSync as mkdirSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync12 } from "node:fs"; import { join as join15 } from "node:path"; import { performance as performance7 } from "node:perf_hooks"; var OPENCODE_CLI_VERSION = "1.1.56"; @@ -146061,6 +146044,101 @@ function detectProviderError(text) { } return null; } +function isRecord(value2) { + return typeof value2 === "object" && value2 !== null && !Array.isArray(value2); +} +function getRecordProperty(ctx) { + if (!isRecord(ctx.value)) { + return void 0; + } + const propertyValue = ctx.value[ctx.key]; + if (!isRecord(propertyValue)) { + return void 0; + } + return propertyValue; +} +function loadRepoOpenCodeConfig(ctx) { + if (!existsSync7(ctx.repoConfigPath)) { + log.info(`\xBB repo opencode.json not found at ${ctx.repoConfigPath}`); + return void 0; + } + try { + const rawConfig = readFileSync7(ctx.repoConfigPath, "utf-8"); + const parsedConfig = JSON.parse(rawConfig); + if (!isRecord(parsedConfig)) { + log.warning(`\xBB repo opencode.json is not an object: ${ctx.repoConfigPath}`); + return void 0; + } + const providerConfig = getRecordProperty({ value: parsedConfig, key: "provider" }); + if (providerConfig) { + const providerNames = Object.keys(providerConfig); + log.info(`\xBB repo opencode provider config detected: ${providerNames.join(", ")}`); + } + const result = parsedConfig; + log.info(`\xBB loaded repo opencode.json from ${ctx.repoConfigPath}`); + return result; + } catch (error49) { + const errorMessage = error49 instanceof Error ? error49.message : String(error49); + log.warning(`\xBB failed to parse repo opencode.json at ${ctx.repoConfigPath}: ${errorMessage}`); + return void 0; + } +} +function parseProviderFromModel(ctx) { + const trimmedModel = ctx.model.trim(); + const slashIndex = trimmedModel.indexOf("/"); + if (slashIndex <= 0) { + return void 0; + } + const providerId = trimmedModel.slice(0, slashIndex).trim().toLowerCase(); + if (!providerId) { + return void 0; + } + return providerId; +} +function buildInlineConfigOverride(ctx) { + const providerId = parseProviderFromModel({ model: ctx.model }); + if (!providerId) { + return void 0; + } + const inlineConfig = { + model: ctx.model, + enabled_providers: [providerId] + }; + return { + providerId, + content: JSON.stringify(inlineConfig) + }; +} +function readNonEmptyEnvVar(ctx) { + const value2 = ctx.env[ctx.name]; + if (!value2) { + return void 0; + } + const trimmed = value2.trim(); + if (!trimmed) { + return void 0; + } + return trimmed; +} +function resolveModelOverride(ctx) { + if (ctx.effort === "mini") { + const miniModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MINI" }); + if (miniModel) { + return { model: miniModel, source: "OPENCODE_MODEL_MINI" }; + } + } + if (ctx.effort === "max") { + const maxModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MAX" }); + if (maxModel) { + return { model: maxModel, source: "OPENCODE_MODEL_MAX" }; + } + } + const baseModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL" }); + if (!baseModel) { + return void 0; + } + return { model: baseModel, source: "OPENCODE_MODEL" }; +} async function installOpencode() { return await installFromNpmTarball({ packageName: "opencode-ai", @@ -146079,10 +146157,13 @@ var opencode = agent({ mkdirSync9(configDir, { recursive: true }); configureOpenCode(ctx); const args2 = ["run", ctx.instructions.full, "--format", "json", "--print-logs"]; - const modelOverride = process.env.OPENCODE_MODEL; + const modelOverride = resolveModelOverride({ + effort: ctx.payload.effort, + env: process.env + }); if (modelOverride) { - args2.push("--model", modelOverride); - log.info(`\xBB model: ${modelOverride} (override)`); + args2.push("--model", modelOverride.model); + log.info(`\xBB model: ${modelOverride.model} (override via ${modelOverride.source})`); } else { log.info(`\xBB model: auto-selected by OpenCode`); } @@ -146094,6 +146175,28 @@ var opencode = agent({ // set GOOGLE_GENERATIVE_AI_API_KEY alias for Google provider compatibility (if not already set) GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY }; + if (modelOverride) { + const inlineOverride = buildInlineConfigOverride({ model: modelOverride.model }); + if (inlineOverride) { + env2.OPENCODE_CONFIG_CONTENT = inlineOverride.content; + log.info( + `\xBB OpenCode inline config override enabled: provider=${inlineOverride.providerId}, model=${modelOverride.model}` + ); + } else { + log.warning( + `\xBB skipping OpenCode inline config override: unable to parse provider from model "${modelOverride.model}"` + ); + } + } + const hasOpenRouterKey = Boolean(env2.OPENROUTER_API_KEY); + const hasAnthropicKey = Boolean(env2.ANTHROPIC_API_KEY); + const hasOpenAiKey = Boolean(env2.OPENAI_API_KEY); + const hasGoogleKey = Boolean( + env2.GOOGLE_API_KEY || env2.GEMINI_API_KEY || env2.GOOGLE_GENERATIVE_AI_API_KEY + ); + log.info( + `\xBB provider key presence: OPENROUTER=${hasOpenRouterKey ? "set" : "unset"}, ANTHROPIC=${hasAnthropicKey ? "set" : "unset"}, OPENAI=${hasOpenAiKey ? "set" : "unset"}, GOOGLE=${hasGoogleKey ? "set" : "unset"}` + ); delete env2.GITHUB_TOKEN; const repoDir = process.cwd(); log.debug(`\xBB starting OpenCode: ${cliPath} ${args2.join(" ")}`); @@ -146246,21 +146349,34 @@ function configureOpenCode(ctx) { const configDir = join15(ctx.tmpdir, ".config", "opencode"); mkdirSync9(configDir, { recursive: true }); const configPath = join15(configDir, "opencode.json"); - const opencodeMcpServers = { - [ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl } - }; + const repoConfigPath = join15(process.cwd(), "opencode.json"); + const repoConfig = loadRepoOpenCodeConfig({ repoConfigPath }); + if (repoConfig?.model) { + log.info(`\xBB repo opencode model configured: ${repoConfig.model}`); + } + const opencodeMcpServers = {}; + const repoMcpServers = getRecordProperty({ value: repoConfig, key: "mcp" }); + if (repoMcpServers) { + Object.assign(opencodeMcpServers, repoMcpServers); + } + opencodeMcpServers[ghPullfrogMcpName] = { type: "remote", url: ctx.mcpServerUrl }; const shell = ctx.payload.shell; - const permission = { - edit: "deny", - read: "deny", - bash: shell !== "enabled" ? "deny" : "allow", - webfetch: ctx.payload.web === "disabled" ? "deny" : "allow", - external_directory: "deny" - }; - const config3 = { - mcp: opencodeMcpServers, - permission - }; + const permission = {}; + const repoPermission = getRecordProperty({ value: repoConfig, key: "permission" }); + if (repoPermission) { + Object.assign(permission, repoPermission); + } + permission.edit = "deny"; + permission.read = "deny"; + permission.bash = shell !== "enabled" ? "deny" : "allow"; + permission.webfetch = ctx.payload.web === "disabled" ? "deny" : "allow"; + permission.external_directory = "deny"; + const config3 = {}; + if (repoConfig) { + Object.assign(config3, repoConfig); + } + config3.mcp = opencodeMcpServers; + config3.permission = permission; const configJson = JSON.stringify(config3, null, 2); try { writeFileSync12(configPath, configJson, "utf-8"); @@ -147348,7 +147464,12 @@ async function setupGit(params) { $("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir }); params.toolState.pushUrl = originUrl; $("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir }); - log.info("\xBB git authentication configured"); + if (params.event.is_pr !== true || !params.event.issue_number) { + log.info("\xBB git authentication configured"); + return; + } + const prNumber = params.event.issue_number; + await checkoutPrBranch(prNumber, params); } // utils/time.ts diff --git a/mcp/server.ts b/mcp/server.ts index 4c7b029..101d7bb 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -25,7 +25,6 @@ export type SubagentStatus = "running" | "completed" | "failed"; export type SubagentState = { id: string; - label: string; status: SubagentStatus; mode: string; stdoutFilePath: string; @@ -47,9 +46,8 @@ export interface ToolState { selectedMode?: string; // per-subagent lifecycle tracking (keyed by subagent uuid) subagents: Map; - // only set on subagent shallow copies — routes set_output to the owning subagent. - // never set on the orchestrator's shared state. - selfSubagentId: string | undefined; + // set while a subagent is running — routes set_output to the correct subagent and prevents nesting + activeSubagentId: string | undefined; backgroundProcesses: Map; review?: { id: number; @@ -83,7 +81,7 @@ export function initToolState(params: InitToolStateParams): ToolState { return { progressCommentId: resolvedId, subagents: new Map(), - selfSubagentId: undefined, + activeSubagentId: undefined, backgroundProcesses: new Map(), usageEntries: [], }; @@ -107,6 +105,24 @@ export interface ToolContext { tmpdir: string; } +/** + * tool names that are only available to the orchestrator. + * subagent MCP servers are started with these tools excluded. + * + * - delegation tools: only the orchestrator can spawn/manage subagents + * - remote-mutating tools: subagents work locally; the orchestrator pushes and creates PRs + */ +export const ORCHESTRATOR_ONLY_TOOLS = [ + "select_mode", + "delegate", + "ask_question", + "push_branch", + "push_tags", + "delete_branch", + "create_pull_request", + "update_pull_request_body", +] as const; + import { log } from "../utils/cli.ts"; import type { RunContextData } from "../utils/runContextData.ts"; import { AskQuestionTool } from "./askQuestion.ts"; @@ -188,50 +204,27 @@ function isAddressInUse(error: unknown): boolean { return message.includes("eaddrinuse") || message.includes("address already in use"); } -// subagent tools: file ops, shell, read-only GitHub, upload, set_output. -// no git/checkout (mutates shared state), no dependencies (shared state), -// no GitHub-write (user-facing side effects), no delegation/remote-mutating. -function buildSubagentTools(ctx: ToolContext): Tool[] { - const tools: Tool[] = [ - IssueInfoTool(ctx), - GetIssueCommentsTool(ctx), - GetIssueEventsTool(ctx), - PullRequestInfoTool(ctx), - CommitInfoTool(ctx), - GetReviewCommentsTool(ctx), - ListPullRequestReviewsTool(ctx), - GetCheckSuiteLogsTool(ctx), - UploadFileTool(ctx), - SetOutputTool(ctx), - FileReadTool(ctx), - FileWriteTool(ctx), - FileEditTool(ctx), - FileDeleteTool(ctx), - ListDirectoryTool(ctx), - ]; - - if (ctx.payload.shell === "restricted") { - tools.push(ShellTool(ctx)); - tools.push(KillBackgroundTool(ctx)); - } - - return tools; -} - -// orchestrator gets everything: file ops, shell, git, GitHub, delegation, remote-mutating -function buildOrchestratorTools(ctx: ToolContext): Tool[] { +// tools shared by both orchestrator and subagent servers +function buildCommonTools(ctx: ToolContext): Tool[] { const tools: Tool[] = [ StartDependencyInstallationTool(ctx), AwaitDependencyInstallationTool(ctx), + CreateCommentTool(ctx), + EditCommentTool(ctx), + ReplyToReviewCommentTool(ctx), + IssueTool(ctx), IssueInfoTool(ctx), GetIssueCommentsTool(ctx), GetIssueEventsTool(ctx), + CreatePullRequestReviewTool(ctx), PullRequestInfoTool(ctx), CommitInfoTool(ctx), CheckoutPrTool(ctx), GetReviewCommentsTool(ctx), ListPullRequestReviewsTool(ctx), + ResolveReviewThreadTool(ctx), GetCheckSuiteLogsTool(ctx), + AddLabelsTool(ctx), GitTool(ctx), GitFetchTool(ctx), UploadFileTool(ctx), @@ -241,22 +234,7 @@ function buildOrchestratorTools(ctx: ToolContext): Tool[] { FileEditTool(ctx), FileDeleteTool(ctx), ListDirectoryTool(ctx), - CreateCommentTool(ctx), - EditCommentTool(ctx), - ReplyToReviewCommentTool(ctx), - CreatePullRequestReviewTool(ctx), - ResolveReviewThreadTool(ctx), - IssueTool(ctx), - AddLabelsTool(ctx), ReportProgressTool(ctx), - SelectModeTool(ctx), - DelegateTool(ctx), - AskQuestionTool(ctx), - PushBranchTool(ctx), - PushTagsTool(ctx), - DeleteBranchTool(ctx), - CreatePullRequestTool(ctx), - UpdatePullRequestBodyTool(ctx), ]; // only add ShellTool when shell is "restricted" @@ -271,6 +249,26 @@ function buildOrchestratorTools(ctx: ToolContext): Tool[] { return tools; } +// orchestrator gets common tools + delegation + remote-mutating tools +function buildOrchestratorTools(ctx: ToolContext): Tool[] { + return [ + ...buildCommonTools(ctx), + SelectModeTool(ctx), + DelegateTool(ctx), + AskQuestionTool(ctx), + PushBranchTool(ctx), + PushTagsTool(ctx), + DeleteBranchTool(ctx), + CreatePullRequestTool(ctx), + UpdatePullRequestBodyTool(ctx), + ]; +} + +// subagent gets only common tools (no delegation, no remote mutation) +function buildSubagentTools(ctx: ToolContext): Tool[] { + return buildCommonTools(ctx); +} + type McpStartResult = { server: FastMCP; url: string; @@ -393,30 +391,21 @@ export type ManagedMcpServer = { stop: () => Promise; }; -type StartSubagentMcpServerParams = { - ctx: ToolContext; - subagentId: string; -}; - /** * Start a per-subagent MCP server (common tools only — no push/PR/delegation). * Each subagent gets its own server; call stop() when the subagent completes. * * The subagent gets its own shallow copy of toolState so scalar writes * (pushUrl, pushDest, selectedMode, etc.) don't mutate the orchestrator's state. - * selfSubagentId is set on the copy so set_output routes to the correct subagent. * Shared references (subagents Map, usageEntries array, dependencyInstallation) * are intentionally shared for coordination (set_output routing, usage tracking). */ -export async function startSubagentMcpServer( - params: StartSubagentMcpServerParams -): Promise { +export async function startSubagentMcpServer(ctx: ToolContext): Promise { const subagentToolState: ToolState = { - ...params.ctx.toolState, - selfSubagentId: params.subagentId, + ...ctx.toolState, backgroundProcesses: new Map(), }; - const subagentCtx: ToolContext = { ...params.ctx, toolState: subagentToolState }; + const subagentCtx: ToolContext = { ...ctx, toolState: subagentToolState }; const tools = buildSubagentTools(subagentCtx); const startResult = await selectMcpPort(subagentCtx, tools); return { url: startResult.url, stop: () => startResult.server.stop() }; diff --git a/package.json b/package.json index fe67c0e..c234779 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pullfrog/pullfrog", - "version": "0.0.171", + "version": "0.0.169", "type": "module", "files": [ "index.js", diff --git a/post b/post index a0dfaf3..f6757ed 100755 --- a/post +++ b/post @@ -41235,7 +41235,7 @@ var Effort = type.enumerated("mini", "auto", "max"); // package.json var package_default = { name: "@pullfrog/pullfrog", - version: "0.0.171", + version: "0.0.169", type: "module", files: [ "index.js", diff --git a/test/ci.test.ts b/test/ci.test.ts index 48aaf18..23481aa 100644 --- a/test/ci.test.ts +++ b/test/ci.test.ts @@ -65,6 +65,8 @@ const expectedAgentEnvVars = [ "GITHUB_TOKEN", ...new Set(Object.values(agentsManifest).flatMap((a) => a.apiKeyNames)), "GEMINI_MODEL", + "OPENCODE_MODEL_MAX", + "OPENCODE_MODEL_MINI", "OPENCODE_MODEL", ].sort(); diff --git a/utils/docker.ts b/utils/docker.ts index 2681b31..0084b83 100644 --- a/utils/docker.ts +++ b/utils/docker.ts @@ -120,6 +120,8 @@ const testEnvAllowList = new Set([ "GOOGLE_GENERATIVE_AI_API_KEY", "CURSOR_API_KEY", "OPENCODE_MODEL", // override OpenCode model (e.g. google/gemini-3-flash-preview) for tests or user preference + "OPENCODE_MODEL_MINI", // effort-specific OpenCode model override for mini effort + "OPENCODE_MODEL_MAX", // effort-specific OpenCode model override for max effort "GEMINI_MODEL", // override Gemini model (e.g. gemini-3-pro-preview) for tests or user preference "LOG_LEVEL", "DEBUG", diff --git a/utils/setup.ts b/utils/setup.ts index 2e5ba5e..4cd4f2c 100644 --- a/utils/setup.ts +++ b/utils/setup.ts @@ -2,7 +2,8 @@ import { execSync } from "node:child_process"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { ShellPermission } from "../external.ts"; +import type { PayloadEvent, ShellPermission } from "../external.ts"; +import { checkoutPrBranch } from "../mcp/checkout.ts"; import type { ToolState } from "../mcp/server.ts"; import { log } from "./cli.ts"; import type { OctokitWithPlugins } from "./github.ts"; @@ -58,7 +59,9 @@ export interface GitContext { postCheckoutScript: string | null; } -export type SetupGitParams = GitContext; +export interface SetupGitParams extends GitContext { + event: PayloadEvent; +} /** * setup git configuration and authentication for the repository. @@ -167,5 +170,16 @@ export async function setupGit(params: SetupGitParams): Promise { // disable credential helpers to prevent prompts and ensure clean auth state $("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir }); - log.info("» git authentication configured"); + // non-PR events: stay on default branch + if (params.event.is_pr !== true || !params.event.issue_number) { + log.info("» git authentication configured"); + return; + } + + // PR event: checkout PR branch using shared helper + const prNumber = params.event.issue_number; + + // use shared checkout helper (handles fork remotes, push config, post-checkout hook) + // this updates toolState.pushUrl for fork PRs and sets toolState.issueNumber + await checkoutPrBranch(prNumber, params); }