@makerbi/remodex 1.4.1 → 1.5.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/package.json +6 -1
- package/src/bridge.js +421 -68
- package/src/desktop-ipc-action-follower.js +42 -12
- package/src/git-handler.js +39 -1
- package/src/macos-launch-agent.js +2 -2
- package/src/rollout-watch.js +17 -7
- package/src/secure-device-state.js +34 -6
- package/src/workspace-handler.js +304 -3
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makerbi/remodex",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Local bridge between Codex and the Remodex mobile app. Run `remodex up` to start.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/AndersonBY/remodex.git",
|
|
8
|
+
"directory": "phodex-bridge"
|
|
9
|
+
},
|
|
5
10
|
"main": "src/index.js",
|
|
6
11
|
"bin": {
|
|
7
12
|
"remodex": "bin/remodex.js"
|
package/src/bridge.js
CHANGED
|
@@ -43,7 +43,6 @@ const { createBridgeSecureTransport } = require("./secure-transport");
|
|
|
43
43
|
const { createRolloutLiveMirrorController } = require("./rollout-live-mirror");
|
|
44
44
|
const {
|
|
45
45
|
createDesktopIpcActionFollower,
|
|
46
|
-
seedConversationStateFromThreadRead,
|
|
47
46
|
} = require("./desktop-ipc-action-follower");
|
|
48
47
|
const { version: bridgePackageVersion = "" } = require("../package.json");
|
|
49
48
|
const {
|
|
@@ -67,6 +66,24 @@ const RELAY_HISTORY_IMAGE_REFERENCE_URL = "remodex://history-image-elided";
|
|
|
67
66
|
const RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
68
67
|
const RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS = 24_000;
|
|
69
68
|
const RELAY_HISTORY_RECENT_TURN_TARGET = 40;
|
|
69
|
+
const RELAY_TURNS_LIST_TARGET_BUDGET_MS = 5_500;
|
|
70
|
+
const RELAY_TURNS_LIST_BUDGET_RESERVE_MS = 1_000;
|
|
71
|
+
const RELAY_TURNS_LIST_RESULT_KEYS = ["data", "items", "turns"];
|
|
72
|
+
const RELAY_TURNS_LIST_PAGINATION_RESULT_KEYS = [
|
|
73
|
+
"nextCursor",
|
|
74
|
+
"next_cursor",
|
|
75
|
+
"cursor",
|
|
76
|
+
"hasNextCursor",
|
|
77
|
+
"has_next_cursor",
|
|
78
|
+
"hasNextPage",
|
|
79
|
+
"has_next_page",
|
|
80
|
+
"hasMore",
|
|
81
|
+
"has_more",
|
|
82
|
+
"prevCursor",
|
|
83
|
+
"prev_cursor",
|
|
84
|
+
"previousCursor",
|
|
85
|
+
"previous_cursor",
|
|
86
|
+
];
|
|
70
87
|
|
|
71
88
|
function buildRelayUserAgentHeader({ version = bridgePackageVersion } = {}) {
|
|
72
89
|
const normalizedVersion = typeof version === "string" && version.trim()
|
|
@@ -169,6 +186,7 @@ function startBridge({
|
|
|
169
186
|
const relaySanitizedRequestMethods = new Set([
|
|
170
187
|
"thread/read",
|
|
171
188
|
"thread/resume",
|
|
189
|
+
"thread/turns/list",
|
|
172
190
|
]);
|
|
173
191
|
const forwardedRequestMethodTTLms = 2 * 60_000;
|
|
174
192
|
const pendingAuthLogin = {
|
|
@@ -206,7 +224,6 @@ function startBridge({
|
|
|
206
224
|
const desktopIpcActionFollower = !config.codexEndpoint
|
|
207
225
|
? createDesktopIpcActionFollower({
|
|
208
226
|
sendApplicationResponse,
|
|
209
|
-
readConversationState: readDesktopConversationState,
|
|
210
227
|
socketPath: config.desktopIpcSocketPath || undefined,
|
|
211
228
|
})
|
|
212
229
|
: null;
|
|
@@ -580,6 +597,9 @@ function startBridge({
|
|
|
580
597
|
if (desktopIpcActionFollower?.observeInbound(rawMessage)) {
|
|
581
598
|
return;
|
|
582
599
|
}
|
|
600
|
+
if (handleBridgeManagedThreadTurnsListRequest(rawMessage)) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
583
603
|
rememberForwardedRequestMethod(rawMessage);
|
|
584
604
|
rememberThreadFromMessage("phone", rawMessage);
|
|
585
605
|
codex.send(rawMessage);
|
|
@@ -612,13 +632,33 @@ function startBridge({
|
|
|
612
632
|
}));
|
|
613
633
|
}
|
|
614
634
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
635
|
+
function handleBridgeManagedThreadTurnsListRequest(rawMessage) {
|
|
636
|
+
const request = parseAdaptiveThreadTurnsListRequest(rawMessage);
|
|
637
|
+
if (!request) {
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
rememberThreadFromMessage("phone", rawMessage);
|
|
642
|
+
(async () => {
|
|
643
|
+
try {
|
|
644
|
+
const response = await fetchAdaptiveThreadTurnsListForRelay(request, {
|
|
645
|
+
fetchPage: (params) => sendCodexRequest("thread/turns/list", params),
|
|
646
|
+
});
|
|
647
|
+
relaySanitizedResponseMethodsById.set(String(request.id), {
|
|
648
|
+
method: "thread/turns/list",
|
|
649
|
+
createdAt: Date.now(),
|
|
650
|
+
});
|
|
651
|
+
sendApplicationResponse(JSON.stringify(response));
|
|
652
|
+
} catch (error) {
|
|
653
|
+
sendApplicationResponse(createJsonRpcErrorResponse(
|
|
654
|
+
request.id,
|
|
655
|
+
error,
|
|
656
|
+
"thread_turns_list_failed"
|
|
657
|
+
));
|
|
658
|
+
}
|
|
659
|
+
})();
|
|
660
|
+
|
|
661
|
+
return true;
|
|
622
662
|
}
|
|
623
663
|
|
|
624
664
|
// ─── Bridge-owned auth snapshot ─────────────────────────────
|
|
@@ -1454,81 +1494,242 @@ function normalizeNonEmptyString(value) {
|
|
|
1454
1494
|
return typeof value === "string" && value.trim() ? value.trim() : "";
|
|
1455
1495
|
}
|
|
1456
1496
|
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
return rawMessage;
|
|
1497
|
+
function parseAdaptiveThreadTurnsListRequest(rawMessage) {
|
|
1498
|
+
const parsed = parseBridgeJSON(rawMessage);
|
|
1499
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1500
|
+
return null;
|
|
1462
1501
|
}
|
|
1463
1502
|
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
if (!thread || typeof thread !== "object" || !Array.isArray(thread.turns)) {
|
|
1467
|
-
return rawMessage;
|
|
1503
|
+
if (parsed.method !== "thread/turns/list") {
|
|
1504
|
+
return null;
|
|
1468
1505
|
}
|
|
1469
1506
|
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
return turn;
|
|
1474
|
-
}
|
|
1507
|
+
if (parsed.id == null) {
|
|
1508
|
+
return null;
|
|
1509
|
+
}
|
|
1475
1510
|
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1511
|
+
const params = parsed.params;
|
|
1512
|
+
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
|
1513
|
+
return null;
|
|
1514
|
+
}
|
|
1480
1515
|
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
}
|
|
1516
|
+
if (!Number.isInteger(params.limit) || params.limit <= 0) {
|
|
1517
|
+
return null;
|
|
1518
|
+
}
|
|
1485
1519
|
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
if (sanitizedItem !== item) {
|
|
1489
|
-
itemDidChange = true;
|
|
1490
|
-
}
|
|
1520
|
+
return parsed;
|
|
1521
|
+
}
|
|
1491
1522
|
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1523
|
+
async function fetchAdaptiveThreadTurnsListForRelay(request, {
|
|
1524
|
+
fetchPage,
|
|
1525
|
+
now = Date.now,
|
|
1526
|
+
targetBudgetMs = RELAY_TURNS_LIST_TARGET_BUDGET_MS,
|
|
1527
|
+
budgetReserveMs = RELAY_TURNS_LIST_BUDGET_RESERVE_MS,
|
|
1528
|
+
rawPageSoftLimitBytes = RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES,
|
|
1529
|
+
payloadSoftLimitBytes = RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES,
|
|
1530
|
+
sanitizeForRelay = sanitizeThreadHistoryImagesForRelay,
|
|
1531
|
+
} = {}) {
|
|
1532
|
+
if (typeof fetchPage !== "function") {
|
|
1533
|
+
throw new Error("fetchPage is required for adaptive turns-list pagination.");
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
const params = request?.params;
|
|
1537
|
+
const requestedLimit = Number.isInteger(params?.limit) && params.limit > 0
|
|
1538
|
+
? params.limit
|
|
1539
|
+
: 1;
|
|
1540
|
+
const startedAt = now();
|
|
1541
|
+
let nextCursor = params?.cursor;
|
|
1542
|
+
let turnsKey = null;
|
|
1543
|
+
let firstResult = null;
|
|
1544
|
+
let lastResult = null;
|
|
1545
|
+
let combinedTurns = [];
|
|
1546
|
+
let response = null;
|
|
1547
|
+
|
|
1548
|
+
while (combinedTurns.length < requestedLimit) {
|
|
1549
|
+
const remaining = requestedLimit - combinedTurns.length;
|
|
1550
|
+
const pageLimit = selectAdaptiveTurnsListBatchLimit(combinedTurns.length, remaining);
|
|
1551
|
+
const pageParams = buildAdaptiveTurnsListPageParams(params, pageLimit, nextCursor);
|
|
1552
|
+
let page;
|
|
1500
1553
|
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
}
|
|
1554
|
+
try {
|
|
1555
|
+
page = await fetchMeasuredAdaptiveTurnsListPage(fetchPage, pageParams, now);
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
if (response) {
|
|
1558
|
+
return response;
|
|
1507
1559
|
}
|
|
1560
|
+
throw error;
|
|
1561
|
+
}
|
|
1508
1562
|
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1563
|
+
const pageResult = page.result;
|
|
1564
|
+
const pageTurnsKey = findTurnsListResultKey(pageResult);
|
|
1565
|
+
if (!pageTurnsKey) {
|
|
1566
|
+
if (!response) {
|
|
1567
|
+
return {
|
|
1568
|
+
id: request.id,
|
|
1569
|
+
result: pageResult ?? null,
|
|
1570
|
+
};
|
|
1513
1571
|
}
|
|
1572
|
+
return response;
|
|
1573
|
+
}
|
|
1514
1574
|
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1575
|
+
if (!turnsKey) {
|
|
1576
|
+
turnsKey = pageTurnsKey;
|
|
1577
|
+
}
|
|
1578
|
+
if (!firstResult) {
|
|
1579
|
+
firstResult = pageResult;
|
|
1580
|
+
}
|
|
1581
|
+
lastResult = pageResult;
|
|
1518
1582
|
|
|
1519
|
-
|
|
1520
|
-
|
|
1583
|
+
const pageTurns = pageResult[pageTurnsKey];
|
|
1584
|
+
combinedTurns = combinedTurns.concat(pageTurns);
|
|
1585
|
+
response = {
|
|
1586
|
+
id: request.id,
|
|
1587
|
+
result: buildAdaptiveTurnsListResult(firstResult, lastResult, turnsKey, combinedTurns),
|
|
1588
|
+
};
|
|
1521
1589
|
|
|
1522
|
-
|
|
1523
|
-
|
|
1590
|
+
nextCursor = readTurnsListNextCursor(pageResult);
|
|
1591
|
+
if (combinedTurns.length >= requestedLimit || !hasRelayCursor(nextCursor) || pageTurns.length === 0) {
|
|
1592
|
+
break;
|
|
1524
1593
|
}
|
|
1525
1594
|
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1595
|
+
const rawPageBytes = jsonByteLength(pageResult);
|
|
1596
|
+
const sanitizedResponseBytes = measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay);
|
|
1597
|
+
const elapsedMs = Math.max(0, now() - startedAt);
|
|
1598
|
+
const remainingBudgetMs = Math.max(0, targetBudgetMs - elapsedMs);
|
|
1599
|
+
if (
|
|
1600
|
+
rawPageBytes >= rawPageSoftLimitBytes
|
|
1601
|
+
|| sanitizedResponseBytes >= payloadSoftLimitBytes
|
|
1602
|
+
|| page.elapsedMs >= Math.max(0, targetBudgetMs - budgetReserveMs)
|
|
1603
|
+
|| remainingBudgetMs <= budgetReserveMs
|
|
1604
|
+
) {
|
|
1605
|
+
break;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
return response ?? {
|
|
1610
|
+
id: request.id,
|
|
1611
|
+
result: {
|
|
1612
|
+
data: [],
|
|
1613
|
+
},
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
async function fetchMeasuredAdaptiveTurnsListPage(fetchPage, params, now) {
|
|
1618
|
+
const startedAt = now();
|
|
1619
|
+
const result = await fetchPage(params);
|
|
1620
|
+
const elapsedMs = Math.max(0, now() - startedAt);
|
|
1621
|
+
return {
|
|
1622
|
+
result,
|
|
1623
|
+
elapsedMs,
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function selectAdaptiveTurnsListBatchLimit(fetchedTurnCount, remainingTurnCount) {
|
|
1628
|
+
if (fetchedTurnCount <= 0) {
|
|
1629
|
+
return Math.min(1, remainingTurnCount);
|
|
1630
|
+
}
|
|
1631
|
+
if (fetchedTurnCount <= 1) {
|
|
1632
|
+
return Math.min(4, remainingTurnCount);
|
|
1633
|
+
}
|
|
1634
|
+
return remainingTurnCount;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
function buildAdaptiveTurnsListPageParams(baseParams, limit, cursor) {
|
|
1638
|
+
const params = {
|
|
1639
|
+
...baseParams,
|
|
1640
|
+
limit,
|
|
1641
|
+
};
|
|
1642
|
+
if (hasRelayCursor(cursor)) {
|
|
1643
|
+
params.cursor = cursor;
|
|
1644
|
+
} else {
|
|
1645
|
+
delete params.cursor;
|
|
1646
|
+
}
|
|
1647
|
+
return params;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function findTurnsListResultKey(result) {
|
|
1651
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
1652
|
+
return null;
|
|
1653
|
+
}
|
|
1654
|
+
return RELAY_TURNS_LIST_RESULT_KEYS.find((key) => Array.isArray(result[key])) || null;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
function buildAdaptiveTurnsListResult(firstResult, lastResult, turnsKey, turns) {
|
|
1658
|
+
const result = {
|
|
1659
|
+
...firstResult,
|
|
1660
|
+
};
|
|
1661
|
+
for (const key of RELAY_TURNS_LIST_RESULT_KEYS) {
|
|
1662
|
+
delete result[key];
|
|
1663
|
+
}
|
|
1664
|
+
result[turnsKey] = turns;
|
|
1665
|
+
|
|
1666
|
+
for (const key of RELAY_TURNS_LIST_PAGINATION_RESULT_KEYS) {
|
|
1667
|
+
if (Object.prototype.hasOwnProperty.call(lastResult, key)) {
|
|
1668
|
+
result[key] = lastResult[key];
|
|
1669
|
+
} else {
|
|
1670
|
+
delete result[key];
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
return result;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function readTurnsListNextCursor(result) {
|
|
1678
|
+
if (!result || typeof result !== "object") {
|
|
1679
|
+
return undefined;
|
|
1680
|
+
}
|
|
1681
|
+
if (hasRelayCursor(result.nextCursor)) {
|
|
1682
|
+
return result.nextCursor;
|
|
1683
|
+
}
|
|
1684
|
+
if (hasRelayCursor(result.next_cursor)) {
|
|
1685
|
+
return result.next_cursor;
|
|
1686
|
+
}
|
|
1687
|
+
return undefined;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function hasRelayCursor(cursor) {
|
|
1691
|
+
return cursor !== undefined && cursor !== null && cursor !== "";
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function jsonByteLength(value) {
|
|
1695
|
+
try {
|
|
1696
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
1697
|
+
} catch {
|
|
1698
|
+
return Number.POSITIVE_INFINITY;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay) {
|
|
1703
|
+
try {
|
|
1704
|
+
const rawResponse = JSON.stringify(response);
|
|
1705
|
+
const sanitizedResponse = sanitizeForRelay(rawResponse, "thread/turns/list");
|
|
1706
|
+
return Buffer.byteLength(sanitizedResponse, "utf8");
|
|
1707
|
+
} catch {
|
|
1708
|
+
return Number.POSITIVE_INFINITY;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// Shrinks thread history snapshots/pages for mobile relay delivery.
|
|
1713
|
+
// This elides bulky blobs and replaces oversized older history with a compact marker.
|
|
1714
|
+
function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod) {
|
|
1715
|
+
if (requestMethod === "thread/turns/list") {
|
|
1716
|
+
return sanitizeThreadTurnsListForRelay(rawMessage);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
if (requestMethod !== "thread/read" && requestMethod !== "thread/resume") {
|
|
1720
|
+
return rawMessage;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
const parsed = parseBridgeJSON(rawMessage);
|
|
1724
|
+
const thread = parsed?.result?.thread;
|
|
1725
|
+
if (!thread || typeof thread !== "object" || !Array.isArray(thread.turns)) {
|
|
1726
|
+
return rawMessage;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
const threadId = normalizeNonEmptyString(thread.id)
|
|
1730
|
+
|| normalizeNonEmptyString(thread.threadId)
|
|
1731
|
+
|| normalizeNonEmptyString(thread.thread_id);
|
|
1732
|
+
const { turns: sanitizedTurns, didSanitize } = sanitizeRelayHistoryTurns(thread.turns, threadId);
|
|
1532
1733
|
|
|
1533
1734
|
if (!didSanitize) {
|
|
1534
1735
|
const trimmedPayload = trimThreadPayloadForRelay(parsed, thread);
|
|
@@ -1549,6 +1750,108 @@ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod) {
|
|
|
1549
1750
|
return trimThreadPayloadForRelay(parseBridgeJSON(sanitizedPayload), null) ?? sanitizedPayload;
|
|
1550
1751
|
}
|
|
1551
1752
|
|
|
1753
|
+
function sanitizeThreadTurnsListForRelay(rawMessage) {
|
|
1754
|
+
const parsed = parseBridgeJSON(rawMessage);
|
|
1755
|
+
const result = parsed?.result;
|
|
1756
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
1757
|
+
return rawMessage;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
const turnsKey = ["data", "items", "turns"].find((key) => Array.isArray(result[key]));
|
|
1761
|
+
if (!turnsKey) {
|
|
1762
|
+
return rawMessage;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
const threadId = normalizeNonEmptyString(result.threadId)
|
|
1766
|
+
|| normalizeNonEmptyString(result.thread_id)
|
|
1767
|
+
|| normalizeNonEmptyString(result.thread?.id)
|
|
1768
|
+
|| normalizeNonEmptyString(result.thread?.threadId)
|
|
1769
|
+
|| normalizeNonEmptyString(result.thread?.thread_id);
|
|
1770
|
+
const { turns: sanitizedTurns, didSanitize } = sanitizeRelayHistoryTurns(result[turnsKey], threadId);
|
|
1771
|
+
const sanitizedParsed = didSanitize
|
|
1772
|
+
? {
|
|
1773
|
+
...parsed,
|
|
1774
|
+
result: {
|
|
1775
|
+
...result,
|
|
1776
|
+
[turnsKey]: sanitizedTurns,
|
|
1777
|
+
},
|
|
1778
|
+
}
|
|
1779
|
+
: parsed;
|
|
1780
|
+
|
|
1781
|
+
return trimTurnsListPayloadForRelay(sanitizedParsed, turnsKey, didSanitize ? null : rawMessage);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function sanitizeRelayHistoryTurns(turns, threadId = "") {
|
|
1785
|
+
let didSanitize = false;
|
|
1786
|
+
const sanitizedTurns = turns.map((turn) => {
|
|
1787
|
+
const sanitizedTurn = sanitizeRelayHistoryTurn(turn, threadId);
|
|
1788
|
+
if (sanitizedTurn !== turn) {
|
|
1789
|
+
didSanitize = true;
|
|
1790
|
+
}
|
|
1791
|
+
return sanitizedTurn;
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1794
|
+
return { turns: sanitizedTurns, didSanitize };
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function sanitizeRelayHistoryTurn(turn, threadId = "") {
|
|
1798
|
+
if (!turn || typeof turn !== "object" || !Array.isArray(turn.items)) {
|
|
1799
|
+
return turn;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
let turnDidChange = false;
|
|
1803
|
+
const turnThreadId = normalizeNonEmptyString(threadId)
|
|
1804
|
+
|| normalizeNonEmptyString(turn.threadId)
|
|
1805
|
+
|| normalizeNonEmptyString(turn.thread_id);
|
|
1806
|
+
const sanitizedItems = turn.items.map((item) => {
|
|
1807
|
+
if (!item || typeof item !== "object") {
|
|
1808
|
+
return item;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
let itemDidChange = false;
|
|
1812
|
+
let sanitizedItem = annotateImageGenerationHistoryItem(item, turnThreadId);
|
|
1813
|
+
if (sanitizedItem !== item) {
|
|
1814
|
+
itemDidChange = true;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
if (Array.isArray(sanitizedItem.content)) {
|
|
1818
|
+
const sanitizedContent = sanitizedItem.content.map((contentItem) => {
|
|
1819
|
+
const sanitizedEntry = sanitizeInlineHistoryImageContentItem(contentItem);
|
|
1820
|
+
if (sanitizedEntry !== contentItem) {
|
|
1821
|
+
itemDidChange = true;
|
|
1822
|
+
}
|
|
1823
|
+
return sanitizedEntry;
|
|
1824
|
+
});
|
|
1825
|
+
|
|
1826
|
+
if (itemDidChange) {
|
|
1827
|
+
sanitizedItem = {
|
|
1828
|
+
...sanitizedItem,
|
|
1829
|
+
content: sanitizedContent,
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
const sanitizedCompactionItem = sanitizeCompactionHistoryItem(sanitizedItem);
|
|
1835
|
+
if (sanitizedCompactionItem !== sanitizedItem) {
|
|
1836
|
+
sanitizedItem = sanitizedCompactionItem;
|
|
1837
|
+
itemDidChange = true;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
if (itemDidChange) {
|
|
1841
|
+
turnDidChange = true;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
return itemDidChange ? sanitizedItem : item;
|
|
1845
|
+
});
|
|
1846
|
+
|
|
1847
|
+
return turnDidChange
|
|
1848
|
+
? {
|
|
1849
|
+
...turn,
|
|
1850
|
+
items: sanitizedItems,
|
|
1851
|
+
}
|
|
1852
|
+
: turn;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1552
1855
|
// Annotates live image-generation notifications so the phone can render a local-file
|
|
1553
1856
|
// preview and does not receive the bulky inline base64 result over the relay.
|
|
1554
1857
|
function sanitizeLiveGeneratedImageMessageForRelay(rawMessage) {
|
|
@@ -1951,6 +2254,55 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
|
|
|
1951
2254
|
return encodeRelayThreadPayload(parsed, candidateThread);
|
|
1952
2255
|
}
|
|
1953
2256
|
|
|
2257
|
+
function trimTurnsListPayloadForRelay(parsed, turnsKey, originalRawMessage = null) {
|
|
2258
|
+
const result = parsed?.result;
|
|
2259
|
+
const turns = result?.[turnsKey];
|
|
2260
|
+
if (!parsed || !result || !Array.isArray(turns)) {
|
|
2261
|
+
return originalRawMessage ?? JSON.stringify(parsed);
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
const encoded = JSON.stringify(parsed);
|
|
2265
|
+
if (Buffer.byteLength(encoded, "utf8") <= RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES) {
|
|
2266
|
+
return originalRawMessage ?? encoded;
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
let fallbackCompactedPayload = null;
|
|
2270
|
+
for (const maxChars of [
|
|
2271
|
+
RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS,
|
|
2272
|
+
Math.floor(RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS / 4),
|
|
2273
|
+
1_000,
|
|
2274
|
+
0,
|
|
2275
|
+
]) {
|
|
2276
|
+
const compactedTurns = turns.map((turn) => compactTurnsListTurnForRelay(turn, maxChars));
|
|
2277
|
+
const compactedPayload = JSON.stringify({
|
|
2278
|
+
...parsed,
|
|
2279
|
+
result: {
|
|
2280
|
+
...result,
|
|
2281
|
+
[turnsKey]: compactedTurns,
|
|
2282
|
+
remodexPageCompactedForRelay: true,
|
|
2283
|
+
},
|
|
2284
|
+
});
|
|
2285
|
+
fallbackCompactedPayload = compactedPayload;
|
|
2286
|
+
if (Buffer.byteLength(compactedPayload, "utf8") <= RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES) {
|
|
2287
|
+
return compactedPayload;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
return fallbackCompactedPayload ?? (originalRawMessage ?? encoded);
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
function compactTurnsListTurnForRelay(turn, maxChars) {
|
|
2295
|
+
if (!turn || typeof turn !== "object" || !Array.isArray(turn.items)) {
|
|
2296
|
+
return turn;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
return {
|
|
2300
|
+
...turn,
|
|
2301
|
+
items: turn.items.map((item) => compactHistoryItemForRelay(item, maxChars)),
|
|
2302
|
+
remodexPageCompactedForRelay: true,
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
|
|
1954
2306
|
function buildRelayHistoryCompactedThread(thread, turns, omittedTurnCount, keptTurnCount) {
|
|
1955
2307
|
return {
|
|
1956
2308
|
...thread,
|
|
@@ -2078,7 +2430,7 @@ function compactHistoryItemForRelay(item, maxChars) {
|
|
|
2078
2430
|
itemId: typeof item?.itemId === "string" ? item.itemId : undefined,
|
|
2079
2431
|
relayPayloadTruncated: true,
|
|
2080
2432
|
};
|
|
2081
|
-
const tailText = firstRelayTextTail(item, maxChars);
|
|
2433
|
+
const tailText = maxChars > 0 ? firstRelayTextTail(item, maxChars) : "";
|
|
2082
2434
|
if (tailText) {
|
|
2083
2435
|
compactItem.text = tailText;
|
|
2084
2436
|
}
|
|
@@ -2183,6 +2535,7 @@ module.exports = {
|
|
|
2183
2535
|
buildRelayAccessTokenHeaders,
|
|
2184
2536
|
buildRelayUserAgentHeader,
|
|
2185
2537
|
createMacOSBridgeWakeAssertion,
|
|
2538
|
+
fetchAdaptiveThreadTurnsListForRelay,
|
|
2186
2539
|
hasRelayConnectionGoneStale,
|
|
2187
2540
|
isTerminalRelayCloseCode,
|
|
2188
2541
|
persistBridgePreferences,
|
|
@@ -77,7 +77,6 @@ function createDesktopIpcActionFollower({
|
|
|
77
77
|
|
|
78
78
|
activeThreadIds.add(threadId);
|
|
79
79
|
ipc.ensureConnected();
|
|
80
|
-
recoverThreadBaseline(threadId);
|
|
81
80
|
return false;
|
|
82
81
|
}
|
|
83
82
|
|
|
@@ -111,6 +110,19 @@ function createDesktopIpcActionFollower({
|
|
|
111
110
|
const nextState = applyConversationStateChange(previousState, params.change);
|
|
112
111
|
if (!nextState) {
|
|
113
112
|
if (isPatchChange(params.change)) {
|
|
113
|
+
const emptyState = createEmptyConversationState();
|
|
114
|
+
const speculativeState = applyConversationStateChange(emptyState, params.change);
|
|
115
|
+
const speculativeActions = projectPendingDesktopActions(threadId, speculativeState);
|
|
116
|
+
if (speculativeActions.length > 0) {
|
|
117
|
+
rawStatesByThreadId.set(threadId, speculativeState);
|
|
118
|
+
syncProjectedActions(threadId, speculativeActions);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (typeof readConversationState !== "function") {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
114
126
|
queueThreadChange(threadId, params.change);
|
|
115
127
|
recoverThreadBaseline(threadId);
|
|
116
128
|
}
|
|
@@ -219,8 +231,7 @@ function createDesktopIpcActionFollower({
|
|
|
219
231
|
}
|
|
220
232
|
|
|
221
233
|
function recoverThreadBaseline(threadId) {
|
|
222
|
-
if (
|
|
223
|
-
|| recoveringThreadIds.has(threadId)
|
|
234
|
+
if (recoveringThreadIds.has(threadId)
|
|
224
235
|
|| rawStatesByThreadId.has(threadId)) {
|
|
225
236
|
return;
|
|
226
237
|
}
|
|
@@ -230,27 +241,39 @@ function createDesktopIpcActionFollower({
|
|
|
230
241
|
.then(() => readConversationState(threadId))
|
|
231
242
|
.then((baselineState) => {
|
|
232
243
|
if (!baselineState || typeof baselineState !== "object") {
|
|
244
|
+
recoverThreadBaselineFromQueuedChanges(threadId, null);
|
|
233
245
|
return;
|
|
234
246
|
}
|
|
235
247
|
|
|
236
|
-
|
|
237
|
-
const queuedChanges = queuedChangesByThreadId.get(threadId) || [];
|
|
238
|
-
queuedChangesByThreadId.delete(threadId);
|
|
239
|
-
for (const change of queuedChanges) {
|
|
240
|
-
nextState = applyConversationStateChange(nextState, change) || nextState;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
rawStatesByThreadId.set(threadId, nextState);
|
|
244
|
-
syncProjectedActions(threadId, projectPendingDesktopActions(threadId, nextState));
|
|
248
|
+
recoverThreadBaselineFromQueuedChanges(threadId, baselineState);
|
|
245
249
|
})
|
|
246
250
|
.catch((error) => {
|
|
247
251
|
console.warn(`${logPrefix} desktop IPC baseline recovery failed for ${threadId}: ${error.message}`);
|
|
252
|
+
recoverThreadBaselineFromQueuedChanges(threadId, null);
|
|
248
253
|
})
|
|
249
254
|
.finally(() => {
|
|
250
255
|
recoveringThreadIds.delete(threadId);
|
|
251
256
|
});
|
|
252
257
|
}
|
|
253
258
|
|
|
259
|
+
function recoverThreadBaselineFromQueuedChanges(threadId, baselineState) {
|
|
260
|
+
const queuedChanges = queuedChangesByThreadId.get(threadId) || [];
|
|
261
|
+
if (queuedChanges.length === 0) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
queuedChangesByThreadId.delete(threadId);
|
|
266
|
+
let nextState = baselineState && typeof baselineState === "object"
|
|
267
|
+
? cloneJSON(baselineState)
|
|
268
|
+
: createEmptyConversationState();
|
|
269
|
+
for (const change of queuedChanges) {
|
|
270
|
+
nextState = applyConversationStateChange(nextState, change) || nextState;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
rawStatesByThreadId.set(threadId, nextState);
|
|
274
|
+
syncProjectedActions(threadId, projectPendingDesktopActions(threadId, nextState));
|
|
275
|
+
}
|
|
276
|
+
|
|
254
277
|
return {
|
|
255
278
|
observeInbound,
|
|
256
279
|
stopAll,
|
|
@@ -555,6 +578,13 @@ function seedConversationStateFromThreadRead(response) {
|
|
|
555
578
|
};
|
|
556
579
|
}
|
|
557
580
|
|
|
581
|
+
function createEmptyConversationState() {
|
|
582
|
+
return {
|
|
583
|
+
turns: [],
|
|
584
|
+
requests: [],
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
558
588
|
function applyImmerPatch(target, patch) {
|
|
559
589
|
const patchPath = Array.isArray(patch?.path) ? patch.path : [];
|
|
560
590
|
const op = readString(patch?.op).toLowerCase();
|
package/src/git-handler.js
CHANGED
|
@@ -51,7 +51,20 @@ function handleGitRequest(rawMessage, sendResponse, options = {}) {
|
|
|
51
51
|
const id = parsed.id;
|
|
52
52
|
const params = parsed.params || {};
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
// Lets long-running git flows push interim progress events to the phone.
|
|
55
|
+
const sendNotification = (notificationMethod, notificationParams) => {
|
|
56
|
+
if (typeof notificationMethod !== "string" || !notificationMethod) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
sendResponse(JSON.stringify({
|
|
60
|
+
method: notificationMethod,
|
|
61
|
+
params: notificationParams ?? {},
|
|
62
|
+
}));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const methodOptions = { ...options, sendNotification };
|
|
66
|
+
|
|
67
|
+
handleGitMethod(method, params, methodOptions)
|
|
55
68
|
.then((result) => {
|
|
56
69
|
sendResponse(JSON.stringify({ id, result }));
|
|
57
70
|
if (method === "thread/name/set") {
|
|
@@ -979,8 +992,26 @@ async function gitRunStackedAction(cwd, params, options = {}) {
|
|
|
979
992
|
const wantsCommit = action === "commit" || action === "commit_push" || action === "commit_push_pr";
|
|
980
993
|
const wantsPr = action === "create_pr" || action === "commit_push_pr";
|
|
981
994
|
|
|
995
|
+
// Emits phase progress events on the same wire used by JSON-RPC responses
|
|
996
|
+
// so the iOS toast can reflect the live step (commit/push/PR) of a stacked action.
|
|
997
|
+
const progressId = typeof params.progressId === "string" && params.progressId.trim()
|
|
998
|
+
? params.progressId.trim()
|
|
999
|
+
: null;
|
|
1000
|
+
const emitPhase = (phase, status) => {
|
|
1001
|
+
if (!progressId || typeof options.sendNotification !== "function") {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
options.sendNotification("git/stackedAction/progress", {
|
|
1005
|
+
progressId,
|
|
1006
|
+
phase,
|
|
1007
|
+
status,
|
|
1008
|
+
});
|
|
1009
|
+
};
|
|
1010
|
+
|
|
982
1011
|
if (params.featureBranch === true) {
|
|
1012
|
+
emitPhase("branch", "started");
|
|
983
1013
|
await gitCreateFeatureBranch(cwd, params);
|
|
1014
|
+
emitPhase("branch", "completed");
|
|
984
1015
|
}
|
|
985
1016
|
|
|
986
1017
|
const branch = await currentBranchName(cwd);
|
|
@@ -1006,6 +1037,7 @@ async function gitRunStackedAction(cwd, params, options = {}) {
|
|
|
1006
1037
|
if (wantsCommit) {
|
|
1007
1038
|
const statusBeforeCommit = await gitStatus(cwd);
|
|
1008
1039
|
if (statusBeforeCommit.dirty) {
|
|
1040
|
+
emitPhase("commit", "started");
|
|
1009
1041
|
const commitResult = await gitCommit(cwd, {
|
|
1010
1042
|
message: params.commitMessage || params.message,
|
|
1011
1043
|
});
|
|
@@ -1015,10 +1047,12 @@ async function gitRunStackedAction(cwd, params, options = {}) {
|
|
|
1015
1047
|
commitSha: commitResult.hash,
|
|
1016
1048
|
subject: firstCommitMessageLine(params.commitMessage || params.message),
|
|
1017
1049
|
};
|
|
1050
|
+
emitPhase("commit", "completed");
|
|
1018
1051
|
} else if (action === "commit") {
|
|
1019
1052
|
throw gitError("nothing_to_commit", "Nothing to commit.");
|
|
1020
1053
|
} else {
|
|
1021
1054
|
result.commit = { status: "skipped_clean" };
|
|
1055
|
+
emitPhase("commit", "skipped");
|
|
1022
1056
|
}
|
|
1023
1057
|
}
|
|
1024
1058
|
|
|
@@ -1039,17 +1073,21 @@ async function gitRunStackedAction(cwd, params, options = {}) {
|
|
|
1039
1073
|
if (statusBeforePush.dirty) {
|
|
1040
1074
|
throw gitError("dirty_worktree", "Commit or stash local changes before pushing.");
|
|
1041
1075
|
}
|
|
1076
|
+
emitPhase("push", "started");
|
|
1042
1077
|
result.push = {
|
|
1043
1078
|
state: "pushed",
|
|
1044
1079
|
...(await gitPush(cwd)),
|
|
1045
1080
|
};
|
|
1081
|
+
emitPhase("push", "completed");
|
|
1046
1082
|
}
|
|
1047
1083
|
|
|
1048
1084
|
if (wantsPr) {
|
|
1085
|
+
emitPhase("createPR", "started");
|
|
1049
1086
|
result.pr = await gitCreatePullRequest(cwd, {
|
|
1050
1087
|
...params,
|
|
1051
1088
|
pushBeforeCreate: false,
|
|
1052
1089
|
}, options);
|
|
1090
|
+
emitPhase("createPR", "completed");
|
|
1053
1091
|
}
|
|
1054
1092
|
|
|
1055
1093
|
result.status = await gitStatus(cwd);
|
|
@@ -33,8 +33,8 @@ const DEFAULT_PAIRING_WAIT_TIMEOUT_MS = 10_000;
|
|
|
33
33
|
const DEFAULT_PAIRING_WAIT_INTERVAL_MS = 200;
|
|
34
34
|
|
|
35
35
|
// Runs the bridge inside launchd while keeping QR rendering in the foreground CLI command.
|
|
36
|
-
function runMacOSBridgeService({ env = process.env } = {}) {
|
|
37
|
-
assertDarwinPlatform();
|
|
36
|
+
function runMacOSBridgeService({ env = process.env, platform = process.platform } = {}) {
|
|
37
|
+
assertDarwinPlatform(platform);
|
|
38
38
|
const config = readDaemonConfig({ env });
|
|
39
39
|
if (!config?.relayUrl) {
|
|
40
40
|
const message = "No relay URL configured for the macOS bridge service.";
|
package/src/rollout-watch.js
CHANGED
|
@@ -441,12 +441,12 @@ function findNewestRolloutFileForThread(root, threadId, { fsModule = fs } = {})
|
|
|
441
441
|
continue;
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
444
|
+
const candidate = {
|
|
445
|
+
filePath: fullPath,
|
|
446
|
+
mtimeMs: fsModule.statSync(fullPath).mtimeMs,
|
|
447
|
+
};
|
|
448
|
+
if (!newestMatch || compareRolloutFileOrder(candidate, newestMatch) < 0) {
|
|
449
|
+
newestMatch = candidate;
|
|
450
450
|
}
|
|
451
451
|
}
|
|
452
452
|
}
|
|
@@ -498,10 +498,20 @@ function collectRecentRolloutFiles(
|
|
|
498
498
|
}
|
|
499
499
|
}
|
|
500
500
|
|
|
501
|
-
candidates.sort(
|
|
501
|
+
candidates.sort(compareRolloutFileOrder);
|
|
502
502
|
return candidates.slice(0, candidateLimit);
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
+
// Keeps rollout selection deterministic when filesystem timestamp resolution
|
|
506
|
+
// reports equal mtimes for several rollout candidates.
|
|
507
|
+
function compareRolloutFileOrder(lhs, rhs) {
|
|
508
|
+
if (lhs.mtimeMs !== rhs.mtimeMs) {
|
|
509
|
+
return rhs.mtimeMs - lhs.mtimeMs;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return rhs.filePath.localeCompare(lhs.filePath);
|
|
513
|
+
}
|
|
514
|
+
|
|
505
515
|
function rolloutFileContainsTurnId(
|
|
506
516
|
filePath,
|
|
507
517
|
turnId,
|
|
@@ -14,6 +14,7 @@ const DEFAULT_STORE_DIR = path.join(os.homedir(), ".remodex");
|
|
|
14
14
|
const DEFAULT_STORE_FILE = path.join(DEFAULT_STORE_DIR, "device-state.json");
|
|
15
15
|
const KEYCHAIN_SERVICE = "com.remodex.bridge.device-state";
|
|
16
16
|
const KEYCHAIN_ACCOUNT = "default";
|
|
17
|
+
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
17
18
|
let hasLoggedKeychainMismatch = false;
|
|
18
19
|
|
|
19
20
|
// Loads the canonical bridge state or bootstraps a fresh one when no trusted state exists yet.
|
|
@@ -22,8 +23,11 @@ function loadOrCreateBridgeDeviceState() {
|
|
|
22
23
|
const keychainRecord = readKeychainStateRecord();
|
|
23
24
|
|
|
24
25
|
if (fileRecord.state) {
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
const canonicalState = recoverBridgeDeviceIdentity(fileRecord.state, {
|
|
27
|
+
fallbackState: keychainRecord.state,
|
|
28
|
+
});
|
|
29
|
+
reconcileLegacyKeychainMirror(canonicalState, keychainRecord);
|
|
30
|
+
return canonicalState;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
if (fileRecord.error) {
|
|
@@ -31,8 +35,9 @@ function loadOrCreateBridgeDeviceState() {
|
|
|
31
35
|
warnOnce(
|
|
32
36
|
"[remodex] Recovering the canonical device-state.json from the legacy Keychain pairing mirror."
|
|
33
37
|
);
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
const recoveredState = recoverBridgeDeviceIdentity(keychainRecord.state);
|
|
39
|
+
writeBridgeDeviceState(recoveredState);
|
|
40
|
+
return recoveredState;
|
|
36
41
|
}
|
|
37
42
|
throw corruptedStateError("device-state.json", fileRecord.error);
|
|
38
43
|
}
|
|
@@ -47,8 +52,9 @@ function loadOrCreateBridgeDeviceState() {
|
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
if (keychainRecord.state) {
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
const recoveredState = recoverBridgeDeviceIdentity(keychainRecord.state);
|
|
56
|
+
writeBridgeDeviceState(recoveredState);
|
|
57
|
+
return recoveredState;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
const nextState = createBridgeDeviceState();
|
|
@@ -382,10 +388,32 @@ function normalizeBridgeDeviceState(rawState) {
|
|
|
382
388
|
};
|
|
383
389
|
}
|
|
384
390
|
|
|
391
|
+
function recoverBridgeDeviceIdentity(state, { fallbackState = null } = {}) {
|
|
392
|
+
if (isValidBridgeDeviceId(state?.macDeviceId)) {
|
|
393
|
+
return state;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (fallbackState && isValidBridgeDeviceId(fallbackState.macDeviceId)) {
|
|
397
|
+
warnOnce("[remodex] Recovering an invalid saved bridge device identity from the legacy Keychain mirror.");
|
|
398
|
+
writeBridgeDeviceState(fallbackState);
|
|
399
|
+
return fallbackState;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
warnOnce("[remodex] Rotating an invalid saved bridge device identity; scan the fresh QR to pair again.");
|
|
403
|
+
const nextState = createBridgeDeviceState();
|
|
404
|
+
nextState.lastSeenPhoneAppVersion = state?.lastSeenPhoneAppVersion || null;
|
|
405
|
+
writeBridgeDeviceState(nextState);
|
|
406
|
+
return nextState;
|
|
407
|
+
}
|
|
408
|
+
|
|
385
409
|
function bridgeStatesEqual(left, right) {
|
|
386
410
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
387
411
|
}
|
|
388
412
|
|
|
413
|
+
function isValidBridgeDeviceId(value) {
|
|
414
|
+
return UUID_V4_PATTERN.test(normalizeNonEmptyString(value));
|
|
415
|
+
}
|
|
416
|
+
|
|
389
417
|
function normalizeNonEmptyString(value) {
|
|
390
418
|
if (typeof value !== "string") {
|
|
391
419
|
return "";
|
package/src/workspace-handler.js
CHANGED
|
@@ -102,6 +102,10 @@ async function handleWorkspaceMethod(method, params) {
|
|
|
102
102
|
return workspaceRevertPatchPreview(repoRoot, params);
|
|
103
103
|
case "workspace/revertPatchApply":
|
|
104
104
|
return withRepoMutationLock(repoRoot, () => workspaceRevertPatchApply(repoRoot, params));
|
|
105
|
+
case "workspace/revertPatchBatchPreview":
|
|
106
|
+
return workspaceRevertPatchBatchPreview(repoRoot, params);
|
|
107
|
+
case "workspace/revertPatchBatchApply":
|
|
108
|
+
return withRepoMutationLock(repoRoot, () => workspaceRevertPatchBatchApply(repoRoot, params));
|
|
105
109
|
default:
|
|
106
110
|
throw workspaceError("unknown_method", `Unknown workspace method: ${method}`);
|
|
107
111
|
}
|
|
@@ -369,7 +373,7 @@ async function workspaceRevertPatchPreview(repoRoot, params) {
|
|
|
369
373
|
};
|
|
370
374
|
}
|
|
371
375
|
|
|
372
|
-
const applyCheck = await
|
|
376
|
+
const applyCheck = await checkReversePatch(repoRoot, forwardPatch);
|
|
373
377
|
const conflicts = applyCheck.ok
|
|
374
378
|
? []
|
|
375
379
|
: parseApplyConflicts(applyCheck.stderr || applyCheck.stdout || "Patch does not apply.");
|
|
@@ -385,6 +389,7 @@ async function workspaceRevertPatchPreview(repoRoot, params) {
|
|
|
385
389
|
|
|
386
390
|
// Reverse-applies the patch only after the same safety checks pass in the locked mutation path.
|
|
387
391
|
async function workspaceRevertPatchApply(repoRoot, params) {
|
|
392
|
+
const forwardPatch = resolveForwardPatch(params);
|
|
388
393
|
const preview = await workspaceRevertPatchPreview(repoRoot, params);
|
|
389
394
|
if (!preview.canRevert) {
|
|
390
395
|
return {
|
|
@@ -396,8 +401,10 @@ async function workspaceRevertPatchApply(repoRoot, params) {
|
|
|
396
401
|
};
|
|
397
402
|
}
|
|
398
403
|
|
|
399
|
-
const
|
|
400
|
-
const applyResult =
|
|
404
|
+
const checkedPatch = await checkReversePatch(repoRoot, forwardPatch);
|
|
405
|
+
const applyResult = checkedPatch.ok
|
|
406
|
+
? await runGitApply(repoRoot, checkedPatch.applyArgs, forwardPatch)
|
|
407
|
+
: checkedPatch;
|
|
401
408
|
if (!applyResult.ok) {
|
|
402
409
|
return {
|
|
403
410
|
success: false,
|
|
@@ -409,6 +416,7 @@ async function workspaceRevertPatchApply(repoRoot, params) {
|
|
|
409
416
|
};
|
|
410
417
|
}
|
|
411
418
|
|
|
419
|
+
await resetTargetedFilesIndex(repoRoot, preview.affectedFiles);
|
|
412
420
|
const status = await gitStatus(repoRoot).catch(() => null);
|
|
413
421
|
return {
|
|
414
422
|
success: true,
|
|
@@ -420,6 +428,91 @@ async function workspaceRevertPatchApply(repoRoot, params) {
|
|
|
420
428
|
};
|
|
421
429
|
}
|
|
422
430
|
|
|
431
|
+
// Validates a newest-first patch batch as one reverse operation so dependent patches see the right state.
|
|
432
|
+
async function workspaceRevertPatchBatchPreview(repoRoot, params) {
|
|
433
|
+
const patches = resolveForwardPatchBatch(params);
|
|
434
|
+
const analyses = patches.map((patch) => analyzeUnifiedPatch(patch.forwardPatch));
|
|
435
|
+
const affectedFiles = uniqueSorted(analyses.flatMap((analysis) => analysis.affectedFiles));
|
|
436
|
+
const unsupportedReasons = uniqueSorted(analyses.flatMap((analysis) => analysis.unsupportedReasons));
|
|
437
|
+
const stagedFiles = await findStagedTargetedFiles(repoRoot, affectedFiles);
|
|
438
|
+
|
|
439
|
+
if (unsupportedReasons.length || stagedFiles.length) {
|
|
440
|
+
return {
|
|
441
|
+
canRevert: false,
|
|
442
|
+
affectedFiles,
|
|
443
|
+
conflicts: [],
|
|
444
|
+
unsupportedReasons,
|
|
445
|
+
stagedFiles,
|
|
446
|
+
patchResults: patches.map((patch, index) => ({
|
|
447
|
+
id: patch.id,
|
|
448
|
+
canRevert: analyses[index].unsupportedReasons.length === 0,
|
|
449
|
+
unsupportedReasons: analyses[index].unsupportedReasons,
|
|
450
|
+
})),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const sequenceCheck = await previewReversePatchSequence(repoRoot, patches, affectedFiles);
|
|
455
|
+
const conflicts = sequenceCheck.ok
|
|
456
|
+
? []
|
|
457
|
+
: parseApplyConflicts(sequenceCheck.stderr || sequenceCheck.stdout || "Patch batch does not apply.");
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
canRevert: sequenceCheck.ok && conflicts.length === 0,
|
|
461
|
+
affectedFiles,
|
|
462
|
+
conflicts,
|
|
463
|
+
unsupportedReasons: [],
|
|
464
|
+
stagedFiles,
|
|
465
|
+
patchResults: patches.map((patch) => ({
|
|
466
|
+
id: patch.id,
|
|
467
|
+
canRevert: sequenceCheck.ok && conflicts.length === 0,
|
|
468
|
+
})),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Applies all batch patches under one repo lock and only marks success after the full reverse patch lands.
|
|
473
|
+
async function workspaceRevertPatchBatchApply(repoRoot, params) {
|
|
474
|
+
const patches = resolveForwardPatchBatch(params);
|
|
475
|
+
const preview = await workspaceRevertPatchBatchPreview(repoRoot, params);
|
|
476
|
+
if (!preview.canRevert) {
|
|
477
|
+
return {
|
|
478
|
+
success: false,
|
|
479
|
+
revertedFiles: [],
|
|
480
|
+
conflicts: preview.conflicts,
|
|
481
|
+
unsupportedReasons: preview.unsupportedReasons,
|
|
482
|
+
stagedFiles: preview.stagedFiles,
|
|
483
|
+
patchResults: preview.patchResults || [],
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const applyResult = await applyReversePatchSequence(repoRoot, patches, preview.affectedFiles);
|
|
488
|
+
if (!applyResult.ok) {
|
|
489
|
+
return {
|
|
490
|
+
success: false,
|
|
491
|
+
revertedFiles: [],
|
|
492
|
+
conflicts: parseApplyConflicts(applyResult.stderr || applyResult.stdout || "Patch batch does not apply."),
|
|
493
|
+
unsupportedReasons: [],
|
|
494
|
+
stagedFiles: [],
|
|
495
|
+
patchResults: patches.map((patch) => ({
|
|
496
|
+
id: patch.id,
|
|
497
|
+
applied: applyResult.appliedPatchIds.includes(patch.id),
|
|
498
|
+
})),
|
|
499
|
+
status: await gitStatus(repoRoot).catch(() => null),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
await resetTargetedFilesIndex(repoRoot, preview.affectedFiles);
|
|
504
|
+
const status = await gitStatus(repoRoot).catch(() => null);
|
|
505
|
+
return {
|
|
506
|
+
success: true,
|
|
507
|
+
revertedFiles: preview.affectedFiles,
|
|
508
|
+
conflicts: [],
|
|
509
|
+
unsupportedReasons: [],
|
|
510
|
+
stagedFiles: [],
|
|
511
|
+
patchResults: patches.map((patch) => ({ id: patch.id, applied: true })),
|
|
512
|
+
status,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
423
516
|
function resolveForwardPatch(params) {
|
|
424
517
|
const forwardPatch =
|
|
425
518
|
typeof params.forwardPatch === "string" ? params.forwardPatch : "";
|
|
@@ -431,6 +524,32 @@ function resolveForwardPatch(params) {
|
|
|
431
524
|
return forwardPatch.endsWith("\n") ? forwardPatch : `${forwardPatch}\n`;
|
|
432
525
|
}
|
|
433
526
|
|
|
527
|
+
function resolveForwardPatchBatch(params) {
|
|
528
|
+
const rawPatches = Array.isArray(params.patches) ? params.patches : [];
|
|
529
|
+
const patches = rawPatches.map((rawPatch, index) => {
|
|
530
|
+
if (typeof rawPatch === "string") {
|
|
531
|
+
return {
|
|
532
|
+
id: String(index),
|
|
533
|
+
forwardPatch: rawPatch.endsWith("\n") ? rawPatch : `${rawPatch}\n`,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const forwardPatch = rawPatch && typeof rawPatch.forwardPatch === "string"
|
|
538
|
+
? rawPatch.forwardPatch
|
|
539
|
+
: "";
|
|
540
|
+
return {
|
|
541
|
+
id: rawPatch && typeof rawPatch.id === "string" ? rawPatch.id : String(index),
|
|
542
|
+
forwardPatch: forwardPatch.endsWith("\n") ? forwardPatch : `${forwardPatch}\n`,
|
|
543
|
+
};
|
|
544
|
+
}).filter((patch) => patch.forwardPatch.trim());
|
|
545
|
+
|
|
546
|
+
if (!patches.length) {
|
|
547
|
+
throw workspaceError("missing_patch", "The request must include at least one non-empty patch.");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return patches;
|
|
551
|
+
}
|
|
552
|
+
|
|
434
553
|
function analyzeUnifiedPatch(rawPatch) {
|
|
435
554
|
const patch = rawPatch.trim();
|
|
436
555
|
if (!patch) {
|
|
@@ -587,6 +706,188 @@ async function findStagedTargetedFiles(cwd, affectedFiles) {
|
|
|
587
706
|
}
|
|
588
707
|
}
|
|
589
708
|
|
|
709
|
+
async function previewReversePatchSequence(repoRoot, patches, affectedFiles) {
|
|
710
|
+
const sandboxRoot = await createPatchSandbox(repoRoot, affectedFiles);
|
|
711
|
+
|
|
712
|
+
try {
|
|
713
|
+
for (const patch of patches) {
|
|
714
|
+
const check = await checkReversePatch(sandboxRoot, patch.forwardPatch);
|
|
715
|
+
if (!check.ok) {
|
|
716
|
+
return { ...check, failedPatchId: patch.id };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const applied = await runGitApply(sandboxRoot, check.applyArgs, patch.forwardPatch);
|
|
720
|
+
if (!applied.ok) {
|
|
721
|
+
return { ...applied, failedPatchId: patch.id };
|
|
722
|
+
}
|
|
723
|
+
await syncPatchSandboxIndex(sandboxRoot);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return { ok: true, stdout: "", stderr: "" };
|
|
727
|
+
} finally {
|
|
728
|
+
await fs.promises.rm(sandboxRoot, { recursive: true, force: true }).catch(() => {});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function applyReversePatchSequence(repoRoot, patches, affectedFiles) {
|
|
733
|
+
const appliedPatchIds = [];
|
|
734
|
+
const backup = await createPatchBackup(repoRoot, affectedFiles);
|
|
735
|
+
|
|
736
|
+
try {
|
|
737
|
+
for (const patch of patches) {
|
|
738
|
+
const checkedPatch = await checkReversePatch(repoRoot, patch.forwardPatch);
|
|
739
|
+
if (!checkedPatch.ok) {
|
|
740
|
+
await restorePatchBackup(repoRoot, backup);
|
|
741
|
+
return { ...checkedPatch, appliedPatchIds, failedPatchId: patch.id };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const appliedPatch = await runGitApply(repoRoot, checkedPatch.applyArgs, patch.forwardPatch);
|
|
745
|
+
if (!appliedPatch.ok) {
|
|
746
|
+
await restorePatchBackup(repoRoot, backup);
|
|
747
|
+
return { ...appliedPatch, appliedPatchIds, failedPatchId: patch.id };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
appliedPatchIds.push(patch.id);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return { ok: true, stdout: "", stderr: "", appliedPatchIds };
|
|
754
|
+
} finally {
|
|
755
|
+
await fs.promises.rm(backup.root, { recursive: true, force: true }).catch(() => {});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function createPatchSandbox(repoRoot, affectedFiles) {
|
|
760
|
+
const sandboxRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "remodex-revert-preview-"));
|
|
761
|
+
|
|
762
|
+
for (const affectedFile of affectedFiles) {
|
|
763
|
+
const sourcePath = path.resolve(repoRoot, affectedFile);
|
|
764
|
+
if (!isPathInside(sourcePath, repoRoot)) {
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const destinationPath = path.resolve(sandboxRoot, affectedFile);
|
|
769
|
+
if (!isPathInside(destinationPath, sandboxRoot) || !fs.existsSync(sourcePath)) {
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
await fs.promises.mkdir(path.dirname(destinationPath), { recursive: true });
|
|
774
|
+
await fs.promises.copyFile(sourcePath, destinationPath);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
await initializePatchSandboxGitRepo(sandboxRoot);
|
|
778
|
+
return sandboxRoot;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function initializePatchSandboxGitRepo(sandboxRoot) {
|
|
782
|
+
await git(sandboxRoot, "init", "-q");
|
|
783
|
+
await git(sandboxRoot, "config", "user.email", "remodex@example.local");
|
|
784
|
+
await git(sandboxRoot, "config", "user.name", "Remodex");
|
|
785
|
+
await syncPatchSandboxIndex(sandboxRoot);
|
|
786
|
+
await git(sandboxRoot, "commit", "-qm", "snapshot", "--allow-empty");
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function syncPatchSandboxIndex(sandboxRoot) {
|
|
790
|
+
await git(sandboxRoot, "add", "-A");
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function createPatchBackup(repoRoot, affectedFiles) {
|
|
794
|
+
const backupRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "remodex-revert-backup-"));
|
|
795
|
+
const entries = [];
|
|
796
|
+
|
|
797
|
+
for (const affectedFile of affectedFiles) {
|
|
798
|
+
const sourcePath = path.resolve(repoRoot, affectedFile);
|
|
799
|
+
if (!isPathInside(sourcePath, repoRoot)) {
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const backupPath = path.resolve(backupRoot, affectedFile);
|
|
804
|
+
if (!isPathInside(backupPath, backupRoot)) {
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const exists = fs.existsSync(sourcePath);
|
|
809
|
+
entries.push({ relativePath: affectedFile, exists });
|
|
810
|
+
if (!exists) {
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
await fs.promises.mkdir(path.dirname(backupPath), { recursive: true });
|
|
815
|
+
await fs.promises.copyFile(sourcePath, backupPath);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return { root: backupRoot, entries };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function restorePatchBackup(repoRoot, backup) {
|
|
822
|
+
for (const entry of backup.entries) {
|
|
823
|
+
const targetPath = path.resolve(repoRoot, entry.relativePath);
|
|
824
|
+
if (!isPathInside(targetPath, repoRoot)) {
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (!entry.exists) {
|
|
829
|
+
await fs.promises.rm(targetPath, { force: true, recursive: true }).catch(() => {});
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const backupPath = path.resolve(backup.root, entry.relativePath);
|
|
834
|
+
if (!isPathInside(backupPath, backup.root)) {
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
|
839
|
+
await fs.promises.copyFile(backupPath, targetPath);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
await resetTargetedFilesIndex(repoRoot, backup.entries.map((entry) => entry.relativePath));
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function resetTargetedFilesIndex(cwd, affectedFiles) {
|
|
846
|
+
if (!affectedFiles.length) {
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
await git(cwd, "reset", "-q", "--", ...affectedFiles);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async function checkReversePatch(cwd, patchText) {
|
|
854
|
+
if (isFileLifecyclePatch(patchText)) {
|
|
855
|
+
const plainCheck = await runGitApply(cwd, ["apply", "--reverse", "--check"], patchText);
|
|
856
|
+
return { ...plainCheck, applyArgs: ["apply", "--reverse"] };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const codexCheckArgs = ["apply", "--reverse", "--check", "--3way"];
|
|
860
|
+
const plainCheckArgs = ["apply", "--reverse", "--check"];
|
|
861
|
+
const codexCheck = await runGitApply(cwd, codexCheckArgs, patchText);
|
|
862
|
+
|
|
863
|
+
if (codexCheck.ok) {
|
|
864
|
+
return { ...codexCheck, applyArgs: ["apply", "--reverse", "--3way"] };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// git apply --3way requires index/worktree agreement; assistant edits are usually unstaged.
|
|
868
|
+
if (!isIndexMismatch(codexCheck)) {
|
|
869
|
+
return { ...codexCheck, applyArgs: ["apply", "--reverse", "--3way"] };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const plainCheck = await runGitApply(cwd, plainCheckArgs, patchText);
|
|
873
|
+
return { ...plainCheck, applyArgs: ["apply", "--reverse"] };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function isFileLifecyclePatch(patchText) {
|
|
877
|
+
return String(patchText || "")
|
|
878
|
+
.split("\n")
|
|
879
|
+
.some((line) => line === "--- /dev/null" || line === "+++ /dev/null");
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function isIndexMismatch(result) {
|
|
883
|
+
const output = `${result.stderr || ""}\n${result.stdout || ""}`;
|
|
884
|
+
return output.includes("does not match index");
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function uniqueSorted(values) {
|
|
888
|
+
return [...new Set(values.filter(Boolean))].sort();
|
|
889
|
+
}
|
|
890
|
+
|
|
590
891
|
async function runGitApply(cwd, args, patchText) {
|
|
591
892
|
const tempPatchPath = await writeTempPatchFile(patchText);
|
|
592
893
|
|