Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,13 @@ Every outbound OAuth request is rewritten to match what Claude Code sends on the

### Tool wire-matching (v1.9.0)

Claude Code sends 24 core tools (plus user-specific MCP tools). OpenCode has 10. These differ in names, descriptions, schemas, parameter names, required fields, and sort order.
Claude Code sends 26 captured core tools (plus user-specific MCP tools). OpenCode has 10. These differ in names, descriptions, schemas, parameter names, required fields, and sort order.

The bridge resolves this by:

1. **Replacing OpenCode's tool definitions only for Claude-compatible targets** with Claude Code's exact wire-captured definitions — matching descriptions, JSON schemas, parameter names, and required fields.
2. **Adding 14 Claude-only stub tools**: `AskUserQuestion`, `CronCreate`, `CronDelete`, `CronList`, `EnterPlanMode`, `EnterWorktree`, `ExitPlanMode`, `ExitWorktree`, `Monitor`, `NotebookEdit`, `RemoteTrigger`, `TaskOutput`, `TaskStop`, `WebSearch`.
3. **Sorting all 24 tools alphabetically** to match Claude Code's ordering.
2. **Adding 16 Claude-only stub tools**: `AskUserQuestion`, `CronCreate`, `CronDelete`, `CronList`, `EnterPlanMode`, `EnterWorktree`, `ExitPlanMode`, `ExitWorktree`, `Monitor`, `NotebookEdit`, `PushNotification`, `RemoteTrigger`, `ScheduleWakeup`, `TaskOutput`, `TaskStop`, `WebSearch`.
3. **Sorting all 26 tools alphabetically** to match Claude Code's ordering.

If the model calls a stub tool, OpenCode's built-in error handling catches it, tells the model the tool is unavailable, and the model adapts on the next turn.

Expand Down Expand Up @@ -163,7 +163,7 @@ All OAuth and header parameters can be overridden via environment variables:
| `ANTHROPIC_CLI_VERSION` | Auto-detected from `claude --version` or `2.1.98` | Claude CLI version string |
| `ANTHROPIC_CLI_BUILD_ID` | `835` | Build ID for headers |
| `ANTHROPIC_ENTRYPOINT` | `sdk-cli` | Entrypoint value for billing |
| `ANTHROPIC_SDK_VERSION` | `0.81.0` | Stainless SDK version |
| `ANTHROPIC_SDK_VERSION` | `0.94.0` | Stainless SDK version |
| `ANTHROPIC_BETA_FLAGS` | Current Claude Code beta flags | Comma-separated beta feature flags |
| `ANTHROPIC_BILLING_CCH` | (computed) | Client attestation hash override |
| `ANTHROPIC_SYSTEM_PROMPT_PATH` | `~/.cache/opencode-claude-bridge/claude-system-prompt.json` | Path to cached system prompt |
Expand Down
33 changes: 33 additions & 0 deletions scripts/validate-opencode-oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ function redactHeaders(headers) {
return copy;
}

function splitHeaderList(value) {
if (typeof value !== "string") return [];
return value.split(",").map((item) => item.trim()).filter(Boolean);
}

function sortedDifference(left, right) {
const rightSet = new Set(right);
return left.filter((item) => !rightSet.has(item)).sort();
}

async function startCaptureProxy(label, outDir) {
let requestCount = 0;
const captures = [];
Expand Down Expand Up @@ -332,9 +342,22 @@ function compareRequests(reference, candidate) {
equal: sha256(reference.body) === sha256(candidate.body),
},
headerDiff,
betaFlags: {
claude: splitHeaderList(reference.headers?.["anthropic-beta"]),
opencode: splitHeaderList(candidate.headers?.["anthropic-beta"]),
},
bodyShape: null,
};

summary.betaFlags.missingFromOpenCode = sortedDifference(
summary.betaFlags.claude,
summary.betaFlags.opencode,
);
summary.betaFlags.extraInOpenCode = sortedDifference(
summary.betaFlags.opencode,
summary.betaFlags.claude,
);

if (parsedReference && parsedCandidate) {
const refTools = Array.isArray(parsedReference.tools) ? parsedReference.tools.map((tool) => tool.name) : [];
const candTools = Array.isArray(parsedCandidate.tools) ? parsedCandidate.tools.map((tool) => tool.name) : [];
Expand Down Expand Up @@ -376,6 +399,12 @@ function compareRequests(reference, candidate) {
opencode: candTools.length,
},
toolNamesEqual: JSON.stringify(refTools) === JSON.stringify(candTools),
toolNames: {
claude: refTools,
opencode: candTools,
missingFromOpenCode: sortedDifference(refTools, candTools),
extraInOpenCode: sortedDifference(candTools, refTools),
},
};
}

