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);
}