opencode-anthropic-fix 0.5.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,12 +21,11 @@ import {
21
21
  import { resolveCacheTtl, shouldPlaceToolBreakpoint } from "./cache.mjs";
22
22
  import {
23
23
  isAdaptiveThinkingModel,
24
+ isClaude3Model,
24
25
  isOpus46Model,
25
26
  isOpus47Model,
26
27
  isOpus48Model,
27
- normalizeThinkingBlock,
28
- CLAUDE_3_MODEL_RE,
29
- } from "./models.mjs";
28
+ } from "./wire-compat.mjs";
30
29
  import {
31
30
  resolveMaxTokens,
32
31
  extractFirstUserMessageText,
@@ -34,9 +33,45 @@ import {
34
33
  stripSlashCommandMessages,
35
34
  repairOrphanedToolUseBlocks,
36
35
  } from "./request-helpers.mjs";
37
- import { isFalsyEnv } from "../env.mjs";
36
+ import { isFalsyEnv, isTruthyEnv } from "../env.mjs";
38
37
  import { sessionMetrics } from "../session-metrics.mjs";
39
38
 
39
+ /**
40
+ * Coerce an incoming `thinking` block to the shape the model actually accepts.
41
+ *
42
+ * Lives here rather than in a model module because it is a BODY transform, not
43
+ * a model query: the only model knowledge it needs is `isAdaptiveThinkingModel`,
44
+ * which now comes from the wire-compat seam. This module is its sole caller.
45
+ *
46
+ * @param {any} thinking
47
+ * @param {string} model
48
+ * @returns {any}
49
+ */
50
+ export function normalizeThinkingBlock(thinking, model) {
51
+ // If thinking is absent or not an object, pass through
52
+ if (!thinking || typeof thinking !== "object") {
53
+ return thinking;
54
+ }
55
+
56
+ // Adaptive thinking models always get { type: "adaptive" }
57
+ // regardless of what format the incoming thinking block has
58
+ if (isAdaptiveThinkingModel(model)) {
59
+ // Check for env-var override to force budget_tokens fallback
60
+ if (isTruthyEnv(process.env.OPENCODE_ANTHROPIC_DISABLE_ADAPTIVE_THINKING)) {
61
+ // Fallback: return as-is if already budget_tokens shape, otherwise default
62
+ if (thinking.type === "enabled" && typeof thinking.budget_tokens === "number") {
63
+ return thinking;
64
+ }
65
+ const parsedBudget = parseInt(process.env.MAX_THINKING_TOKENS, 10);
66
+ return { type: "enabled", budget_tokens: Number.isNaN(parsedBudget) ? 16000 : parsedBudget };
67
+ }
68
+ return { type: "adaptive" };
69
+ }
70
+
71
+ // Non-adaptive models: pass through unchanged
72
+ return thinking;
73
+ }
74
+
40
75
  // Core tool names (CC PascalCase) that are always eager-loaded.
41
76
  export const CORE_TOOL_NAMES = new Set([
42
77
  "Bash",
@@ -59,11 +94,14 @@ export const CORE_TOOL_NAMES = new Set([
59
94
  * @param {string | undefined} body
60
95
  * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', provider?: string, useAdapter?: boolean}} signature
61
96
  * @param {{persistentUserId: string, sessionId: string, accountId: string, turns?: number, usedTools?: Set<string>}} runtime
62
- * @param {string} [betaHeader] - Pre-computed anthropic-beta header value to inject into the body.
97
+ * @param {boolean} [taskBudgetsActive] - Whether `task-budgets-2026-03-13` is active for this
98
+ * request. Derived by the caller from the same gates the adapter applies to a custom beta
99
+ * (signature emulation, shortcut resolution, `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`); this
100
+ * used to be the full pre-computed `anthropic-beta` header string, substring-tested here.
63
101
  * @param {import('../config.mjs').AnthropicAuthConfig} [config] - Plugin configuration for output cap
64
102
  * @returns {string | undefined}
65
103
  */
66
- export function transformRequestBody(body, signature, runtime, betaHeader, config) {
104
+ export function transformRequestBody(body, signature, runtime, taskBudgetsActive, config) {
67
105
  if (!body || typeof body !== "string") return body;
68
106
 
69
107
  try {
@@ -157,7 +195,7 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
157
195
  // ⊆ beta invariant, so the 400 never fires.
158
196
  if (
159
197
  config?.token_economy?.context_management &&
160
- !CLAUDE_3_MODEL_RE.test(parsed.model || "") &&
198
+ !isClaude3Model(parsed.model || "") &&
161
199
  !parsed.context_management
162
200
  ) {
163
201
  parsed.context_management = {
@@ -331,13 +369,10 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
331
369
  // Mirrors real CC's `--workload <tag>` CLI flag (process-scoped, used by
332
370
  // SDK daemon callers spawning cron subprocesses). Empty string -> omit.
333
371
  workload: typeof config?.signature_emulation?.workload === "string" ? config.signature_emulation.workload : "",
334
- // Adapter path only: suppress the canonical billing + identity blocks
335
- // because the shared package prepends its own. Every other stage of the
336
- // system pipeline (anti-verbosity, subagent CC-prefix, title-generator
337
- // swap, dedupe, compaction, tailing, token-budget block) still runs.
338
- suppressCanonicalBlocks: signature.useAdapter === true,
339
372
  };
340
- // Sanitize system prompt and optionally inject Claude Code identity/billing blocks.
373
+ // Host system-prompt policy: sanitize, compact, dedupe, anti-verbosity, and
374
+ // the prompt-cache breakpoint. The canonical billing + identity prefix is
375
+ // NOT built here — the shared package composes it (see system-prompt.mjs).
341
376
  parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signatureWithModel);
342
377
 
343
378
  // Strategy 5 — System prompt tailing: after N turns, trim large system blocks
@@ -581,7 +616,9 @@ export function transformRequestBody(body, signature, runtime, betaHeader, confi
581
616
  // Task budgets: when the task-budgets beta is active, preserve or inject output_config.
582
617
  // The beta unlocks output_config.max_output_tokens for per-task budget control.
583
618
  // Model-router compatibility: the beta header + output_config body are forwarded as-is.
584
- if (betaHeader && betaHeader.includes("task-budgets-2026-03-13")) {
619
+ // The signal is a boolean derived by the caller — this used to substring-test a
620
+ // pre-computed `anthropic-beta` header string built solely for this line.
621
+ if (taskBudgetsActive) {
585
622
  if (!parsed.output_config) {
586
623
  // Default: set a reasonable per-task output budget for long-running agentic tasks.
587
624
  // Claude Code tasks typically need generous output budgets.
@@ -1,13 +1,28 @@
1
- import { createHash as createHashCrypto } from "node:crypto";
2
- import { isTruthyEnv, isFalsyEnv } from "../env.mjs";
3
- import { isOpus46Model, isOpus47Model, isOpus48Model } from "./models.mjs";
4
-
5
- const BILLING_HASH_SALT = "59cf53e54c78";
6
- const BILLING_HASH_INDICES = [4, 7, 20];
1
+ import { isTruthyEnv } from "../env.mjs";
2
+ import { isOpus46Model, isOpus47Model, isOpus48Model } from "./wire-compat.mjs";
7
3
 
4
+ /**
5
+ * The canonical Claude Code identity text.
6
+ *
7
+ * The PLUGIN NO LONGER EMITS IT. `@tormentalabs/claude-code-wire-compat`
8
+ * composes the canonical prefix (billing block + identity block) itself and
9
+ * drops any caller block byte-equal to the identity text, so the prefix lands
10
+ * on the wire exactly once. The constant survives here as a host-policy probe:
11
+ * `compactSystemText` strips a duplicated identity prefix a host prompt may
12
+ * carry, before the array ever reaches the package.
13
+ */
8
14
  export const CLAUDE_CODE_IDENTITY_STRING = "You are Claude Code, Anthropic's official CLI for Claude.";
9
- /** Marker that identifies the canonical billing block inside a system array. */
15
+ /** Marker that identifies a canonical billing block inside a system array. */
10
16
  export const BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
17
+ /**
18
+ * Every canonical identity text the wire recognises.
19
+ *
20
+ * The package drops a caller block byte-equal to its own `IDENTITY_TEXT`, but
21
+ * only that one, and only when the block stands alone. The host pipeline joins
22
+ * blocks before the package sees them, so a host-supplied identity block has to
23
+ * be dropped HERE or it would end up embedded mid-string in the joined run and
24
+ * put the canonical prefix on the wire twice.
25
+ */
11
26
  const KNOWN_IDENTITY_STRINGS = new Set([
12
27
  CLAUDE_CODE_IDENTITY_STRING,
13
28
  "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.",
@@ -46,29 +61,6 @@ const ANTI_VERBOSITY_SYSTEM_PROMPT = [
46
61
  "In code: default to writing no comments. Never write multi-paragraph docstrings or multi-line comment blocks — one short line max. Don't create planning, decision, or analysis documents unless the user asks for them — work from conversation context, not intermediate files.",
47
62
  ].join("\n");
48
63
 
49
- // Max system prompt length that passes CC billing validation.
50
- // The server pattern-matches the system prompt against the real CC prompt.
51
- // Opencode's customizations after ~5800 chars diverge and trigger extra usage billing.
52
- const MAX_SAFE_SYSTEM_TEXT_LENGTH = 5000;
53
-
54
- // A5: Subagent CC-prefix cache.
55
- //
56
- // Context: opencode/packages/opencode/src/session/llm.ts:110 uses
57
- // `input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(model)`
58
- // so any agent with a custom prompt (explore, fast, title, summary, etc.)
59
- // fires WITHOUT the base CC prompt — the server-side fingerprint match fails
60
- // and the request is billed as pay-as-you-go credits instead of Max-plan usage.
61
- //
62
- // Fix: on the first main-agent call (where the anchor is present), cache the
63
- // sanitized CC prefix. On subsequent subagent calls (anchor missing), prepend
64
- // the cached prefix to the sanitized blocks so the fingerprint matches again.
65
- //
66
- // The cache lives at module scope because buildSystemPromptBlocks is re-entered
67
- // per request. It gets populated exactly once per process on the first main call.
68
- const MAX_SUBAGENT_CC_PREFIX = MAX_SAFE_SYSTEM_TEXT_LENGTH;
69
- export const SUBAGENT_CC_ANCHOR = "You are an interactive";
70
- let cachedCCPrompt = null;
71
-
72
64
  // Perf: module-scope regexes reused across per-request hot paths. None use the
73
65
  // `/g` flag and all are consumed via `.test()`, so a single shared instance is
74
66
  // stateless and safe (no `lastIndex` to reset between calls).
@@ -79,19 +71,6 @@ const TAIL_LIST_ITEM_RE = /^\s*[-*]\s/;
79
71
  const OPENCODE_ENV_CONTEXT_PREFIX = "Here is some useful information about the environment you are running in:";
80
72
  const CC_ENV_CONTEXT_PREFIX = "Here is useful information about the environment you are running in:";
81
73
 
82
- /**
83
- * Compute the billing cache hash (cch) matching Claude Code's NP1() function.
84
- * SHA256(salt + chars_at_indices[4,7,20]_from_first_user_msg + version).slice(0,3)
85
- * @param {string} firstUserMessage
86
- * @param {string} version
87
- * @returns {string}
88
- */
89
- export function computeBillingCacheHash(firstUserMessage, version) {
90
- const chars = BILLING_HASH_INDICES.map((i) => firstUserMessage[i] || "0").join("");
91
- const input = `${BILLING_HASH_SALT}${chars}${version}`;
92
- return createHashCrypto("sha256").update(input).digest("hex").slice(0, 3);
93
- }
94
-
95
74
  /**
96
75
  * Models eligible for the "simple system prompt" mode that real CC ships in
97
76
  * v2.1.133+ under GrowthBook flag `tengu_velvet_cascade` (or forced via
@@ -112,48 +91,6 @@ export function isSimpleSystemPromptEligible(model) {
112
91
  return false;
113
92
  }
114
93
 
115
- /**
116
- * Build the billing header block for Claude Code system prompt injection.
117
- * Claude Code v2.1.97: cc_version includes 3-char fingerprint hash (not model ID).
118
- * cch is a static "00000" placeholder (xxHash64 attestation removed in v2.1.97).
119
- *
120
- * Real CC (system.ts:78): version = `${MACRO.VERSION}.${fingerprint}`
121
- * Real CC (system.ts:82): cch = ' cch=00000;' (static, no longer computed)
122
- *
123
- * @param {string} version - CLI version (e.g., "2.1.97")
124
- * @param {string} [firstUserMessage] - First user message text for fingerprint computation
125
- * @param {string} [workloadOverride] - Explicit workload tag from config (`signature_emulation.workload`).
126
- * Takes precedence over `CLAUDE_CODE_WORKLOAD` env var. Mirrors real CC's `--workload <tag>` flag.
127
- * @returns {string}
128
- */
129
- export function buildAnthropicBillingHeader(version, firstUserMessage, workloadOverride) {
130
- if (isFalsyEnv(process.env.CLAUDE_CODE_ATTRIBUTION_HEADER)) return "";
131
- // Real CC sends cc_entrypoint=cli (confirmed via proxy capture).
132
- const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "cli";
133
- // Fix #1: cc_version suffix is the 3-char fingerprint hash, NOT the model ID.
134
- // computeBillingCacheHash() computes SHA256(salt + msg[4]+msg[7]+msg[20] + version)[:3]
135
- // which matches computeFingerprint() in the real CC source (utils/fingerprint.ts).
136
- // Always call the hash function — even for empty messages the real CC computes
137
- // the hash from "000" chars (indices 4,7,20 all missing → fallback "0").
138
- const fingerprint = computeBillingCacheHash(firstUserMessage || "", version);
139
- const ccVersion = `${version}.${fingerprint}`;
140
- // cch: v2.1.97 sends static "cch=00000" — xxHash64 attestation was removed.
141
- // The server uses the PRESENCE of cch=00000 as a CC identification signal.
142
- // The plugin is first-party only, so cch is always emitted; the former
143
- // bedrock/anthropicAws/mantle suppression went away with multi-provider support.
144
- const cchPart = " cch=00000;";
145
- // Build workload part (upstream concatenates directly, no regex replace).
146
- // Config wins over env so `signature_emulation.workload` is portable across hosts.
147
- let workloadPart = "";
148
- const workload = workloadOverride || process.env.CLAUDE_CODE_WORKLOAD;
149
- if (workload) {
150
- // QA fix M5: sanitize workload value to prevent header injection.
151
- const safeWorkload = String(workload).replace(/[;\s\r\n]/g, "_");
152
- if (safeWorkload) workloadPart = ` cc_workload=${safeWorkload};`;
153
- }
154
- return `x-anthropic-billing-header: cc_version=${ccVersion}; cc_entrypoint=${entrypoint};${cchPart}${workloadPart}`;
155
- }
156
-
157
94
  export function sanitizeSystemText(text) {
158
95
  // QA fix M4: use word boundaries to avoid mangling URLs and code identifiers
159
96
  let sanitized = text.replace(/\bOpenCode\b/g, "Claude Code").replace(/\bopencode\b/gi, "Claude");
@@ -348,36 +285,14 @@ export function normalizeSystemTextBlocks(system) {
348
285
  return output;
349
286
  }
350
287
 
351
- /**
352
- * Determine the identity string prefix, matching real CC's getCLISyspromptPrefix().
353
- * Real CC selects based on isNonInteractive + hasAppendSystemPrompt flags.
354
- * OpenCode is always interactive CLI, so DEFAULT_PREFIX is almost always correct.
355
- * We check for Agent SDK signals from the environment to match non-interactive cases.
356
- *
357
- * @returns {string}
358
- */
359
- export function getCLISyspromptPrefix() {
360
- // Agent SDK preset: when running within the Claude Agent SDK with CC preset
361
- if (isTruthyEnv(process.env.CLAUDE_AGENT_SDK_VERSION) && isTruthyEnv(process.env.CLAUDE_CODE_ENTRYPOINT)) {
362
- const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT || "";
363
- if (entrypoint === "agent-sdk" || entrypoint === "sdk") {
364
- return "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.";
365
- }
366
- }
367
- // Non-interactive agent without CC preset
368
- if (isTruthyEnv(process.env.CLAUDE_AGENT_SDK_VERSION) && !isTruthyEnv(process.env.CLAUDE_CODE_ENTRYPOINT)) {
369
- return "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
370
- }
371
- return CLAUDE_CODE_IDENTITY_STRING;
372
- }
373
-
374
288
  /**
375
289
  * Compute the cache_control object for a given cache scope and policy.
376
- * Mirrors real CC getCacheControl() (src/services/api/claude.ts:358-374).
377
290
  *
378
- * Real CC behavior:
379
- * - scope 'global' → {type: 'ephemeral', scope: 'global', ttl?: '1h'}
380
- * - scope 'org' → {type: 'ephemeral', ttl?: '1h'} (org is internal, NOT on wire)
291
+ * HOST cache policy: the ttl is the one `request-body.mjs` resolved for this
292
+ * request (role-scoped / subagent-aware), not a fixed CC value.
293
+ *
294
+ * - scope 'global' → {type: 'ephemeral', scope: 'global', ttl?: <resolved>}
295
+ * - scope 'org' → {type: 'ephemeral', ttl?: <resolved>} (org is internal, NOT on wire)
381
296
  * - scope null → block gets NO cache_control at all (caller should omit it)
382
297
  *
383
298
  * @param {'global' | 'org' | null} cacheScope
@@ -396,66 +311,42 @@ export function getCacheControlForScope(cacheScope, cachePolicy) {
396
311
  }
397
312
 
398
313
  /**
399
- * Split system prompt blocks into structured blocks with cache scoping,
400
- * matching real CC splitSysPromptPrefix() (src/utils/api.ts:321-435).
314
+ * Place the host prompt-cache breakpoints over the sanitized system blocks.
401
315
  *
402
- * Real CC has 3 paths that produce 2 distinct wire formats:
316
+ * This is the cache half of what used to be `splitSysPromptPrefix`. The prefix
317
+ * half (billing block + identity block) is gone: the shared package composes
318
+ * the canonical prefix itself. What remains is host policy the package cannot
319
+ * infer — WHERE the cache breakpoint goes and which ttl/scope it carries.
403
320
  *
404
- * Path A (tool-based cache): skipGlobalCacheForSystemPrompt=true
405
- * - billing → cacheScope: null
406
- * - identity → cacheScope: 'org'
407
- * - rest (joined) → cacheScope: 'org'
408
- * Wire result: identical to Path C (org → ephemeral without scope field)
321
+ * The blocks are joined here, not left loose, because the package joins a run
322
+ * of caller blocks that share a `cache_control` anyway. Joining first is what
323
+ * makes the breakpoint land on one deterministic block.
409
324
  *
410
- * Path B (boundary mode): shouldUseGlobalCacheScope() && boundary marker found
411
- * - billing → cacheScope: null
412
- * - identity → cacheScope: null (NO cache_control in boundary mode!)
413
- * - static blocks (before boundary, joined) → cacheScope: 'global'
414
- * - dynamic blocks (after boundary, joined) → cacheScope: null
415
- *
416
- * Path C (fallback): no global cache feature or no boundary
417
- * - billing → cacheScope: null
418
- * - identity → cacheScope: 'org'
419
- * - rest (joined) → cacheScope: 'org'
325
+ * Boundary mode (`cache_policy.boundary_marker`, or
326
+ * `CLAUDE_CODE_FORCE_GLOBAL_CACHE`): everything before the marker is the static
327
+ * half and takes a `scope: 'global'` breakpoint; everything after is the
328
+ * dynamic half and takes none.
420
329
  *
421
330
  * @param {Array<{text: string}>} blocks - Already sanitized/filtered text blocks
422
- * @param {string | undefined} attributionHeader - The billing header text (or undefined)
423
- * @param {string} identityString - The identity prefix string
424
- * @param {boolean} useBoundaryMode - Whether to use Path B (global cache with boundary)
331
+ * @param {boolean} useBoundaryMode
425
332
  * @returns {Array<{text: string, cacheScope: 'global' | 'org' | null}>}
426
333
  */
427
- export function splitSysPromptPrefix(blocks, attributionHeader, identityString, useBoundaryMode) {
428
- // Separate known blocks from rest, matching real CC's parsing loop
429
- const rest = [];
430
- for (const block of blocks) {
431
- if (!block.text) continue;
432
- // Skip if it's a billing header or identity string (already extracted)
433
- if (block.text.startsWith("x-anthropic-billing-header:")) continue;
434
- if (KNOWN_IDENTITY_STRINGS.has(block.text)) continue;
435
- // Skip the boundary marker itself (real CC skips it in Path A, processes it in Path B)
436
- if (block.text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue;
437
- rest.push(block.text);
438
- }
334
+ export function splitSystemCacheScopes(blocks, useBoundaryMode) {
335
+ const isDroppedBlock = (text) =>
336
+ !text ||
337
+ text.startsWith(BILLING_HEADER_PREFIX) ||
338
+ KNOWN_IDENTITY_STRINGS.has(text) ||
339
+ text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY;
439
340
 
440
- // ====================================================================
441
- // Path B: Global cache with boundary marker
442
- // Real CC (utils/api.ts:219-262): when shouldUseGlobalCacheScope() &&
443
- // boundary marker is found in the system prompt array.
444
- // ====================================================================
445
341
  if (useBoundaryMode) {
446
- // Find boundary marker in the ORIGINAL block array (before filtering)
447
342
  const boundaryIndex = blocks.findIndex((b) => b.text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY);
448
343
 
449
344
  if (boundaryIndex !== -1) {
450
- // Classify blocks as static (before boundary) or dynamic (after boundary)
451
345
  const staticBlocks = [];
452
346
  const dynamicBlocks = [];
453
347
  for (let i = 0; i < blocks.length; i++) {
454
348
  const text = blocks[i].text;
455
- if (!text) continue;
456
- if (text === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue;
457
- if (text.startsWith("x-anthropic-billing-header:")) continue;
458
- if (KNOWN_IDENTITY_STRINGS.has(text)) continue;
349
+ if (isDroppedBlock(text)) continue;
459
350
  if (i < boundaryIndex) {
460
351
  staticBlocks.push(text);
461
352
  } else {
@@ -464,34 +355,40 @@ export function splitSysPromptPrefix(blocks, attributionHeader, identityString,
464
355
  }
465
356
 
466
357
  const result = [];
467
- if (attributionHeader) result.push({ text: attributionHeader, cacheScope: null });
468
- // Identity: cacheScope null in boundary mode (real CC behavior)
469
- result.push({ text: identityString, cacheScope: null });
470
358
  const staticJoined = staticBlocks.join("\n");
471
359
  if (staticJoined) result.push({ text: staticJoined, cacheScope: "global" });
472
360
  const dynamicJoined = dynamicBlocks.join("\n");
473
361
  if (dynamicJoined) result.push({ text: dynamicJoined, cacheScope: null });
474
362
  return result;
475
363
  }
476
- // Boundary marker not found — fall through to Path C
364
+ // Boundary marker not found — fall through to the single-breakpoint shape.
477
365
  }
478
366
 
479
- // ====================================================================
480
- // Path C (fallback) / Path A (tool-based): no boundary or no global cache
481
- // Real CC (utils/api.ts:264-289): identity and rest get cacheScope 'org'
482
- // Path A produces identical wire output to Path C.
483
- // ====================================================================
484
- const result = [];
485
- if (attributionHeader) result.push({ text: attributionHeader, cacheScope: null });
486
- result.push({ text: identityString, cacheScope: "org" });
367
+ const rest = [];
368
+ for (const block of blocks) {
369
+ if (isDroppedBlock(block.text)) continue;
370
+ rest.push(block.text);
371
+ }
487
372
  const restJoined = rest.join("\n");
488
- if (restJoined) result.push({ text: restJoined, cacheScope: "org" });
489
- return result;
373
+ return restJoined ? [{ text: restJoined, cacheScope: "org" }] : [];
490
374
  }
491
375
 
492
376
  /**
377
+ * Apply the HOST system-prompt policy to an already-normalized block array.
378
+ *
379
+ * This is host policy ONLY. The Claude Code canonical prefix (billing block +
380
+ * identity block) is composed by `@tormentalabs/claude-code-wire-compat`, which
381
+ * also drops any caller block byte-equal to the identity text — so the prefix
382
+ * reaches the wire exactly once and the plugin never emits it.
383
+ *
384
+ * What stays here, in order:
385
+ * 1. sanitize (OpenCode -> Claude Code) + compaction
386
+ * 2. title-generator swap, or dedupe of near-identical blocks
387
+ * 3. anti-verbosity injection (Opus 4.6/4.7, unless simple-system-prompt mode)
388
+ * 4. host prompt-cache breakpoint placement
389
+ *
493
390
  * @param {Array<{type: string, text: string, cache_control?: {type: string}}>} system
494
- * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', cachePolicy?: {ttl: string, ttl_supported: boolean, boundary_marker?: boolean}}} signature
391
+ * @param {{promptCompactionMode: 'minimal' | 'off', modelId?: string, simpleSystemPrompt?: boolean, antiVerbosity?: {enabled?: boolean}, cachePolicy?: {ttl: string, ttl_supported: boolean, boundary_marker?: boolean}}} signature
495
392
  * @returns {Array<{type: string, text: string, cache_control?: {type: string}}>}
496
393
  */
497
394
  export function buildSystemPromptBlocks(system, signature) {
@@ -502,40 +399,6 @@ export function buildSystemPromptBlocks(system, signature) {
502
399
  text: compactSystemText(sanitizeSystemText(item.text), signature.promptCompactionMode),
503
400
  }));
504
401
 
505
- // A5: Subagent CC-prefix cache/inject (see constant declaration above for context).
506
- //
507
- // After sanitize, main-agent blocks start with "You are an interactive..." because
508
- // sanitizeSystemText() strips everything before that anchor. Subagent blocks
509
- // (custom prompts from input.agent.prompt) do NOT start with the anchor —
510
- // they start with whatever the agent template says (e.g., "You are a file search
511
- // specialist.").
512
- //
513
- // This logic runs ONLY for Anthropic requests with signature enabled (signature.enabled
514
- // is false for non-Anthropic providers), and skips the title-generator fast path
515
- // because that one is replaced wholesale with COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT below.
516
- if (signature.enabled && !titleGeneratorRequest && sanitized.length > 0) {
517
- const firstText = typeof sanitized[0]?.text === "string" ? sanitized[0].text : "";
518
- const hasCcAnchor = firstText.startsWith(SUBAGENT_CC_ANCHOR);
519
-
520
- if (hasCcAnchor) {
521
- // Main-agent path: cache the prefix on the first hit so subagents can reuse it.
522
- // We slice to MAX_SUBAGENT_CC_PREFIX to avoid unbounded growth if the upstream
523
- // sanitize limit is ever raised.
524
- if (!cachedCCPrompt) {
525
- cachedCCPrompt = firstText.slice(0, MAX_SUBAGENT_CC_PREFIX);
526
- }
527
- } else if (cachedCCPrompt) {
528
- // Subagent path: prepend the cached CC prefix so the fingerprint matches.
529
- // We prepend, not concatenate, so the original subagent prompt stays as a
530
- // separate block — dedupeSystemBlocks and splitSysPromptPrefix handle the
531
- // join on their own downstream.
532
- sanitized = [{ type: "text", text: cachedCCPrompt }, ...sanitized];
533
- }
534
- // If !hasCcAnchor && !cachedCCPrompt: no-op. The cache primes on the very
535
- // first main call in a process. In practice opencode always fires a main
536
- // call before any subagent, so this branch is only hit in synthetic tests.
537
- }
538
-
539
402
  if (titleGeneratorRequest) {
540
403
  sanitized = [{ type: "text", text: COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT }];
541
404
  } else if (signature.promptCompactionMode !== "off") {
@@ -567,62 +430,14 @@ export function buildSystemPromptBlocks(system, signature) {
567
430
  }
568
431
  }
569
432
 
570
- if (!signature.enabled) {
571
- return sanitized;
572
- }
573
-
574
- // Lean system prompt for non-main requests (title-gen is already handled
575
- // above via the COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT swap). For "title"
576
- // and "small" request roles — one-off queries that don't belong to the main
577
- // REPL thread — we skip billing identity + CC identity injection. This
578
- // matches the spirit of real CC's querySource gates: identity context is
579
- // for interactive main-thread conversations, not fire-and-forget calls.
580
- // Opt-in (default off) because it changes the system-prompt shape.
581
- const leanNonMain =
582
- signature.leanNonMain === true &&
583
- (signature.requestRole === "title" || signature.requestRole === "small") &&
584
- !titleGeneratorRequest;
585
- if (leanNonMain) {
586
- return sanitized;
587
- }
588
-
589
- // Build attribution header
590
- const billingHeader = buildAnthropicBillingHeader(
591
- signature.claudeCliVersion,
592
- signature.firstUserMessage,
593
- signature.workload,
594
- );
595
-
596
- // Select the identity string (matches real CC getCLISyspromptPrefix())
597
- const identityString = getCLISyspromptPrefix();
598
-
599
- // Determine cache policy
433
+ // Host prompt-cache breakpoint. The canonical prefix is NOT built here — the
434
+ // package prepends its own billing + identity blocks and drops a caller block
435
+ // equal to the identity text, so the prefix lands on the wire exactly once.
600
436
  const effectiveCachePolicy = signature.cachePolicy || { ttl: "1h", ttl_supported: true };
601
-
602
- // Determine if we should use boundary mode (Path B)
603
- // Real CC: shouldUseGlobalCacheScope() is a GrowthBook feature flag.
604
- // We simulate it via config: boundary_marker=true or CLAUDE_CODE_FORCE_GLOBAL_CACHE=1.
605
437
  const useBoundaryMode =
606
438
  effectiveCachePolicy.boundary_marker || isTruthyEnv(process.env.CLAUDE_CODE_FORCE_GLOBAL_CACHE);
607
439
 
608
- // Run the real CC splitSysPromptPrefix algorithm to get blocks with cacheScope
609
- const scopedBlocks = splitSysPromptPrefix(sanitized, billingHeader || undefined, identityString, useBoundaryMode);
610
-
611
- // Adapter path only: the shared package prepends its OWN billing block
612
- // (index 0) and identity block (index 1), each with its own cache_control
613
- // marker. Emitting ours too would duplicate both blocks on the wire.
614
- //
615
- // This is CONDITIONAL, never unconditional: the legacy path (any request the
616
- // signature-emulation adapter declines — non-Anthropic provider, or emulation
617
- // off) still builds the request through buildRequestHeaders and depends on
618
- // these two blocks being present. Dropping them for every caller would break
619
- // that path silently — no error, just a wrong request on the wire.
620
- const emittedBlocks =
621
- signature.suppressCanonicalBlocks === true ? dropCanonicalPrefixBlocks(scopedBlocks, identityString) : scopedBlocks;
622
-
623
- // Convert scoped blocks to wire format using getCacheControlForScope
624
- // (mirrors real CC buildSystemPromptBlocks → map + getCacheControl)
625
- return emittedBlocks.map((block) => {
440
+ return splitSystemCacheScopes(sanitized, useBoundaryMode).map((block) => {
626
441
  const cc = getCacheControlForScope(block.cacheScope, effectiveCachePolicy);
627
442
  return {
628
443
  type: "text",
@@ -631,39 +446,3 @@ export function buildSystemPromptBlocks(system, signature) {
631
446
  };
632
447
  });
633
448
  }
634
-
635
- /**
636
- * Drop the two canonical prefix blocks the shared package prepends itself.
637
- *
638
- * Both are matched with the SAME predicates `splitSysPromptPrefix` uses to
639
- * recognise them on input, and only the FIRST occurrence of each is removed, so
640
- * a caller-supplied block that happens to repeat the identity string survives.
641
- *
642
- * @param {{text: string, cacheScope: string | null}[]} blocks
643
- * @param {string} identityString
644
- * @returns {{text: string, cacheScope: string | null}[]}
645
- */
646
- function dropCanonicalPrefixBlocks(blocks, identityString) {
647
- let billingDropped = false;
648
- let identityDropped = false;
649
- return blocks.filter((block) => {
650
- const text = typeof block.text === "string" ? block.text : "";
651
- if (!billingDropped && text.startsWith(BILLING_HEADER_PREFIX)) {
652
- billingDropped = true;
653
- return false;
654
- }
655
- if (!identityDropped && text === identityString) {
656
- identityDropped = true;
657
- return false;
658
- }
659
- return true;
660
- });
661
- }
662
-
663
- export function getCachedCCPrompt() {
664
- return cachedCCPrompt;
665
- }
666
-
667
- export function resetCachedCCPrompt() {
668
- cachedCCPrompt = null;
669
- }