@khalilgharbaoui/opencode-claude-code-plugin 0.4.4 → 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
@@ -172,6 +172,7 @@ The account model IDs are internally suffixed, for example `claude-sonnet-4-6@wo
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
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. |
175
176
 
176
177
  ### Overriding model metadata
177
178
 
@@ -311,6 +312,7 @@ Set `permissionMode: "plan"` to forward `--permission-mode plan` to Claude. The
311
312
  ## Quirks worth knowing
312
313
 
313
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`.
314
316
  - **`AskUserQuestion`** from the CLI is converted into plain text content rather than forwarded as a tool call.
315
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.
316
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
@@ -116,6 +116,7 @@ interface ClaudeCodeConfig {
116
116
  hotReloadMcp?: boolean;
117
117
  proxyOpencodeMcpTools?: boolean;
118
118
  multiStepContinuation?: boolean;
119
+ autoContinueIncompleteTurns?: boolean | "smart";
119
120
  }
120
121
  type WebSearchRouting = "claude" | "disabled" | (string & {});
121
122
  interface ClaudeCodeProviderSettings {
@@ -219,6 +220,19 @@ interface ClaudeCodeProviderSettings {
219
220
  * decides when to end the turn entirely on its own).
220
221
  */
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";
222
236
  }
223
237
  type PermissionMode = "acceptEdits" | "auto" | "bypassPermissions" | "default" | "dontAsk" | "plan";
224
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();
@@ -2420,6 +2491,16 @@ ${plan}
2420
2491
  let pendingProxyUnsubscribe = null;
2421
2492
  let resultFallbackTimer = null;
2422
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
+ };
2423
2504
  const clearFallbackTimer = () => {
2424
2505
  if (resultFallbackTimer) {
2425
2506
  clearTimeout(resultFallbackTimer);
@@ -2492,6 +2573,24 @@ ${plan}
2492
2573
  });
2493
2574
  finishWithToolCalls(batch);
2494
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
+ };
2495
2594
  let gotPartialEvents = false;
2496
2595
  const lineHandler = (line) => {
2497
2596
  if (!line.trim()) return;
@@ -2522,6 +2621,7 @@ ${plan}
2522
2621
  const block = msg.content_block;
2523
2622
  const idx = msg.index;
2524
2623
  if (block.type === "thinking") {
2624
+ noteReasoning();
2525
2625
  const reasoningId = generateId();
2526
2626
  reasoningIds.set(idx, reasoningId);
2527
2627
  controller.enqueue({
@@ -2539,10 +2639,12 @@ ${plan}
2539
2639
  id: currentTextId,
2540
2640
  delta: block.text
2541
2641
  });
2642
+ noteVisibleText(block.text);
2542
2643
  hasReceivedContent = true;
2543
2644
  }
2544
2645
  }
2545
2646
  if (block.type === "tool_use" && block.id && block.name) {
2647
+ noteToolActivity();
2546
2648
  toolCallMap.set(idx, {
2547
2649
  id: block.id,
2548
2650
  name: block.name,
@@ -2574,6 +2676,7 @@ ${plan}
2574
2676
  const delta = msg.delta;
2575
2677
  const idx = msg.index;
2576
2678
  if (delta.type === "thinking_delta" && delta.thinking) {
2679
+ noteReasoning();
2577
2680
  const reasoningId = reasoningIds.get(idx);
2578
2681
  if (reasoningId) {
2579
2682
  controller.enqueue({
@@ -2590,6 +2693,7 @@ ${plan}
2590
2693
  id: currentTextId,
2591
2694
  delta: delta.text
2592
2695
  });
2696
+ noteVisibleText(delta.text);
2593
2697
  hasReceivedContent = true;
2594
2698
  }
2595
2699
  if (delta.type === "input_json_delta" && delta.partial_json) {
@@ -2719,9 +2823,11 @@ ${plan}
2719
2823
  delta: block.text
2720
2824
  });
2721
2825
  endTextBlock();
2826
+ noteVisibleText(block.text);
2722
2827
  hasReceivedContent = true;
2723
2828
  }
2724
2829
  if (block.type === "thinking" && block.thinking) {
2830
+ noteReasoning();
2725
2831
  const thinkingId = generateId();
2726
2832
  controller.enqueue({
2727
2833
  type: "reasoning-start",
@@ -2738,6 +2844,7 @@ ${plan}
2738
2844
  });
2739
2845
  }
2740
2846
  if (block.type === "tool_use" && block.id && block.name) {
2847
+ noteToolActivity();
2741
2848
  const parsedInput = block.input ?? {};
2742
2849
  toolCallsById.set(block.id, {
2743
2850
  id: block.id,
@@ -2851,6 +2958,7 @@ ${plan}
2851
2958
  },
2852
2959
  providerExecuted: true
2853
2960
  });
2961
+ noteToolActivity();
2854
2962
  log.info("tool result emitted", {
2855
2963
  toolUseId: block.tool_use_id,
2856
2964
  name: toolCall.name
@@ -2914,6 +3022,46 @@ ${plan}
2914
3022
  )
2915
3023
  );
2916
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
+ });
2917
3065
  for (const [idx, reasoningId] of reasoningIds) {
2918
3066
  if (reasoningStarted.get(idx)) {
2919
3067
  controller.enqueue({
@@ -3036,6 +3184,8 @@ ${plan}
3036
3184
  toolCallId: call.toolCallId,
3037
3185
  toolName: call.toolName
3038
3186
  });
3187
+ noteProxyActivity();
3188
+ noteToolActivity();
3039
3189
  drainBuffer.push(call);
3040
3190
  if (drainTimer) clearTimeout(drainTimer);
3041
3191
  drainTimer = setTimeout(drainNow, DRAIN_QUIET_MS);
@@ -3043,6 +3193,7 @@ ${plan}
3043
3193
  proc.on("error", procErrorHandler);
3044
3194
  if (options.abortSignal) {
3045
3195
  options.abortSignal.addEventListener("abort", () => {
3196
+ autoContinueState.aborted = true;
3046
3197
  if (turnCompleted || controllerClosed) return;
3047
3198
  if (!hasReceivedContent) {
3048
3199
  log.info(
@@ -3556,7 +3707,8 @@ function createClaudeCode(settings = {}) {
3556
3707
  webSearch: settings.webSearch,
3557
3708
  hotReloadMcp: settings.hotReloadMcp ?? true,
3558
3709
  proxyOpencodeMcpTools: settings.proxyOpencodeMcpTools ?? true,
3559
- multiStepContinuation: settings.multiStepContinuation ?? true
3710
+ multiStepContinuation: settings.multiStepContinuation ?? true,
3711
+ autoContinueIncompleteTurns: settings.autoContinueIncompleteTurns ?? "smart"
3560
3712
  });
3561
3713
  };
3562
3714
  const provider = function(modelId) {