add agent and debug macros, improve activity timeouts, migrate claude and codex to cli (#224)

This commit is contained in:
David Blass
2026-02-04 22:29:45 +00:00
committed by pullfrog[bot]
parent adc165d95f
commit 6fbff21fca
20 changed files with 5030 additions and 21553 deletions
+129 -38
View File
@@ -1,12 +1,16 @@
// 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 { type Options, query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { Effort } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import packageJson from "../package.json" with { type: "json" };
import { markActivity } from "../utils/activity.ts";
import { log } from "../utils/cli.ts";
import { installFromNpmTarball } from "../utils/install.ts";
import { spawn } from "../utils/subprocess.ts";
import { type AgentRunContext, agent } from "./shared.ts";
// Model selection based on effort level
@@ -38,6 +42,26 @@ function buildDisallowedTools(ctx: AgentRunContext): string[] {
return disallowed;
}
/**
* Write MCP config file for Claude CLI.
* Returns the path to the config file.
*/
function writeMcpConfig(ctx: AgentRunContext): string {
const configDir = join(ctx.tmpdir, ".claude");
mkdirSync(configDir, { recursive: true });
const configPath = join(configDir, "mcp.json");
const mcpConfig = {
mcpServers: {
[ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl },
},
};
writeFileSync(configPath, JSON.stringify(mcpConfig, null, 2), "utf-8");
log.info(`» MCP config written to ${configPath}`);
return configPath;
}
async function installClaude(): Promise<string> {
const versionRange = packageJson.dependencies["@anthropic-ai/claude-agent-sdk"] || "latest";
return await installFromNpmTarball({
@@ -64,32 +88,100 @@ export const claude = agent({
log.info(`» disallowed tools: ${disallowedTools.join(", ")}`);
}
const queryOptions: Options = {
permissionMode: "bypassPermissions" as const,
disallowedTools,
mcpServers: {
[ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl },
},
model,
pathToClaudeCodeExecutable: cliPath,
env: process.env,
};
// write MCP config file
const mcpConfigPath = writeMcpConfig(ctx);
const queryInstance = query({
prompt: ctx.instructions.full,
options: queryOptions,
// build CLI args
// claude -p "prompt" --dangerously-skip-permissions --mcp-config ./mcp.json --model opus --output-format stream-json --verbose
const args: string[] = [
cliPath,
"-p",
ctx.instructions.full,
"--dangerously-skip-permissions",
"--mcp-config",
mcpConfigPath,
"--model",
model,
"--output-format",
"stream-json",
"--verbose",
];
// add disallowed tools if any
if (disallowedTools.length > 0) {
args.push("--disallowedTools");
args.push(...disallowedTools);
}
log.info("» running Claude CLI...");
let stdoutBuffer = "";
let finalOutput = "";
// Track bash tool IDs to identify when bash tool results come back
const bashToolIds = new Set<string>();
const result = await spawn({
cmd: "node",
args,
cwd: process.cwd(),
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
onStdout: async (chunk) => {
finalOutput += chunk;
// buffer incomplete lines across chunks (NDJSON format)
stdoutBuffer += chunk;
const lines = stdoutBuffer.split("\n");
// keep the last element (may be incomplete) in the buffer
stdoutBuffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const message = JSON.parse(trimmed) as SDKMessage;
markActivity(); // reset activity timeout on every event
log.debug(JSON.stringify(message, null, 2));
const handler = messageHandlers[message.type];
if (handler) {
await handler(message as never, bashToolIds);
}
} catch {
// ignore parse errors - might be non-JSON output
log.debug(`[claude] non-JSON stdout line: ${trimmed.substring(0, 200)}`);
}
}
},
onStderr: (chunk) => {
const trimmed = chunk.trim();
if (trimmed) {
log.debug(`[claude stderr] ${trimmed}`);
log.warning(trimmed);
finalOutput += trimmed + "\n";
}
},
});
// Stream the results
for await (const message of queryInstance) {
log.debug(JSON.stringify(message, null, 2));
const handler = messageHandlers[message.type];
await handler(message as never);
if (result.exitCode !== 0) {
const errorMessage =
result.stderr || finalOutput || result.stdout || "Unknown error - no output from Claude CLI";
log.error(`Claude CLI exited with code ${result.exitCode}: ${errorMessage}`);
return {
success: false,
error: errorMessage,
output: finalOutput || result.stdout || "",
};
}
log.info("» Claude CLI completed successfully");
return {
success: true,
output: "",
output: finalOutput || result.stdout || "",
};
},
});
@@ -97,18 +189,16 @@ export const claude = agent({
type SDKMessageType = SDKMessage["type"];
type SDKMessageHandler<type extends SDKMessageType = SDKMessageType> = (
data: Extract<SDKMessage, { type: type }>
data: Extract<SDKMessage, { type: type }>,
bashToolIds: Set<string>
) => void | Promise<void>;
type SDKMessageHandlers = {
[type in SDKMessageType]: SDKMessageHandler<type>;
};
// Track bash tool IDs to identify when bash tool results come back
const bashToolIds = new Set<string>();
const messageHandlers: SDKMessageHandlers = {
assistant: (data) => {
assistant: (data, bashToolIds) => {
if (data.message?.content) {
for (const content of data.message.content) {
if (content.type === "text" && content.text?.trim()) {
@@ -127,24 +217,24 @@ const messageHandlers: SDKMessageHandlers = {
}
}
},
user: (data) => {
user: (data, bashToolIds) => {
if (data.message?.content) {
for (const content of data.message.content) {
if (content.type === "tool_result") {
const toolUseId = (content as any).tool_use_id;
const isBashTool = toolUseId && bashToolIds.has(toolUseId);
const outputContent =
typeof content.content === "string"
? content.content
: Array.isArray(content.content)
? content.content
.map((c: any) => (typeof c === "string" ? c : c.text || JSON.stringify(c)))
.join("\n")
: String(content.content);
if (isBashTool) {
// Log bash output in a collapsed group
const outputContent =
typeof content.content === "string"
? content.content
: Array.isArray(content.content)
? content.content
.map((c: any) => (typeof c === "string" ? c : c.text || JSON.stringify(c)))
.join("\n")
: String(content.content);
log.startGroup(`bash output`);
if (content.is_error) {
log.warning(outputContent);
@@ -155,9 +245,10 @@ const messageHandlers: SDKMessageHandlers = {
// Clean up the tracked ID
bashToolIds.delete(toolUseId);
} else if (content.is_error) {
const errorContent =
typeof content.content === "string" ? content.content : String(content.content);
log.warning(`Tool error: ${errorContent}`);
log.warning(`Tool error: ${outputContent}`);
} else {
// log successful non-bash tool result at debug level
log.debug(`tool output: ${outputContent}`);
}
}
}
+125 -89
View File
@@ -3,34 +3,24 @@
// changes to web search configuration should be reflected in wiki/websearch.md
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
Codex,
type CodexOptions,
type ModelReasoningEffort,
type ThreadEvent,
type ThreadOptions,
} from "@openai/codex-sdk";
import type { ThreadEvent } from "@openai/codex-sdk";
import type { Effort } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import { markActivity } from "../utils/activity.ts";
import { log } from "../utils/cli.ts";
import { installFromNpmTarball } from "../utils/install.ts";
import { spawn } from "../utils/subprocess.ts";
import { type AgentRunContext, agent } from "./shared.ts";
// model configuration based on effort level
const codexModel: Record<Effort, string> = {
mini: "gpt-5.1-codex-mini",
// https://developers.openai.com/codex/models/
// gpt-5.2-codex is not yet available via api key (even through codex cli)
auto: "gpt-5.1-codex",
max: "gpt-5.1-codex-max",
} as const;
// reasoning effort configuration based on effort level
// uses modelReasoningEffort parameter from ThreadOptions
const codexReasoningEffort: Record<Effort, ModelReasoningEffort | undefined> = {
mini: "low",
auto: undefined, // use default
max: "high",
// configuration based on effort level
// https://developers.openai.com/codex/models/
// gpt-5.2-codex is not yet available via api key (even through codex cli)
type ModelReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
type CodexEffortConfig = { model: string; reasoningEffort?: ModelReasoningEffort };
const codexEffortConfig: Record<Effort, CodexEffortConfig> = {
mini: { model: "gpt-5.1-codex-mini", reasoningEffort: "low" },
auto: { model: "gpt-5.1-codex" },
max: { model: "gpt-5.1-codex-max", reasoningEffort: "high" },
};
function writeCodexConfig(ctx: AgentRunContext): string {
@@ -81,96 +71,142 @@ export const codex = agent({
name: "codex",
install: installCodex,
run: async (ctx) => {
// install CLI at start of run
const cliPath = await installCodex();
// create config directory for codex before setting HOME
const configDir = join(ctx.tmpdir, ".config", "codex");
mkdirSync(configDir, { recursive: true });
const codexDir = writeCodexConfig(ctx);
process.env.HOME = ctx.tmpdir;
process.env.CODEX_HOME = codexDir;
// get model and reasoning effort based on effort level
const model = codexModel[ctx.payload.effort];
const modelReasoningEffort = codexReasoningEffort[ctx.payload.effort];
log.info(`» using model: ${model}`);
if (modelReasoningEffort) {
log.info(`» using modelReasoningEffort: ${modelReasoningEffort}`);
}
// Configure Codex
// validate API key first
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error("OPENAI_API_KEY is required for codex agent");
}
const codexOptions: CodexOptions = {
apiKey,
codexPathOverride: cliPath,
};
// install CLI at start of run
const cliPath = await installCodex();
const codex = new Codex(codexOptions);
// write config file (creates ~/.codex/config.toml)
const codexDir = writeCodexConfig(ctx);
// build thread options based on tool permissions
const threadOptions: ThreadOptions = {
model,
approvalPolicy: "never" as const,
// write: "disabled" → read-only sandbox, otherwise full access for git ops
sandboxMode: ctx.payload.write === "disabled" ? "read-only" : "danger-full-access",
// web: controls network access
networkAccessEnabled: ctx.payload.web !== "disabled",
// search: controls web search
webSearchEnabled: ctx.payload.search !== "disabled",
...(modelReasoningEffort && { modelReasoningEffort }),
};
// get model and reasoning effort based on effort level
const effortConfig = codexEffortConfig[ctx.payload.effort];
log.info(`» using model: ${effortConfig.model} (effort: ${ctx.payload.effort})`);
if (effortConfig.reasoningEffort) {
log.info(`» using modelReasoningEffort: ${effortConfig.reasoningEffort}`);
}
// determine sandbox mode based on write permission
// write: "disabled" → read-only sandbox, otherwise full access for git ops
const sandboxMode = ctx.payload.write === "disabled" ? "read-only" : "danger-full-access";
// determine network and search permissions
// web: "disabled" → no network access, otherwise enabled
const networkAccessEnabled = ctx.payload.web !== "disabled";
// search: "disabled" → no web search, otherwise enabled
const webSearchEnabled = ctx.payload.search !== "disabled";
const args: string[] = [
cliPath,
"exec",
ctx.instructions.full,
"--dangerously-bypass-approvals-and-sandbox",
"--model",
effortConfig.model,
"--sandbox",
sandboxMode,
"--json",
"--config",
`sandbox_workspace_write.network_access=${networkAccessEnabled}`,
"--config",
`features.web_search_request=${webSearchEnabled}`,
];
if (effortConfig.reasoningEffort) {
args.push("--config", `model_reasoning_effort="${effortConfig.reasoningEffort}"`);
}
log.info(
`» Codex options: sandboxMode=${threadOptions.sandboxMode}, networkAccessEnabled=${threadOptions.networkAccessEnabled}, webSearchEnabled=${threadOptions.webSearchEnabled}`
`» Codex options: sandboxMode=${sandboxMode}, networkAccess=${networkAccessEnabled}, webSearch=${webSearchEnabled}`
);
log.info("» running Codex CLI...");
const thread = codex.startThread(threadOptions);
let stdoutBuffer = "";
let finalOutput = "";
try {
const streamedTurn = await thread.runStreamed(ctx.instructions.full);
// Track command execution IDs to identify when command results come back
const commandExecutionIds = new Set<string>();
let finalOutput = "";
for await (const event of streamedTurn.events) {
const handler = messageHandlers[event.type];
log.debug(JSON.stringify(event, null, 2));
if (handler) {
handler(event as never);
const env: NodeJS.ProcessEnv = {
...process.env,
HOME: ctx.tmpdir,
CODEX_HOME: codexDir,
CODEX_API_KEY: apiKey,
};
const result = await spawn({
cmd: "node",
args,
cwd: process.cwd(),
env,
stdio: ["ignore", "pipe", "pipe"],
onStdout: async (chunk) => {
finalOutput += chunk;
// buffer incomplete lines across chunks (NDJSON format)
stdoutBuffer += chunk;
const lines = stdoutBuffer.split("\n");
// keep the last element (may be incomplete) in the buffer
stdoutBuffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const event = JSON.parse(trimmed) as ThreadEvent;
markActivity(); // reset activity timeout on every event
log.debug(JSON.stringify(event, null, 2));
const handler = messageHandlers[event.type as keyof typeof messageHandlers];
if (handler) {
await handler(event as never, commandExecutionIds);
}
} catch {
// ignore parse errors - might be non-JSON output
log.debug(`[codex] non-JSON stdout line: ${trimmed.substring(0, 200)}`);
}
}
if (event.type === "item.completed" && event.item.type === "agent_message") {
finalOutput = event.item.text;
},
onStderr: (chunk) => {
const trimmed = chunk.trim();
if (trimmed) {
log.debug(`[codex stderr] ${trimmed}`);
log.warning(trimmed);
finalOutput += trimmed + "\n";
}
}
},
});
return {
success: true,
output: finalOutput,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.error(`Codex execution failed: ${errorMessage}`);
if (result.exitCode !== 0) {
const errorMessage =
result.stderr || finalOutput || result.stdout || "Unknown error - no output from Codex CLI";
log.error(`Codex CLI exited with code ${result.exitCode}: ${errorMessage}`);
return {
success: false,
error: errorMessage,
output: "",
output: finalOutput || result.stdout || "",
};
}
log.info("» Codex CLI completed successfully");
return {
success: true,
output: finalOutput || result.stdout || "",
};
},
});
// Track command execution IDs to identify when command results come back
const commandExecutionIds = new Set<string>();
type ThreadEventHandler<type extends ThreadEvent["type"]> = (
event: Extract<ThreadEvent, { type: type }>
) => void;
event: Extract<ThreadEvent, { type: type }>,
commandExecutionIds: Set<string>
) => void | Promise<void>;
const messageHandlers: {
[type in ThreadEvent["type"]]: ThreadEventHandler<type>;
@@ -198,7 +234,7 @@ const messageHandlers: {
"turn.failed": (event) => {
log.error(`Turn failed: ${event.error.message}`);
},
"item.started": (event) => {
"item.started": (event, commandExecutionIds) => {
const item = event.item;
if (item.type === "command_execution") {
commandExecutionIds.add(item.id);
@@ -227,7 +263,7 @@ const messageHandlers: {
}
}
},
"item.completed": (event) => {
"item.completed": (event, commandExecutionIds) => {
const item = event.item;
if (item.type === "agent_message") {
log.box(item.text.trim(), { title: "Codex" });
+2
View File
@@ -7,6 +7,7 @@ import { homedir } from "node:os";
import { join } from "node:path";
import type { Effort } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import { markActivity } from "../utils/activity.ts";
import { log } from "../utils/cli.ts";
import { installFromCurl } from "../utils/install.ts";
import { type AgentRunContext, agent } from "./shared.ts";
@@ -271,6 +272,7 @@ export const cursor = agent({
try {
const event = JSON.parse(text) as CursorEvent;
markActivity(); // reset activity timeout on every event
log.debug(JSON.stringify(event, null, 2));
// skip empty thinking deltas
+2
View File
@@ -6,6 +6,7 @@ import { homedir } from "node:os";
import { join } from "node:path";
import type { Effort } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import { markActivity } from "../utils/activity.ts";
import { log } from "../utils/cli.ts";
import { installFromGithub } from "../utils/install.ts";
import { spawn } from "../utils/subprocess.ts";
@@ -226,6 +227,7 @@ export const gemini = agent({
try {
const event = JSON.parse(trimmed) as GeminiEvent;
markActivity(); // reset activity timeout on every event
const handler = messageHandlers[event.type as keyof typeof messageHandlers];
if (handler) {
await handler(event as never);
+3 -3
View File
@@ -4,6 +4,7 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { ghPullfrogMcpName } from "../external.ts";
import { getIdleMs, markActivity } from "../utils/activity.ts";
import { log } from "../utils/cli.ts";
import { installFromNpmTarball } from "../utils/install.ts";
import { spawn } from "../utils/subprocess.ts";
@@ -59,7 +60,6 @@ export const opencode = agent({
log.debug(`» XDG_CONFIG_HOME: ${env.XDG_CONFIG_HOME}`);
const startTime = Date.now();
let lastActivityTime = startTime;
let eventCount = 0;
let output = "";
@@ -95,7 +95,7 @@ export const opencode = agent({
// debug log all events to diagnose ordering and missing MCP/bash tool calls
log.debug(JSON.stringify(event, null, 2));
const timeSinceLastActivity = Date.now() - lastActivityTime;
const timeSinceLastActivity = getIdleMs();
if (timeSinceLastActivity > 10000) {
const activeToolCalls = toolCallTimings.size;
const toolCallInfo =
@@ -106,7 +106,7 @@ export const opencode = agent({
`» no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)`
);
}
lastActivityTime = Date.now();
markActivity(); // reset activity timeout on every event
const handler = messageHandlers[event.type as keyof typeof messageHandlers];
if (handler) {
await handler(event as never);