@khalilgharbaoui/opencode-claude-code-plugin 0.29.1 → 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}`;
@@ -786,12 +844,13 @@ function unwrapToolOutput(part) {
786
844
  }
787
845
  }
788
846
  function unwrapOpencodeQuestionResult(value, question) {
789
- if (!value.startsWith(OPENCODE_QUESTION_RESULT_PREFIX) || !value.endsWith(OPENCODE_QUESTION_RESULT_SUFFIX)) {
847
+ const sentence = value.trim();
848
+ if (!sentence.startsWith(OPENCODE_QUESTION_RESULT_PREFIX) || !sentence.endsWith(OPENCODE_QUESTION_RESULT_SUFFIX)) {
790
849
  return value;
791
850
  }
792
- const body = value.slice(
851
+ const body = sentence.slice(
793
852
  OPENCODE_QUESTION_RESULT_PREFIX.length,
794
- value.length - OPENCODE_QUESTION_RESULT_SUFFIX.length
853
+ sentence.length - OPENCODE_QUESTION_RESULT_SUFFIX.length
795
854
  );
796
855
  if (!body.startsWith('"') || !body.endsWith('"')) return value;
797
856
  let answer;
@@ -1420,7 +1479,7 @@ function isExpectedCleanupError(message) {
1420
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);
1421
1480
  }
1422
1481
  var PROTOCOL_VERSION = "2024-11-05";
1423
- var SERVER_NAME = "opencode_proxy";
1482
+ var SERVER_NAME = PROXY_MCP_SERVER_NAME;
1424
1483
  var PROXY_TOOL_PREFIX = `mcp__${SERVER_NAME}__`;
1425
1484
  var PROXY_DEFAULT_TIMEOUT_MS = 10 * 60 * 1e3;
1426
1485
  var PROXY_NO_DEADLINE_MS = 0;
@@ -2706,16 +2765,6 @@ function clearCompression(sessionKey2) {
2706
2765
  compressions.delete(sessionKey2);
2707
2766
  }
2708
2767
 
2709
- // src/types.ts
2710
- var READ_ONLY_PERMISSION_MODE = "read-only";
2711
- var DEFAULT_PROXY_TOOL_NAMES = [
2712
- "Bash",
2713
- "Edit",
2714
- "Write",
2715
- "WebFetch",
2716
- "Task"
2717
- ];
2718
-
2719
2768
  // src/permission-presets.ts
2720
2769
  var READ_ONLY_DISALLOWED_CLI_TOOLS = [
2721
2770
  "Bash",
@@ -3082,6 +3131,9 @@ function claudeSpawnEnv(opts) {
3082
3131
  if (opts?.effort) {
3083
3132
  env.CLAUDE_CODE_EFFORT_LEVEL = cliEffortLevel(opts.effort);
3084
3133
  }
3134
+ if (opts?.promptCacheTtl) {
3135
+ env.CLAUDE_CODE_PROMPT_CACHE_TTL = opts.promptCacheTtl;
3136
+ }
3085
3137
  if (opts?.ignoreAnthropicApiKey) {
3086
3138
  delete env.ANTHROPIC_API_KEY;
3087
3139
  delete env.ANTHROPIC_AUTH_TOKEN;
@@ -3367,19 +3419,20 @@ function invalidateOtherEffortSessions(baseKey, effort) {
3367
3419
  clearCompression(key);
3368
3420
  }
3369
3421
  }
3370
- function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcpHash, systemPromptFile, ignoreAnthropicApiKey, effort) {
3422
+ function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcpHash, systemPromptFile, ignoreAnthropicApiKey, effort, promptCacheTtl) {
3371
3423
  evictIfNeeded();
3372
3424
  log.info("spawning new claude process", {
3373
3425
  cliPath,
3374
3426
  cliArgs,
3375
3427
  cwd,
3376
3428
  sessionKey: sessionKey2,
3377
- effort
3429
+ effort,
3430
+ promptCacheTtl
3378
3431
  });
3379
3432
  const proc = spawn(cliPath, cliArgs, {
3380
3433
  cwd,
3381
3434
  stdio: ["pipe", "pipe", "pipe"],
3382
- env: claudeSpawnEnv({ ignoreAnthropicApiKey, effort }),
3435
+ env: claudeSpawnEnv({ ignoreAnthropicApiKey, effort, promptCacheTtl }),
3383
3436
  shell: process.platform === "win32"
3384
3437
  });
3385
3438
  const lineEmitter = new EventEmitter3();
@@ -3390,6 +3443,7 @@ function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcp
3390
3443
  mcpHash,
3391
3444
  systemPromptFile,
3392
3445
  effort,
3446
+ promptCacheTtl,
3393
3447
  startedAt: Date.now(),
3394
3448
  cliPath,
3395
3449
  cliArgs: [...cliArgs],
@@ -3496,7 +3550,8 @@ function respawnActiveProcess(sessionKey2, cliPath, cliArgs, cwd, ignoreAnthropi
3496
3550
  old.mcpHash,
3497
3551
  old.systemPromptFile,
3498
3552
  ignoreAnthropicApiKey,
3499
- old.effort
3553
+ old.effort,
3554
+ old.promptCacheTtl
3500
3555
  );
3501
3556
  replacement.pendingProxyCompletions = old.pendingProxyCompletions;
3502
3557
  delete old.pendingProxyCompletions;
@@ -3911,8 +3966,90 @@ async function handleBtwCommand(client, input, options = {}) {
3911
3966
  }
3912
3967
  }
3913
3968
 
3914
- // src/startup-diagnostics.ts
3969
+ // src/plan-usage.ts
3915
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";
3916
4053
  import * as fs4 from "fs";
3917
4054
  import * as path5 from "path";
3918
4055
  import { promisify as promisify2 } from "util";
@@ -4447,7 +4584,7 @@ function pickOpencodeVersion(input) {
4447
4584
  if (typeof direct === "string" && direct.length > 0) return direct;
4448
4585
  return void 0;
4449
4586
  }
4450
- var execFileAsync2 = promisify2(execFile2);
4587
+ var execFileAsync2 = promisify2(execFile3);
4451
4588
  var opencodeVersionProbe;
4452
4589
  function detectOpencodeVersion(execPath = process.execPath) {
4453
4590
  if (opencodeVersionProbe) return opencodeVersionProbe;
@@ -4557,7 +4694,7 @@ function logStartupDiagnostics(providers, opencodeVersion) {
4557
4694
 
4558
4695
  // src/doctor.ts
4559
4696
  var DOCTOR_COMMAND = "claude-code-doctor";
4560
- 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";
4561
4698
  var DOCTOR_MARKER = "\u258C **claude-code doctor**";
4562
4699
  function isRecord3(value) {
4563
4700
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -4687,6 +4824,36 @@ function formatDoctorReport(report) {
4687
4824
  lines.push(`| ${server2.url} | ${describeAuth(server2.auth)} |`);
4688
4825
  }
4689
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
+ }
4690
4857
  const stderr = report.processes.filter((proc) => proc.lastStderr);
4691
4858
  if (stderr.length > 0) {
4692
4859
  lines.push("");
@@ -4746,6 +4913,9 @@ async function gatherDoctorReport(options) {
4746
4913
  auth: await checkProxyAuth(proc.proxyUrl, options.fetchImpl ?? fetch)
4747
4914
  });
4748
4915
  }
4916
+ const planUsage = wantsPlanUsage(options.argument ?? "") ? await (options.planUsageImpl ?? fetchPlanUsage)(cliPath, {
4917
+ ignoreAnthropicApiKey: options.ignoreAnthropicApiKey
4918
+ }) : { status: "not-requested" };
4749
4919
  return {
4750
4920
  plugin: base.plugin,
4751
4921
  opencode: base.opencode,
@@ -4762,7 +4932,9 @@ async function gatherDoctorReport(options) {
4762
4932
  anthropicApiKeyInEnv: base.anthropicApiKeyInEnv,
4763
4933
  processes,
4764
4934
  pendingCalls: snapshotPendingProxyCalls(),
4765
- proxyServers
4935
+ proxyServers,
4936
+ mcpServerErrors: snapshotMcpServerErrors(),
4937
+ planUsage
4766
4938
  };
4767
4939
  }
4768
4940
  async function buildDoctorReport(options) {
@@ -4774,7 +4946,9 @@ async function buildDoctorReport(options) {
4774
4946
  cwd: report.cwd,
4775
4947
  processes: report.processes.length,
4776
4948
  pendingCalls: report.pendingCalls.length,
4777
- 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
4778
4952
  });
4779
4953
  return formatDoctorReport(report);
4780
4954
  } catch (error) {
@@ -5107,6 +5281,7 @@ function parseModelId(modelId) {
5107
5281
 
5108
5282
  // src/agent-models.ts
5109
5283
  var AGENT_DIR_NAMES = ["agents", "agent"];
5284
+ var PROMPT_CACHE_TTLS = ["5m", "1h"];
5110
5285
  var REASONING_EFFORTS = [
5111
5286
  "minimal",
5112
5287
  "low",
@@ -5117,6 +5292,7 @@ var REASONING_EFFORTS = [
5117
5292
  ];
5118
5293
  var registry = {};
5119
5294
  var defaultSubagentModel;
5295
+ var defaultSubagentCacheTtl;
5120
5296
  var providerFallbackModels = [];
5121
5297
  function setAgentRegistry(records) {
5122
5298
  registry = records;
@@ -5130,6 +5306,9 @@ function setDefaultSubagentModel(model) {
5130
5306
  function getDefaultSubagentModel() {
5131
5307
  return defaultSubagentModel;
5132
5308
  }
5309
+ function setDefaultSubagentCacheTtl(ttl) {
5310
+ defaultSubagentCacheTtl = ttl?.trim() || void 0;
5311
+ }
5133
5312
  function setProviderFallbackModels(models) {
5134
5313
  providerFallbackModels = models ?? [];
5135
5314
  }
@@ -5194,6 +5373,25 @@ function resolveAgentEffort(agent, inherited, overrides) {
5194
5373
  }
5195
5374
  return declared;
5196
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
+ }
5197
5395
  function parseAgentFrontmatter(text) {
5198
5396
  const record = {};
5199
5397
  if (!text.startsWith("---")) return record;
@@ -5225,6 +5423,7 @@ function parseAgentFrontmatter(text) {
5225
5423
  else if (key === "model") record.model = value;
5226
5424
  else if (key === "forceModel") record.forceModel = value;
5227
5425
  else if (key === "reasoningEffort") record.reasoningEffort = value;
5426
+ else if (key === "cacheTtl") record.cacheTtl = value;
5228
5427
  }
5229
5428
  return record;
5230
5429
  }
@@ -9353,9 +9552,15 @@ var ClaudeCodeLanguageModel = class {
9353
9552
  this.getOpencodeAgent(options),
9354
9553
  this.getReasoningEffort(options.providerOptions)
9355
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);
9356
9561
  const baseKey = sessionKey(
9357
9562
  cwd,
9358
- `${effectiveModelId}::${scope}::${affinity}::context=${JSON.stringify([this.config.provider, this.getOpencodeAgent(options) ?? null])}`
9563
+ `${effectiveModelId}::${scope}::${affinity}::context=${JSON.stringify(context)}`
9359
9564
  );
9360
9565
  const sk = compactionMode ? sessionKey(cwd, `${effectiveModelId}::compaction::${affinity}`) : effortSessionKey(baseKey, reasoningEffort);
9361
9566
  const toUsage2 = this.toUsage.bind(this);
@@ -9383,7 +9588,11 @@ var ClaudeCodeLanguageModel = class {
9383
9588
  const doctorOptions = {
9384
9589
  cliPath,
9385
9590
  interactive: !!useInteractive,
9386
- 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
9387
9596
  };
9388
9597
  const stream2 = new ReadableStream({
9389
9598
  async start(controller) {
@@ -9943,7 +10152,8 @@ var ClaudeCodeLanguageModel = class {
9943
10152
  spawnMcpHash,
9944
10153
  spawnSystemPromptFile,
9945
10154
  self.config.ignoreAnthropicApiKey,
9946
- reasoningEffort
10155
+ reasoningEffort,
10156
+ promptCacheTtl
9947
10157
  );
9948
10158
  state.proc = ap.proc;
9949
10159
  state.lineEmitter = ap.lineEmitter;
@@ -11351,6 +11561,10 @@ async function buildAgentRegistry(config) {
11351
11561
  setDefaultSubagentModel(
11352
11562
  typeof configured === "string" ? configured : void 0
11353
11563
  );
11564
+ const configuredTtl = options?.defaultSubagentCacheTtl;
11565
+ setDefaultSubagentCacheTtl(
11566
+ typeof configuredTtl === "string" ? configuredTtl : void 0
11567
+ );
11354
11568
  setProviderFallbackModels(parseFallbackModelList(options?.fallbackModels));
11355
11569
  const records = await readAgentMarkdownRecords(
11356
11570
  agentDirectories(
@@ -11372,6 +11586,7 @@ async function buildAgentRegistry(config) {
11372
11586
  model: pick("model") ?? records[name]?.model,
11373
11587
  forceModel: pick("forceModel") ?? records[name]?.forceModel,
11374
11588
  reasoningEffort: pick("reasoningEffort") ?? records[name]?.reasoningEffort,
11589
+ cacheTtl: pick("cacheTtl") ?? records[name]?.cacheTtl,
11375
11590
  fallbackModels: declaredChain.length ? declaredChain : records[name]?.fallbackModels
11376
11591
  };
11377
11592
  }