Files
shockbot/agents/claude.ts
T
Colin McDonnell ddbc610569 review prompt: friendly green callouts + per-section severity emojis (#756)
* review prompt: friendly green callouts + per-section severity emojis

- Replace `[!NOTE]` informational tier and the no-callout minor-suggestions
  tier with friendly green blockquotes (`> ` / `> 💡`). The two loud
  tiers (`[!CAUTION]` / `[!IMPORTANT]`) keep their GitHub admonitions.
- Add a per-`##`-section severity-emoji rule (🚨/⚠️/💡/ℹ️) for
  cross-cutting review concerns that don't anchor to a line and would
  otherwise be buried in summary content.
- Drop the `<br/>` between summary sections — heading + blank line
  carries enough visual spacing.
- Skip the post-run learnings-reflection turn for `IncrementalReview`.
  It's the lowest-novelty mode (delta review against existing PR with
  prior summary already loaded) and almost never produces durable
  learnings — reflection there costs ~$0.50-0.80/run for nothing.
- Surface real error info on `agent-browser` skill install failures
  (exit code + stdout + stderr + spawn error). The skills CLI uses a
  TUI that prints errors to stdout, so the prior stderr-only logging
  silently swallowed every failure.

* review prompt: per-bullet severity emoji + bullets-only sections

Section headings are plain again (no leading severity emoji). Severity
moves to individual bullets so a section that mixes a 🚨 and a 💡 isn't
mislabeled by either. Section bodies are now bullets only — paragraph
prose under a heading is harder to scan and tends to bury the
actionable point.

Bullets can carry indented continuation content (sub-bullets, code
fences, blockquotes) by indenting two spaces under the parent.

* review prompt: cap section length + identifier discipline

Bound each summary section to at most 4 bullets at most 2 lines each,
and explicitly call out identifier-heavy prose as an anti-pattern. The
reader is often a manager or non-author; identifier-dense paragraphs
('foo calls bar.fetch which dispatches to baz via qux...') are
unreadable for them. Default to plain-language behavior descriptions,
name an identifier only when it's the subject of an actionable concern
or a public surface a reader would recognize, target 2-3 backtick
tokens per bullet.

Move the deep-explanation pattern from open blockquote to a default-
collapsed details/summary so depth doesn't dominate the visible body.

* review prompt: hard cap on bullet identifier density + worked rewrite example

Soft 'aim for 2-3 tokens' guidance was ignored — first big-PR e2e
showed 12 of 19 actionable bullets exceeded the target (avg 4.8 tokens,
several over 8). Promote to a hard cap of 3 backticked tokens per
bullet and pair with a concrete bad/good rewrite the agent can pattern-
match against. Also tighten the per-bullet length cap from ~240 to
~200 chars and explicitly call it 'hard cap, not target'.

* review prompt: tighten bullet length cap to 160 chars, dramatize the worked example

V2 e2e test: token discipline improved (4.8 -> 3.3 avg, 12/19 -> 6/14
violations) but length got worse (235 -> 286 chars, 13/14 over the 200
cap). The agent compensated for fewer identifiers with more prose.

Two changes: (1) tighten the cap from ~200 chars to 160 chars / 1
visual line and call out wrap-to-multiple-lines as the failure mode;
(2) rewrite the worked example so the good version is genuinely half
the length of the bad one, not just lower token count. The example was
the thing the agent pattern-matches against; making the good version
~130 chars vs the bad version's ~290 chars sets the right shape.

* review prompt: drop fixed bullet-count cap, keep length + identifier caps

Per user feedback — section length should be governed by content, not
an arbitrary count. Soft guidance ('past ~6, ask whether to split') is
fine; the hard '≤ 4 bullets per section' rule was the wrong shape.
Length cap (160c) and identifier cap (3 backtick tokens) stay; those
target the actual scanability problem.

* review prompt: drop ## subsystem sections, flat 'Issues found' list

Per-section structure forced every concern into a subsystem frame and
made the body read like a series of mini-essays. Replace with two
parts: (1) TL;DR + Key changes as the dispassionate overview, (2) flat
'### Issues found' list ordered by severity, intermixed across files
and subsystems. Per-bullet rules (≤160c, ≤3 backtick tokens, severity
emoji prefix, optional indented continuation) carry over unchanged.

* review prompt: full v6 structure — preamble + cross-cutting H3s + nitpicks

