8e36f76cfa
* postrun: thread AgentRunContext through the retry loop instead of repackaging
drop the per-gate plumbing in `runPostRunRetryLoop`: the loop now receives
`ctx: AgentRunContext` whole and reads `ctx.stopScript` + `ctx.toolState.*`
directly. `getUnsubmittedReview` becomes a pure utility in postRun.ts
instead of a closure shipped over `AgentRunContext`. `AgentRunContext`
loses 4 fields that duplicated `toolState` (`summaryFilePath`,
`summarySeed`, `learningsFilePath`, `getUnsubmittedReview`) and gains
`toolState: ToolState`. both harness call sites collapse from 11 lines to
7; main.ts deletes the inline closure.
`ToolState` and friends move from `action/mcp/server.ts` to
`action/toolState.ts` so non-MCP code (agents, post-run loop) stops
importing run-state types from the MCP server module.
no behavior change. 503/503 tests green.
* toolState: relocate `CommentableLines` to break dep cycle with mcp/review
`action/toolState.ts` was importing `CommentableLines` from
`mcp/review.ts`, which pulled the entire MCP server compile graph (24
files) into any consumer of `ToolState` — including `cf-worker-indexing`
via the `pullfrog/internal` re-export chain through `utils/log.ts` →
`agents/shared.ts` → `toolState.ts`. that exposed a pre-existing TS
error in `mcp/issueEvents.ts` (octokit types resolve differently under
cf-worker's `moduleResolution: bundler`).
move `CommentableLines` (a small `{ RIGHT: Set<number>; LEFT: Set<number> }`
state-shape type) to `toolState.ts` where it's used; re-export from
`mcp/review.ts` for back-compat with test and call-site imports. cuts
cf-worker's mcp/ compile inclusion from 24 files back to 0.
* postRun: drop mock-heavy retry-loop tests; keep pure gate predicate
`runPostRunRetryLoop` and `executeStopHook` were covered by ~560 lines
of mock-heavy regression-gate tests that stubbed `spawn` / `getGitStatus`
and fabricated `AgentRunContext` to drive orchestration paths. per
AGENTS.md ("prefer no test over a mock-heavy test that only catches the
most obvious form of regression") and the empirical track record — the
one real production failure of this code path (#646) was a missing npm
release, not a logic bug a unit test could catch — the value-to-ceremony
ratio is poor. delete them.
keep only the pure predicate: `getUnsubmittedReview(toolState)` is a
decision function whose four input conditions have user-visible
consequences when wrong. 5 assertions, no mocks, no ctx fabrication.
488 tests still pass.
* toolState: import PrepResult from prep/types.ts, not the barrel
same dep-cycle class as the previous CommentableLines fix. importing
PrepResult from prep/index.ts pulled prep/installNodeDependencies.ts
into the Next.js production build's typecheck graph (via
pullfrog/internal → utils/log.ts → agents/shared.ts → toolState.ts →
prep/index.ts → installNodeDependencies.ts), and Next.js's stricter
NODE_ENV-required ProcessEnv shape rejected an existing
`env: { PATH: ... }` literal.
prep/types.ts is a leaf module with zero imports — re-routing the type
import severs the chain. Vercel preview deploy goes from Error → Ready;
preview-sync stops racing the deploy.
826 lines
30 KiB
TypeScript
826 lines
30 KiB
TypeScript
/**
|
||
* Claude Code agent — secure harness around the `claude` CLI.
|
||
*
|
||
* mirrors the opencode harness's security model:
|
||
* - native Bash blocked via --disallowedTools (agent cannot shell out)
|
||
* - managed-settings.json: filesystem sandbox — deny /proc, /sys reads
|
||
* - MCP ShellTool provides restricted shell (filtered env, no secrets)
|
||
* - MCP server injected via --mcp-config (not replacing project config)
|
||
* - ASKPASS handles git auth separately (token never in subprocess env)
|
||
*
|
||
* the agent process itself gets full env (needs LLM API keys, PATH, etc.).
|
||
* security is enforced at the tool layer, not the process layer.
|
||
*/
|
||
import { execFileSync } from "node:child_process";
|
||
import { mkdirSync, writeFileSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { performance } from "node:perf_hooks";
|
||
import { pullfrogMcpName } from "../external.ts";
|
||
|
||
import { getIdleMs, markActivity } from "../utils/activity.ts";
|
||
import { log } from "../utils/cli.ts";
|
||
import { installFromNpmTarball } from "../utils/install.ts";
|
||
import { detectProviderError } from "../utils/providerErrors.ts";
|
||
import { addSkill, installBundledSkills } from "../utils/skills.ts";
|
||
import { SPAWN_ACTIVITY_TIMEOUT_CODE, SpawnTimeoutError, spawn } from "../utils/subprocess.ts";
|
||
import { ThinkingTimer } from "../utils/timer.ts";
|
||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||
import { getDevDependencyVersion } from "../utils/version.ts";
|
||
import { buildLearningsReflectionPrompt, runPostRunRetryLoop } from "./postRun.ts";
|
||
import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "./reviewer.ts";
|
||
import { deriveLabelFromTaskInput } from "./sessionLabeler.ts";
|
||
import {
|
||
type AgentResult,
|
||
type AgentRunContext,
|
||
type AgentUsage,
|
||
agent,
|
||
logTokenTable,
|
||
MAX_STDERR_LINES,
|
||
} from "./shared.ts";
|
||
|
||
async function installClaudeCli(): Promise<string> {
|
||
return await installFromNpmTarball({
|
||
packageName: "@anthropic-ai/claude-code",
|
||
version: getDevDependencyVersion("@anthropic-ai/claude-code"),
|
||
executablePath: "cli.js",
|
||
installDependencies: false,
|
||
});
|
||
}
|
||
|
||
// ── config ─────────────────────────────────────────────────────────────────────
|
||
|
||
function writeMcpConfig(ctx: AgentRunContext): string {
|
||
const configDir = join(ctx.tmpdir, ".claude");
|
||
mkdirSync(configDir, { recursive: true });
|
||
const configPath = join(configDir, "mcp.json");
|
||
writeFileSync(
|
||
configPath,
|
||
JSON.stringify({
|
||
mcpServers: {
|
||
[pullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl },
|
||
},
|
||
})
|
||
);
|
||
return configPath;
|
||
}
|
||
|
||
/**
|
||
* Build the `--agents` JSON definition for the `reviewfrog` subagent.
|
||
* The non-mutative + non-recursive contract is enforced by the prose system
|
||
* prompt baked into the agent — see action/agents/reviewer.ts for why we no
|
||
* longer wire per-agent `disallowedTools` here.
|
||
*/
|
||
function buildAgentsJson(): string {
|
||
const agents = {
|
||
[REVIEWER_AGENT_NAME]: {
|
||
description:
|
||
"Read-only review subagent for self-review and lens-based code review. " +
|
||
"Reads only — no writes, no state-changing shell or MCP calls, no nested subagent dispatch.",
|
||
prompt: REVIEWER_SYSTEM_PROMPT,
|
||
},
|
||
};
|
||
return JSON.stringify(agents);
|
||
}
|
||
|
||
// ── model helpers ─────────────────────────────────────────────────────────────
|
||
|
||
// claude CLI expects bare model names (e.g. "claude-sonnet-4-6"), not provider-prefixed specifiers
|
||
function stripProviderPrefix(specifier: string): string {
|
||
const slashIndex = specifier.indexOf("/");
|
||
return slashIndex > 0 ? specifier.slice(slashIndex + 1) : specifier;
|
||
}
|
||
|
||
// `max` effort is supported on Opus 4.6 / 4.7; other models fall back to `high`.
|
||
// claude-code deny-lists older opus/sonnet generations from `max` at invocation time.
|
||
function resolveEffort(model: string | undefined): "max" | "high" {
|
||
if (model?.includes("opus")) return "max";
|
||
return "high";
|
||
}
|
||
|
||
// ── NDJSON event types ─────────────────────────────────────────────────────────
|
||
|
||
interface ContentBlock {
|
||
type: string;
|
||
text?: string;
|
||
id?: string;
|
||
name?: string;
|
||
input?: unknown;
|
||
tool_use_id?: string;
|
||
content?: string | unknown;
|
||
is_error?: boolean;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface ClaudeSystemEvent {
|
||
type: "system";
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface ClaudeAssistantEvent {
|
||
type: "assistant";
|
||
message?: {
|
||
role?: string;
|
||
content?: ContentBlock[];
|
||
model?: string;
|
||
usage?: {
|
||
input_tokens?: number;
|
||
output_tokens?: number;
|
||
cache_creation_input_tokens?: number;
|
||
cache_read_input_tokens?: number;
|
||
};
|
||
[key: string]: unknown;
|
||
};
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface ClaudeUserEvent {
|
||
type: "user";
|
||
message?: {
|
||
role?: string;
|
||
content?: ContentBlock[];
|
||
[key: string]: unknown;
|
||
};
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface ClaudeResultEvent {
|
||
type: "result";
|
||
subtype?: string;
|
||
// claude CLI sets `is_error: true` (alongside `subtype: "success"`) when
|
||
// an upstream provider fails mid-stream. `api_error_status` carries the
|
||
// provider HTTP status (e.g. 401 for invalid API key). per the official
|
||
// SDK types, `api_error_status` is `number | null`, and the `error_*`
|
||
// subtypes carry their actionable payload in `errors: string[]` instead
|
||
// of `result`.
|
||
is_error?: boolean;
|
||
api_error_status?: number | null;
|
||
errors?: string[];
|
||
result?: string;
|
||
session_id?: string;
|
||
num_turns?: number;
|
||
total_cost_usd?: number;
|
||
total_input_tokens?: number;
|
||
total_output_tokens?: number;
|
||
usage?: {
|
||
input_tokens?: number;
|
||
output_tokens?: number;
|
||
cache_read_input_tokens?: number;
|
||
cache_creation_input_tokens?: number;
|
||
};
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
// additional event types emitted by Claude CLI (handled as no-ops / debug)
|
||
interface ClaudeStreamEvent {
|
||
type: "stream_event";
|
||
[key: string]: unknown;
|
||
}
|
||
interface ClaudeToolProgressEvent {
|
||
type: "tool_progress";
|
||
[key: string]: unknown;
|
||
}
|
||
interface ClaudeToolUseSummaryEvent {
|
||
type: "tool_use_summary";
|
||
[key: string]: unknown;
|
||
}
|
||
interface ClaudeAuthStatusEvent {
|
||
type: "auth_status";
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
type ClaudeEvent =
|
||
| ClaudeSystemEvent
|
||
| ClaudeAssistantEvent
|
||
| ClaudeUserEvent
|
||
| ClaudeResultEvent
|
||
| ClaudeStreamEvent
|
||
| ClaudeToolProgressEvent
|
||
| ClaudeToolUseSummaryEvent
|
||
| ClaudeAuthStatusEvent;
|
||
|
||
// ── runner ──────────────────────────────────────────────────────────────────────
|
||
|
||
type RunParams = {
|
||
label: string;
|
||
args: string[];
|
||
cwd: string;
|
||
env: Record<string, string | undefined>;
|
||
todoTracker?: TodoTracker | undefined;
|
||
onActivityTimeout?: (() => void) | undefined;
|
||
onToolUse?: ((event: { toolName: string; input: unknown }) => void) | undefined;
|
||
};
|
||
|
||
type ClaudeRunResult = AgentResult & { sessionId?: string | undefined };
|
||
|
||
/**
|
||
* Return the tail of `text` capped at `maxCodeUnits` UTF-16 code units,
|
||
* dropping any partial first line. used in the exit-non-zero stdout fallback
|
||
* so we never surface a truncated NDJSON event to operators —
|
||
* `result.stdout.slice(-2048)` would otherwise cut mid-line and produce a
|
||
* syntactically broken JSON fragment. code units rather than bytes because
|
||
* `String.prototype.slice` operates on UTF-16 units; for multi-byte UTF-8
|
||
* content the effective byte budget can be up to 4× the nominal limit.
|
||
*/
|
||
function tailLines(text: string, maxCodeUnits: number): string {
|
||
if (text.length <= maxCodeUnits) return text;
|
||
const tail = text.slice(-maxCodeUnits);
|
||
const firstNewline = tail.indexOf("\n");
|
||
// if no newline in window or it's at the very start, return as-is;
|
||
// otherwise drop the partial first line.
|
||
return firstNewline > 0 && firstNewline < tail.length - 1 ? tail.slice(firstNewline + 1) : tail;
|
||
}
|
||
|
||
export async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||
const startTime = performance.now();
|
||
let eventCount = 0;
|
||
const thinkingTimer = new ThinkingTimer();
|
||
|
||
let finalOutput = "";
|
||
let sessionId: string | undefined;
|
||
let resultErrorSubtype: string | null = null;
|
||
// captures the structured error string from a result event with
|
||
// `is_error: true` (e.g. mid-stream provider auth failures the CLI
|
||
// surfaces as `subtype: "success"` synthetic-stop events, or the
|
||
// `errors[]` array from `error_*` subtypes). preferred over raw
|
||
// stdout/stderr in the exit-non-zero path so the GitHub Actions
|
||
// `##[error]` line shows the actionable message instead of an 8KB+
|
||
// NDJSON dump.
|
||
let lastResultError: string | null = null;
|
||
// set only for synthetic-stop `subtype: "success"` + `is_error: true`
|
||
// events, where `accumulatedTokens` from prior `assistant` events is
|
||
// stale and logging it would mislead operators into thinking billable
|
||
// tokens were spent on a successful turn. deliberately NOT set for
|
||
// `error_max_turns` / `error_during_execution` / `error_*` subtypes
|
||
// because those runs genuinely consumed tokens and operators need
|
||
// billing visibility for them.
|
||
let syntheticStopFailure = false;
|
||
let accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||
// Claude CLI reports a single end-of-run `total_cost_usd` on the result
|
||
// event. per-message events don't carry cost, so there's nothing to sum —
|
||
// we just capture the final value when it arrives.
|
||
let accumulatedCostUsd = 0;
|
||
let tokensLogged = false;
|
||
|
||
function buildUsage(): AgentUsage | undefined {
|
||
const totalInput =
|
||
accumulatedTokens.input + accumulatedTokens.cacheRead + accumulatedTokens.cacheWrite;
|
||
return totalInput > 0 || accumulatedTokens.output > 0
|
||
? {
|
||
agent: "claude",
|
||
inputTokens: totalInput,
|
||
outputTokens: accumulatedTokens.output,
|
||
cacheReadTokens: accumulatedTokens.cacheRead || undefined,
|
||
cacheWriteTokens: accumulatedTokens.cacheWrite || undefined,
|
||
costUsd: accumulatedCostUsd > 0 ? accumulatedCostUsd : undefined,
|
||
}
|
||
: undefined;
|
||
}
|
||
|
||
const handlers = {
|
||
system: (_event: ClaudeSystemEvent) => {
|
||
log.debug(`» ${params.label} system event`);
|
||
},
|
||
assistant: (event: ClaudeAssistantEvent) => {
|
||
const content = event.message?.content;
|
||
if (!content) return;
|
||
|
||
for (const block of content) {
|
||
if (block.type === "text" && block.text?.trim()) {
|
||
const message = block.text.trim();
|
||
log.box(message, { title: params.label });
|
||
finalOutput = message;
|
||
} else if (block.type === "tool_use") {
|
||
const toolName = block.name || "unknown";
|
||
if (params.onToolUse) {
|
||
params.onToolUse({
|
||
toolName,
|
||
input: block.input,
|
||
});
|
||
}
|
||
thinkingTimer.markToolCall();
|
||
log.toolCall({ toolName, input: block.input || {} });
|
||
|
||
// surface the subagent identity when the orchestrator dispatches a
|
||
// Task — claude rolls subagent activity up into a single tool_result
|
||
// (no per-event session_id in its stream), so this log line is the
|
||
// only attribution available before the subagent's report-back.
|
||
if (toolName === "Task" && block.input && typeof block.input === "object") {
|
||
const taskInput = block.input as {
|
||
description?: string;
|
||
subagent_type?: string;
|
||
prompt?: string;
|
||
};
|
||
const label = deriveLabelFromTaskInput(taskInput);
|
||
log.info(
|
||
`» dispatching subagent: ${label}` +
|
||
(taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
|
||
);
|
||
}
|
||
|
||
// agent's explicit MCP report_progress takes priority over todo tracking
|
||
if (toolName.includes("report_progress") && params.todoTracker) {
|
||
log.debug("» report_progress detected, disabling todo tracking");
|
||
params.todoTracker.cancel();
|
||
}
|
||
|
||
// parse TodoWrite events for live progress tracking
|
||
if (toolName === "TodoWrite" && params.todoTracker?.enabled) {
|
||
params.todoTracker.update(block.input);
|
||
}
|
||
}
|
||
}
|
||
|
||
// accumulate per-message usage if available. capture cache fields too
|
||
// so the fallback token table (used when no final `result` event fires)
|
||
// still reports the full breakdown instead of silently dropping cache.
|
||
const msgUsage = event.message?.usage;
|
||
if (msgUsage) {
|
||
accumulatedTokens.input += msgUsage.input_tokens || 0;
|
||
accumulatedTokens.output += msgUsage.output_tokens || 0;
|
||
accumulatedTokens.cacheRead += msgUsage.cache_read_input_tokens || 0;
|
||
accumulatedTokens.cacheWrite += msgUsage.cache_creation_input_tokens || 0;
|
||
}
|
||
},
|
||
user: (event: ClaudeUserEvent) => {
|
||
const content = event.message?.content;
|
||
if (!content) return;
|
||
|
||
for (const block of content) {
|
||
if (typeof block === "string") continue;
|
||
if (block.type === "tool_result") {
|
||
thinkingTimer.markToolResult();
|
||
|
||
const outputContent =
|
||
typeof block.content === "string"
|
||
? block.content
|
||
: Array.isArray(block.content)
|
||
? (block.content as unknown[])
|
||
.map((entry: unknown) =>
|
||
typeof entry === "string"
|
||
? entry
|
||
: typeof entry === "object" && entry !== null && "text" in entry
|
||
? String((entry as { text: unknown }).text)
|
||
: JSON.stringify(entry)
|
||
)
|
||
.join("\n")
|
||
: String(block.content);
|
||
|
||
if (block.is_error) {
|
||
log.info(`» tool error: ${outputContent}`);
|
||
} else {
|
||
log.debug(`» tool output: ${outputContent}`);
|
||
}
|
||
}
|
||
}
|
||
},
|
||
result: (event: ClaudeResultEvent) => {
|
||
if (event.session_id) sessionId = event.session_id;
|
||
const subtype = event.subtype || "unknown";
|
||
const numTurns = event.num_turns || 0;
|
||
|
||
// claude CLI emits synthetic-stop result events with `subtype: "success"`
|
||
// but `is_error: true` when an upstream provider fails mid-stream (e.g.
|
||
// 401 from anthropic). short-circuit before the usage/token-table path
|
||
// so we don't log a usage table for a failed attempt and so downstream
|
||
// (`resultErrorSubtype` branch) surfaces the structured error. gated on
|
||
// `subtype === "success"` because the `error_*` subtypes also set
|
||
// `is_error: true` but carry their payload in `errors: string[]` and
|
||
// are handled by the dedicated branches below.
|
||
if (event.is_error === true && subtype === "success") {
|
||
const apiStatus = event.api_error_status;
|
||
lastResultError =
|
||
event.result?.trim() ||
|
||
`claude reported is_error=true with no result text (api_error_status=${apiStatus ?? "unknown"})`;
|
||
resultErrorSubtype = subtype;
|
||
syntheticStopFailure = true;
|
||
log.info(
|
||
`» ${params.label} result error: subtype=${subtype}, api_error_status=${apiStatus ?? "unknown"}, message=${lastResultError}`
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (subtype === "success") {
|
||
// extract detailed usage from result event (most accurate source).
|
||
// note: `input` here is non-cached input tokens only, matching the
|
||
// semantics of OpenCode's step_finish.tokens.input — the logTokenTable
|
||
// helper sums Input + Cache Read + Cache Write + Output into the Total
|
||
// column so consumers get the real billable figure.
|
||
const usage = event.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;
|
||
// guard against NaN/Infinity from malformed CLI output poisoning the total
|
||
const costUsd =
|
||
typeof event.total_cost_usd === "number" && Number.isFinite(event.total_cost_usd)
|
||
? event.total_cost_usd
|
||
: 0;
|
||
|
||
accumulatedTokens = { input: inputTokens, output: outputTokens, cacheRead, cacheWrite };
|
||
accumulatedCostUsd = costUsd;
|
||
|
||
log.info(`» ${params.label} result: subtype=${subtype}, turns=${numTurns}`);
|
||
|
||
if (!tokensLogged) {
|
||
logTokenTable({
|
||
input: inputTokens,
|
||
cacheRead,
|
||
cacheWrite,
|
||
output: outputTokens,
|
||
costUsd,
|
||
});
|
||
tokensLogged = true;
|
||
}
|
||
} else if (subtype === "error_max_turns") {
|
||
resultErrorSubtype = subtype;
|
||
lastResultError = event.errors?.join("\n").trim() || null;
|
||
log.info(`» ${params.label} max turns reached: ${JSON.stringify(event)}`);
|
||
} else if (subtype === "error_during_execution") {
|
||
resultErrorSubtype = subtype;
|
||
lastResultError = event.errors?.join("\n").trim() || null;
|
||
log.info(`» ${params.label} execution error: ${JSON.stringify(event)}`);
|
||
} else if (subtype.startsWith("error")) {
|
||
resultErrorSubtype = subtype;
|
||
lastResultError = event.errors?.join("\n").trim() || null;
|
||
log.info(`» ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
|
||
} else {
|
||
log.info(`» ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
|
||
}
|
||
|
||
if (event.result?.trim()) {
|
||
finalOutput = event.result.trim();
|
||
}
|
||
},
|
||
// additional Claude CLI event types — debug-logged only
|
||
stream_event: () => {},
|
||
tool_progress: () => {},
|
||
tool_use_summary: () => {},
|
||
auth_status: () => {},
|
||
};
|
||
|
||
const recentStderr: string[] = [];
|
||
|
||
let lastProviderError: string | null = null;
|
||
|
||
let output = "";
|
||
let stdoutBuffer = "";
|
||
|
||
try {
|
||
const result = await spawn({
|
||
cmd: "node",
|
||
args: params.args,
|
||
cwd: params.cwd,
|
||
env: params.env,
|
||
activityTimeout: 300_000,
|
||
onActivityTimeout: params.onActivityTimeout,
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
// run claude in its own process group so SIGKILL on activity timeout /
|
||
// outer cancellation reaches any subprocesses it spawns (rg, file
|
||
// watchers, mcp transports, etc). claude itself is a node bundle so
|
||
// there's no shim-orphan issue like opencode-ai/bin/opencode, but
|
||
// detached + killGroup is the right default for any agent runtime.
|
||
killGroup: true,
|
||
onStdout: async (chunk) => {
|
||
const text = chunk.toString();
|
||
output += text;
|
||
markActivity();
|
||
|
||
stdoutBuffer += text;
|
||
const lines = stdoutBuffer.split("\n");
|
||
stdoutBuffer = lines.pop() || "";
|
||
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) continue;
|
||
|
||
let event: ClaudeEvent;
|
||
try {
|
||
event = JSON.parse(trimmed) as ClaudeEvent;
|
||
} catch {
|
||
log.debug(`» non-JSON stdout line: ${trimmed.substring(0, 200)}`);
|
||
continue;
|
||
}
|
||
|
||
eventCount++;
|
||
log.debug(JSON.stringify(event, null, 2));
|
||
|
||
const timeSinceLastActivity = getIdleMs();
|
||
if (timeSinceLastActivity > 10000) {
|
||
log.info(
|
||
`» no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s (${params.label} may be processing internally) (${eventCount} events processed so far)`
|
||
);
|
||
}
|
||
markActivity();
|
||
|
||
const handler = handlers[event.type as keyof typeof handlers];
|
||
if (!handler) {
|
||
log.debug(`» ${params.label} event (unhandled): type=${event.type}`);
|
||
continue;
|
||
}
|
||
try {
|
||
(handler as (e: ClaudeEvent) => void)(event);
|
||
} catch (err) {
|
||
log.info(
|
||
`» ${params.label} handler for type=${event.type} threw: ${err instanceof Error ? err.message : String(err)}`
|
||
);
|
||
}
|
||
}
|
||
},
|
||
onStderr: (chunk) => {
|
||
const trimmed = chunk.trim();
|
||
if (!trimmed) return;
|
||
|
||
recentStderr.push(trimmed);
|
||
if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift();
|
||
|
||
const providerError = detectProviderError(trimmed);
|
||
if (providerError) {
|
||
lastProviderError = providerError;
|
||
log.info(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
|
||
} else {
|
||
log.debug(trimmed);
|
||
}
|
||
},
|
||
});
|
||
|
||
if (result.exitCode === 0) {
|
||
await params.todoTracker?.flush();
|
||
} else {
|
||
params.todoTracker?.cancel();
|
||
}
|
||
|
||
const duration = performance.now() - startTime;
|
||
log.info(
|
||
`» ${params.label} completed in ${Math.round(duration)}ms with exit code ${result.exitCode}`
|
||
);
|
||
|
||
if (eventCount === 0) {
|
||
const stderrContext = recentStderr.join("\n");
|
||
const diagnosis = lastProviderError
|
||
? `provider error: ${lastProviderError}`
|
||
: "unknown cause (no stdout events received)";
|
||
log.info(`» ${params.label} produced 0 events (${diagnosis})`);
|
||
if (stderrContext) log.info(`» last stderr output:\n${stderrContext}`);
|
||
}
|
||
|
||
// skip the fallback token table only for the synthetic-stop
|
||
// `subtype: "success"` + `is_error: true` case: `accumulatedTokens` from
|
||
// prior `assistant` events is stale there and logging it would mislead
|
||
// operators into thinking billable tokens were spent on a successful turn.
|
||
// `error_max_turns` / `error_during_execution` / `error_*` subtypes
|
||
// represent runs that genuinely consumed tokens, so they still get the
|
||
// table for billing visibility.
|
||
if (
|
||
!tokensLogged &&
|
||
!syntheticStopFailure &&
|
||
(accumulatedTokens.input > 0 ||
|
||
accumulatedTokens.output > 0 ||
|
||
accumulatedTokens.cacheRead > 0 ||
|
||
accumulatedTokens.cacheWrite > 0)
|
||
) {
|
||
logTokenTable({ ...accumulatedTokens, costUsd: accumulatedCostUsd });
|
||
tokensLogged = true;
|
||
}
|
||
|
||
const usage = buildUsage();
|
||
|
||
if (result.exitCode !== 0) {
|
||
const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
|
||
// prefer the structured `lastResultError` (parsed from a result event
|
||
// with `is_error: true`) over raw stdout. raw stdout is the full NDJSON
|
||
// event stream — dumping it into a GitHub Actions `##[error]` line both
|
||
// hides the actionable provider message and pollutes the run log. cap
|
||
// the stdout fallback to the last 2KB so it stays readable when neither
|
||
// a structured error nor stderr is available.
|
||
const truncatedStdout = result.stdout ? tailLines(result.stdout, 2048) : "";
|
||
const errorMessage =
|
||
lastResultError ||
|
||
result.stderr ||
|
||
truncatedStdout ||
|
||
`unknown error - no output from Claude CLI${errorContext}`;
|
||
log.error(
|
||
`${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}`
|
||
);
|
||
log.debug(`stdout: ${result.stdout?.substring(0, 500)}`);
|
||
log.debug(`stderr: ${result.stderr?.substring(0, 500)}`);
|
||
return {
|
||
success: false,
|
||
output: finalOutput || output,
|
||
error: errorMessage,
|
||
usage,
|
||
sessionId,
|
||
};
|
||
}
|
||
|
||
if (eventCount === 0 && lastProviderError) {
|
||
return {
|
||
success: false,
|
||
output: finalOutput || output,
|
||
error: `provider error: ${lastProviderError}`,
|
||
usage,
|
||
sessionId,
|
||
};
|
||
}
|
||
|
||
if (resultErrorSubtype) {
|
||
return {
|
||
success: false,
|
||
output: finalOutput || output,
|
||
error: lastResultError || `result subtype: ${resultErrorSubtype}`,
|
||
usage,
|
||
sessionId,
|
||
};
|
||
}
|
||
|
||
return { success: true, output: finalOutput || output, usage, sessionId };
|
||
} catch (error) {
|
||
params.todoTracker?.cancel();
|
||
const duration = performance.now() - startTime;
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
const isActivityTimeout =
|
||
error instanceof SpawnTimeoutError && error.code === SPAWN_ACTIVITY_TIMEOUT_CODE;
|
||
|
||
const stderrContext = recentStderr.slice(-10).join("\n");
|
||
const diagnosis = lastProviderError
|
||
? `likely cause: ${lastProviderError}`
|
||
: eventCount === 0
|
||
? "Claude produced 0 stdout events - check if the API is reachable"
|
||
: `${eventCount} events were processed before the hang`;
|
||
|
||
log.info(
|
||
`» ${params.label} ${isActivityTimeout ? "hung" : "failed"} after ${(duration / 1000).toFixed(1)}s: ${errorMessage}`
|
||
);
|
||
log.info(`» diagnosis: ${diagnosis}`);
|
||
if (stderrContext)
|
||
log.info(
|
||
`» recent stderr (last ${Math.min(recentStderr.length, 10)} lines):\n${stderrContext}`
|
||
);
|
||
|
||
return {
|
||
success: false,
|
||
output: finalOutput || output,
|
||
error: `${errorMessage} [${diagnosis}]`,
|
||
usage: buildUsage(),
|
||
sessionId,
|
||
};
|
||
}
|
||
}
|
||
|
||
// ── managed settings ────────────────────────────────────────────────────────────
|
||
|
||
const MANAGED_SETTINGS_DIR = "/etc/claude-code";
|
||
const MANAGED_SETTINGS_PATH = `${MANAGED_SETTINGS_DIR}/managed-settings.json`;
|
||
|
||
// managed-settings.json has absolute highest precedence in Claude Code's config hierarchy.
|
||
// it cannot be overridden by user, project, or local settings — safe against malicious PRs.
|
||
//
|
||
// permissions.deny blocks native tools (Read, Grep, Edit, Glob) from accessing /proc and /sys.
|
||
// sandbox.filesystem.denyRead blocks the Bash tool sandbox from reading those paths.
|
||
// allowManagedPermissionRulesOnly prevents malicious PRs from adding allow rules that override
|
||
// our deny rules — safe in CI because --dangerously-skip-permissions makes allow/ask irrelevant.
|
||
// allowManagedHooksOnly prevents malicious project hooks from bypassing deny rules.
|
||
const managedSettings = {
|
||
allowManagedPermissionRulesOnly: true,
|
||
allowManagedHooksOnly: true,
|
||
permissions: {
|
||
deny: [
|
||
"Read(//proc/**)",
|
||
"Read(//sys/**)",
|
||
"Grep(//proc/**)",
|
||
"Grep(//sys/**)",
|
||
"Edit(//proc/**)",
|
||
"Edit(//sys/**)",
|
||
"Glob(//proc/**)",
|
||
"Glob(//sys/**)",
|
||
],
|
||
},
|
||
sandbox: {
|
||
filesystem: {
|
||
denyRead: ["/proc", "/sys"],
|
||
},
|
||
},
|
||
};
|
||
|
||
function installManagedSettings(): void {
|
||
if (process.env.CI !== "true") return;
|
||
|
||
const content = JSON.stringify(managedSettings, null, 2);
|
||
try {
|
||
execFileSync("sudo", ["mkdir", "-p", MANAGED_SETTINGS_DIR]);
|
||
execFileSync("sudo", ["tee", MANAGED_SETTINGS_PATH], {
|
||
input: content,
|
||
stdio: ["pipe", "ignore", "pipe"],
|
||
});
|
||
log.debug(`» wrote managed settings to ${MANAGED_SETTINGS_PATH}`);
|
||
} catch (err) {
|
||
log.warning(`» failed to install managed settings: ${err}`);
|
||
}
|
||
}
|
||
|
||
// ── agent ───────────────────────────────────────────────────────────────────────
|
||
|
||
export const claude = agent({
|
||
name: "claude",
|
||
install: installClaudeCli,
|
||
run: async (ctx) => {
|
||
const cliPath = await installClaudeCli();
|
||
|
||
const specifier = ctx.payload.proxyModel ?? ctx.resolvedModel;
|
||
const model = specifier ? stripProviderPrefix(specifier) : undefined;
|
||
|
||
const homeEnv = {
|
||
HOME: ctx.tmpdir,
|
||
XDG_CONFIG_HOME: join(ctx.tmpdir, ".config"),
|
||
};
|
||
|
||
mkdirSync(join(homeEnv.XDG_CONFIG_HOME, "claude"), { recursive: true });
|
||
|
||
const agentBrowserVersion = getDevDependencyVersion("agent-browser");
|
||
addSkill({
|
||
ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`,
|
||
skill: "agent-browser",
|
||
env: homeEnv,
|
||
agent: "claude",
|
||
});
|
||
|
||
installBundledSkills({ home: homeEnv.HOME });
|
||
|
||
const mcpConfigPath = writeMcpConfig(ctx);
|
||
const effort = resolveEffort(model);
|
||
|
||
installManagedSettings();
|
||
|
||
// base args shared between initial run and continue runs
|
||
const baseArgs = [
|
||
cliPath,
|
||
"--output-format",
|
||
"stream-json",
|
||
"--dangerously-skip-permissions",
|
||
"--mcp-config",
|
||
mcpConfigPath,
|
||
"--verbose",
|
||
"--effort",
|
||
effort,
|
||
"--disallowedTools",
|
||
"Bash,Agent(Bash)",
|
||
"--agents",
|
||
buildAgentsJson(),
|
||
];
|
||
|
||
if (model) {
|
||
baseArgs.push("--model", model);
|
||
}
|
||
|
||
// agent process gets full env — needs LLM API keys, PATH, locale, etc.
|
||
// security is enforced via managed-settings.json, --disallowedTools (Bash), and MCP tool filtering.
|
||
const env: Record<string, string | undefined> = {
|
||
...process.env,
|
||
...homeEnv,
|
||
};
|
||
|
||
const repoDir = process.cwd();
|
||
|
||
log.info(`» effort: ${effort}`);
|
||
log.debug(`» starting Pullfrog (Claude Code): node ${baseArgs.join(" ")}`);
|
||
log.debug(`» working directory: ${repoDir}`);
|
||
|
||
const runParams = {
|
||
label: "Pullfrog",
|
||
cwd: repoDir,
|
||
env,
|
||
todoTracker: ctx.todoTracker,
|
||
onActivityTimeout: ctx.onActivityTimeout,
|
||
onToolUse: ctx.onToolUse,
|
||
};
|
||
|
||
const result = await runClaude({
|
||
...runParams,
|
||
args: [...baseArgs, "-p", ctx.instructions.full],
|
||
});
|
||
|
||
// post-run retry loop aggregates usage across the initial run + every
|
||
// resume, so the caller sees the whole session — not just the final
|
||
// slice. claude needs a sessionId to `--resume`; if it's missing the
|
||
// loop bails (checks still ran, so persistent hook failures still fail
|
||
// the run). the reflection prompt fires once after gates go clean, as a
|
||
// dedicated turn that nudges the agent to persist learnings.
|
||
return runPostRunRetryLoop({
|
||
ctx,
|
||
initialResult: result,
|
||
initialUsage: result.usage,
|
||
reflectionPrompt: ctx.toolState.learningsFilePath
|
||
? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath)
|
||
: undefined,
|
||
canResume: (r) => Boolean(r.sessionId),
|
||
resume: async (c) => {
|
||
const sessionId = c.previousResult.sessionId;
|
||
if (!sessionId) throw new Error("unreachable: canResume gated on sessionId");
|
||
return runClaude({
|
||
...runParams,
|
||
args: [...baseArgs, "-p", c.prompt, "--resume", sessionId],
|
||
});
|
||
},
|
||
});
|
||
},
|
||
});
|