@makerbi/remodex 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,11 +5,19 @@
5
5
  // Depends on: net, ./desktop-ipc-conversation-adapter, ./desktop-ipc-owner-transport, ./desktop-ipc-state-patches, ./desktop-ipc-shared
6
6
 
7
7
  const net = require("net");
8
+ const {
9
+ applyRuntimeSettingsToConversation,
10
+ createThreadMutationQueue,
11
+ hasOwn,
12
+ normalizeThreadSettingsUpdate,
13
+ threadSettingsFromRuntimeSettings,
14
+ } = require("./codex-runtime-settings");
8
15
 
9
16
  const {
10
17
  CLIENT_STATUS_CHANGED,
11
18
  DESKTOP_IPC_METHOD_VERSIONS: METHOD_VERSION_BY_NAME,
12
19
  buildCompleteThreadReadParams,
20
+ buildThreadReadStateContext,
13
21
  cloneJSON,
14
22
  conversationSnapshotShowsActiveTurn,
15
23
  isPlainJSONObject,
@@ -66,6 +74,7 @@ const THREAD_QUEUED_FOLLOWUPS_CHANGED = "thread-queued-followups-changed";
66
74
  const REMODEX_LIVE_OWNER_SOURCE = "desktop-ipc-live-owner";
67
75
 
68
76
  const SUPPORTED_FOLLOWER_REQUEST_METHODS = new Set([
77
+ "thread-owner-discovery",
69
78
  "thread-follower-start-turn",
70
79
  "thread-follower-load-complete-history",
71
80
  "thread-follower-update-thread-settings",
@@ -141,6 +150,7 @@ function createDesktopIpcLiveOwner({
141
150
  maxPatchBytes = DEFAULT_MAX_PATCH_BYTES,
142
151
  requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
143
152
  reconnectMs = DEFAULT_RECONNECT_MS,
153
+ startRouterWhenMissing = false,
144
154
  initialHistoryRetryMs = DEFAULT_INITIAL_HISTORY_RETRY_MS,
145
155
  initialHistoryMaxAttempts = DEFAULT_INITIAL_HISTORY_MAX_ATTEMPTS,
146
156
  liveOwnershipFreshnessMs = DEFAULT_LIVE_OWNERSHIP_FRESHNESS_MS,
@@ -184,6 +194,7 @@ function createDesktopIpcLiveOwner({
184
194
  // until the first user item and turn completion prove the rollout was written.
185
195
  const pendingSidebarMaterializationThreadIds = new Set();
186
196
  const replayedSidebarMaterializationThreadIds = new Set();
197
+ const enqueueMutation = createThreadMutationQueue();
187
198
  const pendingTurnStartParamsByThreadId = new Map();
188
199
  const pendingTurnStartEntriesByRequestId = new Map();
189
200
  const followerRuntimeOverridesByThreadId = new Map();
@@ -192,6 +203,7 @@ function createDesktopIpcLiveOwner({
192
203
  const queuedFollowUpsByThreadId = new Map();
193
204
  const runningQueuedFollowUpThreadIds = new Set();
194
205
  const announcedReadStateThreadIds = new Set();
206
+ const pendingReadStateByThreadId = new Map();
195
207
  const pendingThreadArchiveMetadataByThreadId = new Map();
196
208
  const dirtyThreadIds = new Set();
197
209
  let snapshotTimer = null;
@@ -204,6 +216,10 @@ function createDesktopIpcLiveOwner({
204
216
  requestTimeoutMs,
205
217
  reconnectMs,
206
218
  logPrefix,
219
+ // Let Desktop/VSCode own its bus. Restarting a bridge-owned router forces
220
+ // concurrent Desktop clients to re-elect routers and can split their sockets.
221
+ // Phone history remains local while Desktop is closed; reconnect replays it.
222
+ startRouterWhenMissing,
207
223
  onConnected() {
208
224
  flushPendingThreadArchiveMetadataBroadcasts();
209
225
  requestFollowerStatusForAllOwnedThreads();
@@ -302,7 +318,24 @@ function createDesktopIpcLiveOwner({
302
318
  if (!message || typeof message !== "object") {
303
319
  return;
304
320
  }
305
-
321
+ if (message.method === "account/updated") {
322
+ announcedReadStateThreadIds.clear();
323
+ pendingReadStateByThreadId.clear();
324
+ }
325
+
326
+ if (message.method === "thread/settings/updated") {
327
+ const threadId = readThreadIdFromParams(message.params);
328
+ const settings = message.params?.threadSettings;
329
+ if (threadId && settings && ownedThreadIds.has(threadId)) {
330
+ followerRuntimeOverridesByThreadId.set(threadId, {
331
+ ...(followerRuntimeOverridesByThreadId.get(threadId) || {}),
332
+ ...normalizeThreadSettingsUpdate(settings, { authoritative: true }),
333
+ });
334
+ applyRuntimeSettingsToConversation(conversations.get(threadId), settings, { authoritative: true });
335
+ runtimeSettingsStore?.observe?.(threadId, settings);
336
+ scheduleSnapshot(threadId);
337
+ }
338
+ }
306
339
  const responseId = message.id == null ? "" : String(message.id);
307
340
  if (responseId && !message.method) {
308
341
  resolvePendingTurnStartResponse(responseId, message);
@@ -351,6 +384,11 @@ function createDesktopIpcLiveOwner({
351
384
  refreshOptimisticFallbackForThread(update.threadId);
352
385
  scheduleSnapshot(update.threadId);
353
386
  replaySidebarAnnouncementAfterMaterialization(message, update.threadId);
387
+ if (message.method === "thread/name/updated") {
388
+ // Unopened Desktop threads ignore stream snapshots. Refresh their catalog
389
+ // metadata after the name is persisted, even if the first turn has ended.
390
+ broadcastThreadUnarchived(update.threadId);
391
+ }
354
392
  }
355
393
 
356
394
  if (readString(message.method) === "turn/completed") {
@@ -404,6 +442,7 @@ function createDesktopIpcLiveOwner({
404
442
  queuedFollowUpsByThreadId.clear();
405
443
  runningQueuedFollowUpThreadIds.clear();
406
444
  announcedReadStateThreadIds.clear();
445
+ pendingReadStateByThreadId.clear();
407
446
  ownedThreadIds.clear();
408
447
  conversations.clear();
409
448
  ipc.close();
@@ -423,16 +462,44 @@ function createDesktopIpcLiveOwner({
423
462
  if (hadUnread) {
424
463
  conversation.hasUnreadTurn = false;
425
464
  conversation.unreadMessageCount = 0;
465
+ announcedReadStateThreadIds.delete(normalizedThreadId);
426
466
  scheduleSnapshot(normalizedThreadId);
427
467
  } else if (announcedReadStateThreadIds.has(normalizedThreadId)) {
428
468
  return;
429
469
  }
430
- if (ipc.sendBroadcast(THREAD_READ_STATE_CHANGED, {
431
- conversationId: normalizedThreadId,
432
- hasUnreadTurn: false,
433
- })) {
434
- announcedReadStateThreadIds.add(normalizedThreadId);
470
+ if (pendingReadStateByThreadId.has(normalizedThreadId)) {
471
+ return;
435
472
  }
473
+ const request = Symbol();
474
+ pendingReadStateByThreadId.set(normalizedThreadId, request);
475
+ Promise.resolve()
476
+ .then(() => sendCodexRequest("getAuthStatus", { includeToken: true, refreshToken: false }))
477
+ .then((authStatus) => {
478
+ if (pendingReadStateByThreadId.get(normalizedThreadId) !== request
479
+ || !ownedThreadIds.has(normalizedThreadId)) {
480
+ return;
481
+ }
482
+ const current = conversations.get(normalizedThreadId);
483
+ if (current?.hasUnreadTurn || current?.unreadMessageCount > 0) {
484
+ return;
485
+ }
486
+ const context = buildThreadReadStateContext(authStatus, hostId);
487
+ if (context && ipc.sendBroadcast(THREAD_READ_STATE_CHANGED, {
488
+ conversationId: normalizedThreadId,
489
+ hostId,
490
+ hasUnreadTurn: false,
491
+ context,
492
+ })) {
493
+ announcedReadStateThreadIds.add(normalizedThreadId);
494
+ }
495
+ })
496
+ // A failed identity lookup leaves the next phone read free to retry.
497
+ .catch(() => {})
498
+ .finally(() => {
499
+ if (pendingReadStateByThreadId.get(normalizedThreadId) === request) {
500
+ pendingReadStateByThreadId.delete(normalizedThreadId);
501
+ }
502
+ });
436
503
  }
437
504
 
438
505
  // Snappier Stop UX on Desktop: flip the active turn to interrupted right away;
@@ -479,7 +546,7 @@ function createDesktopIpcLiveOwner({
479
546
  }
480
547
  const sanitizedParams = sanitizeTurnStartParams(cloneJSON(params));
481
548
  sanitizedParams.input = normalizeInputEntriesForDesktop(sanitizedParams.input);
482
- const entry = { params: sanitizedParams, requestId: normalizedRequestId || null };
549
+ const entry = { params: sanitizedParams, requestId: normalizedRequestId || null, runtimeSettingsRevision: runtimeSettingsStore?.get?.(normalizedThreadId)?.revision ?? 0 };
483
550
  const queue = pendingTurnStartParamsByThreadId.get(normalizedThreadId) || [];
484
551
  queue.push(entry);
485
552
  pendingTurnStartParamsByThreadId.set(normalizedThreadId, queue);
@@ -539,7 +606,8 @@ function createDesktopIpcLiveOwner({
539
606
  pending.threadId,
540
607
  pending.entry?.params,
541
608
  "phone",
542
- readTurnIdFromResult(message.result)
609
+ readTurnIdFromResult(message.result),
610
+ pending.entry?.runtimeSettingsRevision
543
611
  );
544
612
  scheduleSnapshot(pending.threadId);
545
613
  }
@@ -711,6 +779,7 @@ function createDesktopIpcLiveOwner({
711
779
  }
712
780
  runningQueuedFollowUpThreadIds.delete(normalizedThreadId);
713
781
  announcedReadStateThreadIds.delete(normalizedThreadId);
782
+ pendingReadStateByThreadId.delete(normalizedThreadId);
714
783
  cancelSidebarAnnouncement(normalizedThreadId);
715
784
  announcedSidebarThreadIds.delete(normalizedThreadId);
716
785
  pendingSidebarMaterializationThreadIds.delete(normalizedThreadId);
@@ -1299,6 +1368,13 @@ function createDesktopIpcLiveOwner({
1299
1368
  if (!SUPPORTED_FOLLOWER_REQUEST_METHODS.has(method)) {
1300
1369
  return false;
1301
1370
  }
1371
+ const requestedHostId = readString(params.hostId || envelope.request?.hostId || envelope.hostId);
1372
+ if (method === "thread-owner-discovery" && !requestedHostId) {
1373
+ return false;
1374
+ }
1375
+ if (requestedHostId && requestedHostId !== hostId) {
1376
+ return false;
1377
+ }
1302
1378
  const threadId = readConversationIdFromFollowerParams(params);
1303
1379
  return Boolean(threadId && ownedThreadIds.has(threadId));
1304
1380
  }
@@ -1307,11 +1383,14 @@ function createDesktopIpcLiveOwner({
1307
1383
  const method = readString(envelope?.method);
1308
1384
  const params = envelope?.params && typeof envelope.params === "object" ? envelope.params : {};
1309
1385
  const conversationId = readConversationIdFromFollowerParams(params);
1310
- if (!conversationId || !ownedThreadIds.has(conversationId)) {
1386
+ if (!conversationId || !canHandleFollowerRequest(envelope)) {
1311
1387
  throw new Error("conversation-not-owned");
1312
1388
  }
1313
1389
 
1314
1390
  switch (method) {
1391
+ case "thread-owner-discovery":
1392
+ // Remodex cannot yet inject Desktop's untrusted app response items.
1393
+ return { supportsUntrustedAppInput: false };
1315
1394
  case "thread-follower-start-turn":
1316
1395
  return await handleFollowerStartTurn(conversationId, params);
1317
1396
  case "thread-follower-load-complete-history":
@@ -1381,7 +1460,15 @@ function createDesktopIpcLiveOwner({
1381
1460
  return { revision };
1382
1461
  }
1383
1462
 
1384
- async function handleFollowerStartTurn(conversationId, params) {
1463
+ function handleFollowerStartTurn(conversationId, params) {
1464
+ return enqueueMutation(conversationId, () => startFollowerTurn(conversationId, params));
1465
+ }
1466
+
1467
+ async function startFollowerTurn(conversationId, params) {
1468
+ if (params.turnStart?.context?.responseItems?.length > 0) {
1469
+ throw new Error("Remodex does not support untrusted app input yet.");
1470
+ }
1471
+ const revisionBefore = runtimeSettingsStore?.get?.(conversationId)?.revision ?? 0;
1385
1472
  const rawTurnStartParams = readFollowerTurnStartParams(params);
1386
1473
  const codexParams = mergeFollowerRuntimeOverrides(conversationId, sanitizeTurnStartParams({
1387
1474
  ...rawTurnStartParams,
@@ -1414,7 +1501,8 @@ function createDesktopIpcLiveOwner({
1414
1501
  conversationId,
1415
1502
  nextCodexParams,
1416
1503
  isKnownHeldPhoneStart ? "phone" : "desktop",
1417
- readTurnIdFromResult(turnStartResult)
1504
+ readTurnIdFromResult(turnStartResult),
1505
+ revisionBefore
1418
1506
  );
1419
1507
  scheduleSnapshot(conversationId);
1420
1508
  if (!isKnownHeldPhoneStart) {
@@ -1478,11 +1566,13 @@ function createDesktopIpcLiveOwner({
1478
1566
  if (!expectedTurnId) {
1479
1567
  throw new Error("Missing expectedTurnId for follower steer request.");
1480
1568
  }
1481
- return await sendCodexRequest("turn/steer", {
1569
+ const result = await sendCodexRequest("turn/steer", {
1482
1570
  threadId: conversationId,
1483
1571
  input: Array.isArray(rawSteerParams.input) ? rawSteerParams.input : [],
1484
1572
  expectedTurnId,
1573
+ ...Object.fromEntries(["clientUserMessageId", "additionalContext", "toolOutput"].filter((key) => hasOwn(rawSteerParams, key)).map((key) => [key, cloneJSON(rawSteerParams[key])])),
1485
1574
  });
1575
+ return { result };
1486
1576
  }
1487
1577
 
1488
1578
  async function handleFollowerInterruptTurn(conversationId, params) {
@@ -1569,100 +1659,49 @@ function createDesktopIpcLiveOwner({
1569
1659
  return { ok: true };
1570
1660
  }
1571
1661
 
1572
- // Desktop runtime option changes are persisted as per-thread overrides and
1573
- // merged into later Desktop-origin turn starts, so acknowledging them is honest
1574
- // instead of a cosmetic broadcast-only update.
1662
+ // Legacy IPC verbs enter the same acknowledged settings path.
1575
1663
  function applyFollowerModelAndReasoning(conversationId, params) {
1576
- const overrides = followerRuntimeOverridesByThreadId.get(conversationId) || {};
1577
- const conversation = conversations.get(conversationId);
1578
- if (Object.prototype.hasOwnProperty.call(params, "model")) {
1579
- overrides.model = readString(params.model);
1580
- if (conversation) {
1581
- conversation.latestModel = overrides.model;
1582
- }
1583
- }
1584
- if (Object.prototype.hasOwnProperty.call(params, "reasoningEffort")) {
1585
- overrides.effort = params.reasoningEffort || null;
1586
- if (conversation) {
1587
- conversation.latestReasoningEffort = overrides.effort;
1588
- }
1589
- }
1590
- if (Object.prototype.hasOwnProperty.call(params, "serviceTier")) {
1591
- overrides.serviceTier = readString(params.serviceTier) || null;
1592
- if (conversation) {
1593
- conversation.latestServiceTier = overrides.serviceTier;
1594
- }
1595
- }
1596
- followerRuntimeOverridesByThreadId.set(conversationId, overrides);
1597
- if (conversation) {
1598
- scheduleSnapshot(conversationId);
1599
- }
1600
- return { ok: true };
1664
+ const settings = normalizeThreadSettingsUpdate(params);
1665
+ if (hasOwn(params, "reasoningEffort")) settings.effort = params.reasoningEffort;
1666
+ return applyFollowerThreadSettings(conversationId, settings);
1601
1667
  }
1602
1668
 
1603
1669
  function applyFollowerCollaborationMode(conversationId, params) {
1604
- if (!params.collaborationMode) {
1605
- return { ok: true };
1606
- }
1607
- const overrides = followerRuntimeOverridesByThreadId.get(conversationId) || {};
1608
- overrides.collaborationMode = cloneJSON(params.collaborationMode);
1609
- followerRuntimeOverridesByThreadId.set(conversationId, overrides);
1610
- const conversation = conversations.get(conversationId);
1611
- if (conversation) {
1612
- conversation.latestCollaborationMode = cloneJSON(params.collaborationMode);
1613
- scheduleSnapshot(conversationId);
1614
- }
1615
- return { ok: true };
1670
+ return applyFollowerThreadSettings(conversationId, { collaborationMode: params.collaborationMode });
1616
1671
  }
1617
1672
 
1618
- // Current Desktop sends the whole thread-settings object; persist it so the
1619
- // composer fields stay accurate and future Desktop-origin turns pick it up.
1620
- function applyFollowerThreadSettings(conversationId, threadSettings) {
1621
- if (!threadSettings || typeof threadSettings !== "object" || Array.isArray(threadSettings)) {
1622
- return { ok: true };
1623
- }
1624
- const overrides = followerRuntimeOverridesByThreadId.get(conversationId) || {};
1625
- const model = readString(threadSettings.model)
1626
- || readString(threadSettings.collaborationMode?.settings?.model);
1627
- const effort = threadSettings.effort;
1628
- const hasServiceTier = Object.prototype.hasOwnProperty.call(threadSettings, "serviceTier");
1629
- if (model) {
1630
- overrides.model = model;
1631
- }
1632
- if (effort !== undefined) {
1633
- overrides.effort = effort ?? null;
1634
- }
1635
- if (hasServiceTier) {
1636
- overrides.serviceTier = readString(threadSettings.serviceTier) || null;
1637
- }
1638
- if (threadSettings.collaborationMode && typeof threadSettings.collaborationMode === "object") {
1639
- overrides.collaborationMode = cloneJSON(threadSettings.collaborationMode);
1640
- }
1641
- followerRuntimeOverridesByThreadId.set(conversationId, overrides);
1642
-
1643
- const conversation = conversations.get(conversationId);
1644
- if (conversation) {
1645
- conversation.latestThreadSettings = {
1646
- ...(conversation.latestThreadSettings && typeof conversation.latestThreadSettings === "object"
1647
- ? conversation.latestThreadSettings
1648
- : {}),
1649
- ...cloneJSON(threadSettings),
1650
- };
1651
- if (model) {
1652
- conversation.latestModel = model;
1673
+ // Settings are acknowledged by the app-server before they become composer
1674
+ // state. Updating a mirror alone never changes the executing runtime.
1675
+ function applyFollowerThreadSettings(conversationId, threadSettings, source = "desktop") {
1676
+ return enqueueMutation(conversationId, async () => {
1677
+ if (!threadSettings || typeof threadSettings !== "object" || Array.isArray(threadSettings)) {
1678
+ throw new Error("Missing thread settings.");
1653
1679
  }
1654
- if (effort !== undefined) {
1655
- conversation.latestReasoningEffort = effort ?? null;
1656
- }
1657
- if (hasServiceTier) {
1658
- conversation.latestServiceTier = overrides.serviceTier;
1659
- }
1660
- if (overrides.collaborationMode) {
1661
- conversation.latestCollaborationMode = cloneJSON(overrides.collaborationMode);
1680
+ const revisionBefore = runtimeSettingsStore?.get?.(conversationId)?.revision;
1681
+ const settings = normalizeThreadSettingsUpdate(threadSettings);
1682
+ await sendCodexRequest("thread/settings/update", { threadId: conversationId, ...settings });
1683
+ if (!ownedThreadIds.has(conversationId)) {
1684
+ // A successful settings mutation proves the local runtime has this task
1685
+ // loaded. Announce ownership only after that acknowledgement and hydrate
1686
+ // complete history before publishing the first Desktop snapshot.
1687
+ markOwnedThread(conversationId);
1688
+ threadsAwaitingInitialHistoryByThreadId.add(conversationId);
1689
+ seedOwnedConversation(conversationId);
1690
+ requestInitialHistoryBaselineIfDue(conversationId);
1662
1691
  }
1692
+ const current = runtimeSettingsStore?.get?.(conversationId);
1693
+ const confirmed = current && current.revision !== revisionBefore
1694
+ ? current
1695
+ : runtimeSettingsStore?.commit?.(conversationId, settings, { source });
1696
+ const applied = confirmed ? {
1697
+ ...settings, ...threadSettingsFromRuntimeSettings(confirmed),
1698
+ } : settings;
1699
+ const overrides = followerRuntimeOverridesByThreadId.get(conversationId) || {};
1700
+ followerRuntimeOverridesByThreadId.set(conversationId, { ...overrides, ...applied });
1701
+ applyRuntimeSettingsToConversation(conversations.get(conversationId), applied, { authoritative: !!confirmed });
1663
1702
  scheduleSnapshot(conversationId);
1664
- }
1665
- return { ok: true };
1703
+ return { ok: true, runtimeSettings: confirmed || null };
1704
+ });
1666
1705
  }
1667
1706
 
1668
1707
  // Desktop followers hand the owner the full queue map and expect it to run
@@ -1700,72 +1739,58 @@ function createDesktopIpcLiveOwner({
1700
1739
  if (!Array.isArray(queue) || queue.length === 0 || runningQueuedFollowUpThreadIds.has(threadId)) {
1701
1740
  return;
1702
1741
  }
1703
- const entry = queue[0];
1704
- if (entry?.pausedReason) {
1705
- return;
1706
- }
1707
- const text = readString(entry?.context?.text)
1708
- || readString(entry?.text)
1709
- || readString(entry?.prompt);
1710
- if (!text) {
1711
- // Unrecognized entry shape: pause it visibly instead of discarding the
1712
- // user's draft. The queue stays blocked (matching Desktop's first-entry
1713
- // semantics) and the user can edit or resend it from any window.
1714
- if (!entry || typeof entry !== "object") {
1715
- queue.shift();
1716
- if (queue.length === 0) {
1717
- queuedFollowUpsByThreadId.delete(threadId);
1718
- }
1742
+ runningQueuedFollowUpThreadIds.add(threadId);
1743
+ let retryChangedQueue = false;
1744
+ enqueueMutation(threadId, async () => {
1745
+ // Desktop replaces queue arrays on refresh. Resolve the current entry only
1746
+ // after settings settle, and retry if another refresh changes it while
1747
+ // request normalization is awaiting context.
1748
+ const entry = queuedFollowUpsByThreadId.get(threadId)?.[0];
1749
+ const canRun = () => ownedThreadIds.has(threadId) && !activeTurnIdForConversation(threadId);
1750
+ if (!entry || entry.pausedReason || !canRun()) return;
1751
+ const text = readString(entry.context?.text) || readString(entry.text) || readString(entry.prompt);
1752
+ if (!text) {
1753
+ if (typeof entry === "object") entry.pausedReason = "remodex-unsupported-entry";
1754
+ else queuedFollowUpsByThreadId.get(threadId)?.shift();
1719
1755
  broadcastQueuedFollowUps(threadId);
1720
1756
  return;
1721
1757
  }
1722
- console.warn(`${logPrefix} desktop queued follow-up entry has no extractable text; pausing it for ${threadId}`);
1723
- entry.pausedReason = "remodex-unsupported-entry";
1724
- broadcastQueuedFollowUps(threadId);
1725
- return;
1726
- }
1727
-
1728
- runningQueuedFollowUpThreadIds.add(threadId);
1729
- const conversation = conversations.get(threadId);
1730
- const startParams = mergeFollowerRuntimeOverrides(threadId, sanitizeTurnStartParams({
1731
- threadId,
1732
- input: [{ type: "text", text }],
1733
- cwd: readString(entry?.cwd) || readString(conversation?.cwd) || undefined,
1734
- }));
1735
- let queuedTurnParams = startParams;
1736
- Promise.resolve()
1737
- .then(() => normalizeTurnStartParams(cloneJSON(startParams)))
1738
- .then((normalized) => {
1739
- const params = normalized && typeof normalized === "object" && !Array.isArray(normalized)
1740
- ? normalized
1741
- : startParams;
1742
- queuedTurnParams = params;
1743
- const pendingEntry = rememberPendingTurnStart(threadId, params);
1744
- if (pendingEntry) {
1745
- insertOptimisticPendingTurn(threadId, pendingEntry);
1746
- scheduleSnapshot(threadId);
1747
- }
1748
- return sendCodexRequest("turn/start", params);
1749
- })
1750
- .then((turnStartResult) => {
1751
- commitAcceptedRuntimeSettings(
1752
- threadId,
1753
- queuedTurnParams,
1754
- "desktop",
1755
- readTurnIdFromResult(turnStartResult)
1756
- );
1758
+ const revisionBefore = runtimeSettingsStore?.get?.(threadId)?.revision ?? 0;
1759
+ const conversation = conversations.get(threadId);
1760
+ const startParams = mergeFollowerRuntimeOverrides(threadId, sanitizeTurnStartParams({
1761
+ threadId,
1762
+ input: [{ type: "text", text }],
1763
+ cwd: readString(entry?.cwd) || readString(conversation?.cwd) || undefined,
1764
+ }));
1765
+ const normalized = await normalizeTurnStartParams(cloneJSON(startParams));
1766
+ if (queuedFollowUpsByThreadId.get(threadId)?.[0] !== entry) {
1767
+ retryChangedQueue = true;
1768
+ return;
1769
+ }
1770
+ if (!canRun() || entry.pausedReason) return;
1771
+ const params = normalized && typeof normalized === "object" && !Array.isArray(normalized)
1772
+ ? normalized : startParams;
1773
+ const pendingEntry = rememberPendingTurnStart(threadId, params);
1774
+ if (pendingEntry) {
1775
+ insertOptimisticPendingTurn(threadId, pendingEntry);
1757
1776
  scheduleSnapshot(threadId);
1758
- queue.shift();
1759
- if (queue.length === 0) {
1760
- queuedFollowUpsByThreadId.delete(threadId);
1761
- }
1762
- broadcastQueuedFollowUps(threadId);
1763
- })
1777
+ }
1778
+ const turnStartResult = await sendCodexRequest("turn/start", params);
1779
+ commitAcceptedRuntimeSettings(threadId, params, "desktop", readTurnIdFromResult(turnStartResult), revisionBefore);
1780
+ scheduleSnapshot(threadId);
1781
+ const currentQueue = queuedFollowUpsByThreadId.get(threadId);
1782
+ const sentIndex = currentQueue?.findIndex((candidate) => candidate === entry
1783
+ || (readString(entry.id) && candidate.id === entry.id)) ?? -1;
1784
+ if (sentIndex >= 0) currentQueue.splice(sentIndex, 1);
1785
+ if (currentQueue?.length === 0) queuedFollowUpsByThreadId.delete(threadId);
1786
+ broadcastQueuedFollowUps(threadId);
1787
+ })
1764
1788
  .catch((error) => {
1765
1789
  console.warn(`${logPrefix} desktop queued follow-up failed for ${threadId}: ${error?.message || "unknown error"}`);
1766
1790
  })
1767
1791
  .finally(() => {
1768
1792
  runningQueuedFollowUpThreadIds.delete(threadId);
1793
+ if (retryChangedQueue) runNextQueuedFollowUp(threadId);
1769
1794
  });
1770
1795
  }
1771
1796
 
@@ -1773,11 +1798,7 @@ function createDesktopIpcLiveOwner({
1773
1798
  // the request itself does not specify them. Phone-origin turns are untouched.
1774
1799
  function mergeFollowerRuntimeOverrides(conversationId, params) {
1775
1800
  const persisted = runtimeSettingsStore?.get?.(conversationId) || null;
1776
- const persistedOverrides = persisted ? {
1777
- model: persisted.model,
1778
- effort: persisted.reasoningEffort,
1779
- serviceTier: persisted.serviceTier,
1780
- } : null;
1801
+ const persistedOverrides = persisted ? threadSettingsFromRuntimeSettings(persisted) : null;
1781
1802
  const liveOverrides = followerRuntimeOverridesByThreadId.get(conversationId) || null;
1782
1803
  const overrides = persistedOverrides || liveOverrides
1783
1804
  ? { ...(persistedOverrides || {}), ...(liveOverrides || {}) }
@@ -1789,11 +1810,11 @@ function createDesktopIpcLiveOwner({
1789
1810
  if (overrides.model && !readString(merged.model)) {
1790
1811
  merged.model = overrides.model;
1791
1812
  }
1792
- if (overrides.effort != null && merged.effort == null) {
1813
+ if (hasOwn(overrides, "effort") && !hasOwn(merged, "effort")) {
1793
1814
  merged.effort = overrides.effort;
1794
1815
  }
1795
- if (overrides.serviceTier && merged.serviceTier == null) {
1796
- merged.serviceTier = overrides.serviceTier;
1816
+ if (hasOwn(overrides, "serviceTier") && !hasOwn(merged, "serviceTier")) {
1817
+ merged.serviceTier = overrides.serviceTier || "default";
1797
1818
  }
1798
1819
  if (overrides.collaborationMode && merged.collaborationMode == null) {
1799
1820
  merged.collaborationMode = cloneJSON(overrides.collaborationMode);
@@ -1801,11 +1822,14 @@ function createDesktopIpcLiveOwner({
1801
1822
  return merged;
1802
1823
  }
1803
1824
 
1804
- function commitAcceptedRuntimeSettings(threadId, params, source, turnId) {
1825
+ function commitAcceptedRuntimeSettings(threadId, params, source, turnId, revisionBefore) {
1805
1826
  try {
1806
- const settings = runtimeSettingsStore?.commit?.(threadId, params, { source, turnId });
1827
+ const current = runtimeSettingsStore?.get?.(threadId);
1828
+ const settings = current && revisionBefore != null && current.revision !== revisionBefore
1829
+ ? current : runtimeSettingsStore?.commit?.(threadId, params, { source, turnId });
1807
1830
  const conversation = conversations.get(threadId);
1808
1831
  if (settings && conversation) {
1832
+ applyRuntimeSettingsToConversation(conversation, threadSettingsFromRuntimeSettings(settings), { authoritative: true });
1809
1833
  runtimeSettingsStore.attachToConversation(threadId, conversation);
1810
1834
  }
1811
1835
  return settings;
@@ -1849,6 +1873,7 @@ function createDesktopIpcLiveOwner({
1849
1873
  }
1850
1874
 
1851
1875
  return {
1876
+ updateThreadSettings: (threadId, params) => applyFollowerThreadSettings(threadId, params, "phone"),
1852
1877
  observeInbound,
1853
1878
  observeOutbound,
1854
1879
  stopAll,
@@ -26,7 +26,8 @@ const DESKTOP_IPC_METHOD_VERSIONS = new Map([
26
26
  ["thread-stream-following-status-requested", 1],
27
27
  ["thread-archived", 2],
28
28
  ["thread-unarchived", 1],
29
- ["thread-read-state-changed", 2],
29
+ ["thread-owner-discovery", 1],
30
+ ["thread-read-state-changed", 3],
30
31
  ["thread-queued-followups-changed", 1],
31
32
  ["thread-follower-start-turn", 2],
32
33
  ["thread-follower-load-complete-history", 1],
@@ -582,6 +583,38 @@ function buildCompleteThreadReadParams(threadId) {
582
583
  };
583
584
  }
584
585
 
586
+ // Desktop 26.903 scopes read receipts to the authenticated identity and runtime.
587
+ // Only the local stdio runtime is owned by this bridge; never broadcast tokens.
588
+ function buildThreadReadStateContext(authStatus, hostId = "local") {
589
+ if (!authStatus || hostId !== "local") {
590
+ return null;
591
+ }
592
+ let identity;
593
+ if (authStatus.authMethod === "chatgpt" || authStatus.authMethod === "chatgptAuthTokens") {
594
+ try {
595
+ const payload = JSON.parse(Buffer.from(
596
+ readString(authStatus.authToken).split(".")[1] || "", "base64url"
597
+ ).toString("utf8"));
598
+ const auth = payload["https://api.openai.com/auth"];
599
+ const accountId = readString(auth?.chatgpt_account_id ?? auth?.account_id);
600
+ const userId = readString(auth?.user_id ?? auth?.chatgpt_user_id);
601
+ if (!accountId || !userId) {
602
+ return null;
603
+ }
604
+ identity = { kind: "chatgpt", accountId, userId };
605
+ } catch {
606
+ return null;
607
+ }
608
+ } else {
609
+ if (authStatus.authMethod == null && authStatus.requiresOpenaiAuth !== false) {
610
+ return null;
611
+ }
612
+ identity = { kind: "execution-storage", authMode: authStatus.authMethod ?? "none" };
613
+ }
614
+ const hostHash = createHash("sha256").update(JSON.stringify(["local", hostId, null])).digest("hex");
615
+ return { identity, executionHostKey: `${hostId}:${hostHash}` };
616
+ }
617
+
585
618
  function resolveIpcSocketPathCandidates() {
586
619
  if (process.platform === "win32") {
587
620
  return ["\\\\.\\pipe\\codex-ipc"];
@@ -666,6 +699,7 @@ function responseItemMessageText(payload) {
666
699
  }
667
700
 
668
701
  module.exports = {
702
+ buildThreadReadStateContext,
669
703
  CLIENT_STATUS_CHANGED,
670
704
  buildCompleteThreadReadParams,
671
705
  buildIpcRequestEnvelope,