Cursor Provider (part 2)
Part 2 of the Cursor Provider page: how a turn actually runs under the hood — the cursor-agent process Omniscio launches, how its output is turned into an ordinary Omniscio session stream, and the stream format as validated against a real capture. Part 1 covers what changes on screen, the readiness gates, choosing a model and the error states.
What it is
This is part 2 of the Cursor Provider page. It covers how a turn actually runs once the provider is enabled: what Omniscio launches, how the agent's output becomes a normal session stream, and the stream format that was validated against a real capture.
Where to find it
There is nothing separate to open — this is the machinery behind an ordinary Cursor-backed session. Enabling the provider, choosing a model and reading its error states are all on part 1.
How it behaves
How it works under the hood
One subprocess per turn. For each user message Omniscio spawns cursor-agent -p --output-format stream-json --stream-partial-output --force --trust [--resume <id>] with the prompt delivered on stdin (not argv — see the stdin note below), via the shared spawnCliChild (whose quoted cmd /d /s /c wrap survives a spaces-in-path install dir like C:\Users\<you>\… — a bare cmd /c split it at the space and silently produced no output). --force auto-approves command execution; --trust clears cursor-agent's separate headless Workspace-Trust gate — without it, cursor-agent in -p/headless mode prints ⚠ Workspace Trust Required and exits code=1 with no result event, which previously killed every Cursor session on its first turn (the original launch-failure bug; see contract invariant I14). The prompt is delivered on stdin (spawnCliChild's stdinInput: written as UTF-8, then the stream is closed = EOF), NOT as an argv arg — cmd.exe is line-oriented and truncates a multi-line argv at the first newline, so a first message with the project-docs block prepended reached cursor-agent as ONLY its first line, ## Injected Project Docs (the docs body + your question + the trailing flags all silently dropped). stdin flows through the cmd.exe → powershell → agent chain intact (verified live). Closing the stream right after the write preserves the EOF that stops cursor-agent blocking on interactive input — an idle-open 'pipe' stdin made every turn stall ~66s and never clean-exit (the multi-minute "couldn't complete this turn" hang plus the intermittent UV_HANDLE_CLOSING crash). See cli-cmd-newline-truncation-contract.md + contract invariant the-prompt-is-delivered-on-stdin-never-in-an-argument. There is no long-lived process: launch() registers an in-memory session (status='ready') with no child, and sendMessage() spawns cursor-agent. When a session is created WITH an initial prompt (the CLI control server, a deep-link, or a recipe), launch() dispatches that first turn itself (fire-and-forget) so the session doesn't sit idle at ready. If Cursor clean-exits after abandoning stream-json and printing only plain text, the runner salvages that reply instead of discarding it. This per-turn one-shot model mirrors the other one-shot-cli engines (Antigravity, Grok) — NOT the persistent-external engines (Codex's long-lived app-server, OpenCode's shared opencode serve server, Gemini's --acp app-server, and Hermes's hermes acp ACP child — all long-lived, not per-turn).
Claude-hook isolation (the "Cursor can't write" fix). cursor-agent secretly imports the user's Claude Code hooks — ~/.claude/settings.json (global) AND the project's <workspace>/.claude/settings.json — and on Windows runs each PowerShell-shaped hook through git-bash, which is a bash syntax error, so cursor's PreToolUse step returns non-JSON and cursor blocks every shell/git command "for safety" (reads + file-edits use an ungated tool path → they still work; the exact "reads work, nothing else does" symptom). There is no cursor flag/env/config to disable hook import. Omniscio fixes it in two parts: (1) a managed cursor HOME (cursor-managed-home.ts), applied only when the user actually has global ~/.claude hooks — the spawn points USERPROFILE/HOME/HOMEDRIVE/HOMEPATH (NOT APPDATA/LOCALAPPDATA, so cursor-agent's own binary + cache still resolve) at an empty managed dir (no .claude → no global hooks, nothing else exposed) and re-injects the user's git commit identity via GIT_CONFIG_GLOBAL. A user with no global hooks keeps their real home untouched, and unlike the earlier junction-mirror cursor never sees the user's ~/.ssh keys / ~/.aws / ~/.gnupg; (2) conditional per-session worktree isolation (OneShotConfig.isolateInWorktree, cursor ONLY) — a NEW cursor session runs in its own git worktree — but ONLY when the repo actually declares project .claude hooks (cursorNeedsWorktreeIsolation); a no-hooks repo runs cursor in place in the project dir, parity with the other one-shot engines — where the repo's tracked .claude/settings.json is neutralized via git update-index --skip-worktree + writing {} (cursor-worktree-claude-neutralize.ts): cursor reads no hooks, but git's index keeps the ORIGINAL so commits/merges never strip the user's guard-e2e (and the {} survives cursor running git reset --hard / git checkout .). Isolation is fired non-blocking at launch() (so the UI's session push isn't delayed) and awaited by the first turn; on any failure the worktree is torn down and the turn falls back to the project dir (the managed home still kills the global hooks). A crash/restart DURING that ~6-min first-turn checkout can leave a half-created worktree on disk that the DB never recorded (a NULL worktreePath, so startup reconcile can't see it); on recovery the first turn reclaims that orphan and retries the checkout once, rather than failing "already exists" and dropping into the un-isolated project dir where the live hooks would jam it (permanent error). Every OTHER one-shot engine (gemini/opencode/antigravity) is byte-for-byte unchanged (hermes migrated to persistent-external ACP 2026-08-01 and is no longer a one-shot engine). Proven live (control = blocked, fix = shell+edit+git run). See cursor-claude-hook-isolation-contract.md.
Preventing a SECOND worktree (the "already isolated, don't cut another" directive). The isolation above secures the ONE worktree Omniscio cuts — but a cursor session running the delivered dev-pipeline would create its OWN second worktree (git worktree add) per the "worktree per session" convention, and that fresh worktree cut off master re-checks-out the repo's LIVE .claude hooks (the neutralize deliberately keeps the real hook in git's index), so cursor jams in it one worktree over. A reactive "neutralize the new worktree" fix can't win the race — dev-pipeline's first action in the new worktree is a shell command (npm run worktree:deps) that jams before any detectable edit — so Omniscio PREVENTS the second worktree instead: every cursor turn in a hooks-declaring repo gets a short workspace directive prepended to its prompt ("you are already in an Omniscio-isolated worktree — do NOT create another; ignore any worktree-per-session skill step, dev-pipeline included"), so the agent works in place (cursor-isolation-directive.ts). It's gated on the project's hooks, never the neutralized worktree (which reads as hook-free), and delivered on the guaranteed stdin prompt channel. See cursor-claude-hook-isolation-contract.md invariant I13.
Native skill delivery. Because the managed HOME hides the user's ~/.claude/skills from cursor, every NEW cursor session also copies the user's skills (portable, marker-non-clobber) into its workspace's native .cursor/skills — which cursor reads regardless of its third-party-extensibility flag — so a cursor session can actually load them (including a portable dev-pipeline). The copy lands in the isolated worktree when there is one, else the project dir. Gated on the Sync config to other AI CLIs feature (the providerConfigSyncSkills toggle + cursor as a target): OFF by default → no delivery and zero added spawn latency. Idempotent + fail-soft (a copy error never breaks the spawn). See cursor-skill-delivery.ts + contract invariant I10.
Multi-turn via --resume. Cursor's result event carries a session id. Omniscio persists it (setCliSessionId) and the NEXT turn passes it as --resume <id> so Cursor restores prior history. Omniscio never overwrites a stored id with an empty one (an interrupted turn that produced no result event keeps the prior id). The plain-text salvage path also preserves any prior id instead of inventing a new one when Cursor answered but emitted no real session id.
Streaming translation. stream-json stdout is parsed line-by-line by a pure translator (cursor-stream-translator.ts) into typed events: assistant/text (streamed to the bubble via SESSION_OUTPUT with streaming:true), tool_call (a ▸ toolname marker — e.g. Edit / Read), and result (terminal: success or error). cursor-agent v2026.06.04 nests the tool TYPE as the single KEY of tool_call (editToolCall / readToolCall — there is no name field) and emits a started THEN a completed frame per call, so the translator derives the marker name from that key (strip ToolCall, capitalize) and emits one marker per call (the completed duplicate is dropped). Before 2026-06-23 it looked only for a flat name, found none, and silently dropped EVERY tool call (no chips, and the lost events also starved the stall-watchdog's turn-liveness signal) — re-recorded against a real capture + locked by tool-calls-turn.ndjson. At end-of-stream one final SESSION_OUTPUT with streaming:false carries the full accumulated text (REPLACE semantics — the same contract every other provider uses). If the CLI emits no recognizable NDJSON at all and exits cleanly, the runner falls back to one final plain-text event from raw stdout. The translator never throws on a malformed line — a non-object line or a null/non-object content[] block is skipped rather than crashed on, so one garbled frame can't abort the burst, drop a real result, and surface as a false "no result event" (with a false crash-report); mirrors acp-update-translator, locked by provider-registry-contract.md the-translator-never-throws-on-malformed-input.
Delivered reply wins over a soft error. A turn that actually streamed substantive assistant text is treated as a success even if its result event flags is_error — the session stays ready, the reply is kept, no "couldn't complete this turn" failure row is written, and cost is still recorded (the raw cause goes to the log only). Only a turn that produced NO answer text (nothing, or just a tool marker) keeps the genuine error. This is a base OneShotExternalSessionManager behavior, so Gemini / OpenCode / Antigravity inherit it too; it stops a soft-erroring turn that already answered from showing a red error next to a real reply. See contract invariant I17.
Cost tracking (≈ estimated). Each successful turn writes per-session totals (updateSessionCost) and an api_cost_log row via trackApiCostRaw with synthetic accountId/source 'cursor' (model label = the picked model id, else 'cursor-composer'). This satisfies the "CLI spend MUST hit api_cost_log" rule. cursor-agent's result event carries a usage token block (input/output/cacheRead/cacheWrite); translateCursorJsonChunk now extracts it (type-guarded, success turns only) and cursor-turn-runner prices it via priceCursorTurn (cursor-cost.ts) at the picked model's published rate — an ESTIMATE (costReporting:'estimated'), since Cursor really bills by subscription/credits. Pricing is gated on a known-model map: Auto / composer / unmapped models stay tokens-only with no fabricated cost — the shared estimateCostUsd would otherwise bill an unknown model at a wrong $3/$15 default. Pricing happens upstream of the session manager so the inline and provider-session-core cost paths record the identical figure. See provider-registry-contract.md cost-is-estimated-from-tokens-never-fabricated.
Interrupt. sendMessage creates a per-turn AbortController, stashes it on the session, and passes its signal to the turn-runner. Pressing stop fires the signal → the runner force-kills the whole child tree (forceKillChild, Windows-safe taskkill /F /T) and resolves aborted:true (a clean stop, not a crash). Partial streamed text is preserved, status returns to ready, and the captured id is kept so the next turn can still resume. Stop is also live during the FIRST turn's one-time worktree-isolation window (the checkout + Claude-hook neutralization that runs before the child spawns): the AbortController is armed before that wait and the wait races the signal, so an interrupt frees the session immediately — and is the recovery path if that checkout ever wedges — instead of doing nothing until it finishes.
Lifecycle teardown (codex-grade). Pausing, archiving, snoozing, or shutting down a Cursor session routes through cursorSessionManager.terminate / terminateAll — wired into terminateBeforeLifecycleTransition, the shutdown sequence, and the shutdown system-message loop. (This deliberately follows the Codex/Gemini wiring, not OpenCode's — OpenCode has gaps in those paths.)
Routing. A SESSION_LAUNCH (and subsequent send / interrupt / terminate / change-provider) with provider: 'cursor' is dispatched to cursorSessionManager instead of the Claude processManager, in both session-service.ts and session-handlers.ts. The session row stores provider: 'cursor' in its DB column — accepted by a dated ledger migration that extends the provider validation trigger to allow the value.
Auth. A Cursor API key is required — cursor-agent login does not authenticate the headless turns Omniscio runs (see "How to enable" step 3). The key is stored encrypted (cursorApiKey, on the sensitive-keys list so the CLI server strips it from GET /settings), trimmed on save, and injected as CURSOR_API_KEY env on spawn via buildCleanSpawnEnv() — never on argv. Both the readiness gate and a runner pre-spawn guard enforce it.
Binary status IPC. The Settings panel's binary status card calls CURSOR_GET_STATUS (cursor:get-status), which runs resolveCursorBinary() and returns { installed, version } — mirroring OPENCODE_GET_STATUS. This isolates the "is the binary on PATH" question from the key gate. (The binary-status card is revealed only once the Allow Cursor sessions toggle is on — see "How to enable" / I9 — so it guides install right after you opt in, not before.)
Capabilities. cursor is declared with { images: false, mcp: false, cloud: false, sshRemote: false, permissionPrompts: false, multiTurn: true } in provider-capabilities.ts — images: false is "no inline-image protocol", not "attachments don't work"; no MCP config, no SSH remote, no permission prompts (auto-approves), but multi-turn (via --resume). Attachments (images, PDFs, docs) and pasted-text ARE delivered: like every one-shot engine, cursor receives them as files saved to the workdir with their paths injected into the prompt (see chat-attachments "Non-Claude engines"), readable by the agent's file tools. Image vision through cursor-agent is engine-dependent (unverified without a live account).
Stream format — validated against a real capture (2026-06-08)
Cursor was authored from docs (no account at authoring time), then validated end-to-end against live cursor-agent … --output-format stream-json runs on a real Cursor account: the field paths the translator depends on — assistant.message.content[].text and result.session_id / result.is_error — are confirmed correct (live turns streamed the reply and captured a cli_session_id). The real stream also carries thinking / tool_call events and a usage token block (now extracted + priced — see Cost tracking above). A later live log also showed a separate fallback shape: Cursor can abandon stream-json and print only plain text, which the runner now salvages. The committed fixtures now include a real cursor-agent v2026.06.04 capture — tests/fixtures/cursor-transcripts/tool-calls-turn.ndjson (a write-then-read turn) — which locks the true tool_call shape (nested editToolCall/readToolCall key, started+completed pair); the older basic-turn.ndjson stays hand-authored. See the feature contract's "Known gaps."
Related
- Cursor Provider — part 1: what changes on screen, the readiness gates, models and error states.
Last verified 2026-10-06