@khalilgharbaoui/opencode-claude-code-plugin 0.12.0 → 0.13.0

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);
@@ -2056,6 +2212,31 @@ function spawnInteractiveProcess(opts) {
2056
2212
  };
2057
2213
  }
2058
2214
 
2215
+ // src/compression-store.ts
2216
+ var MAX_COMPRESSION_ENTRIES = 32;
2217
+ var compressions = /* @__PURE__ */ new Map();
2218
+ function storeCompressionSummary(sessionKey2, summary) {
2219
+ compressions.set(sessionKey2, { summary, restartPending: true });
2220
+ while (compressions.size > MAX_COMPRESSION_ENTRIES) {
2221
+ const oldest = compressions.keys().next();
2222
+ if (oldest.done) break;
2223
+ compressions.delete(oldest.value);
2224
+ log.info("compression store evicted oldest entry", { sessionKey: oldest.value });
2225
+ }
2226
+ }
2227
+ function getCompressionSummary(sessionKey2) {
2228
+ return compressions.get(sessionKey2)?.summary;
2229
+ }
2230
+ function consumeCompressionRestart(sessionKey2) {
2231
+ const state = compressions.get(sessionKey2);
2232
+ if (!state?.restartPending) return false;
2233
+ state.restartPending = false;
2234
+ return true;
2235
+ }
2236
+ function clearCompression(sessionKey2) {
2237
+ compressions.delete(sessionKey2);
2238
+ }
2239
+
2059
2240
  // src/proxy-mcp.ts
2060
2241
  import { createServer } from "http";
2061
2242
  import * as fs4 from "fs";
@@ -2123,6 +2304,7 @@ var TASK_PROXY_NOTE = "This is the ONLY tool that dispatches opencode subagents
2123
2304
  var AGENT_TYPES_HEADING = "Available agent types";
2124
2305
  var AGENT_BLURB_LIMIT = 140;
2125
2306
  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.";
2307
+ var COMPRESS_PROXY_NOTE = "The current turn continues normally after this call \u2014 finish what you are doing. The reset happens at the START of the next turn: the Claude Code session is discarded and a fresh one begins with your summary as its only prior context. Everything else, including tool output and files you read, is gone, so write the summary as the authoritative record. Call this once per compression, when older resolved work no longer needs full detail.";
2126
2308
  function extractAgentTypeList(liveDescription) {
2127
2309
  const live = liveDescription?.trim();
2128
2310
  if (!live) return void 0;
@@ -2337,9 +2519,23 @@ var DEFAULT_PROXY_TOOLS = [
2337
2519
  },
2338
2520
  required: ["questions"]
2339
2521
  }
2522
+ },
2523
+ {
2524
+ name: "compress",
2525
+ description: "Replace older conversation detail with a summary you write, then continue in a fresh Claude Code session. Handled inside the plugin, so it never prompts the operator. " + COMPRESS_PROXY_NOTE,
2526
+ inputSchema: {
2527
+ type: "object",
2528
+ properties: {
2529
+ summary: {
2530
+ type: "string",
2531
+ description: "Dense technical summary of the work being compressed: decisions made, files changed, commands run and their outcomes, and what is still open. This is the ONLY prior context that survives, so anything omitted is lost."
2532
+ }
2533
+ },
2534
+ required: ["summary"]
2535
+ }
2340
2536
  }
2341
2537
  ];
2342
- async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverrides) {
2538
+ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverrides, interceptors) {
2343
2539
  const calls = new EventEmitter3();
2344
2540
  const pending = /* @__PURE__ */ new Map();
2345
2541
  const server2 = createServer(async (req, res) => {
@@ -2416,6 +2612,19 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
2416
2612
  });
2417
2613
  return;
2418
2614
  }
2615
+ const interceptor = interceptors?.get(toolName);
2616
+ if (interceptor) {
2617
+ let intercepted;
2618
+ try {
2619
+ intercepted = await interceptor(input);
2620
+ } catch (interceptorError) {
2621
+ const message = interceptorError instanceof Error ? interceptorError.message : String(interceptorError);
2622
+ log.warn("proxy-mcp interceptor failed", { toolName, error: message });
2623
+ intercepted = { kind: "error", message };
2624
+ }
2625
+ writeToolCallResult(res, requestId, intercepted);
2626
+ return;
2627
+ }
2419
2628
  const callId = crypto2.randomUUID();
