@khalilgharbaoui/opencode-claude-code-plugin 0.20.0 → 0.21.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 +55 -5
- package/dist/index.d.ts +47 -0
- package/dist/index.js +181 -14
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/claude-code-plugin/SKILL.md +33 -3
package/README.md
CHANGED
|
@@ -297,7 +297,9 @@ model: claude-code-work/claude-opus-5@work
|
|
|
297
297
|
| `mcpConfig` | string \| string[] | – | Extra `--mcp-config` paths/JSON passed alongside the bridged config. |
|
|
298
298
|
| `strictMcpConfig` | boolean | `false` | Pass `--strict-mcp-config` so Claude loads **only** the configured servers and ignores `~/.claude/settings.json`. |
|
|
299
299
|
| `hotReloadMcp` | boolean | `true` | With MCP bridging on, compare the merged MCP config and runtime status at the start of each turn and respawn the `claude` process when they drifted, so a server you just enabled or disabled becomes visible without restarting opencode or opening a new chat. Eviction waits for pending proxy calls, never happening mid tool-call, and the session id is preserved for `--resume`. Set `false` to keep a cached subprocess until the chat is reset. It does not reload other provider options and does not watch the contents of files named in `mcpConfig`. |
|
|
300
|
-
| `proxyOpencodeMcpTools` | boolean | `true` |
|
|
300
|
+
| `proxyOpencodeMcpTools` | boolean | `true` | **Currently has no effect on opencode 1.18.31: its discovery step finds nothing to route.** Measured on 2026-09-19 with five MCP servers connected, opencode's tool registry returned only built-in and plugin-declared tools, so the server-name match this option depends on matches nothing. Your MCP servers still work, through the direct `--mcp-config` bridge; what is lost is only the routing of those calls through opencode's permissions and rendering. Left on by default because it is harmless and will resume working if the registry regains them. To forward a specific non-MCP opencode tool meanwhile, use [`proxyOpencodeTools`](#options-reference). Original intent: route the MCP tools discovered from opencode through the in-process `opencode_proxy` server instead of bridging them straight into Claude's `--mcp-config`. With both layers pointed at the same server, direct bridging executes every call twice, once in Claude's own MCP child process and once in opencode; proxying keeps opencode as the single execution site while preserving its permission prompts and tool rows. Falls back to direct bridging when discovery is unavailable, so do not treat it as an exactly-once guarantee for write-capable tools. |
|
|
301
|
+
| `proxyOpencodeTools` | string[] | `[]` | Forward named opencode tools through the proxy by their registry id, for tools another opencode plugin declares directly and that therefore belong to no MCP server (opencode-dcp's `compress`). Explicit allowlist; a forwarded tool runs inside opencode with the calling agent's permissions. A name already held by a proxy def is dropped with a warning rather than taking it over. See [Forwarding opencode's own tools](#forwarding-opencode-s-own-tools). |
|
|
302
|
+
| `stripContextReminders` | boolean | `false` | Remove opencode-dcp's `<dcp-system-reminder>` blocks from message text when no `compress` tool is proxied, so an order the model cannot follow stops being re-sent with every message that carries it. Inert as soon as `compress` is reachable. See [Trimming unsatisfiable context reminders](#trimming-unsatisfiable-context-reminders). |
|
|
301
303
|
| `webSearch` | `"claude"` \| `"disabled"` \| `<tool>` | `"claude"` | Routing for Claude's built-in `WebSearch`. See [WebSearch routing](#websearch-routing). |
|
|
302
304
|
| `multiStepContinuation` | boolean | `true` | Append a system-prompt hint nudging Claude to chain tool calls within one turn instead of pausing between subtasks. Each opencode turn boundary requires the user to manually press "continue", so for multi-step tasks this reduces friction. Set `false` to disable. |
|
|
303
305
|
| `autoContinueIncompleteTurns` | boolean \| `"smart"` | `"smart"` | Smartly continue incomplete Claude CLI results inside the same opencode turn. Reduces manual "continue" presses when Claude ends after reasoning/tool activity without a useful final answer. Set `false` to disable. |
|
|
@@ -490,7 +492,7 @@ It is the one proxy tool opencode never sees. The call is answered inside the pl
|
|
|
490
492
|
|
|
491
493
|
Without it, the appended system prompt tells the model that `compress` is unavailable and to ignore instructions that ask for it, which is the right answer when nothing implements it.
|
|
492
494
|
|
|
493
|
-
The
|
|
495
|
+
The round trip is verified live (Claude Code 2.1.263, opencode 1.18.31, haiku): the model called `mcp__opencode_proxy__compress` with a build identifier in its summary, the plugin logged `compress stored summary; session resets next turn`, the next turn logged `compress reset: dropped claude process and session id` and spawned a second `claude`, and that fresh process answered with the identifier it could only have read from the summary in its system prompt.
|
|
494
496
|
|
|
495
497
|
Only those seven values are actually proxied; anything else you put in `proxyTools` is ignored. Proxying `Edit` also disables `MultiEdit` — opencode has no batched-edit equivalent, so Claude is forced to fan out into single `Edit` calls that each flow through the permission UI. The `"Question"` proxy is version-gated on opencode's built-in `question` tool: on builds that lack the registry entry the def is silently dropped (a forwarded call would otherwise render as `⚙ invalid`), so add it only on opencode versions that ship the `question` tool.
|
|
496
498
|
|
|
@@ -500,6 +502,45 @@ Without `"Task"` in `proxyTools`, Claude's built-in `Agent` tool stays enabled a
|
|
|
500
502
|
"options": { "proxyTools": [] }
|
|
501
503
|
```
|
|
502
504
|
|
|
505
|
+
### Forwarding opencode's own tools
|
|
506
|
+
|
|
507
|
+
`proxyTools` names the tools this plugin ships defs for, and MCP-backed opencode tools are routed automatically ([`proxyOpencodeMcpTools`](#options-reference)). Neither covers a tool that **another opencode plugin declares directly**: it belongs to no MCP server, so the automatic match (`<server>` or `<server>_<tool>`) skips it and the model is never offered it. opencode-dcp's `compress` is the case that matters in practice, because DCP then injects "MAX CONTEXT LIMIT REACHED ... You MUST use the `compress` tool now" reminders that the model has no way to act on.
|
|
508
|
+
|
|
509
|
+
`proxyOpencodeTools` is the explicit allowlist. Empty by default:
|
|
510
|
+
|
|
511
|
+
```json
|
|
512
|
+
"options": {
|
|
513
|
+
"proxyTools": ["Bash", "Edit", "Write", "WebFetch", "Task"],
|
|
514
|
+
"proxyOpencodeTools": ["compress"]
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Names are opencode's tool ids as `client.tool.list()` reports them, matched case-insensitively. An unknown name is skipped with a warning rather than failing the spawn. Forwarded tools use the same broker as every other proxy tool, so [how a proxied call ends](#how-a-proxied-call-ends) applies to them unchanged: abort, orphan sweep, session deletion and child exit all release them.
|
|
519
|
+
|
|
520
|
+
This is deliberately not automatic. A forwarded tool executes inside opencode with the calling agent's permissions, so which ones cross over is your decision, not the plugin's.
|
|
521
|
+
|
|
522
|
+
**The `compress` name collision.** Two different tools want it: DCP's, which rewrites opencode's transcript, and [this plugin's](#context-compression), which resets the Claude Code session. They compress different windows, and after a DCP compress the live `claude` process still holds its full context until something restarts it. If you enable both, the plugin's own tool keeps the name and the forwarded one is dropped with a warning in the log:
|
|
523
|
+
|
|
524
|
+
```
|
|
525
|
+
WARN: proxyOpencodeTools entry dropped: a proxy tool already holds that name, and it keeps it {"collided":["compress"]}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
Pick one. The appended system prompt describes whichever is actually reachable, so the model is told the right semantics either way.
|
|
529
|
+
|
|
530
|
+
Verified live on Claude Code 2.1.263 and opencode 1.18.31 with DCP loaded: the plugin logged `forwarding opencode tools through the proxy {"tools":["compress"]}`, started the proxy with `tools: ["bash","compress"]`, received `proxy-mcp tool call received {"toolName":"compress"}`, queued it through the normal broker, and DCP really ran, returning `Compressed 3 messages into [Compressed conversation section]`. One wrinkle worth knowing: DCP's compress rewrites opencode's message history mid-turn, which makes opencode abort the provider stream at that tool boundary. The pending call is released normally and the result still reaches the model on the next step as text, so the turn completes, but you will see one `abort between proxy tool boundaries` line in the log each time.
|
|
531
|
+
|
|
532
|
+
### Trimming unsatisfiable context reminders
|
|
533
|
+
|
|
534
|
+
DCP anchors its nudges into message text as `<dcp-system-reminder>` blocks, so each one is re-sent with every message that carries it. If no `compress` tool is reachable they are an order the model cannot follow, and the plugin already tells it to ignore them. `stripContextReminders: true` stops paying for them too:
|
|
535
|
+
|
|
536
|
+
```json
|
|
537
|
+
"options": { "stripContextReminders": true }
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Off by default. It removes those blocks from user and assistant text before the transcript reaches the CLI, including the fresh-session rebuild, where every anchored reminder would otherwise replay at once. It leaves opencode's own `<system-reminder>` blocks alone: those are opencode's instructions to the model, not an unsatisfiable order.
|
|
541
|
+
|
|
542
|
+
It switches itself off whenever `compress` is named in `proxyTools` or `proxyOpencodeTools`, since the reminder is then something the model can act on. The check is on configuration, so a name that is configured but missing from opencode's registry still counts as reachable and nothing is stripped, which errs toward keeping the reminder.
|
|
543
|
+
|
|
503
544
|
### Subagent todos
|
|
504
545
|
|
|
505
546
|
When Claude works through a multi-step task it emits `TaskCreate` / `TaskUpdate` calls. The plugin translates those into opencode's full-list `todowrite` so the todo panel populates. Inside a **subagent** that translation is blocked unless you say otherwise: opencode's task tool injects `todowrite: false` into the tools dict for any subagent without an explicit rule, so the plugin's synthetic emissions surface as `⚙ invalid todowrite` rows instead of todos. The built-in `general` subagent denies it by default.
|
|
@@ -568,6 +609,8 @@ A proxied call ends when something happens to it, not when a clock runs out. The
|
|
|
568
609
|
|
|
569
610
|
Because every ending is observed rather than inferred from elapsed time, a `task` can run until it is finished: **`task` and `task_batch` have no deadline by default**. Earlier flat ceilings fired mid-subagent, Claude believed its dispatch had failed, and the eventual result was dropped because the parent turn had already ended on the timeout error; a 60-minute one did the same to anything longer. What the default gives up is only that nothing fires on the clock alone, so a chat parked in a `task` holds its `claude` worker until one of the events above happens. That is the operator's decision to make, so no timer makes it for them.
|
|
570
611
|
|
|
612
|
+
So that a call with no deadline is never silent, the plugin says it is still waiting. Five minutes in, and every five minutes after, a call without a deadline logs a warning naming the tool, the call id, how long it has waited, and what will end it. It never ends the call, it only reports one, which is the whole point: the thing a deadline used to provide was visibility, not correctness, and visibility is what is kept. Calls that do have a deadline are not reported this way, because their deadline already does it. The line reaches your terminal (warnings always go to stderr), so a subagent that has genuinely wedged shows up on its own instead of waiting to be noticed. `/claude-code-doctor` lists the same calls on demand.
|
|
613
|
+
|
|
571
614
|
The same events are also what let a legitimately long call complete, which is the second half of the story: the CLI's own HTTP client used to give up on a silent reply at about five minutes whatever the tool deadline said. Every held call therefore keeps its connection visibly alive. A client that advertises SSE gets immediate headers and a keepalive comment every 15 seconds (since 0.15.0); a client that only accepts JSON gets its headers immediately as well, as a chunked body carrying keepalive whitespace on the same cadence, which is still one valid JSON-RPC response when the result lands, on success and on error. Keepalives are about the connection, not the tool: they never extend or replace a deadline. Claude's MCP client timeout for the proxy server, written into the generated `--mcp-config`, is set to the largest effective deadline, and to the largest value the CLI accepts (Node's timer maximum, about 24.8 days) while any tool has no deadline, because the CLI rejects a `timeout` of `0` outright.
|
|
572
615
|
|
|
573
616
|
### Per-tool proxy timeouts
|
|
@@ -1053,10 +1096,17 @@ Partial support since v0.5.1. DCP runs in a useful degraded mode: its automatic
|
|
|
1053
1096
|
| `experimental.chat.system.transform` (context-limit nudges, iteration reminders) | ✅ Works in headless | Headless spawns forward system-role content via `--append-system-prompt-file`. Interactive mode intentionally omits opencode's forwarded system prompt and keeps only this plugin's CLI/AGENTS/continuation prompt. |
|
|
1054
1097
|
| `/dcp compress`, `/dcp sweep`, `/dcp manual`, `/dcp context`, `/dcp stats` slash commands | ✅ Works | Handled by opencode's `command.execute.before` hook, not the model. |
|
|
1055
1098
|
| Automatic `deduplication` + `purgeErrors` strategies | ✅ Works | Message-transform only, no model tool calls. |
|
|
1056
|
-
| DCP's own autonomous `compress` / `distill` / `prune` tool calls |
|
|
1099
|
+
| DCP's own autonomous `compress` / `distill` / `prune` tool calls | ⚠️ Opt-in | DCP registers those as opencode-native tools rather than through an MCP server, so the automatic MCP routing never saw them. Name one in [`proxyOpencodeTools`](#forwarding-opencode-s-own-tools) and it is forwarded: `proxyOpencodeTools: ["compress"]` makes `mcp__opencode_proxy__compress` run DCP's real tool. |
|
|
1057
1100
|
| Model-driven compression through this plugin's `compress` proxy | ⚠️ Opt-in | Add `"Compress"` to `proxyTools` and the plugin exposes `mcp__opencode_proxy__compress`, which gives the model a working way to compress its own context. It is not DCP's tool and does not use DCP's strategies. See [Context compression](#context-compression). |
|
|
1101
|
+
| DCP's `<dcp-system-reminder>` context-limit nudges when no compress tool is reachable | ⚠️ Opt-in strip | Those reminders are anchored into messages, so each one is re-sent with every message that carries it. If you run without either compress route, `stripContextReminders: true` removes them. It turns itself off as soon as a `compress` tool is proxied. |
|
|
1102
|
+
|
|
1103
|
+
So autonomous compression is available, and DCP's own implementation is now one of the options. Three routes, all opt-in:
|
|
1104
|
+
|
|
1105
|
+
- `proxyOpencodeTools: ["compress"]` forwards **DCP's** tool, which compresses opencode's transcript using DCP's strategies.
|
|
1106
|
+
- `proxyTools: [..., "Compress"]` exposes **this plugin's** tool, which resets the Claude Code session and carries a summary into the fresh one.
|
|
1107
|
+
- Neither, and trigger DCP by hand with `/dcp compress`.
|
|
1058
1108
|
|
|
1059
|
-
|
|
1109
|
+
The two compress different windows, so pick deliberately rather than enabling both; [Forwarding opencode's own tools](#forwarding-opencode-s-own-tools) explains what happens if you do. With neither enabled, the plugin's appended system prompt tells Claude that no such tool exists and to ignore instructions asking for it, which is the correct answer in that case.
|
|
1060
1110
|
|
|
1061
1111
|
---
|
|
1062
1112
|
|
|
@@ -1065,7 +1115,7 @@ So autonomous compression is available, just not DCP's implementation of it. Two
|
|
|
1065
1115
|
- Tool inputs stream as they are constructed (Anthropic's `input_json_delta` is forwarded as `tool-input-delta`), but only for tool calls opencode actually sees. Calls the plugin deliberately does not forward, meaning proxy tools, CLI-internal `WebSearch`, `AskUserQuestion`, `ExitPlanMode`, the todo-ledger `Task*` family and Claude's other internal tools, have their deltas suppressed, because a delta for a tool opencode never saw start renders as a permanently pending `⚙ unknown` row.
|
|
1066
1116
|
- Raw chain-of-thought is not available. Claude 4 family models ship summarized thinking only. See [Extended thinking](#extended-thinking) for the full picture.
|
|
1067
1117
|
- Recommended Claude Code CLI: **2.1.142+**. Older CLIs work for everything else but skip the `--thinking-display` flag, so Claude Opus 4.7 turns may render empty Thinking rows. If something breaks after a Claude Code update, the CLI version is the first thing to check.
|
|
1068
|
-
- **Foreground Task calls have no proxy deadline by default.** The plugin listens for the events that end a call instead of timing it (see [How a proxied call ends](#how-a-proxied-call-ends)), so a subagent runs to completion and a chat parked in one holds its `claude` worker until you abort, send another message, delete the chat, or the process goes away. Add a wall-clock backstop via [`proxyToolTimeoutMs`](#per-tool-proxy-timeouts) if you want one. For independent work that should not block the turn at all, use `background: true` after enabling opencode's experimental background-subagent flag.
|
|
1118
|
+
- **Foreground Task calls have no proxy deadline by default.** The plugin listens for the events that end a call instead of timing it (see [How a proxied call ends](#how-a-proxied-call-ends)), so a subagent runs to completion and a chat parked in one holds its `claude` worker until you abort, send another message, delete the chat, or the process goes away. Such a call warns that it is still waiting after five minutes and every five minutes after, so it is never silent. Add a wall-clock backstop via [`proxyToolTimeoutMs`](#per-tool-proxy-timeouts) if you want one. For independent work that should not block the turn at all, use `background: true` after enabling opencode's experimental background-subagent flag.
|
|
1069
1119
|
- **Subagent todos require explicit permission.** See [Subagent todos](#subagent-todos) for the rule and a working config.
|
|
1070
1120
|
|
|
1071
1121
|
---
|
package/dist/index.d.ts
CHANGED
|
@@ -181,6 +181,8 @@ interface ClaudeCodeConfig {
|
|
|
181
181
|
controlRequestToolBehaviors?: Record<string, ControlRequestBehavior>;
|
|
182
182
|
controlRequestDenyMessage?: string;
|
|
183
183
|
proxyTools?: string[];
|
|
184
|
+
proxyOpencodeTools?: string[];
|
|
185
|
+
stripContextReminders?: boolean;
|
|
184
186
|
extraDisallowedTools?: string[];
|
|
185
187
|
proxyToolTimeoutMs?: Record<string, number>;
|
|
186
188
|
/**
|
|
@@ -316,6 +318,45 @@ interface ClaudeCodeProviderSettings {
|
|
|
316
318
|
* entry, in which case the deny/markdown fallback applies.
|
|
317
319
|
*/
|
|
318
320
|
proxyTools?: string[];
|
|
321
|
+
/**
|
|
322
|
+
* opencode tools to forward through the proxy by name, on top of the
|
|
323
|
+
* built-in `proxyTools` defs. Empty by default.
|
|
324
|
+
*
|
|
325
|
+
* MCP-backed opencode tools are already routed automatically (see
|
|
326
|
+
* `proxyOpencodeMcpTools`), but that match is `<server>` or
|
|
327
|
+
* `<server>_<tool>`, so a tool another opencode plugin declares directly
|
|
328
|
+
* belongs to no server and is never offered to Claude. opencode-dcp's
|
|
329
|
+
* `compress` is the motivating case: dcp injects "MAX CONTEXT LIMIT
|
|
330
|
+
* REACHED ... You MUST use the `compress` tool now" reminders that the
|
|
331
|
+
* model could not act on, because the tool was never in its list.
|
|
332
|
+
*
|
|
333
|
+
* Names are opencode's tool ids as `client.tool.list()` reports them
|
|
334
|
+
* (matched case-insensitively): `["compress"]`. An unknown name is
|
|
335
|
+
* skipped with a warning. This is an explicit allowlist and never
|
|
336
|
+
* automatic: a forwarded tool executes inside opencode with the calling
|
|
337
|
+
* agent's permissions.
|
|
338
|
+
*
|
|
339
|
+
* A name already held by a proxy def is NOT taken over. Listing
|
|
340
|
+
* `"compress"` here while `proxyTools` also contains `"Compress"` leaves
|
|
341
|
+
* the plugin's own in-process compress in charge and drops the forwarded
|
|
342
|
+
* one with a warning, because the two do different things: the plugin's
|
|
343
|
+
* resets the Claude Code session, opencode's compresses opencode's
|
|
344
|
+
* transcript. Pick one.
|
|
345
|
+
*/
|
|
346
|
+
proxyOpencodeTools?: string[];
|
|
347
|
+
/**
|
|
348
|
+
* Remove `<dcp-system-reminder>` blocks from message text when no
|
|
349
|
+
* `compress` tool is being proxied. Off by default.
|
|
350
|
+
*
|
|
351
|
+
* opencode-dcp anchors those reminders into messages, so they are re-sent
|
|
352
|
+
* with every message that carries one. When compress is not reachable
|
|
353
|
+
* they are an instruction the model cannot follow, and the plugin already
|
|
354
|
+
* tells it to ignore them in the appended system prompt. Turning this on
|
|
355
|
+
* stops paying for them as well. It is inert whenever `compress` is
|
|
356
|
+
* proxied (via either `proxyTools` or `proxyOpencodeTools`), since the
|
|
357
|
+
* reminder is then something the model can actually act on.
|
|
358
|
+
*/
|
|
359
|
+
stripContextReminders?: boolean;
|
|
319
360
|
/**
|
|
320
361
|
* Extra Claude Code built-ins to switch off with `--disallowedTools`,
|
|
321
362
|
* on top of the ones implied by `proxyTools`.
|
|
@@ -680,6 +721,12 @@ declare class ClaudeCodeLanguageModel implements LanguageModelV3 {
|
|
|
680
721
|
* answered": only the former is a real version-gate signal.
|
|
681
722
|
*/
|
|
682
723
|
private fetchLiveToolInfo;
|
|
724
|
+
/**
|
|
725
|
+
* Whether dcp-style context reminders should be stripped from this turn's
|
|
726
|
+
* messages. Config-only and synchronous, so it can be answered before the
|
|
727
|
+
* spawn block resolves anything: `userMsg` is built well ahead of it.
|
|
728
|
+
*/
|
|
729
|
+
private stripContextRemindersEnabled;
|
|
683
730
|
/** Share one lazy registry request within a turn without making it stale. */
|
|
684
731
|
private createLiveToolInfoLoader;
|
|
685
732
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1248,6 +1248,61 @@ function resolveDisallowedTools(options) {
|
|
|
1248
1248
|
if (options.disableWebSearch) push("WebSearch");
|
|
1249
1249
|
return out;
|
|
1250
1250
|
}
|
|
1251
|
+
function resolveProxyOpencodeToolDefs(options) {
|
|
1252
|
+
const requested = options.requested ?? [];
|
|
1253
|
+
if (requested.length === 0) return [];
|
|
1254
|
+
const items = options.items;
|
|
1255
|
+
if (!items) {
|
|
1256
|
+
log.warn(
|
|
1257
|
+
"proxyOpencodeTools is set but opencode's tool registry did not answer; forwarding nothing this spawn",
|
|
1258
|
+
{ requested: requested.map(String) }
|
|
1259
|
+
);
|
|
1260
|
+
return [];
|
|
1261
|
+
}
|
|
1262
|
+
const byLowerId = /* @__PURE__ */ new Map();
|
|
1263
|
+
for (const item of items) {
|
|
1264
|
+
const key = item.id.toLowerCase();
|
|
1265
|
+
if (!byLowerId.has(key)) byLowerId.set(key, item);
|
|
1266
|
+
}
|
|
1267
|
+
const taken = options.taken ?? /* @__PURE__ */ new Set();
|
|
1268
|
+
const out = [];
|
|
1269
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1270
|
+
const unknown = [];
|
|
1271
|
+
const collided = [];
|
|
1272
|
+
for (const raw of requested) {
|
|
1273
|
+
const name = String(raw).trim();
|
|
1274
|
+
if (!name) continue;
|
|
1275
|
+
const item = byLowerId.get(name.toLowerCase());
|
|
1276
|
+
if (!item) {
|
|
1277
|
+
unknown.push(name);
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1280
|
+
if (taken.has(item.id)) {
|
|
1281
|
+
collided.push(item.id);
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
if (seen.has(item.id)) continue;
|
|
1285
|
+
seen.add(item.id);
|
|
1286
|
+
out.push({
|
|
1287
|
+
name: item.id,
|
|
1288
|
+
description: typeof item.description === "string" ? item.description : "",
|
|
1289
|
+
inputSchema: item.parameters && typeof item.parameters === "object" ? item.parameters : { type: "object", properties: {} }
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
if (unknown.length > 0) {
|
|
1293
|
+
log.warn("ignoring unknown proxyOpencodeTools entries", {
|
|
1294
|
+
unknown,
|
|
1295
|
+
known: [...byLowerId.values()].map((item) => item.id).join(", ")
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
if (collided.length > 0) {
|
|
1299
|
+
log.warn(
|
|
1300
|
+
"proxyOpencodeTools entry dropped: a proxy tool already holds that name, and it keeps it",
|
|
1301
|
+
{ collided }
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
return out;
|
|
1305
|
+
}
|
|
1251
1306
|
function readBody(req) {
|
|
1252
1307
|
return new Promise((resolve5, reject) => {
|
|
1253
1308
|
const chunks = [];
|
|
@@ -1336,6 +1391,11 @@ function writeJson(res, body) {
|
|
|
1336
1391
|
|
|
1337
1392
|
// src/proxy-broker.ts
|
|
1338
1393
|
import { EventEmitter as EventEmitter2 } from "events";
|
|
1394
|
+
var PROXY_STALL_WARNING_MS = 5 * 6e4;
|
|
1395
|
+
function clearPendingTimers(pending) {
|
|
1396
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
1397
|
+
if (pending.stallTimer) clearInterval(pending.stallTimer);
|
|
1398
|
+
}
|
|
1339
1399
|
var pendingByCallId = /* @__PURE__ */ new Map();
|
|
1340
1400
|
var callIdsBySession = /* @__PURE__ */ new Map();
|
|
1341
1401
|
var emitter = new EventEmitter2();
|
|
@@ -1361,10 +1421,10 @@ function onPendingProxyCall(sessionKey2, handler) {
|
|
|
1361
1421
|
emitter.on(name, handler);
|
|
1362
1422
|
return () => emitter.off(name, handler);
|
|
1363
1423
|
}
|
|
1364
|
-
function queuePendingProxyCall(sessionKey2, call, timeoutOverrides) {
|
|
1424
|
+
function queuePendingProxyCall(sessionKey2, call, timeoutOverrides, stallWarningMs = PROXY_STALL_WARNING_MS) {
|
|
1365
1425
|
const previous = pendingByCallId.get(call.id);
|
|
1366
1426
|
if (previous) {
|
|
1367
|
-
|
|
1427
|
+
clearPendingTimers(previous);
|
|
1368
1428
|
previous.reject(
|
|
1369
1429
|
new Error(`Replaced pending proxy call ${call.id} with a fresh one`)
|
|
1370
1430
|
);
|
|
@@ -1381,6 +1441,7 @@ function queuePendingProxyCall(sessionKey2, call, timeoutOverrides) {
|
|
|
1381
1441
|
if (!current) return;
|
|
1382
1442
|
pendingByCallId.delete(call.id);
|
|
1383
1443
|
indexRemove(current.sessionKey, call.id);
|
|
1444
|
+
clearPendingTimers(current);
|
|
1384
1445
|
current.reject(buildProxyTimeoutError(call.toolName, deadlineMs));
|
|
1385
1446
|
log.notice("timed out pending proxy call", {
|
|
1386
1447
|
sessionKey: current.sessionKey,
|
|
@@ -1389,6 +1450,20 @@ function queuePendingProxyCall(sessionKey2, call, timeoutOverrides) {
|
|
|
1389
1450
|
deadlineMs
|
|
1390
1451
|
});
|
|
1391
1452
|
}, deadlineMs) : null;
|
|
1453
|
+
const stallTimer = deadlineMs === PROXY_NO_DEADLINE_MS && stallWarningMs > 0 ? setInterval(() => {
|
|
1454
|
+
const current = pendingByCallId.get(call.id);
|
|
1455
|
+
if (!current) return;
|
|
1456
|
+
log.warn("proxy call still waiting, no deadline", {
|
|
1457
|
+
sessionKey: current.sessionKey,
|
|
1458
|
+
toolCallId: current.toolCallId,
|
|
1459
|
+
toolName: current.toolName,
|
|
1460
|
+
waitedMs: Date.now() - current.createdAt,
|
|
1461
|
+
emitted: current.emitted === true,
|
|
1462
|
+
channelClosed: current.channel?.closed === true,
|
|
1463
|
+
note: "nothing will time this out; it ends when opencode returns a result, you abort, you send another message, or the claude process goes"
|
|
1464
|
+
});
|
|
1465
|
+
}, stallWarningMs) : null;
|
|
1466
|
+
stallTimer?.unref?.();
|
|
1392
1467
|
const pending = {
|
|
1393
1468
|
sessionKey: sessionKey2,
|
|
1394
1469
|
toolCallId: call.id,
|
|
@@ -1398,6 +1473,7 @@ function queuePendingProxyCall(sessionKey2, call, timeoutOverrides) {
|
|
|
1398
1473
|
createdAt: Date.now(),
|
|
1399
1474
|
deadlineMs,
|
|
1400
1475
|
timer,
|
|
1476
|
+
stallTimer,
|
|
1401
1477
|
resolve: call.resolve,
|
|
1402
1478
|
reject: call.reject
|
|
1403
1479
|
};
|
|
@@ -1448,7 +1524,7 @@ function resolvePendingProxyCallById(toolCallId, result) {
|
|
|
1448
1524
|
if (!pending) return false;
|
|
1449
1525
|
pendingByCallId.delete(toolCallId);
|
|
1450
1526
|
indexRemove(pending.sessionKey, toolCallId);
|
|
1451
|
-
|
|
1527
|
+
clearPendingTimers(pending);
|
|
1452
1528
|
pending.resolve(result);
|
|
1453
1529
|
log.info("resolved pending proxy call", {
|
|
1454
1530
|
sessionKey: pending.sessionKey,
|
|
@@ -1462,7 +1538,7 @@ function rejectPendingProxyCallById(toolCallId, error) {
|
|
|
1462
1538
|
if (!pending) return false;
|
|
1463
1539
|
pendingByCallId.delete(toolCallId);
|
|
1464
1540
|
indexRemove(pending.sessionKey, toolCallId);
|
|
1465
|
-
|
|
1541
|
+
clearPendingTimers(pending);
|
|
1466
1542
|
pending.reject(error);
|
|
1467
1543
|
log.notice("rejected pending proxy call", {
|
|
1468
1544
|
sessionKey: pending.sessionKey,
|
|
@@ -3840,6 +3916,35 @@ function filterSideQuestionHistory(prompt) {
|
|
|
3840
3916
|
(message) => message.role === "assistant" ? { ...message, content: stripPluginNotes(message.content) } : message
|
|
3841
3917
|
);
|
|
3842
3918
|
}
|
|
3919
|
+
var DCP_REMINDER_BLOCK = /<dcp-system-reminder\b[^>]*>[\s\S]*?<\/dcp-system-reminder>/gi;
|
|
3920
|
+
function stripContextReminderBlocks(text) {
|
|
3921
|
+
if (!text.includes("<dcp-system-reminder")) return text;
|
|
3922
|
+
return text.replace(DCP_REMINDER_BLOCK, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
3923
|
+
}
|
|
3924
|
+
function shouldStripContextReminders(options) {
|
|
3925
|
+
if (options.enabled !== true) return false;
|
|
3926
|
+
const namesCompress = (list2) => (list2 ?? []).some((name) => String(name).trim().toLowerCase() === "compress");
|
|
3927
|
+
return !namesCompress(options.proxyTools) && !namesCompress(options.proxyOpencodeTools);
|
|
3928
|
+
}
|
|
3929
|
+
function stripContextReminders(prompt) {
|
|
3930
|
+
let removed = 0;
|
|
3931
|
+
const countIn = (text) => (text.match(DCP_REMINDER_BLOCK) ?? []).length;
|
|
3932
|
+
const out = prompt.map((message) => {
|
|
3933
|
+
if (message.role !== "user" && message.role !== "assistant") return message;
|
|
3934
|
+
if (!Array.isArray(message.content)) return message;
|
|
3935
|
+
let touched = false;
|
|
3936
|
+
const parts = message.content.map((part) => {
|
|
3937
|
+
if (!part || part.type !== "text" || typeof part.text !== "string") return part;
|
|
3938
|
+
const hits = countIn(part.text);
|
|
3939
|
+
if (hits === 0) return part;
|
|
3940
|
+
removed += hits;
|
|
3941
|
+
touched = true;
|
|
3942
|
+
return { ...part, text: stripContextReminderBlocks(part.text) };
|
|
3943
|
+
});
|
|
3944
|
+
return touched ? { ...message, content: parts } : message;
|
|
3945
|
+
});
|
|
3946
|
+
return removed > 0 ? { prompt: out, removed } : { prompt, removed: 0 };
|
|
3947
|
+
}
|
|
3843
3948
|
var SUPPORTED_IMAGE_TYPES = /* @__PURE__ */ new Set([
|
|
3844
3949
|
"image/jpeg",
|
|
3845
3950
|
"image/png",
|
|
@@ -4039,6 +4144,15 @@ function getClaudeUserMessage(prompt, includeHistoryContext = false, opts = {})
|
|
|
4039
4144
|
const compactionMode = opts.compactionMode === true;
|
|
4040
4145
|
const cliToolCallIds = opts.cliToolCallIds;
|
|
4041
4146
|
const content = [];
|
|
4147
|
+
if (opts.stripContextReminders) {
|
|
4148
|
+
const stripped = stripContextReminders(prompt);
|
|
4149
|
+
if (stripped.removed > 0) {
|
|
4150
|
+
log.info("stripped unsatisfiable context reminders", {
|
|
4151
|
+
blocks: stripped.removed
|
|
4152
|
+
});
|
|
4153
|
+
prompt = stripped.prompt;
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4042
4156
|
const pushToolResult = (part) => {
|
|
4043
4157
|
const id = part.toolCallId;
|
|
4044
4158
|
const text = getToolResultText(part);
|
|
@@ -5592,6 +5706,14 @@ You are running via the Claude Code CLI (not a direct API call). This affects co
|
|
|
5592
5706
|
- Everything outside the summary is gone after the reset \u2014 tool output, files you read, and the earlier conversation are not replayed. Write the summary as the authoritative record.
|
|
5593
5707
|
- The \`distill\`, \`prune\`, and \`extract\` tools are NOT available.
|
|
5594
5708
|
- DCP context injections (AGENTS.md, dynamic state) arrive via the system prompt and are already applied.`;
|
|
5709
|
+
var CLAUDE_CLI_OPENCODE_COMPRESS_NOTE = `## Runtime environment: Claude Code CLI
|
|
5710
|
+
|
|
5711
|
+
You are running via the Claude Code CLI (not a direct API call). This affects context management:
|
|
5712
|
+
|
|
5713
|
+
- To compress context, call \`mcp__opencode_proxy__compress\`. Use that exact full name. It runs opencode's own \`compress\` tool, which is what a "MAX CONTEXT LIMIT REACHED" reminder is asking you to do.
|
|
5714
|
+
- It compresses opencode's stored conversation, NOT this Claude Code session. Your current session keeps the context it already has, so do not assume earlier detail is gone after the call.
|
|
5715
|
+
- The \`distill\`, \`prune\`, and \`extract\` tools are NOT available.
|
|
5716
|
+
- DCP context injections (AGENTS.md, dynamic state) arrive via the system prompt and are already applied.`;
|
|
5595
5717
|
function extractSystemMessages(prompt) {
|
|
5596
5718
|
const out = [];
|
|
5597
5719
|
for (const msg of prompt) {
|
|
@@ -5618,7 +5740,7 @@ ${options.compressionSummary.trim()}`
|
|
|
5618
5740
|
);
|
|
5619
5741
|
}
|
|
5620
5742
|
parts.push(
|
|
5621
|
-
options.compressEnabled ? CLAUDE_CLI_COMPRESS_NOTE : CLAUDE_CLI_CONTEXT_NOTE
|
|
5743
|
+
options.compressEnabled ? CLAUDE_CLI_COMPRESS_NOTE : options.opencodeCompressEnabled ? CLAUDE_CLI_OPENCODE_COMPRESS_NOTE : CLAUDE_CLI_CONTEXT_NOTE
|
|
5622
5744
|
);
|
|
5623
5745
|
for (const s of extraSystemContent) {
|
|
5624
5746
|
if (s.trim()) parts.push(s.trim());
|
|
@@ -5865,9 +5987,22 @@ var ClaudeCodeLanguageModel = class {
|
|
|
5865
5987
|
resolved: items !== void 0,
|
|
5866
5988
|
taskDescription: items?.find((item) => item.id === "task")?.description,
|
|
5867
5989
|
questionDescription: question?.description,
|
|
5868
|
-
hasQuestion: !!question
|
|
5990
|
+
hasQuestion: !!question,
|
|
5991
|
+
items
|
|
5869
5992
|
};
|
|
5870
5993
|
}
|
|
5994
|
+
/**
|
|
5995
|
+
* Whether dcp-style context reminders should be stripped from this turn's
|
|
5996
|
+
* messages. Config-only and synchronous, so it can be answered before the
|
|
5997
|
+
* spawn block resolves anything: `userMsg` is built well ahead of it.
|
|
5998
|
+
*/
|
|
5999
|
+
stripContextRemindersEnabled() {
|
|
6000
|
+
return shouldStripContextReminders({
|
|
6001
|
+
enabled: this.config.stripContextReminders,
|
|
6002
|
+
proxyTools: this.config.proxyTools,
|
|
6003
|
+
proxyOpencodeTools: this.config.proxyOpencodeTools
|
|
6004
|
+
});
|
|
6005
|
+
}
|
|
5871
6006
|
/** Share one lazy registry request within a turn without making it stale. */
|
|
5872
6007
|
createLiveToolInfoLoader() {
|
|
5873
6008
|
let pending;
|
|
@@ -5903,10 +6038,10 @@ var ClaudeCodeLanguageModel = class {
|
|
|
5903
6038
|
* Create a proxy MCP server for a single active Claude process/session.
|
|
5904
6039
|
* The process lifecycle owns the server lifecycle via session-manager.
|
|
5905
6040
|
*/
|
|
5906
|
-
async ensureProxyServer(tools, sessionKeyForCalls) {
|
|
6041
|
+
async ensureProxyServer(tools, sessionKeyForCalls, interceptCompress) {
|
|
5907
6042
|
const timeoutOverrides = this.config.proxyToolTimeoutMs;
|
|
5908
6043
|
const interceptors = /* @__PURE__ */ new Map();
|
|
5909
|
-
if (tools.some((t) => t.name === "compress")) {
|
|
6044
|
+
if (interceptCompress && tools.some((t) => t.name === "compress")) {
|
|
5910
6045
|
interceptors.set("compress", (input) => {
|
|
5911
6046
|
const summary = typeof input.summary === "string" ? input.summary.trim() : "";
|
|
5912
6047
|
if (!summary) {
|
|
@@ -6347,7 +6482,8 @@ var ClaudeCodeLanguageModel = class {
|
|
|
6347
6482
|
// at all: every tool result reaching it belongs to opencode and must be
|
|
6348
6483
|
// rendered as text rather than an orphaned `tool_result` (issue #29).
|
|
6349
6484
|
getClaudeUserMessage(options.prompt, includeHistoryContext, {
|
|
6350
|
-
cliToolCallIds: /* @__PURE__ */ new Set()
|
|
6485
|
+
cliToolCallIds: /* @__PURE__ */ new Set(),
|
|
6486
|
+
stripContextReminders: this.stripContextRemindersEnabled()
|
|
6351
6487
|
});
|
|
6352
6488
|
const [runtimeStatus, cliVersion, planModeQuestionActive] = await Promise.all([
|
|
6353
6489
|
getRuntimeMcpStatus(),
|
|
@@ -6866,7 +7002,8 @@ ${plan}
|
|
|
6866
7002
|
const previousPendingProxyCalls = compactionMode ? [] : getPendingProxyCalls(sk);
|
|
6867
7003
|
const userMsg = exitPlanModeQuestionResult ?? getClaudeUserMessage(options.prompt, includeHistoryContext, {
|
|
6868
7004
|
compactionMode,
|
|
6869
|
-
cliToolCallIds: new Set(previousPendingProxyCalls.map((c) => c.toolCallId))
|
|
7005
|
+
cliToolCallIds: new Set(previousPendingProxyCalls.map((c) => c.toolCallId)),
|
|
7006
|
+
stripContextReminders: this.stripContextRemindersEnabled()
|
|
6870
7007
|
});
|
|
6871
7008
|
const resolvedProxy = compactionMode ? null : this.resolvedProxyTools();
|
|
6872
7009
|
const loadLiveToolInfo = this.createLiveToolInfoLoader();
|
|
@@ -7034,7 +7171,12 @@ ${plan}
|
|
|
7034
7171
|
const excludeServers = proxyMcpTools ? new Set(discovery.allEnabledServerNames) : void 0;
|
|
7035
7172
|
const taskProxyEnabled = resolvedProxy?.some((t) => t.name === "task") ?? false;
|
|
7036
7173
|
const questionProxyEnabled = resolvedProxy?.some((t) => t.name === "question") ?? false;
|
|
7037
|
-
const
|
|
7174
|
+
const opencodeToolsRequested = (self.config.proxyOpencodeTools?.length ?? 0) > 0;
|
|
7175
|
+
log.debug("opencode tool forwarding gate", {
|
|
7176
|
+
requested: self.config.proxyOpencodeTools ?? null,
|
|
7177
|
+
opencodeToolsRequested
|
|
7178
|
+
});
|
|
7179
|
+
const liveToolInfo = taskProxyEnabled || questionProxyEnabled || opencodeToolsRequested ? await loadLiveToolInfo() : {
|
|
7038
7180
|
resolved: false,
|
|
7039
7181
|
taskDescription: void 0,
|
|
7040
7182
|
questionDescription: void 0,
|
|
@@ -7070,13 +7212,33 @@ ${plan}
|
|
|
7070
7212
|
kept: liveToolInfo.hasQuestion
|
|
7071
7213
|
});
|
|
7072
7214
|
}
|
|
7215
|
+
const opencodeToolDefs = resolveProxyOpencodeToolDefs({
|
|
7216
|
+
requested: self.config.proxyOpencodeTools,
|
|
7217
|
+
items: liveToolInfo.items,
|
|
7218
|
+
taken: new Set(
|
|
7219
|
+
[...enrichedProxy ?? [], ...proxyMcpTools ?? []].map(
|
|
7220
|
+
(t) => t.name
|
|
7221
|
+
)
|
|
7222
|
+
)
|
|
7223
|
+
});
|
|
7224
|
+
if (opencodeToolDefs.length > 0) {
|
|
7225
|
+
log.info("forwarding opencode tools through the proxy", {
|
|
7226
|
+
tools: opencodeToolDefs.map((t) => t.name)
|
|
7227
|
+
});
|
|
7228
|
+
}
|
|
7073
7229
|
const combinedList = [
|
|
7074
7230
|
...enrichedProxy ?? [],
|
|
7075
|
-
...proxyMcpTools ?? []
|
|
7231
|
+
...proxyMcpTools ?? [],
|
|
7232
|
+
...opencodeToolDefs
|
|
7076
7233
|
];
|
|
7077
7234
|
const combinedProxyTools = combinedList.length > 0 ? combinedList : null;
|
|
7235
|
+
const pluginCompressEnabled = enrichedProxy?.some((t) => t.name === "compress") ?? false;
|
|
7078
7236
|
if (!proxyServer && combinedProxyTools) {
|
|
7079
|
-
proxyServer = await self.ensureProxyServer(
|
|
7237
|
+
proxyServer = await self.ensureProxyServer(
|
|
7238
|
+
combinedProxyTools,
|
|
7239
|
+
sk,
|
|
7240
|
+
pluginCompressEnabled
|
|
7241
|
+
);
|
|
7080
7242
|
}
|
|
7081
7243
|
const questionProxyActive = enrichedProxy?.some((t) => t.name === "question") ?? false;
|
|
7082
7244
|
const allDisallowed = resolveDisallowedTools({
|
|
@@ -7099,7 +7261,10 @@ ${plan}
|
|
|
7099
7261
|
...questionProxyActive ? [QUESTION_PROXY_HINT] : []
|
|
7100
7262
|
],
|
|
7101
7263
|
{
|
|
7102
|
-
compressEnabled:
|
|
7264
|
+
compressEnabled: pluginCompressEnabled,
|
|
7265
|
+
opencodeCompressEnabled: opencodeToolDefs.some(
|
|
7266
|
+
(t) => t.name === "compress"
|
|
7267
|
+
),
|
|
7103
7268
|
compressionSummary: getCompressionSummary(sk)
|
|
7104
7269
|
}
|
|
7105
7270
|
);
|
|
@@ -8923,6 +9088,8 @@ function createClaudeCode(settings = {}) {
|
|
|
8923
9088
|
controlRequestToolBehaviors: settings.controlRequestToolBehaviors,
|
|
8924
9089
|
controlRequestDenyMessage: settings.controlRequestDenyMessage,
|
|
8925
9090
|
proxyTools,
|
|
9091
|
+
proxyOpencodeTools: settings.proxyOpencodeTools,
|
|
9092
|
+
stripContextReminders: settings.stripContextReminders === true,
|
|
8926
9093
|
extraDisallowedTools: settings.extraDisallowedTools,
|
|
8927
9094
|
proxyToolTimeoutMs: settings.proxyToolTimeoutMs,
|
|
8928
9095
|
planModeQuestion: settings.planModeQuestion ?? false,
|