@makerbi/remodex 3.1.0 → 3.3.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,
@@ -27,6 +35,7 @@ const {
27
35
  buildConversationStateFromThread,
28
36
  createEmptyConversationState,
29
37
  readThreadIdFromParams,
38
+ synchronizeDesktopConversationCompatibility,
30
39
  } = require("./desktop-ipc-conversation-adapter");
31
40
  const {
32
41
  DEFAULT_MAX_PATCH_BYTES,
@@ -65,6 +74,7 @@ const THREAD_QUEUED_FOLLOWUPS_CHANGED = "thread-queued-followups-changed";
65
74
  const REMODEX_LIVE_OWNER_SOURCE = "desktop-ipc-live-owner";
66
75
 
67
76
  const SUPPORTED_FOLLOWER_REQUEST_METHODS = new Set([
77
+ "thread-owner-discovery",
68
78
  "thread-follower-start-turn",
69
79
  "thread-follower-load-complete-history",
70
80
  "thread-follower-update-thread-settings",
@@ -94,20 +104,33 @@ const OWNER_INBOUND_METHODS = new Set([
94
104
 
95
105
  const THREAD_READ_METHODS = new Set(["thread/read", "thread/resume"]);
96
106
 
97
- const ALLOWED_TURN_START_PARAM_KEYS = new Set([
107
+ // Current app-server v2 TurnStartParams fields. Desktop's adjacent turnStart.context
108
+ // is presentation state, not part of this RPC shape, so it stays outside the request.
109
+ const APP_SERVER_TURN_START_PARAM_KEYS = new Set([
98
110
  "threadId",
99
111
  "input",
112
+ "additionalContext",
100
113
  "cwd",
101
114
  "approvalPolicy",
102
115
  "approvalsReviewer",
103
116
  "sandboxPolicy",
104
117
  "model",
105
118
  "serviceTier",
119
+ "serviceTierForTurn",
106
120
  "effort",
107
121
  "summary",
108
122
  "personality",
109
123
  "outputSchema",
110
124
  "collaborationMode",
125
+ "clientUserMessageId",
126
+ "cyberAccessProgram",
127
+ "environments",
128
+ "multiAgentMode",
129
+ "permissions",
130
+ "responsesapiClientMetadata",
131
+ "runtimeWorkspaceRoots",
132
+ "toolOutput",
133
+ "turnTrigger",
111
134
  ]);
112
135
 
113
136
  function createDesktopIpcLiveOwner({
@@ -127,6 +150,7 @@ function createDesktopIpcLiveOwner({
127
150
  maxPatchBytes = DEFAULT_MAX_PATCH_BYTES,
128
151
  requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
129
152
  reconnectMs = DEFAULT_RECONNECT_MS,
153
+ startRouterWhenMissing = false,
130
154
  initialHistoryRetryMs = DEFAULT_INITIAL_HISTORY_RETRY_MS,
131
155
  initialHistoryMaxAttempts = DEFAULT_INITIAL_HISTORY_MAX_ATTEMPTS,
132
156
  liveOwnershipFreshnessMs = DEFAULT_LIVE_OWNERSHIP_FRESHNESS_MS,
@@ -170,6 +194,7 @@ function createDesktopIpcLiveOwner({
170
194
  // until the first user item and turn completion prove the rollout was written.
171
195
  const pendingSidebarMaterializationThreadIds = new Set();
172
196
  const replayedSidebarMaterializationThreadIds = new Set();
197
+ const enqueueMutation = createThreadMutationQueue();
173
198
  const pendingTurnStartParamsByThreadId = new Map();
174
199
  const pendingTurnStartEntriesByRequestId = new Map();
175
200
  const followerRuntimeOverridesByThreadId = new Map();
@@ -178,6 +203,7 @@ function createDesktopIpcLiveOwner({
178
203
  const queuedFollowUpsByThreadId = new Map();
179
204
  const runningQueuedFollowUpThreadIds = new Set();
180
205
  const announcedReadStateThreadIds = new Set();
206
+ const pendingReadStateByThreadId = new Map();
181
207
  const pendingThreadArchiveMetadataByThreadId = new Map();
182
208
  const dirtyThreadIds = new Set();
183
209
  let snapshotTimer = null;
@@ -190,6 +216,10 @@ function createDesktopIpcLiveOwner({
190
216
  requestTimeoutMs,
191
217
  reconnectMs,
192
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,
193
223
  onConnected() {
194
224
  flushPendingThreadArchiveMetadataBroadcasts();
195
225
  requestFollowerStatusForAllOwnedThreads();
@@ -288,7 +318,24 @@ function createDesktopIpcLiveOwner({
288
318
  if (!message || typeof message !== "object") {
289
319
  return;
290
320
  }
291
-
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
+ }
292
339
  const responseId = message.id == null ? "" : String(message.id);
293
340
  if (responseId && !message.method) {
294
341
  resolvePendingTurnStartResponse(responseId, message);
@@ -337,6 +384,11 @@ function createDesktopIpcLiveOwner({
337
384
  refreshOptimisticFallbackForThread(update.threadId);
338
385
  scheduleSnapshot(update.threadId);
339
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
+ }
340
392
  }
341
393
 
342
394
  if (readString(message.method) === "turn/completed") {
@@ -390,6 +442,7 @@ function createDesktopIpcLiveOwner({
390
442
  queuedFollowUpsByThreadId.clear();
391
443
  runningQueuedFollowUpThreadIds.clear();
392
444
  announcedReadStateThreadIds.clear();
445
+ pendingReadStateByThreadId.clear();
393
446
  ownedThreadIds.clear();
394
447
  conversations.clear();
395
448
  ipc.close();
@@ -409,16 +462,44 @@ function createDesktopIpcLiveOwner({
409
462
  if (hadUnread) {
410
463
  conversation.hasUnreadTurn = false;
411
464
  conversation.unreadMessageCount = 0;
465
+ announcedReadStateThreadIds.delete(normalizedThreadId);
412
466
  scheduleSnapshot(normalizedThreadId);
413
467
  } else if (announcedReadStateThreadIds.has(normalizedThreadId)) {
414
468
  return;
415
469
  }
416
- if (ipc.sendBroadcast(THREAD_READ_STATE_CHANGED, {
417
- conversationId: normalizedThreadId,
418
- hasUnreadTurn: false,
419
- })) {
420
- announcedReadStateThreadIds.add(normalizedThreadId);
470
+ if (pendingReadStateByThreadId.has(normalizedThreadId)) {
471
+ return;
421
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
+ });
422
503
  }
423
504
 
424
505
  // Snappier Stop UX on Desktop: flip the active turn to interrupted right away;
@@ -465,7 +546,7 @@ function createDesktopIpcLiveOwner({
465
546
  }
466
547
  const sanitizedParams = sanitizeTurnStartParams(cloneJSON(params));
467
548
  sanitizedParams.input = normalizeInputEntriesForDesktop(sanitizedParams.input);
468
- const entry = { params: sanitizedParams, requestId: normalizedRequestId || null };
549
+ const entry = { params: sanitizedParams, requestId: normalizedRequestId || null, runtimeSettingsRevision: runtimeSettingsStore?.get?.(normalizedThreadId)?.revision ?? 0 };
469
550
  const queue = pendingTurnStartParamsByThreadId.get(normalizedThreadId) || [];
470
551
  queue.push(entry);
471
552
  pendingTurnStartParamsByThreadId.set(normalizedThreadId, queue);
@@ -525,7 +606,8 @@ function createDesktopIpcLiveOwner({
525
606
  pending.threadId,
526
607
  pending.entry?.params,
527
608
  "phone",
528
- readTurnIdFromResult(message.result)
609
+ readTurnIdFromResult(message.result),
610
+ pending.entry?.runtimeSettingsRevision
529
611
  );
530
612
  scheduleSnapshot(pending.threadId);
531
613
  }
@@ -697,6 +779,7 @@ function createDesktopIpcLiveOwner({
697
779
  }
698
780
  runningQueuedFollowUpThreadIds.delete(normalizedThreadId);
699
781
  announcedReadStateThreadIds.delete(normalizedThreadId);
782
+ pendingReadStateByThreadId.delete(normalizedThreadId);
700
783
  cancelSidebarAnnouncement(normalizedThreadId);
701
784
  announcedSidebarThreadIds.delete(normalizedThreadId);
702
785
  pendingSidebarMaterializationThreadIds.delete(normalizedThreadId);
@@ -1104,6 +1187,7 @@ function createDesktopIpcLiveOwner({
1104
1187
  return true;
1105
1188
  }
1106
1189
  runtimeSettingsStore?.attachToConversation?.(threadId, conversationState);
1190
+ synchronizeDesktopConversationCompatibility(conversationState);
1107
1191
  if (shouldDelayInitialSnapshotForHistory(threadId)) {
1108
1192
  return false;
1109
1193
  }
@@ -1284,6 +1368,13 @@ function createDesktopIpcLiveOwner({
1284
1368
  if (!SUPPORTED_FOLLOWER_REQUEST_METHODS.has(method)) {
1285
1369
  return false;
1286
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
+ }
1287
1378
  const threadId = readConversationIdFromFollowerParams(params);
1288
1379
  return Boolean(threadId && ownedThreadIds.has(threadId));
1289
1380
  }
@@ -1292,11 +1383,14 @@ function createDesktopIpcLiveOwner({
1292
1383
  const method = readString(envelope?.method);
1293
1384
  const params = envelope?.params && typeof envelope.params === "object" ? envelope.params : {};
1294
1385
  const conversationId = readConversationIdFromFollowerParams(params);
1295
- if (!conversationId || !ownedThreadIds.has(conversationId)) {
1386
+ if (!conversationId || !canHandleFollowerRequest(envelope)) {
1296
1387
  throw new Error("conversation-not-owned");
1297
1388
  }
1298
1389
 
1299
1390
  switch (method) {
1391
+ case "thread-owner-discovery":
1392
+ // Remodex cannot yet inject Desktop's untrusted app response items.
1393
+ return { supportsUntrustedAppInput: false };
1300
1394
  case "thread-follower-start-turn":
1301
1395
  return await handleFollowerStartTurn(conversationId, params);
1302
1396
  case "thread-follower-load-complete-history":
@@ -1366,11 +1460,16 @@ function createDesktopIpcLiveOwner({
1366
1460
  return { revision };
1367
1461
  }
1368
1462
 
1369
- async function handleFollowerStartTurn(conversationId, params) {
1370
- const rawTurnStartParams = params.turnStartParams
1371
- || params.turn_start_params
1372
- || params.turnStart
1373
- || 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;
1472
+ const rawTurnStartParams = readFollowerTurnStartParams(params);
1374
1473
  const codexParams = mergeFollowerRuntimeOverrides(conversationId, sanitizeTurnStartParams({
1375
1474
  ...rawTurnStartParams,
1376
1475
  threadId: conversationId,
@@ -1380,7 +1479,9 @@ function createDesktopIpcLiveOwner({
1380
1479
  ? normalizedParams
1381
1480
  : codexParams;
1382
1481
  markOwnedThread(conversationId);
1383
- const senderRequestId = params.senderRequestId || params.sender_request_id;
1482
+ const senderRequestId = params.senderRequestId
1483
+ || params.sender_request_id
1484
+ || rawTurnStartParams.clientUserMessageId;
1384
1485
  const isKnownHeldPhoneStart = Boolean(
1385
1486
  requestIdKey(senderRequestId)
1386
1487
  && pendingTurnStartEntriesByRequestId.has(requestIdKey(senderRequestId))
@@ -1400,7 +1501,8 @@ function createDesktopIpcLiveOwner({
1400
1501
  conversationId,
1401
1502
  nextCodexParams,
1402
1503
  isKnownHeldPhoneStart ? "phone" : "desktop",
1403
- readTurnIdFromResult(turnStartResult)
1504
+ readTurnIdFromResult(turnStartResult),
1505
+ revisionBefore
1404
1506
  );
1405
1507
  scheduleSnapshot(conversationId);
1406
1508
  if (!isKnownHeldPhoneStart) {
@@ -1417,6 +1519,17 @@ function createDesktopIpcLiveOwner({
1417
1519
  }
1418
1520
  }
1419
1521
 
1522
+ function readFollowerTurnStartParams(params) {
1523
+ const turnStart = isPlainJSONObject(params.turnStart) ? params.turnStart : null;
1524
+ if (isPlainJSONObject(turnStart?.request)) {
1525
+ return turnStart.request;
1526
+ }
1527
+ return params.turnStartParams
1528
+ || params.turn_start_params
1529
+ || params.turnStart
1530
+ || params;
1531
+ }
1532
+
1420
1533
  function mirrorFollowerUserPromptToPhone(threadId, turnStartParams, turnStartResult = null) {
1421
1534
  const text = visibleUserPromptFromInputEntries(turnStartParams?.input);
1422
1535
  if (!text) {
@@ -1453,24 +1566,60 @@ function createDesktopIpcLiveOwner({
1453
1566
  if (!expectedTurnId) {
1454
1567
  throw new Error("Missing expectedTurnId for follower steer request.");
1455
1568
  }
1456
- return await sendCodexRequest("turn/steer", {
1569
+ const result = await sendCodexRequest("turn/steer", {
1457
1570
  threadId: conversationId,
1458
1571
  input: Array.isArray(rawSteerParams.input) ? rawSteerParams.input : [],
1459
1572
  expectedTurnId,
1573
+ ...Object.fromEntries(["clientUserMessageId", "additionalContext", "toolOutput"].filter((key) => hasOwn(rawSteerParams, key)).map((key) => [key, cloneJSON(rawSteerParams[key])])),
1460
1574
  });
1575
+ return { result };
1461
1576
  }
1462
1577
 
1463
1578
  async function handleFollowerInterruptTurn(conversationId, params) {
1464
- const turnId = readString(params.turnId)
1465
- || readString(params.turn_id)
1466
- || activeTurnIdForConversation(conversationId);
1579
+ const requestedTurnId = readString(params.expectedTurnId)
1580
+ || readString(params.expected_turn_id)
1581
+ || readString(params.turnId)
1582
+ || readString(params.turn_id);
1583
+ const activeTurnId = activeTurnIdForConversation(conversationId);
1584
+ if (requestedTurnId && requestedTurnId !== activeTurnId) {
1585
+ return {
1586
+ interruptedTurnId: null,
1587
+ ok: true,
1588
+ };
1589
+ }
1590
+
1591
+ const goalPauseError = await pauseActiveGoalForUserStop(
1592
+ conversationId,
1593
+ params.mode,
1594
+ Boolean(requestedTurnId)
1595
+ );
1596
+ const turnId = requestedTurnId || activeTurnId;
1467
1597
  if (!turnId) {
1468
- throw new Error("Missing turnId for follower interrupt request.");
1598
+ return followerInterruptResult(null, goalPauseError);
1469
1599
  }
1470
- return await sendCodexRequest("turn/interrupt", {
1600
+ await sendCodexRequest("turn/interrupt", {
1471
1601
  threadId: conversationId,
1472
1602
  turnId,
1473
1603
  });
1604
+ return followerInterruptResult(turnId, goalPauseError);
1605
+ }
1606
+
1607
+ async function pauseActiveGoalForUserStop(conversationId, mode, hasRequestedTurnId) {
1608
+ if (mode !== "user-stop" || hasRequestedTurnId) {
1609
+ return "";
1610
+ }
1611
+ if (conversations.get(conversationId)?.threadGoal?.status !== "active") {
1612
+ return "";
1613
+ }
1614
+ try {
1615
+ await sendCodexRequest("thread/goal/set", {
1616
+ threadId: conversationId,
1617
+ status: "paused",
1618
+ });
1619
+ return "";
1620
+ } catch (error) {
1621
+ return error?.message || "Failed to pause thread goal.";
1622
+ }
1474
1623
  }
1475
1624
 
1476
1625
  // Desktop follower approvals only carry decision-style payloads, but app-server
@@ -1510,100 +1659,49 @@ function createDesktopIpcLiveOwner({
1510
1659
  return { ok: true };
1511
1660
  }
1512
1661
 
1513
- // Desktop runtime option changes are persisted as per-thread overrides and
1514
- // merged into later Desktop-origin turn starts, so acknowledging them is honest
1515
- // instead of a cosmetic broadcast-only update.
1662
+ // Legacy IPC verbs enter the same acknowledged settings path.
1516
1663
  function applyFollowerModelAndReasoning(conversationId, params) {
1517
- const overrides = followerRuntimeOverridesByThreadId.get(conversationId) || {};
1518
- const conversation = conversations.get(conversationId);
1519
- if (Object.prototype.hasOwnProperty.call(params, "model")) {
1520
- overrides.model = readString(params.model);
1521
- if (conversation) {
1522
- conversation.latestModel = overrides.model;
1523
- }
1524
- }
1525
- if (Object.prototype.hasOwnProperty.call(params, "reasoningEffort")) {
1526
- overrides.effort = params.reasoningEffort || null;
1527
- if (conversation) {
1528
- conversation.latestReasoningEffort = overrides.effort;
1529
- }
1530
- }
1531
- if (Object.prototype.hasOwnProperty.call(params, "serviceTier")) {
1532
- overrides.serviceTier = readString(params.serviceTier) || null;
1533
- if (conversation) {
1534
- conversation.latestServiceTier = overrides.serviceTier;
1535
- }
1536
- }
1537
- followerRuntimeOverridesByThreadId.set(conversationId, overrides);
1538
- if (conversation) {
1539
- scheduleSnapshot(conversationId);
1540
- }
1541
- return { ok: true };
1664
+ const settings = normalizeThreadSettingsUpdate(params);
1665
+ if (hasOwn(params, "reasoningEffort")) settings.effort = params.reasoningEffort;
1666
+ return applyFollowerThreadSettings(conversationId, settings);
1542
1667
  }
1543
1668
 
1544
1669
  function applyFollowerCollaborationMode(conversationId, params) {
1545
- if (!params.collaborationMode) {
1546
- return { ok: true };
1547
- }
1548
- const overrides = followerRuntimeOverridesByThreadId.get(conversationId) || {};
1549
- overrides.collaborationMode = cloneJSON(params.collaborationMode);
1550
- followerRuntimeOverridesByThreadId.set(conversationId, overrides);
1551
- const conversation = conversations.get(conversationId);
1552
- if (conversation) {
1553
- conversation.latestCollaborationMode = cloneJSON(params.collaborationMode);
1554
- scheduleSnapshot(conversationId);
1555
- }
1556
- return { ok: true };
1670
+ return applyFollowerThreadSettings(conversationId, { collaborationMode: params.collaborationMode });
1557
1671
  }
1558
1672
 
1559
- // Current Desktop sends the whole thread-settings object; persist it so the
1560
- // composer fields stay accurate and future Desktop-origin turns pick it up.
1561
- function applyFollowerThreadSettings(conversationId, threadSettings) {
1562
- if (!threadSettings || typeof threadSettings !== "object" || Array.isArray(threadSettings)) {
1563
- return { ok: true };
1564
- }
1565
- const overrides = followerRuntimeOverridesByThreadId.get(conversationId) || {};
1566
- const model = readString(threadSettings.model)
1567
- || readString(threadSettings.collaborationMode?.settings?.model);
1568
- const effort = threadSettings.effort;
1569
- const hasServiceTier = Object.prototype.hasOwnProperty.call(threadSettings, "serviceTier");
1570
- if (model) {
1571
- overrides.model = model;
1572
- }
1573
- if (effort !== undefined) {
1574
- overrides.effort = effort ?? null;
1575
- }
1576
- if (hasServiceTier) {
1577
- overrides.serviceTier = readString(threadSettings.serviceTier) || null;
1578
- }
1579
- if (threadSettings.collaborationMode && typeof threadSettings.collaborationMode === "object") {
1580
- overrides.collaborationMode = cloneJSON(threadSettings.collaborationMode);
1581
- }
1582
- followerRuntimeOverridesByThreadId.set(conversationId, overrides);
1583
-
1584
- const conversation = conversations.get(conversationId);
1585
- if (conversation) {
1586
- conversation.latestThreadSettings = {
1587
- ...(conversation.latestThreadSettings && typeof conversation.latestThreadSettings === "object"
1588
- ? conversation.latestThreadSettings
1589
- : {}),
1590
- ...cloneJSON(threadSettings),
1591
- };
1592
- if (model) {
1593
- conversation.latestModel = model;
1594
- }
1595
- if (effort !== undefined) {
1596
- conversation.latestReasoningEffort = effort ?? null;
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.");
1597
1679
  }
1598
- if (hasServiceTier) {
1599
- conversation.latestServiceTier = overrides.serviceTier;
1600
- }
1601
- if (overrides.collaborationMode) {
1602
- 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);
1603
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 });
1604
1702
  scheduleSnapshot(conversationId);
1605
- }
1606
- return { ok: true };
1703
+ return { ok: true, runtimeSettings: confirmed || null };
1704
+ });
1607
1705
  }
1608
1706
 
1609
1707
  // Desktop followers hand the owner the full queue map and expect it to run
@@ -1641,72 +1739,58 @@ function createDesktopIpcLiveOwner({
1641
1739
  if (!Array.isArray(queue) || queue.length === 0 || runningQueuedFollowUpThreadIds.has(threadId)) {
1642
1740
  return;
1643
1741
  }
1644
- const entry = queue[0];
1645
- if (entry?.pausedReason) {
1646
- return;
1647
- }
1648
- const text = readString(entry?.context?.text)
1649
- || readString(entry?.text)
1650
- || readString(entry?.prompt);
1651
- if (!text) {
1652
- // Unrecognized entry shape: pause it visibly instead of discarding the
1653
- // user's draft. The queue stays blocked (matching Desktop's first-entry
1654
- // semantics) and the user can edit or resend it from any window.
1655
- if (!entry || typeof entry !== "object") {
1656
- queue.shift();
1657
- if (queue.length === 0) {
1658
- queuedFollowUpsByThreadId.delete(threadId);
1659
- }
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();
1660
1755
  broadcastQueuedFollowUps(threadId);
1661
1756
  return;
1662
1757
  }
1663
- console.warn(`${logPrefix} desktop queued follow-up entry has no extractable text; pausing it for ${threadId}`);
1664
- entry.pausedReason = "remodex-unsupported-entry";
1665
- broadcastQueuedFollowUps(threadId);
1666
- return;
1667
- }
1668
-
1669
- runningQueuedFollowUpThreadIds.add(threadId);
1670
- const conversation = conversations.get(threadId);
1671
- const startParams = mergeFollowerRuntimeOverrides(threadId, sanitizeTurnStartParams({
1672
- threadId,
1673
- input: [{ type: "text", text }],
1674
- cwd: readString(entry?.cwd) || readString(conversation?.cwd) || undefined,
1675
- }));
1676
- let queuedTurnParams = startParams;
1677
- Promise.resolve()
1678
- .then(() => normalizeTurnStartParams(cloneJSON(startParams)))
1679
- .then((normalized) => {
1680
- const params = normalized && typeof normalized === "object" && !Array.isArray(normalized)
1681
- ? normalized
1682
- : startParams;
1683
- queuedTurnParams = params;
1684
- const pendingEntry = rememberPendingTurnStart(threadId, params);
1685
- if (pendingEntry) {
1686
- insertOptimisticPendingTurn(threadId, pendingEntry);
1687
- scheduleSnapshot(threadId);
1688
- }
1689
- return sendCodexRequest("turn/start", params);
1690
- })
1691
- .then((turnStartResult) => {
1692
- commitAcceptedRuntimeSettings(
1693
- threadId,
1694
- queuedTurnParams,
1695
- "desktop",
1696
- readTurnIdFromResult(turnStartResult)
1697
- );
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);
1698
1776
  scheduleSnapshot(threadId);
1699
- queue.shift();
1700
- if (queue.length === 0) {
1701
- queuedFollowUpsByThreadId.delete(threadId);
1702
- }
1703
- broadcastQueuedFollowUps(threadId);
1704
- })
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
+ })
1705
1788
  .catch((error) => {
1706
1789
  console.warn(`${logPrefix} desktop queued follow-up failed for ${threadId}: ${error?.message || "unknown error"}`);
1707
1790
  })
1708
1791
  .finally(() => {
1709
1792
  runningQueuedFollowUpThreadIds.delete(threadId);
1793
+ if (retryChangedQueue) runNextQueuedFollowUp(threadId);
1710
1794
  });
1711
1795
  }
1712
1796
 
@@ -1714,11 +1798,7 @@ function createDesktopIpcLiveOwner({
1714
1798
  // the request itself does not specify them. Phone-origin turns are untouched.
1715
1799
  function mergeFollowerRuntimeOverrides(conversationId, params) {
1716
1800
  const persisted = runtimeSettingsStore?.get?.(conversationId) || null;
1717
- const persistedOverrides = persisted ? {
1718
- model: persisted.model,
1719
- effort: persisted.reasoningEffort,
1720
- serviceTier: persisted.serviceTier,
1721
- } : null;
1801
+ const persistedOverrides = persisted ? threadSettingsFromRuntimeSettings(persisted) : null;
1722
1802
  const liveOverrides = followerRuntimeOverridesByThreadId.get(conversationId) || null;
1723
1803
  const overrides = persistedOverrides || liveOverrides
1724
1804
  ? { ...(persistedOverrides || {}), ...(liveOverrides || {}) }
@@ -1730,11 +1810,11 @@ function createDesktopIpcLiveOwner({
1730
1810
  if (overrides.model && !readString(merged.model)) {
1731
1811
  merged.model = overrides.model;
1732
1812
  }
1733
- if (overrides.effort != null && merged.effort == null) {
1813
+ if (hasOwn(overrides, "effort") && !hasOwn(merged, "effort")) {
1734
1814
  merged.effort = overrides.effort;
1735
1815
  }
1736
- if (overrides.serviceTier && merged.serviceTier == null) {
1737
- merged.serviceTier = overrides.serviceTier;
1816
+ if (hasOwn(overrides, "serviceTier") && !hasOwn(merged, "serviceTier")) {
1817
+ merged.serviceTier = overrides.serviceTier || "default";
1738
1818
  }
1739
1819
  if (overrides.collaborationMode && merged.collaborationMode == null) {
1740
1820
  merged.collaborationMode = cloneJSON(overrides.collaborationMode);
@@ -1742,11 +1822,14 @@ function createDesktopIpcLiveOwner({
1742
1822
  return merged;
1743
1823
  }
1744
1824
 
1745
- function commitAcceptedRuntimeSettings(threadId, params, source, turnId) {
1825
+ function commitAcceptedRuntimeSettings(threadId, params, source, turnId, revisionBefore) {
1746
1826
  try {
1747
- 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 });
1748
1830
  const conversation = conversations.get(threadId);
1749
1831
  if (settings && conversation) {
1832
+ applyRuntimeSettingsToConversation(conversation, threadSettingsFromRuntimeSettings(settings), { authoritative: true });
1750
1833
  runtimeSettingsStore.attachToConversation(threadId, conversation);
1751
1834
  }
1752
1835
  return settings;
@@ -1786,11 +1869,11 @@ function createDesktopIpcLiveOwner({
1786
1869
  return turnId;
1787
1870
  }
1788
1871
  }
1789
- const latestTurn = turns[turns.length - 1];
1790
- return readString(latestTurn?.turnId) || readString(latestTurn?.id);
1872
+ return "";
1791
1873
  }
1792
1874
 
1793
1875
  return {
1876
+ updateThreadSettings: (threadId, params) => applyFollowerThreadSettings(threadId, params, "phone"),
1794
1877
  observeInbound,
1795
1878
  observeOutbound,
1796
1879
  stopAll,
@@ -1881,7 +1964,7 @@ function normalizeInputEntriesForDesktop(input) {
1881
1964
  function sanitizeTurnStartParams(params) {
1882
1965
  const sanitized = {};
1883
1966
  for (const [key, value] of Object.entries(params || {})) {
1884
- if (ALLOWED_TURN_START_PARAM_KEYS.has(key)) {
1967
+ if (APP_SERVER_TURN_START_PARAM_KEYS.has(key)) {
1885
1968
  sanitized[key] = value;
1886
1969
  }
1887
1970
  }
@@ -1891,6 +1974,14 @@ function sanitizeTurnStartParams(params) {
1891
1974
  return sanitized;
1892
1975
  }
1893
1976
 
1977
+ function followerInterruptResult(interruptedTurnId, goalPauseError) {
1978
+ return {
1979
+ interruptedTurnId,
1980
+ ...(goalPauseError ? { goalPauseError } : {}),
1981
+ ok: true,
1982
+ };
1983
+ }
1984
+
1894
1985
  function readThreadFromResponse(message) {
1895
1986
  const result = message?.result || message?.payload || {};
1896
1987
  return readThreadFromPayload(result);