feat(cursor): drive cursor-agent acp as a second app-server dialect#7
Conversation
Add the Cursor CLI ACP (Agent Client Protocol, JSON-RPC 2.0 over stdio)
dialect alongside codex app-server, sharing the RPC session path.
- New src/main/ingest/providers/cursor-acp/ (protocol, driver, normalize,
launch) mirroring codex-appserver/.
- Hoist the dialect-neutral driver contract to app-server-driver.ts
(AppServerDriver, AppServerDriverError, PendingApproval, TurnResult); both
dialects implement one interface.
- AppServerCapability.protocol dialect field ("codex-app-server" | "acp");
RpcSessionManager dispatches on it. cursor declares launchCmd cursor-agent,
args ["acp"], protocol "acp".
- Transport always emits the "jsonrpc":"2.0" header (codex tolerates it) and
gains respondError; notification/server-request channels are Queue.Dequeue.
- codex-approval-view recognizes ACP {optionId, name, kind} options — labels
by name, answers with optionId; codex decisions stay opaque passthrough.
ACP specifics the driver handles: turn completion is the session/prompt
response ({stopReason}), not a notification; the user turn is never echoed
(synthesized from the prompt text); session/load replays the prior transcript
as notifications before its response (drained post-handshake). The turn folds
session/update after the response resolves — the transport routes stdout
through one sequential fiber, so every update is queued before the response,
making a single Queue.clear race-free.
pnpm typecheck clean; pnpm test 531 passed / 2 skipped (live-gated).
Greptile SummaryAdds the Cursor ACP (Agent Client Protocol, JSON-RPC 2.0 over stdio) dialect alongside the existing codex app-server driver, sharing the same newline-delimited transport. Both dialects implement the new
Confidence Score: 4/5The core sequential-transport ordering argument (that all session/update notifications land in the queue before the session/prompt response resolves) is sound and verified by the mid-turn-exit test. The two flagged issues are narrow edge cases, not regressions on any normal path. The items.push before transport.request leaves a logical gap if a turn write-fails while the session stays alive, and the Effect.ignore on respondError silently drops write failures for unsupported requests in both the ACP and codex drivers. Both are narrow and already acknowledged; no normal-path behavior is broken. src/main/ingest/providers/cursor-acp/driver.ts — the items.push / Effect.ignore patterns discussed above; src/main/ingest/providers/codex-appserver/driver.ts — the same pre-existing Effect.ignore on non-approval respond that this PR carries forward.
|
| Filename | Overview |
|---|---|
| src/main/ingest/providers/cursor-acp/driver.ts | New ACP driver implementing AppServerDriver over cursor-agent stdio; correctly relies on sequential transport ordering for the post-response Queue.clear drain, but items.push before transport.request creates an orphaned user entry if the prompt write fails without killing the session |
| src/main/ingest/providers/codex-appserver/transport.ts | Notifications and serverRequests promoted from Stream to Queue.Dequeue; respondError added; jsonrpc header always emitted; Queue.shutdown on process exit confirmed correct |
| src/main/ingest/providers/app-server-driver.ts | New dialect-neutral interface (AppServerDriver, PendingApproval, TurnResult, AppServerLaunchParams) extracted cleanly; well-structured abstraction |
| src/main/ingest/providers/cursor-acp/normalize.ts | AcpItem → ExtractedRows normalizer; execute tools map to Shell rows with structured output, others to generic tool rows; mirrors codex-appserver/normalize.ts output shape correctly |
| src/main/ingest/providers/cursor-acp/protocol.ts | Effect Schema definitions for ACP wire shapes; decodeUnknownOption used throughout for safe fallback; unknown variants intentionally absent (decode to None and are skipped) |
| src/main/services/RpcSessionManager.ts | Factory dispatch by req.protocol (acp vs default codex) cleanly inserted; session semaphore serializes turns per session for both dialects |
| src/main/services/codex-approval-view.ts | ACP option shape (optionId/name/kind) detected by optionId presence; payload encoded as JSON.stringify(optionId) so the driver receives the bare id after parse; codex opaque decisions unchanged |
| tests/cursor-acp-driver.test.ts | Scripted-peer tests covering handshake, permission with numeric id 0, resume/replay-discard, and mid-turn process exit; good coverage of the protocol edge cases |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant M as RpcSessionManager
participant D as CursorAcpDriver
participant T as AppServerTransport
participant P as cursor-agent acp
M->>D: makeCursorAcpDriver(options)
D->>T: makeAppServerTransport()
T->>P: spawn process (stdio)
D->>P: "initialize {protocolVersion:1}"
P-->>D: "result {agentCapabilities}"
alt fresh session
D->>P: "session/new {cwd}"
P-->>D: "result {sessionId}"
else resume
D->>P: "session/load {sessionId, cwd}"
P-->>D: notifications (replay)
P-->>D: "result {modes, models}"
D->>T: Queue.clear(notifications)
end
M->>D: runTurn(text)
D->>D: items.push user message
D->>P: "session/prompt {sessionId, prompt}"
loop while prompt running
P-->>T: "session/update -> notifications queue"
P-->>T: "session/request_permission -> serverRequests queue"
T-->>D: serverRequests fiber picks up permission
D->>P: "respond {outcome:{outcome:selected,optionId}}"
D->>D: SubscriptionRef.update pendingApprovals
P-->>T: more session/update notifications
end
P-->>D: "session/prompt response {stopReason}"
D->>T: Queue.clear(notifications)
D->>D: foldNotification for each update
D->>D: "flushText() -> assistant message item"
D->>D: "normalizeAcpSession(items) -> ExtractedRows"
D-->>M: "TurnResult {status, rows}"
M->>M: IngestStore.replaceSession(rows)
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant M as RpcSessionManager
participant D as CursorAcpDriver
participant T as AppServerTransport
participant P as cursor-agent acp
M->>D: makeCursorAcpDriver(options)
D->>T: makeAppServerTransport()
T->>P: spawn process (stdio)
D->>P: "initialize {protocolVersion:1}"
P-->>D: "result {agentCapabilities}"
alt fresh session
D->>P: "session/new {cwd}"
P-->>D: "result {sessionId}"
else resume
D->>P: "session/load {sessionId, cwd}"
P-->>D: notifications (replay)
P-->>D: "result {modes, models}"
D->>T: Queue.clear(notifications)
end
M->>D: runTurn(text)
D->>D: items.push user message
D->>P: "session/prompt {sessionId, prompt}"
loop while prompt running
P-->>T: "session/update -> notifications queue"
P-->>T: "session/request_permission -> serverRequests queue"
T-->>D: serverRequests fiber picks up permission
D->>P: "respond {outcome:{outcome:selected,optionId}}"
D->>D: SubscriptionRef.update pendingApprovals
P-->>T: more session/update notifications
end
P-->>D: "session/prompt response {stopReason}"
D->>T: Queue.clear(notifications)
D->>D: foldNotification for each update
D->>D: "flushText() -> assistant message item"
D->>D: "normalizeAcpSession(items) -> ExtractedRows"
D-->>M: "TurnResult {status, rows}"
M->>M: IngestStore.replaceSession(rows)
Comments Outside Diff (2)
-
src/main/ingest/providers/cursor-acp/driver.ts, line 607-625 (link)Orphaned user message on prompt-write failure
items.push({ kind: "message", role: "user", text })mutates the session-cumulative array beforetransport.request("session/prompt", ...)is awaited. If that write fails (e.g., a transientstdin.writeerror that doesn't immediately kill the process),ChatMessageServicecatchesAppServerDriverErrorand keeps the session alive — so the nextrunTurncall sees an extra leading user message initems, andnormalizeAcpSessionwill produce a transcript with an orphaned entry.In practice the transport typically becomes
closedon any write failure (the child dies shortly after), making further turns immediately fail. But the logical gap is real: the push should be inside a rollback-aware structure, or at minimum after the request fires successfully. -
src/main/ingest/providers/cursor-acp/driver.ts, line 584-589 (link)Silent write failure on unsupported-request rejection
.pipe(Effect.ignore)swallows anyAppServerTransportErrorfromrespondError. If the write fails, the agent'scursor/ask_question(or other blocking request) gets no response, leaving it stalled with no observable error in the driver. The PR description already notes this as a known follow-up and points out the same pattern exists onrespond(...).pipe(Effect.ignore)in the codex driver (line 163 ofcodex-appserver/driver.ts). Worth sweeping both sites together when addressed: replacingEffect.ignorewithEffect.catchAll(Effect.logWarning(...))keeps the server-requests loop alive while still surfacing the failure.
Reviews (1): Last reviewed commit: "feat(cursor): drive cursor-agent acp as ..." | Re-trigger Greptile
Summary
Adds the Cursor CLI ACP (Agent Client Protocol, JSON-RPC 2.0 over stdio) dialect alongside codex app-server, sharing the same RPC session path. Both dialects ride one newline-delimited transport but differ in handshake, turn-completion signal, and approval format — each encapsulated in its own driver.
app-server-driver.ts— hoists the dialect-neutral contract (AppServerDriver,AppServerDriverError,PendingApproval,TurnResult) out of the codex driver; both dialects implement one interface. Service-layer imports redirected.cursor-acp/— new ACP driver, normalizer (AcpItemaccumulator →ExtractedRows), protocol schemas, and launch fn mirroringcodex-appserver/.RpcSessionManagerpicks the factory bycapability.protocol("codex-app-server" | "acp").transport.ts—notifications/serverRequestsare nowQueue.Dequeue(enables the ACP driver'sQueue.clearbulk-drain), addsrespondError, and always emits the"jsonrpc":"2.0"header (codex tolerates it).codex-approval-view.ts— recognizes ACP{optionId, name, kind}options: labels byname, answers withoptionId; codex decisions stay opaque passthrough.ACP specifics the driver handles
session/promptresponse ({stopReason}), not a notification.session/load(resume) replays the prior transcript as notifications before its response returns; drained post-handshake so resume accumulates only new turns.session/updateafter the response resolves. The transport routes stdout through one sequential fiber, so every update is queued before the response — making a singleQueue.clearrace-free.Deferred (tracked)
cursor/ask_question/cursor/create_planblocking requests rejected with-32601for now (need their own input surface; tied to plan/ask modes we don't select yet).session/cancelinterrupt.Verification
pnpm typecheckclean ·pnpm test531 passed / 2 skipped (live-gated). Scripted-peer tests cover handshake, permissions (incl. numeric id0), resume/replay-discard, and mid-turn process exit. Live smoke against realcursor-agent acp:echo hello-acpshell call with auto-answered permission produced user msg + tool call + output + assistant reply.Known follow-up
Greptile review flagged one P2: the
-32601rejection for unsupported server requests uses.pipe(Effect.ignore), which silently swallows a write failure and could leave the agent blocked. Fix is to log-don't-swallow (keeping the loop alive); the same pattern exists in the codex driver'srespond(...).pipe(Effect.ignore), so it should be swept across both. Not addressed in this PR.