Skip to content

feat(cursor): migrate remote sessions to ACP with model/variant pickers#799

Open
swear01 wants to merge 8 commits into
tiann:mainfrom
swear01:feat/cursor-acp-migration-781
Open

feat(cursor): migrate remote sessions to ACP with model/variant pickers#799
swear01 wants to merge 8 commits into
tiann:mainfrom
swear01:feat/cursor-acp-migration-781

Conversation

@swear01
Copy link
Copy Markdown
Contributor

@swear01 swear01 commented Jun 4, 2026

Summary

Migrates HAPI Cursor remote integration from agent -p --output-format stream-json to agent acp, with legacy stream-json resume only for pre-migration sessions. Adds Zed-style permission/plan/question flows and a two-tier model catalog (ACP wire ids + CLI agent --list-models SKUs) for New Session and in-session pickers.

Closes #781
Fixes #705
Fixes #687

Design heuristics

Protocol & routing

  • New Cursor sessions → ACP only. Metadata cursorSessionProtocol: 'acp'. No silent fallback to stream-json if ACP init/load fails (fail loud; operator starts fresh).
  • Legacy resume only when metadata says so: existing cursorSessionId without acp keeps cursorLegacyRemoteLauncher until removed in a follow-up.
  • Live session truth = ACP configOptions + hub session.model. Match Zed: prefer session/set_config_option (model/mode categories), not ad-hoc CLI flags mid-session.

Model catalog (two sources, one UI)

Source Shape Role
ACP session/new / snapshot ~28 wire ids (base[param=…]) What the agent can actually set_config_option
agent --list-models ~100+ CLI slugs (gpt-5.5-high-fast, …) Human-facing variants under each wire base
  • Merge rule: group CLI SKUs under cursorCliSkuBaseId(slug); only attach SKUs whose base matches an ACP wire base.
  • Picker display: when a base has CLI SKUs, hide raw ACP wire rows (e.g. context=272k,reasoning=medium) and show probe/display names instead.
  • Apply rule: Web sends wire id or CLI slug → CLI resolves via matchCliSkuToAcpWireId before set_config_option.
  • No semantic guessing on ACP suffix strings for labels; scoring uses explicit SKU suffix hints (-high-fast, -low, …) only for mapping slug → wire.

Picker UX (Web)

  • Dual columns (Model + Variant) only when a base has multiple variants; otherwise flat list of wires.
  • New Session: defaultValue: 'auto'; in-session: null (Default = agent default).
  • Multi-variant base click: update highlighted base without applying until user picks a variant (shouldApply: false).
  • Session open race: if local cursorSelectedBase is still 'auto' but picker.baseKey is known from session.model, highlight the session base (avoids flashing Default).

Catalog discovery (CLI / Hub)

  • While ACP transport lock is held: never spawn agent --list-models; read live snapshot → shared on-disk cache (HAPI_HOME/cache/cursor-models.json) → in-memory cache.
  • Cold runner (no lock): short agent acp wire probe first; then optional CLI probe only to attach cliModelSkus (wires remain authoritative).
  • Session RPC vs machine RPC: machine path can always enrich; session child under ACP lock uses cache/snapshot only.
  • SessionChat: parallel useCursorModelsForMachine when metadata.machineId is set so in-session picker gets SKUs even when session RPC is lock-bound.

Keepalive / optimistic UI

  • setModel updates session state immediately; update-metadata keepalive pushes model to hub; rollback if ACP rejects.
  • Hub accepts model patches on metadata updates for cursor sessions.

Explicit non-goals (this PR)

  • Does not remove legacy stream-json launcher (marked TODO).
  • Does not poll ACP for usage; still agent-pushed usage_update only.
  • CLI slugs with no matching ACP wire base are not shown (cannot be applied).

Test plan

  • bun typecheck
  • bun run test (cli + hub + web unit)
  • Manual: New Session → Cursor → Model/Variant lists show multiple variants per base (e.g. gpt-5.5)
  • Manual: Open session → Model column matches current base (not stuck on Default)
  • Manual: Variant change applies; hub session.model updates
  • Manual: New ACP session → permission approve/deny from web
  • Manual: Legacy stream-json session still resumes (if available)
  • Manual: Plan / ask_question tools render when triggered