Replaces the flat 'Issues found' bullet list with the iterated v6 shape:

- Preamble is a bolded inline 'Reviewed changes' lead-in plus bullets
  plus a collapsed 'Review metadata' block (mode/files/commits/refs/
  reviewed commits list/prior pullfrog review/staleness note).
- Each cross-cutting concern gets a '### emoji Title' section. The
  visible problem write-up is human-friendly and DESCRIBES THE PROBLEM
  ONLY — no asks, no suggested fixes, no 'the right thing to do is'.
- Each section carries a collapsed 'Technical details' block wrapped
  in a 4-backtick markdown fence (so it can hold its own 3-tick code
  fences cleanly, agent-readable, one-click copyable). Standard four
  inner sections: Affected sites, Required outcome, optional Suggested
  approach, optional Open questions for the human.
- '### ℹ️ Nitpicks' at the bottom for body-only nits that don't
  inline; simple bullets, no technical-details collapse.
- Anti-paragraph-wall rule: never two successive plain paragraphs in
  visible '### ' sections; alternate prose with structure.
- Inline-vs-body discipline: anything that anchors to a single line
  goes inline, body is for cross-cutting only.
- Drops legacy '### Key changes', '### Issues found', '<b>TL;DR</b>',
  and the '<sub>Summary</sub>' line.

* model effort: bump Gemini + GPT to high effort; drop Gemini Pro→Flash subagent

E2E review eval against a substantive billing-module diff surfaced two
related quality gaps:

