@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.
- package/bin/remodex.js +56 -5
- package/package.json +1 -1
- package/src/bridge.js +85 -2
- package/src/desktop-ipc-action-follower.js +836 -3
- package/src/git-handler.js +92 -28
- package/src/ios-app-compatibility.js +7 -7
- package/src/macos-launch-agent.js +46 -1
- package/src/qr.js +2 -5
- package/src/rollout-live-mirror.js +159 -3
- package/src/secure-device-state.js +41 -2
- package/src/secure-transport.js +7 -0
- package/src/session-jsonl-history.js +54 -1
- package/src/voice-handler.js +33 -3
package/src/git-handler.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
213
|
-
|
|
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 = "
|
|
8
|
-
const IOS_APP_COMPATIBILITY_GATE_BRIDGE_VERSION = "
|
|
9
|
-
const
|
|
10
|
-
const LEGACY_BRIDGE_DOWNGRADE_COMMAND = `npm install -g remodex@${
|
|
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:
|
|
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
|
-
+ `${
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
package/src/secure-transport.js
CHANGED
|
@@ -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,
|