Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

- **opencode's threshold-triggered auto-compaction is now suppressed for Cursor models by
default.** The Cursor agent runtime already self-compacts on its own context threshold
(`preCompact` hook with `trigger: "auto"`), so opencode-driven compaction was redundant —
and it caused two real failures. First, the compaction turn runs with zero tools declared
while the Cursor agent uses its own tools anyway, which opencode rejects (`Tool call not
allowed while generating summary`) — mitigated in 0.7.1-next.1 (#91), and now avoided
entirely for the automatic trigger. Second, compaction rewrites the transcript, which
classifies as a divergence and mints a **fresh Cursor agentId** — and every distinct
agentId permanently holds a guarded SQLite `store.db`/`-wal`/`-shm` triple that
`agent.close()` cannot release (it only flushes analytics and releases the executor lease).
That descriptor growth fed an uncatchable `EXC_GUARD` process kill.

Suppression uses a large `limit.input` — the value opencode uses as its compaction
threshold — leaving the real `limit.context` intact so the TUI context gauge and cost
reporting still work. Manual `/compact` is unaffected and still relies on #91's fix.

**Tradeoff:** this suppresses the proactive threshold trigger only, and opencode has no
reactive context-overflow recovery wired up for this provider, so its transcript is no
longer trimmed automatically. Ordinary turns send only the new message, but a cold replay
(new session, expired agent, changed MCP set) resends everything; if that overflows the
model the turn fails and `/compact` is the manual recovery. Opt back out with
`provider.cursor.options.autoCompaction: true`.

## [0.7.1-next.1] — 2026-08-03 (pre-release)

Pre-release of the compaction fix (#91). Not yet on `latest`; install with
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ See [SECURITY.md](./SECURITY.md) for the full threat model.
| `toolDisplay` | `"blocks"` | How Cursor's internal tool activity is shown — see [Tool display](#tool-display) |
| `systemPrompt` | `"rules"` | How opencode's system prompt reaches the agent — see [System prompt](#system-prompt) |
| `transport` | — | Cursor agent transport (`"http1"` \| `"http2-direct"` \| `"sidecar"`) — see [Transport](#transport) |
| `autoCompaction` | `false` | Let opencode drive auto-compaction. Off by default because the Cursor agent self-compacts — see [Compaction](#compaction) |

| Environment variable | Default | Meaning |
| --- | --- | --- |
Expand Down Expand Up @@ -423,6 +424,42 @@ To force the fallback:
{ "provider": { "cursor": { "options": { "toolDisplay": "reasoning" } } } }
```

## Compaction

**opencode's threshold-triggered auto-compaction is suppressed for Cursor models by default.** The
Cursor agent runtime compacts its own conversation as it approaches its context threshold, so a
second, opencode-driven pass is redundant — and it is actively harmful here:

- The compaction turn asks the model to summarize with **no tools available**. The Cursor agent runs
its own tools regardless, which opencode rejects outright
(`Tool call not allowed while generating summary`).
- Compaction rewrites the transcript, so the next turn no longer matches what the Cursor agent saw.
The plugin correctly treats that as a divergence and creates a **fresh Cursor agent** — and every
distinct agent permanently holds its own SQLite store open for the life of the process, which has
been observed to crash opencode outright.

The suppression works by emitting a very large `limit.input`, which is what opencode uses as its
compaction threshold. The real `limit.context` is left untouched, so the TUI's context-window gauge
and cost reporting keep working.

> [!IMPORTANT]
> This suppresses the **proactive** threshold trigger only, and opencode has no reactive
> context-overflow recovery wired up for this provider. In exchange, opencode's transcript is no
> longer trimmed automatically, so it grows for the life of the session. Ordinary turns send only
> the new message to an already-running agent, but a *cold replay* — a new session, an expired
> agent, or a changed MCP server set — resends the whole transcript. If that ever overflows the
> model, the turn fails with a provider error and the fix is to run `/compact` manually.
>
> Set `autoCompaction: true` if you would rather have opencode keep bounding the transcript for you.

Manual `/compact` is unaffected and still works — it has no threshold gate.

To hand compaction back to opencode:

```json
{ "provider": { "cursor": { "options": { "autoCompaction": true } } } }
```

## Transport

opencode runs on [Bun](https://bun.sh), whose `node:http2` client is incompatible with the Cursor
Expand Down
23 changes: 23 additions & 0 deletions scripts/integration-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,29 @@ fi

echo "PASS: opencode loaded the plugin and listed $CURSOR_COUNT Cursor model(s)."

# Drift gate for the auto-compaction suppression. We disable opencode's
# threshold-triggered compaction by emitting a large `limit.input`, which is what
# opencode uses as that threshold. That field is honored by opencode's runtime
# but is NOT declared in the published @opencode-ai/sdk config types, so nothing
# in `tsc` or the unit suite can notice if opencode ever stops reading it.
# Without this check, such a regression is silent: auto-compaction quietly
# resumes, and with it the per-compaction agentId churn that leaks guarded
# SQLite descriptors and has crashed opencode outright.
VERBOSE_OUT="$("$OPENCODE" models cursor --verbose 2>/dev/null)"
if ! printf '%s\n' "$VERBOSE_OUT" | grep -q '"input": 1000000000'; then
echo "FAIL: limit.input sentinel did not survive into opencode's model registry."
echo " Auto-compaction suppression is broken — see 'Compaction' in README.md."
echo "----- limit blocks as resolved by opencode -----"
printf '%s\n' "$VERBOSE_OUT" | grep -A4 '"limit"' | head -20
exit 1
fi
# The gauge must still work: context has to stay a real value, not be zeroed.
if printf '%s\n' "$VERBOSE_OUT" | grep -A4 '"limit"' | grep -q '"context": 0'; then
echo "FAIL: limit.context was zeroed — the TUI context gauge would be dead."
exit 1
fi
echo "PASS: limit.input sentinel reaches opencode's registry with limit.context intact."

# Assert the packed artifact actually ships the delegation tools.
PLUGIN_JS="$WORK/node_modules/@stablekernel/opencode-cursor/dist/plugin/index.js"
for TOOL in cursor_cloud_agent cursor_delegate; do
Expand Down
35 changes: 30 additions & 5 deletions src/model-discovery.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { ModelListItem } from "@cursor/sdk";
import type { Config } from "@opencode-ai/plugin";
import { fingerprintApiKey, resolveCursorApiKey } from "./api-key.js";
import { resolveContextLimit, resolveCost, resolveOutputLimit } from "./model-limits.js";
import {
NO_AUTO_COMPACTION_INPUT_LIMIT,
resolveContextLimit,
resolveCost,
resolveOutputLimit,
} from "./model-limits.js";
import { readLatestModelCache, readModelCache, writeModelCache } from "./model-cache.js";
import { FALLBACK_MODELS } from "./fallback-models.js";
import { loadCursorSdk } from "./cursor-runtime.js";
Expand Down Expand Up @@ -111,9 +116,17 @@ export interface OpencodeModelConfigEntry {
* Per-model context/output window. opencode's config channel is the only
* one that reaches the model registry for providers absent from
* models.dev, so the TUI session header's context-window percentage
* depends on this being present. Both fields are required by the schema.
* depends on this being present. `context` and `output` are required by
* the schema.
*
* `input` is an undocumented-but-runtime-honored field used only as
* opencode's auto-compaction threshold. We emit
* {@link NO_AUTO_COMPACTION_INPUT_LIMIT} to suppress auto-compaction while
* keeping `context` honest so the TUI gauge still works. The published
* `@opencode-ai/sdk` config types omit it, so it is excluded from
* `_limitKeyGuard` below.
*/
limit: { context: number; output: number };
limit: { context: number; input?: number; output: number };
/**
* Per-model API pricing, USD per million tokens. Note the FLAT snake_case
* cache keys — the config schema (`ProviderConfig` in
Expand Down Expand Up @@ -154,8 +167,11 @@ const _costKeyGuard: _KeysAccepted<
NonNullable<AcceptedModelConfig["cost"]>
> = true;
void _costKeyGuard;
// `input` is deliberately excluded: opencode's runtime reads it (verified in
// the 1.18.11 binary and end-to-end via `Provider.list()`), but the published
// config types don't declare it. The guard still protects `context`/`output`.
const _limitKeyGuard: _KeysAccepted<
OpencodeModelConfigEntry["limit"],
Omit<OpencodeModelConfigEntry["limit"], "input">,
NonNullable<AcceptedModelConfig["limit"]>
> = true;
void _limitKeyGuard;
Expand All @@ -165,7 +181,10 @@ void _limitKeyGuard;
* Cursor SDK runs an agent (it calls tools itself), so every model is marked
* `tool_call: true` and `temperature: false`.
*/
export function toOpencodeModels(items: ModelListItem[]): Record<string, OpencodeModelConfigEntry> {
export function toOpencodeModels(
items: ModelListItem[],
opts: { autoCompaction?: boolean } = {},
): Record<string, OpencodeModelConfigEntry> {
const out: Record<string, OpencodeModelConfigEntry> = {};
for (const item of items) {
const params = defaultModelParams(item);
Expand All @@ -181,6 +200,12 @@ export function toOpencodeModels(items: ModelListItem[]): Record<string, Opencod
options: Object.keys(params).length > 0 ? { params } : {},
limit: {
context: resolveContextLimit(item.id),
// Suppress opencode's auto-compaction unless the user opts in: the
// Cursor agent self-compacts, and opencode's compaction mints a
// fresh agentId per cycle, permanently leaking guarded SQLite fds.
...(opts.autoCompaction
? {}
: { input: NO_AUTO_COMPACTION_INPUT_LIMIT }),
output: resolveOutputLimit(item.id),
},
cost: {
Expand Down
35 changes: 35 additions & 0 deletions src/model-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,41 @@ const MODEL_CONTEXT_LIMITS: Record<string, number> = {

const DEFAULT_CONTEXT_LIMIT = 200_000;

/**
* Sentinel `limit.input` that pushes opencode's auto-compaction threshold out
* of reach, so auto-compaction never fires. opencode computes the threshold as
* `limit.input ? limit.input - reserved : limit.context - maxOutput`, so a huge
* `input` makes it unreachable while `limit.context` stays honest — the TUI
* context gauge keeps working.
*
* Why suppress it: the Cursor agent runtime self-compacts on its own context
* threshold (`@cursor/sdk` `dist/esm/357.js`, `preCompact` hook with
* `trigger: "auto"`), so opencode-driven compaction is redundant. It is also
* harmful — each opencode compaction rewrites the transcript, which classifies
* as `divergence` and mints a fresh Cursor agentId, and every distinct agentId
* permanently adds a guarded SQLite `store.db`/`-wal`/`-shm` triple that
* `agent.close()` cannot release.
*
* This is NOT a real model capability. Verified against the opencode 1.18.11
* binary by enumerating the call sites of `Is()` (the threshold function) rather
* than textual hits on `limit.input`, since consumers reach it transitively:
* - `vl()` — the proactive auto-compaction trigger. Suppressed here.
* - `Pd()` — preserve-recent-tokens budget, also used by manual
* `/compact`. Inert: it is `min(8000, max(2000,
* floor(Is*0.25)))`, which saturates at 8000 for any
* `Is >= 32000` — true both before and after the sentinel.
* Everything else that touches `limit.input` is catalog merge/serialization.
*
* Also verified end-to-end (isolated HOME, `opencode models cursor --verbose`)
* that a config-channel `limit.input` survives validation and reaches
* `Provider.list()` with `limit.context` intact.
*
* Caveat: `Is()` is `max(0, input - reserved)`, so a user setting
* `compaction.reserved >= this value` would drive the threshold to 0 and make
* compaction fire every turn. Absurd but user-settable.
*/
export const NO_AUTO_COMPACTION_INPUT_LIMIT = 1_000_000_000;

/**
* Resolve a model's context window by longest-prefix match against
* {@link MODEL_CONTEXT_LIMITS}. Falls back to 200K for unknown models.
Expand Down
13 changes: 11 additions & 2 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ export const CursorPlugin: Plugin = async (input) => {
let resolvedCwd = directory ?? process.cwd();
let forwardMcp = true;
let userMcp: Record<string, McpServerConfig> = {};
// Whether to let opencode drive auto-compaction. Default false: the Cursor
// agent self-compacts (preCompact hook, trigger:"auto"), so opencode's
// compaction is redundant and is what mints a fresh agentId per compaction.
// Opt in with `provider.cursor.options.autoCompaction: true`.
let autoCompaction = false;
// Skill forwarding state, mirroring the MCP forwarding pattern.
let forwardSkills = true;
let skillFilterOptions: SkillFilterOptions | undefined;
Expand Down Expand Up @@ -183,6 +188,7 @@ export const CursorPlugin: Plugin = async (input) => {
// Forward opencode's configured MCP servers to the Cursor
// agent so it can use the same servers. Opt out via
// `provider.cursor.options.forwardMcp: false`.
autoCompaction = existingOptions["autoCompaction"] === true;
forwardMcp = existingOptions["forwardMcp"] !== false;
userMcp = (existingOptions["mcpServers"] ?? {}) as Record<
string,
Expand Down Expand Up @@ -265,7 +271,10 @@ export const CursorPlugin: Plugin = async (input) => {
? { skillsCatalogue: currentSkillsCatalogue }
: {}),
},
models: { ...toOpencodeModels(models), ...(existing.models ?? {}) },
models: {
...toOpencodeModels(models, { autoCompaction }),
...(existing.models ?? {}),
},
};
},

Expand All @@ -274,7 +283,7 @@ export const CursorPlugin: Plugin = async (input) => {
models: async (_provider, ctx) => {
const apiKey = apiKeyFromAuth(ctx.auth);
const { models } = await discoverModels({ apiKey });
return buildModelV2Map(models);
return buildModelV2Map(models, { autoCompaction });
},
},

Expand Down
20 changes: 17 additions & 3 deletions src/plugin/model-v2.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import type { ModelListItem } from "@cursor/sdk";
import { modelSupportsReasoning } from "../model-discovery.js";
import { resolveContextLimit, resolveCost, resolveOutputLimit } from "../model-limits.js";
import {
NO_AUTO_COMPACTION_INPUT_LIMIT,
resolveContextLimit,
resolveCost,
resolveOutputLimit,
} from "../model-limits.js";
import { buildModelVariants, defaultModelParams } from "../model-variants.js";

export const PROVIDER_ID = "cursor";
Expand All @@ -24,7 +29,10 @@ export function providerNpm(): string {
* limits are resolved per model from the shared maps in `../model-limits.js`,
* falling back to $0 / 200K context / 32K output for models absent from them.
*/
export function buildModelV2Map(items: ModelListItem[]): Record<string, ModelV2> {
export function buildModelV2Map(
items: ModelListItem[],
opts: { autoCompaction?: boolean } = {},
): Record<string, ModelV2> {
const out: Record<string, ModelV2> = {};
for (const item of items) {
const params = defaultModelParams(item);
Expand All @@ -46,7 +54,13 @@ export function buildModelV2Map(items: ModelListItem[]): Record<string, ModelV2>
const c = resolveCost(item.id);
return { input: c.input, output: c.output, cache: { read: c.cacheRead, write: c.cacheWrite } };
})(),
limit: { context: resolveContextLimit(item.id), output: resolveOutputLimit(item.id) },
limit: {
context: resolveContextLimit(item.id),
...(opts.autoCompaction
? {}
: { input: NO_AUTO_COMPACTION_INPUT_LIMIT }),
output: resolveOutputLimit(item.id),
},
status: "active",
options: Object.keys(params).length > 0 ? { params } : {},
headers: {},
Expand Down
33 changes: 25 additions & 8 deletions test/model-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ vi.mock("../src/model-cache.js", () => ({
const { discoverModels, modelSupportsReasoning, toOpencodeModels } = await import(
"../src/model-discovery.js"
);
const { NO_AUTO_COMPACTION_INPUT_LIMIT } = await import("../src/model-limits.js");

afterEach(() => readLatestModelCache.mockReset());

Expand Down Expand Up @@ -83,16 +84,30 @@ describe("toOpencodeModels", () => {

describe("toOpencodeModels config-channel limits and cost", () => {
it("emits per-model limit with both context and output", () => {
const out = toOpencodeModels([
{ id: "claude-opus-4-8", displayName: "Opus 4.8" },
{ id: "gpt-5.5", displayName: "GPT-5.5" },
{ id: "grok-4.5", displayName: "Grok 4.5" },
] satisfies ModelListItem[]);
const out = toOpencodeModels(
[
{ id: "claude-opus-4-8", displayName: "Opus 4.8" },
{ id: "gpt-5.5", displayName: "GPT-5.5" },
{ id: "grok-4.5", displayName: "Grok 4.5" },
] satisfies ModelListItem[],
{ autoCompaction: true },
);
expect(out["claude-opus-4-8"]!.limit).toEqual({ context: 300_000, output: 64_000 });
expect(out["gpt-5.5"]!.limit).toEqual({ context: 272_000, output: 64_000 });
expect(out["grok-4.5"]!.limit).toEqual({ context: 256_000, output: 32_000 });
});

it("emits the no-auto-compaction input sentinel by default, keeping context honest", () => {
const out = toOpencodeModels([
{ id: "claude-opus-4-8", displayName: "Opus 4.8" },
] satisfies ModelListItem[]);
expect(out["claude-opus-4-8"]!.limit).toEqual({
context: 300_000,
input: NO_AUTO_COMPACTION_INPUT_LIMIT,
output: 64_000,
});
});

it("emits cost with FLAT snake_case cache keys, not nested cache object", () => {
const out = toOpencodeModels([
{ id: "claude-sonnet-4-6", displayName: "Sonnet 4.6" },
Expand All @@ -119,10 +134,12 @@ describe("toOpencodeModels config-channel limits and cost", () => {
});

it("falls back to 200K/32K and $0 for unknown models", () => {
const out = toOpencodeModels([
{ id: "brand-new-model", displayName: "New" },
] satisfies ModelListItem[]);
const out = toOpencodeModels(
[{ id: "brand-new-model", displayName: "New" }] satisfies ModelListItem[],
{ autoCompaction: true },
);
expect(out["brand-new-model"]!.limit).toEqual({ context: 200_000, output: 32_000 });
expect(out["brand-new-model"]!.limit.input).toBeUndefined();
expect(out["brand-new-model"]!.cost).toEqual({
input: 0,
output: 0,
Expand Down
19 changes: 19 additions & 0 deletions test/model-v2.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { ModelListItem } from "@cursor/sdk";
import { buildModelV2Map } from "../src/plugin/model-v2.js";
import { NO_AUTO_COMPACTION_INPUT_LIMIT } from "../src/model-limits.js";

describe("buildModelV2Map", () => {
it("seeds the fast-off default into options and exposes a fast opt-in variant", () => {
Expand Down Expand Up @@ -47,6 +48,24 @@ describe("buildModelV2Map", () => {
expect(map["claude-opus-4-8"]!.limit.context).toBe(300_000);
});

it("emits the no-auto-compaction input sentinel by default, keeping context honest", () => {
// The Cursor agent self-compacts; opencode's compaction mints a fresh
// agentId per cycle, which permanently leaks guarded SQLite descriptors.
const map = buildModelV2Map([{ id: "claude-opus-4-8", displayName: "Opus 4.8" }]);
expect(map["claude-opus-4-8"]!.limit.input).toBe(NO_AUTO_COMPACTION_INPUT_LIMIT);
// context stays real so the TUI gauge keeps working
expect(map["claude-opus-4-8"]!.limit.context).toBe(300_000);
expect(map["claude-opus-4-8"]!.limit.output).toBe(64_000);
});

it("omits the input sentinel when autoCompaction is opted in", () => {
const map = buildModelV2Map([{ id: "claude-opus-4-8", displayName: "Opus 4.8" }], {
autoCompaction: true,
});
expect(map["claude-opus-4-8"]!.limit.input).toBeUndefined();
expect(map["claude-opus-4-8"]!.limit.context).toBe(300_000);
});

it("sets cost from per-model map for known models", () => {
const map = buildModelV2Map([
{ id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" },
Expand Down
Loading