1. Gemini Pro at thinkingLevel=medium (#663's CI-timeout fix) reviewed
   the diff only, took the 0-lens path, and missed a catastrophic
   camelCase/snake_case service-vs-schema mismatch. Bumping back to
   high — review work is exactly the wrong shape for the medium/high
   tradeoff #663 was optimizing for; the per-turn TTFT cost is worth
   paying when reasoning IS the value.

2. GPT had no reasoningEffort override, defaulting to upstream medium.
   Same diff, similar shallow result vs Claude. Adding reasoningEffort:
   high for the curated direct-OpenAI slugs, mirroring the Gemini
   pattern (Anthropic separately uses --effort high via the Claude
   Code CLI flag in claude.ts).

3. Gemini Pro's subagentModel was 'gemini-flash' — but Google has no
   in-between tier between Pro and Flash, and Flash is a meaningful
   capability cliff for review work. Dropping the override so subagents
   inherit Pro. Cost stays reasonable since Gemini Pro is already the
   cheapest of the flagship trio.

Other providers unchanged: Anthropic opus→sonnet and OpenAI gpt→gpt-5.4
remain (each is a one-tier drop to a still-capable sibling).

* model effort: revert orchestrator override, set explicit high on reviewfrog subagent

Reshape the effort design after eval:

- Drop the explicit Gemini and GPT model-level overrides — orchestrators
  now run at upstream defaults (Gemini high, GPT-5.x medium). Gemini's
  upstream IS high, so this is a no-op there; GPT goes back to upstream
  medium for orchestrator-level routing work.
- Add explicit 'high' on the reviewfrog subagent via agent.options.
  OpenCode merge order is base ← model.options ← agent.options ← variant
  per session/llm.ts:141, so the subagent always runs at high regardless
  of which orchestrator dispatched it. Both thinkingConfig.thinkingLevel
  (Gemini) and reasoningEffort (GPT) keys included; irrelevant keys are
  ignored per provider.
- Bump providers-live timeouts (12min job / 10min step, from 8/6) to
  budget for Gemini's TTFT variance at high effort. #663's 4min timeout
  was sized for the medium-effort override that's now removed.

* model effort: restore Gemini explicit high override (no-override path breaks)

Bare 'rely on upstream default' for Gemini failed in e2e — removing the
model-level provider config produced 'Function call is missing a
thought_signature' API errors on every gemini-pro run. Even though
upstream opencode's options() returns the same thinkingLevel: high we
were explicitly setting, opencode's resolution path differs subtly
between the two cases. v2's explicit override worked; v3's removal
broke. Reproducible across two consecutive runs.

Restoring the explicit Gemini override (back to v2 design). GPT
orchestrator stays UN-overridden — at upstream default (medium) — since
removing that override didn't trigger the same failure pattern and the
reviewfrog subagent agent.options high override compensates for the
extra depth GPT loses at medium.

* diag: remove reviewfrog agent.options to isolate Gemini thought_signature failure

v3 (no Gemini orch override) failed with thought_signature error. v4
(restored Gemini orch override at v2-equivalent) ALSO failed, even
though the orchestrator config matches v2. The variable between v2
(working) and v4 (failing) is the new reviewfrog agent.options block.
Removing it to confirm — if Gemini works again, the agent.options
addition is the culprit and we need a different shape for it.

* opencode-ai: bump 1.1.56 → 1.15.0 + clean up gemini effort config

opencode-ai@1.1.56 was published 2026-02-10 (3 months old). The Google
API tightened thought_signature validation 24-48h ago (per
https://discuss.ai.google.dev/t/gemini-thought-signature-patch/122555),
and the bug class hits opencode's session→prompt serializer for MCP
tool-call parts (anomalyco/opencode#4832, #8321). Latest stable bumps
us through ~3 months of fixes; needed for Gemini-direct to stop dying
with 'thought_signature is missing' on every multi-turn run.

Companion cleanup: the gemini provider override in opencode.ts had
30-line block of comments, four unused constants, and a 6-line
Object.fromEntries map for two entries. Replaced with one source-of-
truth helper that loops modelAliases, filters provider==='google',
strips the 'google/' prefix, and returns the override map. Adding any
future Google alias to the registry now flows through automatically.

Test added: action/agents/opencode.test.ts asserts the helper covers
every direct-Google alias, strips the prefix correctly, and pins every
entry to thinkingLevel high — catches drift in helper logic without
hardcoding the API ids the test would have to update in lockstep
with the registry.

* fix(workflow): tolerate listJobsForWorkflowRun 404 in resolveRun

PR #750 (docker testing rewrite) replaced the per-call env allowlist
with full process.env passthrough into the test container. That now
leaks GITHUB_RUN_ID + GITHUB_JOB into runs whose MCP token is scoped
to a DIFFERENT repo (e.g. providers-live smoke runs the action against
pullfrog/test-repo with pullfrog/app's run ID). The unconditional
listJobsForWorkflowRun call 404s and crashes the entire run, breaking
every providers-live job on main since #750 landed.

jobId is purely cosmetic (deep-links 'View workflow run' footer to a
specific job vs the run-level URL). Wrapping the API call in try/catch
so a 404 logs a debug message and falls through to undefined jobId is
the right fix — the failure mode is exactly what graceful degradation
is for, and the alternative (filter the env vars at the docker boundary)
re-introduces the kind of allowlist #750 was getting rid of.

* opencode-ai: pin 1.14.51 instead of 1.15.0 (effect refactor breaks JSON output)

opencode 1.15.0 (May 15) ships a major architectural refactor onto
@effect — the run command boots an in-process server via
@opencode-ai/sdk/v2 and the JSON event emission path through that SDK
client doesn't surface on stdout the way our parser expects (CI run
on 1.15.0 produced 0 stdout events but the agent still completed).
Local invocation also hangs at the in-process server boot.

The Gemini thought_signature fixes (the original reason for bumping)
landed earlier in the 1.14.x line, so 1.14.51 (May 14) gets us the
upstream fix without the Effect rewrite. Defer the 1.15.x bump until
we're ready to rewire our parser/spawn around the new SDK.

* opencode-ai: revert to 1.1.56; gha: filter outer-CI workflow-run vars at the docker boundary

Two related changes for the docker testing harness's ergonomics:

1. Revert opencode-ai 1.14.51 → 1.1.56. The 1.14+ line ships an Effect
   refactor (the SDK-v2 client + in-process server architecture) that
   our --format json parser doesn't speak — even the 1.14.51 release,
   pre-dating the 1.15.0 Effect rename, produced 0 stdout events on
   our skill-invoke smoke. There's no clean pre-Effect version that
   ships the Gemini thought_signature fix; that fix needs a separate
   workstream once we're ready to rewire the parser onto SDK v2.

2. Filter outer-CI workflow-run identifiers (GITHUB_RUN_ID, GITHUB_JOB,
   GITHUB_WORKFLOW, GITHUB_ACTION, GITHUB_REF, GITHUB_SHA, etc.) from
   gha.ts's --env-file passthrough. PR #750's full-process.env design
   leaks pullfrog/app's CI run identifiers into runs that act against
   a different repo (e.g. pullfrog/test-repo); any code path inside
   the action that uses them as keys (most notably resolveRun's
   listJobsForWorkflowRun lookup) 404s. Filtering them here means
   the action sees undefined and skips the lookup, complementing the
   defensive try/catch in resolveRun (commit addc76d4). GITHUB_REPOSITORY
   and GITHUB_TOKEN are NOT filtered — those are genuinely needed.

Companion to addc76d4 (resolveRun 404 tolerance). The two together
make this class of bug 'either fix would have caught it' rather than
'silently breaks the entire test matrix'.

* fix(deps): sync pnpm-lock.yaml with opencode-ai 1.1.56 manifest revert

Forgot to refresh the lockfile after reverting the manifest in 02c6d8c1.
CI's frozen-lockfile install was failing with 'lockfile: 1.14.51,
manifest: 1.1.56' mismatch.
2026-05-16 04:58:31 +00:00

989 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { BEDROCK_MODEL_ID_ENV, isBedrockAnthropicId } from "../models.ts";
import { getIdleMs, markActivity } from "../utils/activity.ts";
import { formatJsonValue, log } from "../utils/cli.ts";
import { installFromNpmTarball } from "../utils/install.ts";
import { findProviderErrorMatch } from "../utils/providerErrors.ts";
import { addSkill, installBundledSkills } from "../utils/skills.ts";
import {
DEFAULT_MAX_RETAINED_BYTES,
SPAWN_ACTIVITY_TIMEOUT_CODE,
SpawnTimeoutError,
spawn,
TailBuffer,
} 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,
shouldRunReflection,
} from "./postRun.ts";
import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "./reviewer.ts";
import { formatWithLabel, ORCHESTRATOR_LABEL, SessionLabeler } 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 Claude Code path always runs against an Anthropic model (see
* resolveAgent), so we hardcode the cheaper-sibling downshift: lenses run
* on Sonnet, the orchestrator stays on whatever model `--model` was passed.
*
* Per-call model override is also possible (Task tool's `model` arg accepts
* 'sonnet' | 'opus' | 'haiku') and takes precedence over what's set here —
* we don't pass it; the per-subagent `model` field is the right default.
*
* 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 lens-based code review (correctness, security, billing-subsystem, etc.). " +
"Reads only — no writes, no state-changing shell or MCP calls, no nested subagent dispatch.",
prompt: REVIEWER_SYSTEM_PROMPT,
model: "claude-sonnet-4-6",
},
};
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;
}
// `high` is the model's tuned default ("equivalent to not setting the parameter"
// per Anthropic docs). `max` is "absolute maximum capability with no constraints
// on token spending" — meaningfully slower and burns more thinking budget per
// turn. We default everyone to `high`; PRs that genuinely need full-send can
// opt in via a future per-run override rather than paying the wall-time cost on
// every Opus run.
function resolveEffort(_model: string | undefined): "high" {
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;
}
// SDK schema (per claude-agent-sdk docs) puts `session_id` and
// `parent_tool_use_id` at the top level of every Assistant/User/System/Result
// message, not inside `message`. Subagent events carry a non-null
// `parent_tool_use_id` pointing at the orchestrator's Task/Agent tool_use id.
interface ClaudeSystemEvent {
type: "system";
session_id?: string;
parent_tool_use_id?: string | null;
[key: string]: unknown;
}
interface ClaudeAssistantEvent {
type: "assistant";
session_id?: string;
parent_tool_use_id?: string | null;
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";
session_id?: string;
parent_tool_use_id?: string | null;
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;
// per-session labeler so parallel subagent log lines can be differentiated.
// claude-agent-sdk runs subagents inside the orchestrator's session — they
// share `session_id` — and stamps every subagent message with a non-null
// `parent_tool_use_id` pointing at the Agent tool_use that spawned them.
// we bind each Agent tool_use id to its dispatched label up front, then
// labelFor short-circuits to the direct mapping when parent_tool_use_id is
// set. orchestrator events (parent_tool_use_id === null) flow through the
// sessionID path and bind to ORCHESTRATOR_LABEL on first sighting.
const labeler = new SessionLabeler();
function eventLabel(event: { session_id?: string; parent_tool_use_id?: string | null }): string {
return labeler.labelFor(event.session_id ?? null, event.parent_tool_use_id ?? null);
}
function withLabel(label: string, message: string): string {
return label === ORCHESTRATOR_LABEL ? message : formatWithLabel(label, message);
}
// one ThinkingTimer per session — sharing a single timer across sessions
// conflated cross-session interleaving as parent thinking time. each timer
// formats its log lines through the session label so attribution is visible.
const thinkingTimers = new Map<string, ThinkingTimer>();
function timerFor(label: string): ThinkingTimer {
let t = thinkingTimers.get(label);
if (!t) {
const formatLine = (line: string) =>
label === ORCHESTRATOR_LABEL ? line : formatWithLabel(label, line);
t = new ThinkingTimer(formatLine);
thinkingTimers.set(label, t);
}
return t;
}
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) => {
// claude-agent-sdk only emits system:init for the top-level query, so
// this binds the orchestrator label and never appears in subagent flow.
// we still route through eventLabel so a subagent system event (if the
// SDK ever adds one) wouldn't go silently misattributed.
const label = eventLabel(event);
log.debug(withLabel(label, `» ${params.label} system event`));
},
assistant: (event: ClaudeAssistantEvent) => {
const content = event.message?.content;
if (!content) return;
const label = eventLabel(event);
const boxTitle = label === ORCHESTRATOR_LABEL ? params.label : `${params.label} [${label}]`;
for (const block of content) {
if (block.type === "text" && block.text?.trim()) {
const message = block.text.trim();
log.box(message, { title: boxTitle });
// only the orchestrator's text becomes the run's "output" — subagent
// report-back text would otherwise clobber the parent's final answer.
if (label === ORCHESTRATOR_LABEL) {
finalOutput = message;
}
} else if (block.type === "tool_use") {
const toolName = block.name || "unknown";
if (params.onToolUse) {
params.onToolUse({
toolName,
input: block.input,
});
}
timerFor(label).markToolCall();
const inputFormatted = formatJsonValue(block.input || {});
const toolCallLine =
inputFormatted !== "{}" ? `» ${toolName}(${inputFormatted})` : `» ${toolName}()`;
log.info(withLabel(label, toolCallLine));
// when the orchestrator dispatches a subagent, bind the Agent
// tool_use id to the dispatched label so future events carrying
// `parent_tool_use_id === block.id` resolve directly to the right
// lens. v2.1.63+ renamed the tool to "Agent"; older versions
// emitted "Task". match both for forward-compat.
if (
(toolName === "Task" || toolName === "Agent") &&
block.input &&
typeof block.input === "object"
) {
const taskInput = block.input as {
description?: string;
subagent_type?: string;
prompt?: string;
};
const dispatchedLabel = labeler.recordTaskDispatch(taskInput, block.id ?? null);
log.info(
withLabel(
label,
`» dispatching subagent: ${dispatchedLabel}` +
(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. only honor the
// orchestrator's todos — subagents emit their own todo lists which
// would otherwise clobber the visible progress comment.
if (
toolName === "TodoWrite" &&
params.todoTracker?.enabled &&
label === ORCHESTRATOR_LABEL
) {
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;
const label = eventLabel(event);
for (const block of content) {
if (typeof block === "string") continue;
if (block.type === "tool_result") {
timerFor(label).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(withLabel(label, `» tool error: ${outputContent}`));
} else {
log.debug(withLabel(label, `» 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[] = [];
// ring buffer of recent non-JSON stdout lines. Claude CLI prints
// human-readable TTY chrome (status bubbles, quota notices, etc.)
// alongside the NDJSON event stream. when the CLI exits non-zero without
// emitting a structured error event, these lines are the only actionable
// signal — preferring them over the NDJSON tail keeps progress comments
// readable. issue #643.
const recentNonJsonStdout: string[] = [];
let lastProviderError: string | null = null;
// capped accumulator — see opencode.ts for rationale (issue #680).
const output = new TailBuffer(DEFAULT_MAX_RETAINED_BYTES);
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,
// claude already drains every chunk via onStdout (NDJSON parsing) and
// onStderr (recentStderr ring buffer). retaining a second copy in the
// spawn wrapper would grow unbounded for long sessions and previously
// crashed the wrapper with RangeError. see issue #680.
retain: "none",
onStdout: async (chunk) => {
const text = chunk.toString();
output.append(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)}`);
recentNonJsonStdout.push(trimmed);
if (recentNonJsonStdout.length > MAX_STDERR_LINES) recentNonJsonStdout.shift();
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 match = findProviderErrorMatch(trimmed);
if (match) {
lastProviderError = match.label;
log.info(`» provider error detected (${match.label}): ${match.excerpt}`);
} 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.
//
// result.stdout / result.stderr are empty because we pass retain:"none"
// to spawn (see issue #680); the agent layer keeps its own bounded
// mirrors via `output` (TailBuffer) and `recentStderr` (ring buffer).
const stdoutSnapshot = output.toString();
const stderrSnapshot = recentStderr.join("\n");
const truncatedStdout = stdoutSnapshot ? tailLines(stdoutSnapshot, 2048) : "";
// prefer non-JSON stdout (human-readable TTY chrome the CLI prints,
// including status bubbles and quota notices) over the raw NDJSON
// tail. when the CLI exits 1 without emitting `is_error` (issue #643),
// the NDJSON fallback would otherwise dump 2KB of `system/init` events
// into the progress comment with no mention of the actual cause.
const nonJsonStdoutSnapshot = recentNonJsonStdout.join("\n");
const errorMessage =
lastResultError ||
stderrSnapshot ||
nonJsonStdoutSnapshot ||
truncatedStdout ||
`unknown error - no output from Claude CLI${errorContext}`;
log.error(
`${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}`
);
log.debug(`stdout: ${stdoutSnapshot.substring(0, 500)}`);
log.debug(`stderr: ${stderrSnapshot.substring(0, 500)}`);
return {
success: false,
output: finalOutput || stdoutSnapshot,
error: errorMessage,
usage,
sessionId,
};
}
if (eventCount === 0 && lastProviderError) {
return {
success: false,
output: finalOutput || output.toString(),
error: `provider error: ${lastProviderError}`,
usage,
sessionId,
};
}
if (resultErrorSubtype) {
return {
success: false,
output: finalOutput || output.toString(),
error: lastResultError || `result subtype: ${resultErrorSubtype}`,
usage,
sessionId,
};
}
return { success: true, output: finalOutput || output.toString(), 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.toString(),
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;
// claude-code on Bedrock takes the bare AWS model ID — no provider prefix
// to strip, since the ID is already in `provider.model` form (e.g.
// `us.anthropic.claude-opus-4-7`). detect via the env-var sentinel: if
// BEDROCK_MODEL_ID is set and matches the resolved specifier, this is a
// bedrock route. see `wiki/model-resolution.md` for the routing pattern.
const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
const isBedrockRoute =
specifier !== undefined &&
bedrockModelId !== undefined &&
bedrockModelId === specifier &&
isBedrockAnthropicId(specifier);
const model = !specifier
? undefined
: isBedrockRoute
? specifier
: stripProviderPrefix(specifier);
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.
//
// bedrock route: claude-code reads `CLAUDE_CODE_USE_BEDROCK=1` to switch
// its provider implementation from the direct Anthropic API to Bedrock.
// AWS_BEARER_TOKEN_BEDROCK / AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY +
// AWS_REGION are already in process.env from the workflow's `env:` block.
// see https://docs.claude.com/en/docs/claude-code/amazon-bedrock.
//
// we only force CLAUDE_CODE_USE_BEDROCK=1 when this is a Pullfrog-routed
// bedrock run; if the user has set the env var manually for some other
// reason (e.g. always-Bedrock org policy), `...process.env` already
// carries it through and we don't disturb it.
const env: Record<string, string | undefined> = {
...process.env,
...homeEnv,
};
if (isBedrockRoute) {
env.CLAUDE_CODE_USE_BEDROCK = "1";
}
// claude-code's `Vw()` resolver prefers ANTHROPIC_API_KEY over the OAuth
// token when both are set, so we strip the API key to fall through to the
// Max-subscription path. bedrock route uses AWS creds and is excluded.
if (env.CLAUDE_CODE_OAUTH_TOKEN && !isBedrockRoute && env.ANTHROPIC_API_KEY) {
log.debug(
"» CLAUDE_CODE_OAUTH_TOKEN present — stripping ANTHROPIC_API_KEY from Claude Code env so the OAuth subscription is used"
);
delete env.ANTHROPIC_API_KEY;
}
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 && shouldRunReflection(ctx.toolState.selectedMode)
? 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],
});
},
});
},
});