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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.1.2] - 2026-02-09

### Added
- OpenCode-owned tool loop adapter for OpenAI-style `tool_calls` responses (`src/proxy/tool-loop.ts`)
- Focused integration coverage for request-1/request-2 tool loop continuity (`tests/integration/opencode-loop.integration.test.ts`)
- CI test split scripts: `test:ci:unit` and `test:ci:integration`
- GitHub Actions job summaries for unit and integration suites
- Packaging CLI entrypoint `open-cursor` for npm/global installs (`src/cli/opencode-cursor.ts`)
- Model discovery parser utility for CLI install/sync workflows (`src/cli/model-discovery.ts`)

### Changed
- CI workflow split into separate `unit` and `integration` jobs
- Integration CI defaults to OpenCode-owned loop mode (`CURSOR_ACP_TOOL_LOOP_MODE=opencode`)
- npm package metadata now targets publish/install as `open-cursor`
- Build now emits CLI artifacts for package bins (`dist/opencode-cursor.js`, `dist/discover.js`)

### Fixed
- Node proxy fallback after `EADDRINUSE` now recreates the server before dynamic port bind
- Streaming termination guards prevent duplicate flush/output after intercepted tool call
- Auth unit tests now clean all candidate auth paths to avoid environment-dependent flakes
- Provider config generator no longer hardcodes a local filesystem npm path
- Added auth home-path override (`CURSOR_ACP_HOME_DIR`) for deterministic auth path resolution in tests/automation
- Added proxy reuse toggle (`CURSOR_ACP_REUSE_EXISTING_PROXY`) to avoid accidentally attaching to unrelated local proxy servers

## [2.1.0] - 2026-02-07

### Added
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ Integration CI defaults to OpenCode-owned loop mode:
- `CURSOR_ACP_TOOL_LOOP_MODE=opencode`
- `CURSOR_ACP_PROVIDER_BOUNDARY=v1`
- `CURSOR_ACP_PROVIDER_BOUNDARY_AUTOFALLBACK=false`
- `CURSOR_ACP_TOOL_LOOP_MAX_REPEAT=3`
- `CURSOR_ACP_ENABLE_OPENCODE_TOOLS=true`
- `CURSOR_ACP_FORWARD_TOOL_CALLS=false`
- `CURSOR_ACP_EMIT_TOOL_UPDATES=false`
Expand Down Expand Up @@ -329,10 +330,12 @@ Provider-boundary rollout:
- `CURSOR_ACP_PROVIDER_BOUNDARY=legacy` - Original provider/runtime boundary behavior
- `CURSOR_ACP_PROVIDER_BOUNDARY=v1` - New shared boundary/interception path (recommended)
- `CURSOR_ACP_PROVIDER_BOUNDARY_AUTOFALLBACK=true` - Emergency fallback from `v1` to `legacy` for the current request only
- `CURSOR_ACP_TOOL_LOOP_MAX_REPEAT=3` - Max repeated failing tool-call fingerprints before guard termination (or fallback when enabled)

Auto-fallback trigger conditions:
- Only active when `CURSOR_ACP_PROVIDER_BOUNDARY=v1`
- Triggered only when `v1` boundary extraction throws during tool-call interception
- Triggered when `v1` boundary extraction throws during tool-call interception
- Triggered when the tool-loop guard threshold is reached (same tool + arg shape + error class)
- Does not trigger for normal cases like disallowed tools or no tool call
- Does not trigger for unrelated runtime errors (for example, tool mapper/tool execution failures)

