@khalilgharbaoui/opencode-claude-code-plugin 0.12.0 → 0.12.1
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 +20 -0
- package/dist/index.d.ts +37 -1
- package/dist/index.js +317 -22
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -183,6 +183,7 @@ The account model IDs are internally suffixed, for example `claude-sonnet-4-6@wo
|
|
|
183
183
|
| `permissionMode` | `acceptEdits` \| `auto` \| `bypassPermissions` \| `default` \| `dontAsk` \| `plan` | – | Forwarded to `claude --permission-mode`. |
|
|
184
184
|
| `proxyTools` | string[] | `["Bash", "Edit", "Write", "WebFetch", "Task"]` | Claude built-in tools to route through opencode's executor + permission UI. See [Selective tool proxy](#selective-tool-proxy). |
|
|
185
185
|
| `proxyToolTimeoutMs` | `Record<string, number>` | – | Per-tool proxy call deadline in ms, keyed by proxy tool name (`bash`, `task`, …). Defaults: 10 min flat, `task` → 60 min. For `bash`, the call's own `input.timeout` is honoured on top (`max(resolved, input.timeout)`). See [Selective tool proxy](#selective-tool-proxy). |
|
|
186
|
+
| `planModeQuestion` | boolean | `false` | Route `ExitPlanMode` approval through opencode's native `question` tool instead of a text "(yes/no)" prompt. Off because opencode's question form is currently broken upstream. See [Plan mode](#plan-mode). |
|
|
186
187
|
| `controlRequestBehavior` | `allow` \| `deny` | `allow` | Default response when `skipPermissions: false` and Claude sends a `can_use_tool` control request. |
|
|
187
188
|
| `controlRequestToolBehaviors` | `Record<string, "allow" \| "deny">` | – | Per-tool override for `can_use_tool`. Example: `{ "Bash": "deny", "Read": "allow" }`. |
|
|
188
189
|
| `controlRequestDenyMessage` | string | built-in message | Message returned to Claude on a deny. |
|
|
@@ -450,6 +451,25 @@ Each chat keeps a long-lived `claude` subprocess so the model retains its native
|
|
|
450
451
|
|
|
451
452
|
Set `permissionMode: "plan"` to forward `--permission-mode plan` to Claude. The plugin handles `ExitPlanMode` specially — instead of forwarding it as a tool call, it converts it to a confirmation prompt that flows through opencode normally.
|
|
452
453
|
|
|
454
|
+
By default that prompt is text: the plan is rendered as markdown, followed by `**Do you want to proceed with this plan?** (yes/no)`, and you answer in your next message.
|
|
455
|
+
|
|
456
|
+
### Approval as a real form (`planModeQuestion`, opt-in)
|
|
457
|
+
|
|
458
|
+
Set `planModeQuestion: true` to route the approval through opencode's native `question` tool instead:
|
|
459
|
+
|
|
460
|
+
```json
|
|
461
|
+
"options": {
|
|
462
|
+
"permissionMode": "plan",
|
|
463
|
+
"planModeQuestion": true
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
The plan is still rendered, but the turn then ends on `tool-calls` and opencode runs its own `question` tool, so approval is a form rather than prose. Your answer is fed back to the CLI as the `tool_result` for the original `ExitPlanMode` call, which is what actually unlocks plan mode on the Claude side. A "yes" typed as ordinary text never does that. Anything other than picking `yes` (including custom text) comes back as rejection feedback the model is told to act on.
|
|
468
|
+
|
|
469
|
+
> **Leave this off for now.** It depends on the same opencode `question` form that is [broken upstream](#with-question-in-proxytools-currently-blocked-upstream--leave-it-off): with it on, a plan approval hangs until you interrupt the turn. On opencode builds with no `question` registry entry at all the plugin silently keeps the text path (look for `plan-mode question gate` in the log). Re-test when [anomalyco/opencode#36603](https://github.com/anomalyco/opencode/pull/36603) merges.
|
|
470
|
+
|
|
471
|
+
Approval bridge contributed by [@CollieIsCute](https://github.com/CollieIsCute).
|
|
472
|
+
|
|
453
473
|
---
|
|
454
474
|
|
|
455
475
|
## AskUserQuestion
|
package/dist/index.d.ts
CHANGED
|
@@ -162,6 +162,14 @@ interface ClaudeCodeConfig {
|
|
|
162
162
|
controlRequestDenyMessage?: string;
|
|
163
163
|
proxyTools?: string[];
|
|
164
164
|
proxyToolTimeoutMs?: Record<string, number>;
|
|
165
|
+
/**
|
|
166
|
+
* Route `ExitPlanMode` through opencode's native `question` tool so plan
|
|
167
|
+
* approval is a real form instead of a "(yes/no)" line the operator has to
|
|
168
|
+
* answer in prose. Off by default: opencode's question form is currently
|
|
169
|
+
* broken upstream, so enabling this trades a working text prompt for a
|
|
170
|
+
* silent hang. See the plan-mode gotcha in AGENTS.md.
|
|
171
|
+
*/
|
|
172
|
+
planModeQuestion?: boolean;
|
|
165
173
|
webSearch?: WebSearchRouting;
|
|
166
174
|
hotReloadMcp?: boolean;
|
|
167
175
|
proxyOpencodeMcpTools?: boolean;
|
|
@@ -292,6 +300,23 @@ interface ClaudeCodeProviderSettings {
|
|
|
292
300
|
* long build the caller explicitly asked to run is never undercut.
|
|
293
301
|
*/
|
|
294
302
|
proxyToolTimeoutMs?: Record<string, number>;
|
|
303
|
+
/**
|
|
304
|
+
* Route Claude's `ExitPlanMode` through opencode's native `question` tool.
|
|
305
|
+
*
|
|
306
|
+
* Off (default): the plan is rendered as markdown followed by
|
|
307
|
+
* `**Do you want to proceed with this plan?** (yes/no)` and the operator
|
|
308
|
+
* answers in prose. On: the plan is rendered, the turn ends on
|
|
309
|
+
* `tool-calls`, and opencode runs its own `question` tool so approval is a
|
|
310
|
+
* real form; the answer is fed back to the CLI as the `tool_result` for
|
|
311
|
+
* the original `ExitPlanMode` call, which is what unlocks plan mode.
|
|
312
|
+
*
|
|
313
|
+
* Two reasons it is opt-in. opencode's `question` form does not currently
|
|
314
|
+
* render (upstream anomalyco/opencode#36604), so an enabled bridge hangs
|
|
315
|
+
* the turn until the operator interrupts; and older opencode builds have
|
|
316
|
+
* no `question` registry entry at all, in which case the plugin silently
|
|
317
|
+
* keeps the text path. See the plan-mode gotcha in AGENTS.md.
|
|
318
|
+
*/
|
|
319
|
+
planModeQuestion?: boolean;
|
|
295
320
|
/**
|
|
296
321
|
* Strip `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` from the environment of
|
|
297
322
|
* every spawned `claude` process. When an API key is present, Claude Code
|
|
@@ -525,9 +550,20 @@ declare class ClaudeCodeLanguageModel implements LanguageModelV3 {
|
|
|
525
550
|
* call resolves to `⚙ invalid`; the version gate drops the def.
|
|
526
551
|
*
|
|
527
552
|
* Returns undefined/false when the SDK client is unavailable (direct
|
|
528
|
-
* AI-SDK use, tests) so the static defs stand.
|
|
553
|
+
* AI-SDK use, tests) so the static defs stand. `resolved` distinguishes
|
|
554
|
+
* "the registry answered and has no `question` entry" from "nobody
|
|
555
|
+
* answered": only the former is a real version-gate signal.
|
|
529
556
|
*/
|
|
530
557
|
private fetchLiveToolInfo;
|
|
558
|
+
/** Share one lazy registry request within a turn without making it stale. */
|
|
559
|
+
private createLiveToolInfoLoader;
|
|
560
|
+
/**
|
|
561
|
+
* Whether the ExitPlanMode approval bridge is live for this turn: the
|
|
562
|
+
* operator opted in AND opencode's registry actually has the `question`
|
|
563
|
+
* tool. Without the registry entry the emitted tool-call would render as
|
|
564
|
+
* `⚙ invalid` and wedge the turn, so the plugin keeps the text path.
|
|
565
|
+
*/
|
|
566
|
+
private resolvePlanModeQuestion;
|
|
531
567
|
/**
|
|
532
568
|
* Create a proxy MCP server for a single active Claude process/session.
|
|
533
569
|
* The process lifecycle owns the server lifecycle via session-manager.
|
package/dist/index.js
CHANGED
|
@@ -393,7 +393,7 @@ var SUPPORTED_IMAGE_TYPES = /* @__PURE__ */ new Set([
|
|
|
393
393
|
"image/webp"
|
|
394
394
|
]);
|
|
395
395
|
function toImageBlock(part) {
|
|
396
|
-
const raw = part.data ?? part.url ?? part.source?.data;
|
|
396
|
+
const raw = part.image ?? part.data ?? part.url ?? part.source?.data;
|
|
397
397
|
if (!raw) {
|
|
398
398
|
log.warn("file part without data, skipping");
|
|
399
399
|
return null;
|
|
@@ -728,6 +728,161 @@ Now continuing with the current message:
|
|
|
728
728
|
});
|
|
729
729
|
}
|
|
730
730
|
|
|
731
|
+
// src/plan-mode-question.ts
|
|
732
|
+
var QUESTION_TOOL_NAME = "question";
|
|
733
|
+
var APPROVED_EXIT_PLAN_MODE_MESSAGE = "User has approved your plan. You can now start coding. Start with updating your todo list if applicable.";
|
|
734
|
+
var REJECTED_EXIT_PLAN_MODE_PREFIX = "The user doesn't want to proceed with this tool use. The tool use was rejected. To tell you how to proceed, the user said:";
|
|
735
|
+
var PLAN_MODE_APPROVAL_QUESTION = "Do you want to proceed with this plan?";
|
|
736
|
+
var OPENCODE_QUESTION_RESULT_PREFIX = `User has answered your questions: "${PLAN_MODE_APPROVAL_QUESTION}"="`;
|
|
737
|
+
var OPENCODE_QUESTION_RESULT_SUFFIX = `". You can now continue with the user's answers in mind.`;
|
|
738
|
+
var KEY_SEPARATOR = "\0";
|
|
739
|
+
function isPlanModeQuestionActive(input) {
|
|
740
|
+
if (input.compactionMode) return false;
|
|
741
|
+
if (input.configured !== true) return false;
|
|
742
|
+
return input.opencodeHasQuestion;
|
|
743
|
+
}
|
|
744
|
+
var pendingQuestions = /* @__PURE__ */ new Map();
|
|
745
|
+
function pendingKey(sessionKey2, questionToolCallId) {
|
|
746
|
+
return `${sessionKey2}${KEY_SEPARATOR}${questionToolCallId}`;
|
|
747
|
+
}
|
|
748
|
+
function clearExitPlanModeQuestions(sessionKey2) {
|
|
749
|
+
const prefix = `${sessionKey2}${KEY_SEPARATOR}`;
|
|
750
|
+
for (const key of pendingQuestions.keys()) {
|
|
751
|
+
if (key.startsWith(prefix)) pendingQuestions.delete(key);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
function createExitPlanModeQuestionCall(sessionKey2, exitPlanModeToolUseId, plan, questionToolCallId = `exit_plan_question_${exitPlanModeToolUseId}`) {
|
|
755
|
+
pendingQuestions.set(pendingKey(sessionKey2, questionToolCallId), exitPlanModeToolUseId);
|
|
756
|
+
return {
|
|
757
|
+
toolCallId: questionToolCallId,
|
|
758
|
+
toolName: QUESTION_TOOL_NAME,
|
|
759
|
+
input: {
|
|
760
|
+
questions: [
|
|
761
|
+
{
|
|
762
|
+
header: "Plan approval",
|
|
763
|
+
question: PLAN_MODE_APPROVAL_QUESTION,
|
|
764
|
+
options: [
|
|
765
|
+
{ label: "yes", description: "" },
|
|
766
|
+
{ label: "no", description: "" }
|
|
767
|
+
],
|
|
768
|
+
multiple: false,
|
|
769
|
+
custom: true
|
|
770
|
+
}
|
|
771
|
+
]
|
|
772
|
+
},
|
|
773
|
+
text: plan ? `
|
|
774
|
+
|
|
775
|
+
${plan}
|
|
776
|
+
` : "\n\n"
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
function buildToolResultMessage(input) {
|
|
780
|
+
return JSON.stringify({
|
|
781
|
+
type: "user",
|
|
782
|
+
message: {
|
|
783
|
+
role: "user",
|
|
784
|
+
content: [
|
|
785
|
+
input.approved ? {
|
|
786
|
+
type: "tool_result",
|
|
787
|
+
tool_use_id: input.toolUseId,
|
|
788
|
+
content: APPROVED_EXIT_PLAN_MODE_MESSAGE
|
|
789
|
+
} : {
|
|
790
|
+
type: "tool_result",
|
|
791
|
+
tool_use_id: input.toolUseId,
|
|
792
|
+
content: `${REJECTED_EXIT_PLAN_MODE_PREFIX}
|
|
793
|
+
${input.feedback || "no"}`,
|
|
794
|
+
is_error: true
|
|
795
|
+
}
|
|
796
|
+
]
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
function tryParseJson(text) {
|
|
801
|
+
try {
|
|
802
|
+
return JSON.parse(text);
|
|
803
|
+
} catch {
|
|
804
|
+
return text;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
function unwrapToolOutput(part) {
|
|
808
|
+
const output = part?.output ?? part?.result;
|
|
809
|
+
if (typeof output === "string") return tryParseJson(output);
|
|
810
|
+
if (!output || typeof output !== "object") return output;
|
|
811
|
+
switch (output.type) {
|
|
812
|
+
case "json":
|
|
813
|
+
case "error-json":
|
|
814
|
+
return output.value;
|
|
815
|
+
case "text":
|
|
816
|
+
case "error-text":
|
|
817
|
+
return tryParseJson(String(output.value ?? ""));
|
|
818
|
+
case "execution-denied":
|
|
819
|
+
return {
|
|
820
|
+
denied: true,
|
|
821
|
+
reason: String(output.reason ?? "question rejected")
|
|
822
|
+
};
|
|
823
|
+
case "content":
|
|
824
|
+
return Array.isArray(output.value) ? output.value.map((item) => {
|
|
825
|
+
if (item?.type === "text") return item.text;
|
|
826
|
+
return JSON.stringify(item);
|
|
827
|
+
}).join("\n") : output.value;
|
|
828
|
+
default:
|
|
829
|
+
return output;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
function unwrapOpencodeQuestionResult(value) {
|
|
833
|
+
if (value.startsWith(OPENCODE_QUESTION_RESULT_PREFIX) && value.endsWith(OPENCODE_QUESTION_RESULT_SUFFIX)) {
|
|
834
|
+
return value.slice(
|
|
835
|
+
OPENCODE_QUESTION_RESULT_PREFIX.length,
|
|
836
|
+
-OPENCODE_QUESTION_RESULT_SUFFIX.length
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
return value;
|
|
840
|
+
}
|
|
841
|
+
function collectAnswerStrings(value) {
|
|
842
|
+
if (typeof value === "string") return [unwrapOpencodeQuestionResult(value)];
|
|
843
|
+
if (Array.isArray(value)) return value.flatMap(collectAnswerStrings);
|
|
844
|
+
if (!value || typeof value !== "object") return [];
|
|
845
|
+
const obj = value;
|
|
846
|
+
if (obj.denied === true) return [String(obj.reason ?? "question rejected")];
|
|
847
|
+
for (const key of ["answers", "answer", "selected", "selection", "value"]) {
|
|
848
|
+
if (key in obj) return collectAnswerStrings(obj[key]);
|
|
849
|
+
}
|
|
850
|
+
return [];
|
|
851
|
+
}
|
|
852
|
+
function classifyQuestionResult(part) {
|
|
853
|
+
const output = unwrapToolOutput(part);
|
|
854
|
+
const answers = collectAnswerStrings(output).map((answer) => answer.trim()).filter(Boolean);
|
|
855
|
+
if (answers.length === 1 && answers[0].toLowerCase() === "yes") {
|
|
856
|
+
return { approved: true, feedback: "" };
|
|
857
|
+
}
|
|
858
|
+
return {
|
|
859
|
+
approved: false,
|
|
860
|
+
feedback: answers.length > 0 ? answers.join("\n") : "no"
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
function consumeExitPlanModeQuestionResult(sessionKey2, prompt) {
|
|
864
|
+
for (let i = prompt.length - 1; i >= 0; i--) {
|
|
865
|
+
const msg = prompt[i];
|
|
866
|
+
if (!Array.isArray(msg.content)) continue;
|
|
867
|
+
for (const part of msg.content) {
|
|
868
|
+
if (part?.type !== "tool-result" || typeof part.toolCallId !== "string") {
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
const key = pendingKey(sessionKey2, part.toolCallId);
|
|
872
|
+
const exitPlanModeToolUseId = pendingQuestions.get(key);
|
|
873
|
+
if (!exitPlanModeToolUseId) continue;
|
|
874
|
+
pendingQuestions.delete(key);
|
|
875
|
+
const result = classifyQuestionResult(part);
|
|
876
|
+
return buildToolResultMessage({
|
|
877
|
+
toolUseId: exitPlanModeToolUseId,
|
|
878
|
+
approved: result.approved,
|
|
879
|
+
feedback: result.feedback
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
|
|
731
886
|
// src/mcp-bridge.ts
|
|
732
887
|
import * as fs2 from "fs";
|
|
733
888
|
import * as path2 from "path";
|
|
@@ -1341,6 +1496,7 @@ function setClaudeSessionId(key, sessionId) {
|
|
|
1341
1496
|
claudeSessions.set(key, sessionId);
|
|
1342
1497
|
}
|
|
1343
1498
|
function deleteClaudeSessionId(key) {
|
|
1499
|
+
clearExitPlanModeQuestions(key);
|
|
1344
1500
|
const claudeSessionId = claudeSessions.get(key);
|
|
1345
1501
|
if (claudeSessionId) clearLedger(claudeSessionId);
|
|
1346
1502
|
claudeSessions.delete(key);
|
|
@@ -3182,7 +3338,9 @@ var ClaudeCodeLanguageModel = class {
|
|
|
3182
3338
|
* call resolves to `⚙ invalid`; the version gate drops the def.
|
|
3183
3339
|
*
|
|
3184
3340
|
* Returns undefined/false when the SDK client is unavailable (direct
|
|
3185
|
-
* AI-SDK use, tests) so the static defs stand.
|
|
3341
|
+
* AI-SDK use, tests) so the static defs stand. `resolved` distinguishes
|
|
3342
|
+
* "the registry answered and has no `question` entry" from "nobody
|
|
3343
|
+
* answered": only the former is a real version-gate signal.
|
|
3186
3344
|
*/
|
|
3187
3345
|
async fetchLiveToolInfo() {
|
|
3188
3346
|
const items = await fetchOpencodeToolList(
|
|
@@ -3192,11 +3350,43 @@ var ClaudeCodeLanguageModel = class {
|
|
|
3192
3350
|
);
|
|
3193
3351
|
const question = items?.find((item) => item.id === "question");
|
|
3194
3352
|
return {
|
|
3353
|
+
resolved: items !== void 0,
|
|
3195
3354
|
taskDescription: items?.find((item) => item.id === "task")?.description,
|
|
3196
3355
|
questionDescription: question?.description,
|
|
3197
3356
|
hasQuestion: !!question
|
|
3198
3357
|
};
|
|
3199
3358
|
}
|
|
3359
|
+
/** Share one lazy registry request within a turn without making it stale. */
|
|
3360
|
+
createLiveToolInfoLoader() {
|
|
3361
|
+
let pending;
|
|
3362
|
+
return () => {
|
|
3363
|
+
pending ??= this.fetchLiveToolInfo();
|
|
3364
|
+
return pending;
|
|
3365
|
+
};
|
|
3366
|
+
}
|
|
3367
|
+
/**
|
|
3368
|
+
* Whether the ExitPlanMode approval bridge is live for this turn: the
|
|
3369
|
+
* operator opted in AND opencode's registry actually has the `question`
|
|
3370
|
+
* tool. Without the registry entry the emitted tool-call would render as
|
|
3371
|
+
* `⚙ invalid` and wedge the turn, so the plugin keeps the text path.
|
|
3372
|
+
*/
|
|
3373
|
+
async resolvePlanModeQuestion(compactionMode, loadLiveToolInfo = () => this.fetchLiveToolInfo()) {
|
|
3374
|
+
if (compactionMode || this.config.planModeQuestion !== true) return false;
|
|
3375
|
+
const info = await loadLiveToolInfo();
|
|
3376
|
+
const active = isPlanModeQuestionActive({
|
|
3377
|
+
configured: this.config.planModeQuestion,
|
|
3378
|
+
opencodeHasQuestion: info.hasQuestion,
|
|
3379
|
+
compactionMode
|
|
3380
|
+
});
|
|
3381
|
+
if (!active) {
|
|
3382
|
+
log.info("plan-mode question gate", {
|
|
3383
|
+
opencodeHasQuestion: info.hasQuestion,
|
|
3384
|
+
registryResolved: info.resolved,
|
|
3385
|
+
active
|
|
3386
|
+
});
|
|
3387
|
+
}
|
|
3388
|
+
return active;
|
|
3389
|
+
}
|
|
3200
3390
|
/**
|
|
3201
3391
|
* Create a proxy MCP server for a single active Claude process/session.
|
|
3202
3392
|
* The process lifecycle owns the server lifecycle via session-manager.
|
|
@@ -3569,14 +3759,11 @@ var ClaudeCodeLanguageModel = class {
|
|
|
3569
3759
|
const hasExistingSession = !!getClaudeSessionId(sk);
|
|
3570
3760
|
const includeHistoryContext = !hasExistingSession && hasPriorConversation;
|
|
3571
3761
|
const reasoningEffort = this.getReasoningEffort(options.providerOptions);
|
|
3572
|
-
const userMsg = getClaudeUserMessage(
|
|
3573
|
-
|
|
3574
|
-
includeHistoryContext,
|
|
3575
|
-
reasoningEffort
|
|
3576
|
-
);
|
|
3577
|
-
const [runtimeStatus, cliVersion] = await Promise.all([
|
|
3762
|
+
const userMsg = consumeExitPlanModeQuestionResult(sk, options.prompt) ?? getClaudeUserMessage(options.prompt, includeHistoryContext, reasoningEffort);
|
|
3763
|
+
const [runtimeStatus, cliVersion, planModeQuestionActive] = await Promise.all([
|
|
3578
3764
|
getRuntimeMcpStatus(),
|
|
3579
|
-
detectCliVersion(this.config.cliPath)
|
|
3765
|
+
detectCliVersion(this.config.cliPath),
|
|
3766
|
+
this.resolvePlanModeQuestion(compactionMode)
|
|
3580
3767
|
]);
|
|
3581
3768
|
const systemPromptFile = buildAppendedSystemPrompt(
|
|
3582
3769
|
cwd,
|
|
@@ -3665,6 +3852,20 @@ var ClaudeCodeLanguageModel = class {
|
|
|
3665
3852
|
if (block.name === "ExitPlanMode") {
|
|
3666
3853
|
const parsedInput = block.input ?? {};
|
|
3667
3854
|
const plan = parsedInput?.plan || "";
|
|
3855
|
+
if (planModeQuestionActive) {
|
|
3856
|
+
const questionCall = createExitPlanModeQuestionCall(
|
|
3857
|
+
sk,
|
|
3858
|
+
block.id,
|
|
3859
|
+
plan
|
|
3860
|
+
);
|
|
3861
|
+
responseText += questionCall.text;
|
|
3862
|
+
toolCalls.push({
|
|
3863
|
+
id: questionCall.toolCallId,
|
|
3864
|
+
name: questionCall.toolName,
|
|
3865
|
+
args: questionCall.input
|
|
3866
|
+
});
|
|
3867
|
+
continue;
|
|
3868
|
+
}
|
|
3668
3869
|
responseText += `
|
|
3669
3870
|
|
|
3670
3871
|
${plan}
|
|
@@ -3715,7 +3916,19 @@ ${plan}
|
|
|
3715
3916
|
error: String(err)
|
|
3716
3917
|
});
|
|
3717
3918
|
}
|
|
3718
|
-
|
|
3919
|
+
if (tc.name === "ExitPlanMode" && planModeQuestionActive) {
|
|
3920
|
+
const parsedInput = args;
|
|
3921
|
+
const plan = parsedInput?.plan || "";
|
|
3922
|
+
const questionCall = createExitPlanModeQuestionCall(sk, tc.id, plan);
|
|
3923
|
+
responseText += questionCall.text;
|
|
3924
|
+
toolCalls.push({
|
|
3925
|
+
id: questionCall.toolCallId,
|
|
3926
|
+
name: questionCall.toolName,
|
|
3927
|
+
args: questionCall.input
|
|
3928
|
+
});
|
|
3929
|
+
} else {
|
|
3930
|
+
toolCalls.push({ id: tc.id, name: tc.name, args });
|
|
3931
|
+
}
|
|
3719
3932
|
toolCallStreams.delete(msg.index);
|
|
3720
3933
|
}
|
|
3721
3934
|
}
|
|
@@ -3788,6 +4001,16 @@ ${plan}
|
|
|
3788
4001
|
});
|
|
3789
4002
|
}
|
|
3790
4003
|
for (const tc of result.toolCalls) {
|
|
4004
|
+
if (tc.name === QUESTION_TOOL_NAME) {
|
|
4005
|
+
content.push({
|
|
4006
|
+
type: "tool-call",
|
|
4007
|
+
toolCallId: tc.id,
|
|
4008
|
+
toolName: tc.name,
|
|
4009
|
+
input: JSON.stringify(tc.args),
|
|
4010
|
+
providerExecuted: false
|
|
4011
|
+
});
|
|
4012
|
+
continue;
|
|
4013
|
+
}
|
|
3791
4014
|
const {
|
|
3792
4015
|
name: mappedName,
|
|
3793
4016
|
input: mappedInput,
|
|
@@ -3810,11 +4033,13 @@ ${plan}
|
|
|
3810
4033
|
const usage = this.toUsage(result.usage);
|
|
3811
4034
|
return {
|
|
3812
4035
|
content,
|
|
3813
|
-
// Claude CLI's `result` message signals a fully-completed turn
|
|
3814
|
-
// tools have already been executed internally and final assistant
|
|
3815
|
-
//
|
|
3816
|
-
// loop
|
|
3817
|
-
finishReason: this.toFinishReason(
|
|
4036
|
+
// Claude CLI's `result` message normally signals a fully-completed turn:
|
|
4037
|
+
// tools have already been executed internally and final assistant text
|
|
4038
|
+
// has been produced. ExitPlanMode is the exception: we surface it as
|
|
4039
|
+
// opencode's native question tool so the outer loop must run that tool.
|
|
4040
|
+
finishReason: this.toFinishReason(
|
|
4041
|
+
result.toolCalls.some((tc) => tc.name === QUESTION_TOOL_NAME) ? "tool-calls" : "stop"
|
|
4042
|
+
),
|
|
3818
4043
|
usage,
|
|
3819
4044
|
request: { body: { text: userMsg } },
|
|
3820
4045
|
response: {
|
|
@@ -3918,13 +4143,19 @@ ${plan}
|
|
|
3918
4143
|
const hasActiveProcess = !!getActiveProcess(sk);
|
|
3919
4144
|
const includeHistoryContext = !hasExistingSession && !hasActiveProcess && hasPriorConversation;
|
|
3920
4145
|
const reasoningEffort = this.getReasoningEffort(options.providerOptions);
|
|
3921
|
-
const
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
4146
|
+
const exitPlanModeQuestionResult = compactionMode ? null : consumeExitPlanModeQuestionResult(sk, options.prompt);
|
|
4147
|
+
if (exitPlanModeQuestionResult) {
|
|
4148
|
+
log.info("sending plan approval decision to claude", { sk });
|
|
4149
|
+
}
|
|
4150
|
+
const userMsg = exitPlanModeQuestionResult ?? getClaudeUserMessage(options.prompt, includeHistoryContext, reasoningEffort, {
|
|
4151
|
+
compactionMode
|
|
4152
|
+
});
|
|
3927
4153
|
const resolvedProxy = compactionMode ? null : this.resolvedProxyTools();
|
|
4154
|
+
const loadLiveToolInfo = this.createLiveToolInfoLoader();
|
|
4155
|
+
const planModeQuestionActive = await this.resolvePlanModeQuestion(
|
|
4156
|
+
compactionMode,
|
|
4157
|
+
loadLiveToolInfo
|
|
4158
|
+
);
|
|
3928
4159
|
const self = this;
|
|
3929
4160
|
const previousPendingProxyCalls = compactionMode ? [] : getPendingProxyCalls(sk);
|
|
3930
4161
|
const previousPendingProxyMatches = previousPendingProxyCalls.map((call) => ({
|
|
@@ -4070,7 +4301,8 @@ ${plan}
|
|
|
4070
4301
|
const excludeServers = proxyMcpTools ? new Set(discovery.allEnabledServerNames) : void 0;
|
|
4071
4302
|
const taskProxyEnabled = resolvedProxy?.some((t) => t.name === "task") ?? false;
|
|
4072
4303
|
const questionProxyEnabled = resolvedProxy?.some((t) => t.name === "question") ?? false;
|
|
4073
|
-
const liveToolInfo = taskProxyEnabled || questionProxyEnabled ? await
|
|
4304
|
+
const liveToolInfo = taskProxyEnabled || questionProxyEnabled ? await loadLiveToolInfo() : {
|
|
4305
|
+
resolved: false,
|
|
4074
4306
|
taskDescription: void 0,
|
|
4075
4307
|
questionDescription: void 0,
|
|
4076
4308
|
hasQuestion: false
|
|
@@ -4362,6 +4594,37 @@ ${plan}
|
|
|
4362
4594
|
} catch {
|
|
4363
4595
|
}
|
|
4364
4596
|
};
|
|
4597
|
+
const finishWithExitPlanQuestion = (call) => {
|
|
4598
|
+
if (controllerClosed) return;
|
|
4599
|
+
endTextBlock();
|
|
4600
|
+
controller.enqueue({
|
|
4601
|
+
type: "tool-input-start",
|
|
4602
|
+
id: call.toolCallId,
|
|
4603
|
+
toolName: call.toolName,
|
|
4604
|
+
providerExecuted: false
|
|
4605
|
+
});
|
|
4606
|
+
controller.enqueue({
|
|
4607
|
+
type: "tool-call",
|
|
4608
|
+
toolCallId: call.toolCallId,
|
|
4609
|
+
toolName: call.toolName,
|
|
4610
|
+
input: JSON.stringify(call.input),
|
|
4611
|
+
providerExecuted: false
|
|
4612
|
+
});
|
|
4613
|
+
controller.enqueue({
|
|
4614
|
+
type: "finish",
|
|
4615
|
+
finishReason: toFinishReason("tool-calls"),
|
|
4616
|
+
usage: toUsage(resultMeta.usage),
|
|
4617
|
+
providerMetadata: {
|
|
4618
|
+
"claude-code": resultMeta
|
|
4619
|
+
}
|
|
4620
|
+
});
|
|
4621
|
+
controllerClosed = true;
|
|
4622
|
+
cleanupTurn();
|
|
4623
|
+
try {
|
|
4624
|
+
controller.close();
|
|
4625
|
+
} catch {
|
|
4626
|
+
}
|
|
4627
|
+
};
|
|
4365
4628
|
const drainNow = () => {
|
|
4366
4629
|
if (drainTimer) {
|
|
4367
4630
|
clearTimeout(drainTimer);
|
|
@@ -4687,6 +4950,21 @@ ${plan}
|
|
|
4687
4950
|
endTextBlock();
|
|
4688
4951
|
} else if (tc.name === "ExitPlanMode") {
|
|
4689
4952
|
const plan = parsedInput?.plan || "";
|
|
4953
|
+
if (planModeQuestionActive) {
|
|
4954
|
+
const questionCall = createExitPlanModeQuestionCall(
|
|
4955
|
+
sk,
|
|
4956
|
+
tc.id,
|
|
4957
|
+
plan
|
|
4958
|
+
);
|
|
4959
|
+
const planId2 = startTextBlock();
|
|
4960
|
+
controller.enqueue({
|
|
4961
|
+
type: "text-delta",
|
|
4962
|
+
id: planId2,
|
|
4963
|
+
delta: questionCall.text
|
|
4964
|
+
});
|
|
4965
|
+
finishWithExitPlanQuestion(questionCall);
|
|
4966
|
+
return;
|
|
4967
|
+
}
|
|
4690
4968
|
const planId = startTextBlock();
|
|
4691
4969
|
controller.enqueue({
|
|
4692
4970
|
type: "text-delta",
|
|
@@ -4853,6 +5131,21 @@ ${plan}
|
|
|
4853
5131
|
endTextBlock();
|
|
4854
5132
|
} else if (block.name === "ExitPlanMode") {
|
|
4855
5133
|
const plan = parsedInput?.plan || "";
|
|
5134
|
+
if (planModeQuestionActive) {
|
|
5135
|
+
const questionCall = createExitPlanModeQuestionCall(
|
|
5136
|
+
sk,
|
|
5137
|
+
block.id,
|
|
5138
|
+
plan
|
|
5139
|
+
);
|
|
5140
|
+
const planId2 = startTextBlock();
|
|
5141
|
+
controller.enqueue({
|
|
5142
|
+
type: "text-delta",
|
|
5143
|
+
id: planId2,
|
|
5144
|
+
delta: questionCall.text
|
|
5145
|
+
});
|
|
5146
|
+
finishWithExitPlanQuestion(questionCall);
|
|
5147
|
+
return;
|
|
5148
|
+
}
|
|
4856
5149
|
const planId = startTextBlock();
|
|
4857
5150
|
controller.enqueue({
|
|
4858
5151
|
type: "text-delta",
|
|
@@ -5818,6 +6111,7 @@ function collectStartupDiagnostics(providers, opencodeVersion) {
|
|
|
5818
6111
|
proxyTools: stringList(firstOption(providers, "proxyTools")),
|
|
5819
6112
|
mcpServers,
|
|
5820
6113
|
interactiveTransport: firstOption(providers, "interactive") === true || process.env.CLAUDE_CODE_INTERACTIVE_TRANSPORT === "1",
|
|
6114
|
+
planModeQuestion: firstOption(providers, "planModeQuestion") === true,
|
|
5821
6115
|
anthropicApiKeyInEnv: Boolean(
|
|
5822
6116
|
process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN
|
|
5823
6117
|
)
|
|
@@ -5906,6 +6200,7 @@ function createClaudeCode(settings = {}) {
|
|
|
5906
6200
|
controlRequestDenyMessage: settings.controlRequestDenyMessage,
|
|
5907
6201
|
proxyTools,
|
|
5908
6202
|
proxyToolTimeoutMs: settings.proxyToolTimeoutMs,
|
|
6203
|
+
planModeQuestion: settings.planModeQuestion ?? false,
|
|
5909
6204
|
webSearch: settings.webSearch,
|
|
5910
6205
|
hotReloadMcp: settings.hotReloadMcp ?? true,
|
|
5911
6206
|
proxyOpencodeMcpTools: settings.proxyOpencodeMcpTools ?? true,
|