Follow-up

  • Remove cursorLegacyRemoteLauncher once legacy sessions are migrated or expired.
  • Cursor usage_update before first prompt (upstream/agent dependent).

Made with Cursor

swear01 and others added 2 commits June 4, 2026 08:00
…ffort pickers

Move stream-json remote launcher to legacy path and add ACP launcher with
set_config_option model/mode sync, optimistic keepalive on config changes, and
shared catalog caching. Web gets dual base/effort Cursor pickers for session and
new-session flows; hide composer status bar when Cursor sends no usage_update.

Co-authored-by: Cursor <cursoragent@cursor.com>
Enrich the web/mobile picker with agent --list-models SKUs grouped under
ACP wire bases, fix session-open base highlight, and keep catalog discovery
safe while the ACP transport holds the CLI lock.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Resetting Cursor model to Default does not update ACP backend — the web Default row sends model: null, but applyLiveModel returns before making any ACP session/set_config_option call, so HAPI clears session.model while Cursor keeps the previously selected explicit model. Evidence: web/src/lib/sessionChatCursorModel.ts:46 maps Default to wireId: null, and cli/src/cursor/cursorAcpRemoteLauncher.ts:316 exits before applying a backend default. Users who switch from a model variant back to Default will see Default in HAPI, but subsequent prompts can still run on the old Cursor model.
    Suggested fix:
    if (!requested || isSpawnDefaultModel(requested)) {
        const modelOption = backend.getConfigOptionByCategory?.(acpSessionId, 'model');
        const defaultWire = modelOption?.options.find((option) => isSpawnDefaultModel(option.value))?.value;
        if (modelOption && defaultWire) {
            await backend.setConfigOption(acpSessionId, modelOption.id, defaultWire);
            backend.pinSessionModelWireId(acpSessionId, defaultWire);
        } else if (options.throwOnFailure) {
            throw new Error('Cursor default model is not available via ACP');
        }
        this.currentBackendModel = null;
        previousSetModel(undefined);
        this.session.pushKeepAlive();
        syncCursorModelsFromAcp(backend, acpSessionId);
        return null;
    }

Questions

  • None.

Summary

  • Review mode: initial
  • One live Cursor ACP model reset regression found. Add a regression test that starts with an explicit Cursor model, calls session.setModel(null) / applyModelConfig(null), and asserts the backend receives the ACP default model option.

Testing

  • Not run (automation; static PR review only).

HAPI Bot

const previousModel = this.currentBackendModel ?? this.session.model ?? null;
const applySeq = ++this.modelApplySeq;

