71feba0a76
* fix: prevent log.writeSummary from overwriting reportProgress content The run summary was showing logs instead of the final reportProgress content because log.writeSummary() was called after reportProgress. Now log.writeSummary() checks if the summary was already overwritten by reportProgress and skips if so. Fixes #86 * refactor: replace dynamic import with static import in cli.ts Replace unnecessary dynamic import of wasSummaryOverwritten with static import. No circular dependency exists since comment.ts doesn't import from cli.ts. * Fix run summary writing --------- Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
202 lines
7.0 KiB
TypeScript
202 lines
7.0 KiB
TypeScript
import { type Options, query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
import type { Effort } from "../external.ts";
|
|
import packageJson from "../package.json" with { type: "json" };
|
|
import { log } from "../utils/cli.ts";
|
|
import { addInstructions } from "./instructions.ts";
|
|
import { agent, createAgentEnv, installFromNpmTarball } from "./shared.ts";
|
|
|
|
// Model selection based on effort level
|
|
// Note: mini uses Haiku for speed, auto uses opusplan for balance, max uses Opus for capability
|
|
const claudeEffortModels: Record<Effort, string> = {
|
|
mini: "haiku",
|
|
auto: "opusplan",
|
|
max: "opus",
|
|
};
|
|
|
|
// 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",
|
|
install: async () => {
|
|
const versionRange = packageJson.dependencies["@anthropic-ai/claude-agent-sdk"] || "latest";
|
|
return await installFromNpmTarball({
|
|
packageName: "@anthropic-ai/claude-agent-sdk",
|
|
version: versionRange,
|
|
executablePath: "cli.js",
|
|
});
|
|
},
|
|
run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => {
|
|
// Ensure API key is NOT in process.env - only pass via SDK's env option
|
|
delete process.env.ANTHROPIC_API_KEY;
|
|
|
|
const prompt = addInstructions({ payload, repo });
|
|
log.group("Full prompt", () => log.info(prompt));
|
|
|
|
// 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.
|
|
// for private repos, native Bash is allowed since secrets are less exposed.
|
|
const disallowedTools = repo.isPublic ? ["Bash"] : [];
|
|
const sandboxOptions: Options = payload.sandbox
|
|
? {
|
|
permissionMode: "default",
|
|
disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"],
|
|
async canUseTool(toolName, input, _options) {
|
|
if (toolName.startsWith("mcp__gh_pullfrog__"))
|
|
return { behavior: "allow", updatedInput: input, updatedPermissions: [] };
|
|
return { behavior: "deny", message: "tool not allowed in sandbox mode" };
|
|
},
|
|
}
|
|
: {
|
|
permissionMode: "bypassPermissions" as const,
|
|
disallowedTools,
|
|
};
|
|
|
|
if (payload.sandbox) {
|
|
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
|
}
|
|
|
|
// Pass secrets via SDK's env option only (not process.env)
|
|
// This ensures secrets are only available to Claude Code subprocess, not user code
|
|
const queryOptions: Options = {
|
|
...sandboxOptions,
|
|
mcpServers,
|
|
model,
|
|
pathToClaudeCodeExecutable: cliPath,
|
|
env: createAgentEnv({ ANTHROPIC_API_KEY: apiKey }),
|
|
};
|
|
|
|
const queryInstance = query({
|
|
prompt,
|
|
options: queryOptions,
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
output: "",
|
|
};
|
|
},
|
|
});
|
|
|
|
type SDKMessageType = SDKMessage["type"];
|
|
|
|
type SDKMessageHandler<type extends SDKMessageType = SDKMessageType> = (
|
|
data: Extract<SDKMessage, { type: type }>
|
|
) => 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) => {
|
|
if (data.message?.content) {
|
|
for (const content of data.message.content) {
|
|
if (content.type === "text" && content.text?.trim()) {
|
|
log.box(content.text.trim(), { title: "Claude" });
|
|
} else if (content.type === "tool_use") {
|
|
// Track bash tool IDs
|
|
if (content.name === "bash" && content.id) {
|
|
bashToolIds.add(content.id);
|
|
}
|
|
|
|
log.toolCall({
|
|
toolName: content.name,
|
|
input: content.input,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
},
|
|
user: (data) => {
|
|
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);
|
|
|
|
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);
|
|
} else {
|
|
log.info(outputContent);
|
|
}
|
|
log.endGroup();
|
|
// 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}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
result: async (data) => {
|
|
if (data.subtype === "success") {
|
|
const usage = data.usage;
|
|
const inputTokens = usage?.input_tokens || 0;
|
|
const cacheRead = usage?.cache_read_input_tokens || 0;
|
|
const cacheWrite = usage?.cache_creation_input_tokens || 0;
|
|
const outputTokens = usage?.output_tokens || 0;
|
|
const totalInput = inputTokens + cacheRead + cacheWrite;
|
|
|
|
log.table([
|
|
[
|
|
{ data: "Cost", header: true },
|
|
{ data: "Input", header: true },
|
|
{ data: "Cache Read", header: true },
|
|
{ data: "Cache Write", header: true },
|
|
{ data: "Output", header: true },
|
|
],
|
|
[
|
|
`$${data.total_cost_usd?.toFixed(4) || "0.0000"}`,
|
|
String(totalInput),
|
|
String(cacheRead),
|
|
String(cacheWrite),
|
|
String(outputTokens),
|
|
],
|
|
]);
|
|
} else if (data.subtype === "error_max_turns") {
|
|
log.error(`Max turns reached: ${JSON.stringify(data)}`);
|
|
} else if (data.subtype === "error_during_execution") {
|
|
log.error(`Execution error: ${JSON.stringify(data)}`);
|
|
} else {
|
|
log.error(`Failed: ${JSON.stringify(data)}`);
|
|
}
|
|
},
|
|
system: () => {},
|
|
stream_event: () => {},
|
|
tool_progress: () => {},
|
|
auth_status: () => {},
|
|
};
|