Expand Down Expand Up @@ -497,10 +526,14 @@ async function main() {
if (report.comparison?.available) {
console.log(`Body hash equal: ${report.comparison.bodyHash.equal}`);
console.log(`Header differences: ${Object.keys(report.comparison.headerDiff).length}`);
console.log(`Missing beta flags: ${report.comparison.betaFlags.missingFromOpenCode.length}`);
console.log(`Extra beta flags: ${report.comparison.betaFlags.extraInOpenCode.length}`);
if (report.comparison.bodyShape) {
console.log(`Tool names equal: ${report.comparison.bodyShape.toolNamesEqual}`);
console.log(`Claude tool count: ${report.comparison.bodyShape.toolCount.claude}`);
console.log(`OpenCode tool count: ${report.comparison.bodyShape.toolCount.opencode}`);
console.log(`Missing tools: ${report.comparison.bodyShape.toolNames.missingFromOpenCode.length}`);
console.log(`Extra tools: ${report.comparison.bodyShape.toolNames.extraInOpenCode.length}`);
}
}
}
Expand Down
35 changes: 32 additions & 3 deletions src/claude-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Claude Code tool definitions — exact wire-captured schemas.
*
* This module provides:
* 1. The exact tool definitions Claude Code sends to the API (24 core tools)
* 1. The exact tool definitions Claude Code sends to the API (26 core tools)
* 2. Bidirectional parameter-name translation maps (snake_case ↔ camelCase)
* 3. A helper to build the full sorted tool array matching Claude Code's wire format
*
Expand Down Expand Up @@ -669,7 +669,7 @@ const SHARED_TOOLS: ToolDefinition[] = [
];

