@khalilgharbaoui/opencode-claude-code-plugin 0.11.2 → 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/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);
@@ -2071,8 +2227,10 @@ var SERVER_NAME = "opencode_proxy";
2071
2227
  var PROXY_TOOL_PREFIX = `mcp__${SERVER_NAME}__`;
2072
2228
  var PROXY_DEFAULT_TIMEOUT_MS = 10 * 60 * 1e3;
2073
2229
  var PROXY_PER_TOOL_DEFAULT_TIMEOUT_MS = {
2074
- task: 60 * 60 * 1e3
2230
+ task: 60 * 60 * 1e3,
2075
2231
  // 60 min
2232
+ question: 30 * 60 * 1e3
2233
+ // 30 min
2076
2234
  };
2077
2235
  var MAX_PROXY_TIMEOUT_MS = 2 ** 31 - 1;
2078
2236
  function resolveProxyCallTimeoutMs(toolName, input, overrides) {
@@ -2120,6 +2278,7 @@ function buildProxyTimeoutError(toolName, ms) {
2120
2278
  var TASK_PROXY_NOTE = "This is the ONLY tool that dispatches opencode subagents (including user @-mentions). Claude Code's built-in TaskCreate/TaskUpdate manage a local todo list and cannot dispatch subagents. Do not search config files to verify a subagent type exists \u2014 invalid types fail fast with a clear error. Foreground calls block until the subagent finishes; set `background` to request opencode's background execution mode. Task calls get a 60-minute proxy deadline by default (configurable via proxyToolTimeoutMs).";
2121
2279
  var AGENT_TYPES_HEADING = "Available agent types";
2122
2280
  var AGENT_BLURB_LIMIT = 140;
2281
+ var QUESTION_PROXY_NOTE = "This routes structured questions through opencode's native `question` tool, which renders a TUI form with the options you provide and blocks until the operator answers. Claude Code's built-in AskUserQuestion is disabled in this environment; this proxy is the ONLY way to ask the operator for a decision or clarification. Answers come back as arrays of selected labels (set `multiple: true` to allow more than one). If the operator dismisses the form the call returns an error \u2014 treat that as 'no answer' and stop, do not guess. Question calls get a 30-minute proxy deadline by default (configurable via proxyToolTimeoutMs); for long-AFK scenarios prefer fewer, high-signal questions.";
2123
2282
  function extractAgentTypeList(liveDescription) {
2124
2283
  const live = liveDescription?.trim();
2125
2284
  if (!live) return void 0;
@@ -2148,6 +2307,19 @@ function overlayTaskProxyDescription(tools, liveDescription) {
2148
2307
  ${t.description}` } : t
2149
2308
  );
2150
2309
  }
2310
+ function overlayQuestionProxyDescription(tools, liveDescription) {
2311
+ const live = liveDescription?.trim();
2312
+ if (!live) return tools;
2313
+ return tools.map(
2314
+ (t) => t.name === "question" ? { ...t, description: `${live}
2315
+
2316
+ ${QUESTION_PROXY_NOTE}` } : t
2317
+ );
2318
+ }
2319
+ function filterQuestionProxyByOpencodeSupport(tools, opencodeHasQuestion) {
2320
+ if (opencodeHasQuestion) return tools;
2321
+ return tools.filter((t) => t.name !== "question");
2322
+ }
2151
2323
  var DEFAULT_PROXY_TOOLS = [
2152
2324
  {
2153
2325
  name: "bash",
@@ -2271,6 +2443,56 @@ var DEFAULT_PROXY_TOOLS = [
2271
2443
  },
2272
2444
  required: ["description", "prompt", "subagent_type"]
2273
2445
  }
2446
+ },
2447
+ {
2448
+ name: "question",
2449
+ description: "Ask the operator structured questions with options and receive their answers back. Routed through opencode's native `question` tool so the prompt renders as a real TUI form (with options and a custom-answer field) instead of a plain text turn. Use this when you need a decision, clarification, or preference from the operator mid-task. " + QUESTION_PROXY_NOTE,
2450
+ inputSchema: {
2451
+ type: "object",
2452
+ properties: {
2453
+ questions: {
2454
+ type: "array",
2455
+ description: "Questions to ask.",
2456
+ items: {
2457
+ type: "object",
2458
+ properties: {
2459
+ question: {
2460
+ type: "string",
2461
+ description: "Complete question."
2462
+ },
2463
+ header: {
2464
+ type: "string",
2465
+ description: "Very short label (max 30 chars)."
2466
+ },
2467
+ options: {
2468
+ type: "array",
2469
+ description: "Available choices.",
2470
+ items: {
2471
+ type: "object",
2472
+ properties: {
2473
+ label: {
2474
+ type: "string",
2475
+ description: "Display text (1-5 words, concise)."
2476
+ },
2477
+ description: {
2478
+ type: "string",
2479
+ description: "Explanation of choice."
2480
+ }
2481
+ },
2482
+ required: ["label", "description"]
2483
+ }
2484
+ },
2485
+ multiple: {
2486
+ type: "boolean",
2487
+ description: "Allow selecting multiple choices. Defaults to false."
2488
+ }
2489
+ },
2490
+ required: ["question", "header", "options"]
2491
+ }
2492
+ }
2493
+ },
2494
+ required: ["questions"]
2495
+ }
2274
2496
  }
2275
2497
  ];
2276
2498
  async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverrides) {
@@ -2388,23 +2610,14 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
2388
2610
  if (timer) clearTimeout(timer);
2389
2611
  pending.delete(callId);
2390
2612
  });
2391
- if (result.kind === "error") {
2392
- writeJson(res, {
2393
- jsonrpc: "2.0",
2394
- id: requestId,
2395
- result: {
2396
- content: [{ type: "text", text: result.message }],
2397
- isError: true
2398
- }
2399
- });
2400
- return;
2401
- }
2613
+ const text = result.kind === "error" ? result.message : result.text;
2614
+ const isError = result.kind === "error" || result.isError === true;
2402
2615
  writeJson(res, {
2403
2616
  jsonrpc: "2.0",
2404
2617
  id: requestId,
2405
2618
  result: {
2406
- content: [{ type: "text", text: result.text }],
2407
- isError: result.isError === true
2619
+ content: [{ type: "text", text }],
2620
+ isError
2408
2621
  }
2409
2622
  });
2410
2623
  return;
@@ -2532,7 +2745,13 @@ function disallowedToolFlags(tools) {
2532
2745
  glob: ["Glob"],
2533
2746
  grep: ["Grep"],
2534
2747
  webfetch: ["WebFetch"],
2535
- task: ["Agent"]
2748
+ task: ["Agent"],
2749
+ // `question` disables Claude Code's built-in `AskUserQuestion` so the
2750
+ // structured-questions path flows through opencode's native `question`
2751
+ // tool instead — same UI/permission/audit benefits as the other
2752
+ // proxies. Without this, the model can call both and the two paths
2753
+ // diverge (opencode's form vs the headless deny-and-render fallback).
2754
+ question: ["AskUserQuestion"]
2536
2755
  };
2537
2756
  const out = [];
2538
2757
  const seen = /* @__PURE__ */ new Set();
@@ -2923,6 +3142,14 @@ Subagent dispatch in this environment goes through exactly one tool: \`mcp__open
2923
3142
  - If that tool is not in your visible tool list it is deferred \u2014 load it with ToolSearch (\`select:mcp__opencode_proxy__task\`), then call it.
2924
3143
  - Claude Code's built-in TaskCreate/TaskUpdate/TaskList manage a local todo list. They cannot dispatch subagents; creating a task there runs nothing. Never report a subagent as dispatched unless \`mcp__opencode_proxy__task\` returned its result.
2925
3144
  - Do not verify a subagent's existence by searching config files \u2014 the tool's description lists the available agent types, and invalid types fail fast with a clear error.`;
3145
+ var QUESTION_PROXY_HINT = `## Asking the operator questions
3146
+
3147
+ Structured questions in this environment go through exactly one tool: \`mcp__opencode_proxy__question\`.
3148
+
3149
+ - When you need to ask the operator a question with options, call \`mcp__opencode_proxy__question\` with a \`questions\` array (each item has \`question\`, \`header\`, \`options\` of \`{label, description}\`, and optional \`multiple\`).
3150
+ - If that tool is not in your visible tool list it is deferred \u2014 load it with ToolSearch (\`select:mcp__opencode_proxy__question\`), then call it by its FULL name.
3151
+ - Do NOT call bare \`question\` \u2014 that is not a tool. Always use the full \`mcp__opencode_proxy__question\` name when invoking it.
3152
+ - Claude Code's built-in \`AskUserQuestion\` is disabled in this environment; the proxy is the only way to ask structured questions.`;
2926
3153
  var CLAUDE_CLI_CONTEXT_NOTE = `## Runtime environment: Claude Code CLI
2927
3154
 
2928
3155
  You are running via the Claude Code CLI (not a direct API call). This affects context management:
@@ -3098,22 +3325,67 @@ var ClaudeCodeLanguageModel = class {
3098
3325
  return out.length > 0 ? out : null;
3099
3326
  }
3100
3327
  /**
3101
- * Live description of opencode's `task` tool for the current
3102
- * provider/model, exactly as opencode's registry renders it for native
3103
- * models including the "Available agent types" list (built from the
3104
- * default agent's permissions). Overlaid onto the static `task` proxy
3105
- * def so Claude sees the same subagent catalog native opencode models
3106
- * see, instead of hunting through config files. Returns undefined when
3107
- * the SDK client is unavailable (direct AI-SDK use, tests) so the
3108
- * static def stands.
3328
+ * Live tool info derived from a single `client.tool.list()` fetch:
3329
+ *
3330
+ * - `taskDescription`: opencode's `task` tool description exactly as the
3331
+ * registry renders it for native models, including the "Available
3332
+ * agent types" list. Overlaid onto the static `task` proxy def so
3333
+ * Claude sees the same subagent catalog native models see, instead
3334
+ * of hunting through config files.
3335
+ * - `questionDescription` / `hasQuestion`: opencode's `question` tool
3336
+ * description and whether the registry has the entry at all. Older
3337
+ * builds lack it, in which case a `mcp__opencode_proxy__question`
3338
+ * call resolves to `⚙ invalid`; the version gate drops the def.
3339
+ *
3340
+ * Returns undefined/false when the SDK client is unavailable (direct
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.
3109
3344
  */
3110
- async fetchLiveTaskDescription() {
3345
+ async fetchLiveToolInfo() {
3111
3346
  const items = await fetchOpencodeToolList(
3112
3347
  this.config.provider,
3113
3348
  this.modelId,
3114
3349
  this.config.cwd
3115
3350
  );
3116
- return items?.find((item) => item.id === "task")?.description || void 0;
3351
+ const question = items?.find((item) => item.id === "question");
3352
+ return {
3353
+ resolved: items !== void 0,
3354
+ taskDescription: items?.find((item) => item.id === "task")?.description,
3355
+ questionDescription: question?.description,
3356
+ hasQuestion: !!question
3357
+ };
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;
3117
3389
  }
3118
3390
  /**
3119
3391
  * Create a proxy MCP server for a single active Claude process/session.
@@ -3487,14 +3759,11 @@ var ClaudeCodeLanguageModel = class {
3487
3759
  const hasExistingSession = !!getClaudeSessionId(sk);
3488
3760
  const includeHistoryContext = !hasExistingSession && hasPriorConversation;
3489
3761
  const reasoningEffort = this.getReasoningEffort(options.providerOptions);
3490
- const userMsg = getClaudeUserMessage(
3491
- options.prompt,
3492
- includeHistoryContext,
3493
- reasoningEffort
3494
- );
3495
- 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([
3496
3764
  getRuntimeMcpStatus(),
3497
- detectCliVersion(this.config.cliPath)
3765
+ detectCliVersion(this.config.cliPath),
3766
+ this.resolvePlanModeQuestion(compactionMode)
3498
3767
  ]);
3499
3768
  const systemPromptFile = buildAppendedSystemPrompt(
3500
3769
  cwd,
@@ -3583,6 +3852,20 @@ var ClaudeCodeLanguageModel = class {
3583
3852
  if (block.name === "ExitPlanMode") {
3584
3853
  const parsedInput = block.input ?? {};
3585
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
+ }
3586
3869
  responseText += `
3587
3870
 
3588
3871
  ${plan}
@@ -3633,7 +3916,19 @@ ${plan}
3633
3916
  error: String(err)
3634
3917
  });
3635
3918
  }
3636
- 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
+ }
3637
3932
  toolCallStreams.delete(msg.index);
