@khalilgharbaoui/opencode-claude-code-plugin 0.4.3 → 0.4.5

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
@@ -171,6 +171,8 @@ The account model IDs are internally suffixed, for example `claude-sonnet-4-6@wo
171
171
  | `mcpConfig` | string \| string[] | – | Extra `--mcp-config` paths/JSON passed alongside the bridged config. |
172
172
  | `strictMcpConfig` | boolean | `false` | Pass `--strict-mcp-config` so Claude loads **only** the configured servers and ignores `~/.claude/settings.json`. |
173
173
  | `webSearch` | `"claude"` \| `"disabled"` \| `<tool>` | `"claude"` | Routing for Claude's built-in `WebSearch`. See [WebSearch routing](#websearch-routing). |
174
+ | `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. |
175
+ | `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. |
174
176
 
175
177
  ### Overriding model metadata
176
178
 
@@ -310,6 +312,7 @@ Set `permissionMode: "plan"` to forward `--permission-mode plan` to Claude. The
310
312
  ## Quirks worth knowing
311
313
 
312
314
  - **Empty text blocks are dropped.** Claude sometimes opens a `content_block_start` for text but never sends a delta. The plugin no longer emits the empty block (which was triggering Anthropic 400s like `cache_control cannot be set for empty text blocks`).
315
+ - **Smart incomplete-turn continuation.** By default, the plugin keeps the current opencode stream open and feeds Claude CLI a small internal continuation message when Claude emits a `result` after reasoning/tool activity without a useful visible answer. It still stops normally on final-looking answers, questions, blockers, errors, aborts, or internal safety-budget exhaustion. Disable with `"autoContinueIncompleteTurns": false`.
313
316
  - **`AskUserQuestion`** from the CLI is converted into plain text content rather than forwarded as a tool call.
314
317
  - **Wire-inactivity watchdog.** Once the CLI has produced any content, the stream closes gracefully if stdout goes silent for 60 seconds without a `result` message arriving. Resets on every line received, so long mid-turn pauses (Sonnet between text-end and the next tool_use, for example) are tolerated. On a user-initiated abort, the watchdog shortens to 5 seconds.
315
318
  - **Per-iteration usage.** When the CLI internally retries with tools, the plugin only counts the last iteration's usage so opencode's context accounting stays accurate.
package/dist/index.d.ts CHANGED
@@ -115,6 +115,8 @@ interface ClaudeCodeConfig {
115
115
  webSearch?: WebSearchRouting;
116
116
  hotReloadMcp?: boolean;
117
117
  proxyOpencodeMcpTools?: boolean;
118
+ multiStepContinuation?: boolean;
119
+ autoContinueIncompleteTurns?: boolean | "smart";
118
120
  }
119
121
  type WebSearchRouting = "claude" | "disabled" | (string & {});
120
122
  interface ClaudeCodeProviderSettings {
@@ -207,6 +209,30 @@ interface ClaudeCodeProviderSettings {
207
209
  * an opencode round-trip).
208
210
  */
209
211
  proxyOpencodeMcpTools?: boolean;
212
+ /**
213
+ * Append a short system-prompt hint that nudges Claude to chain
214
+ * multiple tool calls within a single turn instead of pausing for user
215
+ * confirmation between subtasks. Each turn boundary in opencode
216
+ * requires the user to manually press "continue" to resume, so for
217
+ * multi-step tasks this option reduces friction. Defaults to `true`.
218
+ *
219
+ * Set to `false` if you prefer the un-nudged model behavior (Claude
220
+ * decides when to end the turn entirely on its own).
221
+ */
222
+ multiStepContinuation?: boolean;
223
+ /**
224
+ * Smartly continue incomplete Claude CLI results inside the same opencode
225
+ * turn. Claude CLI sometimes emits `result` after reasoning/tool activity
226
+ * without a useful final answer, which makes opencode stop and wait for the
227
+ * user to type "continue". With the default `"smart"`, the plugin detects
228
+ * those incomplete result boundaries, feeds Claude a small continuation
229
+ * message internally, and keeps the opencode stream open. Final answers,
230
+ * questions, blockers, errors, aborts, and safety-budget exhaustion still
231
+ * stop normally.
232
+ *
233
+ * Set to `false` to disable.
234
+ */
235
+ autoContinueIncompleteTurns?: boolean | "smart";
210
236
  }
211
237
  type PermissionMode = "acceptEdits" | "auto" | "bypassPermissions" | "default" | "dontAsk" | "plan";
212
238
  type ControlRequestBehavior = "allow" | "deny";
package/dist/index.js CHANGED
@@ -1483,6 +1483,77 @@ function hasNewUserContent(prompt) {
1483
1483
  }
1484
1484
  return false;
1485
1485
  }
1486
+ var AUTO_CONTINUE_MAX_ATTEMPTS = 8;
1487
+ var AUTO_CONTINUE_MAX_ELAPSED_MS = 10 * 60 * 1e3;
1488
+ var AUTO_CONTINUE_NO_PROGRESS_LIMIT = 2;
1489
+ var AUTO_CONTINUE_PROMPT = "Continue the task from where you stopped. Do not summarize; keep working until the requested task is complete, you need clarification, or you hit a real blocker.";
1490
+ function normalizeVisibleText(text) {
1491
+ return text.replace(/\s+/g, " ").trim();
1492
+ }
1493
+ function looksLikeQuestion(text) {
1494
+ const normalized = normalizeVisibleText(text).toLowerCase();
1495
+ if (!normalized) return false;
1496
+ if (normalized.endsWith("?")) return true;
1497
+ return /\b(please confirm|can you confirm|should i|would you like|do you want|which option|choose|pick one|need your|need you to|what would you like)\b/.test(normalized);
1498
+ }
1499
+ function looksLikeBlocker(text) {
1500
+ const normalized = normalizeVisibleText(text).toLowerCase();
1501
+ if (!normalized) return false;
1502
+ return /\b(blocked|blocker|cannot proceed|can't proceed|unable to proceed|need clarification|need more information|permission denied|failed and needs|requires your|manual step|required from you)\b/.test(normalized);
1503
+ }
1504
+ function looksLikeFinalAnswer(text) {
1505
+ const normalized = normalizeVisibleText(text).toLowerCase();
1506
+ if (normalized.length < 40) return false;
1507
+ if (looksLikeQuestion(normalized) || looksLikeBlocker(normalized)) return false;
1508
+ return /\b(done|completed|fixed|implemented|verified|published|released|sent|delivered|updated)\b/.test(normalized) || /\b(checks?|tests?) passed\b/.test(normalized) || /\b(summary|what changed|verification)\b/.test(normalized);
1509
+ }
1510
+ function continuationSignature(snapshot) {
1511
+ const text = normalizeVisibleText(snapshot.text).slice(-500);
1512
+ return JSON.stringify({
1513
+ text,
1514
+ reasoning: snapshot.hadReasoning,
1515
+ tools: snapshot.hadToolActivity,
1516
+ proxy: snapshot.hadProxyActivity
1517
+ });
1518
+ }
1519
+ function shouldAutoContinueIncompleteTurn(state, snapshot) {
1520
+ if (state.enabled === false) return { continue: false, reason: "disabled" };
1521
+ if (snapshot.isError) return { continue: false, reason: "error" };
1522
+ if (state.aborted) return { continue: false, reason: "aborted" };
1523
+ if (state.attempts >= AUTO_CONTINUE_MAX_ATTEMPTS) {
1524
+ return { continue: false, reason: "max-attempts" };
1525
+ }
1526
+ const now = snapshot.now ?? Date.now();
1527
+ if (now - state.startedAt > AUTO_CONTINUE_MAX_ELAPSED_MS) {
1528
+ return { continue: false, reason: "max-elapsed" };
1529
+ }
1530
+ const text = normalizeVisibleText(snapshot.text);
1531
+ if (looksLikeQuestion(text)) return { continue: false, reason: "question" };
1532
+ if (looksLikeBlocker(text)) return { continue: false, reason: "blocker" };
1533
+ if (looksLikeFinalAnswer(text)) {
1534
+ return { continue: false, reason: "final-answer" };
1535
+ }
1536
+ const hadActivity = snapshot.hadReasoning || snapshot.hadToolActivity || snapshot.hadProxyActivity;
1537
+ if (!hadActivity) return { continue: false, reason: "no-activity" };
1538
+ const signature = continuationSignature(snapshot);
1539
+ const noProgress = signature === state.lastSignature;
1540
+ if (noProgress && state.noProgressCount + 1 >= AUTO_CONTINUE_NO_PROGRESS_LIMIT) {
1541
+ return { continue: false, reason: "no-progress" };
1542
+ }
1543
+ if (!text) {
1544
+ return { continue: true, reason: "activity-without-visible-answer" };
1545
+ }
1546
+ return { continue: true, reason: "non-final-progress" };
1547
+ }
1548
+ function makeAutoContinueMessage() {
1549
+ return JSON.stringify({
1550
+ type: "user",
1551
+ message: {
1552
+ role: "user",
1553
+ content: [{ type: "text", text: AUTO_CONTINUE_PROMPT }]
1554
+ }
1555
+ });
1556
+ }
1486
1557
  function readPromptFileIfPresent(path5) {
1487
1558
  try {
1488
1559
  const content = readFileSync2(path5, "utf8").trim();
@@ -1501,13 +1572,22 @@ function nearestWorkspaceAgentsPrompt(cwd) {
1501
1572
  dir = parent;
1502
1573
  }
1503
1574
  }
1504
- function buildAppendedSystemPrompt(cwd) {
1575
+ var MULTI_STEP_TASK_HINT = `## Continuing through multi-step tasks
1576
+
1577
+ opencode requires the user to press "continue" after each turn ends. When a
1578
+ task has multiple steps, do them all in one turn \u2014 chain tool calls rather
1579
+ than pausing for user confirmation between subtasks. End the turn only
1580
+ when the task is done, you need clarification on intent, or you hit a real
1581
+ blocker. The user can interrupt or abort at any time; turn endings should
1582
+ mark meaningful checkpoints, not every completed substep.`;
1583
+ function buildAppendedSystemPrompt(cwd, includeMultiStepHint = true) {
1505
1584
  const parts = [];
1506
1585
  const configRoot = process.env.XDG_CONFIG_HOME ?? join4(homedir2(), ".config");
1507
1586
  const globalAgents = readPromptFileIfPresent(join4(configRoot, "opencode", "AGENTS.md"));
1508
1587
  const workspaceAgents = nearestWorkspaceAgentsPrompt(cwd);
1509
1588
  if (globalAgents) parts.push(globalAgents);
1510
1589
  if (workspaceAgents && workspaceAgents !== globalAgents) parts.push(workspaceAgents);
1590
+ if (includeMultiStepHint) parts.push(MULTI_STEP_TASK_HINT);
1511
1591
  const content = parts.join("\n\n");
1512
1592
  if (!content) return void 0;
1513
1593
  const path5 = join4(tmpdir2(), `opencode-cc-sys-${randomUUID2()}.md`);
@@ -1981,7 +2061,10 @@ var ClaudeCodeLanguageModel = class {
1981
2061
  reasoningEffort
1982
2062
  );
1983
2063
  const runtimeStatus = await getRuntimeMcpStatus();
1984
- const systemPromptFile = buildAppendedSystemPrompt(cwd);
2064
+ const systemPromptFile = buildAppendedSystemPrompt(
2065
+ cwd,
2066
+ this.config.multiStepContinuation !== false
2067
+ );
1985
2068
  const cliArgs = buildCliArgs({
1986
2069
  sessionKey: sk,
1987
2070
  skipPermissions: this.config.skipPermissions !== false,
@@ -2351,7 +2434,10 @@ ${plan}
2351
2434
  runtimeStatus,
2352
2435
  excludeServers
2353
2436
  );
2354
- const systemPromptFile = activeProcess ? void 0 : buildAppendedSystemPrompt(cwd);
2437
+ const systemPromptFile = activeProcess ? void 0 : buildAppendedSystemPrompt(
2438
+ cwd,
2439
+ self.config.multiStepContinuation !== false
2440
+ );
2355
2441
  const cliArgs = buildCliArgs({
2356
2442
  sessionKey: sk,
2357
2443
  skipPermissions,
@@ -2405,6 +2491,16 @@ ${plan}
2405
2491
  let pendingProxyUnsubscribe = null;
2406
2492
  let resultFallbackTimer = null;
2407
2493
  let hasReceivedContent = false;
2494
+ let visibleTextSinceContinue = "";
2495
+ let hadReasoningSinceContinue = false;
2496
+ let hadToolActivitySinceContinue = false;
2497
+ let hadProxyActivitySinceContinue = false;
2498
+ const autoContinueState = {
2499
+ enabled: self.config.autoContinueIncompleteTurns,
2500
+ attempts: 0,
2501
+ startedAt: Date.now(),
2502
+ noProgressCount: 0
2503
+ };
2408
2504
  const clearFallbackTimer = () => {
2409
2505
  if (resultFallbackTimer) {
2410
2506
  clearTimeout(resultFallbackTimer);
@@ -2477,6 +2573,24 @@ ${plan}
2477
2573
  });
2478
2574
  finishWithToolCalls(batch);
2479
2575
  };
2576
+ const noteVisibleText = (text) => {
2577
+ visibleTextSinceContinue += text;
2578
+ };
2579
+ const noteReasoning = () => {
2580
+ hadReasoningSinceContinue = true;
2581
+ };
2582
+ const noteToolActivity = () => {
2583
+ hadToolActivitySinceContinue = true;
2584
+ };
2585
+ const noteProxyActivity = () => {
2586
+ hadProxyActivitySinceContinue = true;
2587
+ };
2588
+ const resetAutoContinueWindow = () => {
2589
+ visibleTextSinceContinue = "";
2590
+ hadReasoningSinceContinue = false;
2591
+ hadToolActivitySinceContinue = false;
2592
+ hadProxyActivitySinceContinue = false;
2593
+ };
2480
2594
  let gotPartialEvents = false;
2481
2595
  const lineHandler = (line) => {
2482
2596
  if (!line.trim()) return;
@@ -2507,6 +2621,7 @@ ${plan}
2507
2621
  const block = msg.content_block;
2508
2622
  const idx = msg.index;
2509
2623
  if (block.type === "thinking") {
2624
+ noteReasoning();
2510
2625
  const reasoningId = generateId();
2511
2626
  reasoningIds.set(idx, reasoningId);
2512
2627
  controller.enqueue({
@@ -2524,10 +2639,12 @@ ${plan}
2524
2639
  id: currentTextId,
2525
2640
  delta: block.text
2526
2641
  });
2642
+ noteVisibleText(block.text);
2527
2643
  hasReceivedContent = true;
2528
2644
  }
2529
2645
  }
2530
2646
  if (block.type === "tool_use" && block.id && block.name) {
2647
+ noteToolActivity();
2531
2648
  toolCallMap.set(idx, {
2532
2649
  id: block.id,
2533
2650
  name: block.name,
@@ -2559,6 +2676,7 @@ ${plan}
2559
2676
  const delta = msg.delta;
2560
2677
  const idx = msg.index;
2561
2678
  if (delta.type === "thinking_delta" && delta.thinking) {
2679
+ noteReasoning();
2562
2680
  const reasoningId = reasoningIds.get(idx);
2563
2681
  if (reasoningId) {
2564
2682
  controller.enqueue({
@@ -2575,6 +2693,7 @@ ${plan}
2575
2693
  id: currentTextId,
2576
2694
  delta: delta.text
2577
2695
  });
2696
+ noteVisibleText(delta.text);
2578
2697
  hasReceivedContent = true;
2579
2698
  }
2580
2699
  if (delta.type === "input_json_delta" && delta.partial_json) {
@@ -2704,9 +2823,11 @@ ${plan}
2704
2823
  delta: block.text
2705
2824
  });
2706
2825
  endTextBlock();
2826
+ noteVisibleText(block.text);
2707
2827
  hasReceivedContent = true;
2708
2828
  }
2709
2829
  if (block.type === "thinking" && block.thinking) {
2830
+ noteReasoning();
2710
2831
  const thinkingId = generateId();
2711
2832
  controller.enqueue({
2712
2833
  type: "reasoning-start",
@@ -2723,6 +2844,7 @@ ${plan}
2723
2844
  });
2724
2845
  }
2725
2846
  if (block.type === "tool_use" && block.id && block.name) {
2847
+ noteToolActivity();
2726
2848
  const parsedInput = block.input ?? {};
2727
2849
  toolCallsById.set(block.id, {
2728
2850
  id: block.id,
@@ -2836,6 +2958,7 @@ ${plan}
2836
2958
  },
2837
2959
  providerExecuted: true
2838
2960
  });
2961
+ noteToolActivity();
2839
2962
  log.info("tool result emitted", {
2840
2963
  toolUseId: block.tool_use_id,
2841
2964
  name: toolCall.name
@@ -2899,6 +3022,46 @@ ${plan}
2899
3022
  )
2900
3023
  );
2901
3024
  }
3025
+ const autoDecision = shouldAutoContinueIncompleteTurn(
3026
+ autoContinueState,
3027
+ {
3028
+ text: visibleTextSinceContinue,
3029
+ hadReasoning: hadReasoningSinceContinue,
3030
+ hadToolActivity: hadToolActivitySinceContinue,
3031
+ hadProxyActivity: hadProxyActivitySinceContinue,
3032
+ isError: msg.is_error
3033
+ }
3034
+ );
3035
+ if (autoDecision.continue) {
3036
+ const signature = continuationSignature({
3037
+ text: visibleTextSinceContinue,
3038
+ hadReasoning: hadReasoningSinceContinue,
3039
+ hadToolActivity: hadToolActivitySinceContinue,
3040
+ hadProxyActivity: hadProxyActivitySinceContinue,
3041
+ isError: msg.is_error
3042
+ });
3043
+ autoContinueState.noProgressCount = signature === autoContinueState.lastSignature ? autoContinueState.noProgressCount + 1 : 0;
3044
+ autoContinueState.lastSignature = signature;
3045
+ autoContinueState.attempts++;
3046
+ log.info("auto-continuing incomplete claude result", {
3047
+ sessionKey: sk,
3048
+ reason: autoDecision.reason,
3049
+ attempts: autoContinueState.attempts,
3050
+ textLength: visibleTextSinceContinue.length,
3051
+ hadReasoning: hadReasoningSinceContinue,
3052
+ hadToolActivity: hadToolActivitySinceContinue,
3053
+ hadProxyActivity: hadProxyActivitySinceContinue
3054
+ });
3055
+ turnCompleted = false;
3056
+ resetAutoContinueWindow();
3057
+ proc.stdin?.write(makeAutoContinueMessage() + "\n");
3058
+ return;
3059
+ }
3060
+ log.info("auto-continuation stopped", {
3061
+ sessionKey: sk,
3062
+ reason: autoDecision.reason,
3063
+ attempts: autoContinueState.attempts
3064
+ });
2902
3065
  for (const [idx, reasoningId] of reasoningIds) {
2903
3066
  if (reasoningStarted.get(idx)) {
2904
3067
  controller.enqueue({
@@ -3021,6 +3184,8 @@ ${plan}
3021
3184
  toolCallId: call.toolCallId,
3022
3185
  toolName: call.toolName
3023
3186
  });
3187
+ noteProxyActivity();
3188
+ noteToolActivity();
3024
3189
  drainBuffer.push(call);
3025
3190
  if (drainTimer) clearTimeout(drainTimer);
3026
3191
  drainTimer = setTimeout(drainNow, DRAIN_QUIET_MS);
@@ -3028,6 +3193,7 @@ ${plan}
3028
3193
  proc.on("error", procErrorHandler);
3029
3194
  if (options.abortSignal) {
3030
3195
  options.abortSignal.addEventListener("abort", () => {
3196
+ autoContinueState.aborted = true;
3031
3197
  if (turnCompleted || controllerClosed) return;
3032
3198
  if (!hasReceivedContent) {
3033
3199
  log.info(
@@ -3540,7 +3706,9 @@ function createClaudeCode(settings = {}) {
3540
3706
  proxyTools,
3541
3707
  webSearch: settings.webSearch,
3542
3708
  hotReloadMcp: settings.hotReloadMcp ?? true,
3543
- proxyOpencodeMcpTools: settings.proxyOpencodeMcpTools ?? true
3709
+ proxyOpencodeMcpTools: settings.proxyOpencodeMcpTools ?? true,
3710
+ multiStepContinuation: settings.multiStepContinuation ?? true,
3711
+ autoContinueIncompleteTurns: settings.autoContinueIncompleteTurns ?? "smart"
3544
3712
  });
3545
3713
  };
3546
3714
  const provider = function(modelId) {