Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
29ffae0
fix: report per-turn usage across compaction continuations
mradwankhalil Sep 3, 2026
994db2b
fix: classify OpenCode summary updates as metadata
mradwankhalil Sep 3, 2026
848ed77
test: wire compaction regressions into package suite
mradwankhalil Sep 3, 2026
fb4d999
test: cover Claude session reset after compaction
mradwankhalil Sep 4, 2026
f858a34
fix: reset Claude session after compaction
mradwankhalil Sep 4, 2026
5f600b8
feat(claude-prompt): persistent prompt input queue
mradwankhalil Sep 6, 2026
9584142
refactor(bridge-pool): persist bridge input and iterator
mradwankhalil Sep 6, 2026
55112db
feat(proxy): reuse Claude SDK Query across normal turns
mradwankhalil Sep 6, 2026
0c89d3a
fix(executable-path): resolve Windows Claude CLI fallbacks
mradwankhalil Sep 6, 2026
ae1a42e
test: cover persistent Claude Query lifecycle
mradwankhalil Sep 6, 2026
d0cb807
perf(bridge-pool): defer input.close() off the response path
mradwankhalil Sep 6, 2026
0b1aa2f
refactor(iterator): serialize SDK stream pulls
mradwankhalil Sep 12, 2026
00b87c9
fix(bridge-pool): harden bridge teardown
mradwankhalil Sep 12, 2026
8c24d64
fix(proxy): serialize persistent response pumps
mradwankhalil Sep 12, 2026
0865ab1
test: cover persistent continuation cancellation
mradwankhalil Sep 12, 2026
f6fe917
test: cover lifecycle instrumentation
mradwankhalil Sep 12, 2026
de6c815
feat(models): declare input window for 1M models
mradwankhalil Sep 13, 2026
7d8f499
fix(proxy): relay tool-result media to the parked Claude turn
mradwankhalil Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
},
Expand Down
72 changes: 68 additions & 4 deletions src/bridge-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand All @@ -19,13 +30,45 @@ export type ParkedBridge = {
pendingTools: Map<string, ParkedToolCall>;
/** SDK assistant messages whose usage was already reported to OpenCode. */
seenAssistantUsageIds: Set<string>;
/** Latest assistant usage, retained for replay-only tool continuations. */
lastAssistantUsage?: OpenAIUsage;
createdAt: number;
/** Continues consuming the SDK stream after tools resolve. */
continueStream?: () => AsyncGenerator<unknown, void, unknown>;
continueStream?: (
releasePump?: () => void,
requestSignal?: AbortSignal,
) => AsyncGenerator<unknown, void, unknown>;
pumpGate: ExclusivePumpGate;
input?: ClaudePromptInput;
streamIterator?: AsyncIterator<unknown>;
persistent?: boolean;
closed?: boolean;
modelId?: string;
cwd?: string;
};

const bridges = new Map<string, ParkedBridge>();

/**
* 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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 8 additions & 2 deletions src/executable-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ function knownClaudeLocations(
env: NodeJS.ProcessEnv | Record<string, string | undefined>,
): 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"], {
Expand All @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ function buildProviderModel(
cost: zeroCost(),
limit: {
context: model.contextWindow,
...(model.inputWindow ? { input: model.inputWindow } : {}),
output: model.maxTokens,
},
status: "active",
Expand Down Expand Up @@ -142,6 +143,7 @@ function buildConfigModelEntry(model: ClaudeModel): Record<string, unknown> {
},
limit: {
context: model.contextWindow,
...(model.inputWindow ? { input: model.inputWindow } : {}),
output: model.maxTokens,
},
options: {
Expand Down
8 changes: 6 additions & 2 deletions src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 {
Expand All @@ -38,6 +41,7 @@ function model(
reasoning: true,
contextWindow: limit.context,
maxTokens: limit.output,
...(limit.input ? { inputWindow: limit.input } : {}),
...(resolvedId ? { resolvedId } : {}),
};
}
Expand Down
61 changes: 61 additions & 0 deletions src/prompt-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { SdkUserPrompt } from "./prompt.js";

type PromptResult = IteratorResult<SdkUserPrompt>;

class PromptQueue implements AsyncIterable<SdkUserPrompt>, AsyncIterator<SdkUserPrompt> {
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<PromptResult> {
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<PromptResult> {
this.close();
return { done: true, value: undefined as never };
}

[Symbol.asyncIterator](): AsyncIterator<SdkUserPrompt> {
return this;
}
}

export type ClaudePromptInput = {
stream: AsyncIterable<SdkUserPrompt>;
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(),
};
}
109 changes: 109 additions & 0 deletions src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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,
Expand Down
Loading