3638
3933
  }
3639
3934
  }
@@ -3706,6 +4001,16 @@ ${plan}
3706
4001
  });
3707
4002
  }
3708
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
+ }
3709
4014
  const {
3710
4015
  name: mappedName,
3711
4016
  input: mappedInput,
@@ -3728,11 +4033,13 @@ ${plan}
3728
4033
  const usage = this.toUsage(result.usage);
3729
4034
  return {
3730
4035
  content,
3731
- // Claude CLI's `result` message signals a fully-completed turn
3732
- // tools have already been executed internally and final assistant
3733
- // text has been produced. Always report "stop" so opencode doesn't
3734
- // loop expecting to run tools itself.
3735
- 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
+ ),
3736
4043
  usage,
3737
4044
  request: { body: { text: userMsg } },
3738
4045
  response: {
@@ -3836,13 +4143,19 @@ ${plan}
3836
4143
  const hasActiveProcess = !!getActiveProcess(sk);
3837
4144
  const includeHistoryContext = !hasExistingSession && !hasActiveProcess && hasPriorConversation;
3838
4145
  const reasoningEffort = this.getReasoningEffort(options.providerOptions);
3839
- const userMsg = getClaudeUserMessage(
3840
- options.prompt,
3841
- includeHistoryContext,
3842
- reasoningEffort,
3843
- { compactionMode }
3844
- );
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
+ });
3845
4153
  const resolvedProxy = compactionMode ? null : this.resolvedProxyTools();
