@makerbi/remodex 2.0.1 → 2.3.0

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.
@@ -24,6 +24,12 @@ const NEW_THREAD_DEEP_LINK = "codex://threads/new";
24
24
  class CodexDesktopRefresher {
25
25
  constructor({
26
26
  enabled = true,
27
+ // When desktop IPC live sync streams conversation content, the refresher's
28
+ // only remaining job is navigation: bring Codex to the phone-driven thread
29
+ // when a phone message starts. Mid-run and completion refreshes are content
30
+ // reload workarounds from the pre-IPC era and would repeatedly deep-link
31
+ // and steal focus, so navigation-only mode drops them.
32
+ navigationOnly = false,
27
33
  debounceMs = DEFAULT_DEBOUNCE_MS,
28
34
  refreshCommand = "",
29
35
  bundleId = DEFAULT_BUNDLE_ID,
@@ -40,6 +46,7 @@ class CodexDesktopRefresher {
40
46
  customRefreshFailureThreshold = DEFAULT_CUSTOM_REFRESH_FAILURE_THRESHOLD,
41
47
  } = {}) {
42
48
  this.enabled = enabled;
49
+ this.navigationOnly = navigationOnly;
43
50
  this.debounceMs = debounceMs;
44
51
  this.refreshCommand = refreshCommand;
45
52
  this.bundleId = bundleId;
@@ -82,8 +89,8 @@ class CodexDesktopRefresher {
82
89
  this.unavailableLogged = false;
83
90
  }
84
91
 
85
- handleInbound(rawMessage) {
86
- const parsed = safeParseJSON(rawMessage);
92
+ handleInbound(rawMessage, parsedMessage = null) {
93
+ const parsed = parsedMessage ?? safeParseJSON(rawMessage);
87
94
  if (!parsed) {
88
95
  return;
89
96
  }
@@ -117,8 +124,8 @@ class CodexDesktopRefresher {
117
124
  }
118
125
  }
119
126
 
120
- handleOutbound(rawMessage) {
121
- const parsed = safeParseJSON(rawMessage);
127
+ handleOutbound(rawMessage, parsedMessage = null) {
128
+ const parsed = parsedMessage ?? safeParseJSON(rawMessage);
122
129
  if (!parsed) {
123
130
  return;
124
131
  }
@@ -126,6 +133,9 @@ class CodexDesktopRefresher {
126
133
  const method = parsed.method;
127
134
  if (method === "turn/completed") {
128
135
  this.clearFallbackTimer();
136
+ if (this.navigationOnly) {
137
+ return;
138
+ }
129
139
  const turnId = extractTurnId(parsed);
130
140
  if (turnId && turnId === this.lastTurnIdRefreshed) {
131
141
  this.log(`refresh skipped (debounced): completion already refreshed for ${turnId}`);
@@ -317,6 +327,10 @@ class CodexDesktopRefresher {
317
327
  this.bundleId,
318
328
  this.appPath,
319
329
  targetUrl || "",
330
+ // Navigation-only mode must never launch Codex from closed: content
331
+ // already syncs over IPC live sync, so a deep link while the app is
332
+ // down would only cold-start it to show a thread nobody asked to see.
333
+ this.navigationOnly ? "0" : "1",
320
334
  ]);
321
335
  }
322
336
 
@@ -372,7 +386,7 @@ class CodexDesktopRefresher {
372
386
 
373
387
  // Keeps one lightweight rollout watcher alive for the current Remodex-controlled thread.
374
388
  ensureWatcher(threadId) {
375
- if (!this.canRefresh() || !threadId) {
389
+ if (this.navigationOnly || !this.canRefresh() || !threadId) {
376
390
  return;
377
391
  }
378
392
 
@@ -558,12 +572,19 @@ function readBridgeConfig({
558
572
  env
559
573
  );
560
574
  const explicitRefreshEnabled = readOptionalBooleanEnv(["REMODEX_REFRESH_ENABLED"], env);
575
+ const explicitDesktopIpcLiveSyncEnabled = readOptionalBooleanEnv(["REMODEX_DESKTOP_IPC_LIVE_SYNC"], env);
561
576
  const explicitKeepMacAwakeEnabled = readOptionalBooleanEnv(["REMODEX_KEEP_MAC_AWAKE"], env);
562
577
  const persistedKeepMacAwakeEnabled = typeof daemonConfig.keepMacAwakeEnabled === "boolean"
563
578
  ? daemonConfig.keepMacAwakeEnabled
564
579
  : null;
565
- // Desktop refresh is opt-in for now because Codex.app still lacks true live updates.
566
- const defaultRefreshEnabled = false;
580
+ // The deep-link refresh workaround stays opt-in; local IPC live sync is the primary desktop path.
581
+ // Once opted in, the persisted choice must survive restarts: `remodex restart`
582
+ // rewrites daemon-config.json from this computed config, so ignoring the
583
+ // persisted flag silently disabled the refresher on every restart.
584
+ const persistedRefreshEnabled = typeof daemonConfig.refreshEnabled === "boolean"
585
+ ? daemonConfig.refreshEnabled
586
+ : null;
587
+ const defaultRefreshEnabled = persistedRefreshEnabled == null ? false : persistedRefreshEnabled;
567
588
  return {
568
589
  relayUrl,
569
590
  relayAccessToken,
@@ -588,6 +609,13 @@ function readBridgeConfig({
588
609
  : explicitKeepMacAwakeEnabled,
589
610
  codexEndpoint,
590
611
  desktopIpcSocketPath: readFirstDefinedEnv(["REMODEX_DESKTOP_IPC_SOCKET"], "", env),
612
+ desktopIpcLiveSyncEnabled: explicitDesktopIpcLiveSyncEnabled == null
613
+ ? true
614
+ : explicitDesktopIpcLiveSyncEnabled,
615
+ desktopIpcSnapshotDebounceMs: parseIntegerEnv(
616
+ readFirstDefinedEnv(["REMODEX_DESKTOP_IPC_SNAPSHOT_DEBOUNCE_MS"], "75", env),
617
+ 75
618
+ ),
591
619
  refreshCommand,
592
620
  codexBundleId: readFirstDefinedEnv(["REMODEX_CODEX_BUNDLE_ID"], DEFAULT_BUNDLE_ID, env),
593
621
  codexAppPath: DEFAULT_APP_PATH,
@@ -0,0 +1,242 @@
1
+ // FILE: cursor-acp-client.js
2
+ // Purpose: Small JSON-RPC stdio client for Cursor's `cursor-agent acp` server.
3
+ // Layer: Bridge runtime provider transport
4
+ // Exports: createCursorAcpClient
5
+ // Depends on: child_process
6
+
7
+ const { spawn } = require("child_process");
8
+
9
+ const DEFAULT_ACP_REQUEST_TIMEOUT_MS = 30_000;
10
+
11
+ function createCursorAcpClient({
12
+ command = "cursor-agent",
13
+ args = ["acp"],
14
+ cwd = process.cwd(),
15
+ env = process.env,
16
+ spawnImpl = spawn,
17
+ requestTimeoutMs = DEFAULT_ACP_REQUEST_TIMEOUT_MS,
18
+ onNotification = null,
19
+ onRequest = null,
20
+ } = {}) {
21
+ return new CursorAcpClient({
22
+ args,
23
+ command,
24
+ cwd,
25
+ env,
26
+ onNotification,
27
+ onRequest,
28
+ requestTimeoutMs,
29
+ spawnImpl,
30
+ });
31
+ }
32
+
33
+ class CursorAcpClient {
34
+ constructor({
35
+ command,
36
+ args,
37
+ cwd,
38
+ env,
39
+ spawnImpl,
40
+ requestTimeoutMs,
41
+ onNotification,
42
+ onRequest,
43
+ }) {
44
+ this.command = command;
45
+ this.args = args;
46
+ this.cwd = cwd;
47
+ this.env = env;
48
+ this.spawn = spawnImpl;
49
+ this.requestTimeoutMs = requestTimeoutMs;
50
+ this.onNotification = onNotification;
51
+ this.onRequest = onRequest;
52
+ this.child = null;
53
+ this.nextRequestId = 1;
54
+ this.pendingRequests = new Map();
55
+ this.stdoutBuffer = "";
56
+ this.stderr = "";
57
+ this.closed = false;
58
+ }
59
+
60
+ start() {
61
+ if (this.child) {
62
+ return;
63
+ }
64
+
65
+ this.child = this.spawn(this.command, this.args, {
66
+ cwd: this.cwd,
67
+ env: this.env,
68
+ stdio: ["pipe", "pipe", "pipe"],
69
+ });
70
+ this.child.stdout?.setEncoding?.("utf8");
71
+ this.child.stderr?.setEncoding?.("utf8");
72
+
73
+ this.child.stdout?.on("data", (chunk) => this.handleStdout(chunk));
74
+ this.child.stderr?.on("data", (chunk) => {
75
+ this.stderr = truncateTail(`${this.stderr}${chunk}`, 4_000);
76
+ });
77
+ this.child.on("error", (error) => this.failAll(error));
78
+ this.child.on("close", (code, signal) => {
79
+ this.closed = true;
80
+ this.failAll(new Error(`Cursor ACP exited with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}.`));
81
+ });
82
+ }
83
+
84
+ request(method, params = {}, timeoutMs = this.requestTimeoutMs) {
85
+ this.start();
86
+ const id = this.nextRequestId;
87
+ this.nextRequestId += 1;
88
+
89
+ return new Promise((resolve, reject) => {
90
+ const timeout = setTimeout(() => {
91
+ this.pendingRequests.delete(id);
92
+ reject(new Error(`Cursor ACP request timed out: ${method}`));
93
+ }, timeoutMs);
94
+
95
+ this.pendingRequests.set(id, {
96
+ method,
97
+ reject,
98
+ resolve,
99
+ timeout,
100
+ });
101
+ try {
102
+ this.writeFrame({ jsonrpc: "2.0", id, method, params });
103
+ } catch (error) {
104
+ clearTimeout(timeout);
105
+ this.pendingRequests.delete(id);
106
+ reject(error);
107
+ }
108
+ });
109
+ }
110
+
111
+ notify(method, params = {}) {
112
+ this.start();
113
+ this.writeFrame({ jsonrpc: "2.0", method, params });
114
+ }
115
+
116
+ respond(id, result) {
117
+ this.writeFrame({ jsonrpc: "2.0", id, result });
118
+ }
119
+
120
+ rejectRequest(id, code, message) {
121
+ this.writeFrame({
122
+ jsonrpc: "2.0",
123
+ id,
124
+ error: {
125
+ code,
126
+ message,
127
+ },
128
+ });
129
+ }
130
+
131
+ kill(signal = "SIGTERM") {
132
+ for (const waiter of this.pendingRequests.values()) {
133
+ clearTimeout(waiter.timeout);
134
+ waiter.reject(new Error("Cursor ACP client stopped."));
135
+ }
136
+ this.pendingRequests.clear();
137
+ try {
138
+ this.child?.kill(signal);
139
+ } catch {
140
+ // Ignore shutdown races; the process may already have exited.
141
+ }
142
+ }
143
+
144
+ writeFrame(frame) {
145
+ if (this.closed || !this.child?.stdin?.writable) {
146
+ throw new Error("Cursor ACP stdin is not writable.");
147
+ }
148
+ this.child.stdin.write(`${JSON.stringify(frame)}\n`);
149
+ }
150
+
151
+ handleStdout(chunk) {
152
+ this.stdoutBuffer += String(chunk);
153
+ for (;;) {
154
+ const newlineIndex = this.stdoutBuffer.indexOf("\n");
155
+ if (newlineIndex === -1) {
156
+ return;
157
+ }
158
+ const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
159
+ this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
160
+ if (line) {
161
+ this.handleFrame(line);
162
+ }
163
+ }
164
+ }
165
+
166
+ handleFrame(line) {
167
+ const frame = safeParseJSON(line);
168
+ if (!frame || typeof frame !== "object") {
169
+ return;
170
+ }
171
+
172
+ if (frame.id != null && !frame.method) {
173
+ this.handleResponse(frame);
174
+ return;
175
+ }
176
+
177
+ if (frame.id != null && frame.method) {
178
+ this.handleRequest(frame);
179
+ return;
180
+ }
181
+
182
+ if (frame.method) {
183
+ this.onNotification?.(frame);
184
+ }
185
+ }
186
+
187
+ handleResponse(frame) {
188
+ const waiter = this.pendingRequests.get(frame.id);
189
+ if (!waiter) {
190
+ return;
191
+ }
192
+
193
+ this.pendingRequests.delete(frame.id);
194
+ clearTimeout(waiter.timeout);
195
+ if (frame.error) {
196
+ const error = new Error(frame.error.message || `Cursor ACP request failed: ${waiter.method}`);
197
+ error.code = frame.error.code;
198
+ error.data = frame.error.data;
199
+ waiter.reject(error);
200
+ return;
201
+ }
202
+ waiter.resolve(frame.result ?? null);
203
+ }
204
+
205
+ handleRequest(frame) {
206
+ Promise.resolve()
207
+ .then(() => this.onRequest?.(frame))
208
+ .then((result) => {
209
+ if (result !== undefined) {
210
+ this.respond(frame.id, result);
211
+ }
212
+ })
213
+ .catch((error) => {
214
+ this.rejectRequest(frame.id, error?.code || -32603, error?.message || "Cursor ACP client request failed.");
215
+ });
216
+ }
217
+
218
+ failAll(error) {
219
+ for (const waiter of this.pendingRequests.values()) {
220
+ clearTimeout(waiter.timeout);
221
+ waiter.reject(error);
222
+ }
223
+ this.pendingRequests.clear();
224
+ }
225
+ }
226
+
227
+ function safeParseJSON(rawValue) {
228
+ try {
229
+ return JSON.parse(String(rawValue || ""));
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ function truncateTail(value, maxChars) {
236
+ const text = String(value || "");
237
+ return text.length <= maxChars ? text : text.slice(-maxChars);
238
+ }
239
+
240
+ module.exports = {
241
+ createCursorAcpClient,
242
+ };
@@ -0,0 +1,134 @@
1
+ // FILE: cursor-models.js
2
+ // Purpose: Converts Cursor ACP model config options into Remodex model/list entries.
3
+ // Layer: Bridge runtime provider helper
4
+ // Exports: Cursor provider constants plus ACP model parsing helpers.
5
+ // Depends on: ./runtime-provider-models
6
+
7
+ const {
8
+ CURSOR_PROVIDER_ID,
9
+ buildRuntimeModelOption,
10
+ } = require("./runtime-provider-models");
11
+
12
+ const DEFAULT_CURSOR_MODEL = "composer-2.5";
13
+
14
+ function parseCursorModelsFromSessionResult(sessionResult = {}) {
15
+ const modelConfig = readModelConfigOption(sessionResult.configOptions);
16
+ if (!modelConfig) {
17
+ return [buildCursorModelOption(DEFAULT_CURSOR_MODEL, { name: "Composer 2.5" }, true)].filter(Boolean);
18
+ }
19
+
20
+ const currentValue = readString(modelConfig.currentValue);
21
+ const options = flattenModelOptions(modelConfig.options);
22
+ const seen = new Set();
23
+ const models = [];
24
+
25
+ for (const option of options) {
26
+ const modelId = readString(option.value || option.modelId || option.id);
27
+ if (!modelId || seen.has(modelId)) {
28
+ continue;
29
+ }
30
+ seen.add(modelId);
31
+ const model = buildCursorModelOption(modelId, option, modelId === currentValue);
32
+ if (model) {
33
+ models.push(model);
34
+ }
35
+ }
36
+
37
+ return models.length
38
+ ? models
39
+ : [buildCursorModelOption(currentValue || DEFAULT_CURSOR_MODEL, { name: "Cursor" }, true)].filter(Boolean);
40
+ }
41
+
42
+ function readModelConfigOption(configOptions) {
43
+ const options = Array.isArray(configOptions) ? configOptions : [];
44
+ return options.find((option) => {
45
+ const category = readString(option?.category).toLowerCase();
46
+ const id = readString(option?.id).toLowerCase();
47
+ return category === "model" || id === "model";
48
+ }) || null;
49
+ }
50
+
51
+ function flattenModelOptions(options) {
52
+ if (!Array.isArray(options)) {
53
+ return [];
54
+ }
55
+
56
+ const flattened = [];
57
+ for (const option of options) {
58
+ if (Array.isArray(option?.options)) {
59
+ flattened.push(...flattenModelOptions(option.options));
60
+ } else if (option && typeof option === "object") {
61
+ flattened.push(option);
62
+ }
63
+ }
64
+ return flattened;
65
+ }
66
+
67
+ function buildCursorModelOption(modelReference, option = {}, isDefault = false) {
68
+ const normalizedReference = normalizeCursorModelReference(modelReference);
69
+ if (!normalizedReference) {
70
+ return null;
71
+ }
72
+
73
+ return buildRuntimeModelOption({
74
+ provider: CURSOR_PROVIDER_ID,
75
+ id: normalizedReference,
76
+ model: normalizedReference,
77
+ displayName: readString(option.name) || displayNameForCursorModel(normalizedReference),
78
+ description: readString(option.description) || `Cursor ACP model (${normalizedReference})`,
79
+ isDefault,
80
+ supportsFastMode: false,
81
+ supportedReasoningEfforts: [],
82
+ defaultReasoningEffort: null,
83
+ });
84
+ }
85
+
86
+ function normalizeCursorModelReference(value) {
87
+ const normalized = readString(value);
88
+ if (!normalized || normalized.startsWith("{") || normalized.startsWith("[")) {
89
+ return "";
90
+ }
91
+ if (!/^[A-Za-z0-9._:-]+(?:\[[^\]\r\n]*\])?$/.test(normalized)) {
92
+ return "";
93
+ }
94
+ return normalized;
95
+ }
96
+
97
+ function displayNameForCursorModel(modelReference) {
98
+ const normalized = normalizeCursorModelReference(modelReference);
99
+ const base = normalized.split("[")[0] || normalized;
100
+ const lowered = base.toLowerCase();
101
+
102
+ if (lowered === "default") {
103
+ return "Auto";
104
+ }
105
+ if (lowered.startsWith("gpt-")) {
106
+ return `GPT-${base.slice(4)}`;
107
+ }
108
+ return base
109
+ .split(/[-_]/)
110
+ .filter(Boolean)
111
+ .map(titleCase)
112
+ .join(" ");
113
+ }
114
+
115
+ function titleCase(value) {
116
+ if (!value) {
117
+ return "";
118
+ }
119
+ return value.charAt(0).toUpperCase() + value.slice(1);
120
+ }
121
+
122
+ function readString(value) {
123
+ return typeof value === "string" && value.trim() ? value.trim() : "";
124
+ }
125
+
126
+ module.exports = {
127
+ CURSOR_PROVIDER_ID,
128
+ DEFAULT_CURSOR_MODEL,
129
+ buildCursorModelOption,
130
+ displayNameForCursorModel,
131
+ normalizeCursorModelReference,
132
+ parseCursorModelsFromSessionResult,
133
+ readModelConfigOption,
134
+ };