diff --git a/package.json b/package.json index 382e607..2b43ed6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/opencode-claude", - "version": "0.14.0", + "version": "0.14.0-fix.1", "description": "Claude Code in OpenCode — local CLI auth, Agent SDK harness, effort variants, tools/MCP, images & compact.", "license": "MIT", "type": "module", @@ -21,7 +21,7 @@ "scripts": { "prebuild": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "build": "tsc -p tsconfig.json", - "test": "bun test/smoke.ts", + "test": "bun test/smoke.ts && bun test/usage-regression.ts && bun test/request-kind-regression.ts && bun test/compaction-resume-regression.ts && bun test/persistent-query-regression.ts && bun test/bridge-teardown-regression.ts && bun test/persistent-park-race-regression.ts && bun test/persistent-pump-concurrency-regression.ts && bun test/persistent-continuation-lifecycle-regression.ts && bun test/lifecycle-instrumentation-regression.ts && bun test/pump-gate-regression.ts && bun test/iterator-race-regression.ts", "test:haiku": "bun test/haiku-live.ts", "prepublishOnly": "npm run build" }, diff --git a/src/bridge-pool.ts b/src/bridge-pool.ts index 9259084..a03f9c8 100644 --- a/src/bridge-pool.ts +++ b/src/bridge-pool.ts @@ -3,12 +3,23 @@ * (Cursor bridge-pool pattern). */ import type { ClaudeQueryHandle } from "./query.js"; +import type { ClaudePromptInput } from "./prompt-input.js"; +import type { ExclusivePumpGate } from "./serialized-iterator.js"; +import type { OpenAIUsage } from "./usage.js"; +import { log } from "./log.js"; +import type { ToolResultAttachment } from "./prompt.js"; + +/** Resolved payload for a parked tool call, including image attachments. */ +export type ToolResultPayload = { + text: string; + attachments: ToolResultAttachment[]; +}; export type ParkedToolCall = { id: string; name: string; arguments: string; - resolve: (result: string) => void; + resolve: (result: ToolResultPayload) => void; reject: (error: Error) => void; }; @@ -19,13 +30,45 @@ export type ParkedBridge = { pendingTools: Map; /** SDK assistant messages whose usage was already reported to OpenCode. */ seenAssistantUsageIds: Set; + /** Latest assistant usage, retained for replay-only tool continuations. */ + lastAssistantUsage?: OpenAIUsage; createdAt: number; /** Continues consuming the SDK stream after tools resolve. */ - continueStream?: () => AsyncGenerator; + continueStream?: ( + releasePump?: () => void, + requestSignal?: AbortSignal, + ) => AsyncGenerator; + pumpGate: ExclusivePumpGate; + input?: ClaudePromptInput; + streamIterator?: AsyncIterator; + persistent?: boolean; + closed?: boolean; + modelId?: string; + cwd?: string; }; const bridges = new Map(); +/** + * Closing the prompt input tears the SDK stream down synchronously, which put + * multi-second latency directly on the response path: every turn paid to close + * the previous turn's bridge before it could proceed. Defer it to the next + * macrotask instead. The stream still closes and the leak this lifecycle work + * fixes stays fixed — it just no longer happens while a request is waiting. + */ +function closeInputDeferred(bridge: ParkedBridge): void { + const input = bridge.input; + if (!input) return; + setTimeout(() => { + try { + input.close(); + } catch { + // teardown is best-effort + } + }, 0); +} + + export function putBridge(bridge: ParkedBridge): void { // One active bridge per conversation — drop any prior turn for this key. for (const [id, existing] of bridges) { @@ -34,6 +77,8 @@ export function putBridge(bridge: ParkedBridge): void { tool.reject(new Error("Superseded by a newer turn")); } existing.pendingTools.clear(); + closeInputDeferred(existing); + existing.closed = true; try { existing.handle.close(); } catch { @@ -67,14 +112,33 @@ export function findBridgeByPendingTool( return undefined; } +export function deleteBridgesByConversation(conversationKey: string): void { + for (const [id, bridge] of bridges) { + if (bridge.conversationKey === conversationKey) deleteBridge(id); + } +} + export function deleteBridge(id: string): void { const bridge = bridges.get(id); if (!bridge) return; + bridge.closed = true; for (const tool of bridge.pendingTools.values()) { tool.reject(new Error("Bridge closed")); } - bridge.handle.close(); - bridges.delete(id); + bridge.pendingTools.clear(); + closeInputDeferred(bridge); + try { + bridge.handle.close(); + } catch { + log.warn("[opencode-claude] bridge handle close failed", { bridgeId: id }); + } finally { + bridges.delete(id); + log.info("[opencode-claude] bridge close", { + bridgeId: id, + conversationKey: bridge.conversationKey, + pendingTools: bridge.pendingTools.size, + }); + } } export function clearAllBridges(): void { diff --git a/src/executable-path.ts b/src/executable-path.ts index 11a8e44..52f036b 100644 --- a/src/executable-path.ts +++ b/src/executable-path.ts @@ -34,7 +34,11 @@ function knownClaudeLocations( env: NodeJS.ProcessEnv | Record, ): string[] { const home = typeof env.HOME === "string" && env.HOME ? env.HOME : homedir(); - const candidates = [join(home, ".local", "bin", "claude")]; + const names = + process.platform === "win32" + ? ["claude.cmd", "claude.exe", "claude.bat", "claude"] + : ["claude"]; + const candidates = names.map((name) => join(home, ".local", "bin", name)); try { const prefix = spawnSync("npm", ["prefix", "-g"], { @@ -44,7 +48,9 @@ function knownClaudeLocations( stdio: ["ignore", "pipe", "ignore"], }); const dir = `${prefix.stdout || ""}`.trim(); - if (dir) candidates.push(join(dir, "bin", "claude")); + if (dir) { + for (const name of names) candidates.push(join(dir, "bin", name)); + } } catch { // no npm prefix available — PATH and ~/.local/bin remain } diff --git a/src/index.ts b/src/index.ts index 9e9936e..afea018 100644 --- a/src/index.ts +++ b/src/index.ts @@ -108,6 +108,7 @@ function buildProviderModel( cost: zeroCost(), limit: { context: model.contextWindow, + ...(model.inputWindow ? { input: model.inputWindow } : {}), output: model.maxTokens, }, status: "active", @@ -142,6 +143,7 @@ function buildConfigModelEntry(model: ClaudeModel): Record { }, limit: { context: model.contextWindow, + ...(model.inputWindow ? { input: model.inputWindow } : {}), output: model.maxTokens, }, options: { diff --git a/src/models.ts b/src/models.ts index 5960853..6e7c791 100644 --- a/src/models.ts +++ b/src/models.ts @@ -9,10 +9,13 @@ export type ClaudeModel = { reasoning: boolean; contextWindow: number; maxTokens: number; + inputWindow?: number; resolvedId?: string; }; -const LIMIT_1M = { context: 1_000_000, output: 128_000 } as const; +// Declare an input window so OpenCode's auto-compaction trigger (input − reserved) +// fires predictably (~90%) instead of falling back to (context − output). +const LIMIT_1M = { context: 1_000_000, input: 900_000, output: 128_000 } as const; const LIMIT_200K = { context: 200_000, output: 64_000 } as const; /** OpenCode may inject these before merging plugin variants — disable extras. */ @@ -29,7 +32,7 @@ export const GENERATED_VARIANT_KEYS = [ function model( id: string, name: string, - limit: { context: number; output: number }, + limit: { context: number; input?: number; output: number }, resolvedId?: string, ): ClaudeModel { return { @@ -38,6 +41,7 @@ function model( reasoning: true, contextWindow: limit.context, maxTokens: limit.output, + ...(limit.input ? { inputWindow: limit.input } : {}), ...(resolvedId ? { resolvedId } : {}), }; } diff --git a/src/prompt-input.ts b/src/prompt-input.ts new file mode 100644 index 0000000..1f48943 --- /dev/null +++ b/src/prompt-input.ts @@ -0,0 +1,61 @@ +import type { SdkUserPrompt } from "./prompt.js"; + +type PromptResult = IteratorResult; + +class PromptQueue implements AsyncIterable, AsyncIterator { + private readonly values: SdkUserPrompt[] = []; + private readonly waiters: Array<(result: PromptResult) => void> = []; + private closed = false; + + push(prompt: SdkUserPrompt): void { + if (this.closed) { + throw new Error("Claude prompt stream is closed"); + } + const resolve = this.waiters.shift(); + if (resolve) { + resolve({ done: false, value: prompt }); + } else { + this.values.push(prompt); + } + } + + close(): void { + if (this.closed) return; + this.closed = true; + while (this.waiters.length > 0) { + this.waiters.shift()!({ done: true, value: undefined as never }); + } + } + + async next(): Promise { + const value = this.values.shift(); + if (value) return { done: false, value }; + if (this.closed) return { done: true, value: undefined as never }; + return new Promise((resolve) => this.waiters.push(resolve)); + } + + async return(): Promise { + this.close(); + return { done: true, value: undefined as never }; + } + + [Symbol.asyncIterator](): AsyncIterator { + return this; + } +} + +export type ClaudePromptInput = { + stream: AsyncIterable; + push: (prompt: SdkUserPrompt) => void; + close: () => void; +}; + +export function createClaudePromptInput(initial: SdkUserPrompt): ClaudePromptInput { + const queue = new PromptQueue(); + queue.push(initial); + return { + stream: queue, + push: (prompt) => queue.push(prompt), + close: () => queue.close(), + }; +} diff --git a/src/prompt.ts b/src/prompt.ts index 6eab0ec..0ec94cf 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -376,6 +376,115 @@ export function contentHasAttachments(content: unknown): boolean { ); }); } +/** + * Extract image parts from an OpenAI-compatible tool result content array so + * they can be re-attached as MCP image content blocks when a parked tool call + * is resolved (tool-result images otherwise never reach Claude). + * Text stays in `extractTextContent`; this only collects binaries. Remote + * http(s) image URLs are skipped — MCP image content requires base64 data. + */ +export type ToolResultAttachment = { + type: "image"; + data: string; + mimeType: string; +}; + +export function extractToolResultImages( + content: unknown, +): ToolResultAttachment[] { + if (!Array.isArray(content)) return []; + const images: ToolResultAttachment[] = []; + const push = (mediaType: string, data: string): void => { + if (!data || !mediaLooksLikeImage(mediaType)) return; + images.push({ type: "image", data, mimeType: mediaType }); + }; + for (const part of content) { + if (!part || typeof part !== "object") continue; + const p = part as Record; + const type = typeof p.type === "string" ? p.type : ""; + + if (type === "image_url" || type === "input_image") { + const imageUrl = p.image_url; + const url = + typeof imageUrl === "string" + ? imageUrl + : imageUrl && + typeof imageUrl === "object" && + typeof (imageUrl as { url?: unknown }).url === "string" + ? (imageUrl as { url: string }).url + : null; + const parsed = url ? parseDataUrl(url) : null; + if (parsed) push(parsed.mediaType, parsed.data); + continue; + } + + if (type === "file" || type === "input_file") { + const file = ( + p.file && typeof p.file === "object" ? p.file : p + ) as Record; + const fileData = + typeof file.file_data === "string" ? file.file_data : null; + const url = + typeof file.url === "string" + ? file.url + : fileData && /^data:/i.test(fileData) + ? fileData + : null; + const data = + typeof file.data === "string" + ? file.data + : fileData && !/^data:/i.test(fileData) + ? fileData + : null; + const mediaType = + typeof file.media_type === "string" + ? file.media_type + : typeof file.mime_type === "string" + ? file.mime_type + : typeof file.mime === "string" + ? file.mime + : ""; + const parsed = url + ? parseDataUrl(url) + : data && /^data:/i.test(data) + ? parseDataUrl(data) + : null; + if (parsed) push(parsed.mediaType, parsed.data); + else if (data && mediaType) push(mediaType, data); + continue; + } + + if (type === "image") { + const mediaType = + typeof p.media_type === "string" + ? p.media_type + : typeof p.mimeType === "string" + ? p.mimeType + : typeof p.mime === "string" + ? p.mime + : "image/png"; + const source = p.source; + if (source && typeof source === "object") { + const s = source as Record; + if (s.type === "base64" && typeof s.data === "string") + push(mediaType, s.data); + else if (s.type === "url" && typeof s.url === "string") { + const parsed = parseDataUrl(s.url); + if (parsed) push(parsed.mediaType, parsed.data); + } + continue; + } + const image = p.image; + if (typeof image === "string") { + const parsed = parseDataUrl(image); + if (parsed) push(parsed.mediaType, parsed.data); + else push(mediaType, image); + } + continue; + } + } + return images; +} export function openaiContentToAnthropicBlocks( content: unknown, diff --git a/src/proxy.ts b/src/proxy.ts index 8aae174..e780610 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -10,12 +10,15 @@ */ import { createHash, randomUUID } from "node:crypto"; import { + clearAllBridges, deleteBridge, + deleteBridgesByConversation, findBridgeByConversation, findBridgeByPendingTool, putBridge, type ParkedBridge, type ParkedToolCall, + type ToolResultPayload, } from "./bridge-pool.js"; import { buildClaudeCodeChildEnv } from "./auth-env.js"; import { @@ -55,23 +58,31 @@ import { import { buildConversationTranscript, extractTextContent, + extractToolResultImages, latestUserPrompt, priorMessagesOf, promptAsStream, withConversationContext, type SdkUserPrompt, } from "./prompt.js"; +import { createClaudePromptInput } from "./prompt-input.js"; +import { + ExclusivePumpGate, + SerializedAsyncIterator, +} from "./serialized-iterator.js"; import { detectMetaRequestKind, metaSystemPrompt, requestKeyNamespace, } from "./request-kind.js"; import { - addUniqueAssistantUsage, + addUniqueAssistantUsageState, formatCompactNote, - resolveTurnUsage, + resolveOpenCodeUsage, usageFromAssistantEvent, usageFromSdkResult, + usageFromSdkTurnResult, + type AssistantUsageState, type OpenAIUsage, } from "./usage.js"; @@ -258,6 +269,7 @@ export async function startProxy(): Promise { } export async function stopProxy(): Promise { + clearAllBridges(); if (server) { server.stop(true); server = null; @@ -325,15 +337,49 @@ async function handleRequest(req: Request): Promise { function collectToolResults( messages: OpenAIMessage[], -): Map { - const results = new Map(); +): Map { + const results = new Map(); for (const msg of messages) { if (msg.role !== "tool" || !msg.tool_call_id) continue; - results.set(msg.tool_call_id, extractTextContent(msg.content)); + const attachments = extractToolResultImages(msg.content); + if (attachments.length > 0) { + log.info("[opencode-claude] tool result attachments", { + toolCallId: msg.tool_call_id, + count: attachments.length, + mimeTypes: attachments.map((a) => a.mimeType), + }); + } + results.set(msg.tool_call_id, { + text: extractTextContent(msg.content), + attachments, + }); } return results; } +/** + * OpenCode promotes tool-result media (images/PDFs) into a synthetic user + * message ("Attached media from tool result:") for providers that cannot carry + * media inside tool results — which includes every openai-compatible provider. + * In the parked-bridge path that message would otherwise be dropped, because + * the turn resumes by resolving the parked MCP call only. Relay its images + * with the tool result so Claude actually sees them. + */ +const SYNTHETIC_TOOL_MEDIA_PROMPT = "Attached media from tool result:"; + +function collectSyntheticToolMedia( + messages: OpenAIMessage[], +): Array<{ type: "image"; data: string; mimeType: string }> { + const media: Array<{ type: "image"; data: string; mimeType: string }> = []; + for (const msg of messages) { + if (msg.role !== "user") continue; + if (extractTextContent(msg.content).trim() !== SYNTHETIC_TOOL_MEDIA_PROMPT) + continue; + media.push(...extractToolResultImages(msg.content)); + } + return media; +} + function selectionFromRequest( req: Request, body: ChatCompletionRequest, @@ -354,15 +400,45 @@ async function handleChatCompletions( const messages = Array.isArray(body.messages) ? body.messages : []; const metaKind = detectMetaRequestKind(messages); const sessionHeader = req.headers.get(SESSION_HEADER); + const baseConversationKey = + sessionHeader || conversationKeyFromMessages(messages); const conversationKey = - requestKeyNamespace(metaKind) + - (sessionHeader || conversationKeyFromMessages(messages)); + requestKeyNamespace(metaKind) + baseConversationKey; + if (metaKind === "summary") { + // OpenCode has compacted its history; resuming the old Claude session would + // restore the pre-compaction context and make the next usage snapshot jump. + deleteBridgesByConversation(baseConversationKey); + clearForeignSessionId(baseConversationKey); + } const selection = selectionFromRequest(req, body); const model = resolveClaudeModelId(selection.modelId); const stream = body.stream !== false; + const requestDirectory = req.headers.get(DIRECTORY_HEADER)?.trim(); + const cwd = + process.env.OPENCODE_CLAUDE_CWD || requestDirectory || process.cwd(); // Resume a parked bridge if OpenCode returned tool results. const toolResults = collectToolResults(messages); + // Media promoted by OpenCode to a synthetic "Attached media from tool result:" + // user message must ride the resolved tool call, or the parked turn resumes + // without the image attached. + const syntheticMedia = collectSyntheticToolMedia(messages); + if (syntheticMedia.length > 0) { + if (toolResults.size === 1) { + const only = [...toolResults.values()][0]; + if (only.attachments.length === 0) { + only.attachments = syntheticMedia; + log.info("[opencode-claude] attached promoted tool-result media", { + count: syntheticMedia.length, + }); + } + } else { + log.warn("[opencode-claude] promoted tool media could not be mapped", { + toolResults: toolResults.size, + count: syntheticMedia.length, + }); + } + } let existing = findBridgeByConversation(conversationKey); // Fallback: match by tool_call_id when the session header is missing/changed. if ((!existing || existing.pendingTools.size === 0) && toolResults.size > 0) { @@ -375,47 +451,49 @@ async function handleChatCompletions( } } if (existing && existing.pendingTools.size > 0) { - let resolved = 0; - for (const [toolId, tool] of existing.pendingTools) { - const result = toolResults.get(toolId); - if (result !== undefined) { - tool.resolve(result); - existing.pendingTools.delete(toolId); - resolved++; + const releasePump = await existing.pumpGate.acquire(); + let handedOff = false; + try { + if (!existing.closed) { + let resolved = 0; + for (const [toolId, tool] of existing.pendingTools) { + const result = toolResults.get(toolId); + if (result !== undefined) { + tool.resolve(result); + existing.pendingTools.delete(toolId); + resolved++; + } + } + if (existing.pendingTools.size === 0 && existing.continueStream) { + log.info("[opencode-claude] resuming parked bridge", { + conversationKey: existing.conversationKey, + resolved, + }); + const continued = existing.continueStream(releasePump, req.signal); + handedOff = true; + return stream + ? streamOpenAIResponse(continued, body.model || model, existing) + : collectTurnResponse(continued, body.model || model, existing); + } + // Still parked — do not start a parallel Claude turn (OpenCode may retry + // or send a follow-up before tool results arrive). Re-emit pending calls. + // Also covers partial tool results (resolved > 0 but others still pending). + if (existing.pendingTools.size > 0) { + log.info("[opencode-claude] re-emitting parked tool_calls", { + conversationKey: existing.conversationKey, + pending: existing.pendingTools.size, + resolved, + }); + const parkedEvents = (async function* () { + yield { type: "__park__", tools: [...existing!.pendingTools.values()] }; + })(); + return stream + ? streamOpenAIResponse(parkedEvents, body.model || model, existing) + : collectTurnResponse(parkedEvents, body.model || model, existing); + } } - } - if (existing.pendingTools.size === 0 && existing.continueStream) { - log.info("[opencode-claude] resuming parked bridge", { - conversationKey: existing.conversationKey, - resolved, - }); - return stream - ? streamOpenAIResponse( - existing.continueStream(), - body.model || model, - existing, - ) - : collectTurnResponse( - existing.continueStream(), - body.model || model, - existing, - ); - } - // Still parked — do not start a parallel Claude turn (OpenCode may retry - // or send a follow-up before tool results arrive). Re-emit pending calls. - // Also covers partial tool results (resolved > 0 but others still pending). - if (existing.pendingTools.size > 0) { - log.info("[opencode-claude] re-emitting parked tool_calls", { - conversationKey: existing.conversationKey, - pending: existing.pendingTools.size, - resolved, - }); - const parkedEvents = (async function* () { - yield { type: "__park__", tools: [...existing!.pendingTools.values()] }; - })(); - return stream - ? streamOpenAIResponse(parkedEvents, body.model || model, existing) - : collectTurnResponse(parkedEvents, body.model || model, existing); + } finally { + if (!handedOff) releasePump(); } } @@ -433,9 +511,6 @@ async function handleChatCompletions( const openCodeTools = Array.isArray(body.tools) ? body.tools : []; const isMetaRequest = metaKind !== null; - const requestDirectory = req.headers.get(DIRECTORY_HEADER)?.trim(); - const cwd = - process.env.OPENCODE_CLAUDE_CWD || requestDirectory || process.cwd(); const bridgeId = randomUUID(); const pendingTools = new Map(); let handle: ClaudeQueryHandle | null = null; @@ -444,6 +519,11 @@ async function handleChatCompletions( const notifyPark = () => { parked = true; + log.info("[opencode-claude] MCP park", { + bridgeId, + conversationKey, + pendingTools: pendingTools.size, + }); const waiters = parkWaiters; parkWaiters = []; for (const resolve of waiters) resolve(); @@ -513,6 +593,42 @@ async function handleChatCompletions( ); } + if ( + existing?.persistent && + !existing.closed && + existing.input && + existing.continueStream + ) { + if (existing.modelId !== model || existing.cwd !== cwd) { + deleteBridge(existing.id); + existing = undefined; + } else { + const input = existing.input; + const continueStream = existing.continueStream; + log.info("[opencode-claude] reusing persistent bridge", { + bridgeId: existing.id, + conversationKey: existing.conversationKey, + model, + }); + const releasePump = await existing.pumpGate.acquire(); + let handedOff = false; + try { + if (existing.closed) { + existing = undefined; + } else { + input.push(toSdkUserPrompt(prompt)); + const continued = continueStream(releasePump, req.signal); + handedOff = true; + return stream + ? streamOpenAIResponse(continued, body.model || model, existing) + : collectTurnResponse(continued, body.model || model, existing); + } + } finally { + if (!handedOff) releasePump(); + } + } + } + let resume = getForeignSessionId(conversationKey); if (resume && !findClaudeSessionFile(resume)) { // The claude CLI resumes by looking the session up on disk. A missing @@ -576,7 +692,7 @@ async function handleChatCompletions( const titleSource = [...messages] .reverse() .find((message) => message.role === "user"); - const queryPrompt: string | AsyncIterable = metaKind === "title" + const queryPrompt: string | SdkUserPrompt = metaKind === "title" ? [ "Create a concise 3-7 word session title for the request quoted below.", "Output only the title, with no quotation marks or punctuation at the end.", @@ -588,7 +704,15 @@ async function handleChatCompletions( ].join("\n") : typeof contextualPrompt === "string" ? contextualPrompt || " " - : promptAsStream(contextualPrompt); + : contextualPrompt; + const persistentInput = isMetaRequest + ? undefined + : createClaudePromptInput(toSdkUserPrompt(queryPrompt)); + const sdkPrompt: string | AsyncIterable = persistentInput + ? persistentInput.stream + : typeof queryPrompt === "string" + ? queryPrompt + : promptAsStream(queryPrompt); const hasTodoWrite = openCodeToolNames.includes("todowrite"); const utilitySystemPrompt = isMetaRequest @@ -599,8 +723,16 @@ async function handleChatCompletions( "This is a single-turn text transformation. Return only the requested summary. Do not inspect files, execute commands, or use tools.", ].filter(Boolean).join("\n\n") : undefined; + log.info("[opencode-claude] query start", { + bridgeId, + conversationKey, + model, + persistent: !isMetaRequest, + hasMcp: mcpServers !== undefined, + resumed: resume !== undefined, + }); handle = await queryStarter({ - prompt: queryPrompt, + prompt: sdkPrompt, cwd, model, resume: isMetaRequest ? undefined : resume, @@ -649,6 +781,26 @@ async function handleChatCompletions( : {}), }, }); + log.info("[opencode-claude] query acquired", { + bridgeId, + conversationKey, + model, + }); + const streamIterator = handle.stream[Symbol.asyncIterator](); + const serializedIterator = new SerializedAsyncIterator(streamIterator, { + onNextStart: (shared) => { + log.info("[opencode-claude] iterator next start", { bridgeId, shared }); + }, + onNextFinish: (done) => { + log.info("[opencode-claude] iterator next finish", { bridgeId, done }); + }, + onNextError: () => { + log.info("[opencode-claude] iterator next error", { bridgeId }); + }, + onRelease: () => { + log.info("[opencode-claude] iterator next release", { bridgeId }); + }, + }); const bridge: ParkedBridge = { id: bridgeId, @@ -657,11 +809,35 @@ async function handleChatCompletions( pendingTools, seenAssistantUsageIds: new Set(), createdAt: Date.now(), + input: persistentInput, + streamIterator: serializedIterator, + pumpGate: new ExclusivePumpGate(), + persistent: !isMetaRequest, + modelId: model, + cwd, }; putBridge(bridge); - async function* consumeStream(): AsyncGenerator { - const iterator = handle!.stream[Symbol.asyncIterator](); + async function* consumeStream( + suppliedReleasePump?: () => void, + requestSignal: AbortSignal = req.signal, + ): AsyncGenerator { + const iterator = serializedIterator; + const releasePump = + suppliedReleasePump ?? (await bridge.pumpGate.acquire()); + let keepBridge = false; + let sawFirstSdkEvent = false; + const noteFirstSdkEvent = (event: unknown) => { + if (sawFirstSdkEvent) return; + sawFirstSdkEvent = true; + const type = + event && + typeof event === "object" && + typeof (event as { type?: unknown }).type === "string" + ? (event as { type: string }).type + : typeof event; + log.info("[opencode-claude] first SDK event", { bridgeId, type }); + }; try { while (true) { const parkControl = { @@ -698,6 +874,12 @@ async function handleChatCompletions( }, ms); stallTimer.unref?.(); }); + let requestAbortHandler: (() => void) | undefined; + const requestAbortPromise = new Promise((_, reject) => { + requestAbortHandler = () => reject(new Error("Claude request aborted")); + if (requestSignal.aborted) requestAbortHandler(); + else requestSignal.addEventListener("abort", requestAbortHandler, { once: true }); + }); const nextPromise = iterator.next(); let raced: @@ -708,45 +890,64 @@ async function handleChatCompletions( nextPromise.then((value) => ({ kind: "event" as const, value })), parkPromise.then(() => ({ kind: "park" as const })), stallPromise, + requestAbortPromise, ]); } catch (error) { // Stall watchdog fired — the turn is dead. Swallow the late // iterator settlement so it cannot surface as an unhandled // rejection after we throw. + parkControl.cancel?.(); nextPromise.then( () => {}, () => {}, ); + log.warn("[opencode-claude] stream wait interrupted", { + bridgeId, + reason: requestSignal.aborted ? "client-abort" : "watchdog-or-iterator-error", + }); throw error; } finally { if (stallTimer) clearTimeout(stallTimer); + if (requestAbortHandler) { + requestSignal.removeEventListener("abort", requestAbortHandler); + } } if (raced.kind === "park" || (parked && pendingTools.size > 0)) { + keepBridge = true; parkControl.cancel?.(); await Promise.resolve(); // The iterator's pending next() may already have consumed the // assistant event that carries the parked tool call (and its // per-call usage). Forward it before parking so usage accounting // and session binding stay intact. - if (raced.kind === "event" && !raced.value.done) { - const pendingEvent = raced.value.value; - const pendingSessionId = extractSessionId(pendingEvent); - if (pendingSessionId) { - setForeignSessionId(conversationKey, pendingSessionId, { - modelId: model, - cwd, - }); + if (raced.kind === "event") { + iterator.release(nextPromise); + if (!raced.value.done) { + const pendingEvent = raced.value.value; + noteFirstSdkEvent(pendingEvent); + const pendingSessionId = extractSessionId(pendingEvent); + if (pendingSessionId) { + setForeignSessionId(conversationKey, pendingSessionId, { + modelId: model, + cwd, + }); + } + yield pendingEvent; } - yield pendingEvent; } yield { type: "__park__", tools: [...pendingTools.values()] }; return; } parkControl.cancel?.(); - if (raced.value.done) break; + if (raced.value.done) { + iterator.release(nextPromise); + break; + } const event = raced.value.value; + iterator.release(nextPromise); + noteFirstSdkEvent(event); const sessionId = extractSessionId(event); if (sessionId) { setForeignSessionId(conversationKey, sessionId, { @@ -755,19 +956,28 @@ async function handleChatCompletions( }); } yield event; + if (isTurnBoundary(event)) { + keepBridge = bridge.persistent === true && !isErrorResult(event); + return; + } } } finally { - if (!parked) { - handle?.close(); + releasePump(); + if (!keepBridge && !bridge.closed) { deleteBridge(bridgeId); } } } - bridge.continueStream = async function* () { + bridge.continueStream = async function* (suppliedReleasePump, requestSignal) { + log.info("[opencode-claude] bridge continuation", { + bridgeId, + conversationKey, + suppliedPump: suppliedReleasePump !== undefined, + }); parked = false; parkWaiters = []; - yield* consumeStream(); + yield* consumeStream(suppliedReleasePump, requestSignal); }; // A turn that dies BEFORE producing any content (bad token, session limit, @@ -785,6 +995,32 @@ async function handleChatCompletions( return collectTurnResponse(consumeStream(), body.model || model, bridge); } +function toSdkUserPrompt(prompt: string | SdkUserPrompt): SdkUserPrompt { + if (typeof prompt !== "string") return prompt; + return { + type: "user", + message: { role: "user", content: prompt }, + parent_tool_use_id: null, + }; +} + +function isTurnBoundary(event: unknown): boolean { + return ( + !!event && + typeof event === "object" && + (event as { type?: unknown }).type === "result" + ); +} + +function isErrorResult(event: unknown): boolean { + return ( + !!event && + typeof event === "object" && + (event as { type?: unknown; is_error?: unknown }).type === "result" && + (event as { is_error?: unknown }).is_error === true + ); +} + function extractSessionId(event: unknown): string | null { if (!event || typeof event !== "object") return null; @@ -869,16 +1105,25 @@ async function buildOpenCodeMcpServer( resolve: () => {}, reject: () => {}, }; - const resultPromise = new Promise((resolve, reject) => { - pending.resolve = resolve; - pending.reject = reject; - }); + const resultPromise = new Promise( + (resolve, reject) => { + pending.resolve = resolve; + pending.reject = reject; + }, + ); // Register before notifying so the stream consumer sees the tool. pendingTools.set(id, pending); onPark(); const result = await resultPromise; return { - content: [{ type: "text", text: result }], + content: [ + { type: "text" as const, text: result.text }, + ...result.attachments.map((attachment) => ({ + type: "image" as const, + data: attachment.data, + mimeType: attachment.mimeType, + })), + ], }; }, { alwaysLoad: true }, @@ -922,7 +1167,10 @@ async function collectTurnResponse( let content = ""; let reasoning = ""; - let turnUsage: OpenAIUsage | null = null; + let usageState: AssistantUsageState = { + aggregate: null, + latest: bridge.lastAssistantUsage ?? null, + }; let resultUsage: OpenAIUsage | null = null; let lastErrorNorm: string | null = null; let errorText: string | null = null; @@ -949,12 +1197,13 @@ async function collectTurnResponse( } else if (mapped.kind === "reasoning") { if (!suppressReasoning) reasoning += mapped.text; } else if (mapped.kind === "usage-delta") { - turnUsage = addUniqueAssistantUsage( - turnUsage, + usageState = addUniqueAssistantUsageState( + usageState, mapped.usage, mapped.messageId, bridge.seenAssistantUsageIds, ); + if (usageState.latest) bridge.lastAssistantUsage = usageState.latest; } else if (mapped.kind === "usage") { resultUsage = mapped.usage; } else if (mapped.kind === "error") { @@ -972,7 +1221,7 @@ async function collectTurnResponse( noteError(message); } - const usage = resolveTurnUsage(turnUsage, resultUsage); + const usage = resolveOpenCodeUsage(usageState, resultUsage); // Buffered responses have not committed HTTP headers yet. Even if an agent // produced partial work first, preserve the real 429 so OpenCode starts its @@ -1248,7 +1497,10 @@ function streamOpenAIResponse( }); let finishReason: string | null = "stop"; - let turnUsage: OpenAIUsage | null = null; + let usageState: AssistantUsageState = { + aggregate: null, + latest: bridge.lastAssistantUsage ?? null, + }; let resultUsage: OpenAIUsage | null = null; let lastErrorNorm: string | null = null; const sendError = (text: string) => { @@ -1362,12 +1614,13 @@ function streamOpenAIResponse( } if (mapped.kind === "usage-delta") { - turnUsage = addUniqueAssistantUsage( - turnUsage, + usageState = addUniqueAssistantUsageState( + usageState, mapped.usage, mapped.messageId, bridge.seenAssistantUsageIds, ); + if (usageState.latest) bridge.lastAssistantUsage = usageState.latest; } if (mapped.kind === "usage") { @@ -1401,7 +1654,7 @@ function streamOpenAIResponse( finishReason = "stop"; } - const usage = resolveTurnUsage(turnUsage, resultUsage); + const usage = resolveOpenCodeUsage(usageState, resultUsage); if (!streamClosed) { send({ id: completionId, @@ -1429,6 +1682,10 @@ function streamOpenAIResponse( // the turn down instead of leaking the CLI process and the bridge. streamClosed = true; if (heartbeat) clearInterval(heartbeat); + log.info("[opencode-claude] client stream cancel", { + bridgeId: bridge.id, + conversationKey: bridge.conversationKey, + }); deleteBridge(bridge.id); }, }); @@ -1586,7 +1843,16 @@ function mapSdkEvent(event: unknown): MappedEvent { } if (e.type === "result") { - const usage = usageFromSdkResult(event); + const turnUsage = usageFromSdkTurnResult(event); + const accountingUsage = usageFromSdkResult(event); + const usage = turnUsage + ? { + ...turnUsage, + ...(accountingUsage?.model_usage !== undefined + ? { model_usage: accountingUsage.model_usage } + : {}), + } + : null; if (e.is_error) { const text = typeof e.result === "string" diff --git a/src/query.ts b/src/query.ts index e458a71..554c99c 100644 --- a/src/query.ts +++ b/src/query.ts @@ -349,6 +349,10 @@ export async function startClaudeQuery( const close = () => { if (closed) return; closed = true; + if (result && typeof result.close === "function") { + result.close(); + return; + } killProcessTree(getPid(), { signal: "SIGTERM", force: true }); if (result && typeof result.return === "function") { try { diff --git a/src/request-kind.ts b/src/request-kind.ts index de5296b..d51d447 100644 --- a/src/request-kind.ts +++ b/src/request-kind.ts @@ -11,6 +11,9 @@ type MessageLike = { content?: unknown; }; +const OPENCODE_UPDATE_SUMMARY_PATTERN = + /here is the conversation so far:\s*[\s\S]*?<\/conversation>\s*here is the summary of the conversation before the above:\s*[\s\S]*?<\/prior-summary>\s*the summarizes everything that happened before the \. construct a new summary that combines both\.\s*output exactly the markdown structure shown inside