@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 CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "@makerbi/remodex",
3
- "version": "1.4.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
- // Seeds the desktop IPC follower when it receives patches before a full snapshot.
616
- async function readDesktopConversationState(threadId) {
617
- const result = await sendCodexRequest("thread/read", {
618
- threadId,
619
- includeTurns: true,
620
- });
621
- return seedConversationStateFromThreadRead(result);
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
- // Shrinks `thread/read` and `thread/resume` snapshots for mobile relay delivery.
1458
- // This elides bulky blobs and replaces oversized older history with a compact marker.
1459
- function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod) {
1460
- if (requestMethod !== "thread/read" && requestMethod !== "thread/resume") {
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
- const parsed = parseBridgeJSON(rawMessage);
1465
- const thread = parsed?.result?.thread;
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
- let didSanitize = false;
1471
- const sanitizedTurns = thread.turns.map((turn) => {
1472
- if (!turn || typeof turn !== "object" || !Array.isArray(turn.items)) {
1473
- return turn;
1474
- }
1507
+ if (parsed.id == null) {
1508
+ return null;
1509
+ }
1475
1510
 
1476
- let turnDidChange = false;
1477
- const threadId = normalizeNonEmptyString(thread.id)
1478
- || normalizeNonEmptyString(thread.threadId)
1479
- || normalizeNonEmptyString(thread.thread_id);
1511
+ const params = parsed.params;
1512
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
1513
+ return null;
1514
+ }
1480
1515
 
1481
- const sanitizedItems = turn.items.map((item) => {
1482
- if (!item || typeof item !== "object") {
1483
- return item;
1484
- }
1516
+ if (!Number.isInteger(params.limit) || params.limit <= 0) {
1517
+ return null;
1518
+ }
1485
1519
 
1486
- let itemDidChange = false;
1487
- let sanitizedItem = annotateImageGenerationHistoryItem(item, threadId);
1488
- if (sanitizedItem !== item) {
1489
- itemDidChange = true;
1490
- }
1520
+ return parsed;
1521
+ }
1491
1522
 
1492
- if (Array.isArray(item.content)) {
1493
- const sanitizedContent = item.content.map((contentItem) => {
1494
- const sanitizedEntry = sanitizeInlineHistoryImageContentItem(contentItem);
1495
- if (sanitizedEntry !== contentItem) {
1496
- itemDidChange = true;
1497
- }
1498
- return sanitizedEntry;
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
- if (itemDidChange) {
1502
- sanitizedItem = {
1503
- ...sanitizedItem,
1504
- content: sanitizedContent,
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
- const sanitizedCompactionItem = sanitizeCompactionHistoryItem(sanitizedItem);
1510
- if (sanitizedCompactionItem !== sanitizedItem) {
1511
- sanitizedItem = sanitizedCompactionItem;
1512
- itemDidChange = true;
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
- if (itemDidChange) {
1516
- turnDidChange = true;
1517
- }
1575
+ if (!turnsKey) {
1576
+ turnsKey = pageTurnsKey;
1577
+ }
1578
+ if (!firstResult) {
1579
+ firstResult = pageResult;
1580
+ }
1581
+ lastResult = pageResult;
1518
1582
 
1519
- return itemDidChange ? sanitizedItem : item;
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
- if (!turnDidChange) {
1523
- return turn;
1590
+ nextCursor = readTurnsListNextCursor(pageResult);
1591
+ if (combinedTurns.length >= requestedLimit || !hasRelayCursor(nextCursor) || pageTurns.length === 0) {
1592
+ break;
1524
1593
  }
1525
1594
 
1526
- didSanitize = true;
1527
- return {
1528
- ...turn,
1529
- items: sanitizedItems,
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 (typeof readConversationState !== "function"
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
- let nextState = cloneJSON(baselineState);
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();
@@ -51,7 +51,20 @@ function handleGitRequest(rawMessage, sendResponse, options = {}) {
51
51
  const id = parsed.id;
52
52
  const params = parsed.params || {};
53
53
 
54
- handleGitMethod(method, params, options)
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.";
@@ -441,12 +441,12 @@ function findNewestRolloutFileForThread(root, threadId, { fsModule = fs } = {})
441
441
  continue;
442
442
  }
443
443
 
444
- const stat = fsModule.statSync(fullPath);
445
- if (!newestMatch || stat.mtimeMs > newestMatch.mtimeMs) {
446
- newestMatch = {
447
- filePath: fullPath,
448
- mtimeMs: stat.mtimeMs,
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((lhs, rhs) => rhs.mtimeMs - lhs.mtimeMs);
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
- reconcileLegacyKeychainMirror(fileRecord.state, keychainRecord);
26
- return fileRecord.state;
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
- writeBridgeDeviceState(keychainRecord.state);
35
- return keychainRecord.state;
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
- writeBridgeDeviceState(keychainRecord.state);
51
- return keychainRecord.state;
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 "";
@@ -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 runGitApply(repoRoot, ["apply", "--reverse", "--check"], forwardPatch);
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 forwardPatch = resolveForwardPatch(params);
400
- const applyResult = await runGitApply(repoRoot, ["apply", "--reverse"], forwardPatch);
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