if (!requested || isSpawnDefaultModel(requested)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Resetting Cursor model to Default does not update ACP backend. The web Default row sends model: null, but this branch returns before making any ACP session/set_config_option call, so HAPI clears session.model while Cursor keeps the previously selected explicit model. Users who switch from a model variant back to Default will see Default in HAPI, but subsequent prompts can still run on the old Cursor model.

Suggested fix:

if (!requested || isSpawnDefaultModel(requested)) {
    const modelOption = backend.getConfigOptionByCategory?.(acpSessionId, 'model');
    const defaultWire = modelOption?.options.find((option) => isSpawnDefaultModel(option.value))?.value;
    if (modelOption && defaultWire) {
        await backend.setConfigOption(acpSessionId, modelOption.id, defaultWire);
        backend.pinSessionModelWireId(acpSessionId, defaultWire);
    } else if (options.throwOnFailure) {
        throw new Error('Cursor default model is not available via ACP');
    }
    this.currentBackendModel = null;
    previousSetModel(undefined);
    this.session.pushKeepAlive();
    syncCursorModelsFromAcp(backend, acpSessionId);
    return null;
}

Web sends model: null for Default; push session/set_config_option with the
ACP default[] wire so Cursor backend matches hub state. Regression tests
for setModel(null) and applyModelConfig(null).

Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Stale ACP lock can permanently disable Cursor model discovery — the new cross-process guard treats any existing lock directory as an active agent acp transport, but the lock is only removed by normal close/exit handlers. If the session child is SIGKILLed, the machine reboots, or the process crashes before unregisterActiveAcpTransport(), isAgentAcpTransportActive() keeps returning true forever, so listCursorModels() takes the active-ACP path and will not run the cold ACP/CLI probes. Users then see no Cursor model catalog until they manually delete $HAPI_HOME/locks/agent-acp-active. Evidence: cli/src/agent/backends/acp/agentCliGuard.ts:47 checks only directory existence.
    Suggested fix:
    function staleLockPid(): number | null {
        const raw = readFileSync(join(getAcpLockDir(), 'pid'), 'utf8').trim();
        const pid = Number(raw);
        if (!Number.isInteger(pid) || pid <= 0) return null;
        try {
            process.kill(pid, 0);
            return null;
        } catch {
            return pid;
        }
    }
    
    export function isAgentAcpTransportActive(): boolean {
        if (activeAcpTransportCount > 0) return true;
        const lockDir = getAcpLockDir();
        if (!existsSync(lockDir)) return false;
        if (staleLockPid() !== null) {
            rmSync(lockDir, { recursive: true, force: true });
            return false;
        }
        return true;
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • One lock-lifetime regression remains in the Cursor ACP model discovery path. Add a regression test that writes a lock pid for a non-running process and verifies listCursorModels() can leave the active-ACP path and probe again.

Testing

  • Not run (automation; static PR review only).

HAPI Bot

}

export function isAgentAcpTransportActive(): boolean {
return activeAcpTransportCount > 0 || existsSync(getAcpLockDir());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Stale ACP lock can permanently disable Cursor model discovery. The new cross-process guard treats any existing lock directory as an active agent acp transport, but the lock is only removed by normal close/exit handlers. If the session child is SIGKILLed, the machine reboots, or the process crashes before unregisterActiveAcpTransport(), this keeps returning true forever, so listCursorModels() takes the active-ACP path and will not run the cold ACP/CLI probes. Users then see no Cursor model catalog until they manually delete $HAPI_HOME/locks/agent-acp-active.

Suggested fix:

function staleLockPid(): number | null {
    const raw = readFileSync(join(getAcpLockDir(), 'pid'), 'utf8').trim();
    const pid = Number(raw);
    if (!Number.isInteger(pid) || pid <= 0) return null;
    try {
        process.kill(pid, 0);
        return null;
    } catch {
        return pid;
    }
}

export function isAgentAcpTransportActive(): boolean {
    if (activeAcpTransportCount > 0) return true;
    const lockDir = getAcpLockDir();
    if (!existsSync(lockDir)) return false;
    if (staleLockPid() !== null) {
        rmSync(lockDir, { recursive: true, force: true });
        return false;
    }
    return true;
}

Check lock pid with signal 0; remove orphaned lock dirs after SIGKILL or
crash so listCursorModels can run cold probes again. Regression tests for
guard and catalog discovery.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Cursor model discovery is disabled by unrelated ACP sessions — AcpStdioTransport now calls registerActiveAcpTransport() for every ACP process, but isAgentAcpTransportActive() is used only to decide whether it is safe to spawn Cursor's agent --list-models. Gemini/OpenCode/Kimi also construct AcpSdkBackend, so any live non-Cursor ACP session creates $HAPI_HOME/locks/agent-acp-active; the runner then treats Cursor ACP as active and listCursorModels() returns only a snapshot/shared cache or an empty catalog instead of probing Cursor. That blocks Cursor model pickers while unrelated ACP sessions are running. Evidence cli/src/agent/backends/acp/AcpStdioTransport.ts:76.
    Suggested fix:
    const shouldGuardAgentCli = options.command === 'agent'
    
    if (shouldGuardAgentCli) {
        registerActiveAcpTransport()
    }
    
    private releaseAgentCliGuard(): void {
        if (!shouldGuardAgentCli || this.guardReleased) return
        this.guardReleased = true
        unregisterActiveAcpTransport()
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The stale-lock issue from the prior HAPI Bot review appears addressed, but the guard is now applied too broadly and can regress Cursor model discovery whenever other ACP agents are active.

Testing

  • Not run (automation; static PR review only).

HAPI Bot

buildAcpStdioSpawnOptions(options.env)
) as ChildProcessWithoutNullStreams;

registerActiveAcpTransport();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Cursor model discovery is disabled by unrelated ACP sessions. AcpStdioTransport now calls registerActiveAcpTransport() for every ACP process, but isAgentAcpTransportActive() is used only to decide whether it is safe to spawn Cursor's agent --list-models. Gemini/OpenCode/Kimi also construct AcpSdkBackend, so any live non-Cursor ACP session creates $HAPI_HOME/locks/agent-acp-active; the runner then treats Cursor ACP as active and listCursorModels() returns only a snapshot/shared cache or an empty catalog instead of probing Cursor.

Suggested fix:

const shouldGuardAgentCli = options.command === 'agent'

if (shouldGuardAgentCli) {
    registerActiveAcpTransport()
}

private releaseAgentCliGuard(): void {
    if (!shouldGuardAgentCli || this.guardReleased) return
    this.guardReleased = true
    unregisterActiveAcpTransport()
}

Stale-lock cleanup clears dead pids; handler tests must simulate an
active lock with the current process pid to avoid cold probes/timeouts.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] ACP lock blocks Cursor model discovery during unrelated ACP sessions — AcpStdioTransport now registers the global agent-acp-active lock for every ACP subprocess, but listCursorModels() treats that lock as meaning Cursor's agent acp is active and refuses to spawn agent --list-models. Gemini/OpenCode/Kimi all construct AcpSdkBackend with non-agent commands, so any live session for those backends can make Cursor model pickers return only stale cache or an empty list. Evidence cli/src/agent/backends/acp/AcpStdioTransport.ts:76.
    Suggested fix:
    private readonly shouldGuardAgentCli: boolean;
    
    constructor(options: { command: string; args?: string[]; env?: Record<string, string> }) {
        this.shouldGuardAgentCli = options.command === 'agent';
        // ... spawn setup ...
        if (this.shouldGuardAgentCli) {
            registerActiveAcpTransport();
        }
    }
    
    private releaseAgentCliGuard(): void {
        if (!this.shouldGuardAgentCli || this.guardReleased) return;
        this.guardReleased = true;
        unregisterActiveAcpTransport();
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The stale-lock test update does not address the remaining regression: the Cursor-specific CLI guard is still applied to all ACP transports. I found no other high-confidence issues in the latest diff.

Testing

  • Not run (automation; static PR review only).

HAPI Bot

buildAcpStdioSpawnOptions(options.env)
) as ChildProcessWithoutNullStreams;

registerActiveAcpTransport();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] ACP lock blocks Cursor model discovery during unrelated ACP sessions. This registers the global agent-acp-active lock for every ACP subprocess, but listCursorModels() treats that lock as meaning Cursor's agent acp is active and refuses to spawn agent --list-models. Gemini/OpenCode/Kimi construct AcpSdkBackend with gemini, opencode, and kimi, so any live non-Cursor ACP session can make Cursor model pickers return only stale cache or an empty list.