4154
+ const loadLiveToolInfo = this.createLiveToolInfoLoader();
4155
+ const planModeQuestionActive = await this.resolvePlanModeQuestion(
4156
+ compactionMode,
4157
+ loadLiveToolInfo
4158
+ );
3846
4159
  const self = this;
3847
4160
  const previousPendingProxyCalls = compactionMode ? [] : getPendingProxyCalls(sk);
3848
4161
  const previousPendingProxyMatches = previousPendingProxyCalls.map((call) => ({
@@ -3987,26 +4300,53 @@ ${plan}
3987
4300
  );
3988
4301
  const excludeServers = proxyMcpTools ? new Set(discovery.allEnabledServerNames) : void 0;
3989
4302
  const taskProxyEnabled = resolvedProxy?.some((t) => t.name === "task") ?? false;
4303
+ const questionProxyEnabled = resolvedProxy?.some((t) => t.name === "question") ?? false;
4304
+ const liveToolInfo = taskProxyEnabled || questionProxyEnabled ? await loadLiveToolInfo() : {
4305
+ resolved: false,
4306
+ taskDescription: void 0,
4307
+ questionDescription: void 0,
4308
+ hasQuestion: false
4309
+ };
3990
4310
  let enrichedProxy = resolvedProxy;
3991
- if (resolvedProxy && taskProxyEnabled) {
3992
- const liveTaskDescription = await self.fetchLiveTaskDescription();
4311
+ if (enrichedProxy && taskProxyEnabled) {
3993
4312
  enrichedProxy = overlayTaskProxyDescription(
3994
- resolvedProxy,
3995
- liveTaskDescription
4313
+ enrichedProxy,
4314
+ liveToolInfo.taskDescription
3996
4315
  );
3997
4316
  log.info("task proxy description overlay", {
3998
- applied: Boolean(liveTaskDescription),
3999
- liveDescriptionLength: liveTaskDescription?.length ?? 0,
4317
+ applied: Boolean(liveToolInfo.taskDescription),
4318
+ liveDescriptionLength: liveToolInfo.taskDescription?.length ?? 0,
4000
4319
  listsAgentTypes: Boolean(
4001
- liveTaskDescription?.includes("Available agent types")
4320
+ liveToolInfo.taskDescription?.includes(
4321
+ "Available agent types"
4322
+ )
4002
4323
  )
4003
4324
  });
4004
4325
  }
4005
- const combinedProxyTools = enrichedProxy || proxyMcpTools ? [...enrichedProxy ?? [], ...proxyMcpTools ?? []] : null;
4326
+ if (enrichedProxy && questionProxyEnabled) {
4327
+ enrichedProxy = overlayQuestionProxyDescription(
4328
+ enrichedProxy,
4329
+ liveToolInfo.hasQuestion ? liveToolInfo.questionDescription : void 0
4330
+ );
4331
+ enrichedProxy = filterQuestionProxyByOpencodeSupport(
4332
+ enrichedProxy,
4333
+ liveToolInfo.hasQuestion
4334
+ );
4335
+ log.info("question proxy version gate", {
4336
+ opencodeHasQuestion: liveToolInfo.hasQuestion,
4337
+ kept: liveToolInfo.hasQuestion
4338
+ });
4339
+ }
4340
+ const combinedList = [
4341
+ ...enrichedProxy ?? [],
4342
+ ...proxyMcpTools ?? []
4343
+ ];
4344
+ const combinedProxyTools = combinedList.length > 0 ? combinedList : null;
4006
4345
  if (!proxyServer && combinedProxyTools) {
4007
4346
  proxyServer = await self.ensureProxyServer(combinedProxyTools, sk);
4008
4347
  }
4009
- const proxyDisallowed = resolvedProxy ? disallowedToolFlags(resolvedProxy) : [];
4348
+ const questionProxyActive = enrichedProxy?.some((t) => t.name === "question") ?? false;
4349
+ const proxyDisallowed = enrichedProxy ? disallowedToolFlags(enrichedProxy) : [];
4010
4350
  const extraDisallowed = [];
4011
4351
  if (self.config.webSearch === "disabled") extraDisallowed.push("WebSearch");
4012
4352
  const allDisallowed = [...proxyDisallowed, ...extraDisallowed];
@@ -4021,7 +4361,8 @@ ${plan}
4021
4361
  self.config.multiStepContinuation !== false,
4022
4362
  [
4023
4363
  ...extractSystemMessages(options.prompt),
4024
- ...taskProxyEnabled ? [SUBAGENT_DISPATCH_HINT] : []
4364
+ ...taskProxyEnabled ? [SUBAGENT_DISPATCH_HINT] : [],
4365
+ ...questionProxyActive ? [QUESTION_PROXY_HINT] : []
4025
4366
  ]
4026
4367
  );
4027
4368
  cliArgs = buildCliArgs({
@@ -4253,6 +4594,37 @@ ${plan}
4253
4594
  } catch {
4254
4595
  }
4255
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
+ };
4256
4628
  const drainNow = () => {
4257
4629
  if (drainTimer) {
4258
4630
  clearTimeout(drainTimer);
@@ -4578,6 +4950,21 @@ ${plan}
4578
4950
  endTextBlock();
4579
4951
  } else if (tc.name === "ExitPlanMode") {
4580
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
+ }
4581
4968
  const planId = startTextBlock();
4582
4969
  controller.enqueue({
4583
4970
  type: "text-delta",
@@ -4744,6 +5131,21 @@ ${plan}
4744
5131
  endTextBlock();
4745
5132
  } else if (block.name === "ExitPlanMode") {
4746
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
+ }
4747
5149
  const planId = startTextBlock();
4748
5150
  controller.enqueue({
4749
5151
  type: "text-delta",
@@ -5238,21 +5640,21 @@ var defaultModels = {
5238
5640
  family: "haiku",
5239
5641
  reasoning: false,
5240
5642
  context: 2e5,
5241
- output: 8192,
5643
+ output: 64e3,
5242
5644
  cost: haikuCost,
5243
5645
  multiplier: 1,
5244
- releaseDate: "2024-10-22"
5646
+ releaseDate: "2025-10-01"
5245
5647
  }),
5246
5648
  "claude-sonnet-4-5": defineModel({
5247
5649
  id: "claude-sonnet-4-5",
5248
5650
  name: "Claude Sonnet 4.5",
5249
5651
  family: "sonnet",
5250
5652
  reasoning: true,
5251
- context: 1e6,
5252
- output: 16384,
5653
+ context: 2e5,
5654
+ output: 64e3,
5253
5655
  cost: sonnetCost,
5254
5656
  multiplier: 3,
5255
- releaseDate: "2025-04-14"
5657
+ releaseDate: "2025-09-29"
5256
5658
  }),
5257
5659
  "claude-sonnet-4-6": defineModel({
5258
5660
  id: "claude-sonnet-4-6",
@@ -5260,7 +5662,7 @@ var defaultModels = {
5260
5662
  family: "sonnet",
5261
5663
  reasoning: true,
5262
5664
  context: 1e6,
5263
- output: 16384,
5665
+ output: 128e3,
5264
5666
  cost: sonnetCost,
5265
5667
  multiplier: 3,
5266
5668
  releaseDate: "2025-06-19"
@@ -5281,11 +5683,11 @@ var defaultModels = {
5281
5683
  name: "Claude Opus 4.5",
5282
5684
  family: "opus",
5283
5685
  reasoning: true,
5284
- context: 1e6,
5285
- output: 16384,
5686
+ context: 2e5,
5687
+ output: 64e3,
5286
5688
  cost: opusCost,
5287
5689
  multiplier: 5,
5288
- releaseDate: "2025-04-14"
5690
+ releaseDate: "2025-11-01"
5289
5691
  }),
5290
5692
  "claude-opus-4-6": defineModel({
5291
5693
  id: "claude-opus-4-6",
@@ -5293,7 +5695,7 @@ var defaultModels = {
5293
5695
  family: "opus",
5294
5696
  reasoning: true,
5295
5697
  context: 1e6,
5296
- output: 16384,
5698
+ output: 128e3,
5297
5699
  cost: opusCost,
5298
5700
  multiplier: 5,
5299
5701
  releaseDate: "2025-06-19"
@@ -5304,7 +5706,7 @@ var defaultModels = {
5304
5706
  family: "opus",
5305
5707
  reasoning: true,
5306
5708
  context: 1e6,
5307
- output: 16384,
5709
+ output: 128e3,
5308
5710
  cost: opusCost,
5309
5711
  multiplier: 5,
5310
5712
  releaseDate: "2025-07-16"
@@ -5315,7 +5717,7 @@ var defaultModels = {
5315
5717
  family: "opus",
5316
5718
  reasoning: true,
5317
5719
  context: 1e6,
5318
- output: 16384,
5720
+ output: 128e3,
5319
5721
  cost: opusCost,
5320
5722
  multiplier: 5,
5321
5723
  releaseDate: "2026-05-28"
@@ -5337,7 +5739,7 @@ var defaultModels = {
5337
5739
  family: "fable",
5338
5740
  reasoning: true,
5339
5741
  context: 1e6,
5340
- output: 16384,
5742
+ output: 128e3,
5341
5743
  cost: fableCost,
5342
5744
  multiplier: 10,
5343
5745
  releaseDate: "2026-06-09"
@@ -5352,7 +5754,7 @@ var defaultModels = {
5352
5754
  family: "mythos",
5353
5755
  reasoning: true,
5354
5756
  context: 1e6,
5355
- output: 16384,
5757
+ output: 128e3,
5356
5758
  cost: fableCost,
5357
5759
  multiplier: 10,
5358
5760
  releaseDate: "2026-06-09"
@@ -5709,6 +6111,7 @@ function collectStartupDiagnostics(providers, opencodeVersion) {
5709
6111
  proxyTools: stringList(firstOption(providers, "proxyTools")),
5710
6112
  mcpServers,
5711
6113
  interactiveTransport: firstOption(providers, "interactive") === true || process.env.CLAUDE_CODE_INTERACTIVE_TRANSPORT === "1",
6114
+ planModeQuestion: firstOption(providers, "planModeQuestion") === true,
5712
6115
  anthropicApiKeyInEnv: Boolean(
5713
6116
  process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN
5714
6117
  )
@@ -5797,6 +6200,7 @@ function createClaudeCode(settings = {}) {
5797
6200
  controlRequestDenyMessage: settings.controlRequestDenyMessage,
5798
6201
  proxyTools,
5799
6202
  proxyToolTimeoutMs: settings.proxyToolTimeoutMs,
6203
+ planModeQuestion: settings.planModeQuestion ?? false,
5800
6204
  webSearch: settings.webSearch,
5801
6205
  hotReloadMcp: settings.hotReloadMcp ?? true,
5802
6206
  proxyOpencodeMcpTools: settings.proxyOpencodeMcpTools ?? true,