@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 +2 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +153 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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) {
|