---
title: Cursor Provider (part 2)
---

# Cursor Provider (part 2)

## What it is

This is part 2 of the [Cursor Provider](cursor-provider.md) 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](cursor-provider.md).

## 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](../../.claude/memory/contracts/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](../../src/main/services/engines/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](../../src/main/services/engines/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](../../.claude/memory/contracts/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](../../src/main/services/engines/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](../../.claude/memory/contracts/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](../../src/main/services/engines/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](../../.claude/memory/contracts/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](../../.claude/memory/contracts/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, ssh: 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](chat-attachments.md) "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](cursor-provider.md) — part 1: what changes on screen, the readiness gates, models and error states.
