@makerbi/remodex 2.0.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1197 @@
1
+ // FILE: cursor-provider.js
2
+ // Purpose: Adapts Cursor CLI ACP to Remodex provider-aware thread and turn RPCs.
3
+ // Layer: Bridge runtime provider
4
+ // Exports: createCursorProvider
5
+ // Depends on: crypto, ./cursor-acp-client, ./cursor-models, ./runtime-provider-models
6
+
7
+ const { randomUUID } = require("crypto");
8
+ const { createCursorAcpClient } = require("./cursor-acp-client");
9
+ const {
10
+ CURSOR_PROVIDER_ID,
11
+ DEFAULT_CURSOR_MODEL,
12
+ normalizeCursorModelReference,
13
+ parseCursorModelsFromSessionResult,
14
+ } = require("./cursor-models");
15
+ const { normalizeRuntimeProvider } = require("./runtime-provider-models");
16
+
17
+ const CURSOR_THREAD_PREFIX = "cursor-thread-";
18
+ const CURSOR_TURN_PREFIX = "cursor-turn-";
19
+ const CURSOR_MODEL_CACHE_TTL_MS = 60_000;
20
+ const CURSOR_MODEL_LIST_TIMEOUT_MS = 12_000;
21
+ const CURSOR_SESSION_LIST_TIMEOUT_MS = 12_000;
22
+ const CURSOR_SESSION_START_TIMEOUT_MS = 15_000;
23
+ const CURSOR_LOAD_HISTORY_TIMEOUT_MS = 15_000;
24
+
25
+ function createCursorProvider({
26
+ sendApplicationMessage,
27
+ env = process.env,
28
+ randomUUIDImpl = randomUUID,
29
+ createAcpClient = createCursorAcpClient,
30
+ logPrefix = "[remodex]",
31
+ } = {}) {
32
+ return new CursorProvider({
33
+ createAcpClient,
34
+ env,
35
+ logPrefix,
36
+ randomUUIDImpl,
37
+ sendApplicationMessage,
38
+ });
39
+ }
40
+
41
+ class CursorProvider {
42
+ constructor({
43
+ sendApplicationMessage,
44
+ env,
45
+ randomUUIDImpl,
46
+ createAcpClient,
47
+ logPrefix,
48
+ }) {
49
+ this.id = CURSOR_PROVIDER_ID;
50
+ this.sendApplicationMessage = sendApplicationMessage;
51
+ this.env = env;
52
+ this.randomUUID = randomUUIDImpl;
53
+ this.createAcpClient = createAcpClient;
54
+ this.logPrefix = logPrefix;
55
+ this.modelCache = null;
56
+ this.threads = new Map();
57
+ this.sessionThreadCache = new Map();
58
+ this.activeTurnsByTurnId = new Map();
59
+ this.activeTurnIdByThreadId = new Map();
60
+ this.finalizedTurns = new Set();
61
+ this.warnedAvailabilityReason = "";
62
+ }
63
+
64
+ canHandleProvider(provider) {
65
+ return normalizeRuntimeProvider(provider) === CURSOR_PROVIDER_ID;
66
+ }
67
+
68
+ ownsThread(threadId) {
69
+ const normalized = readString(threadId);
70
+ return this.threads.has(normalized)
71
+ || this.sessionThreadCache.has(normalized)
72
+ || normalized.startsWith(CURSOR_THREAD_PREFIX);
73
+ }
74
+
75
+ async listModels() {
76
+ const cached = this.readFreshModelCache();
77
+ if (cached) {
78
+ return cached;
79
+ }
80
+
81
+ const client = this.newClient({ cwd: process.cwd() });
82
+ try {
83
+ await initializeCursorClient(client, CURSOR_MODEL_LIST_TIMEOUT_MS);
84
+ const session = await client.request(
85
+ "session/new",
86
+ { cwd: process.cwd(), mcpServers: [] },
87
+ CURSOR_MODEL_LIST_TIMEOUT_MS
88
+ );
89
+ const models = parseCursorModelsFromSessionResult(session);
90
+ this.modelCache = {
91
+ expiresAt: Date.now() + CURSOR_MODEL_CACHE_TTL_MS,
92
+ value: models,
93
+ };
94
+ return models;
95
+ } catch (error) {
96
+ this.warnUnavailable(error?.message || "Cursor models are unavailable.");
97
+ return [];
98
+ } finally {
99
+ client.kill();
100
+ }
101
+ }
102
+
103
+ async listThreads(params = {}) {
104
+ const limit = boundedPositiveInteger(params.limit, 50);
105
+ const client = this.newClient({ cwd: process.cwd() });
106
+ try {
107
+ const initializeResult = await initializeCursorClient(client, CURSOR_SESSION_LIST_TIMEOUT_MS);
108
+ if (!initializeResult?.agentCapabilities?.sessionCapabilities?.list) {
109
+ return { data: [], nextCursor: null };
110
+ }
111
+
112
+ const result = await client.request(
113
+ "session/list",
114
+ buildSessionListParams(params),
115
+ CURSOR_SESSION_LIST_TIMEOUT_MS
116
+ );
117
+ const sessions = Array.isArray(result?.sessions) ? result.sessions : [];
118
+ const listedThreads = sessions
119
+ .slice(0, limit)
120
+ .map((session) => this.threadFromSession(session))
121
+ .filter(Boolean);
122
+ const localThreads = Array.from(this.threads.values())
123
+ .filter((thread) => !thread.archived)
124
+ .map((thread) => publicThread(thread));
125
+ const data = dedupeThreadsById([...localThreads, ...listedThreads])
126
+ .sort(compareThreadsByUpdatedAt)
127
+ .slice(0, limit);
128
+ return {
129
+ data,
130
+ nextCursor: result?.nextCursor || result?.next_cursor || null,
131
+ };
132
+ } catch (error) {
133
+ this.warnUnavailable(error?.message || "Cursor sessions are unavailable.");
134
+ return { data: [], nextCursor: null };
135
+ } finally {
136
+ client.kill();
137
+ }
138
+ }
139
+
140
+ async handleRequest(request) {
141
+ const method = readString(request?.method);
142
+ switch (method) {
143
+ case "thread/start":
144
+ return this.threadStart(request);
145
+ case "thread/resume":
146
+ case "thread/read":
147
+ return this.threadRead(request);
148
+ case "thread/turns/list":
149
+ return this.threadTurnsList(request);
150
+ case "thread/name/set":
151
+ return this.threadNameSet(request);
152
+ case "thread/archive":
153
+ return this.threadArchive(request, true);
154
+ case "thread/unarchive":
155
+ return this.threadArchive(request, false);
156
+ case "turn/start":
157
+ return this.turnStart(request);
158
+ case "turn/interrupt":
159
+ return this.turnInterrupt(request);
160
+ default:
161
+ throw unsupportedMethodError(method);
162
+ }
163
+ }
164
+
165
+ shutdown() {
166
+ for (const active of this.activeTurnsByTurnId.values()) {
167
+ active.stopped = true;
168
+ try {
169
+ active.client.notify("session/cancel", { sessionId: active.sessionId });
170
+ } catch {
171
+ // Ignore cancellation races during shutdown.
172
+ }
173
+ active.client.kill("SIGTERM");
174
+ }
175
+ this.activeTurnsByTurnId.clear();
176
+ this.activeTurnIdByThreadId.clear();
177
+ }
178
+
179
+ async threadStart(request) {
180
+ const params = request.params || {};
181
+ const now = new Date().toISOString();
182
+ const requestedCwd = readProjectCwd(params);
183
+ const cwd = requestedCwd || process.cwd();
184
+ const model = normalizeCursorModel(params.model);
185
+ const sessionId = await this.createSession({ cwd, model });
186
+ const thread = {
187
+ id: cursorThreadIdForSession(sessionId),
188
+ title: readString(params.title) || "Cursor chat",
189
+ cwd,
190
+ model,
191
+ createdAt: now,
192
+ updatedAt: now,
193
+ archived: false,
194
+ hasProjectCwd: Boolean(requestedCwd),
195
+ sessionId,
196
+ turns: [],
197
+ };
198
+ this.threads.set(thread.id, thread);
199
+ this.sessionThreadCache.set(sessionId, publicThread(thread));
200
+ this.sessionThreadCache.set(thread.id, publicThread(thread));
201
+ return { thread: publicThread(thread) };
202
+ }
203
+
204
+ async threadRead(request) {
205
+ const params = request.params || {};
206
+ const threadId = readThreadId(params);
207
+ const thread = await this.resolveThread(threadId).catch((error) => {
208
+ if (error?.errorCode !== "thread_not_found" || !threadId) {
209
+ throw error;
210
+ }
211
+ return this.adoptThread(threadId, params);
212
+ });
213
+
214
+ const responseThread = { ...publicThread(thread) };
215
+ if (params.includeTurns === true || params.include_turns === true) {
216
+ responseThread.turns = await this.turnsForThread(threadId, { sortDirection: "asc" });
217
+ }
218
+ return { thread: responseThread };
219
+ }
220
+
221
+ async threadTurnsList(request) {
222
+ const params = request.params || {};
223
+ const threadId = readThreadId(params);
224
+ const limit = boundedPositiveInteger(params.limit, 50);
225
+ const sortDirection = readString(params.sortDirection || params.sort_direction) || "desc";
226
+ const turns = await this.turnsForThread(threadId, { sortDirection });
227
+ return {
228
+ data: turns.slice(0, limit),
229
+ nextCursor: null,
230
+ };
231
+ }
232
+
233
+ async turnStart(request) {
234
+ const params = request.params || {};
235
+ const threadId = readThreadId(params);
236
+ const thread = await this.resolveThreadForTurn(threadId, params);
237
+ if (this.activeTurnIdByThreadId.has(thread.id)) {
238
+ throw activeTurnError(thread.id);
239
+ }
240
+
241
+ const model = normalizeCursorModel(params.model || thread.model);
242
+ const { prompt, inputText } = buildPromptFromTurnInput(params.input);
243
+ if (!prompt) {
244
+ const error = new Error("Cursor turn/start requires text input.");
245
+ error.errorCode = "cursor_missing_input";
246
+ throw error;
247
+ }
248
+
249
+ thread.model = model;
250
+ thread.updatedAt = new Date().toISOString();
251
+ const turnId = `${CURSOR_TURN_PREFIX}${this.randomUUID()}`;
252
+ const turn = createStoredTurn({
253
+ inputText,
254
+ model,
255
+ threadId: thread.id,
256
+ turnId,
257
+ });
258
+ thread.turns.push(turn);
259
+
260
+ setImmediate(() => {
261
+ this.runTurn({
262
+ model,
263
+ params,
264
+ prompt,
265
+ thread,
266
+ turn,
267
+ turnId,
268
+ });
269
+ });
270
+
271
+ this.emit("turn/started", {
272
+ threadId: thread.id,
273
+ turnId,
274
+ turn: {
275
+ id: turnId,
276
+ status: "running",
277
+ },
278
+ });
279
+
280
+ return {
281
+ turnId,
282
+ turn: {
283
+ id: turnId,
284
+ threadId: thread.id,
285
+ status: "running",
286
+ },
287
+ };
288
+ }
289
+
290
+ runTurn({ model, params, prompt, thread, turn, turnId }) {
291
+ const active = createActiveCursorTurn({ params, thread, turn, turnId });
292
+ const client = this.newClient({
293
+ cwd: thread.cwd || process.cwd(),
294
+ onNotification: (frame) => this.handleAcpNotification({ active, frame, thread, turnId }),
295
+ onRequest: (frame) => this.handleAcpClientRequest({ active, frame }),
296
+ });
297
+ active.client = client;
298
+ this.activeTurnsByTurnId.set(turnId, active);
299
+ this.activeTurnIdByThreadId.set(thread.id, turnId);
300
+
301
+ this.runAcpTurn({ active, client, model, params, prompt, thread, turn, turnId })
302
+ .catch((error) => {
303
+ this.completeTurn({
304
+ errorMessage: error?.message || "Cursor ACP turn failed.",
305
+ status: active.stopped ? "stopped" : "failed",
306
+ thread,
307
+ turn,
308
+ turnId,
309
+ });
310
+ });
311
+ }
312
+
313
+ async runAcpTurn(context) {
314
+ try {
315
+ await this.runAcpTurnImpl(context);
316
+ } finally {
317
+ context.client.kill();
318
+ }
319
+ }
320
+
321
+ async runAcpTurnImpl({ active, client, model, params, prompt, thread, turn, turnId }) {
322
+ await initializeCursorClient(client);
323
+ const cwd = thread.cwd || process.cwd();
324
+
325
+ if (thread.sessionId) {
326
+ active.loadingHistory = true;
327
+ try {
328
+ await client.request("session/load", {
329
+ sessionId: thread.sessionId,
330
+ cwd,
331
+ mcpServers: [],
332
+ }, CURSOR_LOAD_HISTORY_TIMEOUT_MS);
333
+ } catch (error) {
334
+ this.warnUnavailable(`Cursor session load failed; starting a new session: ${error.message}`);
335
+ thread.sessionId = "";
336
+ } finally {
337
+ active.loadingHistory = false;
338
+ }
339
+ }
340
+
341
+ if (!thread.sessionId) {
342
+ const session = await client.request("session/new", { cwd, mcpServers: [] });
343
+ thread.sessionId = readString(session?.sessionId);
344
+ if (!thread.sessionId) {
345
+ throw cursorProtocolError("Cursor ACP session/new did not return a sessionId.");
346
+ }
347
+ active.sessionId = thread.sessionId;
348
+ this.sessionThreadCache.set(thread.sessionId, publicThread(thread));
349
+ } else {
350
+ active.sessionId = thread.sessionId;
351
+ }
352
+
353
+ await this.applyCursorSessionConfig({ active, client, model, params });
354
+ await client.request("session/prompt", {
355
+ sessionId: thread.sessionId,
356
+ prompt: [{ type: "text", text: prompt }],
357
+ });
358
+
359
+ this.completeTurn({
360
+ status: active.stopped ? "stopped" : "completed",
361
+ thread,
362
+ turn,
363
+ turnId,
364
+ });
365
+ }
366
+
367
+ async applyCursorSessionConfig({ active, client, model, params }) {
368
+ if (!active.sessionId) {
369
+ return;
370
+ }
371
+
372
+ const selectedMode = cursorModeForParams(params);
373
+ if (selectedMode) {
374
+ await client.request("session/set_config_option", {
375
+ sessionId: active.sessionId,
376
+ configId: "mode",
377
+ value: selectedMode,
378
+ }).catch(() => null);
379
+ }
380
+
381
+ if (!model) {
382
+ return;
383
+ }
384
+ await client.request("session/set_config_option", {
385
+ sessionId: active.sessionId,
386
+ configId: "model",
387
+ value: model,
388
+ });
389
+ }
390
+
391
+ handleAcpNotification({ active, frame, thread, turnId }) {
392
+ if (frame.method !== "session/update") {
393
+ return;
394
+ }
395
+ const update = frame.params?.update;
396
+ if (!update || active.loadingHistory) {
397
+ return;
398
+ }
399
+
400
+ switch (update.sessionUpdate) {
401
+ case "agent_message_chunk":
402
+ this.appendAssistantText({ active, content: update.content, thread, turnId });
403
+ break;
404
+ case "agent_thought_chunk":
405
+ this.appendReasoningText({ content: update.content, thread, turnId });
406
+ break;
407
+ case "tool_call":
408
+ this.appendToolCallSummary({ active, update, thread, turnId });
409
+ break;
410
+ case "session_info_update":
411
+ this.applySessionInfoUpdate({ thread, update });
412
+ break;
413
+ default:
414
+ break;
415
+ }
416
+ }
417
+
418
+ handleAcpClientRequest({ active, frame }) {
419
+ if (frame.method !== "session/request_permission") {
420
+ const error = new Error(`Unsupported Cursor ACP client request: ${frame.method || "unknown"}`);
421
+ error.code = -32601;
422
+ throw error;
423
+ }
424
+
425
+ if (active.stopped) {
426
+ return {
427
+ outcome: { outcome: "cancelled" },
428
+ };
429
+ }
430
+
431
+ return {
432
+ outcome: {
433
+ outcome: "selected",
434
+ optionId: selectPermissionOption(frame.params?.options, active.params),
435
+ },
436
+ };
437
+ }
438
+
439
+ appendAssistantText({ active, content, thread, turnId }) {
440
+ const text = contentText(content);
441
+ const delta = computeTextDelta(active.assistantText, text);
442
+ if (!delta) {
443
+ return;
444
+ }
445
+
446
+ active.assistantText += delta;
447
+ const assistantItem = active.turn.items.find((item) => item.id === active.assistantItemId);
448
+ if (assistantItem) {
449
+ assistantItem.text = active.assistantText;
450
+ assistantItem.content = textContent(active.assistantText);
451
+ }
452
+ this.emit("item/agentMessage/delta", {
453
+ threadId: thread.id,
454
+ turnId,
455
+ itemId: active.assistantItemId,
456
+ delta,
457
+ textDelta: delta,
458
+ assistantPhase: "final_answer",
459
+ item: {
460
+ id: active.assistantItemId,
461
+ turnId,
462
+ type: "agentMessage",
463
+ phase: "final",
464
+ },
465
+ });
466
+ }
467
+
468
+ appendReasoningText({ content, thread, turnId }) {
469
+ const delta = contentText(content);
470
+ if (!delta) {
471
+ return;
472
+ }
473
+ this.emit("item/reasoning/textDelta", {
474
+ threadId: thread.id,
475
+ turnId,
476
+ itemId: `cursor-reasoning-${turnId}`,
477
+ delta,
478
+ textDelta: delta,
479
+ item: {
480
+ id: `cursor-reasoning-${turnId}`,
481
+ type: "reasoning",
482
+ turnId,
483
+ },
484
+ });
485
+ }
486
+
487
+ appendToolCallSummary({ active, update, thread, turnId }) {
488
+ const title = readString(update.title);
489
+ if (!title || active.toolCallIds.has(update.toolCallId)) {
490
+ return;
491
+ }
492
+ active.toolCallIds.add(update.toolCallId);
493
+ this.appendReasoningText({
494
+ content: { type: "text", text: `\n${title}\n` },
495
+ thread,
496
+ turnId,
497
+ });
498
+ }
499
+
500
+ applySessionInfoUpdate({ thread, update }) {
501
+ const title = readString(update.title);
502
+ if (title) {
503
+ thread.title = title;
504
+ }
505
+ thread.updatedAt = normalizeDateString(update.updatedAt) || new Date().toISOString();
506
+ this.emit("thread/name/updated", {
507
+ threadId: thread.id,
508
+ thread_id: thread.id,
509
+ name: thread.title,
510
+ title: thread.title,
511
+ });
512
+ }
513
+
514
+ completeTurn({ errorMessage = "", status, thread, turn, turnId }) {
515
+ if (this.finalizedTurns.has(turnId)) {
516
+ return false;
517
+ }
518
+ this.finalizedTurns.add(turnId);
519
+ pruneSet(this.finalizedTurns, 500);
520
+ this.activeTurnsByTurnId.delete(turnId);
521
+ this.activeTurnIdByThreadId.delete(thread.id);
522
+ thread.updatedAt = new Date().toISOString();
523
+ turn.status = status;
524
+ turn.completedAt = thread.updatedAt;
525
+ if (errorMessage) {
526
+ turn.error = { message: errorMessage };
527
+ }
528
+
529
+ const assistantItem = turn.items.find((item) => item.type === "agentMessage");
530
+ if (assistantItem && assistantItem.text) {
531
+ this.emit("item/completed", {
532
+ threadId: thread.id,
533
+ turnId,
534
+ itemId: assistantItem.id,
535
+ message: assistantItem.text,
536
+ assistantPhase: "final_answer",
537
+ item: {
538
+ id: assistantItem.id,
539
+ turnId,
540
+ type: "agentMessage",
541
+ phase: "final",
542
+ text: assistantItem.text,
543
+ content: assistantItem.content,
544
+ },
545
+ });
546
+ }
547
+
548
+ this.emit("turn/completed", {
549
+ threadId: thread.id,
550
+ turnId,
551
+ model: thread.model,
552
+ status,
553
+ turn: {
554
+ id: turnId,
555
+ status,
556
+ error: errorMessage ? { message: errorMessage } : undefined,
557
+ },
558
+ });
559
+ return true;
560
+ }
561
+
562
+ async turnInterrupt(request) {
563
+ const params = request.params || {};
564
+ const turnId = readString(params.turnId || params.turn_id);
565
+ const threadId = readThreadId(params);
566
+ const resolvedTurnId = turnId || this.activeTurnIdByThreadId.get(threadId) || "";
567
+ const active = this.activeTurnsByTurnId.get(resolvedTurnId);
568
+ if (!active) {
569
+ return { success: true, interrupted: false };
570
+ }
571
+
572
+ active.stopped = true;
573
+ try {
574
+ active.client.notify("session/cancel", { sessionId: active.sessionId });
575
+ } catch {
576
+ // The process may already be exiting.
577
+ }
578
+ active.client.kill("SIGINT");
579
+ return { success: true, interrupted: true };
580
+ }
581
+
582
+ async threadNameSet(request) {
583
+ const params = request.params || {};
584
+ const thread = await this.resolveThread(readThreadId(params));
585
+ const name = readString(params.name || params.title);
586
+ if (name) {
587
+ thread.title = name;
588
+ thread.updatedAt = new Date().toISOString();
589
+ }
590
+ const publicValue = publicThread(thread);
591
+ this.emit("thread/name/updated", {
592
+ threadId: publicValue.id,
593
+ thread_id: publicValue.id,
594
+ name: publicValue.name,
595
+ title: publicValue.title,
596
+ });
597
+ return { thread: publicValue };
598
+ }
599
+
600
+ async threadArchive(request, archived) {
601
+ const thread = await this.resolveThread(readThreadId(request.params));
602
+ thread.archived = archived;
603
+ thread.updatedAt = new Date().toISOString();
604
+ return { thread: publicThread(thread) };
605
+ }
606
+
607
+ async turnsForThread(threadId, { sortDirection = "desc" } = {}) {
608
+ const thread = await this.resolveThread(threadId);
609
+ let turns = thread.turns || [];
610
+ if (thread.sessionId && !turns.length) {
611
+ turns = await this.loadSessionTurns(thread.sessionId, thread);
612
+ thread.turns = turns;
613
+ }
614
+ const normalizedDirection = readString(sortDirection).toLowerCase();
615
+ return normalizedDirection === "asc" ? [...turns] : [...turns].reverse();
616
+ }
617
+
618
+ async loadSessionTurns(sessionId, thread) {
619
+ const client = this.newClient({ cwd: thread.cwd || process.cwd() });
620
+ const collector = createHistoryCollector(thread);
621
+ client.onNotification = (frame) => {
622
+ if (frame.method === "session/update") {
623
+ collector.append(frame.params?.update);
624
+ }
625
+ };
626
+
627
+ try {
628
+ await initializeCursorClient(client, CURSOR_LOAD_HISTORY_TIMEOUT_MS);
629
+ await client.request("session/load", {
630
+ sessionId,
631
+ cwd: thread.cwd || process.cwd(),
632
+ mcpServers: [],
633
+ }, CURSOR_LOAD_HISTORY_TIMEOUT_MS);
634
+ return collector.turns.slice(-200);
635
+ } catch {
636
+ return thread.turns || [];
637
+ } finally {
638
+ client.kill();
639
+ }
640
+ }
641
+
642
+ async resolveThread(threadId) {
643
+ const normalized = readString(threadId);
644
+ if (this.threads.has(normalized)) {
645
+ return this.threads.get(normalized);
646
+ }
647
+
648
+ if (this.sessionThreadCache.has(normalized)) {
649
+ const cached = this.sessionThreadCache.get(normalized);
650
+ return this.rememberSessionThread(cached);
651
+ }
652
+
653
+ throw threadNotFoundError(normalized);
654
+ }
655
+
656
+ async resolveThreadForTurn(threadId, params = {}) {
657
+ try {
658
+ const thread = await this.resolveThread(threadId);
659
+ this.applyRequestedProjectCwd(thread, params);
660
+ return thread;
661
+ } catch (error) {
662
+ if (!threadId || error?.errorCode !== "thread_not_found") {
663
+ throw error;
664
+ }
665
+ }
666
+
667
+ return this.adoptThread(threadId, params);
668
+ }
669
+
670
+ applyRequestedProjectCwd(thread, params = {}) {
671
+ const requestedCwd = readProjectCwd(params);
672
+ if (!requestedCwd || !thread || (thread.hasProjectCwd && thread.cwd === requestedCwd)) {
673
+ return;
674
+ }
675
+
676
+ thread.cwd = requestedCwd;
677
+ thread.hasProjectCwd = true;
678
+ }
679
+
680
+ rememberSessionThread(thread) {
681
+ const hasProjectCwd = thread.hasProjectCwd !== false
682
+ && thread.metadata?.projectCwdSource !== "fallback";
683
+ const sessionId = thread.sessionId || cursorSessionIdFromThreadId(thread.id) || thread.id;
684
+ const stored = {
685
+ id: thread.id,
686
+ title: thread.title || thread.name || "Cursor chat",
687
+ cwd: thread.cwd || process.cwd(),
688
+ model: normalizeCursorModel(thread.model),
689
+ createdAt: thread.createdAt || new Date().toISOString(),
690
+ updatedAt: thread.updatedAt || new Date().toISOString(),
691
+ archived: false,
692
+ hasProjectCwd,
693
+ sessionId,
694
+ turns: Array.isArray(thread.turns) ? thread.turns : [],
695
+ };
696
+ this.threads.set(stored.id, stored);
697
+ this.sessionThreadCache.set(stored.id, publicThread(stored));
698
+ this.sessionThreadCache.set(stored.sessionId, publicThread(stored));
699
+ return stored;
700
+ }
701
+
702
+ adoptThread(threadId, params = {}) {
703
+ // Existing Codex-local chats can switch providers mid-thread; adopt the id
704
+ // locally so the Cursor turn can stream back into the same timeline.
705
+ const now = new Date().toISOString();
706
+ const requestedCwd = readProjectCwd(params);
707
+ const sessionId = cursorSessionIdFromThreadId(threadId);
708
+ const thread = {
709
+ id: threadId,
710
+ title: readString(params.title) || "Cursor chat",
711
+ cwd: requestedCwd || process.cwd(),
712
+ model: normalizeCursorModel(params.model),
713
+ createdAt: now,
714
+ updatedAt: now,
715
+ archived: false,
716
+ hasProjectCwd: Boolean(requestedCwd),
717
+ sessionId,
718
+ turns: [],
719
+ };
720
+ this.threads.set(thread.id, thread);
721
+ return thread;
722
+ }
723
+
724
+ threadFromSession(session) {
725
+ const id = readString(session.sessionId || session.id);
726
+ if (!id) {
727
+ return null;
728
+ }
729
+
730
+ const sessionCwd = readString(session.cwd || session.directory || session.path);
731
+ const threadId = cursorThreadIdForSession(id);
732
+ const thread = {
733
+ id: threadId,
734
+ title: readString(session.title || session.name) || "Cursor chat",
735
+ cwd: sessionCwd || process.cwd(),
736
+ model: DEFAULT_CURSOR_MODEL,
737
+ createdAt: normalizeDateString(session.createdAt || session.created_at),
738
+ updatedAt: normalizeDateString(session.updatedAt || session.updated_at),
739
+ archived: false,
740
+ hasProjectCwd: Boolean(sessionCwd),
741
+ sessionId: id,
742
+ turns: [],
743
+ };
744
+ this.sessionThreadCache.set(threadId, publicThread(thread));
745
+ this.sessionThreadCache.set(id, publicThread(thread));
746
+ return publicThread(thread);
747
+ }
748
+
749
+ readFreshModelCache() {
750
+ if (!this.modelCache || Date.now() > this.modelCache.expiresAt) {
751
+ return null;
752
+ }
753
+ return this.modelCache.value;
754
+ }
755
+
756
+ newClient(options = {}) {
757
+ return this.createAcpClient({
758
+ command: resolveCursorCommand(this.env),
759
+ cwd: options.cwd || process.cwd(),
760
+ env: this.env,
761
+ onNotification: options.onNotification,
762
+ onRequest: options.onRequest,
763
+ });
764
+ }
765
+
766
+ async createSession({ cwd, model }) {
767
+ const client = this.newClient({ cwd });
768
+ try {
769
+ await initializeCursorClient(client, CURSOR_SESSION_START_TIMEOUT_MS);
770
+ const session = await client.request(
771
+ "session/new",
772
+ { cwd, mcpServers: [] },
773
+ CURSOR_SESSION_START_TIMEOUT_MS
774
+ );
775
+ const sessionId = readString(session?.sessionId);
776
+ if (!sessionId) {
777
+ throw cursorProtocolError("Cursor ACP session/new did not return a sessionId.");
778
+ }
779
+ if (model) {
780
+ await client.request("session/set_config_option", {
781
+ sessionId,
782
+ configId: "model",
783
+ value: model,
784
+ }, CURSOR_SESSION_START_TIMEOUT_MS).catch(() => null);
785
+ }
786
+ return sessionId;
787
+ } finally {
788
+ client.kill();
789
+ }
790
+ }
791
+
792
+ emit(method, params) {
793
+ this.sendApplicationMessage?.(JSON.stringify({
794
+ method,
795
+ params: removeUndefinedValues(params || {}),
796
+ }));
797
+ }
798
+
799
+ warnUnavailable(reason) {
800
+ const normalizedReason = readString(reason);
801
+ if (!normalizedReason || this.warnedAvailabilityReason === normalizedReason) {
802
+ return;
803
+ }
804
+ this.warnedAvailabilityReason = normalizedReason;
805
+ console.warn(`${this.logPrefix} Cursor unavailable: ${normalizedReason}`);
806
+ }
807
+ }
808
+
809
+ async function initializeCursorClient(client, timeoutMs) {
810
+ return client.request("initialize", {
811
+ protocolVersion: 1,
812
+ clientCapabilities: {
813
+ fs: { readTextFile: false, writeTextFile: false },
814
+ terminal: false,
815
+ _meta: {
816
+ parameterizedModelPicker: true,
817
+ },
818
+ },
819
+ clientInfo: {
820
+ name: "remodex_bridge",
821
+ title: "Remodex Bridge",
822
+ version: "1.0.0",
823
+ },
824
+ }, timeoutMs);
825
+ }
826
+
827
+ function createActiveCursorTurn({ params, thread, turn, turnId }) {
828
+ return {
829
+ assistantItemId: `cursor-agent-${turnId}`,
830
+ assistantText: "",
831
+ client: null,
832
+ loadingHistory: false,
833
+ params,
834
+ sessionId: thread.sessionId || "",
835
+ stopped: false,
836
+ threadId: thread.id,
837
+ toolCallIds: new Set(),
838
+ turn,
839
+ };
840
+ }
841
+
842
+ function createStoredTurn({ inputText, model, threadId, turnId }) {
843
+ const now = new Date().toISOString();
844
+ return {
845
+ id: turnId,
846
+ model,
847
+ status: "running",
848
+ createdAt: now,
849
+ items: [
850
+ {
851
+ id: `cursor-user-${turnId}`,
852
+ type: "userMessage",
853
+ role: "user",
854
+ text: inputText,
855
+ content: textContent(inputText),
856
+ createdAt: now,
857
+ },
858
+ {
859
+ id: `cursor-agent-${turnId}`,
860
+ type: "agentMessage",
861
+ role: "assistant",
862
+ phase: "final",
863
+ text: "",
864
+ content: textContent(""),
865
+ createdAt: now,
866
+ },
867
+ ],
868
+ metadata: {
869
+ threadId,
870
+ provider: CURSOR_PROVIDER_ID,
871
+ },
872
+ };
873
+ }
874
+
875
+ function createHistoryCollector(thread) {
876
+ const turns = [];
877
+ let currentTurn = null;
878
+
879
+ return {
880
+ turns,
881
+ append(update) {
882
+ if (!update || typeof update !== "object") {
883
+ return;
884
+ }
885
+
886
+ const text = contentText(update.content);
887
+ if (!text) {
888
+ return;
889
+ }
890
+
891
+ if (update.sessionUpdate === "user_message_chunk" || !currentTurn) {
892
+ currentTurn = {
893
+ id: `${CURSOR_TURN_PREFIX}history-${turns.length + 1}`,
894
+ model: thread.model || DEFAULT_CURSOR_MODEL,
895
+ status: "completed",
896
+ createdAt: new Date().toISOString(),
897
+ completedAt: new Date().toISOString(),
898
+ items: [],
899
+ };
900
+ turns.push(currentTurn);
901
+ }
902
+
903
+ if (update.sessionUpdate === "user_message_chunk") {
904
+ currentTurn.items.push({
905
+ id: `${currentTurn.id}-user`,
906
+ type: "userMessage",
907
+ role: "user",
908
+ text,
909
+ content: textContent(text),
910
+ });
911
+ return;
912
+ }
913
+
914
+ if (update.sessionUpdate === "agent_message_chunk") {
915
+ const existing = currentTurn.items.find((item) => item.type === "agentMessage");
916
+ if (existing) {
917
+ existing.text += text;
918
+ existing.content = textContent(existing.text);
919
+ } else {
920
+ currentTurn.items.push({
921
+ id: `${currentTurn.id}-agent`,
922
+ type: "agentMessage",
923
+ role: "assistant",
924
+ phase: "final",
925
+ text,
926
+ content: textContent(text),
927
+ });
928
+ }
929
+ }
930
+ },
931
+ };
932
+ }
933
+
934
+ function publicThread(thread) {
935
+ const hasProjectCwd = thread.hasProjectCwd !== false;
936
+ return {
937
+ id: thread.id,
938
+ title: thread.title,
939
+ name: thread.title,
940
+ cwd: hasProjectCwd ? thread.cwd : null,
941
+ model: normalizeCursorModel(thread.model),
942
+ modelProvider: CURSOR_PROVIDER_ID,
943
+ provider: CURSOR_PROVIDER_ID,
944
+ createdAt: thread.createdAt,
945
+ updatedAt: thread.updatedAt,
946
+ metadata: {
947
+ provider: CURSOR_PROVIDER_ID,
948
+ projectCwdSource: hasProjectCwd ? "explicit" : "fallback",
949
+ sessionId: thread.sessionId || null,
950
+ },
951
+ };
952
+ }
953
+
954
+ function normalizeCursorModel(value) {
955
+ return normalizeCursorModelReference(value) || DEFAULT_CURSOR_MODEL;
956
+ }
957
+
958
+ function cursorThreadIdForSession(sessionId) {
959
+ const normalized = readString(sessionId);
960
+ return normalized ? `${CURSOR_THREAD_PREFIX}${normalized}` : "";
961
+ }
962
+
963
+ function cursorSessionIdFromThreadId(threadId) {
964
+ const normalized = readString(threadId);
965
+ return normalized.startsWith(CURSOR_THREAD_PREFIX)
966
+ ? normalized.slice(CURSOR_THREAD_PREFIX.length)
967
+ : "";
968
+ }
969
+
970
+ function resolveCursorCommand(env = process.env) {
971
+ return readString(env.REMODEX_CURSOR_COMMAND) || readString(env.CURSOR_AGENT_COMMAND) || "cursor-agent";
972
+ }
973
+
974
+ function buildSessionListParams(params = {}) {
975
+ const result = {};
976
+ const cwd = readString(params.cwd || params.current_working_directory || params.working_directory);
977
+ if (cwd) {
978
+ result.cwd = cwd;
979
+ }
980
+ const cursor = readString(params.cursor);
981
+ if (cursor) {
982
+ result.cursor = cursor;
983
+ }
984
+ return result;
985
+ }
986
+
987
+ function cursorModeForParams(params = {}) {
988
+ const mode = readString(params.collaborationMode?.mode || params.collaboration_mode?.mode).toLowerCase();
989
+ if (mode === "plan") {
990
+ return "plan";
991
+ }
992
+ if (mode === "ask") {
993
+ return "ask";
994
+ }
995
+ return "";
996
+ }
997
+
998
+ function selectPermissionOption(options, params = {}) {
999
+ const choices = Array.isArray(options) ? options : [];
1000
+ const preferredKinds = shouldSkipPermissions(params)
1001
+ ? ["allow_always", "allow_once", "reject_once", "reject_always"]
1002
+ : ["allow_once", "allow_always", "reject_once", "reject_always"];
1003
+
1004
+ for (const kind of preferredKinds) {
1005
+ const choice = choices.find((option) => readString(option?.kind) === kind);
1006
+ if (choice?.optionId) {
1007
+ return choice.optionId;
1008
+ }
1009
+ }
1010
+ return readString(choices[0]?.optionId);
1011
+ }
1012
+
1013
+ function shouldSkipPermissions(params = {}) {
1014
+ const approvalPolicy = readString(params.approvalPolicy || params.approval_policy).toLowerCase();
1015
+ const sandbox = readString(params.sandbox).toLowerCase();
1016
+ const sandboxType = readString(params.sandboxPolicy?.type || params.sandbox_policy?.type).toLowerCase();
1017
+ return approvalPolicy === "never"
1018
+ || sandbox.includes("danger")
1019
+ || sandboxType === "dangerfullaccess"
1020
+ || sandboxType === "danger-full-access";
1021
+ }
1022
+
1023
+ function buildPromptFromTurnInput(input) {
1024
+ if (typeof input === "string") {
1025
+ return {
1026
+ inputText: input.trim(),
1027
+ prompt: input.trim(),
1028
+ };
1029
+ }
1030
+ if (!Array.isArray(input)) {
1031
+ return { inputText: "", prompt: "" };
1032
+ }
1033
+
1034
+ const textParts = [];
1035
+ const fallbackParts = [];
1036
+ for (const item of input) {
1037
+ if (typeof item === "string") {
1038
+ appendNonEmpty(textParts, item);
1039
+ continue;
1040
+ }
1041
+ if (!item || typeof item !== "object") {
1042
+ continue;
1043
+ }
1044
+ const type = readString(item.type).toLowerCase();
1045
+ if (type.includes("image")) {
1046
+ appendNonEmpty(fallbackParts, imageFallbackText(item));
1047
+ continue;
1048
+ }
1049
+ appendNonEmpty(textParts, item.text || item.content || item.message);
1050
+ }
1051
+
1052
+ const inputText = textParts.join("\n\n").trim() || fallbackParts.join("\n\n").trim();
1053
+ const prompt = [...textParts, ...fallbackParts].join("\n\n").trim();
1054
+ return { inputText, prompt };
1055
+ }
1056
+
1057
+ function imageFallbackText(item) {
1058
+ const imagePath = readString(item.path || item.url || item.image_url || item.dataURL || item.data_url);
1059
+ return imagePath ? `[image attached: ${imagePath}]` : "[image attached]";
1060
+ }
1061
+
1062
+ function contentText(content) {
1063
+ if (typeof content === "string") {
1064
+ return content;
1065
+ }
1066
+ if (!content || typeof content !== "object") {
1067
+ return "";
1068
+ }
1069
+ if (content.type === "text") {
1070
+ return typeof content.text === "string" ? content.text : "";
1071
+ }
1072
+ return readRawString(content.text || content.content || content.message);
1073
+ }
1074
+
1075
+ function computeTextDelta(previousText, incomingText) {
1076
+ if (!incomingText) {
1077
+ return "";
1078
+ }
1079
+ if (!previousText || incomingText.startsWith(previousText)) {
1080
+ return incomingText.slice(previousText.length);
1081
+ }
1082
+ return incomingText;
1083
+ }
1084
+
1085
+ function textContent(text) {
1086
+ return [{ type: "text", text: text || "" }];
1087
+ }
1088
+
1089
+ function dedupeThreadsById(threads) {
1090
+ const seen = new Set();
1091
+ const result = [];
1092
+ for (const thread of threads) {
1093
+ const id = readString(thread?.id);
1094
+ if (!id || seen.has(id)) {
1095
+ continue;
1096
+ }
1097
+ seen.add(id);
1098
+ result.push(thread);
1099
+ }
1100
+ return result;
1101
+ }
1102
+
1103
+ function compareThreadsByUpdatedAt(lhs, rhs) {
1104
+ const lhsTime = Date.parse(lhs?.updatedAt || lhs?.updated_at || lhs?.createdAt || lhs?.created_at || 0) || 0;
1105
+ const rhsTime = Date.parse(rhs?.updatedAt || rhs?.updated_at || rhs?.createdAt || rhs?.created_at || 0) || 0;
1106
+ return rhsTime - lhsTime;
1107
+ }
1108
+
1109
+ function normalizeDateString(value) {
1110
+ if (typeof value === "number" && Number.isFinite(value)) {
1111
+ const milliseconds = Math.abs(value) < 10_000_000_000 ? value * 1000 : value;
1112
+ return new Date(milliseconds).toISOString();
1113
+ }
1114
+ const normalized = readString(value);
1115
+ const parsed = Date.parse(normalized);
1116
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : "";
1117
+ }
1118
+
1119
+ function boundedPositiveInteger(value, fallback) {
1120
+ const numeric = Number(value);
1121
+ if (!Number.isFinite(numeric) || numeric <= 0) {
1122
+ return fallback;
1123
+ }
1124
+ return Math.min(Math.floor(numeric), 200);
1125
+ }
1126
+
1127
+ function removeUndefinedValues(value) {
1128
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1129
+ return value;
1130
+ }
1131
+ const result = {};
1132
+ for (const [key, child] of Object.entries(value)) {
1133
+ if (child !== undefined) {
1134
+ result[key] = removeUndefinedValues(child);
1135
+ }
1136
+ }
1137
+ return result;
1138
+ }
1139
+
1140
+ function pruneSet(set, maxSize) {
1141
+ while (set.size > maxSize) {
1142
+ const [first] = set;
1143
+ set.delete(first);
1144
+ }
1145
+ }
1146
+
1147
+ function appendNonEmpty(target, value) {
1148
+ const text = readString(value);
1149
+ if (text) {
1150
+ target.push(text);
1151
+ }
1152
+ }
1153
+
1154
+ function readProjectCwd(params = {}) {
1155
+ return readString(params.cwd || params.current_working_directory || params.working_directory);
1156
+ }
1157
+
1158
+ function readThreadId(params = {}) {
1159
+ return readString(params.threadId || params.thread_id || params.id);
1160
+ }
1161
+
1162
+ function readString(value) {
1163
+ return typeof value === "string" && value.trim() ? value.trim() : "";
1164
+ }
1165
+
1166
+ function readRawString(value) {
1167
+ return typeof value === "string" ? value : "";
1168
+ }
1169
+
1170
+ function unsupportedMethodError(method) {
1171
+ const error = new Error(`Unsupported Cursor provider method: ${method || "unknown"}`);
1172
+ error.errorCode = "unsupported_cursor_method";
1173
+ return error;
1174
+ }
1175
+
1176
+ function threadNotFoundError(threadId) {
1177
+ const error = new Error(`Cursor thread not found: ${threadId || "unknown"}`);
1178
+ error.errorCode = "thread_not_found";
1179
+ return error;
1180
+ }
1181
+
1182
+ function activeTurnError(threadId) {
1183
+ const error = new Error(`Cursor thread already has a running turn: ${threadId}`);
1184
+ error.errorCode = "thread_turn_active";
1185
+ return error;
1186
+ }
1187
+
1188
+ function cursorProtocolError(message) {
1189
+ const error = new Error(message);
1190
+ error.errorCode = "cursor_protocol_error";
1191
+ return error;
1192
+ }
1193
+
1194
+ module.exports = {
1195
+ createCursorProvider,
1196
+ selectPermissionOption,
1197
+ };