@makerbi/remodex 1.5.4 → 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.
@@ -12,10 +12,12 @@ const {
12
12
  resolveSessionsRoot,
13
13
  } = require("./rollout-watch");
14
14
  const { resolveCodexGeneratedImagesRoot } = require("./codex-home");
15
+ const { buildApplyPatchFileChangeItem } = require("./apply-patch-changes");
15
16
 
16
17
  const DEFAULT_POLL_INTERVAL_MS = 700;
17
18
  const DEFAULT_LOOKUP_TIMEOUT_MS = 5_000;
18
19
  const DEFAULT_IDLE_TIMEOUT_MS = 60_000;
20
+ const DEFAULT_ACTIVITY_HEARTBEAT_MS = 5_000;
19
21
  const DESKTOP_RESUME_METHODS = new Set(["thread/read", "thread/resume"]);
20
22
 
21
23
  // Observes desktop-authored rollout files and replays the currently active run as
@@ -30,6 +32,7 @@ function createRolloutLiveMirrorController({
30
32
  pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
31
33
  lookupTimeoutMs = DEFAULT_LOOKUP_TIMEOUT_MS,
32
34
  idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
35
+ activityHeartbeatMs = DEFAULT_ACTIVITY_HEARTBEAT_MS,
33
36
  } = {}) {
34
37
  const mirrorsByThreadId = new Map();
35
38
 
@@ -63,6 +66,7 @@ function createRolloutLiveMirrorController({
63
66
  pollIntervalMs,
64
67
  lookupTimeoutMs,
65
68
  idleTimeoutMs,
69
+ activityHeartbeatMs,
66
70
  onStop() {
67
71
  if (mirrorsByThreadId.get(threadId) === mirror) {
68
72
  mirrorsByThreadId.delete(threadId);
@@ -98,6 +102,7 @@ function createThreadRolloutLiveMirror({
98
102
  pollIntervalMs,
99
103
  lookupTimeoutMs,
100
104
  idleTimeoutMs,
105
+ activityHeartbeatMs,
101
106
  onStop = () => {},
102
107
  }) {
103
108
  const startedAt = now();
@@ -108,6 +113,7 @@ function createThreadRolloutLiveMirror({
108
113
  let lastSize = 0;
109
114
  let partialLine = "";
110
115
  let lastActivityAt = startedAt;
116
+ let lastHeartbeatAt = 0;
111
117
  let didBootstrap = false;
112
118
 
113
119
  const intervalId = setIntervalFn(tick, pollIntervalMs);
@@ -148,6 +154,7 @@ function createThreadRolloutLiveMirror({
148
154
  });
149
155
  lastSize = fileSize;
150
156
  lastActivityAt = currentTime;
157
+ lastHeartbeatAt = currentTime;
151
158
  if (state.isDesktopOrigin === false) {
152
159
  stop();
153
160
  }
@@ -158,17 +165,37 @@ function createThreadRolloutLiveMirror({
158
165
  const chunk = readFileSlice(rolloutPath, lastSize, fileSize, fsModule);
159
166
  lastSize = fileSize;
160
167
  lastActivityAt = currentTime;
168
+ lastHeartbeatAt = currentTime;
161
169
  if (!chunk) {
162
170
  return;
163
171
  }
164
172
 
165
- const combined = `${partialLine}${chunk}`;
166
- const lines = combined.split("\n");
167
- partialLine = lines.pop() || "";
173
+ const combined = partialLine ? `${partialLine}${chunk}` : chunk;
174
+ let searchStart = 0;
175
+ let nlIndex;
176
+ const lines = [];
177
+ while ((nlIndex = combined.indexOf("\n", searchStart)) !== -1) {
178
+ lines.push(combined.substring(searchStart, nlIndex));
179
+ searchStart = nlIndex + 1;
180
+ }
181
+ partialLine = searchStart < combined.length ? combined.substring(searchStart) : "";
168
182
  processRolloutLines(lines, state, sendApplicationResponse);
169
183
  return;
170
184
  }
171
185
 
186
+ if (
187
+ state.isDesktopOrigin !== false
188
+ && state.activeTurnId
189
+ && currentTime - lastHeartbeatAt >= activityHeartbeatMs
190
+ ) {
191
+ lastHeartbeatAt = currentTime;
192
+ sendApplicationResponse(JSON.stringify(createNotification("turn/activity", {
193
+ threadId: state.threadId,
194
+ turnId: state.activeTurnId,
195
+ id: state.activeTurnId,
196
+ })));
197
+ }
198
+
172
199
  if (currentTime - lastActivityAt >= idleTimeoutMs) {
173
200
  stop();
174
201
  }
@@ -269,9 +296,6 @@ function bootstrapFromExistingRollout({
269
296
  }
270
297
 
271
298
  state.isDesktopOrigin = true;
272
- if (activeTurnId) {
273
- state.activeTurnId = activeTurnId;
274
- }
275
299
  processRolloutLines(activeRunLines, state, sendApplicationResponse);
276
300
  }
277
301
 
@@ -320,21 +344,25 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
320
344
  const eventType = readString(payload.type);
321
345
 
322
346
  if (eventType === "task_started") {
323
- const turnId = readString(payload.turn_id) || readString(payload.turnId);
324
- if (!turnId) {
325
- return [];
326
- }
327
-
347
+ const explicitTurnId = readString(payload.turn_id) || readString(payload.turnId);
348
+ const turnId = explicitTurnId || buildSyntheticTurnId(state, entry);
328
349
  state.activeTurnId = turnId;
350
+ state.activeTurnIdIsSynthetic = !explicitTurnId;
329
351
  state.reasoningItemId = buildSyntheticItemId("thinking", state.threadId, turnId);
330
352
  state.hasThinking = false;
331
353
  state.commandCalls.clear();
354
+ state.applyPatchCalls.clear();
355
+ state.emittedPatchApplyEndCalls.clear();
332
356
 
333
- notifications.push(createNotification("turn/started", {
357
+ const startedParams = {
334
358
  threadId: state.threadId,
335
- turnId,
336
- id: turnId,
337
- }));
359
+ remodexDesktopMirror: true,
360
+ remodexRolloutLiveMirror: true,
361
+ };
362
+ startedParams.turnId = turnId;
363
+ startedParams.id = turnId;
364
+ notifications.push(createNotification("turn/started", startedParams));
365
+ notifications.push(...flushPendingUserMessageNotifications(state, turnId));
338
366
  notifications.push(...ensureThinkingNotifications(state));
339
367
  return notifications;
340
368
  }
@@ -345,20 +373,32 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
345
373
  return [];
346
374
  }
347
375
 
376
+ const turnId = resolveRolloutEventTurnId(state, payload);
377
+ if (!turnId) {
378
+ state.pendingUserMessages.push({
379
+ id: readString(payload.id),
380
+ message,
381
+ timestamp: readUserMessageTimestamp(entry, payload),
382
+ });
383
+ return [];
384
+ }
385
+
348
386
  notifications.push(createNotification("codex/event/user_message", {
349
387
  threadId: state.threadId,
350
- turnId: readString(payload.turn_id) || readString(payload.turnId) || state.activeTurnId || "",
388
+ turnId,
351
389
  message,
390
+ ...timestampParams(readUserMessageTimestamp(entry, payload)),
352
391
  }));
353
392
  return notifications;
354
393
  }
355
394
 
356
395
  if (eventType === "task_complete") {
357
- const turnId = readString(payload.turn_id) || readString(payload.turnId) || state.activeTurnId;
396
+ const turnId = resolveRolloutEventTurnId(state, payload);
358
397
  if (!turnId) {
359
398
  return [];
360
399
  }
361
400
 
401
+ notifications.push(...turnFileChangeSnapshotNotifications(state, turnId));
362
402
  notifications.push(createNotification("turn/completed", {
363
403
  threadId: state.threadId,
364
404
  turnId,
@@ -368,6 +408,11 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
368
408
  return notifications;
369
409
  }
370
410
 
411
+ if (eventType === "item_completed") {
412
+ notifications.push(...itemCompletedNotifications(state, payload));
413
+ return notifications;
414
+ }
415
+
371
416
  if (eventType === "agent_reasoning") {
372
417
  notifications.push(...reasoningNotifications(state, firstNonEmptyString([
373
418
  readString(payload.message),
@@ -382,7 +427,7 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
382
427
  if (!message || !shouldMirrorAgentMessage(payload)) {
383
428
  return [];
384
429
  }
385
- const turnId = readString(payload.turn_id) || readString(payload.turnId) || state.activeTurnId || "";
430
+ const turnId = resolveRolloutEventTurnId(state, payload);
386
431
 
387
432
  notifications.push(createNotification("codex/event/agent_message", {
388
433
  threadId: state.threadId,
@@ -400,6 +445,11 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
400
445
  return notifications;
401
446
  }
402
447
 
448
+ if (eventType === "patch_apply_end") {
449
+ notifications.push(...patchApplyEndNotifications(state, payload));
450
+ return notifications;
451
+ }
452
+
403
453
  return [];
404
454
  }
405
455
 
@@ -420,6 +470,11 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
420
470
  return notifications;
421
471
  }
422
472
 
473
+ if (itemType === "customtoolcall") {
474
+ notifications.push(...customToolStartNotifications(state, payload));
475
+ return notifications;
476
+ }
477
+
423
478
  if (itemType === "functioncalloutput") {
424
479
  notifications.push(...toolOutputNotifications(state, payload));
425
480
  return notifications;
@@ -466,6 +521,13 @@ function toolStartNotifications(state, payload) {
466
521
  }
467
522
 
468
523
  const argumentsObject = parseToolArguments(payload.arguments);
524
+ if (isInternalProgressPlanToolName(toolName)) {
525
+ return [
526
+ ...ensureThinkingNotifications(state),
527
+ ...planUpdateNotifications(state, argumentsObject),
528
+ ];
529
+ }
530
+
469
531
  state.commandCalls.set(callId, {
470
532
  toolName,
471
533
  command: resolveToolCommand(toolName, argumentsObject),
@@ -503,6 +565,114 @@ function toolStartNotifications(state, payload) {
503
565
  ];
504
566
  }
505
567
 
568
+ function customToolStartNotifications(state, payload) {
569
+ if (!state.activeTurnId) {
570
+ return [];
571
+ }
572
+
573
+ const callId = readString(payload.call_id) || readString(payload.callId);
574
+ const toolName = readString(payload.name);
575
+ if (!callId || !toolName) {
576
+ return [];
577
+ }
578
+
579
+ const notifications = [...ensureThinkingNotifications(state)];
580
+ if (toolName === "apply_patch") {
581
+ const item = buildApplyPatchFileChangeItem({
582
+ callId,
583
+ patch: readString(payload.input),
584
+ status: readString(payload.status) || "completed",
585
+ idFallback: buildSyntheticItemId("file-change", state.threadId, state.activeTurnId, callId),
586
+ });
587
+ if (item) {
588
+ state.applyPatchCalls.set(callId, item);
589
+ notifications.push(createNotification("codex/event/patch_apply_begin", {
590
+ threadId: state.threadId,
591
+ turnId: state.activeTurnId,
592
+ id: state.activeTurnId,
593
+ call_id: callId,
594
+ itemId: item.id,
595
+ status: "inProgress",
596
+ changes: item.changes,
597
+ }));
598
+ }
599
+ }
600
+
601
+ const activityMessage = genericToolActivityMessage(toolName);
602
+ if (!activityMessage) {
603
+ return notifications;
604
+ }
605
+
606
+ return [
607
+ ...notifications,
608
+ createNotification("codex/event/background_event", {
609
+ threadId: state.threadId,
610
+ turnId: state.activeTurnId,
611
+ call_id: callId,
612
+ message: activityMessage,
613
+ }),
614
+ ];
615
+ }
616
+
617
+ function patchApplyEndNotifications(state, payload) {
618
+ const turnId = resolveRolloutEventTurnId(state, payload);
619
+ const callId = readString(payload.call_id) || readString(payload.callId);
620
+ if (!turnId || !callId || state.emittedPatchApplyEndCalls.has(callId)) {
621
+ return [];
622
+ }
623
+
624
+ const fileChangeItem = state.applyPatchCalls.get(callId);
625
+ const changes = Array.isArray(payload.changes)
626
+ ? payload.changes
627
+ : fileChangeItem?.changes || [];
628
+ if (changes.length === 0) {
629
+ return [];
630
+ }
631
+
632
+ state.emittedPatchApplyEndCalls.add(callId);
633
+ return [
634
+ ...ensureThinkingNotifications(state),
635
+ createNotification("codex/event/patch_apply_end", {
636
+ threadId: state.threadId,
637
+ turnId,
638
+ id: turnId,
639
+ call_id: callId,
640
+ itemId: fileChangeItem?.id || callId,
641
+ status: readString(payload.status) || fileChangeItem?.status || "completed",
642
+ success: payload.success !== false,
643
+ changes,
644
+ }),
645
+ ];
646
+ }
647
+
648
+ function turnFileChangeSnapshotNotifications(state, turnId) {
649
+ const patchEntries = Array.from(state.applyPatchCalls.entries());
650
+ if (!turnId || patchEntries.length === 0) {
651
+ return [];
652
+ }
653
+
654
+ const changes = patchEntries.flatMap(([, item]) => Array.isArray(item?.changes) ? item.changes : []);
655
+ if (changes.length === 0) {
656
+ return [];
657
+ }
658
+
659
+ const [lastCallId, lastItem] = patchEntries[patchEntries.length - 1];
660
+ const itemId = readString(lastItem?.id) || readString(lastCallId) || buildSyntheticItemId("file-change", state.threadId, turnId);
661
+ return [
662
+ createNotification("codex/event/patch_apply_end", {
663
+ threadId: state.threadId,
664
+ turnId,
665
+ id: turnId,
666
+ call_id: itemId,
667
+ itemId,
668
+ status: "completed",
669
+ success: true,
670
+ changes,
671
+ remodexTurnFileChangeSnapshot: true,
672
+ }),
673
+ ];
674
+ }
675
+
506
676
  function toolOutputNotifications(state, payload) {
507
677
  if (!state.activeTurnId) {
508
678
  return [];
@@ -597,6 +767,28 @@ function imageGenerationNotifications(state, payload, { preferCallId = false } =
597
767
  ];
598
768
  }
599
769
 
770
+ function itemCompletedNotifications(state, payload) {
771
+ const item = payload && typeof payload.item === "object" && !Array.isArray(payload.item)
772
+ ? payload.item
773
+ : null;
774
+ if (!item || normalizeRolloutItemType(item.type) !== "plan") {
775
+ return [];
776
+ }
777
+
778
+ const turnId = resolveRolloutEventTurnId(state, payload);
779
+ if (!turnId) {
780
+ return [];
781
+ }
782
+
783
+ return [
784
+ createNotification("item/completed", {
785
+ threadId: state.threadId,
786
+ turnId,
787
+ item,
788
+ }),
789
+ ];
790
+ }
791
+
600
792
  function ensureThinkingNotifications(state) {
601
793
  if (!state.activeTurnId || state.hasThinking) {
602
794
  return [];
@@ -626,6 +818,10 @@ function createMirrorState(threadId) {
626
818
  reasoningItemId: null,
627
819
  hasThinking: false,
628
820
  commandCalls: new Map(),
821
+ applyPatchCalls: new Map(),
822
+ emittedPatchApplyEndCalls: new Set(),
823
+ pendingUserMessages: [],
824
+ activeTurnIdIsSynthetic: false,
629
825
  };
630
826
  }
631
827
 
@@ -677,6 +873,58 @@ function parseToolArguments(rawArguments) {
677
873
  return parsed && typeof parsed === "object" ? parsed : {};
678
874
  }
679
875
 
876
+ function planUpdateNotifications(state, argumentsObject) {
877
+ const plan = normalizeProgressPlanSteps(argumentsObject.plan);
878
+ if (plan.length === 0) {
879
+ return [];
880
+ }
881
+
882
+ const params = {
883
+ threadId: state.threadId,
884
+ turnId: state.activeTurnId,
885
+ plan,
886
+ };
887
+ const explanation = readString(argumentsObject.explanation);
888
+ if (explanation) {
889
+ params.explanation = explanation;
890
+ }
891
+
892
+ return [createNotification("turn/plan/updated", params)];
893
+ }
894
+
895
+ function normalizeProgressPlanSteps(rawPlan) {
896
+ if (!Array.isArray(rawPlan)) {
897
+ return [];
898
+ }
899
+
900
+ return rawPlan.flatMap((rawStep) => {
901
+ if (!rawStep || typeof rawStep !== "object") {
902
+ return [];
903
+ }
904
+
905
+ const step = readString(rawStep.step);
906
+ const status = normalizeProgressPlanStatus(rawStep.status);
907
+ if (!step || !status) {
908
+ return [];
909
+ }
910
+
911
+ return [{ step, status }];
912
+ });
913
+ }
914
+
915
+ function normalizeProgressPlanStatus(rawStatus) {
916
+ const normalized = readString(rawStatus);
917
+ switch (normalized) {
918
+ case "pending":
919
+ case "in_progress":
920
+ case "inProgress":
921
+ case "completed":
922
+ return normalized;
923
+ default:
924
+ return "";
925
+ }
926
+ }
927
+
680
928
  function resolveToolCommand(toolName, argumentsObject) {
681
929
  if (isCommandToolName(toolName)) {
682
930
  return firstNonEmptyString([
@@ -704,6 +952,10 @@ function isCommandToolName(toolName) {
704
952
  return normalized === "exec_command" || normalized === "shell_command";
705
953
  }
706
954
 
955
+ function isInternalProgressPlanToolName(toolName) {
956
+ return readString(toolName).toLowerCase() === "update_plan";
957
+ }
958
+
707
959
  function genericToolActivityMessage(toolName) {
708
960
  switch (readString(toolName).toLowerCase()) {
709
961
  case "apply_patch":
@@ -722,8 +974,51 @@ function shouldMirrorAgentMessage(payload) {
722
974
  return phase !== "commentary";
723
975
  }
724
976
 
725
- function createNotification(method, params) {
726
- return { method, params };
977
+ function createNotification(method, params = {}) {
978
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
979
+ return { method, params };
980
+ }
981
+
982
+ return {
983
+ method,
984
+ params: {
985
+ remodexDesktopMirror: true,
986
+ remodexRolloutLiveMirror: true,
987
+ ...params,
988
+ },
989
+ };
990
+ }
991
+
992
+ function flushPendingUserMessageNotifications(state, turnId) {
993
+ const messages = state.pendingUserMessages.splice(0);
994
+ if (messages.length === 0) {
995
+ return [];
996
+ }
997
+
998
+ return messages.map((pending) => createNotification("codex/event/user_message", {
999
+ threadId: state.threadId,
1000
+ turnId: turnId || state.activeTurnId || "",
1001
+ message: pending.message,
1002
+ ...(pending.id ? { id: pending.id } : {}),
1003
+ ...timestampParams(pending.timestamp),
1004
+ }));
1005
+ }
1006
+
1007
+ function readUserMessageTimestamp(entry, payload = {}) {
1008
+ return firstNonEmptyString([
1009
+ readString(payload.createdAt),
1010
+ readString(payload.created_at),
1011
+ readString(payload.timestamp),
1012
+ readString(payload.time),
1013
+ readString(entry?.timestamp),
1014
+ ]);
1015
+ }
1016
+
1017
+ function timestampParams(timestamp) {
1018
+ const normalizedTimestamp = readString(timestamp);
1019
+ return normalizedTimestamp
1020
+ ? { createdAt: normalizedTimestamp, timestamp: normalizedTimestamp }
1021
+ : {};
727
1022
  }
728
1023
 
729
1024
  function buildSyntheticItemId(kind, threadId, turnId, suffix = "") {
@@ -731,6 +1026,18 @@ function buildSyntheticItemId(kind, threadId, turnId, suffix = "") {
731
1026
  return `rollout-${kind}:${threadId}:${turnId}${suffixPart}`;
732
1027
  }
733
1028
 
1029
+ function buildSyntheticTurnId(state, entry) {
1030
+ const timestamp = readString(entry?.timestamp) || "unknown";
1031
+ return `rollout-turn:${state.threadId}:${timestamp}`;
1032
+ }
1033
+
1034
+ function resolveRolloutEventTurnId(state, payload = {}) {
1035
+ if (state.activeTurnIdIsSynthetic && state.activeTurnId) {
1036
+ return state.activeTurnId;
1037
+ }
1038
+ return readString(payload.turn_id) || readString(payload.turnId) || state.activeTurnId || "";
1039
+ }
1040
+
734
1041
  function buildAgentMessageItemId(threadId, turnId, entry, message) {
735
1042
  const timestamp = readString(entry?.timestamp) || "untimed";
736
1043
  const messageHash = crypto
@@ -765,6 +1072,10 @@ function resetRunState(state) {
765
1072
  state.reasoningItemId = null;
766
1073
  state.hasThinking = false;
767
1074
  state.commandCalls.clear();
1075
+ state.applyPatchCalls.clear();
1076
+ state.emittedPatchApplyEndCalls.clear();
1077
+ state.pendingUserMessages.length = 0;
1078
+ state.activeTurnIdIsSynthetic = false;
768
1079
  }
769
1080
 
770
1081
  function readThreadId(params) {
@@ -498,7 +498,11 @@ function collectRecentRolloutFiles(
498
498
  }
499
499
  }
500
500
 
501
- candidates.sort(compareRolloutFileOrder);
501
+ candidates.sort((lhs, rhs) =>
502
+ (rhs.mtimeMs - lhs.mtimeMs)
503
+ || path.basename(rhs.filePath).localeCompare(path.basename(lhs.filePath))
504
+ || rhs.filePath.localeCompare(lhs.filePath)
505
+ );
502
506
  return candidates.slice(0, candidateLimit);
503
507
  }
504
508
 
@@ -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, 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");
@@ -83,6 +83,30 @@ function resetBridgeDeviceState() {
83
83
  };
84
84
  }
85
85
 
86
+ // Clears trusted phones while preserving the Mac's stable identity across re-pairing.
87
+ function resetBridgeTrustState() {
88
+ const existingState = readBridgeDeviceState();
89
+ if (!existingState) {
90
+ return {
91
+ hadState: false,
92
+ preservedMacIdentity: false,
93
+ clearedTrustedPhones: false,
94
+ };
95
+ }
96
+
97
+ const nextState = normalizeBridgeDeviceState({
98
+ ...existingState,
99
+ trustedPhones: {},
100
+ });
101
+ const clearedTrustedPhones = Object.keys(existingState.trustedPhones || {}).length > 0;
102
+ writeBridgeDeviceState(nextState);
103
+ return {
104
+ hadState: true,
105
+ preservedMacIdentity: true,
106
+ clearedTrustedPhones,
107
+ };
108
+ }
109
+
86
110
  // Generates a fresh relay session for every bridge launch so QR pairing stays explicit per-run.
87
111
  function resolveBridgeRelaySession(state, { persist = true } = {}) {
88
112
  return {
@@ -129,6 +153,22 @@ function rememberLastSeenPhoneAppVersion(state, phoneAppVersion, { persist = tru
129
153
  return nextState;
130
154
  }
131
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
+
132
172
  function getTrustedPhonePublicKey(state, phoneDeviceId) {
133
173
  const normalizedDeviceId = normalizeNonEmptyString(phoneDeviceId);
134
174
  if (!normalizedDeviceId) {
@@ -152,6 +192,7 @@ function createBridgeDeviceState() {
152
192
  macIdentityPublicKey: base64UrlToBase64(publicJwk.x),
153
193
  macIdentityPrivateKey: base64UrlToBase64(privateJwk.d),
154
194
  trustedPhones: {},
195
+ lastSeenDeviceKind: null,
155
196
  lastSeenPhoneAppVersion: null,
156
197
  };
157
198
  }
@@ -361,6 +402,8 @@ function normalizeBridgeDeviceState(rawState) {
361
402
  const macIdentityPublicKey = normalizeNonEmptyString(rawState?.macIdentityPublicKey);
362
403
  const macIdentityPrivateKey = normalizeNonEmptyString(rawState?.macIdentityPrivateKey);
363
404
  const lastSeenPhoneAppVersion = normalizeNonEmptyString(rawState?.lastSeenPhoneAppVersion) || null;
405
+ const lastSeenDeviceKind = normalizeDeviceKind(rawState?.lastSeenDeviceKind)
406
+ || inferLegacyDeviceKind({ lastSeenPhoneAppVersion });
364
407
 
365
408
  if (!macDeviceId || !macIdentityPublicKey || !macIdentityPrivateKey) {
366
409
  throw new Error("Bridge device state is incomplete");
@@ -384,6 +427,7 @@ function normalizeBridgeDeviceState(rawState) {
384
427
  macIdentityPublicKey,
385
428
  macIdentityPrivateKey,
386
429
  trustedPhones,
430
+ lastSeenDeviceKind,
387
431
  lastSeenPhoneAppVersion,
388
432
  };
389
433
  }
@@ -406,6 +450,24 @@ function recoverBridgeDeviceIdentity(state, { fallbackState = null } = {}) {
406
450
  return nextState;
407
451
  }
408
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
+
409
471
  function bridgeStatesEqual(left, right) {
410
472
  return JSON.stringify(left) === JSON.stringify(right);
411
473
  }
@@ -451,8 +513,10 @@ module.exports = {
451
513
  getTrustedPhonePublicKey,
452
514
  loadOrCreateBridgeDeviceState,
453
515
  readBridgeDeviceState,
516
+ rememberLastSeenClientDeviceKind,
454
517
  rememberLastSeenPhoneAppVersion,
455
518
  rememberTrustedPhone,
456
519
  resetBridgeDeviceState,
520
+ resetBridgeTrustState,
457
521
  resolveBridgeRelaySession,
458
522
  };