@khalilgharbaoui/opencode-claude-code-plugin 0.29.2 → 0.30.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.
package/README.md CHANGED
@@ -324,6 +324,24 @@ That beats whatever effort the call arrived with. It has to, because opencode re
324
324
 
325
325
  An agent that declares nothing keeps the inherited effort, so this changes nothing until a file asks for it. An unrecognised level is refused and the inherited one kept, since the CLI rejects a level it does not know. Compaction is exempt: its summary always gets the full budget.
326
326
 
327
+ ### The prompt cache an agent writes
328
+
329
+ The third thing a turn costs is the prompt cache it writes, and the same file can state that too:
330
+
331
+ ```yaml
332
+ cacheTtl: 5m
333
+ ```
334
+
335
+ Or once, for every subagent that declares nothing, as `defaultSubagentCacheTtl` in the provider options. Values are `5m` and `1h`; anything else warns and leaves the CLI alone. It applies to headless spawns only: `/compact` and the experimental interactive transport keep the CLI's own default.
336
+
337
+ Claude Code's automatic default is a 1-hour cache on a subscription, and a 1-hour cache write is billed above a 5-minute one. That trade pays off for a long-lived main session, which re-reads the cache it wrote. It does not pay off for a fan-out of short workers: each one writes an hour-long cache, finishes, and never reads it again, and all of it comes out of the same weekly limit. Declaring `cacheTtl: 5m` on the workers while the main session keeps the default is the point of the knob.
338
+
339
+ **It is unset by default**, for the same reason `defaultSubagentModel` is: an upgrade must not quietly change how anybody's turns are cached.
340
+
341
+ One piece of Claude Code trivia is worth stating plainly, because it is the opposite of what the names suggest. The CLI has a per-agent `experimental.cacheTtl` and a `CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL`, and **neither of them does anything to this plugin.** Both apply only to subagents the CLI runs itself, through its own `Task` tool, which this plugin disallows by default so that opencode runs the subagent instead. An opencode subagent arrives here as its own `doStream` and its own `claude --print` process, and the CLI counts that as a main conversation. So the knob that reaches every process this plugin spawns is the main-conversation one, `CLAUDE_CODE_PROMPT_CACHE_TTL`, which is what `cacheTtl` sets. Measured on CLI 2.1.280 by reading `usage.cache_creation` back off a real turn: the main variable moved the writes to `ephemeral_5m_input_tokens`; the subagent variable left them at 1 hour.
342
+
343
+ Like model and effort, the TTL is part of the Claude session key, so changing it respawns rather than sharing a process. Compaction is exempt.
344
+
327
345
  To force an **account** rather than a model, pin the full string. This only applies if you declared [`accounts`](#multiple-claude-code-accounts) in the first place; with the default single-account setup there is nothing to pin. Both halves are needed, because the provider selects the account's config dir and the `@account` marker is what the model was registered under for that provider:
328
346
 
329
347
  ```yaml
@@ -434,6 +452,7 @@ reaches the CLI, so there is nothing there to fall back from.
434
452
  | `skipPermissions` | boolean | `true` | Pass `--dangerously-skip-permissions` to `claude`. It is still passed when `proxyTools` is set: proxied calls go through opencode's permission system regardless, but unproxied CLI built-ins do not. The one case where the flag is dropped is `permissionMode: "plan"`, because the CLI lets the skip flag override plan mode outright. See [Plan mode](#plan-mode). |
435
453
  | `permissionMode` | `acceptEdits` \| `auto` \| `bypassPermissions` \| `default` \| `dontAsk` \| `plan` | – | Forwarded to headless `claude --permission-mode`. `"plan"` also suppresses `--dangerously-skip-permissions` (see the row above). Not version-gated, so check that your installed CLI accepts the value. The [interactive transport](#interactive-transport-experimental) does not forward it. |
436
454
  | `permissionPreset` | `"read-only"` | – | A named permission posture, so you set one option instead of combining five and getting one wrong. Opt-in: unset is exactly today's behaviour. `"read-only"` replaces `skipPermissions`, `permissionMode`, `controlRequestBehavior` and `controlRequestToolBehaviors`, and filters the write and command tools out of `proxyTools`. See [Read-only mode](#read-only-mode). |
455
+ | `defaultSubagentCacheTtl` | string | – | Prompt cache TTL (`5m` or `1h`) for plugin-discovered `mode: subagent` agents whose own definition states no `cacheTtl`. Reaches the CLI as `CLAUDE_CODE_PROMPT_CACHE_TTL`. Unset means the CLI keeps choosing, which is 1 hour on a subscription. An unrecognised value warns and changes nothing. See [The prompt cache an agent writes](#the-prompt-cache-an-agent-writes). |
437
456
  | `defaultSubagentModel` | string | – | Model that plugin-discovered `mode: subagent` agents run on when their own definition pins nothing. The caller's account is kept; only the model name changes. An agent's own `forceModel` wins over it, and an unknown id is refused rather than spawned. Unset means no implicit override at all. See [Subagents: your account, their model](#subagents-your-account-their-model). |
438
457
  | `fallbackModels` | string[] | – | Ordered models to try when the one a turn would run on is refused. The default for agents that declare no `fallbackModels` of their own; a per-agent list **replaces** this one rather than extending it. Always the same account, never a different one. Only two things arm it: the CLI refusing the model (`model_not_found`) and a usage limit on an account with no other account to offer. Each model is tried at most once per turn and an exhausted chain surfaces the original error. Unset means no chain at all. See [Fallback model chain](#fallback-model-chain). |
439
458
  | `proxyTools` | string[] | `["Bash", "Edit", "Write", "WebFetch", "Task"]` | Claude built-in tools to route through opencode's executor + permission UI. Opt-in extras: `"Question"`, `"Compress"`. See [Selective tool proxy](#selective-tool-proxy). |
@@ -766,6 +785,9 @@ sqlite3 ~/.local/share/opencode/opencode.db \
766
785
 
767
786
  - A small per-call latency hop through `127.0.0.1:<random>/mcp`.
768
787
  - Batched-edit ergonomics: with `Edit` proxied, Claude can no longer use `MultiEdit`, so a refactor that would have been one tool call becomes N single `Edit` calls.
788
+ - **One extra Claude Code API call per `claude` process**, and it is a `ToolSearch`. A proxied tool reaches the model as an MCP tool, and Claude Code 2.1.280 defers MCP tools behind its own `ToolSearch` tool, so before the first proxied call of a session the model spends one request finding the tool. Claude's built-in `Bash` is never deferred, so an unproxied tool goes straight to the call.
789
+
790
+ Measured on 2.1.280 with `claude-haiku-4-5`, three runs a side, one `echo` command: 3 CLI API calls with `Bash` proxied against 2 with the CLI running it, and roughly twice the cache reads. It is paid **once per process, not once per call**: the same task with two sequential commands measured 4 calls against 3, with a single `ToolSearch` either way. It is also not a function of how many tools you have, since a run with `strictMcpConfig: true` and 28 tools still spent it. Setting `ENABLE_TOOL_SEARCH=0` does remove it, and costs far more than it saves (all ~164 tool definitions then sit in every prompt, which measured 2.5 to 4 times the total cost and tripped a compaction), so that is not a fix and the plugin does not do it. `ToolSearch` is one of Claude's internal tools, so you never see the call, only the cost. Full numbers: `docs/agents-history.md` under `#g166`.
769
791
 
770
792
  ### How a proxied call ends
771
793
 
@@ -863,6 +885,14 @@ It carries the startup-diagnostics fields (plugin version, opencode version, `cl
863
885
  - every pending proxy call, with the tool, the call id, how long it has waited, and its deadline,
864
886
  - each proxy server's URL with one unauthenticated `initialize` posted to it: `401, good` is the patched behaviour, and anything else is flagged unsafe with the fix (restart every opencode window, since a window opened before 0.13.2 keeps serving an open port). See [Proxy endpoint security](#proxy-endpoint-security).
865
887
 
888
+ When Claude Code refused an entry in an `--mcp-config` it was handed, an **MCP config entries Claude Code skipped** section names each one with the CLI's own category and sentence. That section only appears when there is something in it. It matters because a skipped server is absent from the CLI's server list entirely rather than listed as broken, so the model silently does not have those tools; if the skipped name is `opencode_proxy` the report says so plainly, because then it is the plugin's own server and every proxied tool call in the session fails. The same thing is a warning in your terminal when it happens.
889
+
890
+ ```text
891
+ /claude-code-doctor usage
892
+ ```
893
+
894
+ adds a **Plan usage** section: the CLI's own answer to `/cost`, which is the subscription-or-API-key line, how much of the 5-hour and 7-day windows is used, when each resets, and what has been contributing to them. It is measured free (`num_turns: 0`, `$0`, no API call: the CLI answers it locally), so it costs no tokens and nothing is billed. It is opt-in anyway because reading it starts a short-lived `claude` process, which runs your `SessionStart` hooks and takes a few seconds. Without the argument the section says so and the report stays instant. A CLI that cannot answer leaves one line saying why and the rest of the report is unaffected.
895
+
866
896
  The `permissionPreset` row reads `provider: preset` for every registered provider, `none` where none is set, so two accounts configured with different postures are not collapsed into one answer. When a preset is in force, a **Permission preset overrides** block under the table lists the options it replaced, in the same words the log uses. A name the plugin does not recognise is reported as `readonly (unknown, nothing applied)` rather than shown as if it took effect: a typo'd safety option runs at full permissions, and the report is where you find that out. See [Read-only mode](#read-only-mode).
867
897
 
868
898
  Nothing secret goes in it: not the proxy bearer token, not the value of `ANTHROPIC_API_KEY`, not the system prompt, not a pending call's arguments. A `claude-code-doctor` command you defined yourself is never overwritten. The name has no space in it because opencode reads everything after the first space as the command's arguments. The whole exchange is kept out of any transcript replayed to the CLI, like a `/btw` pair.
@@ -1205,6 +1235,7 @@ The plugin respects the standard Claude Code thinking env vars. If you set them
1205
1235
  | Env var | Effect |
1206
1236
  |---|---|
1207
1237
  | `CLAUDE_CODE_EFFORT_LEVEL=<level>` | Session effort override. Passes through when no effort was requested; a variant or an agent's `reasoningEffort` replaces it for that spawn. |
1238
+ | `CLAUDE_CODE_PROMPT_CACHE_TTL=5m\|1h` | Prompt cache TTL for the whole machine. Passes through when no agent asked; an agent's `cacheTtl` or `defaultSubagentCacheTtl` replaces it for that spawn. |
1208
1239
  | `CLAUDE_CODE_DISABLE_THINKING=1` | Disable thinking entirely. |
1209
1240
  | `CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING=1` | Disable adaptive thinking only. |
1210
1241
  | `CLAUDE_CODE_SHOW_THINKING_SUMMARIES=0` | Suppress summaries (the plugin sets this to `1` by default when unset). |
package/dist/index.d.ts CHANGED
@@ -498,6 +498,14 @@ interface ClaudeCodeProviderSettings {
498
498
  * caller's model exactly as opencode intends. See `src/agent-models.ts`.
499
499
  */
500
500
  defaultSubagentModel?: string;
501
+ /**
502
+ * Prompt cache TTL (`"5m"` or `"1h"`) for subagents whose own definition
503
+ * states no `cacheTtl`. Unset (the default) means the plugin sets nothing
504
+ * and the CLI keeps choosing, which is 1 hour on a subscription. Setting
505
+ * `"5m"` is the cheaper trade for short-lived workers that never re-read
506
+ * the cache they wrote. See `resolveAgentCacheTtl` in `src/agent-models.ts`.
507
+ */
508
+ defaultSubagentCacheTtl?: string;
501
509
  /**
502
510
  * Models to try, in order, when the model a turn would have run on is
503
511
  * refused. The default for every agent that declares no `fallbackModels` of
@@ -939,6 +947,17 @@ interface ClaudeStreamMessage {
939
947
  name?: string;
940
948
  status?: string;
941
949
  }>;
950
+ /**
951
+ * `--mcp-config` entries the CLI refused to load. Omitted entirely when
952
+ * there are none, and an affected server is absent from `mcp_servers` too,
953
+ * which is why it needs its own field: a skipped server is invisible in the
954
+ * list. See `parseMcpServerErrors` in `cli-events.ts`.
955
+ */
956
+ mcp_server_errors?: Array<{
957
+ name?: string;
958
+ type?: string;
959
+ message?: string;
960
+ }>;
942
961
  compact_metadata?: Record<string, unknown>;
943
962
  compactMetadata?: Record<string, unknown>;
944
963
  rate_limit_info?: Record<string, unknown>;
@@ -1259,6 +1278,12 @@ type AgentRecord = {
1259
1278
  forceModel?: string;
1260
1279
  /** Thinking budget this agent wants, whatever the caller's picker says. */
1261
1280
  reasoningEffort?: string;
1281
+ /**
1282
+ * Prompt cache TTL for this agent's own `claude` process, `5m` or `1h`.
1283
+ * See `resolveAgentCacheTtl` for why this is a main-conversation setting
1284
+ * and not the CLI's per-agent `experimental.cacheTtl`.
1285
+ */
1286
+ cacheTtl?: string;
1262
1287
  /**
1263
1288
  * Models to try, in order, when the one this agent would have run is
1264
1289
  * refused. Same account throughout; see `src/model-fallback.ts`.
package/dist/index.js CHANGED
@@ -405,6 +405,17 @@ function titleizeAccount(account) {
405
405
  return normalizeAccountName(account).split("-").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
406
406
  }
407
407
 
408
+ // src/types.ts
409
+ var READ_ONLY_PERMISSION_MODE = "read-only";
410
+ var DEFAULT_PROXY_TOOL_NAMES = [
411
+ "Bash",
412
+ "Edit",
413
+ "Write",
414
+ "WebFetch",
415
+ "Task"
416
+ ];
417
+ var PROXY_MCP_SERVER_NAME = "opencode_proxy";
418
+
408
419
  // src/cli-events.ts
409
420
  function isRecord(value) {
410
421
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -529,6 +540,35 @@ function reportRateLimitEvent(msg) {
529
540
  log[report.level](report.message, data);
530
541
  return report.transcript;
531
542
  }
543
+ var MCP_SERVER_ERROR_TYPES = {
544
+ unknown_type: "its `type` is not one Claude Code knows",
545
+ url_missing_type: "it has a `url` but no `type`",
546
+ invalid_config: "its configuration did not validate",
547
+ reserved_name: "its name is reserved"
548
+ };
549
+ function parseMcpServerErrors(msg) {
550
+ const raw = msg.mcp_server_errors;
551
+ if (!Array.isArray(raw)) return [];
552
+ const errors = [];
553
+ for (const entry of raw) {
554
+ if (!isRecord(entry)) continue;
555
+ errors.push({
556
+ name: str(entry.name) ?? "unknown",
557
+ type: str(entry.type) ?? "unknown",
558
+ message: str(entry.message) ?? ""
559
+ });
560
+ }
561
+ return errors;
562
+ }
563
+ function describeMcpServerError(error) {
564
+ const why = MCP_SERVER_ERROR_TYPES[error.type];
565
+ const reason = why ? ` because ${why}` : "";
566
+ const detail = error.message ? ` Claude Code said: ${error.message}` : "";
567
+ if (error.name === PROXY_MCP_SERVER_NAME) {
568
+ return `Claude Code skipped the plugin's own MCP server "${error.name}" (${error.type})${reason}, so every proxied tool call this session will fail. This is a plugin bug or a corrupted scratch config rather than something in your own MCP settings: report it with this line.${detail}`;
569
+ }
570
+ return `Claude Code skipped MCP server "${error.name}" (${error.type})${reason}, so its tools are not available to the model this session. Fix the entry in your MCP config.${detail}`;
571
+ }
532
572
  var API_KEY_SOURCES = /* @__PURE__ */ new Set([
533
573
  "ANTHROPIC_API_KEY",
534
574
  "apiKeyHelper",
@@ -554,7 +594,8 @@ function parseSystemInit(msg) {
554
594
  model: str(raw.model),
555
595
  cliVersion: str(raw.claude_code_version),
556
596
  toolCount: Array.isArray(raw.tools) ? raw.tools.length : 0,
557
- mcpServers: servers
597
+ mcpServers: servers,
598
+ mcpServerErrors: parseMcpServerErrors(msg)
558
599
  };
559
600
  }
560
601
  function apiKeySourceWarning(apiKeySource, ignoreAnthropicApiKey) {
@@ -564,6 +605,11 @@ function apiKeySourceWarning(apiKeySource, ignoreAnthropicApiKey) {
564
605
  }
565
606
  var warnedApiKeySources = /* @__PURE__ */ new Set();
566
607
  var warnedMcpFailures = /* @__PURE__ */ new Set();
608
+ var warnedMcpSkips = /* @__PURE__ */ new Set();
609
+ var lastMcpServerErrors = /* @__PURE__ */ new Map();
610
+ function snapshotMcpServerErrors() {
611
+ return [...lastMcpServerErrors.values()];
612
+ }
567
613
  function reportSystemInit(msg, options = {}) {
568
614
  const info = parseSystemInit(msg);
569
615
  if (!info) return;
@@ -573,8 +619,20 @@ function reportSystemInit(msg, options = {}) {
573
619
  model: info.model ?? null,
574
620
  cliVersion: info.cliVersion ?? null,
575
621
  tools: info.toolCount,
576
- mcpServers: info.mcpServers
622
+ mcpServers: info.mcpServers,
623
+ mcpServerErrors: info.mcpServerErrors
577
624
  });
625
+ for (const error of info.mcpServerErrors) {
626
+ lastMcpServerErrors.set(error.name, error);
627
+ const key2 = `${error.name}:${error.type}`;
628
+ const message = describeMcpServerError(error);
629
+ if (warnedMcpSkips.has(key2)) {
630
+ log.debug(message, { server: error.name, type: error.type });
631
+ continue;
632
+ }
633
+ warnedMcpSkips.add(key2);
634
+ log.warn(message, { server: error.name, type: error.type });
635
+ }
578
636
  for (const server2 of info.mcpServers) {
579
637
  if (server2.status === "connected") continue;
580
638
  const key2 = `${server2.name}:${server2.status}`;
@@ -1421,7 +1479,7 @@ function isExpectedCleanupError(message) {
1421
1479
  return message.includes("timed out after") && message.includes("waiting for opencode to resolve") || message.includes("rejecting as orphaned") || message.includes("was orphaned by a new user turn") || message.includes("stream was aborted") || message.includes(SERVER_CLOSED_MESSAGE);
1422
1480
  }
1423
1481
  var PROTOCOL_VERSION = "2024-11-05";
1424
- var SERVER_NAME = "opencode_proxy";
1482
+ var SERVER_NAME = PROXY_MCP_SERVER_NAME;
1425
1483
  var PROXY_TOOL_PREFIX = `mcp__${SERVER_NAME}__`;
1426
1484
  var PROXY_DEFAULT_TIMEOUT_MS = 10 * 60 * 1e3;
1427
1485
  var PROXY_NO_DEADLINE_MS = 0;
@@ -2707,16 +2765,6 @@ function clearCompression(sessionKey2) {
2707
2765
  compressions.delete(sessionKey2);
2708
2766
  }
2709
2767
 
2710
- // src/types.ts
2711
- var READ_ONLY_PERMISSION_MODE = "read-only";
2712
- var DEFAULT_PROXY_TOOL_NAMES = [
2713
- "Bash",
2714
- "Edit",
2715
- "Write",
2716
- "WebFetch",
2717
- "Task"
2718
- ];
2719
-
2720
2768
  // src/permission-presets.ts
2721
2769
  var READ_ONLY_DISALLOWED_CLI_TOOLS = [
2722
2770
  "Bash",
@@ -3083,6 +3131,9 @@ function claudeSpawnEnv(opts) {
3083
3131
  if (opts?.effort) {
3084
3132
  env.CLAUDE_CODE_EFFORT_LEVEL = cliEffortLevel(opts.effort);
3085
3133
  }
3134
+ if (opts?.promptCacheTtl) {
3135
+ env.CLAUDE_CODE_PROMPT_CACHE_TTL = opts.promptCacheTtl;
3136
+ }
3086
3137
  if (opts?.ignoreAnthropicApiKey) {
3087
3138
  delete env.ANTHROPIC_API_KEY;
3088
3139
  delete env.ANTHROPIC_AUTH_TOKEN;
@@ -3368,19 +3419,20 @@ function invalidateOtherEffortSessions(baseKey, effort) {
3368
3419
  clearCompression(key);
3369
3420
  }
3370
3421
  }
3371
- function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcpHash, systemPromptFile, ignoreAnthropicApiKey, effort) {
3422
+ function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcpHash, systemPromptFile, ignoreAnthropicApiKey, effort, promptCacheTtl) {
3372
3423
  evictIfNeeded();
3373
3424
  log.info("spawning new claude process", {
3374
3425
  cliPath,
3375
3426
  cliArgs,
3376
3427
  cwd,
3377
3428
  sessionKey: sessionKey2,
3378
- effort
3429
+ effort,
3430
+ promptCacheTtl
3379
3431
  });
3380
3432
  const proc = spawn(cliPath, cliArgs, {
3381
3433
  cwd,
3382
3434
  stdio: ["pipe", "pipe", "pipe"],
3383
- env: claudeSpawnEnv({ ignoreAnthropicApiKey, effort }),
3435
+ env: claudeSpawnEnv({ ignoreAnthropicApiKey, effort, promptCacheTtl }),
3384
3436
  shell: process.platform === "win32"
3385
3437
  });
3386
3438
  const lineEmitter = new EventEmitter3();
@@ -3391,6 +3443,7 @@ function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcp
3391
3443
  mcpHash,
3392
3444
  systemPromptFile,
3393
3445
  effort,
3446
+ promptCacheTtl,
3394
3447
  startedAt: Date.now(),
3395
3448
  cliPath,
3396
3449
  cliArgs: [...cliArgs],
@@ -3497,7 +3550,8 @@ function respawnActiveProcess(sessionKey2, cliPath, cliArgs, cwd, ignoreAnthropi
3497
3550
  old.mcpHash,
3498
3551
  old.systemPromptFile,
3499
3552
  ignoreAnthropicApiKey,
3500
- old.effort
3553
+ old.effort,
3554
+ old.promptCacheTtl
3501
3555
  );
3502
3556
  replacement.pendingProxyCompletions = old.pendingProxyCompletions;
3503
3557
  delete old.pendingProxyCompletions;
@@ -3912,8 +3966,90 @@ async function handleBtwCommand(client, input, options = {}) {
3912
3966
  }
3913
3967
  }
3914
3968
 
3915
- // src/startup-diagnostics.ts
3969
+ // src/plan-usage.ts
3916
3970
  import { execFile as execFile2 } from "child_process";
3971
+ var PLAN_USAGE_ARGUMENTS = /* @__PURE__ */ new Set(["usage", "cost", "stats", "limits"]);
3972
+ function wantsPlanUsage(argument) {
3973
+ return PLAN_USAGE_ARGUMENTS.has(argument.trim().toLowerCase());
3974
+ }
3975
+ var PLAN_USAGE_MAX_CHARS = 4e3;
3976
+ function truncate(text) {
3977
+ return text.length <= PLAN_USAGE_MAX_CHARS ? text : `${text.slice(0, PLAN_USAGE_MAX_CHARS)}
3978
+ [truncated]`;
3979
+ }
3980
+ function parsePlanUsage(stdout) {
3981
+ const trimmed = stdout.trim();
3982
+ if (!trimmed) return { status: "failed", error: "the CLI printed nothing" };
3983
+ const candidates = trimmed.split("\n").reverse();
3984
+ for (const line of candidates) {
3985
+ const start = line.indexOf("{");
3986
+ if (start === -1) continue;
3987
+ let parsed;
3988
+ try {
3989
+ parsed = JSON.parse(line.slice(start));
3990
+ } catch {
3991
+ continue;
3992
+ }
3993
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) continue;
3994
+ const record = parsed;
3995
+ if (record.type !== void 0 && record.type !== "result") continue;
3996
+ const text = typeof record.result === "string" ? record.result.trim() : "";
3997
+ if (record.is_error === true) {
3998
+ return { status: "failed", error: text || "the CLI reported an error" };
3999
+ }
4000
+ if (!text) continue;
4001
+ return {
4002
+ status: "ok",
4003
+ text: truncate(text),
4004
+ costUsd: typeof record.total_cost_usd === "number" ? record.total_cost_usd : 0,
4005
+ numTurns: typeof record.num_turns === "number" ? record.num_turns : 0
4006
+ };
4007
+ }
4008
+ return { status: "failed", error: "no result object in the CLI's reply" };
4009
+ }
4010
+ function runCli(cliPath, args, timeoutMs, env) {
4011
+ return new Promise((resolve6, reject) => {
4012
+ execFile2(
4013
+ cliPath,
4014
+ args,
4015
+ // `killSignal` so a CLI wedged on a hook is actually gone, and a generous
4016
+ // buffer because the reply is prose of unbounded length.
4017
+ { timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 4 * 1024 * 1024, env },
4018
+ (error, stdout) => {
4019
+ if (stdout && stdout.trim()) return resolve6(stdout);
4020
+ if (error) return reject(error);
4021
+ resolve6(stdout ?? "");
4022
+ }
4023
+ );
4024
+ });
4025
+ }
4026
+ async function fetchPlanUsage(cliPath, options = {}) {
4027
+ const timeoutMs = options.timeoutMs ?? 2e4;
4028
+ const run = options.runImpl ?? runCli;
4029
+ try {
4030
+ const env = claudeSpawnEnv({ ignoreAnthropicApiKey: options.ignoreAnthropicApiKey });
4031
+ const stdout = await run(
4032
+ cliPath,
4033
+ ["-p", "/cost", "--output-format", "json"],
4034
+ timeoutMs,
4035
+ env
4036
+ );
4037
+ const usage = parsePlanUsage(stdout);
4038
+ if (usage.status === "ok") {
4039
+ log.info("read claude plan usage", { costUsd: usage.costUsd, numTurns: usage.numTurns });
4040
+ } else if (usage.status === "failed") {
4041
+ log.debug("could not read claude plan usage", { error: usage.error });
4042
+ }
4043
+ return usage;
4044
+ } catch (error) {
4045
+ const message = error instanceof Error ? error.message : String(error);
4046
+ log.debug("could not read claude plan usage", { error: message });
4047
+ return { status: "failed", error: message };
4048
+ }
4049
+ }
4050
+
4051
+ // src/startup-diagnostics.ts
4052
+ import { execFile as execFile3 } from "child_process";
3917
4053
  import * as fs4 from "fs";
3918
4054
  import * as path5 from "path";
3919
4055
  import { promisify as promisify2 } from "util";
@@ -4448,7 +4584,7 @@ function pickOpencodeVersion(input) {
4448
4584
  if (typeof direct === "string" && direct.length > 0) return direct;
4449
4585
  return void 0;
4450
4586
  }
4451
- var execFileAsync2 = promisify2(execFile2);
4587
+ var execFileAsync2 = promisify2(execFile3);
4452
4588
  var opencodeVersionProbe;
4453
4589
  function detectOpencodeVersion(execPath = process.execPath) {
4454
4590
  if (opencodeVersionProbe) return opencodeVersionProbe;
@@ -4558,7 +4694,7 @@ function logStartupDiagnostics(providers, opencodeVersion) {
4558
4694
 
4559
4695
  // src/doctor.ts
4560
4696
  var DOCTOR_COMMAND = "claude-code-doctor";
4561
- var DOCTOR_COMMAND_DESCRIPTION = "Report what the Claude Code plugin sees: versions, cwd, live processes, pending proxy calls";
4697
+ var DOCTOR_COMMAND_DESCRIPTION = "Report what the Claude Code plugin sees: versions, cwd, live processes, pending proxy calls. Add `usage` for plan windows";
4562
4698
  var DOCTOR_MARKER = "\u258C **claude-code doctor**";
4563
4699
  function isRecord3(value) {
4564
4700
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -4688,6 +4824,36 @@ function formatDoctorReport(report) {
4688
4824
  lines.push(`| ${server2.url} | ${describeAuth(server2.auth)} |`);
4689
4825
  }
4690
4826
  }
4827
+ if (report.mcpServerErrors.length > 0) {
4828
+ lines.push("");
4829
+ lines.push("**MCP config entries Claude Code skipped**");
4830
+ lines.push("");
4831
+ lines.push("| server | category | Claude Code said |");
4832
+ lines.push("|---|---|---|");
4833
+ for (const error of report.mcpServerErrors) {
4834
+ lines.push(`| ${error.name} | \`${error.type}\` | ${error.message || "no detail"} |`);
4835
+ }
4836
+ lines.push("");
4837
+ lines.push("A skipped server is missing from the model's tools with no other sign of it.");
4838
+ }
4839
+ lines.push("");
4840
+ lines.push("**Plan usage**");
4841
+ lines.push("");
4842
+ switch (report.planUsage.status) {
4843
+ case "ok":
4844
+ lines.push("```text");
4845
+ lines.push(report.planUsage.text);
4846
+ lines.push("```");
4847
+ break;
4848
+ case "failed":
4849
+ lines.push(`Could not read it from the CLI: ${report.planUsage.error}`);
4850
+ break;
4851
+ case "not-requested":
4852
+ lines.push(
4853
+ "Not checked. Run `/claude-code-doctor usage` for the account's plan windows and reset times, straight from the CLI. It costs no tokens, but it does start a `claude` process, so it runs your `SessionStart` hooks and takes a few seconds."
4854
+ );
4855
+ break;
4856
+ }
4691
4857
  const stderr = report.processes.filter((proc) => proc.lastStderr);
4692
4858
  if (stderr.length > 0) {
4693
4859
  lines.push("");
@@ -4747,6 +4913,9 @@ async function gatherDoctorReport(options) {
4747
4913
  auth: await checkProxyAuth(proc.proxyUrl, options.fetchImpl ?? fetch)
4748
4914
  });
4749
4915
  }
4916
+ const planUsage = wantsPlanUsage(options.argument ?? "") ? await (options.planUsageImpl ?? fetchPlanUsage)(cliPath, {
4917
+ ignoreAnthropicApiKey: options.ignoreAnthropicApiKey
4918
+ }) : { status: "not-requested" };
4750
4919
  return {
4751
4920
  plugin: base.plugin,
4752
4921
  opencode: base.opencode,
@@ -4763,7 +4932,9 @@ async function gatherDoctorReport(options) {
4763
4932
  anthropicApiKeyInEnv: base.anthropicApiKeyInEnv,
4764
4933
  processes,
4765
4934
  pendingCalls: snapshotPendingProxyCalls(),
4766
- proxyServers
4935
+ proxyServers,
4936
+ mcpServerErrors: snapshotMcpServerErrors(),
4937
+ planUsage
4767
4938
  };
4768
4939
  }
4769
4940
  async function buildDoctorReport(options) {
@@ -4775,7 +4946,9 @@ async function buildDoctorReport(options) {
4775
4946
  cwd: report.cwd,
4776
4947
  processes: report.processes.length,
4777
4948
  pendingCalls: report.pendingCalls.length,
4778
- proxyServers: report.proxyServers.map((server2) => server2.auth.status)
4949
+ proxyServers: report.proxyServers.map((server2) => server2.auth.status),
4950
+ mcpServerErrors: report.mcpServerErrors.length,
4951
+ planUsage: report.planUsage.status
4779
4952
  });
4780
4953
  return formatDoctorReport(report);
4781
4954
  } catch (error) {
@@ -5108,6 +5281,7 @@ function parseModelId(modelId) {
5108
5281
 
5109
5282
  // src/agent-models.ts
5110
5283
  var AGENT_DIR_NAMES = ["agents", "agent"];
5284
+ var PROMPT_CACHE_TTLS = ["5m", "1h"];
5111
5285
  var REASONING_EFFORTS = [
5112
5286
  "minimal",
5113
5287
  "low",
@@ -5118,6 +5292,7 @@ var REASONING_EFFORTS = [
5118
5292
  ];
5119
5293
  var registry = {};
5120
5294
  var defaultSubagentModel;
5295
+ var defaultSubagentCacheTtl;
5121
5296
  var providerFallbackModels = [];
5122
5297
  function setAgentRegistry(records) {
5123
5298
  registry = records;
@@ -5131,6 +5306,9 @@ function setDefaultSubagentModel(model) {
5131
5306
  function getDefaultSubagentModel() {
5132
5307
  return defaultSubagentModel;
5133
5308
  }
5309
+ function setDefaultSubagentCacheTtl(ttl) {
5310
+ defaultSubagentCacheTtl = ttl?.trim() || void 0;
5311
+ }
5134
5312
  function setProviderFallbackModels(models) {
5135
5313
  providerFallbackModels = models ?? [];
5136
5314
  }
@@ -5195,6 +5373,25 @@ function resolveAgentEffort(agent, inherited, overrides) {
5195
5373
  }
5196
5374
  return declared;
5197
5375
  }
5376
+ function resolveAgentCacheTtl(agent, overrides) {
5377
+ if (!agent) return void 0;
5378
+ const record = (overrides?.records ?? registry)[agent];
5379
+ if (!record) return void 0;
5380
+ const fallback = overrides ? overrides.defaultSubagentCacheTtl : defaultSubagentCacheTtl;
5381
+ const declared = record.cacheTtl?.trim();
5382
+ const wanted = declared || (record.mode === "subagent" ? fallback?.trim() : void 0);
5383
+ if (!wanted) return void 0;
5384
+ if (!PROMPT_CACHE_TTLS.includes(wanted)) {
5385
+ log.warn("agent prompt cache ttl refused: unknown value", {
5386
+ agent,
5387
+ wanted,
5388
+ allowed: PROMPT_CACHE_TTLS.join(", ")
5389
+ });
5390
+ return void 0;
5391
+ }
5392
+ log.debug("agent prompt cache ttl", { agent, ttl: wanted });
5393
+ return wanted;
5394
+ }
5198
5395
  function parseAgentFrontmatter(text) {
5199
5396
  const record = {};
5200
5397
  if (!text.startsWith("---")) return record;
@@ -5226,6 +5423,7 @@ function parseAgentFrontmatter(text) {
5226
5423
  else if (key === "model") record.model = value;
5227
5424
  else if (key === "forceModel") record.forceModel = value;
5228
5425
  else if (key === "reasoningEffort") record.reasoningEffort = value;
5426
+ else if (key === "cacheTtl") record.cacheTtl = value;
5229
5427
  }
5230
5428
  return record;
5231
5429
  }
@@ -9354,9 +9552,15 @@ var ClaudeCodeLanguageModel = class {
9354
9552
  this.getOpencodeAgent(options),
9355
9553
  this.getReasoningEffort(options.providerOptions)
9356
9554
  );
9555
+ const promptCacheTtl = compactionMode ? void 0 : resolveAgentCacheTtl(this.getOpencodeAgent(options));
9556
+ const context = [
9557
+ this.config.provider,
9558
+ this.getOpencodeAgent(options) ?? null
9559
+ ];
9560
+ if (promptCacheTtl) context.push(promptCacheTtl);
9357
9561
  const baseKey = sessionKey(
9358
9562
  cwd,
9359
- `${effectiveModelId}::${scope}::${affinity}::context=${JSON.stringify([this.config.provider, this.getOpencodeAgent(options) ?? null])}`
9563
+ `${effectiveModelId}::${scope}::${affinity}::context=${JSON.stringify(context)}`
9360
9564
  );
9361
9565
  const sk = compactionMode ? sessionKey(cwd, `${effectiveModelId}::compaction::${affinity}`) : effortSessionKey(baseKey, reasoningEffort);
9362
9566
  const toUsage2 = this.toUsage.bind(this);
@@ -9384,7 +9588,11 @@ var ClaudeCodeLanguageModel = class {
9384
9588
  const doctorOptions = {
9385
9589
  cliPath,
9386
9590
  interactive: !!useInteractive,
9387
- turnStats: this.config.turnStats === true
9591
+ turnStats: this.config.turnStats === true,
9592
+ ignoreAnthropicApiKey: this.config.ignoreAnthropicApiKey === true,
9593
+ // `/claude-code-doctor usage` opts into the CLI's own plan-usage
9594
+ // report; anything else here is ignored, as it always has been.
9595
+ argument: doctor.rest
9388
9596
  };
9389
9597
  const stream2 = new ReadableStream({
9390
9598
  async start(controller) {
@@ -9944,7 +10152,8 @@ var ClaudeCodeLanguageModel = class {
9944
10152
  spawnMcpHash,
9945
10153
  spawnSystemPromptFile,
9946
10154
  self.config.ignoreAnthropicApiKey,
9947
- reasoningEffort
10155
+ reasoningEffort,
10156
+ promptCacheTtl
9948
10157
  );
9949
10158
  state.proc = ap.proc;
9950
10159
  state.lineEmitter = ap.lineEmitter;
@@ -11352,6 +11561,10 @@ async function buildAgentRegistry(config) {
11352
11561
  setDefaultSubagentModel(
11353
11562
  typeof configured === "string" ? configured : void 0
11354
11563
  );
11564
+ const configuredTtl = options?.defaultSubagentCacheTtl;
11565
+ setDefaultSubagentCacheTtl(
11566
+ typeof configuredTtl === "string" ? configuredTtl : void 0
11567
+ );
11355
11568
  setProviderFallbackModels(parseFallbackModelList(options?.fallbackModels));
11356
11569
  const records = await readAgentMarkdownRecords(
11357
11570
  agentDirectories(
@@ -11373,6 +11586,7 @@ async function buildAgentRegistry(config) {
11373
11586
  model: pick("model") ?? records[name]?.model,
11374
11587
  forceModel: pick("forceModel") ?? records[name]?.forceModel,
11375
11588
  reasoningEffort: pick("reasoningEffort") ?? records[name]?.reasoningEffort,
11589
+ cacheTtl: pick("cacheTtl") ?? records[name]?.cacheTtl,
11376
11590
  fallbackModels: declaredChain.length ? declaredChain : records[name]?.fallbackModels
11377
11591
  };
11378
11592
  }