@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 +31 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +243 -28
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/claude-code-plugin/SKILL.md +50 -6
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
|
-
|
|
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 =
|
|
851
|
+
const body = sentence.slice(
|
|
793
852
|
OPENCODE_QUESTION_RESULT_PREFIX.length,
|
|
794
|
-
|
|
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 =
|
|
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/
|
|
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(
|
|
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(
|
|
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
|
}
|