Expand Down
2 changes: 1 addition & 1 deletion cmd/installer/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ func validateConfig(m *model) error {

func verifyPlugin(m *model) error {
// Try to load plugin with node to catch syntax/import errors
pluginPath := filepath.Join(m.projectDir, "dist", "index.js")
pluginPath := filepath.Join(m.projectDir, "dist", "plugin-entry.js")
cmd := exec.Command("node", "-e", fmt.Sprintf(`require("%s")`, pluginPath))
if err := cmd.Run(); err != nil {
return fmt.Errorf("plugin failed to load: %w", err)
Expand Down
17 changes: 17 additions & 0 deletions docs/RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Release Notes

## v2.1.2 - OpenCode Tool Loop + CI Split

### Highlights

- Added OpenCode-owned multi-turn tool loop support by intercepting allowed tool calls and returning OpenAI-compatible `tool_calls` responses.
- Added integration tests for stream/non-stream interception, request-2 continuity with `role:"tool"`, and passthrough behavior.
- Split CI into separate unit and integration jobs, each with a concise run summary in GitHub Actions.
- Added npm-ready CLI packaging with `open-cursor` install/sync/status commands.
- Updated package metadata and build outputs for publishable npm bins.

### Quality / Stability

- Fixed Node proxy fallback bind path when default port is occupied.
- Added streaming termination guards to avoid duplicate flush and post-termination output.
- Stabilized auth unit tests by cleaning all candidate auth locations.
- Removed hardcoded local-path provider npm reference from generated provider config.

## v2.0.0 - ACP Implementation

### New Features
Expand Down
125 changes: 125 additions & 0 deletions docs/implementation/pr19-pr20-v1-stabilization-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# PR19/PR20 Implementation Plan (Auto Model, Production-First)

## Status (Tuesday, February 10, 2026)

- PR #17 merged into `main` at `2026-02-10T07:23:53Z`.
- PR #18 merged into `main` at `2026-02-10T07:26:53Z`.
- Baseline production behavior (model `cursor-acp/auto`):
- `v1` intercepts tool calls but repeatedly fails on `edit` argument/schema mismatch.
- `legacy` and `v1+autofallback` execute more useful calls (`todowrite`/`read`) but still show long tool-loop runs on the travel prompt.

## Goal

Make `v1` reliably compatible with OpenCode tool schemas in production, while preserving legacy fallback safety.

## PR #19: v1 Schema Compatibility + Argument Normalization

### Scope

1. Add a compatibility layer for intercepted `tool_call` arguments.
2. Normalize model-generated argument variants into OpenCode tool schema shape.
3. Define deterministic behavior when normalized args still fail schema validation.
4. Clarify schema source for OpenCode-owned tools (including `todowrite`).

### Implementation

1. Add `src/provider/tool-schema-compat.ts`.
2. Add generic key alias normalization:
- `filePath|file|target_file` -> `path`
- `contents|text|streamContent` -> `content`
- `oldString` -> `old_string`
- `newString` -> `new_string`
3. Add alias collision rule:
- If canonical key already exists, aliases do not overwrite it.
- Aliases that collide are dropped and logged.
4. Add tool-specific normalization (no lossy semantic rewrite by default):
- `edit`: normalize key names only. Do not silently rewrite to `write`.
- `todowrite`: normalize status values (`todo|pending` -> `pending`, `in-progress` -> `in_progress`, `done` -> `completed`), default `priority=medium` when missing.
5. Schema source and ownership:
- Build runtime `toolSchemaMap` from request `body.tools[]` in `src/plugin.ts`.
- `todowrite` is treated as OpenCode-owned (remote) schema, not part of local default tools.
6. Validation behavior after normalization:
- If schema exists and args validate: intercept with normalized args.
- If schema exists and args still fail: do not rewrite semantics; forward the normalized call to OpenCode and rely on native tool validation error for model self-repair.
- Log structured compat error (`tool`, `missing`, `unexpected`, `repairHint`) for loop-guard consumption.
7. Wire compat into v1 interception path only in `src/provider/runtime-interception.ts`.
8. Add debug logs for `tool`, `originalArgKeys`, `normalizedArgKeys`, `collisionKeys`, `validationOk`, `repairHint`.

### Explicit Safety Decision

- `edit -> write` rewrite is disabled in PR #19 to avoid destructive semantic drift.
- Optional rewrite (if ever added) must be explicitly env-gated and only when file does not exist.

### Tests

1. `tests/unit/provider-tool-schema-compat.test.ts`
2. Extend `tests/unit/provider-runtime-interception.test.ts` for v1 normalization/validation paths.
3. Add tests for alias collisions and canonical precedence.
4. Extend `tests/integration/opencode-loop.integration.test.ts` with invalid `edit` args + model repair loop scenario (no rewrite).

### Acceptance

1. Travel prompt no longer loops on `edit` type/path errors in v1.
2. No regression in legacy behavior.
3. Unit + integration suites pass.

## PR #20: Loop Guard + Controlled Auto-Fallback + Production Hardening

### Scope

1. Prevent infinite repeated tool-call failures.
2. Add explicit per-request guard plumbing used by both v1 and legacy handlers.
3. Add optional emergency fallback from v1 to legacy on repeated failures.
4. Define exact termination ownership and chunk format in stream/non-stream handlers.
5. Improve production diagnostics for faster incident triage.

### Implementation

1. Add `src/provider/tool-loop-guard.ts` with per-request state.
2. Extend `HandleToolLoopEventBaseOptions` in `src/provider/runtime-interception.ts` with `toolLoopGuard`.
3. Guard fingerprint:
- `tool + normalizedArgShape + errorClass`.
- `errorClass` derived from prior `role:"tool"` result content in request messages when available; fallback `unknown`.
4. Add per-request guard threshold (env):
- `CURSOR_ACP_TOOL_LOOP_MAX_REPEAT` (default `3`).
5. Thread guard through Bun/Node stream handlers in `src/plugin.ts`:
- Create one guard per incoming chat request.
- Pass guard into every `handleToolLoopEventWithFallback` call.
6. On threshold breach:
- If `CURSOR_ACP_PROVIDER_BOUNDARY_AUTOFALLBACK=true` and boundary is v1:
- call `boundaryContext.activateLegacyFallback("toolLoopGuard", error)`;
- continue with legacy for subsequent events in the same request.
- Else:
- return terminal error signal from runtime interception;
- plugin stream driver emits terminal assistant chunk using `createChatCompletionChunk(..., done=true)` and `[DONE]`.
- non-stream driver returns `createChatCompletionResponse` with explicit error text.
7. Add structured logs:
- `loopGuardTriggered`, `fingerprint`, `repeatCount`, `fallbackActivated`.
8. Document env flags and behavior in `README.md`.

### Ownership Clarification

- Runtime interception decides `allow/fallback/terminate`.
- `src/plugin.ts` owns SSE/non-stream emission and stream termination mechanics.

### Tests

1. Unit tests for loop guard counting and reset semantics.
2. Integration test for repeated invalid `edit` calls:
- `v1` with no fallback -> terminal error chunk.
- `v1` with fallback -> switch to legacy after threshold.
3. Parity tests in both modes (`v1`, `legacy`) for core scenarios.
4. Test that fallback still only auto-triggers on configured guard condition or boundary extraction failures.

### Acceptance

1. No unbounded loop on repeated invalid calls.
2. Fallback behavior is deterministic and gated by env.
3. Production run completes with actionable output or explicit terminal error.

## Execution Sequence

1. Branch `feat/pr19-v1-schema-compat` from updated `main`; open PR #19.
2. Validate production matrix (`auto` only) and CI.
3. Branch `feat/pr20-loop-guard-fallback` from PR #19; open PR #20.
4. Validate matrix again, then merge #19 followed by #20.
14 changes: 6 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
{
"name": "@nomadcxx/opencode-cursor",
"version": "2.1.1",
"name": "open-cursor",
"version": "2.1.2",
"description": "No prompt limits. No broken streams. Full thinking + tool support. Your Cursor subscription, properly integrated.",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "bun build ./src/index.ts ./src/plugin-entry.ts --outdir ./dist --target node",
"dev": "bun build ./src/index.ts ./src/plugin-entry.ts --outdir ./dist --target node --watch",
"build": "bun build ./src/index.ts ./src/plugin-entry.ts ./src/cli/discover.ts ./src/cli/opencode-cursor.ts --outdir ./dist --target node",
"dev": "bun build ./src/index.ts ./src/plugin-entry.ts ./src/cli/discover.ts ./src/cli/opencode-cursor.ts --outdir ./dist --target node --watch",
"test": "bun test",
"test:unit": "bun test tests/unit",
"test:integration": "bun test tests/integration",
"test:ci:unit": "bun test tests/tools/defaults.test.ts tests/tools/executor-chain.test.ts tests/tools/sdk-executor.test.ts tests/tools/mcp-executor.test.ts tests/tools/skills.test.ts tests/tools/registry.test.ts tests/unit/proxy/prompt-builder.test.ts tests/unit/proxy/tool-loop.test.ts tests/unit/provider-boundary.test.ts tests/unit/provider-runtime-interception.test.ts tests/unit/plugin.test.ts tests/unit/plugin-tools-hook.test.ts tests/unit/plugin-tool-resolution.test.ts tests/unit/plugin-config.test.ts tests/unit/auth.test.ts tests/unit/streaming/line-buffer.test.ts tests/unit/streaming/parser.test.ts tests/unit/streaming/types.test.ts tests/unit/streaming/delta-tracker.test.ts tests/competitive/edge.test.ts",
"test:ci:unit": "bun test tests/tools/defaults.test.ts tests/tools/executor-chain.test.ts tests/tools/sdk-executor.test.ts tests/tools/mcp-executor.test.ts tests/tools/skills.test.ts tests/tools/registry.test.ts tests/unit/cli/model-discovery.test.ts tests/unit/proxy/prompt-builder.test.ts tests/unit/proxy/tool-loop.test.ts tests/unit/provider-boundary.test.ts tests/unit/provider-runtime-interception.test.ts tests/unit/provider-tool-schema-compat.test.ts tests/unit/provider-tool-loop-guard.test.ts tests/unit/plugin.test.ts tests/unit/plugin-tools-hook.test.ts tests/unit/plugin-tool-resolution.test.ts tests/unit/plugin-config.test.ts tests/unit/auth.test.ts tests/unit/streaming/line-buffer.test.ts tests/unit/streaming/parser.test.ts tests/unit/streaming/types.test.ts tests/unit/streaming/delta-tracker.test.ts tests/competitive/edge.test.ts",
"test:ci:integration": "bun test tests/integration/comprehensive.test.ts tests/integration/tools-router.integration.test.ts tests/integration/stream-router.integration.test.ts tests/integration/opencode-loop.integration.test.ts",
"discover": "bun run src/cli/discover.ts",
"prepublishOnly": "bun run build"
},
"bin": {
"open-cursor": "./dist/cli/opencode-cursor.js",
"cursor-discover": "./dist/cli/discover.js"
},
"exports": {
Expand All @@ -38,9 +39,6 @@
"bun-types": "^1.1.0"
},
"license": "ISC",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/Nomadcxx/opencode-cursor.git"
Expand Down
10 changes: 9 additions & 1 deletion src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ export interface AuthResult {
error?: string;
}

function getHomeDir(): string {
const override = process.env.CURSOR_ACP_HOME_DIR;
if (override && override.length > 0) {
return override;
}
return homedir();
}

export async function pollForAuthFile(
timeoutMs: number = AUTH_POLL_TIMEOUT,
intervalMs: number = AUTH_POLL_INTERVAL
Expand Down Expand Up @@ -215,7 +223,7 @@ export function verifyCursorAuth(): boolean {
* - Linux: ~/.config/cursor/ (XDG), XDG_CONFIG_HOME/cursor/, ~/.cursor/
*/
export function getPossibleAuthPaths(): string[] {
const home = homedir();
const home = getHomeDir();
const paths: string[] = [];
const isDarwin = platform() === "darwin";

Expand Down
23 changes: 14 additions & 9 deletions src/cli/discover.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
#!/usr/bin/env bun
import { ModelDiscoveryService } from "../models/discovery.js";
import { ConfigUpdater } from "../models/config.js";
#!/usr/bin/env node
import { readFileSync, writeFileSync, existsSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import {
discoverModelsFromCursorAgent,
fallbackModels,
} from "./model-discovery.js";

async function main() {
console.log("Discovering Cursor models...");

const service = new ModelDiscoveryService();
const models = await service.discover();
let models = fallbackModels();
try {
models = discoverModelsFromCursorAgent();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Warning: cursor-agent model discovery failed, using fallback list (${message})`);
}

console.log(`Found ${models.length} models:`);
for (const model of models) {
console.log(` - ${model.id}: ${model.name}`);
}

// Update config
const updater = new ConfigUpdater();
const configPath = join(homedir(), ".config/opencode/opencode.json");

if (!existsSync(configPath)) {
Expand All @@ -29,7 +34,7 @@ async function main() {

// Update cursor-acp provider models
if (existingConfig.provider?.["cursor-acp"]) {
const formatted = updater.formatModels(models);
const formatted = Object.fromEntries(models.map((model) => [model.id, { name: model.name }]));
existingConfig.provider["cursor-acp"].models = {
...existingConfig.provider["cursor-acp"].models,
...formatted
Expand All @@ -45,4 +50,4 @@ async function main() {
console.log("Done!");
}

main().catch(console.error);
main().catch(console.error);
50 changes: 50 additions & 0 deletions src/cli/model-discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { execFileSync } from "child_process";
import { stripAnsi } from "../utils/errors.js";

export type DiscoveredModel = {
id: string;
name: string;
};

export function parseCursorModelsOutput(output: string): DiscoveredModel[] {
const clean = stripAnsi(output);
const models: DiscoveredModel[] = [];
const seen = new Set<string>();

for (const line of clean.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(
/^([a-zA-Z0-9._-]+)\s+-\s+(.+?)(?:\s+\((?:current|default)\))*\s*$/,
);
if (!match) continue;

const id = match[1];
if (seen.has(id)) continue;
seen.add(id);
models.push({ id, name: match[2].trim() });
}

return models;
}

export function discoverModelsFromCursorAgent(): DiscoveredModel[] {
const raw = execFileSync("cursor-agent", ["models"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const models = parseCursorModelsOutput(raw);
if (models.length === 0) {
throw new Error("No models parsed from cursor-agent output");
}
return models;
}

export function fallbackModels(): DiscoveredModel[] {
return [
{ id: "auto", name: "Auto" },
{ id: "sonnet-4.5", name: "Claude 4.5 Sonnet" },
{ id: "opus-4.6", name: "Claude 4.6 Opus" },
{ id: "gpt-5.2", name: "GPT-5.2" },
];
}
Loading
Loading