2420
2629
  log.info("proxy-mcp tool call received", {
2421
2630
  callId,
@@ -2454,16 +2663,7 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
2454
2663
  if (timer) clearTimeout(timer);
2455
2664
  pending.delete(callId);
2456
2665
  });
2457
- const text = result.kind === "error" ? result.message : result.text;
2458
- const isError = result.kind === "error" || result.isError === true;
2459
- writeJson(res, {
2460
- jsonrpc: "2.0",
2461
- id: requestId,
2462
- result: {
2463
- content: [{ type: "text", text }],
2464
- isError
2465
- }
2466
- });
2666
+ writeToolCallResult(res, requestId, result);
2467
2667
  return;
2468
2668
  }
2469
2669
  writeJson(res, {
@@ -2618,6 +2818,18 @@ function readBody(req) {
2618
2818
  req.on("error", reject);
2619
2819
  });
2620
2820
  }
2821
+ function writeToolCallResult(res, requestId, result) {
2822
+ const text = result.kind === "error" ? result.message : result.text;
2823
+ const isError = result.kind === "error" || result.isError === true;
2824
+ writeJson(res, {
2825
+ jsonrpc: "2.0",
2826
+ id: requestId ?? null,
2827
+ result: {
2828
+ content: [{ type: "text", text }],
2829
+ isError
2830
+ }
2831
+ });
2832
+ }
2621
2833
  function writeJson(res, body) {
2622
2834
  const payload = JSON.stringify(body);
2623
2835
  res.statusCode = 200;
@@ -3003,6 +3215,15 @@ You are running via the Claude Code CLI (not a direct API call). This affects co
3003
3215
  - Context window management is handled automatically by Claude CLI's own session history.
3004
3216
  - Ignore any system instructions that tell you to call \`compress\` \u2014 they are intended for direct API providers, not this environment.
3005
3217
  - DCP context injections (AGENTS.md, dynamic state) arrive via the system prompt and are already applied.`;
3218
+ var CLAUDE_CLI_COMPRESS_NOTE = `## Runtime environment: Claude Code CLI
3219
+
3220
+ You are running via the Claude Code CLI (not a direct API call). This affects context management:
3221
+
3222
+ - To compress context, call \`mcp__opencode_proxy__compress\` with a \`summary\` argument. Use that exact full name.
3223
+ - The reset happens at the start of your NEXT turn: this Claude Code session is discarded and a fresh one starts with your summary as its only prior context. Keep working normally after the call.
3224
+ - Everything outside the summary is gone after the reset \u2014 tool output, files you read, and the earlier conversation are not replayed. Write the summary as the authoritative record.
3225
+ - The \`distill\`, \`prune\`, and \`extract\` tools are NOT available.
3226
+ - DCP context injections (AGENTS.md, dynamic state) arrive via the system prompt and are already applied.`;
3006
3227
  function extractSystemMessages(prompt) {
3007
3228
  const out = [];
3008
3229
  for (const msg of prompt) {
@@ -3019,9 +3240,18 @@ function extractSystemMessages(prompt) {
3019
3240
  }
3020
3241
  return out;
3021
3242
  }
3022
- function buildAppendedSystemPrompt(cwd, includeMultiStepHint = true, extraSystemContent = []) {
3243
+ function buildAppendedSystemPrompt(cwd, includeMultiStepHint = true, extraSystemContent = [], options = {}) {
3023
3244
  const parts = [];
3024
- parts.push(CLAUDE_CLI_CONTEXT_NOTE);
3245
+ if (options.compressionSummary?.trim()) {
3246
+ parts.push(
3247
+ `## Summary of earlier work (context was compressed)
3248
+
3249
+ ${options.compressionSummary.trim()}`
3250
+ );
3251
+ }
3252
+ parts.push(
3253
+ options.compressEnabled ? CLAUDE_CLI_COMPRESS_NOTE : CLAUDE_CLI_CONTEXT_NOTE
3254
+ );
3025
3255
  for (const s of extraSystemContent) {
3026
3256
  if (s.trim()) parts.push(s.trim());
3027
3257
  }
@@ -3182,7 +3412,9 @@ var ClaudeCodeLanguageModel = class {
3182
3412
  * call resolves to `⚙ invalid`; the version gate drops the def.
3183
3413
  *
3184
3414
  * Returns undefined/false when the SDK client is unavailable (direct
3185
- * AI-SDK use, tests) so the static defs stand.
3415
+ * AI-SDK use, tests) so the static defs stand. `resolved` distinguishes
3416
+ * "the registry answered and has no `question` entry" from "nobody
3417
+ * answered": only the former is a real version-gate signal.
3186
3418
  */
3187
3419
  async fetchLiveToolInfo() {
3188
3420
  const items = await fetchOpencodeToolList(
@@ -3192,18 +3424,71 @@ var ClaudeCodeLanguageModel = class {
3192
3424
  );
3193
3425
  const question = items?.find((item) => item.id === "question");
3194
3426
  return {
3427
+ resolved: items !== void 0,
3195
3428
  taskDescription: items?.find((item) => item.id === "task")?.description,
3196
3429
  questionDescription: question?.description,
3197
3430
  hasQuestion: !!question
3198
3431
  };
3199
3432
  }
3433
+ /** Share one lazy registry request within a turn without making it stale. */
3434
+ createLiveToolInfoLoader() {
3435
+ let pending;
3436
+ return () => {
3437
+ pending ??= this.fetchLiveToolInfo();
3438
+ return pending;
3439
+ };
3440
+ }
3441
+ /**
3442
+ * Whether the ExitPlanMode approval bridge is live for this turn: the
3443
+ * operator opted in AND opencode's registry actually has the `question`
3444
+ * tool. Without the registry entry the emitted tool-call would render as
3445
+ * `⚙ invalid` and wedge the turn, so the plugin keeps the text path.
3446
+ */
3447
+ async resolvePlanModeQuestion(compactionMode, loadLiveToolInfo = () => this.fetchLiveToolInfo()) {
3448
+ if (compactionMode || this.config.planModeQuestion !== true) return false;
3449
+ const info = await loadLiveToolInfo();
3450
+ const active = isPlanModeQuestionActive({
3451
+ configured: this.config.planModeQuestion,
3452
+ opencodeHasQuestion: info.hasQuestion,
3453
+ compactionMode
3454
+ });
3455
+ if (!active) {
3456
+ log.info("plan-mode question gate", {
3457
+ opencodeHasQuestion: info.hasQuestion,
3458
+ registryResolved: info.resolved,
3459
+ active
3460
+ });
3461
+ }
3462
+ return active;
3463
+ }
3200
3464
  /**
3201
3465
  * Create a proxy MCP server for a single active Claude process/session.
3202
3466
  * The process lifecycle owns the server lifecycle via session-manager.
3203
3467
  */
3204
3468
  async ensureProxyServer(tools, sessionKeyForCalls) {
3205
3469
  const timeoutOverrides = this.config.proxyToolTimeoutMs;
3206
- const srv = await createProxyMcpServer(tools, timeoutOverrides);
3470
+ const interceptors = /* @__PURE__ */ new Map();
3471
+ if (tools.some((t) => t.name === "compress")) {
3472
+ interceptors.set("compress", (input) => {
3473
+ const summary = typeof input.summary === "string" ? input.summary.trim() : "";
3474
+ if (!summary) {
3475
+ return {
3476
+ kind: "error",
3477
+ message: "compress needs a non-empty `summary`: it becomes the only prior context after the reset. Nothing was compressed."
3478
+ };
3479
+ }
3480
+ storeCompressionSummary(sessionKeyForCalls, summary);
3481
+ log.info("compress stored summary; session resets next turn", {
3482
+ sessionKey: sessionKeyForCalls,
3483
+ summaryLength: summary.length
3484
+ });
3485
+ return {
3486
+ kind: "text",
3487
+ text: "Summary stored. Finish this turn as normal; the next turn starts a fresh Claude Code session with this summary as its only prior context."
3488
+ };
3489
+ });
3490
+ }
3491
+ const srv = await createProxyMcpServer(tools, timeoutOverrides, interceptors);
3207
3492
  srv.calls.on("call", (call) => {
3208
3493
  queuePendingProxyCall(sessionKeyForCalls, call, timeoutOverrides);
3209
3494
  });
@@ -3565,23 +3850,24 @@ var ClaudeCodeLanguageModel = class {
3565
3850
  if (!hasPriorConversation) {
3566
3851
  deleteClaudeSessionId(sk);
3567
3852
  deleteActiveProcess(sk);
3853
+ clearCompression(sk);
3568
3854
  }
3569
3855
  const hasExistingSession = !!getClaudeSessionId(sk);
3570
3856
  const includeHistoryContext = !hasExistingSession && hasPriorConversation;
3571
3857
  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([
3858
+ const userMsg = consumeExitPlanModeQuestionResult(sk, options.prompt) ?? getClaudeUserMessage(options.prompt, includeHistoryContext, reasoningEffort);
3859
+ const [runtimeStatus, cliVersion, planModeQuestionActive] = await Promise.all([
3578
3860
  getRuntimeMcpStatus(),
3579
- detectCliVersion(this.config.cliPath)
3861
+ detectCliVersion(this.config.cliPath),
3862
+ this.resolvePlanModeQuestion(compactionMode)
3580
3863
  ]);
3581
3864
  const systemPromptFile = buildAppendedSystemPrompt(
3582
3865
  cwd,
3583
3866
  this.config.multiStepContinuation !== false,
3584
- extractSystemMessages(options.prompt)
3867
+ extractSystemMessages(options.prompt),
3868
+ // doGenerate has no proxy wiring, so `compress` is not callable here.
3869
+ // An existing summary still carries: it is this key's prior context.
3870
+ { compressEnabled: false, compressionSummary: getCompressionSummary(sk) }
3585
3871
  );
3586
3872
  const cliArgs = buildCliArgs({
3587
3873
  sessionKey: sk,
@@ -3665,6 +3951,20 @@ var ClaudeCodeLanguageModel = class {
3665
3951
  if (block.name === "ExitPlanMode") {
3666
3952
  const parsedInput = block.input ?? {};
3667
3953
  const plan = parsedInput?.plan || "";
3954
+ if (planModeQuestionActive) {
3955
+ const questionCall = createExitPlanModeQuestionCall(
3956
+ sk,
3957
+ block.id,
3958
+ plan
3959
+ );
3960
+ responseText += questionCall.text;
3961
+ toolCalls.push({
3962
+ id: questionCall.toolCallId,
3963
+ name: questionCall.toolName,
3964
+ args: questionCall.input
3965
+ });
3966
+ continue;
3967
+ }
3668
3968
  responseText += `
3669
3969
 
3670
3970
  ${plan}
@@ -3715,7 +4015,19 @@ ${plan}
3715
4015
  error: String(err)
3716
4016
  });
3717
4017
  }
3718
- toolCalls.push({ id: tc.id, name: tc.name, args });
4018
+ if (tc.name === "ExitPlanMode" && planModeQuestionActive) {
4019
+ const parsedInput = args;
4020
+ const plan = parsedInput?.plan || "";
4021
+ const questionCall = createExitPlanModeQuestionCall(sk, tc.id, plan);
4022
+ responseText += questionCall.text;
4023
+ toolCalls.push({
4024
+ id: questionCall.toolCallId,
4025
+ name: questionCall.toolName,
4026
+ args: questionCall.input
4027
+ });
4028
+ } else {
4029
+ toolCalls.push({ id: tc.id, name: tc.name, args });
4030
+ }
3719
4031
  toolCallStreams.delete(msg.index);
3720
4032
  }
3721
4033
  }
@@ -3788,6 +4100,16 @@ ${plan}
3788
4100
  });
3789
4101
  }
3790
4102
  for (const tc of result.toolCalls) {
4103
+ if (tc.name === QUESTION_TOOL_NAME) {
4104
+ content.push({
4105
+ type: "tool-call",
4106
+ toolCallId: tc.id,
4107
+ toolName: tc.name,
4108
+ input: JSON.stringify(tc.args),
4109
+ providerExecuted: false
4110
+ });
4111
+ continue;
4112
+ }
3791
4113
  const {
3792
4114
  name: mappedName,
3793
4115
  input: mappedInput,
@@ -3810,11 +4132,13 @@ ${plan}
3810
4132
  const usage = this.toUsage(result.usage);
3811
4133
  return {
3812
4134
  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"),
4135
+ // Claude CLI's `result` message normally signals a fully-completed turn:
4136
+ // tools have already been executed internally and final assistant text
4137
+ // has been produced. ExitPlanMode is the exception: we surface it as
4138
+ // opencode's native question tool so the outer loop must run that tool.
4139
+ finishReason: this.toFinishReason(
4140
+ result.toolCalls.some((tc) => tc.name === QUESTION_TOOL_NAME) ? "tool-calls" : "stop"
4141
+ ),
3818
4142
  usage,
3819
4143
  request: { body: { text: userMsg } },
3820
4144
  response: {
@@ -3913,18 +4237,25 @@ ${plan}
3913
4237
  if (!hasPriorConversation) {
3914
4238
  deleteClaudeSessionId(sk);
3915
4239
  deleteActiveProcess(sk);
4240
+ clearCompression(sk);
3916
4241
  }
3917
4242
  const hasExistingSession = !!getClaudeSessionId(sk);
3918
4243
  const hasActiveProcess = !!getActiveProcess(sk);
3919
4244
  const includeHistoryContext = !hasExistingSession && !hasActiveProcess && hasPriorConversation;
3920
4245
  const reasoningEffort = this.getReasoningEffort(options.providerOptions);
3921
- const userMsg = getClaudeUserMessage(
3922
- options.prompt,
3923
- includeHistoryContext,
3924
- reasoningEffort,
3925
- { compactionMode }
3926
- );
4246
+ const exitPlanModeQuestionResult = compactionMode ? null : consumeExitPlanModeQuestionResult(sk, options.prompt);
4247
+ if (exitPlanModeQuestionResult) {
4248
+ log.info("sending plan approval decision to claude", { sk });
4249
+ }
4250
+ const userMsg = exitPlanModeQuestionResult ?? getClaudeUserMessage(options.prompt, includeHistoryContext, reasoningEffort, {
4251
+ compactionMode
4252
+ });
3927
4253
  const resolvedProxy = compactionMode ? null : this.resolvedProxyTools();
4254
+ const loadLiveToolInfo = this.createLiveToolInfoLoader();
4255
+ const planModeQuestionActive = await this.resolvePlanModeQuestion(
4256
+ compactionMode,
4257
+ loadLiveToolInfo
4258
+ );
3928
4259
  const self = this;
3929
4260
  const previousPendingProxyCalls = compactionMode ? [] : getPendingProxyCalls(sk);
3930
4261
  const previousPendingProxyMatches = previousPendingProxyCalls.map((call) => ({
@@ -3957,6 +4288,13 @@ ${plan}
3957
4288
  deleteActiveProcess(sk);
3958
4289
  deleteClaudeSessionId(sk);
3959
4290
  }
4291
+ if (!compactionMode && !hasMatchedPendingResults && consumeCompressionRestart(sk)) {
4292
+ deleteActiveProcess(sk);
4293
+ deleteClaudeSessionId(sk);
4294
+ log.info("compress reset: dropped claude process and session id", {
4295
+ sessionKey: sk
4296
+ });
4297
+ }
3960
4298
  let activeProcess = getActiveProcess(sk);
3961
4299
  let proc;
3962
4300
  let lineEmitter;
@@ -4070,7 +4408,8 @@ ${plan}
4070
4408
  const excludeServers = proxyMcpTools ? new Set(discovery.allEnabledServerNames) : void 0;
4071
4409
  const taskProxyEnabled = resolvedProxy?.some((t) => t.name === "task") ?? false;
4072
4410
  const questionProxyEnabled = resolvedProxy?.some((t) => t.name === "question") ?? false;
4073
- const liveToolInfo = taskProxyEnabled || questionProxyEnabled ? await self.fetchLiveToolInfo() : {
4411
+ const liveToolInfo = taskProxyEnabled || questionProxyEnabled ? await loadLiveToolInfo() : {
4412
+ resolved: false,
4074
4413
  taskDescription: void 0,
4075
4414
  questionDescription: void 0,
4076
4415
  hasQuestion: false
@@ -4131,7 +4470,11 @@ ${plan}
4131
4470
  ...extractSystemMessages(options.prompt),
4132
4471
  ...taskProxyEnabled ? [SUBAGENT_DISPATCH_HINT] : [],
4133
4472
  ...questionProxyActive ? [QUESTION_PROXY_HINT] : []
4134
- ]
4473
+ ],
4474
+ {
4475
+ compressEnabled: enrichedProxy?.some((t) => t.name === "compress") ?? false,
4476
+ compressionSummary: getCompressionSummary(sk)
4477
+ }
4135
4478
  );
4136
4479
  cliArgs = buildCliArgs({
4137
4480
  sessionKey: sk,
@@ -4362,6 +4705,37 @@ ${plan}
4362
4705
  } catch {
4363
4706
  }
4364
4707
  };
4708
+ const finishWithExitPlanQuestion = (call) => {
4709
+ if (controllerClosed) return;
4710
+ endTextBlock();
4711
+ controller.enqueue({
4712
+ type: "tool-input-start",
4713
+ id: call.toolCallId,
4714
+ toolName: call.toolName,
4715
+ providerExecuted: false
4716
+ });
4717
+ controller.enqueue({
4718
+ type: "tool-call",
4719
+ toolCallId: call.toolCallId,
4720
+ toolName: call.toolName,
4721
+ input: JSON.stringify(call.input),
4722
+ providerExecuted: false
4723
+ });
4724
+ controller.enqueue({
4725
+ type: "finish",
4726
+ finishReason: toFinishReason("tool-calls"),
4727
+ usage: toUsage(resultMeta.usage),
4728
+ providerMetadata: {
4729
+ "claude-code": resultMeta
4730
+ }
4731
+ });
4732
+ controllerClosed = true;
4733
+ cleanupTurn();
4734
+ try {
4735
+ controller.close();
4736
+ } catch {
4737
+ }
4738
+ };
4365
4739
  const drainNow = () => {
4366
4740
  if (drainTimer) {
4367
4741
  clearTimeout(drainTimer);
@@ -4687,6 +5061,21 @@ ${plan}
4687
5061
  endTextBlock();
4688
5062
  } else if (tc.name === "ExitPlanMode") {
4689
5063
  const plan = parsedInput?.plan || "";
5064
+ if (planModeQuestionActive) {
5065
+ const questionCall = createExitPlanModeQuestionCall(
5066
+ sk,
5067
+ tc.id,
5068
+ plan
5069
+ );
5070
+ const planId2 = startTextBlock();
5071
+ controller.enqueue({
5072
+ type: "text-delta",
5073
+ id: planId2,
5074
+ delta: questionCall.text
5075
+ });
5076
+ finishWithExitPlanQuestion(questionCall);
5077
+ return;
5078
+ }
4690
5079
  const planId = startTextBlock();
4691
5080
  controller.enqueue({
4692
5081
  type: "text-delta",
@@ -4853,6 +5242,21 @@ ${plan}
4853
5242
  endTextBlock();
4854
5243
  } else if (block.name === "ExitPlanMode") {
4855
5244
  const plan = parsedInput?.plan || "";
5245
+ if (planModeQuestionActive) {
5246
+ const questionCall = createExitPlanModeQuestionCall(
5247
+ sk,
5248
+ block.id,
5249
+ plan
5250
+ );
5251
+ const planId2 = startTextBlock();
5252
+ controller.enqueue({
5253
+ type: "text-delta",
5254
+ id: planId2,
5255
+ delta: questionCall.text
5256
+ });
5257
+ finishWithExitPlanQuestion(questionCall);
5258
+ return;
5259
+ }
4856
5260
  const planId = startTextBlock();
4857
5261
  controller.enqueue({
4858
5262
  type: "text-delta",
@@ -5818,6 +6222,7 @@ function collectStartupDiagnostics(providers, opencodeVersion) {
5818
6222
  proxyTools: stringList(firstOption(providers, "proxyTools")),
5819
6223
  mcpServers,
5820
6224
  interactiveTransport: firstOption(providers, "interactive") === true || process.env.CLAUDE_CODE_INTERACTIVE_TRANSPORT === "1",
6225
+ planModeQuestion: firstOption(providers, "planModeQuestion") === true,
5821
6226
  anthropicApiKeyInEnv: Boolean(
5822
6227
  process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN
5823
6228
  )
@@ -5906,6 +6311,7 @@ function createClaudeCode(settings = {}) {
5906
6311
  controlRequestDenyMessage: settings.controlRequestDenyMessage,
5907
6312
  proxyTools,
5908
6313
  proxyToolTimeoutMs: settings.proxyToolTimeoutMs,
6314
+ planModeQuestion: settings.planModeQuestion ?? false,
5909
6315
  webSearch: settings.webSearch,
5910
6316
  hotReloadMcp: settings.hotReloadMcp ?? true,
5911
6317
  proxyOpencodeMcpTools: settings.proxyOpencodeMcpTools ?? true,
@@ -6144,6 +6550,7 @@ var index_default = {
6144
6550
  };
6145
6551
  export {
6146
6552
  ClaudeCodeLanguageModel,
6553
+ DEFAULT_PROXY_TOOL_NAMES,
6147
6554
  bridgeOpencodeMcp,
6148
6555
  claudeCodeProviders,
6149
6556
  configModelsForProvider,