Suggested fix:

private readonly shouldGuardAgentCli: boolean;

constructor(options: { command: string; args?: string[]; env?: Record<string, string> }) {
    this.shouldGuardAgentCli = options.command === 'agent';
    // ... spawn setup ...
    if (this.shouldGuardAgentCli) {
        registerActiveAcpTransport();
    }
}

private releaseAgentCliGuard(): void {
    if (!this.shouldGuardAgentCli || this.guardReleased) return;
    this.guardReleased = true;
    unregisterActiveAcpTransport();
}

Gemini/OpenCode/Kimi ACP sessions must not register agent-acp-active;
that blocked listCursorModels while unrelated backends were running.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Cursor model changes are still exposed for local sessions — the new Cursor config RPC can only apply model changes after the ACP remote launcher installs its model apply handler, but the hub route still allows /sessions/:id/model for locally controlled Cursor sessions. In local mode canApplyModelConfig() stays false, so the UI can send a model change for an active local Cursor session and consistently get a 409 instead of hiding/rejecting the unsupported action at the boundary. Evidence cli/src/cursor/runCursor.ts:139; related route gap hub/src/web/routes/sessions.ts:358.
    Suggested fix:
    if ((flavor === 'codex' || flavor === 'cursor') && sessionResult.session.agentState?.controlledByUser === true) {
        return c.json({ error: 'Model selection can only be changed for remote sessions' }, 409)
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The prior ACP guard issue is fixed in the latest head. I found one remaining regression around exposing Cursor model changes while the session is locally controlled.

Testing

  • Not run (automation; static PR review only).

HAPI Bot

if (config.model !== undefined) {
const requestedModel = resolveNullableSessionModel(config.model);
const sessionInstance = sessionWrapperRef.current;
if (!sessionInstance?.canApplyModelConfig()) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Cursor model changes are still exposed for local sessions. This new guard makes model application depend on the ACP remote launcher's model apply handler, but the hub route still allows /sessions/:id/model for locally controlled Cursor sessions. In local mode the handler is never installed, so the UI can send a model change for an active local Cursor session and consistently get Cursor ACP session is not ready to apply model changes instead of hiding/rejecting the unsupported action at the boundary.\n\nSuggested fix:\nts\nif ((flavor === 'codex' || flavor === 'cursor') && sessionResult.session.agentState?.controlledByUser === true) {\n return c.json({ error: 'Model selection can only be changed for remote sessions' }, 409)\n}\n

Hub returns 409 when controlledByUser is set, matching Codex. Web hides
model and variant pickers for local Cursor sessions so users do not hit
a dead RPC path. Document pre-push-review in AGENTS.md.

Verified: bun typecheck; bun run test (919 cli + 243 hub + 768 web + 46 shared).
Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Cursor ask_question responses send labels/indexes instead of ACP ids — the new Cursor parser drops each question id and option id, then the shared footer submits answers keyed by String(i) with selected option labels. CursorExtensionAdapter forwards those values as questionId and selectedOptionIds, so ACP receives 0 / Option A instead of the original ids like approach / a; prompts with choices can be answered incorrectly or rejected. Evidence web/src/components/ToolCard/cursorAskQuestion.ts:20; related formatter cli/src/cursor/utils/cursorExtensionAdapter.ts:108.
    Suggested fix:
    export type CursorAskQuestionQuestion = AskUserQuestionQuestion & {
        id: string
        options: Array<AskUserQuestionQuestion['options'][number] & { id: string }>
    }
    
    const questionId = typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : String(questions.length)
    const optionId = typeof opt.id === 'string' && opt.id.trim() ? opt.id.trim() : label
    options.push({ id: optionId, label, description: null })
    
    // When submitting CursorAskQuestion, key by q.id and send option.id values.
    answers[question.id] = selectedOptionIndices
        .map((idx) => question.options[idx]?.id)
        .filter((id): id is string => Boolean(id))

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The previous local Cursor model-change issue is fixed in this head. I found one remaining regression in the new Cursor ACP ask-question flow.

Testing

  • Not run (automation; static PR review only). Add a web-side test that a Cursor question { id: 'approach', options: [{ id: 'a', label: 'Option A' }] } submits { approach: ['a'] }, plus the existing adapter test already covers forwarding those ids.

HAPI Bot

for (const raw of rawQuestions) {
if (!isObject(raw)) continue

const question = typeof raw.prompt === 'string'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] This parser drops Cursor's stable question/option ids. The footer later submits answers keyed by question index with selected labels, and CursorExtensionAdapter forwards those values as ACP questionId / selectedOptionIds, so ACP receives values like 0 and Option A instead of approach and a.

Suggested fix:

const questionId = typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : String(questions.length)
const optionId = typeof opt.id === 'string' && opt.id.trim() ? opt.id.trim() : label
options.push({ id: optionId, label, description: null })

// In AskUserQuestionFooter for CursorAskQuestion:
answers[question.id] = selectedOptionIndices
    .map((idx) => question.options[idx]?.id)
    .filter((id): id is string => Boolean(id))

Parse and submit question.id and option.id so ACP receives keys like
{ approach: ['a'] } instead of index/label. Verified: bun typecheck && bun run test.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • No issues found at >=80% confidence in the latest diff.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • Previous HAPI Bot finding about Cursor ask_question stable ids appears addressed in this head. Residual risk is mainly integration coverage: this PR changes Cursor remote ACP startup, live model switching, model discovery cache/locks, and web model pickers across CLI/hub/web boundaries.

Testing

  • Not run (automation; static PR review only). Existing added tests cover the reviewed model picker, Cursor ACP launcher, hub route/cache, and ask_question id paths.

HAPI Bot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant