@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 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
- options.prompt,
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
- toolCalls.push({ id: tc.id, name: tc.name, args });
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
- // text has been produced. Always report "stop" so opencode doesn't
3816
- // loop expecting to run tools itself.
3817
- finishReason: this.toFinishReason("stop"),
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 userMsg = getClaudeUserMessage(
3922
- options.prompt,
3923
- includeHistoryContext,
3924
- reasoningEffort,
3925
- { compactionMode }
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 self.fetchLiveToolInfo() : {
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,