diff --git a/docs/architecture/cursor-agent-reverse-engineering.md b/docs/architecture/cursor-agent-reverse-engineering.md new file mode 100644 index 0000000..9737014 --- /dev/null +++ b/docs/architecture/cursor-agent-reverse-engineering.md @@ -0,0 +1,100 @@ +# Reverse-Engineering cursor-agent + +How to read cursor-agent's actual behavior so this project can track its wire protocol, tool schemas, and edit semantics across builds. This is interop work: we drive `cursor-agent` as a subprocess and parse its output, so we need its ground truth, not guesses. + +- **Last updated:** 2026-07-21 +- **Agent build inspected:** `2026.07.17-3e2a980` +- **Scope:** the `cursor-agent` CLI we ship against, not Cursor's ACP channel (oakimov covers that, and it is a different interface). + +--- + +## The key fact: cursor-agent is readable JavaScript on disk + +`cursor-agent` is not a compiled binary. The entrypoint is a small Bourne shell launcher; the implementation is a set of Node.js bundles sitting in the version directory: + +``` +~/.local/share/cursor-agent/versions// + cursor-agent # ~4 KB shell launcher + *.index.js # ~67 chunks, ~8 MB total, minified but plain text +``` + +The bundles are minified (one long line each) but not obfuscated or encrypted. `grep` works directly; a beautifier makes them readable. This is the whole protocol, the tool definitions, the prompt scaffolding, and the request builders, already on the machine. No unpacking, no network capture required to answer most questions. + +Worked example (from build `2026.07.17`, chunk `3143.index.js`), the edit builder: + +```js +d = fileTooLarge || contentBeforeWrite === undefined + ? [{ old_string: "", new_string: fileText }] // full-file replace + : contentBeforeWrite === fileText ? [] : computeDiffHunks(...) +``` + +That single expression explains the Composer edit loop documented in [known-limitations.md](../known-limitations.md): when cursor-agent has no diff base (small or freshly written file), it emits an edit as a full-file replace with an empty `old_string`, which this plugin reroutes to `write`. The behavior is cursor-agent's, confirmed at the source. + +Tool taxonomy in the same bundle: `shellToolCall`, `grepToolCall`, `editToolCall`, `readToolCall`, `writeToolCall`, `deleteToolCall`, `lsToolCall`. + +--- + +## Recommended method, in order + +### 1. Static read of the bundles (primary) + +Highest yield, lowest risk, repeatable per build. + +```sh +D=~/.local/share/cursor-agent/versions/ +# locate a concept +grep -lF "editToolCall" "$D"/*.index.js +# read a minified window without beautifying the whole file +grep -oE '.{120}old_string.{260}' "$D"/3143.index.js +# beautify a chunk for real reading +npx prettier --parser babel "$D"/3143.index.js > /tmp/3143.pretty.js # or js-beautify +``` + +Targets worth mapping: +- Tool call cases and their arg shapes (`*ToolCall` → `{path, ...}`), to keep `src/proxy/tool-loop.ts` and `src/acp/tools.ts` aligned. +- Edit construction (full-file vs diff hunks), which drives our reroute logic. +- Stream event framing and `subtype` usage, to match `src/streaming/*`. +- Prompt and system text, to understand why a model emits a given tool shape. + +Cross-check findings against what the plugin already parses. Much of this is implicitly known in `src/streaming/*`, `src/proxy/tool-loop.ts`, `src/acp/tools.ts`, and [cursor-agent-tools.md](../cursor-agent-tools.md); the goal is to make it explicit and versioned. + +### 2. Behavioral capture (confirmation) + +We already do a form of this to fill the tool map. Keep it as a check on the static read, not the main source: run `cursor-agent --print --output-format stream-json` against a fixed prompt battery (read, write, small edit, large edit, bash, grep, glob, ls, multi-tool, plan), save raw NDJSON as fixtures, and diff across builds. Use it to verify that what the bundle says matches what the agent emits. + +### 3. Network interception (only if needed) + +cursor-agent talks TLS to `api2.cursor.sh` / `api3.cursor.sh` (blob assets on `cursor.blob.core.windows.net`). A proxy such as mitmproxy could show the upstream request and response, but it is the heaviest path and it is not the recommended starting point: + +- The client is readable, so how it builds and signs requests, and whether it pins certificates, can be read directly from the bundles instead of intercepted. +- Interception needs cursor-agent to trust a local CA and to skip pinning. Determine both from the source first (`grep -i` for `checkServerIdentity`, `rejectUnauthorized`, proxy env handling) before spending time on a proxy. + +Reach for this only when a live upstream payload is the specific unknown. + +--- + +## On the npm route + +Getting the source from npm, the way the Claude Code bundle was read, does not apply cleanly here: + +- Claude Code is published by its vendor as `@anthropic-ai/claude-code`, so npm is the source. +- On npm, `cursor-agent@1.0.3` and `cursor-cli@1.0.0` exist but use a `1.x` scheme, not Cursor's date builds (`2026.07.17`). They look like third-party wrappers, not Anysphere's official distribution. Treat them as unverified. +- Cursor ships the real agent through its own installer into `~/.local/share/cursor-agent/versions/`, which is what we already have. npm would at best hand back the same minified bundle. + +So npm is a dead end for the official build; the local version directory is the authoritative copy. + +--- + +## Legality and handling + +This is interoperability analysis of a client we run under our own account on our own machine, to build a compatible integration. Standard practice, same posture as the existing stream-json parser. Keep it to understanding and documenting the protocol. Do not redistribute Cursor's bundle contents; commit our own notes, schemas, and fixtures, not their code. + +--- + +## Maintenance + +cursor-agent auto-updates and the protocol drifts between builds (see the version churn already noted in the architecture docs). On each bump: + +1. Re-run the static targets above against the new version directory. +2. Re-capture the behavioral fixtures and diff. +3. Record the build number here and in any schema notes, and update the plugin's parser if a tool shape or event changed. diff --git a/docs/known-limitations.md b/docs/known-limitations.md new file mode 100644 index 0000000..0b21f6a --- /dev/null +++ b/docs/known-limitations.md @@ -0,0 +1,52 @@ +# Known Limitations + +A running list of behaviors we understand, have decided not to fix (or not yet), and want to keep visible. Add to it as new ones surface. Each entry says what breaks, why, how far it spreads, and why it stays open. + +- **Last updated:** 2026-07-21 + +--- + +## Tool handling + +### Composer loops when editing very small files + +**Symptom.** With Cursor's Composer models, a prompt to edit a file of roughly four lines or fewer can loop. OpenCode shows repeated `Wrote` events instead of an edit, the model reads the tool result as a failure, and it retries the same change until it gives up. + +**Cause.** Composer emits a full-file body under `streamContent` for a trivial change rather than a targeted `old_string`/`new_string` edit. The plugin reads that as a full-file edit and reroutes it to `write` (`tryRerouteEditToWrite` in `src/provider/runtime-interception.ts`). The write succeeds, OpenCode surfaces it as `Wrote`, and Composer expected an edit result, so it runs the task again. + +**Scope.** Small or degenerate files only. On real content, Composer sends a proper targeted edit and it applies surgically with no loop. We reproduced the loop on a one-line file and confirmed a clean edit on a 47-line file in the same session (2026-07-21, Composer 2.5, cursor-agent `2026.07.09`, opencode 1.18.3). This is why ~700 weekly installs have not reported it: normal editing does not hit the path. + +**Why it stays open.** Two gaps sit behind it, both shipped with the original Composer fix and neither introduced by a later regression: + +1. The reroute guard `detectSuspiciousStreamContentWrite` only fires for files of five lines or more (`existingLines < 5` returns early). Smaller files skip the guard. +2. `4053f6e` added a `cursorOwnedMutation` / `completed_cursor_edit_success` signal to tell the model an edit already landed, but no caller consumes it. The signal is produced in `runtime-interception.ts` and read nowhere. + +Closing either gap means changing how edit and write ownership is signaled back to the model. Commits `72499f4`, `d625421`, and `44364d9` (June 2026) settled that boundary to stop duplicate writes. Reopening it to catch a one-line edge case risks the heavier bug we already fixed. The cost of the fix outweighs the cost of the limitation. + +**References.** Composer guard `958d8fe`; mutation classifier `4053f6e`; reroute introduced in `82afd37`. Code: `src/provider/runtime-interception.ts` (`detectSuspiciousStreamContentWrite`, `tryRerouteEditToWrite`, `detectCursorOwnedMutation`). + +### applyAgentDiff is an unhandled cursor-owned edit path + +**Symptom.** None observed yet. cursor-agent defines an `applyAgentDiffToolCall` that the plugin does not map. If it reaches the stream, the generic normalizer produces `applyagentdiff`, no OpenCode tool matches, and it passes through to cursor-agent. + +**Cause.** cursor-agent classifies `applyAgentDiffToolCall` internally as an `edit` and applies the diff itself. The event exposes only `{path}` plus a success/error `result`; the diff body is not in the payload. It is a notification of a cursor-owned mutation, like `editToolCall` in display mode, not an executable request. + +**Scope.** Unknown. We have not seen `applyAgentDiff` in any captured stream (Composer used `editToolCall` / `streamContent`). It may appear only under certain models or modes. + +**Why it stays open.** Correct handling is recognition, not execution: mark the mutation as cursor-owned so the model gets a completion and does not loop, via the `cursorOwnedMutation` / `completed_cursor_edit_success` signal that `runtime-interception.ts` already produces and no caller consumes. That touches the same edit/write ownership the June 2026 commits stabilized, so it needs its own change and test pass. Mapping it to `edit` for OpenCode to run would double-apply and lacks the diff anyway. + +**Testing needed.** Confirm whether `applyAgentDiff` reaches the plugin in real traffic. If it does, wire the `cursorOwnedMutation` consumer and verify no loop and no double-apply. + +**References.** cursor-agent bundle `189.index.js` (`applyAgentDiffToolCall: "edit"`, args `{path}`, `.result` success/error). Related: unconsumed `cursorOwnedMutation` in `src/provider/runtime-interception.ts`. + +--- + +## Architecture, speed, performance + +To be filled in. Placeholder for per-request `cursor-agent` spawn cost, streaming latency, and related tradeoffs. + +--- + +## Adding an entry + +Keep the five parts: symptom, cause, scope, why it stays open, references. Name the file and function, and the commit if there is one. State the reproduction environment with a build number when the behavior depends on Cursor or OpenCode versions.