This commit is contained in:
Colin McDonnell
2026-01-12 13:55:43 -08:00
parent c6572f0987
commit 45f837cedb
12 changed files with 1731 additions and 1391 deletions
+17 -11
View File
@@ -5,13 +5,19 @@ import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts";
import { agent, createAgentEnv, installFromNpmTarball } from "./shared.ts";
// effort levels for Claude Code
// Claude Code automatically selects the best model based on effort
const claudeEffortLevels: Record<Effort, "low" | "medium" | "high"> = {
nothink: "low",
think: "medium",
max: "high",
} as const;
// Model selection based on effort level
// Note: nothink uses Haiku for speed, think uses Sonnet for balance, max uses Opus for capability
const claudeEffortModels: Record<Effort, string> = {
nothink: "claude-haiku-4-5-20250929",
think: "claude-sonnet-4-5-20250929",
max: "claude-opus-4-5-20250929",
};
// FUTURE: Consider using Anthropic's "effort" parameter (beta) with Opus 4.5 for all tasks.
// This would allow a single model with effort levels ("low", "medium", "high") controlling
// token spend across responses, tool calls, and thinking. Requires beta header "effort-2025-11-24".
// See: https://platform.claude.com/docs/en/build-with-claude/effort
// This approach could replace model selection if effort proves effective for controlling capability.
export const claude = agent({
name: "claude",
@@ -30,9 +36,9 @@ export const claude = agent({
const prompt = addInstructions({ payload, repo });
log.group("Full prompt", () => log.info(prompt));
// get effort level - Claude Code automatically selects the best model
const effortLevel = claudeEffortLevels[effort];
log.info(`Using effort: ${effortLevel}`);
// select model based on effort level
const model = claudeEffortModels[effort];
log.info(`Using model: ${model} (effort: ${effort})`);
// SECURITY: For PUBLIC repos, Claude Code spawns subprocesses with full process.env, leaking API keys.
// disable native Bash; agents use MCP bash tool which filters secrets.
@@ -62,7 +68,7 @@ export const claude = agent({
const queryOptions: Options = {
...sandboxOptions,
mcpServers,
effort: effortLevel,
model,
pathToClaudeCodeExecutable: cliPath,
env: createAgentEnv({ ANTHROPIC_API_KEY: apiKey }),
};
+27 -12
View File
@@ -1,19 +1,32 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk";
import {
Codex,
type CodexOptions,
type ModelReasoningEffort,
type ThreadEvent,
type ThreadOptions,
} from "@openai/codex-sdk";
import type { Effort } from "../external.ts";
import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts";
import { agent, installFromNpmTarball, setupProcessAgentEnv } from "./shared.ts";
// model configuration based on effort level
const codexModel: Record<Effort, string> = {
nothink: "gpt-5.1-codex-mini",
think: "gpt-5.1-codex",
max: "gpt-5.1-codex-max",
} as const;
// reasoning effort configuration based on effort level
// uses model_reasoning_effort parameter for o1 models
const codexReasoningEffort: Record<Effort, string | undefined> = {
// uses modelReasoningEffort parameter from ThreadOptions
const codexReasoningEffort: Record<Effort, ModelReasoningEffort | undefined> = {
nothink: "low",
think: undefined, // use default
max: "xhigh",
} as const;
max: "high",
};
interface WriteCodexConfigParams {
tempHome: string;
@@ -88,12 +101,12 @@ export const codex = agent({
CODEX_HOME: codexDir, // point Codex to our config directory
});
// get reasoning effort based on effort level
// get model and reasoning effort based on effort level
const model = codexModel[effort];
const modelReasoningEffort = codexReasoningEffort[effort];
log.info(`Using model: ${model}`);
if (modelReasoningEffort) {
log.info(`Using model_reasoning_effort: ${modelReasoningEffort}`);
} else {
log.info(`Using default reasoning effort`);
log.info(`Using modelReasoningEffort: ${modelReasoningEffort}`);
}
// Configure Codex
@@ -108,22 +121,24 @@ export const codex = agent({
const codex = new Codex(codexOptions);
// Build thread options with optional model_reasoning_effort
// Build thread options with model and optional model_reasoning_effort
const baseThreadOptions = payload.sandbox
? {
model,
approvalPolicy: "never" as const,
sandboxMode: "read-only" as const,
networkAccessEnabled: false,
}
: {
model,
approvalPolicy: "never" as const,
// use danger-full-access to allow git operations (workspace-write blocks .git directory writes)
sandboxMode: "danger-full-access" as const,
networkAccessEnabled: true,
};
const threadOptions = modelReasoningEffort
? { ...baseThreadOptions, model_reasoning_effort: modelReasoningEffort }
const threadOptions: ThreadOptions = modelReasoningEffort
? { ...baseThreadOptions, modelReasoningEffort }
: baseThreadOptions;
const thread = codex.startThread(threadOptions);
+46 -11
View File
@@ -1,7 +1,8 @@
import { spawn } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Effort } from "../external.ts";
import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts";
import {
@@ -11,6 +12,14 @@ import {
installFromCurl,
} from "./shared.ts";
// effort configuration for Cursor
// only "max" overrides the model; nothink/think use default ("auto")
const cursorEffortModels: Record<Effort, string | null> = {
nothink: null, // use default (auto)
think: null, // use default (auto)
max: "opus-4.5-thinking",
} as const;
// cursor cli event types inferred from stream-json output
interface CursorSystemEvent {
type: "system";
@@ -91,10 +100,36 @@ export const cursor = agent({
executableName: "cursor-agent",
});
},
run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort: _effort }) => {
run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort }) => {
configureCursorMcpServers({ mcpServers, cliPath });
configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic });
// determine model based on effort level
// respect project's .cursor/cli.json if it specifies a model
const projectCliConfigPath = join(process.cwd(), ".cursor", "cli.json");
let modelOverride: string | null = null;
if (existsSync(projectCliConfigPath)) {
try {
const projectConfig = JSON.parse(readFileSync(projectCliConfigPath, "utf-8"));
if (projectConfig.model) {
log.info(`Using model from project .cursor/cli.json: ${projectConfig.model}`);
} else {
modelOverride = cursorEffortModels[effort];
}
} catch {
modelOverride = cursorEffortModels[effort];
}
} else {
modelOverride = cursorEffortModels[effort];
}
if (modelOverride) {
log.info(`Using model: ${modelOverride} (effort: ${effort})`);
} else if (!existsSync(projectCliConfigPath)) {
log.info(`Using default model (effort: ${effort})`);
}
// track logged model_call_ids to avoid duplicates
// cursor emits each assistant message twice: once without model_call_id, then again with it
const loggedModelCallIds = new Set<string>();
@@ -171,16 +206,16 @@ export const cursor = agent({
// configure sandbox mode if enabled
// in sandbox mode: remove --force flag and rely on cli-config.json sandbox settings
const baseArgs = ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps"];
// add model flag if we have an override
if (modelOverride) {
baseArgs.push("--model", modelOverride);
}
const cursorArgs = payload.sandbox
? [
"--print",
fullPrompt,
"--output-format",
"stream-json",
"--approve-mcps",
// --force removed in sandbox mode to enforce safety checks
]
: ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps", "--force"];
? baseArgs // --force removed in sandbox mode to enforce safety checks
: [...baseArgs, "--force"];
if (payload.sandbox) {
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
+36 -19
View File
@@ -1,6 +1,7 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Effort } from "../external.ts";
import { log } from "../utils/cli.ts";
import { spawn } from "../utils/subprocess.ts";
import { addInstructions } from "./instructions.ts";
@@ -11,12 +12,13 @@ import {
installFromGithub,
} from "./shared.ts";
// model configuration based on effort level
// auto for default, flash for fast/low-cost, pro for max capability
const geminiModels = {
nothink: "gemini-2.5-flash",
think: "gemini-2.5-pro", // auto selection
max: "gemini-2.5-pro",
// effort configuration: model + thinking level
// thinkingLevel is set via settings.json modelConfig.generateContentConfig.thinkingConfig
// see: https://ai.google.dev/gemini-api/docs/thinking#thinking-levels
const geminiEffortConfig: Record<Effort, { model: string; thinkingLevel: string }> = {
nothink: { model: "gemini-2.5-flash", thinkingLevel: "LOW" },
think: { model: "gemini-2.5-flash", thinkingLevel: "HIGH" },
max: { model: "gemini-2.5-pro", thinkingLevel: "HIGH" },
} as const;
// gemini cli event types inferred from stream-json output (NDJSON format)
@@ -165,16 +167,16 @@ export const gemini = agent({
});
},
run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort }) => {
configureGeminiMcpServers({ mcpServers, isPublicRepo: repo.isPublic });
// get model and thinking level based on effort
const { model, thinkingLevel } = geminiEffortConfig[effort];
log.info(`Using model: ${model}, thinkingLevel: ${thinkingLevel}`);
configureGeminiSettings({ mcpServers, isPublicRepo: repo.isPublic, thinkingLevel });
if (!apiKey) {
throw new Error("google_api_key or gemini_api_key is required for gemini agent");
}
// get model based on effort level
const model = geminiModels[effort];
log.info(`Using model: ${model}`);
const sessionPrompt = addInstructions({ payload, repo });
log.group("Full prompt", () => log.info(sessionPrompt));
@@ -290,16 +292,22 @@ export const gemini = agent({
type ConfigureGeminiParams = {
mcpServers: ConfigureMcpServersParams["mcpServers"];
isPublicRepo: boolean;
thinkingLevel: string;
};
/**
* Configure MCP servers for Gemini by writing to settings.json.
* Gemini CLI uses `httpUrl` for HTTP/streamable transport, `url` for SSE transport.
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
* Configure Gemini CLI settings by writing to settings.json.
* - MCP servers: uses `httpUrl` for HTTP/streamable transport
* - thinkingLevel: configured via modelConfig.generateContentConfig.thinkingConfig
* - For public repos, excludeTools disables native shell
*
* For public repos, also configures excludeTools to disable native shell.
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
*/
function configureGeminiMcpServers({ mcpServers, isPublicRepo }: ConfigureGeminiParams): void {
function configureGeminiSettings({
mcpServers,
isPublicRepo,
thinkingLevel,
}: ConfigureGeminiParams): void {
const realHome = homedir();
const geminiConfigDir = join(realHome, ".gemini");
const settingsPath = join(geminiConfigDir, "settings.json");
@@ -343,17 +351,26 @@ function configureGeminiMcpServers({ mcpServers, isPublicRepo }: ConfigureGemini
log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`);
}
// merge with existing settings, overwriting mcpServers
// for public repos, exclude native shell tool to prevent secret leakage via env
// merge with existing settings, overwriting mcpServers and modelConfig
const newSettings: Record<string, unknown> = {
...existingSettings,
mcpServers: geminiMcpServers,
// configure thinking level via modelConfig
// see: https://ai.google.dev/api/generate-content (ThinkingConfig)
modelConfig: {
generateContentConfig: {
thinkingConfig: {
thinkingLevel,
},
},
},
};
// for public repos, exclude native shell tool to prevent secret leakage via env
if (isPublicRepo) {
newSettings.excludeTools = ["run_shell_command"];
}
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
log.info(`» MCP config written to ${settingsPath}`);
log.info(`» Gemini settings written to ${settingsPath}`);
}