@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/README.md +37 -2
- package/dist/index.d.ts +39 -2
- package/dist/index.js +445 -38
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3816
|
-
// loop
|
|
3817
|
-
finishReason: this.toFinishReason(
|
|
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
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
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
|
|
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,
|