@makerbi/remodex 1.5.8 → 2.0.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.
@@ -20,9 +20,11 @@ const GITHUB_CLI_TIMEOUT_MS = 120_000;
20
20
  const GIT_DRAFT_PATCH_MAX_BYTES = 80_000;
21
21
  const EMPTY_TREE_HASH = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
22
22
  const DEFAULT_GIT_WRITER_MODEL = "gpt-5.4-mini";
23
+ const STATUS_UPSTREAM_FETCH_TTL_MS = 15_000;
23
24
 
24
25
  let runStructuredCodexJsonImpl = runStructuredCodexJson;
25
26
  let runGitHubCliImpl = runGitHubCli;
27
+ const statusUpstreamFetchCache = new Map();
26
28
 
27
29
  function resolveGitWriterModel(rawModel) {
28
30
  const trimmed = typeof rawModel === "string" ? rawModel.trim() : "";
@@ -172,9 +174,43 @@ async function gitStatus(cwd) {
172
174
  return nonRepositoryStatus(cwd);
173
175
  }
174
176
 
175
- const [porcelain, branchInfo, repoRoot] = await Promise.all([
177
+ const snapshot = await readGitStatusSnapshot(cwd);
178
+ const { ahead, behind } = await freshBranchInfoForStatus(cwd, snapshot);
179
+ const dirty = snapshot.files.length > 0;
180
+ const noUpstream = snapshot.tracking === null && !snapshot.detached;
181
+ const hasHeadCommit = await refExists(cwd, "HEAD").catch(() => false);
182
+ const hasPushRemote = await pushRemoteAvailable(cwd, snapshot.tracking).catch(() => false);
183
+ const publishedToRemote = !snapshot.detached && !!snapshot.branch && await remoteBranchExists(cwd, snapshot.branch).catch(() => false);
184
+ const localOnlyCommitCount = await countLocalOnlyCommits(cwd, { detached: snapshot.detached }).catch(() => 0);
185
+ const state = computeState(dirty, ahead, behind, snapshot.detached, noUpstream);
186
+ const canPush = hasPushRemote && hasHeadCommit && (ahead > 0 || noUpstream) && !snapshot.detached;
187
+ const diff = await repoDiffTotals(cwd, {
188
+ tracking: snapshot.tracking,
189
+ fileLines: snapshot.fileLines,
190
+ }).catch(() => ({ additions: 0, deletions: 0, binaryFiles: 0 }));
191
+
192
+ return {
193
+ isRepo: true,
194
+ repoRoot: snapshot.repoRoot,
195
+ branch: snapshot.branch,
196
+ tracking: snapshot.tracking,
197
+ dirty,
198
+ hasHeadCommit,
199
+ hasPushRemote,
200
+ ahead,
201
+ behind,
202
+ localOnlyCommitCount,
203
+ state,
204
+ canPush,
205
+ publishedToRemote,
206
+ files: snapshot.files,
207
+ diff,
208
+ };
209
+ }
210
+
211
+ async function readGitStatusSnapshot(cwd) {
212
+ const [porcelain, repoRoot] = await Promise.all([
176
213
  git(cwd, "status", "--porcelain=v1", "-b"),
177
- revListCounts(cwd).catch(() => ({ ahead: 0, behind: 0 })),
178
214
  resolveRepoRoot(cwd).catch(() => null),
179
215
  ]);
180
216
 
@@ -184,45 +220,27 @@ async function gitStatus(cwd) {
184
220
 
185
221
  const branch = parseBranchFromStatus(branchLine);
186
222
  const tracking = parseTrackingFromStatus(branchLine);
223
+ const detached = branchLine.includes("HEAD detached") || branchLine.includes("no branch");
187
224
  const files = fileLines.map((line) => ({
188
225
  path: line.substring(3).trim(),
189
226
  status: line.substring(0, 2).trim(),
190
227
  }));
191
228
 
192
- const dirty = files.length > 0;
193
- const { ahead, behind } = branchInfo;
194
- const detached = branchLine.includes("HEAD detached") || branchLine.includes("no branch");
195
- const noUpstream = tracking === null && !detached;
196
- const hasHeadCommit = await refExists(cwd, "HEAD").catch(() => false);
197
- const hasPushRemote = await pushRemoteAvailable(cwd, tracking).catch(() => false);
198
- const publishedToRemote = !detached && !!branch && await remoteBranchExists(cwd, branch).catch(() => false);
199
- const localOnlyCommitCount = await countLocalOnlyCommits(cwd, { detached }).catch(() => 0);
200
- const state = computeState(dirty, ahead, behind, detached, noUpstream);
201
- const canPush = hasPushRemote && hasHeadCommit && (ahead > 0 || noUpstream) && !detached;
202
- const diff = await repoDiffTotals(cwd, {
203
- tracking,
204
- fileLines,
205
- }).catch(() => ({ additions: 0, deletions: 0, binaryFiles: 0 }));
206
-
207
229
  return {
208
- isRepo: true,
209
230
  repoRoot,
210
231
  branch,
211
232
  tracking,
212
- dirty,
213
- hasHeadCommit,
214
- hasPushRemote,
215
- ahead,
216
- behind,
217
- localOnlyCommitCount,
218
- state,
219
- canPush,
220
- publishedToRemote,
233
+ detached,
234
+ fileLines,
221
235
  files,
222
- diff,
223
236
  };
224
237
  }
225
238
 
239
+ async function freshBranchInfoForStatus(cwd, snapshot) {
240
+ await refreshStatusUpstreamIfNeeded(cwd, snapshot.tracking, snapshot.repoRoot).catch(() => false);
241
+ return await revListCounts(cwd).catch(() => ({ ahead: 0, behind: 0 }));
242
+ }
243
+
226
244
  async function gitInit(cwd) {
227
245
  if (await isInsideGitWorkTree(cwd)) {
228
246
  throw gitError("already_git_repository", "This folder is already inside a Git repository.");
@@ -2506,6 +2524,52 @@ async function revListCounts(cwd) {
2506
2524
  };
2507
2525
  }
2508
2526
 
2527
+ // Keeps Update eligibility based on the current upstream ref, not stale local fetch data.
2528
+ async function refreshStatusUpstreamIfNeeded(cwd, tracking, repoRoot) {
2529
+ const parsedTracking = parseTrackingRef(tracking);
2530
+ if (!parsedTracking) {
2531
+ return false;
2532
+ }
2533
+
2534
+ const cacheKey = `${repoRoot || cwd}\0${parsedTracking.remote}\0${parsedTracking.branch}`;
2535
+ const now = Date.now();
2536
+ const lastFetchAt = statusUpstreamFetchCache.get(cacheKey) || 0;
2537
+ if (now - lastFetchAt < STATUS_UPSTREAM_FETCH_TTL_MS) {
2538
+ return false;
2539
+ }
2540
+
2541
+ statusUpstreamFetchCache.set(cacheKey, now);
2542
+ if (statusUpstreamFetchCache.size > 200) {
2543
+ statusUpstreamFetchCache.clear();
2544
+ statusUpstreamFetchCache.set(cacheKey, now);
2545
+ }
2546
+
2547
+ await git(
2548
+ cwd,
2549
+ "fetch",
2550
+ "--quiet",
2551
+ parsedTracking.remote,
2552
+ `+refs/heads/${parsedTracking.branch}:refs/remotes/${parsedTracking.remote}/${parsedTracking.branch}`
2553
+ );
2554
+ return true;
2555
+ }
2556
+
2557
+ function parseTrackingRef(tracking) {
2558
+ if (typeof tracking !== "string") {
2559
+ return null;
2560
+ }
2561
+
2562
+ const separatorIndex = tracking.indexOf("/");
2563
+ if (separatorIndex <= 0 || separatorIndex === tracking.length - 1) {
2564
+ return null;
2565
+ }
2566
+
2567
+ return {
2568
+ remote: tracking.slice(0, separatorIndex),
2569
+ branch: tracking.slice(separatorIndex + 1),
2570
+ };
2571
+ }
2572
+
2509
2573
  function parseBranchFromStatus(line) {
2510
2574
  // "## main...origin/main" or "## main" or "## HEAD (no branch)"
2511
2575
  const match = line.match(/^## (.+?)(?:\.{3}|$)/);
@@ -4,10 +4,10 @@
4
4
  // Exports: version comparison + bridge/iPhone compatibility helpers
5
5
  // Depends on: none
6
6
 
7
- const MINIMUM_SUPPORTED_IOS_APP_VERSION = "1.5";
8
- const IOS_APP_COMPATIBILITY_GATE_BRIDGE_VERSION = "1.3.9";
9
- const LEGACY_BRIDGE_VERSION_FOR_IOS_1_0 = "1.3.7";
10
- const LEGACY_BRIDGE_DOWNGRADE_COMMAND = `npm install -g remodex@${LEGACY_BRIDGE_VERSION_FOR_IOS_1_0}`;
7
+ const MINIMUM_SUPPORTED_IOS_APP_VERSION = "2.0";
8
+ const IOS_APP_COMPATIBILITY_GATE_BRIDGE_VERSION = "2.0.0";
9
+ const LEGACY_BRIDGE_VERSION_FOR_IOS_1_X = "1.5.1";
10
+ const LEGACY_BRIDGE_DOWNGRADE_COMMAND = `npm install -g @makerbi/remodex@${LEGACY_BRIDGE_VERSION_FOR_IOS_1_X}`;
11
11
  const NOTICE_BOX_WIDTH = 74;
12
12
 
13
13
  function buildIOSAppCompatibilitySnapshot({
@@ -80,7 +80,7 @@ function buildSnapshot({
80
80
  isCompatible,
81
81
  requiresAppUpdate,
82
82
  minimumSupportedIOSAppVersion: MINIMUM_SUPPORTED_IOS_APP_VERSION,
83
- legacyBridgeVersion: LEGACY_BRIDGE_VERSION_FOR_IOS_1_0,
83
+ legacyBridgeVersion: LEGACY_BRIDGE_VERSION_FOR_IOS_1_X,
84
84
  downgradeCommand: LEGACY_BRIDGE_DOWNGRADE_COMMAND,
85
85
  message,
86
86
  };
@@ -108,7 +108,7 @@ function buildLegacyIOSAppCompatibilityMessage({
108
108
  return `Remodex bridge ${normalizedBridgeVersion} requires Remodex iPhone `
109
109
  + `${MINIMUM_SUPPORTED_IOS_APP_VERSION} or later. `
110
110
  + `Update the iPhone app from the App Store first, or install Remodex bridge `
111
- + `${LEGACY_BRIDGE_VERSION_FOR_IOS_1_0} to keep using iPhone ${normalizedIOSAppVersion}.`;
111
+ + `${LEGACY_BRIDGE_VERSION_FOR_IOS_1_X} to keep using iPhone ${normalizedIOSAppVersion}.`;
112
112
  }
113
113
 
114
114
  function buildCachedIOSAppCompatibilityWarning({
@@ -232,7 +232,7 @@ function splitVersionParts(value) {
232
232
 
233
233
  module.exports = {
234
234
  LEGACY_BRIDGE_DOWNGRADE_COMMAND,
235
- LEGACY_BRIDGE_VERSION_FOR_IOS_1_0,
235
+ LEGACY_BRIDGE_VERSION_FOR_IOS_1_X,
236
236
  IOS_APP_COMPATIBILITY_GATE_BRIDGE_VERSION,
237
237
  MINIMUM_SUPPORTED_IOS_APP_VERSION,
238
238
  buildCachedIOSAppCompatibilityWarning,
@@ -5,13 +5,14 @@
5
5
  // Depends on: child_process, fs, os, path, ./bridge, ./daemon-state, ./codex-desktop-refresher, ./qr, ./secure-device-state
6
6
 
7
7
  const { execFileSync } = require("child_process");
8
+ const { createHash } = require("crypto");
8
9
  const fs = require("fs");
9
10
  const os = require("os");
10
11
  const path = require("path");
11
12
  const { startBridge } = require("./bridge");
12
13
  const { readBridgeConfig } = require("./codex-desktop-refresher");
13
14
  const { printQR } = require("./qr");
14
- const { resetBridgeTrustState } = require("./secure-device-state");
15
+ const { readBridgeDeviceState, resetBridgeTrustState } = require("./secure-device-state");
15
16
  const {
16
17
  clearBridgeStatus,
17
18
  clearPairingSession,
@@ -276,6 +277,7 @@ function getMacOSBridgeServiceStatus({
276
277
  daemonConfig: sanitizedDaemonConfigForStatus(readDaemonConfig({ env, fsImpl })),
277
278
  bridgeStatus: readBridgeStatus({ env, fsImpl }),
278
279
  pairingSession: readPairingSession({ env, fsImpl }),
280
+ trustedDevice: buildTrustedDeviceSummary(readBridgeDeviceState()),
279
281
  stdoutLogPath: resolveBridgeStdoutLogPath({ env }),
280
282
  stderrLogPath: resolveBridgeStderrLogPath({ env }),
281
283
  };
@@ -301,12 +303,18 @@ function printMacOSBridgeServiceStatus(options = {}) {
301
303
  const bridgeState = status.bridgeStatus?.state || "unknown";
302
304
  const connectionStatus = status.bridgeStatus?.connectionStatus || "unknown";
303
305
  const pairingCreatedAt = status.pairingSession?.createdAt || "none";
306
+ const activeDevice = status.bridgeStatus?.activeDevice || status.bridgeStatus?.activePhone;
307
+ const trustedPhoneCount = status.trustedDevice?.trustedPhoneCount || 0;
308
+ const activeDeviceName = formatDeviceKind(activeDevice?.deviceKind) || "device";
309
+ const trustedDeviceName = formatDeviceKind(status.trustedDevice?.lastSeenDeviceKind) || "device";
304
310
  console.log(`[remodex] Service label: ${status.label}`);
305
311
  console.log(`[remodex] Installed: ${status.installed ? "yes" : "no"}`);
306
312
  console.log(`[remodex] Launchd loaded: ${status.launchdLoaded ? "yes" : "no"}`);
307
313
  console.log(`[remodex] PID: ${status.launchdPid || status.bridgeStatus?.pid || "unknown"}`);
308
314
  console.log(`[remodex] Bridge state: ${bridgeState}`);
309
315
  console.log(`[remodex] Connection: ${connectionStatus}`);
316
+ console.log(`[remodex] Active ${activeDeviceName}: ${activeDevice?.connected ? activeDevice.phoneFingerprint || "yes" : "no"}`);
317
+ console.log(`[remodex] Trusted ${trustedDeviceName}: ${trustedPhoneCount > 0 ? "yes" : "no"}`);
310
318
  console.log(`[remodex] Pairing payload: ${pairingCreatedAt}`);
311
319
  console.log(`[remodex] Stdout log: ${status.stdoutLogPath}`);
312
320
  console.log(`[remodex] Stderr log: ${status.stderrLogPath}`);
@@ -628,7 +636,44 @@ function normalizeNonEmptyString(value) {
628
636
  return typeof value === "string" && value.trim() ? value.trim() : "";
629
637
  }
630
638
 
639
+ function buildTrustedDeviceSummary(deviceState) {
640
+ const trustedPhoneEntries = Object.entries(deviceState?.trustedPhones || {})
641
+ .filter(([phoneDeviceId, publicKey]) => normalizeNonEmptyString(phoneDeviceId) && normalizeNonEmptyString(publicKey));
642
+ const firstTrustedPhoneId = trustedPhoneEntries[0]?.[0] || "";
643
+ const lastSeenPhoneAppVersion = normalizeNonEmptyString(deviceState?.lastSeenPhoneAppVersion) || null;
644
+ return {
645
+ macDeviceFingerprint: shortFingerprint(deviceState?.macDeviceId),
646
+ trustedPhoneCount: trustedPhoneEntries.length,
647
+ trustedPhoneFingerprint: shortFingerprint(firstTrustedPhoneId),
648
+ lastSeenDeviceKind: normalizeNonEmptyString(deviceState?.lastSeenDeviceKind) || (lastSeenPhoneAppVersion ? "iphone" : null),
649
+ lastSeenPhoneAppVersion,
650
+ };
651
+ }
652
+
653
+ function formatDeviceKind(deviceKind) {
654
+ const normalized = normalizeNonEmptyString(deviceKind).toLowerCase();
655
+ if (normalized === "iphone") {
656
+ return "iPhone";
657
+ }
658
+ if (normalized === "android") {
659
+ return "Android";
660
+ }
661
+ if (normalized === "mac") {
662
+ return "Mac";
663
+ }
664
+ return "";
665
+ }
666
+
667
+ function shortFingerprint(value) {
668
+ const normalized = normalizeNonEmptyString(value);
669
+ if (!normalized) {
670
+ return null;
671
+ }
672
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 8);
673
+ }
674
+
631
675
  module.exports = {
676
+ buildTrustedDeviceSummary,
632
677
  buildLaunchAgentPlist,
633
678
  getMacOSBridgeServiceStatus,
634
679
  mergeBridgeStatusForDaemon,
package/src/qr.js CHANGED
@@ -9,7 +9,6 @@ const qrcode = require("qrcode-terminal");
9
9
 
10
10
  const SHORT_PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
11
11
  const SHORT_PAIRING_CODE_LENGTH = 10;
12
-
13
12
  // Generates a short-lived human-friendly pairing token for reconnect flows.
14
13
  function createShortPairingCode({
15
14
  length = SHORT_PAIRING_CODE_LENGTH,
@@ -50,7 +49,7 @@ function printQR(pairingSessionOrPayload, options = {}) {
50
49
  console.log("\nScan this QR with the iPhone:\n");
51
50
  qrcode.generate(payload, { small: true });
52
51
  if (pairingCode) {
53
- console.log("Or paste this pairing code in the iPhone app:\n");
52
+ console.log("Or enter this pairing code in the iPhone app:\n");
54
53
  console.log(pairingCode);
55
54
  }
56
55
  console.log(`\nSession ID: ${sessionIdShort || "(none)"}`);
@@ -58,9 +57,7 @@ function printQR(pairingSessionOrPayload, options = {}) {
58
57
  console.log(`Expires: ${new Date(pairingPayload.expiresAt).toISOString()}\n`);
59
58
 
60
59
  if (shouldPrintPairingJson({ env, explicitValue: options.printPairingJson })) {
61
- // Opt-in only: this is the same bearer-like payload as the QR scan target.
62
- console.log("Pairing JSON (debug only; same sensitive bytes as the QR):\n");
63
- console.log(`${payload}\n`);
60
+ console.log("Pairing JSON debug output is disabled because the payload contains private relay metadata.\n");
64
61
  }
65
62
  }
66
63
 
@@ -18,6 +18,10 @@ const DEFAULT_POLL_INTERVAL_MS = 700;
18
18
  const DEFAULT_LOOKUP_TIMEOUT_MS = 5_000;
19
19
  const DEFAULT_IDLE_TIMEOUT_MS = 60_000;
20
20
  const DEFAULT_ACTIVITY_HEARTBEAT_MS = 5_000;
21
+ const DEFAULT_BOOTSTRAP_REPLAY_BATCH_INTERVAL_MS = 0;
22
+ const BOOTSTRAP_REPLAY_CHUNK_SIZE = 50;
23
+ const BOOTSTRAP_REPLAY_CHUNK_MAX_BYTES = 128 * 1024;
24
+ const BOOTSTRAP_REPLAY_CACHE_LIMIT = 32;
21
25
  const DESKTOP_RESUME_METHODS = new Set(["thread/read", "thread/resume"]);
22
26
 
23
27
  // Observes desktop-authored rollout files and replays the currently active run as
@@ -29,12 +33,16 @@ function createRolloutLiveMirrorController({
29
33
  now = () => Date.now(),
30
34
  setIntervalFn = setInterval,
31
35
  clearIntervalFn = clearInterval,
36
+ setTimeoutFn = setTimeout,
37
+ clearTimeoutFn = clearTimeout,
32
38
  pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
33
39
  lookupTimeoutMs = DEFAULT_LOOKUP_TIMEOUT_MS,
34
40
  idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
35
41
  activityHeartbeatMs = DEFAULT_ACTIVITY_HEARTBEAT_MS,
42
+ bootstrapReplayBatchIntervalMs = DEFAULT_BOOTSTRAP_REPLAY_BATCH_INTERVAL_MS,
36
43
  } = {}) {
37
44
  const mirrorsByThreadId = new Map();
45
+ const bootstrapReplayCache = new Map();
38
46
 
39
47
  function observeInbound(rawMessage) {
40
48
  const request = safeParseJSON(rawMessage);
@@ -63,10 +71,14 @@ function createRolloutLiveMirrorController({
63
71
  now,
64
72
  setIntervalFn,
65
73
  clearIntervalFn,
74
+ setTimeoutFn,
75
+ clearTimeoutFn,
66
76
  pollIntervalMs,
67
77
  lookupTimeoutMs,
68
78
  idleTimeoutMs,
69
79
  activityHeartbeatMs,
80
+ bootstrapReplayBatchIntervalMs,
81
+ bootstrapReplayCache,
70
82
  onStop() {
71
83
  if (mirrorsByThreadId.get(threadId) === mirror) {
72
84
  mirrorsByThreadId.delete(threadId);
@@ -99,14 +111,19 @@ function createThreadRolloutLiveMirror({
99
111
  now,
100
112
  setIntervalFn,
101
113
  clearIntervalFn,
114
+ setTimeoutFn,
115
+ clearTimeoutFn,
102
116
  pollIntervalMs,
103
117
  lookupTimeoutMs,
104
118
  idleTimeoutMs,
105
119
  activityHeartbeatMs,
120
+ bootstrapReplayBatchIntervalMs,
121
+ bootstrapReplayCache,
106
122
  onStop = () => {},
107
123
  }) {
108
124
  const startedAt = now();
109
125
  const state = createMirrorState(threadId);
126
+ const bootstrapReplayTimeouts = new Set();
110
127
 
111
128
  let isStopped = false;
112
129
  let rolloutPath = null;
@@ -151,6 +168,10 @@ function createThreadRolloutLiveMirror({
151
168
  state,
152
169
  fsModule,
153
170
  sendApplicationResponse,
171
+ bootstrapReplayCache,
172
+ setTimeoutFn,
173
+ bootstrapReplayBatchIntervalMs,
174
+ pendingTimeouts: bootstrapReplayTimeouts,
154
175
  });
155
176
  lastSize = fileSize;
156
177
  lastActivityAt = currentTime;
@@ -216,6 +237,10 @@ function createThreadRolloutLiveMirror({
216
237
 
217
238
  isStopped = true;
218
239
  clearIntervalFn(intervalId);
240
+ for (const timeout of bootstrapReplayTimeouts) {
241
+ clearTimeoutFn(timeout);
242
+ }
243
+ bootstrapReplayTimeouts.clear();
219
244
  onStop();
220
245
  }
221
246
 
@@ -231,6 +256,10 @@ function bootstrapFromExistingRollout({
231
256
  state,
232
257
  fsModule,
233
258
  sendApplicationResponse,
259
+ bootstrapReplayCache,
260
+ setTimeoutFn = setTimeout,
261
+ bootstrapReplayBatchIntervalMs = DEFAULT_BOOTSTRAP_REPLAY_BATCH_INTERVAL_MS,
262
+ pendingTimeouts,
234
263
  }) {
235
264
  const initialContents = readFileSlice(rolloutPath, 0, fileSize, fsModule);
236
265
  if (!initialContents) {
@@ -261,7 +290,7 @@ function bootstrapFromExistingRollout({
261
290
  const taskEventType = parsed?.type === "event_msg"
262
291
  ? readString(parsed?.payload?.type)
263
292
  : "";
264
- if (taskEventType === "user_message") {
293
+ if (taskEventType === "user_message" && !insideActiveRun) {
265
294
  pendingUserPreludeLine = line;
266
295
  }
267
296
  if (taskEventType === "task_started") {
@@ -273,6 +302,7 @@ function bootstrapFromExistingRollout({
273
302
  if (pendingUserPreludeLine) {
274
303
  activeRunLines.push(pendingUserPreludeLine);
275
304
  }
305
+ pendingUserPreludeLine = null;
276
306
  activeRunLines.push(line);
277
307
  continue;
278
308
  }
@@ -282,7 +312,7 @@ function bootstrapFromExistingRollout({
282
312
  }
283
313
 
284
314
  activeRunLines.push(line);
285
- if (taskEventType === "task_complete") {
315
+ if (isRolloutTerminalTaskEvent(taskEventType)) {
286
316
  insideActiveRun = false;
287
317
  activeTurnId = "";
288
318
  activeRunLines.length = 0;
@@ -296,7 +326,33 @@ function bootstrapFromExistingRollout({
296
326
  }
297
327
 
298
328
  state.isDesktopOrigin = true;
299
- processRolloutLines(activeRunLines, state, sendApplicationResponse);
329
+ const replayId = bootstrapReplayId(state.threadId, rolloutPath, initialContents);
330
+ const cachedBatches = bootstrapReplayCache?.get(replayId);
331
+ const replayNotifications = cachedBatches ? null : [];
332
+ // Rebuild local mirror state from the already-written run while collecting
333
+ // every notification into bounded catch-up batches for the phone.
334
+ processRolloutLines(activeRunLines, state, (rawNotification) => {
335
+ if (cachedBatches) {
336
+ return;
337
+ }
338
+ const notification = safeParseJSON(rawNotification);
339
+ if (notification) {
340
+ replayNotifications.push(notification);
341
+ }
342
+ });
343
+ const batches = cachedBatches || chunkBootstrapReplayNotifications(replayNotifications);
344
+ rememberBootstrapReplayBatches(bootstrapReplayCache, replayId, batches);
345
+ emitBootstrapReplayBatches(state, replayId, batches, sendApplicationResponse, {
346
+ setTimeoutFn,
347
+ batchIntervalMs: bootstrapReplayBatchIntervalMs,
348
+ pendingTimeouts,
349
+ });
350
+ }
351
+
352
+ function isRolloutTerminalTaskEvent(eventType) {
353
+ return eventType === "task_complete"
354
+ || eventType === "turn_aborted"
355
+ || eventType === "task_aborted";
300
356
  }
301
357
 
302
358
  function processRolloutLines(lines, state, sendApplicationResponse) {
@@ -322,6 +378,106 @@ function processRolloutLines(lines, state, sendApplicationResponse) {
322
378
  }
323
379
  }
324
380
 
381
+ function emitBootstrapReplayBatches(
382
+ state,
383
+ replayId,
384
+ batches,
385
+ sendApplicationResponse,
386
+ {
387
+ setTimeoutFn = setTimeout,
388
+ batchIntervalMs = DEFAULT_BOOTSTRAP_REPLAY_BATCH_INTERVAL_MS,
389
+ pendingTimeouts,
390
+ } = {}
391
+ ) {
392
+ if (!Array.isArray(batches) || batches.length === 0) {
393
+ return;
394
+ }
395
+
396
+ const sendBatch = (batch, batchIndex) => {
397
+ sendApplicationResponse(JSON.stringify(createNotification("remodex/rollout/bootstrapReplay", {
398
+ threadId: state.threadId,
399
+ replayId,
400
+ batchIndex,
401
+ batchCount: batches.length,
402
+ notifications: batch,
403
+ })));
404
+ };
405
+
406
+ for (let batchIndex = 0; batchIndex < batches.length; batchIndex += 1) {
407
+ const batch = batches[batchIndex];
408
+ if (batchIndex === 0) {
409
+ sendBatch(batch, batchIndex);
410
+ continue;
411
+ }
412
+
413
+ if (batchIntervalMs <= 0) {
414
+ sendBatch(batch, batchIndex);
415
+ continue;
416
+ }
417
+
418
+ const timeout = setTimeoutFn(() => {
419
+ pendingTimeouts?.delete(timeout);
420
+ sendBatch(batch, batchIndex);
421
+ }, batchIndex * batchIntervalMs);
422
+ pendingTimeouts?.add(timeout);
423
+ }
424
+ }
425
+
426
+ function chunkBootstrapReplayNotifications(notifications) {
427
+ const batches = [];
428
+ let batch = [];
429
+ let batchBytes = 0;
430
+
431
+ for (const notification of notifications) {
432
+ const notificationBytes = Buffer.byteLength(JSON.stringify(notification), "utf8");
433
+ const shouldStartNextBatch = batch.length > 0
434
+ && (
435
+ batch.length >= BOOTSTRAP_REPLAY_CHUNK_SIZE
436
+ || batchBytes + notificationBytes > BOOTSTRAP_REPLAY_CHUNK_MAX_BYTES
437
+ );
438
+ if (shouldStartNextBatch) {
439
+ batches.push(batch);
440
+ batch = [];
441
+ batchBytes = 0;
442
+ }
443
+
444
+ batch.push(notification);
445
+ batchBytes += notificationBytes;
446
+ }
447
+
448
+ if (batch.length > 0) {
449
+ batches.push(batch);
450
+ }
451
+ return batches;
452
+ }
453
+
454
+ function bootstrapReplayId(threadId, rolloutPath, contents) {
455
+ return crypto
456
+ .createHash("sha256")
457
+ .update(readString(threadId))
458
+ .update("\0")
459
+ .update(readString(rolloutPath))
460
+ .update("\0")
461
+ .update(String(contents || ""))
462
+ .digest("hex")
463
+ .slice(0, 24);
464
+ }
465
+
466
+ function rememberBootstrapReplayBatches(cache, replayId, batches) {
467
+ if (!cache || cache.has(replayId)) {
468
+ return;
469
+ }
470
+
471
+ cache.set(replayId, batches);
472
+ while (cache.size > BOOTSTRAP_REPLAY_CACHE_LIMIT) {
473
+ const oldestKey = cache.keys().next().value;
474
+ if (!oldestKey) {
475
+ break;
476
+ }
477
+ cache.delete(oldestKey);
478
+ }
479
+ }
480
+
325
481
  function synthesizeNotificationsFromRolloutEntry(entry, state) {
326
482
  if (entry?.type === "session_meta") {
327
483
  populateSessionMetaState(state, entry.payload);
@@ -1,7 +1,7 @@
1
1
  // FILE: secure-device-state.js
2
- // Purpose: Persists canonical bridge identity, trusted-phone state, and last seen iPhone app version for local QR pairing.
2
+ // Purpose: Persists canonical bridge identity, trusted mobile state, and last seen companion metadata for local QR pairing.
3
3
  // Layer: CLI helper
4
- // Exports: loadOrCreateBridgeDeviceState, readBridgeDeviceState, resetBridgeDeviceState, resetBridgeTrustState, rememberTrustedPhone, rememberLastSeenPhoneAppVersion, getTrustedPhonePublicKey, resolveBridgeRelaySession
4
+ // Exports: loadOrCreateBridgeDeviceState, readBridgeDeviceState, resetBridgeDeviceState, resetBridgeTrustState, rememberTrustedPhone, rememberLastSeenPhoneAppVersion, rememberLastSeenClientDeviceKind, getTrustedPhonePublicKey, resolveBridgeRelaySession
5
5
  // Depends on: fs, os, path, crypto, child_process
6
6
 
7
7
  const fs = require("fs");
@@ -153,6 +153,22 @@ function rememberLastSeenPhoneAppVersion(state, phoneAppVersion, { persist = tru
153
153
  return nextState;
154
154
  }
155
155
 
156
+ function rememberLastSeenClientDeviceKind(state, deviceKind, { persist = true } = {}) {
157
+ const normalizedDeviceKind = normalizeDeviceKind(deviceKind);
158
+ if (!normalizedDeviceKind) {
159
+ return state;
160
+ }
161
+
162
+ const nextState = normalizeBridgeDeviceState({
163
+ ...state,
164
+ lastSeenDeviceKind: normalizedDeviceKind,
165
+ });
166
+ if (persist) {
167
+ writeBridgeDeviceState(nextState);
168
+ }
169
+ return nextState;
170
+ }
171
+
156
172
  function getTrustedPhonePublicKey(state, phoneDeviceId) {
157
173
  const normalizedDeviceId = normalizeNonEmptyString(phoneDeviceId);
158
174
  if (!normalizedDeviceId) {
@@ -176,6 +192,7 @@ function createBridgeDeviceState() {
176
192
  macIdentityPublicKey: base64UrlToBase64(publicJwk.x),
177
193
  macIdentityPrivateKey: base64UrlToBase64(privateJwk.d),
178
194
  trustedPhones: {},
195
+ lastSeenDeviceKind: null,
179
196
  lastSeenPhoneAppVersion: null,
180
197
  };
181
198
  }
@@ -385,6 +402,8 @@ function normalizeBridgeDeviceState(rawState) {
385
402
  const macIdentityPublicKey = normalizeNonEmptyString(rawState?.macIdentityPublicKey);
386
403
  const macIdentityPrivateKey = normalizeNonEmptyString(rawState?.macIdentityPrivateKey);
387
404
  const lastSeenPhoneAppVersion = normalizeNonEmptyString(rawState?.lastSeenPhoneAppVersion) || null;
405
+ const lastSeenDeviceKind = normalizeDeviceKind(rawState?.lastSeenDeviceKind)
406
+ || inferLegacyDeviceKind({ lastSeenPhoneAppVersion });
388
407
 
389
408
  if (!macDeviceId || !macIdentityPublicKey || !macIdentityPrivateKey) {
390
409
  throw new Error("Bridge device state is incomplete");
@@ -408,6 +427,7 @@ function normalizeBridgeDeviceState(rawState) {
408
427
  macIdentityPublicKey,
409
428
  macIdentityPrivateKey,
410
429
  trustedPhones,
430
+ lastSeenDeviceKind,
411
431
  lastSeenPhoneAppVersion,
412
432
  };
413
433
  }
@@ -430,6 +450,24 @@ function recoverBridgeDeviceIdentity(state, { fallbackState = null } = {}) {
430
450
  return nextState;
431
451
  }
432
452
 
453
+ function normalizeDeviceKind(value) {
454
+ const normalized = normalizeNonEmptyString(value).toLowerCase();
455
+ if (normalized === "ios" || normalized === "iphone") {
456
+ return "iphone";
457
+ }
458
+ if (normalized === "android") {
459
+ return "android";
460
+ }
461
+ if (normalized === "mac" || normalized === "macos" || normalized === "darwin") {
462
+ return "mac";
463
+ }
464
+ return normalized || null;
465
+ }
466
+
467
+ function inferLegacyDeviceKind({ lastSeenPhoneAppVersion } = {}) {
468
+ return lastSeenPhoneAppVersion ? "iphone" : null;
469
+ }
470
+
433
471
  function bridgeStatesEqual(left, right) {
434
472
  return JSON.stringify(left) === JSON.stringify(right);
435
473
  }
@@ -475,6 +513,7 @@ module.exports = {
475
513
  getTrustedPhonePublicKey,
476
514
  loadOrCreateBridgeDeviceState,
477
515
  readBridgeDeviceState,
516
+ rememberLastSeenClientDeviceKind,
478
517
  rememberLastSeenPhoneAppVersion,
479
518
  rememberTrustedPhone,
480
519
  resetBridgeDeviceState,
@@ -39,6 +39,7 @@ function createBridgeSecureTransport({
39
39
  deviceState,
40
40
  displayName = "",
41
41
  onTrustedPhoneUpdate = null,
42
+ onSecureSessionReady = null,
42
43
  persistTrustedPhone = true,
43
44
  }) {
44
45
  let currentDeviceState = deviceState;
@@ -382,7 +383,13 @@ function createBridgeSecureTransport({
382
383
  activeSession.firstOutboundSeq = nextBridgeOutboundSeq;
383
384
  }
384
385
 
386
+ const completedHandshakeMode = pendingHandshake.handshakeMode;
385
387
  pendingHandshake = null;
388
+ onSecureSessionReady?.({
389
+ phoneDeviceId: activeSession.phoneDeviceId,
390
+ handshakeMode: completedHandshakeMode,
391
+ keyEpoch: activeSession.keyEpoch,
392
+ });
386
393
  sendControlMessage({
387
394
  kind: "secureReady",
388
395
  sessionId,