@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 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` | 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. |
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 store, the interceptor and the two prompt variants are covered by tests, but the full "model calls compress, the next turn really is a fresh process carrying only the summary" round-trip has not been verified against a live CLI. Treat it as working-but-unproven and check the plugin log the first time you rely on it.
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 | ❌ Not supported | DCP registers those as opencode-native tools. Claude CLI only ever sees its own built-ins and MCP-bridged servers, so the model never sees them. |
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
- So autonomous compression is available, just not DCP's implementation of it. Two routes: add `"Compress"` to `proxyTools` so the model can compress its own context through this plugin, or leave it off and trigger DCP manually with `/dcp compress` whenever you would have wanted the model to call it. With `"Compress"` absent, 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.
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
- if (previous.timer) clearTimeout(previous.timer);
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
- if (pending.timer) clearTimeout(pending.timer);
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
- if (pending.timer) clearTimeout(pending.timer);
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 liveToolInfo = taskProxyEnabled || questionProxyEnabled ? await loadLiveToolInfo() : {
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(combinedProxyTools, sk);
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: enrichedProxy?.some((t) => t.name === "compress") ?? false,
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,