@makerbi/remodex 1.4.0 → 1.5.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.
@@ -20,6 +20,8 @@ const DEFAULT_APP_BOOT_WAIT_MS = 1_200;
20
20
  const DEFAULT_THREAD_MATERIALIZE_WAIT_MS = 4_000;
21
21
  const DEFAULT_THREAD_MATERIALIZE_POLL_MS = 250;
22
22
  const DEFAULT_WAKE_DISPLAY_DURATION_SECONDS = 30;
23
+ const WINDOWS_BOUNCE_URL = "codex://settings";
24
+ const DESKTOP_THREAD_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/;
23
25
 
24
26
  function handleDesktopRequest(rawMessage, sendResponse, options = {}) {
25
27
  let parsed;
@@ -71,16 +73,39 @@ async function handleDesktopMethod(method, params, options = {}) {
71
73
  const threadMaterializeWaitMs = options.threadMaterializeWaitMs ?? DEFAULT_THREAD_MATERIALIZE_WAIT_MS;
72
74
  const threadMaterializePollMs = options.threadMaterializePollMs ?? DEFAULT_THREAD_MATERIALIZE_POLL_MS;
73
75
 
74
- if (platform !== "darwin") {
75
- throw desktopError(
76
- "unsupported_platform",
77
- "Mac handoff is only available when the bridge is running on macOS."
78
- );
79
- }
80
-
81
76
  switch (method) {
77
+ case "desktop/continueOnDesktop":
78
+ if (platform !== "darwin" && platform !== "win32") {
79
+ throw desktopError(
80
+ "unsupported_platform",
81
+ "Desktop handoff is only available when the bridge is running on macOS or Windows."
82
+ );
83
+ }
84
+
85
+ return continueOnDesktop(params, {
86
+ platform,
87
+ bundleId,
88
+ appPath,
89
+ executor,
90
+ env,
91
+ fsModule,
92
+ isAppRunning,
93
+ sleepFn,
94
+ appBootWaitMs,
95
+ relaunchWaitMs,
96
+ threadMaterializeWaitMs,
97
+ threadMaterializePollMs,
98
+ });
82
99
  case "desktop/continueOnMac":
83
- return continueOnMac(params, {
100
+ if (platform !== "darwin") {
101
+ throw desktopError(
102
+ "unsupported_platform",
103
+ "Mac handoff is only available when the bridge is running on macOS."
104
+ );
105
+ }
106
+
107
+ return continueOnDesktop(params, {
108
+ platform,
84
109
  bundleId,
85
110
  appPath,
86
111
  executor,
@@ -106,10 +131,11 @@ async function handleDesktopMethod(method, params, options = {}) {
106
131
  }
107
132
  }
108
133
 
109
- // Waits for fresh phone-authored chats to materialize locally before deep-linking them on Mac.
110
- async function continueOnMac(
134
+ // Waits for fresh phone-authored chats to materialize locally before deep-linking them on desktop.
135
+ async function continueOnDesktop(
111
136
  params,
112
137
  {
138
+ platform,
113
139
  bundleId,
114
140
  appPath,
115
141
  executor,
@@ -125,11 +151,53 @@ async function continueOnMac(
125
151
  ) {
126
152
  const threadId = resolveThreadId(params);
127
153
  if (!threadId) {
128
- throw desktopError("missing_thread_id", "A thread id is required to continue on Mac.");
154
+ throw desktopError("missing_thread_id", "A thread id is required to continue on desktop.");
155
+ }
156
+ if (!isValidDesktopThreadId(threadId)) {
157
+ throw desktopError("invalid_thread_id", "The requested desktop thread id is not valid.");
129
158
  }
130
159
 
131
160
  const targetUrl = `codex://threads/${threadId}`;
132
161
  const desktopKnown = isThreadLikelyKnownOnDesktop(threadId, { env, fsModule });
162
+
163
+ if (platform === "win32") {
164
+ try {
165
+ if (desktopKnown) {
166
+ await refreshWindowsCodex(targetUrl, {
167
+ executor,
168
+ env,
169
+ sleepFn,
170
+ settleMs: relaunchWaitMs,
171
+ });
172
+ } else {
173
+ await openWindowsDeepLink(WINDOWS_BOUNCE_URL, { executor, env });
174
+ await sleepFn(appBootWaitMs);
175
+ await waitForThreadMaterialization(threadId, {
176
+ env,
177
+ fsModule,
178
+ sleepFn,
179
+ timeoutMs: threadMaterializeWaitMs,
180
+ pollMs: threadMaterializePollMs,
181
+ });
182
+ await openWindowsDeepLink(targetUrl, { executor, env });
183
+ }
184
+ } catch (error) {
185
+ throw desktopError(
186
+ "handoff_failed",
187
+ "Could not open Codex on this PC.",
188
+ error
189
+ );
190
+ }
191
+
192
+ return {
193
+ success: true,
194
+ relaunched: false,
195
+ targetUrl,
196
+ threadId,
197
+ desktopKnown,
198
+ };
199
+ }
200
+
133
201
  const appRunning = typeof isAppRunning === "function"
134
202
  ? await isAppRunning(appPath)
135
203
  : await detectRunningCodexApp(appPath, executor);
@@ -311,6 +379,11 @@ function resolveThreadId(params) {
311
379
  return "";
312
380
  }
313
381
 
382
+ // Keeps desktop deep links to a single safe route segment before handing them to OS launchers.
383
+ function isValidDesktopThreadId(threadId) {
384
+ return typeof threadId === "string" && DESKTOP_THREAD_ID_PATTERN.test(threadId);
385
+ }
386
+
314
387
  function desktopError(errorCode, userMessage, cause = null) {
315
388
  const error = new Error(userMessage);
316
389
  error.errorCode = errorCode;
@@ -372,6 +445,22 @@ async function openCodexApp({ bundleId, appPath, executor }) {
372
445
  }
373
446
  }
374
447
 
448
+ async function openWindowsDeepLink(targetUrl, { executor, env }) {
449
+ await executor(env?.SystemRoot ? path.join(env.SystemRoot, "System32", "rundll32.exe") : "rundll32.exe", [
450
+ "url.dll,FileProtocolHandler",
451
+ targetUrl,
452
+ ], {
453
+ timeout: HANDOFF_TIMEOUT_MS,
454
+ windowsHide: true,
455
+ });
456
+ }
457
+
458
+ async function refreshWindowsCodex(targetUrl, { executor, env, sleepFn, settleMs }) {
459
+ await openWindowsDeepLink(WINDOWS_BOUNCE_URL, { executor, env });
460
+ await sleepFn(settleMs);
461
+ await openWindowsDeepLink(targetUrl, { executor, env });
462
+ }
463
+
375
464
  // Gives the desktop a short window to materialize the requested thread before the final deep link.
376
465
  async function openWhenThreadReady(
377
466
  threadId,
@@ -77,7 +77,6 @@ function createDesktopIpcActionFollower({
77
77
 
78
78
  activeThreadIds.add(threadId);
79
79
  ipc.ensureConnected();
80
- recoverThreadBaseline(threadId);
81
80
  return false;
82
81
  }
83
82
 
@@ -111,6 +110,19 @@ function createDesktopIpcActionFollower({
111
110
  const nextState = applyConversationStateChange(previousState, params.change);
112
111
  if (!nextState) {
113
112
  if (isPatchChange(params.change)) {
113
+ const emptyState = createEmptyConversationState();
114
+ const speculativeState = applyConversationStateChange(emptyState, params.change);
115
+ const speculativeActions = projectPendingDesktopActions(threadId, speculativeState);
116
+ if (speculativeActions.length > 0) {
117
+ rawStatesByThreadId.set(threadId, speculativeState);
118
+ syncProjectedActions(threadId, speculativeActions);
119
+ return;
120
+ }
121
+
122
+ if (typeof readConversationState !== "function") {
123
+ return;
124
+ }
125
+
114
126
  queueThreadChange(threadId, params.change);
115
127
  recoverThreadBaseline(threadId);
116
128
  }
@@ -219,8 +231,7 @@ function createDesktopIpcActionFollower({
219
231
  }
220
232
 
221
233
  function recoverThreadBaseline(threadId) {
222
- if (typeof readConversationState !== "function"
223
- || recoveringThreadIds.has(threadId)
234
+ if (recoveringThreadIds.has(threadId)
224
235
  || rawStatesByThreadId.has(threadId)) {
225
236
  return;
226
237
  }
@@ -230,27 +241,39 @@ function createDesktopIpcActionFollower({
230
241
  .then(() => readConversationState(threadId))
231
242
  .then((baselineState) => {
232
243
  if (!baselineState || typeof baselineState !== "object") {
244
+ recoverThreadBaselineFromQueuedChanges(threadId, null);
233
245
  return;
234
246
  }
235
247
 
236
- let nextState = cloneJSON(baselineState);
237
- const queuedChanges = queuedChangesByThreadId.get(threadId) || [];
238
- queuedChangesByThreadId.delete(threadId);
239
- for (const change of queuedChanges) {
240
- nextState = applyConversationStateChange(nextState, change) || nextState;
241
- }
242
-
243
- rawStatesByThreadId.set(threadId, nextState);
244
- syncProjectedActions(threadId, projectPendingDesktopActions(threadId, nextState));
248
+ recoverThreadBaselineFromQueuedChanges(threadId, baselineState);
245
249
  })
246
250
  .catch((error) => {
247
251
  console.warn(`${logPrefix} desktop IPC baseline recovery failed for ${threadId}: ${error.message}`);
252
+ recoverThreadBaselineFromQueuedChanges(threadId, null);
248
253
  })
249
254
  .finally(() => {
250
255
  recoveringThreadIds.delete(threadId);
251
256
  });
252
257
  }
253
258
 
259
+ function recoverThreadBaselineFromQueuedChanges(threadId, baselineState) {
260
+ const queuedChanges = queuedChangesByThreadId.get(threadId) || [];
261
+ if (queuedChanges.length === 0) {
262
+ return;
263
+ }
264
+
265
+ queuedChangesByThreadId.delete(threadId);
266
+ let nextState = baselineState && typeof baselineState === "object"
267
+ ? cloneJSON(baselineState)
268
+ : createEmptyConversationState();
269
+ for (const change of queuedChanges) {
270
+ nextState = applyConversationStateChange(nextState, change) || nextState;
271
+ }
272
+
273
+ rawStatesByThreadId.set(threadId, nextState);
274
+ syncProjectedActions(threadId, projectPendingDesktopActions(threadId, nextState));
275
+ }
276
+
254
277
  return {
255
278
  observeInbound,
256
279
  stopAll,
@@ -555,6 +578,13 @@ function seedConversationStateFromThreadRead(response) {
555
578
  };
556
579
  }
557
580
 
581
+ function createEmptyConversationState() {
582
+ return {
583
+ turns: [],
584
+ requests: [],
585
+ };
586
+ }
587
+
558
588
  function applyImmerPatch(target, patch) {
559
589
  const patchPath = Array.isArray(patch?.path) ? patch.path : [];
560
590
  const op = readString(patch?.op).toLowerCase();
@@ -13,6 +13,8 @@ const { promisify } = require("util");
13
13
 
14
14
  const execFileAsync = promisify(execFile);
15
15
  const GIT_TIMEOUT_MS = 30_000;
16
+ /** Node defaults maxBuffer to 1 MiB; large repo diffs exceed it ("stdout maxBuffer length exceeded"). */
17
+ const GIT_EXEC_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
16
18
  const GIT_DRAFT_TIMEOUT_MS = 120_000;
17
19
  const GITHUB_CLI_TIMEOUT_MS = 120_000;
18
20
  const GIT_DRAFT_PATCH_MAX_BYTES = 80_000;
@@ -49,7 +51,20 @@ function handleGitRequest(rawMessage, sendResponse, options = {}) {
49
51
  const id = parsed.id;
50
52
  const params = parsed.params || {};
51
53
 
52
- handleGitMethod(method, params, options)
54
+ // Lets long-running git flows push interim progress events to the phone.
55
+ const sendNotification = (notificationMethod, notificationParams) => {
56
+ if (typeof notificationMethod !== "string" || !notificationMethod) {
57
+ return;
58
+ }
59
+ sendResponse(JSON.stringify({
60
+ method: notificationMethod,
61
+ params: notificationParams ?? {},
62
+ }));
63
+ };
64
+
65
+ const methodOptions = { ...options, sendNotification };
66
+
67
+ handleGitMethod(method, params, methodOptions)
53
68
  .then((result) => {
54
69
  sendResponse(JSON.stringify({ id, result }));
55
70
  if (method === "thread/name/set") {
@@ -977,8 +992,26 @@ async function gitRunStackedAction(cwd, params, options = {}) {
977
992
  const wantsCommit = action === "commit" || action === "commit_push" || action === "commit_push_pr";
978
993
  const wantsPr = action === "create_pr" || action === "commit_push_pr";
979
994
 
995
+ // Emits phase progress events on the same wire used by JSON-RPC responses
996
+ // so the iOS toast can reflect the live step (commit/push/PR) of a stacked action.
997
+ const progressId = typeof params.progressId === "string" && params.progressId.trim()
998
+ ? params.progressId.trim()
999
+ : null;
1000
+ const emitPhase = (phase, status) => {
1001
+ if (!progressId || typeof options.sendNotification !== "function") {
1002
+ return;
1003
+ }
1004
+ options.sendNotification("git/stackedAction/progress", {
1005
+ progressId,
1006
+ phase,
1007
+ status,
1008
+ });
1009
+ };
1010
+
980
1011
  if (params.featureBranch === true) {
1012
+ emitPhase("branch", "started");
981
1013
  await gitCreateFeatureBranch(cwd, params);
1014
+ emitPhase("branch", "completed");
982
1015
  }
983
1016
 
984
1017
  const branch = await currentBranchName(cwd);
@@ -1004,6 +1037,7 @@ async function gitRunStackedAction(cwd, params, options = {}) {
1004
1037
  if (wantsCommit) {
1005
1038
  const statusBeforeCommit = await gitStatus(cwd);
1006
1039
  if (statusBeforeCommit.dirty) {
1040
+ emitPhase("commit", "started");
1007
1041
  const commitResult = await gitCommit(cwd, {
1008
1042
  message: params.commitMessage || params.message,
1009
1043
  });
@@ -1013,10 +1047,12 @@ async function gitRunStackedAction(cwd, params, options = {}) {
1013
1047
  commitSha: commitResult.hash,
1014
1048
  subject: firstCommitMessageLine(params.commitMessage || params.message),
1015
1049
  };
1050
+ emitPhase("commit", "completed");
1016
1051
  } else if (action === "commit") {
1017
1052
  throw gitError("nothing_to_commit", "Nothing to commit.");
1018
1053
  } else {
1019
1054
  result.commit = { status: "skipped_clean" };
1055
+ emitPhase("commit", "skipped");
1020
1056
  }
1021
1057
  }
1022
1058
 
@@ -1037,17 +1073,21 @@ async function gitRunStackedAction(cwd, params, options = {}) {
1037
1073
  if (statusBeforePush.dirty) {
1038
1074
  throw gitError("dirty_worktree", "Commit or stash local changes before pushing.");
1039
1075
  }
1076
+ emitPhase("push", "started");
1040
1077
  result.push = {
1041
1078
  state: "pushed",
1042
1079
  ...(await gitPush(cwd)),
1043
1080
  };
1081
+ emitPhase("push", "completed");
1044
1082
  }
1045
1083
 
1046
1084
  if (wantsPr) {
1085
+ emitPhase("createPR", "started");
1047
1086
  result.pr = await gitCreatePullRequest(cwd, {
1048
1087
  ...params,
1049
1088
  pushBeforeCreate: false,
1050
1089
  }, options);
1090
+ emitPhase("createPR", "completed");
1051
1091
  }
1052
1092
 
1053
1093
  result.status = await gitStatus(cwd);
@@ -2387,7 +2427,7 @@ async function gitDiffNoIndexNumstat(cwd, filePath) {
2387
2427
  const { stdout } = await execFileAsync(
2388
2428
  "git",
2389
2429
  ["diff", "--no-index", "--numstat", "--", "/dev/null", filePath],
2390
- { cwd, timeout: GIT_TIMEOUT_MS }
2430
+ { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_EXEC_MAX_BUFFER_BYTES }
2391
2431
  );
2392
2432
  return stdout;
2393
2433
  } catch (err) {
@@ -2413,7 +2453,7 @@ async function gitDiffNoIndexPatch(cwd, filePath) {
2413
2453
  const { stdout } = await execFileAsync(
2414
2454
  "git",
2415
2455
  ["diff", "--no-index", "--binary", "--", "/dev/null", filePath],
2416
- { cwd, timeout: GIT_TIMEOUT_MS }
2456
+ { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_EXEC_MAX_BUFFER_BYTES }
2417
2457
  );
2418
2458
  return stdout;
2419
2459
  } catch (err) {
@@ -2428,7 +2468,11 @@ async function gitDiffNoIndexPatch(cwd, filePath) {
2428
2468
  // ─── Helpers ──────────────────────────────────────────────────
2429
2469
 
2430
2470
  function git(cwd, ...args) {
2431
- return execFileAsync("git", args, { cwd, timeout: GIT_TIMEOUT_MS })
2471
+ return execFileAsync("git", args, {
2472
+ cwd,
2473
+ timeout: GIT_TIMEOUT_MS,
2474
+ maxBuffer: GIT_EXEC_MAX_BUFFER_BYTES,
2475
+ })
2432
2476
  .then(({ stdout }) => stdout)
2433
2477
  .catch((err) => {
2434
2478
  const msg = (err.stderr || err.message || "").trim();
@@ -33,8 +33,8 @@ const DEFAULT_PAIRING_WAIT_TIMEOUT_MS = 10_000;
33
33
  const DEFAULT_PAIRING_WAIT_INTERVAL_MS = 200;
34
34
 
35
35
  // Runs the bridge inside launchd while keeping QR rendering in the foreground CLI command.
36
- function runMacOSBridgeService({ env = process.env } = {}) {
37
- assertDarwinPlatform();
36
+ function runMacOSBridgeService({ env = process.env, platform = process.platform } = {}) {
37
+ assertDarwinPlatform(platform);
38
38
  const config = readDaemonConfig({ env });
39
39
  if (!config?.relayUrl) {
40
40
  const message = "No relay URL configured for the macOS bridge service.";