/**
* The 14 Claude-only tools that OpenCode doesn't have.
* The 16 Claude-only tools that OpenCode doesn't have.
* These are sent as stubs — the model may try to call them, but they'll
* never actually execute (handled by returning a graceful error).
*/
Expand Down Expand Up @@ -851,6 +851,20 @@ const STUB_TOOLS: ToolDefinition[] = [
additionalProperties: false,
},
},
{
name: "PushNotification",
description: "This tool sends a desktop notification in the user's terminal. If Remote Control is connected, it also pushes to their phone. Either way, it pulls their attention from whatever they're doing — a meeting, another task, dinner — to this session. That's the cost. The benefit is they learn something now that they'd want to know now: a long task finished while they were away, a build is ready, you've hit something that needs their decision before you can continue.\n\nBecause a notification they didn't need is annoying in a way that accumulates, err toward not sending one. Don't notify for routine progress, or to announce you've answered something they asked seconds ago and are clearly still watching, or when a quick task completes. Notify when there's a real chance they've walked away and there's something worth coming back for — or when they've explicitly asked you to notify them.\n\nKeep the message under 200 characters, one line, no markdown. Lead with what they'd act on — \"build failed: 2 auth tests\" tells them more than \"task done\" and more than a status dump.\n\nIf the result says the push wasn't sent, that's expected — no action needed.",
input_schema: {
"$schema": "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: {
message: { description: "The notification body. Keep it under 200 characters; mobile OSes truncate.", type: "string", minLength: 1 },
status: { type: "string", const: "proactive" },
},
required: ["message", "status"],
additionalProperties: false,
},
},
{
name: "RemoteTrigger",
description: "Call the claude.ai remote-trigger API. Use this instead of curl \u2014 the OAuth token is added automatically in-process and never exposed.",
Expand All @@ -866,6 +880,21 @@ const STUB_TOOLS: ToolDefinition[] = [
additionalProperties: false,
},
},
{
name: "ScheduleWakeup",
description: "Schedule when to resume work in /loop dynamic mode — the user invoked /loop without an interval, asking you to self-pace iterations of a specific task.\n\nPass the same /loop prompt back via `prompt` each turn so the next firing repeats the task. For an autonomous /loop (no user prompt), pass the literal sentinel `<<autonomous-loop-dynamic>>` as `prompt` instead — the runtime resolves it back to the autonomous-loop instructions at fire time. (There is a similar `<<autonomous-loop>>` sentinel for CronCreate-based autonomous loops; do not confuse the two — ScheduleWakeup always uses the `-dynamic` variant.) Omit the call to end the loop.\n\n## Picking delaySeconds\n\nThe Anthropic prompt cache has a 5-minute TTL. Sleeping past 300 seconds means the next wake-up reads your full conversation context uncached — slower and more expensive. So the natural breakpoints:\n\n- **Under 5 minutes (60s–270s)**: cache stays warm. Right for active work — checking a build, polling for state that's about to change, watching a process you just started.\n- **5 minutes to 1 hour (300s–3600s)**: pay the cache miss. Right when there's no point checking sooner — waiting on something that takes minutes to change, or genuinely idle.\n\n**Don't pick 300s.** It's the worst-of-both: you pay the cache miss without amortizing it. If you're tempted to \"wait 5 minutes,\" either drop to 270s (stay in cache) or commit to 1200s+ (one cache miss buys a much longer wait). Don't think in round-number minutes — think in cache windows.\n\nFor idle ticks with no specific signal to watch, default to **1200s–1800s** (20–30 min). The loop checks back, you don't burn cache 12× per hour for nothing, and the user can always interrupt if they need you sooner.\n\nThink about what you're actually waiting for, not just \"how long should I sleep.\" If you kicked off an 8-minute build, sleeping 60s burns the cache 8 times before it finishes — sleep ~270s twice instead.\n\nThe runtime clamps to [60, 3600], so you don't need to clamp yourself.\n\n## The reason field\n\nOne short sentence on what you chose and why. Goes to telemetry and is shown back to the user. \"checking long bun build\" beats \"waiting.\" The user reads this to understand what you're doing without having to predict your cadence in advance — make it specific.\n",
input_schema: {
"$schema": "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: {
delaySeconds: { description: "Seconds from now to wake up. Clamped to [60, 3600] by the runtime.", type: "number" },
reason: { description: "One short sentence explaining the chosen delay. Goes to telemetry and is shown to the user. Be specific.", type: "string" },
prompt: { description: "The /loop input to fire on wake-up. Pass the same /loop input verbatim each turn so the next firing re-enters the skill and continues the loop. For autonomous /loop (no user prompt), pass the literal sentinel `<<autonomous-loop-dynamic>>` instead (the dynamic-pacing variant, not the CronCreate-mode `<<autonomous-loop>>`).", type: "string" },
},
required: ["delaySeconds", "reason", "prompt"],
additionalProperties: false,
},
},
{
name: "TaskOutput",
description: "DEPRECATED: Prefer using the Read tool on the task's output file path instead. Retrieves output from a running or completed task.",
Expand Down Expand Up @@ -913,7 +942,7 @@ const STUB_TOOLS: ToolDefinition[] = [

// ── Public API ──────────────────────────────────────────────────────

/** All 24 Claude Code core tools, sorted alphabetically by name. */
/** All 26 Claude Code core tools, sorted alphabetically by name. */
export function getClaudeTools(): ToolDefinition[] {
return [...SHARED_TOOLS, ...STUB_TOOLS].sort((a, b) =>
a.name.localeCompare(b.name),
Expand Down
6 changes: 3 additions & 3 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,11 @@ export const USER_AGENT =
process.env.ANTHROPIC_USER_AGENT ||
`claude-cli/${CLI_VERSION} (external, ${ENTRYPOINT})`;

// Exact beta flags from Claude Code 2.1.98 (confirmed via request interception)
// Exact beta flags from Claude Code 2.1.178 (confirmed via request interception)
// oauth-2025-04-20 is added separately only for OAuth auth.
export const BETA_FLAGS =
process.env.ANTHROPIC_BETA_FLAGS ||
"interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,claude-code-20250219,advisor-tool-2026-03-01,effort-2025-11-24";
"interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,claude-code-20250219,advisor-tool-2026-03-01,effort-2025-11-24,extended-cache-ttl-2025-04-11";

export const OAUTH_BETA_FLAG = "oauth-2025-04-20";

Expand All @@ -83,7 +83,7 @@ export const ANTHROPIC_VERSION = "2023-06-01";
// Stainless SDK headers (matches what Claude Code's bundled SDK sends)
export const STAINLESS_HEADERS: Record<string, string> = {
"x-stainless-lang": "js",
"x-stainless-package-version": process.env.ANTHROPIC_SDK_VERSION || "0.81.0",
"x-stainless-package-version": process.env.ANTHROPIC_SDK_VERSION || "0.94.0",
"x-stainless-os": process.platform === "darwin" ? "MacOS" : process.platform === "linux" ? "Linux" : "Windows",
"x-stainless-arch": process.arch === "arm64" ? "arm64" : process.arch,
"x-stainless-runtime": "node",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => {
headers.set("authorization", `Bearer ${accessToken}`);
headers.delete("x-api-key");
headers.delete("x-session-affinity");
headers.delete("x-session-id");
if (!headers.has("accept")) headers.set("accept", "application/json");
headers.set("user-agent", USER_AGENT);
headers.set("x-app", "cli");
Expand Down