@makerbi/remodex 1.5.8 → 2.0.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.
package/bin/remodex.js CHANGED
@@ -120,7 +120,7 @@ async function main({
120
120
  ok: true,
121
121
  currentVersion: version,
122
122
  plistPath: result?.plistPath,
123
- pairingSession: result?.pairingSession,
123
+ pairingSession: sanitizePairingSessionForOutput(result?.pairingSession),
124
124
  },
125
125
  message: "[remodex] macOS bridge service is running.",
126
126
  jsonOutput,
@@ -141,7 +141,7 @@ async function main({
141
141
  ok: true,
142
142
  currentVersion: version,
143
143
  plistPath: result?.plistPath,
144
- pairingSession: result?.pairingSession,
144
+ pairingSession: sanitizePairingSessionForOutput(result?.pairingSession),
145
145
  },
146
146
  message: "[remodex] macOS bridge service restarted.",
147
147
  jsonOutput,
@@ -156,10 +156,20 @@ async function main({
156
156
  consoleImpl,
157
157
  exitImpl,
158
158
  });
159
- consoleImpl.log("[remodex] Refreshing bridge pairing QR...");
160
159
  const result = await deps.startMacOSBridgeService({
161
160
  waitForPairing: true,
162
161
  });
162
+ if (jsonOutput) {
163
+ emitJson({
164
+ ok: true,
165
+ currentVersion: version,
166
+ plistPath: result?.plistPath,
167
+ pairingSession: result?.pairingSession,
168
+ });
169
+ return;
170
+ }
171
+
172
+ consoleImpl.log("[remodex] Refreshing bridge pairing QR...");
163
173
  deps.printMacOSBridgePairingQr({
164
174
  pairingSession: result.pairingSession,
165
175
  });
@@ -193,7 +203,7 @@ async function main({
193
203
  });
194
204
  if (jsonOutput) {
195
205
  emitJson({
196
- ...deps.getMacOSBridgeServiceStatus(),
206
+ ...sanitizeBridgeServiceStatusForOutput(deps.getMacOSBridgeServiceStatus()),
197
207
  currentVersion: version,
198
208
  });
199
209
  return;
@@ -272,7 +282,7 @@ async function main({
272
282
  "Usage: remodex up | remodex run [--extra-device|--extra-devices=N] | remodex start | remodex restart | "
273
283
  + "remodex qr | remodex pair | remodex stop | remodex status | "
274
284
  + "remodex reset-pairing | remodex resume | remodex watch [threadId] | remodex --version | "
275
- + "append --json to start/restart/stop/status/reset-pairing/resume for machine-readable output"
285
+ + "append --json to start/restart/qr/pair/stop/status/reset-pairing/resume for machine-readable output"
276
286
  );
277
287
  exitImpl(1);
278
288
  }
@@ -361,6 +371,47 @@ function emitJson(payload) {
361
371
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
362
372
  }
363
373
 
374
+ // Keeps machine-readable CLI output useful without exposing relay endpoints or live pairing payloads.
375
+ function sanitizeBridgeServiceStatusForOutput(status = {}) {
376
+ const sanitized = {
377
+ ...status,
378
+ daemonConfig: sanitizeDaemonConfigForOutput(status.daemonConfig),
379
+ pairingSession: sanitizePairingSessionForOutput(status.pairingSession),
380
+ };
381
+ return sanitized;
382
+ }
383
+
384
+ function sanitizeDaemonConfigForOutput(config) {
385
+ if (!config || typeof config !== "object") {
386
+ return config || null;
387
+ }
388
+ const { relayUrl, pushServiceUrl, ...rest } = config;
389
+ return {
390
+ ...rest,
391
+ relayConfigured: Boolean(relayUrl),
392
+ pushServiceConfigured: Boolean(pushServiceUrl),
393
+ };
394
+ }
395
+
396
+ function sanitizePairingSessionForOutput(pairingSession) {
397
+ if (!pairingSession || typeof pairingSession !== "object") {
398
+ return pairingSession || null;
399
+ }
400
+ const payload = pairingSession.pairingPayload || {};
401
+ return {
402
+ createdAt: pairingSession.createdAt,
403
+ pairingCode: pairingSession.pairingCode,
404
+ pairingPayload: {
405
+ v: payload.v,
406
+ expiresAt: payload.expiresAt,
407
+ hasRelay: Boolean(payload.relay),
408
+ hasSessionId: Boolean(payload.sessionId),
409
+ hasMacIdentityPublicKey: Boolean(payload.macIdentityPublicKey),
410
+ displayName: payload.displayName,
411
+ },
412
+ };
413
+ }
414
+
364
415
  function assertMacOSCommand(name, {
365
416
  platform = process.platform,
366
417
  consoleImpl = console,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makerbi/remodex",
3
- "version": "1.5.8",
3
+ "version": "2.0.0",
4
4
  "description": "Local bridge between Codex and the Remodex mobile app. Run `remodex up` to start.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/bridge.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // Depends on: ws, crypto, os, ./bridge-status, ./codex-desktop-refresher, ./codex-transport, ./rollout-watch, ./voice-handler
6
6
 
7
7
  const WebSocket = require("ws");
8
- const { randomBytes, randomUUID } = require("crypto");
8
+ const { createHash, randomBytes, randomUUID } = require("crypto");
9
9
  const { execFile, spawn } = require("child_process");
10
10
  const fs = require("fs");
11
11
  const path = require("path");
@@ -46,6 +46,7 @@ const { createPushNotificationTracker } = require("./push-notification-tracker")
46
46
  const { resolveCodexGeneratedImagesRoot } = require("./codex-home");
47
47
  const {
48
48
  loadOrCreateBridgeDeviceState,
49
+ rememberLastSeenClientDeviceKind,
49
50
  rememberLastSeenPhoneAppVersion,
50
51
  resolveBridgeRelaySession,
51
52
  } = require("./secure-device-state");
@@ -238,6 +239,7 @@ function startBridge({
238
239
  requestId: null,
239
240
  startedAt: 0,
240
241
  };
242
+ let activePhoneSummary = null;
241
243
  const secureTransport = createBridgeSecureTransport({
242
244
  sessionId,
243
245
  relayUrl: relayBaseUrl,
@@ -247,6 +249,13 @@ function startBridge({
247
249
  deviceState = nextDeviceState;
248
250
  sendRelayRegistrationUpdate(nextDeviceState);
249
251
  },
252
+ onSecureSessionReady(session) {
253
+ activePhoneSummary = buildActivePhoneSummary(session, deviceState);
254
+ const lastPublishedBridgeStatus = bridgeStatusPublisher.latest();
255
+ if (lastPublishedBridgeStatus) {
256
+ publishBridgeStatus(lastPublishedBridgeStatus);
257
+ }
258
+ },
250
259
  });
251
260
  let primaryRelayChannel = null;
252
261
  // Keeps one stable sender identity across reconnects so buffered replay state
@@ -420,6 +429,9 @@ function startBridge({
420
429
 
421
430
  lastConnectionStatus = status;
422
431
  lastConnectionError = lastError;
432
+ if (status !== "connected") {
433
+ activePhoneSummary = null;
434
+ }
423
435
  publishBridgeStatus({
424
436
  state: "running",
425
437
  connectionStatus: status,
@@ -1529,6 +1541,20 @@ function startBridge({
1529
1541
  function bridgeManagedInitializeCompatibilityError(params) {
1530
1542
  const clientInfo = params && typeof params === "object" ? params.clientInfo : null;
1531
1543
  const clientName = normalizeNonEmptyString(clientInfo?.name);
1544
+ const clientDeviceKind = classifyClientDeviceKind(clientName);
1545
+ if (clientDeviceKind) {
1546
+ deviceState = rememberLastSeenClientDeviceKind(deviceState, clientDeviceKind);
1547
+ if (activePhoneSummary?.connected) {
1548
+ activePhoneSummary = {
1549
+ ...activePhoneSummary,
1550
+ deviceKind: clientDeviceKind,
1551
+ };
1552
+ const lastPublishedBridgeStatus = bridgeStatusPublisher.latest();
1553
+ if (lastPublishedBridgeStatus) {
1554
+ publishBridgeStatus(lastPublishedBridgeStatus);
1555
+ }
1556
+ }
1557
+ }
1532
1558
  if (clientName !== "codexmobile_ios") {
1533
1559
  return null;
1534
1560
  }
@@ -1697,7 +1723,11 @@ function startBridge({
1697
1723
  }
1698
1724
 
1699
1725
  function publishBridgeStatus(status) {
1700
- bridgeStatusPublisher.publish(status);
1726
+ bridgeStatusPublisher.publish({
1727
+ ...status,
1728
+ activeDevice: activePhoneSummary,
1729
+ activePhone: activePhoneSummary,
1730
+ });
1701
1731
  }
1702
1732
 
1703
1733
  // Refreshes the relay's trusted-mac index after the QR bootstrap locks in a phone identity.
@@ -1950,6 +1980,47 @@ function buildMacRegistration(deviceState, pairingSession) {
1950
1980
  };
1951
1981
  }
1952
1982
 
1983
+ function buildActivePhoneSummary(session, deviceState = null) {
1984
+ const phoneFingerprint = shortFingerprint(session?.phoneDeviceId);
1985
+ if (!phoneFingerprint) {
1986
+ return null;
1987
+ }
1988
+
1989
+ return {
1990
+ connected: true,
1991
+ phoneFingerprint,
1992
+ deviceKind: normalizeNonEmptyString(deviceState?.lastSeenDeviceKind) || null,
1993
+ handshakeMode: normalizeNonEmptyString(session?.handshakeMode) || null,
1994
+ keyEpoch: Number.isFinite(session?.keyEpoch) ? session.keyEpoch : null,
1995
+ updatedAt: new Date().toISOString(),
1996
+ };
1997
+ }
1998
+
1999
+ function classifyClientDeviceKind(clientName) {
2000
+ const normalized = normalizeNonEmptyString(clientName).toLowerCase();
2001
+ if (!normalized) {
2002
+ return null;
2003
+ }
2004
+ if (normalized.includes("android")) {
2005
+ return "android";
2006
+ }
2007
+ if (normalized.includes("ios") || normalized.includes("iphone")) {
2008
+ return "iphone";
2009
+ }
2010
+ if (normalized.includes("macos") || normalized.includes("mac")) {
2011
+ return "mac";
2012
+ }
2013
+ return null;
2014
+ }
2015
+
2016
+ function shortFingerprint(value) {
2017
+ const normalized = normalizeNonEmptyString(value);
2018
+ if (!normalized) {
2019
+ return null;
2020
+ }
2021
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 8);
2022
+ }
2023
+
1953
2024
  function shutdown(codex, getSocket, beforeExit = () => {}, { exitCode = 0 } = {}) {
1954
2025
  beforeExit();
1955
2026
 
@@ -2553,6 +2624,10 @@ function compactEmergencySingleTurnForRelay(turn, maxChars, maxItems) {
2553
2624
  "created_at",
2554
2625
  "completedAt",
2555
2626
  "completed_at",
2627
+ "timeZoneIdentifier",
2628
+ "timeZone",
2629
+ "timezone",
2630
+ "time_zone",
2556
2631
  "status",
2557
2632
  "role",
2558
2633
  "kind",
@@ -3822,8 +3897,16 @@ function compactHistoryItemForRelay(item, maxChars) {
3822
3897
  turn_id: typeof item?.turn_id === "string" ? item.turn_id : undefined,
3823
3898
  createdAt: relayScalarHistoryMetadata(item?.createdAt),
3824
3899
  created_at: relayScalarHistoryMetadata(item?.created_at),
3900
+ startedAt: relayScalarHistoryMetadata(item?.startedAt),
3901
+ started_at: relayScalarHistoryMetadata(item?.started_at),
3902
+ completedAt: relayScalarHistoryMetadata(item?.completedAt),
3903
+ completed_at: relayScalarHistoryMetadata(item?.completed_at),
3825
3904
  timestamp: relayScalarHistoryMetadata(item?.timestamp),
3826
3905
  time: relayScalarHistoryMetadata(item?.time),
3906
+ timeZoneIdentifier: relayScalarHistoryMetadata(item?.timeZoneIdentifier),
3907
+ timeZone: relayScalarHistoryMetadata(item?.timeZone),
3908
+ timezone: relayScalarHistoryMetadata(item?.timezone),
3909
+ time_zone: relayScalarHistoryMetadata(item?.time_zone),
3827
3910
  relayPayloadTruncated: true,
3828
3911
  };
3829
3912
  const tailText = maxChars > 0 ? firstRelayTextTail(item, maxChars) : "";
@@ -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
 
@@ -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,
@@ -1,5 +1,6 @@
1
1
  // FILE: session-jsonl-history.js
2
- // Purpose: Reconstructs a small thread/turns/list page from local Codex session JSONL files.
2
+ // Purpose: Reconstructs a small thread/turns/list page from local Codex session JSONL files,
3
+ // including desktop-local timestamp metadata for mobile history rendering.
3
4
 
4
5
  const fs = require("fs");
5
6
  const { buildApplyPatchFileChangeItem } = require("./apply-patch-changes");
@@ -83,6 +84,7 @@ function parseSessionJsonlTurns(content, { threadId = "" } = {}) {
83
84
  let activeTurnId = "";
84
85
  let sessionThreadId = normalizeString(threadId);
85
86
  let sessionCwd = "";
87
+ let sessionTimeZone = "";
86
88
  const skippedCallIds = new Set();
87
89
  const toolCallsByCallId = new Map();
88
90
  const pendingUserMessages = [];
@@ -115,6 +117,26 @@ function parseSessionJsonlTurns(content, { threadId = "" } = {}) {
115
117
  || normalizeString(payload?.thread_id)
116
118
  || normalizeString(payload?.threadId);
117
119
  sessionCwd ||= normalizeString(payload?.cwd);
120
+ sessionTimeZone ||= normalizeString(payload?.timezone)
121
+ || normalizeString(payload?.timeZone)
122
+ || normalizeString(payload?.time_zone);
123
+ continue;
124
+ }
125
+
126
+ if (entry?.type === "turn_context") {
127
+ const payload = objectValue(entry.payload);
128
+ sessionCwd = normalizeString(payload?.cwd) || sessionCwd;
129
+ sessionTimeZone = normalizeString(payload?.timezone)
130
+ || normalizeString(payload?.timeZone)
131
+ || normalizeString(payload?.time_zone)
132
+ || sessionTimeZone;
133
+ activeTurnId = normalizeString(payload?.turn_id)
134
+ || normalizeString(payload?.turnId)
135
+ || activeTurnId;
136
+ if (activeTurnId) {
137
+ const turn = ensureTurn(turns, turnsById, activeTurnId, sessionThreadId, entry.timestamp);
138
+ applyHistoryTimeZone(turn, sessionTimeZone);
139
+ }
118
140
  continue;
119
141
  }
120
142
 
@@ -127,6 +149,7 @@ function parseSessionJsonlTurns(content, { threadId = "" } = {}) {
127
149
  || activeTurnId
128
150
  || `turn-line-${index + 1}`;
129
151
  const turn = ensureTurn(turns, turnsById, activeTurnId, sessionThreadId, entry.timestamp);
152
+ applyHistoryTimeZone(turn, sessionTimeZone);
130
153
  flushPendingUserMessagesToTurn(turn, pendingUserMessages);
131
154
  continue;
132
155
  }
@@ -139,6 +162,7 @@ function parseSessionJsonlTurns(content, { threadId = "" } = {}) {
139
162
  sessionThreadId,
140
163
  entry.timestamp
141
164
  );
165
+ applyHistoryTimeZone(turn, sessionTimeZone);
142
166
  turn.status = "completed";
143
167
  activeTurnId = "";
144
168
  continue;
@@ -162,6 +186,7 @@ function parseSessionJsonlTurns(content, { threadId = "" } = {}) {
162
186
  toolCallsByCallId,
163
187
  });
164
188
  if (item) {
189
+ applyHistoryTimeZone(item, sessionTimeZone);
165
190
  turn.items.push(item);
166
191
  }
167
192
  continue;
@@ -170,6 +195,7 @@ function parseSessionJsonlTurns(content, { threadId = "" } = {}) {
170
195
  if (eventType === "user_message") {
171
196
  const explicitTurnId = normalizeString(payload?.turn_id) || normalizeString(payload?.turnId);
172
197
  const item = createUserMessageHistoryItem(payload, index + 1, entry.timestamp);
198
+ applyHistoryTimeZone(item, sessionTimeZone);
173
199
  if (!explicitTurnId && !activeTurnId) {
174
200
  pushPendingUserMessage(pendingUserMessages, item);
175
201
  continue;
@@ -182,6 +208,7 @@ function parseSessionJsonlTurns(content, { threadId = "" } = {}) {
182
208
  sessionThreadId,
183
209
  entry.timestamp
184
210
  );
211
+ applyHistoryTimeZone(turn, sessionTimeZone);
185
212
  addHistoryItemToTurn(turn, item);
186
213
  continue;
187
214
  }
@@ -207,6 +234,7 @@ function parseSessionJsonlTurns(content, { threadId = "" } = {}) {
207
234
  sessionThreadId,
208
235
  entry.timestamp
209
236
  );
237
+ applyHistoryTimeZone(turn, sessionTimeZone);
210
238
  const item = normalizeResponseItemForHistory(payload, index + 1, {
211
239
  cwd: sessionCwd,
212
240
  toolCallsByCallId,
@@ -222,6 +250,7 @@ function parseSessionJsonlTurns(content, { threadId = "" } = {}) {
222
250
  if (itemTimestamp && !item.timestamp) {
223
251
  item.timestamp = itemTimestamp;
224
252
  }
253
+ applyHistoryTimeZone(item, sessionTimeZone);
225
254
  addHistoryItemToTurn(turn, item);
226
255
  }
227
256
  }
@@ -336,6 +365,12 @@ function historyItemTimestamp(item, fallbackTimestamp = "") {
336
365
  return firstNonEmptyString([
337
366
  normalizeString(item?.createdAt),
338
367
  normalizeString(item?.created_at),
368
+ normalizeString(item?.startedAt),
369
+ normalizeString(item?.started_at),
370
+ normalizeString(item?.completedAt),
371
+ normalizeString(item?.completed_at),
372
+ normalizeString(item?.endedAt),
373
+ normalizeString(item?.ended_at),
339
374
  normalizeString(item?.timestamp),
340
375
  normalizeString(item?.time),
341
376
  normalizeString(fallbackTimestamp),
@@ -379,6 +414,7 @@ function flushPendingUserMessagesToTurn(turn, pendingUserMessages) {
379
414
  }
380
415
 
381
416
  for (const item of pendingUserMessages.splice(0)) {
417
+ applyHistoryTimeZone(item, normalizeString(turn.timeZone) || normalizeString(turn.timezone));
382
418
  addHistoryItemToTurn(turn, item);
383
419
  }
384
420
  }
@@ -403,6 +439,23 @@ function ensureTurn(turns, turnsById, turnId, threadId, timestamp) {
403
439
  return turn;
404
440
  }
405
441
 
442
+ function applyHistoryTimeZone(target, timeZone) {
443
+ const normalizedTimeZone = normalizeString(timeZone);
444
+ if (!target || !normalizedTimeZone) {
445
+ return target;
446
+ }
447
+ if (!target.timeZoneIdentifier) {
448
+ target.timeZoneIdentifier = normalizedTimeZone;
449
+ }
450
+ if (!target.timeZone) {
451
+ target.timeZone = normalizedTimeZone;
452
+ }
453
+ if (!target.timezone) {
454
+ target.timezone = normalizedTimeZone;
455
+ }
456
+ return target;
457
+ }
458
+
406
459
  function normalizeResponseItemForHistory(payload, lineNumber, { cwd = "", toolCallsByCallId = new Map() } = {}) {
407
460
  const type = normalizeHistoryItemType(payload.type);
408
461
  if (!type) {
@@ -8,7 +8,8 @@ const OPENAI_TRANSCRIPTIONS_URL = "https://api.openai.com/v1/audio/transcription
8
8
  const CHATGPT_TRANSCRIPTIONS_URL = "https://chatgpt.com/backend-api/transcribe";
9
9
  const DEFAULT_TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe";
10
10
  const MAX_AUDIO_BYTES = 10 * 1024 * 1024;
11
- const MAX_DURATION_MS = 120_000;
11
+ const MAX_DURATION_SECONDS = 150;
12
+ const MAX_DURATION_MS = MAX_DURATION_SECONDS * 1_000;
12
13
 
13
14
  function createVoiceHandler({
14
15
  sendCodexRequest,
@@ -95,7 +96,7 @@ async function transcribeVoice(
95
96
  throw voiceError("invalid_duration", "Voice messages must include a positive duration.");
96
97
  }
97
98
  if (durationMs > MAX_DURATION_MS) {
98
- throw voiceError("duration_too_long", "Voice messages are limited to 120 seconds.");
99
+ throw voiceError("duration_too_long", `Voice messages are limited to ${MAX_DURATION_SECONDS} seconds.`);
99
100
  }
100
101
 
101
102
  const audioBuffer = decodeAudioBase64(params.audioBase64);
@@ -283,7 +284,36 @@ function normalizeBase64(value) {
283
284
  }
284
285
 
285
286
  function isLikelyBase64(value) {
286
- return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
287
+ if (typeof value !== "string" || value.length === 0 || value.length % 4 !== 0) {
288
+ return false;
289
+ }
290
+
291
+ const paddingStart = value.indexOf("=");
292
+ if (paddingStart !== -1) {
293
+ const paddingLength = value.length - paddingStart;
294
+ if (paddingLength > 2) {
295
+ return false;
296
+ }
297
+ for (let i = paddingStart; i < value.length; i += 1) {
298
+ if (value[i] !== "=") {
299
+ return false;
300
+ }
301
+ }
302
+ }
303
+
304
+ // Avoid one giant regex: V8 can overflow its stack on multi-MB voice clips.
305
+ const dataEnd = paddingStart === -1 ? value.length : paddingStart;
306
+ for (let i = 0; i < dataEnd; i += 1) {
307
+ const code = value.charCodeAt(i);
308
+ const isUppercase = code >= 65 && code <= 90;
309
+ const isLowercase = code >= 97 && code <= 122;
310
+ const isDigit = code >= 48 && code <= 57;
311
+ if (!isUppercase && !isLowercase && !isDigit && value[i] !== "+" && value[i] !== "/") {
312
+ return false;
313
+ }
314
+ }
315
+
316
+ return true;
287
317
  }
288
318
 
289
319
  function hasRiffWaveHeader(buffer) {