@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 +31 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +239 -25
- 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}`;
|
|
@@ -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 =
|
|
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/
|
|
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(
|
|
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(
|
|
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
|
}
|