Files
hapi/cli/src/cursor/session.ts
T
e4cc3916c8 fix(cursor): CreatePlan Yes accepts and continues task (nested outcome + plan→execute) (#1097)
* fix(cursor): nest ACP extension outcome so plan approvals aren't cancelled

Cursor's ACP blocking extension methods (cursor/ask_question,
cursor/create_plan) expect the JSON-RPC result to nest the outcome under
an `outcome` key, e.g. `{ outcome: { outcome: "accepted" } }`. The
adapter returned it flat (`{ outcome: "accepted" }`), so Cursor read
`response.outcome.outcome` as undefined and fell back to a cancellation
— an approved plan was relayed to the agent as `User cancelled`, making
plan mode unusable over HAPI for Cursor sessions.

Wrap every ask_question / create_plan response in the nested envelope
and add regression tests asserting the exact wire shape for the
affirmative approval -> CreatePlan path (plus approved_for_session,
reject, and abort).

Fixes #79

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

* fix(cursor): continue task after CreatePlan Yes (plan→execute)

Nested ACP accept alone is not a complete fix: Yes unblocked create_plan
but the prompt turn still ended with the plan "done" and no execution.
Mirror Claude ExitPlanMode: on accept, leave plan/ask for an executable
mode and queue a continue prompt so the original user task keeps going.

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

* fix(cursor): sync runCursor enqueue mode after CreatePlan accept

Codex Major on #1097: setPermissionMode alone left runCursor's
currentPermissionMode stale, so the next user message could re-enter
plan/ask after Yes. Notify onPermissionModeChanged from CursorSession
so the enqueue source of truth stays aligned with the session.

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

---------

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 12:57:31 +08:00

124 lines
4.6 KiB
TypeScript

import { ApiClient, ApiSessionClient } from '@/lib';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { AgentSessionBase } from '@/agent/sessionBase';
import type { EnhancedMode, PermissionMode } from './loop';
import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy';
import type { CursorSessionProtocol } from './utils/cursorProtocol';
type LocalLaunchFailure = {
message: string;
exitReason: LocalLaunchExitReason;
};
type CursorModelApplyHandler = (model: string | null | undefined) => Promise<string | null>;
type CursorPermissionModeChangedHandler = (mode: PermissionMode) => void;
export class CursorSession extends AgentSessionBase<EnhancedMode> {
readonly cursorArgs?: string[];
/** Cursor-native `--worktree` name (`true` = flag without name). */
readonly cursorWorktree?: boolean | string;
/** Extra `--add-dir` roots for Cursor ACP spawn. */
readonly cursorAddDirs?: readonly string[];
model?: string;
readonly startedBy: 'runner' | 'terminal';
readonly startingMode: 'local' | 'remote';
localLaunchFailure: LocalLaunchFailure | null = null;
private modelApplyHandler: CursorModelApplyHandler | null = null;
private permissionModeChangedHandler: CursorPermissionModeChangedHandler | null = null;
constructor(opts: {
api: ApiClient;
client: ApiSessionClient;
path: string;
logPath: string;
sessionId: string | null;
messageQueue: MessageQueue2<EnhancedMode>;
onModeChange: (mode: 'local' | 'remote') => void;
mode?: 'local' | 'remote';
startedBy: 'runner' | 'terminal';
startingMode: 'local' | 'remote';
cursorArgs?: string[];
cursorWorktree?: boolean | string;
cursorAddDirs?: readonly string[];
model?: string;
permissionMode?: PermissionMode;
onPermissionModeChanged?: CursorPermissionModeChangedHandler;
}) {
super({
api: opts.api,
client: opts.client,
path: opts.path,
logPath: opts.logPath,
sessionId: opts.sessionId,
messageQueue: opts.messageQueue,
onModeChange: opts.onModeChange,
mode: opts.mode,
sessionLabel: 'CursorSession',
sessionIdLabel: 'Cursor',
applySessionIdToMetadata: (metadata, sessionId, extras) => ({
...metadata,
cursorSessionId: sessionId,
...extras
}),
permissionMode: opts.permissionMode
});
this.cursorArgs = opts.cursorArgs;
this.cursorWorktree = opts.cursorWorktree;
this.cursorAddDirs = opts.cursorAddDirs;
this.model = opts.model;
this.startedBy = opts.startedBy;
this.startingMode = opts.startingMode;
this.permissionMode = opts.permissionMode;
this.permissionModeChangedHandler = opts.onPermissionModeChanged ?? null;
}
setPermissionMode = (mode: PermissionMode): void => {
this.permissionMode = mode;
// Keep runCursor's enqueue source of truth in sync (CreatePlan accept, ACP mode sync).
this.permissionModeChangedHandler?.(mode);
};
setModel = (model: string | null | undefined): void => {
this.model = model ?? undefined;
};
registerModelApplyHandler = (handler: CursorModelApplyHandler): (() => void) => {
this.modelApplyHandler = handler;
return () => {
if (this.modelApplyHandler === handler) {
this.modelApplyHandler = null;
}
};
};
canApplyModelConfig = (): boolean => this.modelApplyHandler !== null;
applyModelConfig = async (model: string | null | undefined): Promise<string | null> => {
if (!this.modelApplyHandler) {
throw new Error('Cursor ACP session is not ready to apply model changes');
}
return await this.modelApplyHandler(model);
};
recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => {
this.localLaunchFailure = { message, exitReason };
};
sendAgentMessage = (message: unknown): void => {
this.client.sendAgentMessage(message);
};
sendUserMessage = (text: string): void => {
this.client.sendUserMessage(text);
};
sendSessionEvent = (event: Parameters<ApiSessionClient['sendSessionEvent']>[0]): void => {
this.client.sendSessionEvent(event);
};
onSessionFoundWithProtocol = (sessionId: string, protocol: CursorSessionProtocol): void => {
this.onSessionFound(sessionId, { cursorSessionProtocol: protocol });
};
}