@makerbi/remodex 2.0.0 → 2.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.
@@ -2,17 +2,63 @@
2
2
  // Purpose: Mirrors live Codex Desktop IPC pending actions to the phone and routes replies back to the desktop runtime.
3
3
  // Layer: CLI helper
4
4
  // Exports: createDesktopIpcActionFollower, projectPendingDesktopActions
5
- // Depends on: net, os, path
5
+ // Depends on: net, ./desktop-ipc-conversation-projector, ./desktop-ipc-shared
6
6
 
7
+ const { createHash } = require("crypto");
7
8
  const net = require("net");
8
- const os = require("os");
9
- const path = require("path");
10
9
 
11
- const FRAME_HEADER_BYTES = 4;
12
- const MAX_FRAME_BYTES = 256 * 1024 * 1024;
10
+ const {
11
+ createDesktopConversationProjector,
12
+ desktopTurnsShareLogicalIdentity,
13
+ matchDesktopTurnIdentityContinuities,
14
+ projectDesktopConversationStateToThread,
15
+ } = require("./desktop-ipc-conversation-projector");
16
+ const {
17
+ DESKTOP_IPC_METHOD_VERSIONS: METHOD_VERSION_BY_NAME,
18
+ FRAME_HEADER_BYTES,
19
+ MAX_FRAME_BYTES,
20
+ cloneJSON,
21
+ normalizeToken,
22
+ readString,
23
+ requestIdKey,
24
+ resolveDefaultIpcSocketPath,
25
+ safeParseJSON,
26
+ writeFrame,
27
+ } = require("./desktop-ipc-shared");
28
+
13
29
  const REQUEST_TIMEOUT_MS = 10_000;
30
+ const OWNERSHIP_PROBE_TIMEOUT_MS = 1_500;
31
+ // Fresh Desktop threads are not materialized in the local thread store yet, so
32
+ // baseline reads fail until the rollout flushes; retry with backoff instead of
33
+ // hammering thread/read on every patch broadcast.
34
+ const MAX_BASELINE_RECOVERY_ATTEMPTS = 5;
35
+ const BASELINE_RECOVERY_BASE_DELAY_MS = 1_000;
36
+ const BASELINE_RECOVERY_MAX_DELAY_MS = 15_000;
37
+ const MAX_QUEUED_CHANGES_PER_THREAD = 300;
38
+ const BACKGROUND_DISCONNECT_GRACE_MS = 30_000;
39
+ // Phone interest survives per-thread release by design, so cap the set to keep a
40
+ // marathon single Desktop connection from accumulating every thread id forever.
41
+ const MAX_ACTIVE_THREAD_IDS = 512;
14
42
  const DESKTOP_IPC_ACTION_SOURCE = "desktop-ipc-action-follower";
15
- const DESKTOP_RESUME_METHODS = new Set(["thread/read", "thread/resume"]);
43
+ const REMODEX_LIVE_OWNER_SOURCE = "desktop-ipc-live-owner";
44
+ const DESKTOP_STATE_READ_METHODS = new Set(["thread/read", "thread/resume", "thread/turns/list"]);
45
+ // Sidebar refreshes should also keep the Litter subscription alive. Without
46
+ // this, a phone with no selected chat never connects to the Desktop bus and
47
+ // cannot discover runs that started on the Mac.
48
+ const DESKTOP_BACKGROUND_DISCOVERY_METHODS = new Set(["thread/list"]);
49
+ const DESKTOP_TURNS_CURSOR_PREFIX = "remodex-desktop-turns:";
50
+ // A cached Desktop state that claims an active turn is only trustworthy while
51
+ // Desktop keeps streaming updates for it. Live runs broadcast deltas far more
52
+ // often than this window; a silent "active" cache is a stale reconnect echo
53
+ // (e.g. Desktop never saw the turn finish) and must not answer phone reads, or
54
+ // the phone shows a phantom running indicator until real history loads.
55
+ const STALE_ACTIVE_READ_MAX_AGE_MS = 20_000;
56
+ const DESKTOP_FOLLOWER_REQUEST_METHODS = new Set([
57
+ "turn/start",
58
+ "turn/steer",
59
+ "turn/interrupt",
60
+ "thread/compact/start",
61
+ ]);
16
62
  const ACTION_METHODS = new Set([
17
63
  "item/commandExecution/requestApproval",
18
64
  "item/fileChange/requestApproval",
@@ -27,23 +73,148 @@ const REPLY_METHOD_BY_ACTION_METHOD = new Map([
27
73
  ["item/permissions/requestApproval", "thread-follower-file-approval-decision"],
28
74
  ["item/tool/requestUserInput", "thread-follower-submit-user-input"],
29
75
  ]);
30
- const METHOD_VERSION_BY_NAME = new Map([
31
- ["initialize", 1],
32
- ["thread-follower-command-approval-decision", 1],
33
- ["thread-follower-file-approval-decision", 1],
34
- ["thread-follower-submit-user-input", 1],
35
- ]);
36
76
  const APPROVAL_DECISIONS = new Set(["accept", "acceptForSession", "decline", "cancel"]);
37
77
 
78
+ // The app-server turns/list contract is newest-first by default. Keep the
79
+ // projected Desktop snapshot in that contract so iOS can reverse each page
80
+ // exactly once when rebuilding chronological history.
81
+ function buildDesktopTurnsListResult(turns, params = {}) {
82
+ const chronologicalTurns = Array.isArray(turns) ? turns : [];
83
+ const snapshotRevision = desktopTurnsSnapshotRevision(chronologicalTurns);
84
+ const direction = normalizeToken(readString(params?.sortDirection) || "desc") === "asc"
85
+ ? "asc"
86
+ : "desc";
87
+ const orderedTurns = direction === "asc"
88
+ ? chronologicalTurns.slice()
89
+ : chronologicalTurns.slice().reverse();
90
+
91
+ let startIndex = 0;
92
+ const cursor = readString(params?.cursor);
93
+ if (cursor) {
94
+ const parsedCursor = parseDesktopTurnsCursor(
95
+ cursor,
96
+ direction,
97
+ snapshotRevision,
98
+ orderedTurns
99
+ );
100
+ if (parsedCursor == null) {
101
+ return null;
102
+ }
103
+ startIndex = parsedCursor;
104
+ }
105
+
106
+ const requestedLimit = Number(params?.limit);
107
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
108
+ ? Math.floor(requestedLimit)
109
+ : orderedTurns.length;
110
+ const page = orderedTurns.slice(startIndex, startIndex + limit);
111
+ const hasMore = startIndex + page.length < orderedTurns.length;
112
+ const nextCursor = hasMore && page.length > 0
113
+ ? desktopTurnsCursor(
114
+ direction,
115
+ snapshotRevision,
116
+ page[page.length - 1],
117
+ startIndex + page.length
118
+ )
119
+ : null;
120
+ const clonedPage = cloneJSON(page);
121
+ return {
122
+ data: clonedPage,
123
+ nextCursor,
124
+ hasMore,
125
+ };
126
+ }
127
+
128
+ function isDesktopTurnsCursor(value) {
129
+ return readString(value).startsWith(DESKTOP_TURNS_CURSOR_PREFIX);
130
+ }
131
+
132
+ function desktopTurnsCursor(direction, snapshotRevision, turn, nextIndex) {
133
+ const turnId = readString(turn?.id)
134
+ || readString(turn?.turnId)
135
+ || readString(turn?.turn_id);
136
+ const anchor = turnId ? `id:${encodeURIComponent(turnId)}` : `index:${nextIndex}`;
137
+ return `${DESKTOP_TURNS_CURSOR_PREFIX}${direction}:${snapshotRevision}:${anchor}`;
138
+ }
139
+
140
+ function parseDesktopTurnsCursor(cursor, direction, snapshotRevision, orderedTurns) {
141
+ const prefix = `${DESKTOP_TURNS_CURSOR_PREFIX}${direction}:${snapshotRevision}:`;
142
+ if (!cursor.startsWith(prefix)) {
143
+ return null;
144
+ }
145
+ const anchor = cursor.slice(prefix.length);
146
+ if (anchor.startsWith("id:")) {
147
+ let turnId = "";
148
+ try {
149
+ turnId = decodeURIComponent(anchor.slice(3));
150
+ } catch {
151
+ return null;
152
+ }
153
+ const anchorIndex = orderedTurns.findIndex((turn) => (
154
+ readString(turn?.id) === turnId
155
+ || readString(turn?.turnId) === turnId
156
+ || readString(turn?.turn_id) === turnId
157
+ ));
158
+ return anchorIndex === -1 ? null : anchorIndex + 1;
159
+ }
160
+ if (anchor.startsWith("index:")) {
161
+ const parsedIndex = Number(anchor.slice(6));
162
+ return Number.isInteger(parsedIndex) && parsedIndex >= 0 && parsedIndex <= orderedTurns.length
163
+ ? parsedIndex
164
+ : null;
165
+ }
166
+ return null;
167
+ }
168
+
169
+ function desktopTurnsSnapshotRevision(turns) {
170
+ const hash = createHash("sha256");
171
+ const paginationStructure = (Array.isArray(turns) ? turns : []).map((turn) => ({
172
+ id: readString(turn?.id) || readString(turn?.turnId) || readString(turn?.turn_id),
173
+ input: turn?.input ?? turn?.prompt ?? null,
174
+ userItems: (Array.isArray(turn?.items) ? turn.items : []).flatMap((item) => {
175
+ const role = readString(item?.role).toLowerCase();
176
+ const type = normalizeToken(readString(item?.type));
177
+ const isUserItem = role === "user" || type === "usermessage";
178
+ if (!isUserItem) {
179
+ return [];
180
+ }
181
+ return [{
182
+ id: readString(item?.id) || readString(item?.itemId) || readString(item?.item_id),
183
+ role,
184
+ type,
185
+ // Prompt text disambiguates index-derived turn ids after insertion or
186
+ // reorder. Non-user item lifecycles do not change turn pagination.
187
+ userContent: item?.text ?? item?.content ?? null,
188
+ }];
189
+ }),
190
+ }));
191
+ hash.update(JSON.stringify(paginationStructure));
192
+ return hash.digest("hex").slice(0, 24);
193
+ }
194
+
38
195
  // Opens the Desktop IPC bus on demand and exposes Mac-owned pending actions as normal app-server requests.
39
196
  function createDesktopIpcActionFollower({
40
197
  sendApplicationResponse,
41
198
  readConversationState = null,
199
+ forwardToLocalCodex = null,
200
+ // Synchronous authority check against the bridge's own live owner: threads
201
+ // streamed by the local app-server must never be held, served, or routed as
202
+ // Desktop-owned. Broadcast-driven liveOwnerThreadIds lags this check, so it
203
+ // alone cannot close the race between a local claim and a Desktop echo.
204
+ isLocallyOwnedThread = () => false,
205
+ normalizeTurnStartParams = (params) => params,
206
+ runtimeSettingsStore = null,
42
207
  logPrefix = "[remodex]",
43
208
  socketPath = resolveDefaultIpcSocketPath(),
44
209
  netModule = net,
45
210
  now = () => Date.now(),
211
+ snapshotDebounceMs = 0,
212
+ setTimeoutFn = setTimeout,
213
+ clearTimeoutFn = clearTimeout,
214
+ onNormalizedHistoryIndexRebuilt = () => {},
46
215
  requestTimeoutMs = REQUEST_TIMEOUT_MS,
216
+ ownershipProbeTimeoutMs = OWNERSHIP_PROBE_TIMEOUT_MS,
217
+ backgroundDisconnectGraceMs = BACKGROUND_DISCONNECT_GRACE_MS,
47
218
  } = {}) {
48
219
  const ipc = createDesktopIpcClient({
49
220
  socketPath,
@@ -52,17 +223,104 @@ function createDesktopIpcActionFollower({
52
223
  requestTimeoutMs,
53
224
  logPrefix,
54
225
  onEnvelope,
226
+ onConnected() {
227
+ probeHeldFollowerRequests();
228
+ },
55
229
  onDisconnect,
56
230
  });
57
231
  const rawStatesByThreadId = new Map();
58
- const assistantMessageTextsByThreadId = new Map();
232
+ const rawStateUpdatedAtByThreadId = new Map();
233
+ const pendingSnapshotsByThreadId = new Map();
234
+ // History and live updates have different authorities. Once Desktop exposes
235
+ // normalized history that the legacy turns array does not fully cover, keep
236
+ // canonical paging on app-server/JSONL for this Desktop source epoch while
237
+ // still allowing a bounded Desktop tail to own live deltas.
238
+ const canonicalHistoryThreadIds = new Set();
239
+ const canonicalHistoryReplacementSentThreadIds = new Set();
240
+ const projectedLiveActiveTurnIdsByThreadId = new Map();
241
+ const desktopLiveLifecycleByThreadId = new Map();
242
+ const normalizedLiveIndexesByThreadId = new Map();
243
+ const staleYieldedThreadIds = new Set();
244
+ const conversationProjector = createDesktopConversationProjector({ now });
59
245
  const pendingRoutesByRequestId = new Map();
60
246
  const activeThreadIds = new Set();
247
+ // Threads discovered from Litter snapshots before the phone reads them.
248
+ // Their raw state is retained for lifecycle detection, but their transcript
249
+ // stays off the relay until the user actually opens the chat.
250
+ const backgroundOnlyThreadIds = new Set();
251
+ // Lifecycle already announced to the phone must outlive the raw Litter
252
+ // baseline. Otherwise a disconnect/eviction can erase the only evidence
253
+ // needed to send the matching completion and leave a phantom running badge.
254
+ const announcedBackgroundTurnsByThreadId = new Map();
255
+ const backgroundDisconnectTimersByThreadId = new Map();
256
+ // JS Set preserves insertion order; delete-before-add refreshes recency, and
257
+ // cap eviction skips threads with pending prompts so approvals are not lost.
258
+ function rememberActiveThread(threadId) {
259
+ activeThreadIds.delete(threadId);
260
+ activeThreadIds.add(threadId);
261
+ while (activeThreadIds.size > MAX_ACTIVE_THREAD_IDS) {
262
+ const oldest = oldestEvictableActiveThreadId();
263
+ if (oldest === undefined) {
264
+ break;
265
+ }
266
+ activeThreadIds.delete(oldest);
267
+ forgetEvictedThreadState(oldest);
268
+ }
269
+ }
270
+
271
+ function oldestEvictableActiveThreadId() {
272
+ for (const threadId of activeThreadIds) {
273
+ if (!hasPendingProjectedActions(threadId)
274
+ && !announcedBackgroundTurnsByThreadId.has(threadId)) {
275
+ return threadId;
276
+ }
277
+ }
278
+ return undefined;
279
+ }
280
+
281
+ function hasPendingProjectedActions(threadId) {
282
+ for (const route of pendingRoutesByRequestId.values()) {
283
+ if (route.threadId === threadId) {
284
+ return true;
285
+ }
286
+ }
287
+ return false;
288
+ }
289
+
290
+ // Cleanup for cap-evicted threads only: clears follower caches without touching
291
+ // liveOwnerThreadIds (still-owned local streams must not become hijackable) and
292
+ // without rejecting held requests (removeDesktopThreadState handles real removal).
293
+ function forgetEvictedThreadState(threadId) {
294
+ cancelPendingSnapshot(threadId);
295
+ settleAnnouncedBackgroundTurn(threadId, "interrupted");
296
+ backgroundOnlyThreadIds.delete(threadId);
297
+ rawStatesByThreadId.delete(threadId);
298
+ rawStateUpdatedAtByThreadId.delete(threadId);
299
+ canonicalHistoryThreadIds.delete(threadId);
300
+ canonicalHistoryReplacementSentThreadIds.delete(threadId);
301
+ projectedLiveActiveTurnIdsByThreadId.delete(threadId);
302
+ desktopLiveLifecycleByThreadId.delete(threadId);
303
+ normalizedLiveIndexesByThreadId.delete(threadId);
304
+ conversationProjector.remove(threadId);
305
+ queuedChangesByThreadId.delete(threadId);
306
+ baselineRecoveryStateByThreadId.delete(threadId);
307
+ recoveringThreadIds.delete(threadId);
308
+ ownershipProbeDeadlinesByThreadId.delete(threadId);
309
+ pendingOwnershipProbeTokensByThreadId.delete(threadId);
310
+ desktopOwnedByProbeThreadIds.delete(threadId);
311
+ }
61
312
  const recoveringThreadIds = new Set();
62
313
  const queuedChangesByThreadId = new Map();
63
-
64
- function observeInbound(rawMessage) {
65
- const message = safeParseJSON(rawMessage);
314
+ const baselineRecoveryStateByThreadId = new Map();
315
+ const liveOwnerThreadIds = new Set();
316
+ const heldFollowerRequestsByThreadId = new Map();
317
+ const ownershipProbeDeadlinesByThreadId = new Map();
318
+ const pendingOwnershipProbeTokensByThreadId = new Map();
319
+ const desktopOwnedByProbeThreadIds = new Set();
320
+ let nextOwnershipProbeToken = 0;
321
+
322
+ function observeInbound(rawMessage, parsedMessage = null) {
323
+ const message = parsedMessage ?? safeParseJSON(rawMessage);
66
324
  const responseRoute = desktopRouteForResponse(message);
67
325
  if (responseRoute) {
68
326
  submitDesktopActionResponse(responseRoute, message);
@@ -70,7 +328,97 @@ function createDesktopIpcActionFollower({
70
328
  }
71
329
 
72
330
  const method = readString(message?.method);
73
- if (!DESKTOP_RESUME_METHODS.has(method)) {
331
+ if (DESKTOP_BACKGROUND_DISCOVERY_METHODS.has(method)) {
332
+ ipc.ensureConnected();
333
+ }
334
+ if (DESKTOP_STATE_READ_METHODS.has(method)) {
335
+ const threadId = readThreadId(message?.params);
336
+ if (threadId && backgroundOnlyThreadIds.delete(threadId)) {
337
+ const rawState = rawStatesByThreadId.get(threadId);
338
+ let announcedBackgroundTurn = null;
339
+ let announcedBackgroundTurnId = "";
340
+ // Reconcile any lifecycle that may have completed while the raw
341
+ // baseline was unavailable before handing full ownership to the
342
+ // projector. From this point normal projected events own completion.
343
+ if (rawState) {
344
+ syncBackgroundThreadLifecycle(threadId, null, rawState);
345
+ announcedBackgroundTurn = announcedBackgroundTurnsByThreadId.get(threadId) || null;
346
+ announcedBackgroundTurnId = readString(announcedBackgroundTurn?.id);
347
+ } else {
348
+ settleAnnouncedBackgroundTurn(threadId, "interrupted");
349
+ }
350
+ clearBackgroundDisconnectTimer(threadId);
351
+ announcedBackgroundTurnsByThreadId.delete(threadId);
352
+ if (rawState) {
353
+ const liveState = boundedDesktopLiveStateForThread(threadId, rawState);
354
+ const hasCanonicalNormalizedHistory = canonicalHistoryThreadIds.has(threadId)
355
+ || hasNormalizedHistoryOutsideRawTurns(
356
+ rawState,
357
+ normalizedLiveIndexesByThreadId.get(threadId)
358
+ );
359
+ if (hasCanonicalNormalizedHistory) {
360
+ // Normalized history reads stay canonical, but the full active turn
361
+ // is already in this bounded Desktop state. Publish it once now so
362
+ // the phone does not begin with only the next streamed delta.
363
+ canonicalHistoryThreadIds.add(threadId);
364
+ canonicalHistoryReplacementSentThreadIds.add(threadId);
365
+ conversationProjector.remove(threadId);
366
+ sendApplicationResponse(JSON.stringify({
367
+ method: "thread/replaced",
368
+ params: {
369
+ threadId,
370
+ remodexDesktopMirror: true,
371
+ remodexDesktopIpcMirror: true,
372
+ remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
373
+ },
374
+ }));
375
+ const output = conversationProjector.project(threadId, liveState, {
376
+ includeAllActiveTurns: true,
377
+ });
378
+ for (const notification of output.notifications || []) {
379
+ const isDuplicateBackgroundStart = notification.method === "turn/started"
380
+ && readString(notification.params?.turnId) === announcedBackgroundTurnId;
381
+ if (!isDuplicateBackgroundStart) {
382
+ const isSamePromotedRun = notification.method === "turn/started"
383
+ && announcedBackgroundTurn
384
+ && desktopTurnsShareLogicalIdentity(
385
+ announcedBackgroundTurn,
386
+ notification.params?.turn
387
+ );
388
+ const promotedNotification = isSamePromotedRun
389
+ ? notificationWithTurnIdentityContinuity(notification)
390
+ : notification;
391
+ sendApplicationResponse(JSON.stringify(promotedNotification));
392
+ }
393
+ }
394
+ } else {
395
+ // Legacy reads below carry their full projected baseline. Seed the
396
+ // projector so the next patch becomes a delta instead of replaying
397
+ // that baseline a second time.
398
+ conversationProjector.seed(threadId, liveState);
399
+ }
400
+ rememberDesktopLiveProjection(threadId, liveState);
401
+ }
402
+ }
403
+ }
404
+ if (DESKTOP_FOLLOWER_REQUEST_METHODS.has(method)) {
405
+ const route = buildDesktopFollowerRoute(message);
406
+ if (route && isDesktopRoutableThread(route.threadId)) {
407
+ submitDesktopFollowerRequest(route, message);
408
+ return true;
409
+ }
410
+ if (route && shouldHoldFollowerRequest(message, route.threadId)) {
411
+ holdFollowerRequest(route.threadId, rawMessage);
412
+ probeDesktopOwnership(route);
413
+ return true;
414
+ }
415
+ }
416
+
417
+ if (tryServeDesktopOwnedRead(message)) {
418
+ return true;
419
+ }
420
+
421
+ if (!DESKTOP_STATE_READ_METHODS.has(method)) {
74
422
  return false;
75
423
  }
76
424
 
@@ -79,30 +427,101 @@ function createDesktopIpcActionFollower({
79
427
  return false;
80
428
  }
81
429
 
82
- activeThreadIds.add(threadId);
430
+ rememberActiveThread(threadId);
431
+ if (!rawStatesByThreadId.has(threadId)
432
+ && !liveOwnerThreadIds.has(threadId)
433
+ && !isLocallyOwnedThread(threadId)) {
434
+ ownershipProbeDeadlinesByThreadId.set(threadId, now() + ownershipProbeTimeoutMs);
435
+ }
83
436
  ipc.ensureConnected();
84
437
  return false;
85
438
  }
86
439
 
87
440
  function stopAll() {
441
+ for (const threadId of pendingSnapshotsByThreadId.keys()) {
442
+ cancelPendingSnapshot(threadId);
443
+ }
88
444
  rawStatesByThreadId.clear();
89
- assistantMessageTextsByThreadId.clear();
445
+ rawStateUpdatedAtByThreadId.clear();
446
+ canonicalHistoryThreadIds.clear();
447
+ canonicalHistoryReplacementSentThreadIds.clear();
448
+ projectedLiveActiveTurnIdsByThreadId.clear();
449
+ desktopLiveLifecycleByThreadId.clear();
450
+ normalizedLiveIndexesByThreadId.clear();
451
+ conversationProjector.reset();
90
452
  pendingRoutesByRequestId.clear();
91
453
  activeThreadIds.clear();
454
+ backgroundOnlyThreadIds.clear();
455
+ announcedBackgroundTurnsByThreadId.clear();
456
+ for (const timer of backgroundDisconnectTimersByThreadId.values()) {
457
+ clearTimeout(timer);
458
+ }
459
+ backgroundDisconnectTimersByThreadId.clear();
92
460
  recoveringThreadIds.clear();
461
+ baselineRecoveryStateByThreadId.clear();
93
462
  queuedChangesByThreadId.clear();
463
+ liveOwnerThreadIds.clear();
464
+ ownershipProbeDeadlinesByThreadId.clear();
465
+ pendingOwnershipProbeTokensByThreadId.clear();
466
+ desktopOwnedByProbeThreadIds.clear();
467
+ for (const queue of heldFollowerRequestsByThreadId.values()) {
468
+ for (const entry of queue) {
469
+ clearTimeout(entry.timer);
470
+ }
471
+ }
472
+ heldFollowerRequestsByThreadId.clear();
94
473
  ipc.close();
95
474
  }
96
475
 
97
476
  // Desktop broadcasts carry the live conversation state Litter projects from.
98
477
  function onEnvelope(envelope) {
478
+ if (envelope?.type === "broadcast"
479
+ && (envelope.method === "thread-archived" || envelope.method === "thread-unarchived")) {
480
+ syncThreadArchiveBroadcast(envelope);
481
+ return;
482
+ }
99
483
  if (envelope?.type !== "broadcast" || envelope.method !== "thread-stream-state-changed") {
100
484
  return;
101
485
  }
102
486
 
103
487
  const params = envelope.params || {};
104
488
  const threadId = readString(params.conversationId) || readString(params.conversation_id);
105
- if (!threadId || !activeThreadIds.has(threadId)) {
489
+ if (isRemodexLiveOwnerBroadcast(params)) {
490
+ if (threadId) {
491
+ if (params.remodexOwnerReleased === true) {
492
+ removeDesktopThreadState(threadId);
493
+ } else {
494
+ releaseDesktopThreadState(threadId);
495
+ }
496
+ }
497
+ return;
498
+ }
499
+ if (!threadId) {
500
+ return;
501
+ }
502
+ if (isSnapshotChange(params.change)) {
503
+ clearBackgroundDisconnectTimer(threadId);
504
+ }
505
+ const peerOwnershipSnapshot = isPeerOwnershipSnapshot(params);
506
+ if (peerOwnershipSnapshot && !isLocallyOwnedThread(threadId)) {
507
+ liveOwnerThreadIds.delete(threadId);
508
+ ownershipProbeDeadlinesByThreadId.delete(threadId);
509
+ desktopOwnedByProbeThreadIds.delete(threadId);
510
+ } else if (liveOwnerThreadIds.has(threadId) || isLocallyOwnedThread(threadId)) {
511
+ // Desktop echoes of a locally-streamed thread must not become follower
512
+ // state: they would shadow the app-server as the source for reads and
513
+ // mirror ghost rows the phone already has.
514
+ return;
515
+ }
516
+ // Litter sends one snapshot for every loaded conversation when this client
517
+ // connects. Keep those baselines locally so a later idle -> running patch
518
+ // can update the sidebar even if the phone never opened that chat. The
519
+ // background-only path below emits lifecycle only, never transcript rows.
520
+ if (!activeThreadIds.has(threadId) && isSnapshotChange(params.change)) {
521
+ rememberActiveThread(threadId);
522
+ backgroundOnlyThreadIds.add(threadId);
523
+ }
524
+ if (!activeThreadIds.has(threadId)) {
106
525
  return;
107
526
  }
108
527
 
@@ -111,6 +530,16 @@ function createDesktopIpcActionFollower({
111
530
  return;
112
531
  }
113
532
 
533
+ const pendingSnapshot = pendingSnapshotsByThreadId.get(threadId);
534
+ if (pendingSnapshot && isPatchChange(params.change)) {
535
+ const patchedSnapshot = applyConversationStateChange(pendingSnapshot.state, params.change);
536
+ if (patchedSnapshot) {
537
+ pendingSnapshot.state = patchedSnapshot;
538
+ return;
539
+ }
540
+ flushPendingSnapshot(threadId);
541
+ }
542
+
114
543
  const previousState = rawStatesByThreadId.get(threadId) || null;
115
544
  const nextState = applyConversationStateChange(previousState, params.change);
116
545
  if (!nextState) {
@@ -120,7 +549,12 @@ function createDesktopIpcActionFollower({
120
549
  const speculativeActions = projectPendingDesktopActions(threadId, speculativeState);
121
550
  if (speculativeActions.length > 0) {
122
551
  rawStatesByThreadId.set(threadId, speculativeState);
552
+ rawStateUpdatedAtByThreadId.set(threadId, now());
553
+ if (!backgroundOnlyThreadIds.has(threadId)) {
554
+ conversationProjector.seed(threadId, speculativeState);
555
+ }
123
556
  syncProjectedActions(threadId, speculativeActions);
557
+ releaseHeldFollowerRequests(threadId, { toDesktop: true });
124
558
  return;
125
559
  }
126
560
 
@@ -134,17 +568,396 @@ function createDesktopIpcActionFollower({
134
568
  return;
135
569
  }
136
570
 
571
+ if (isSnapshotChange(params.change) && snapshotDebounceMs > 0) {
572
+ schedulePendingSnapshot(threadId, nextState);
573
+ return;
574
+ }
575
+
576
+ commitConversationState(threadId, nextState, {
577
+ isFullSnapshot: isSnapshotChange(params.change),
578
+ change: params.change,
579
+ });
580
+ }
581
+
582
+ function schedulePendingSnapshot(threadId, state) {
583
+ cancelPendingSnapshot(threadId);
584
+ const timer = setTimeoutFn(() => {
585
+ const pending = pendingSnapshotsByThreadId.get(threadId);
586
+ if (!pending || pending.timer !== timer) {
587
+ return;
588
+ }
589
+ pendingSnapshotsByThreadId.delete(threadId);
590
+ commitConversationState(threadId, pending.state, { isFullSnapshot: true });
591
+ }, Math.max(0, snapshotDebounceMs));
592
+ timer?.unref?.();
593
+ pendingSnapshotsByThreadId.set(threadId, { state, timer });
594
+ }
595
+
596
+ function flushPendingSnapshot(threadId) {
597
+ const pending = pendingSnapshotsByThreadId.get(threadId);
598
+ if (!pending) {
599
+ return false;
600
+ }
601
+ clearTimeoutFn(pending.timer);
602
+ pendingSnapshotsByThreadId.delete(threadId);
603
+ commitConversationState(threadId, pending.state, { isFullSnapshot: true });
604
+ return true;
605
+ }
606
+
607
+ function cancelPendingSnapshot(threadId) {
608
+ const pending = pendingSnapshotsByThreadId.get(threadId);
609
+ if (!pending) {
610
+ return false;
611
+ }
612
+ clearTimeoutFn(pending.timer);
613
+ pendingSnapshotsByThreadId.delete(threadId);
614
+ return true;
615
+ }
616
+
617
+ function commitConversationState(threadId, nextState, {
618
+ isFullSnapshot = false,
619
+ change = null,
620
+ } = {}) {
621
+ if (liveOwnerThreadIds.has(threadId) || isLocallyOwnedThread(threadId)) {
622
+ releaseDesktopThreadState(threadId);
623
+ return false;
624
+ }
625
+ runtimeSettingsStore?.attachToConversation?.(threadId, nextState);
626
+ if (isFullSnapshot) {
627
+ rebuildNormalizedLiveIndex(threadId, nextState);
628
+ } else {
629
+ updateNormalizedLiveIndex(threadId, nextState, change);
630
+ }
631
+ const previousState = rawStatesByThreadId.get(threadId) || null;
137
632
  rawStatesByThreadId.set(threadId, nextState);
138
- syncProjectedAssistantDeltas(threadId, previousState, nextState);
633
+ rawStateUpdatedAtByThreadId.set(threadId, now());
634
+ // A usable state arrived: recovery bookkeeping and pre-baseline queued
635
+ // patches are obsolete (snapshots replace state wholesale).
636
+ baselineRecoveryStateByThreadId.delete(threadId);
637
+ if (isFullSnapshot) {
638
+ queuedChangesByThreadId.delete(threadId);
639
+ }
640
+ if (backgroundOnlyThreadIds.has(threadId)) {
641
+ syncBackgroundThreadLifecycle(threadId, previousState, nextState);
642
+ } else {
643
+ syncProjectedConversationState(threadId, nextState, {
644
+ isFullSnapshot,
645
+ });
646
+ }
139
647
  syncProjectedActions(threadId, projectPendingDesktopActions(threadId, nextState));
648
+ releaseHeldFollowerRequests(threadId, { toDesktop: true });
649
+ return true;
650
+ }
651
+
652
+ function rebuildNormalizedLiveIndex(threadId, state) {
653
+ const index = createNormalizedLiveIndex(state);
654
+ if (index) {
655
+ normalizedLiveIndexesByThreadId.set(threadId, index);
656
+ onNormalizedHistoryIndexRebuilt(threadId);
657
+ } else {
658
+ normalizedLiveIndexesByThreadId.delete(threadId);
659
+ }
660
+ }
661
+
662
+ function updateNormalizedLiveIndex(threadId, state, change) {
663
+ const index = normalizedLiveIndexesByThreadId.get(threadId);
664
+ if (!index) {
665
+ if (hasNormalizedTurnStore(state)) {
666
+ rebuildNormalizedLiveIndex(threadId, state);
667
+ }
668
+ return;
669
+ }
670
+ if (normalizedLiveIndexNeedsRebuild(change)) {
671
+ rebuildNormalizedLiveIndex(threadId, state);
672
+ return;
673
+ }
674
+ refreshTouchedNormalizedActiveTurns(index, state, change);
140
675
  }
141
676
 
142
677
  function onDisconnect() {
678
+ // Patch baselines are connection-scoped (Desktop re-sends a snapshot after
679
+ // reconnect), but the projector cache is not: keeping it lets the reconnect
680
+ // snapshot diff against already-mirrored content instead of replaying it.
681
+ for (const threadId of pendingSnapshotsByThreadId.keys()) {
682
+ cancelPendingSnapshot(threadId);
683
+ }
143
684
  rawStatesByThreadId.clear();
144
- assistantMessageTextsByThreadId.clear();
145
- pendingRoutesByRequestId.clear();
685
+ rawStateUpdatedAtByThreadId.clear();
686
+ // A reconnect is a new Desktop source epoch. The first normalized snapshot
687
+ // must repair canonical history instead of silently seeding content that may
688
+ // have changed while IPC was down.
689
+ canonicalHistoryReplacementSentThreadIds.clear();
690
+ projectedLiveActiveTurnIdsByThreadId.clear();
691
+ desktopLiveLifecycleByThreadId.clear();
692
+ normalizedLiveIndexesByThreadId.clear();
146
693
  recoveringThreadIds.clear();
694
+ baselineRecoveryStateByThreadId.clear();
147
695
  queuedChangesByThreadId.clear();
696
+ pendingOwnershipProbeTokensByThreadId.clear();
697
+ desktopOwnedByProbeThreadIds.clear();
698
+ for (const threadId of announcedBackgroundTurnsByThreadId.keys()) {
699
+ scheduleBackgroundDisconnectSettlement(threadId);
700
+ }
701
+ // Keep activeThreadIds: phone interest is phone-scoped, not connection-scoped.
702
+ // Clearing it here would make reconnect snapshots for a thread the phone is
703
+ // still viewing fail the activeThreadIds.has() guard until the phone happens
704
+ // to issue a fresh read. Growth is bounded by the LRU cap instead.
705
+ // Keep pending approval routes too: a transient disconnect proves nothing
706
+ // about the prompt's outcome, and falsely resolving it would dismiss a
707
+ // still-blocking approval on the phone. Reconnect snapshots reconcile them.
708
+ // Keep held turns queued: a disconnect proves nothing about ownership. Their
709
+ // hold timers route them through the bus (with a reconnect attempt), and only
710
+ // a proven delivery failure falls back to the local app-server.
711
+ }
712
+
713
+ // The bridge's own live owner just claimed this thread's stream, so drop stale
714
+ // Desktop state instead of hijacking future phone requests into Desktop IPC.
715
+ function releaseDesktopThreadState(threadId) {
716
+ cancelPendingSnapshot(threadId);
717
+ settleAnnouncedBackgroundTurn(threadId, "interrupted");
718
+ if (backgroundOnlyThreadIds.delete(threadId)) {
719
+ activeThreadIds.delete(threadId);
720
+ }
721
+ liveOwnerThreadIds.add(threadId);
722
+ ownershipProbeDeadlinesByThreadId.delete(threadId);
723
+ pendingOwnershipProbeTokensByThreadId.delete(threadId);
724
+ desktopOwnedByProbeThreadIds.delete(threadId);
725
+ syncProjectedActions(threadId, []);
726
+ rawStatesByThreadId.delete(threadId);
727
+ rawStateUpdatedAtByThreadId.delete(threadId);
728
+ canonicalHistoryThreadIds.delete(threadId);
729
+ canonicalHistoryReplacementSentThreadIds.delete(threadId);
730
+ projectedLiveActiveTurnIdsByThreadId.delete(threadId);
731
+ desktopLiveLifecycleByThreadId.delete(threadId);
732
+ normalizedLiveIndexesByThreadId.delete(threadId);
733
+ conversationProjector.remove(threadId);
734
+ queuedChangesByThreadId.delete(threadId);
735
+ baselineRecoveryStateByThreadId.delete(threadId);
736
+ releaseHeldFollowerRequests(threadId, { toDesktop: false });
737
+ }
738
+
739
+ // The live owner is releasing/removing its stream, not claiming it; cancel any
740
+ // speculative phone request instead of routing it to either runtime. Phone
741
+ // interest (activeThreadIds) deliberately survives the release: if Desktop
742
+ // picks the thread up next, its broadcasts must be processed immediately
743
+ // instead of being dropped until the phone happens to issue another read.
744
+ function removeDesktopThreadState(threadId) {
745
+ cancelPendingSnapshot(threadId);
746
+ settleAnnouncedBackgroundTurn(threadId, "interrupted");
747
+ if (backgroundOnlyThreadIds.delete(threadId)) {
748
+ activeThreadIds.delete(threadId);
749
+ }
750
+ liveOwnerThreadIds.delete(threadId);
751
+ ownershipProbeDeadlinesByThreadId.delete(threadId);
752
+ pendingOwnershipProbeTokensByThreadId.delete(threadId);
753
+ desktopOwnedByProbeThreadIds.delete(threadId);
754
+ syncProjectedActions(threadId, []);
755
+ rawStatesByThreadId.delete(threadId);
756
+ rawStateUpdatedAtByThreadId.delete(threadId);
757
+ canonicalHistoryThreadIds.delete(threadId);
758
+ canonicalHistoryReplacementSentThreadIds.delete(threadId);
759
+ projectedLiveActiveTurnIdsByThreadId.delete(threadId);
760
+ desktopLiveLifecycleByThreadId.delete(threadId);
761
+ normalizedLiveIndexesByThreadId.delete(threadId);
762
+ conversationProjector.remove(threadId);
763
+ queuedChangesByThreadId.delete(threadId);
764
+ baselineRecoveryStateByThreadId.delete(threadId);
765
+ rejectHeldFollowerRequests(threadId, "This thread is no longer available for Desktop routing.");
766
+ }
767
+
768
+ // A just-resumed Desktop-owned thread has no snapshot yet, so hold phone turn
769
+ // requests briefly instead of racing them into the local app-server. Holding is
770
+ // bounded to a short window after resume so purely local threads stay fast.
771
+ function shouldHoldFollowerRequest(message, threadId) {
772
+ if (typeof forwardToLocalCodex !== "function" || message?.id == null) {
773
+ return false;
774
+ }
775
+ if (!threadId
776
+ || !activeThreadIds.has(threadId)
777
+ || rawStatesByThreadId.has(threadId)
778
+ || liveOwnerThreadIds.has(threadId)
779
+ || isLocallyOwnedThread(threadId)) {
780
+ return false;
781
+ }
782
+ const probeDeadline = ownershipProbeDeadlinesByThreadId.get(threadId);
783
+ if (!probeDeadline || now() > probeDeadline) {
784
+ ownershipProbeDeadlinesByThreadId.delete(threadId);
785
+ return false;
786
+ }
787
+ return true;
788
+ }
789
+
790
+ // Asks the IPC bus whether any client owns this thread so held requests resolve
791
+ // as soon as possible instead of waiting out the full post-resume window.
792
+ function probeDesktopOwnership(route) {
793
+ const threadId = route.threadId;
794
+ if (pendingOwnershipProbeTokensByThreadId.has(threadId)) {
795
+ return;
796
+ }
797
+ const probeToken = ++nextOwnershipProbeToken;
798
+ pendingOwnershipProbeTokensByThreadId.set(threadId, probeToken);
799
+ ipc.sendDiscoveryRequest({
800
+ type: "request",
801
+ method: route.method,
802
+ // Codex Desktop rejects discovery unless the nested request version matches
803
+ // the method version, so mirror the normal request envelope here.
804
+ version: METHOD_VERSION_BY_NAME.get(route.method) || 1,
805
+ params: route.params,
806
+ }, ownershipProbeTimeoutMs)
807
+ .then((canHandle) => {
808
+ if (pendingOwnershipProbeTokensByThreadId.get(threadId) !== probeToken) {
809
+ return;
810
+ }
811
+ pendingOwnershipProbeTokensByThreadId.delete(threadId);
812
+ if (liveOwnerThreadIds.has(threadId) || isLocallyOwnedThread(threadId)) {
813
+ return;
814
+ }
815
+ if (canHandle === true) {
816
+ desktopOwnedByProbeThreadIds.add(threadId);
817
+ releaseHeldFollowerRequests(threadId, { toDesktop: true });
818
+ return;
819
+ }
820
+ // A negative discovery answer only means no currently connected client
821
+ // claimed the request. Keep holding so the bounded timer can route the
822
+ // request through the bus and only fall back locally after no-client-found.
823
+ });
824
+ }
825
+
826
+ // IPC may finish connecting after the first probe returned no answer; retry
827
+ // still-held phone turns once the bus can actually discover peer owners.
828
+ function probeHeldFollowerRequests() {
829
+ for (const [threadId, queue] of heldFollowerRequestsByThreadId.entries()) {
830
+ if (!queue || queue.length === 0 || liveOwnerThreadIds.has(threadId)) {
831
+ continue;
832
+ }
833
+ const message = safeParseJSON(queue[0].rawMessage);
834
+ const route = message ? buildDesktopFollowerRoute(message) : null;
835
+ if (route && shouldHoldFollowerRequest(message, threadId)) {
836
+ probeDesktopOwnership(route);
837
+ }
838
+ }
839
+ }
840
+
841
+ function isDesktopRoutableThread(threadId) {
842
+ return !liveOwnerThreadIds.has(threadId)
843
+ && !isLocallyOwnedThread(threadId)
844
+ && (rawStatesByThreadId.has(threadId) || desktopOwnedByProbeThreadIds.has(threadId));
845
+ }
846
+
847
+ function holdFollowerRequest(threadId, rawMessage) {
848
+ const probeDeadline = ownershipProbeDeadlinesByThreadId.get(threadId) || 0;
849
+ const message = safeParseJSON(rawMessage);
850
+ const method = readString(message?.method);
851
+ const entry = {
852
+ rawMessage,
853
+ timer: setTimeout(() => {
854
+ const queue = heldFollowerRequestsByThreadId.get(threadId) || [];
855
+ const index = queue.indexOf(entry);
856
+ if (index < 0) {
857
+ return;
858
+ }
859
+ queue.splice(index, 1);
860
+ if (queue.length === 0) {
861
+ heldFollowerRequestsByThreadId.delete(threadId);
862
+ }
863
+ routeExpiredHeldRequestThroughBus(rawMessage);
864
+ }, Math.max(0, probeDeadline - now())),
865
+ };
866
+ entry.timer.unref?.();
867
+ const queue = heldFollowerRequestsByThreadId.get(threadId) || [];
868
+ if (method === "turn/start") {
869
+ rejectQueuedHeldTurnStarts(queue);
870
+ }
871
+ queue.push(entry);
872
+ heldFollowerRequestsByThreadId.set(threadId, queue);
873
+ }
874
+
875
+ function rejectQueuedHeldTurnStarts(queue) {
876
+ for (let index = queue.length - 1; index >= 0; index -= 1) {
877
+ const entry = queue[index];
878
+ const message = safeParseJSON(entry.rawMessage);
879
+ if (readString(message?.method) !== "turn/start") {
880
+ continue;
881
+ }
882
+ queue.splice(index, 1);
883
+ clearTimeout(entry.timer);
884
+ rejectHeldFollowerRequest(message, "Superseded by a newer held turn/start request.");
885
+ }
886
+ }
887
+
888
+ // Codex Desktop's real IPC router ignores client-origin discovery probes, so an
889
+ // unanswered probe proves nothing. Route the expired request through the bus as
890
+ // a normal request: the router discovers a Desktop owner itself, and a proven
891
+ // no-handler error falls back to the local app-server via the delivery-failure
892
+ // path instead of double-running the turn on both runtimes.
893
+ function routeExpiredHeldRequestThroughBus(rawMessage) {
894
+ const message = safeParseJSON(rawMessage);
895
+ const route = message ? buildDesktopFollowerRoute(message) : null;
896
+ if (route) {
897
+ // The request is being routed definitively now, so a late discovery answer
898
+ // must not retroactively mark the thread Desktop-owned.
899
+ pendingOwnershipProbeTokensByThreadId.delete(route.threadId);
900
+ }
901
+ if (!route || liveOwnerThreadIds.has(route.threadId) || isLocallyOwnedThread(route.threadId)) {
902
+ forwardToLocalCodex(rawMessage);
903
+ return;
904
+ }
905
+ submitDesktopFollowerRequest(route, message);
906
+ }
907
+
908
+ function releaseHeldFollowerRequests(threadId, { toDesktop } = {}) {
909
+ const queue = heldFollowerRequestsByThreadId.get(threadId);
910
+ if (!queue || queue.length === 0) {
911
+ heldFollowerRequestsByThreadId.delete(threadId);
912
+ return;
913
+ }
914
+
915
+ heldFollowerRequestsByThreadId.delete(threadId);
916
+ let releasedTurnStart = false;
917
+ for (const entry of queue) {
918
+ clearTimeout(entry.timer);
919
+ const originalMessage = safeParseJSON(entry.rawMessage);
920
+ if (readString(originalMessage?.method) === "turn/start") {
921
+ if (releasedTurnStart) {
922
+ rejectHeldFollowerRequest(originalMessage, "Superseded by another held turn/start request.");
923
+ continue;
924
+ }
925
+ releasedTurnStart = true;
926
+ }
927
+ const message = toDesktop ? originalMessage : null;
928
+ const route = message ? buildDesktopFollowerRoute(message) : null;
929
+ if (route && isDesktopRoutableThread(route.threadId)) {
930
+ submitDesktopFollowerRequest(route, message);
931
+ } else {
932
+ forwardToLocalCodex?.(entry.rawMessage);
933
+ }
934
+ }
935
+ }
936
+
937
+ function rejectHeldFollowerRequests(threadId, reason) {
938
+ const queue = heldFollowerRequestsByThreadId.get(threadId);
939
+ if (!queue || queue.length === 0) {
940
+ heldFollowerRequestsByThreadId.delete(threadId);
941
+ return;
942
+ }
943
+ heldFollowerRequestsByThreadId.delete(threadId);
944
+ for (const entry of queue) {
945
+ clearTimeout(entry.timer);
946
+ rejectHeldFollowerRequest(safeParseJSON(entry.rawMessage), reason);
947
+ }
948
+ }
949
+
950
+ function rejectHeldFollowerRequest(message, reason) {
951
+ if (message?.id == null) {
952
+ return;
953
+ }
954
+ sendApplicationResponse(JSON.stringify({
955
+ id: message.id,
956
+ error: {
957
+ code: -32000,
958
+ message: reason,
959
+ },
960
+ }));
148
961
  }
149
962
 
150
963
  function syncProjectedActions(threadId, actions) {
@@ -155,13 +968,7 @@ function createDesktopIpcActionFollower({
155
968
  }
156
969
 
157
970
  pendingRoutesByRequestId.delete(requestId);
158
- sendApplicationResponse(JSON.stringify({
159
- method: "serverRequest/resolved",
160
- params: {
161
- threadId,
162
- requestId,
163
- },
164
- }));
971
+ sendApplicationResponse(JSON.stringify(projectedResolvedNotification(threadId, requestId)));
165
972
  }
166
973
 
167
974
  for (const action of actions) {
@@ -182,6 +989,443 @@ function createDesktopIpcActionFollower({
182
989
  }
183
990
  }
184
991
 
992
+ // Legacy Desktop snapshots can still serve reads, but normalized history is
993
+ // permanently canonical for this Desktop source epoch. A later snapshot that
994
+ // happens to refill raw turns must not reclaim paging and flap live sources.
995
+ function tryServeDesktopOwnedRead(message) {
996
+ const method = readString(message?.method);
997
+ if (!DESKTOP_STATE_READ_METHODS.has(method) || message?.id == null) {
998
+ return false;
999
+ }
1000
+ const threadId = readThreadId(message.params);
1001
+ const ownsDesktopCursor = method === "thread/turns/list"
1002
+ && isDesktopTurnsCursor(message.params?.cursor);
1003
+ if (!threadId) {
1004
+ return false;
1005
+ }
1006
+ if (liveOwnerThreadIds.has(threadId) || isLocallyOwnedThread(threadId)) {
1007
+ return ownsDesktopCursor ? rejectDesktopTurnsCursor(message) : false;
1008
+ }
1009
+ const rawState = rawStatesByThreadId.get(threadId);
1010
+ if (pendingSnapshotsByThreadId.has(threadId)) {
1011
+ return ownsDesktopCursor ? rejectDesktopTurnsCursor(message) : false;
1012
+ }
1013
+ if (!rawState) {
1014
+ return ownsDesktopCursor ? rejectDesktopTurnsCursor(message) : false;
1015
+ }
1016
+
1017
+ rememberActiveThread(threadId);
1018
+ // Newer Litter snapshots keep materialized history in
1019
+ // turnHistory.history.entitiesByKey while leaving the legacy top-level
1020
+ // turns array empty or limited to only the current turn. The Desktop
1021
+ // projector cannot decode the remaining normalized store, so a partial
1022
+ // legacy projection is not authoritative. Yield before answering with
1023
+ // truncated history and let app-server/JSONL paging reconstruct it.
1024
+ if (hasNormalizedHistoryOutsideRawTurns(
1025
+ rawState,
1026
+ normalizedLiveIndexesByThreadId.get(threadId)
1027
+ )) {
1028
+ canonicalHistoryThreadIds.add(threadId);
1029
+ }
1030
+ if (canonicalHistoryThreadIds.has(threadId)) {
1031
+ if (isDesktopLiveTurnStateSnapshotRequest(message)) {
1032
+ const liveState = boundedDesktopLiveStateForThread(threadId, rawState);
1033
+ sendApplicationResponse(JSON.stringify({
1034
+ id: message.id,
1035
+ result: buildDesktopLiveTurnStateResult(liveState.turns),
1036
+ }));
1037
+ return true;
1038
+ }
1039
+ // Falling through only starts a canonical request. It may be a metadata-
1040
+ // only resume or may fail before history arrives, so keep the repair
1041
+ // signal armed until a live update can force a verified reload.
1042
+ return ownsDesktopCursor ? rejectDesktopTurnsCursor(message) : false;
1043
+ }
1044
+ const thread = projectDesktopConversationStateToThread(threadId, rawState, { now });
1045
+ // A run that Desktop stopped streaming updates for is not a live run: serving
1046
+ // it from cache would answer thread-list refreshes with a phantom "running"
1047
+ // turn until real history loads. Let the local app-server answer instead.
1048
+ if (hasActiveProjectedTurn(thread)
1049
+ && isRawStateStaleForActiveRead(threadId)
1050
+ && !ownsDesktopCursor) {
1051
+ staleYieldedThreadIds.add(threadId);
1052
+ return false;
1053
+ }
1054
+ const result = method === "thread/turns/list"
1055
+ ? buildDesktopTurnsListResult(thread.turns, message.params)
1056
+ : {
1057
+ // The projected thread is the entire payload the phone decodes;
1058
+ // echoing the raw Desktop conversationState alongside it doubled
1059
+ // heavy threads past the relay frame limit for nothing.
1060
+ thread,
1061
+ };
1062
+ if (!result) {
1063
+ return ownsDesktopCursor ? rejectDesktopTurnsCursor(message) : false;
1064
+ }
1065
+ sendApplicationResponse(JSON.stringify({
1066
+ id: message.id,
1067
+ result,
1068
+ }));
1069
+ return true;
1070
+ }
1071
+
1072
+ function rejectDesktopTurnsCursor(message) {
1073
+ sendApplicationResponse(JSON.stringify({
1074
+ id: message.id,
1075
+ error: {
1076
+ code: -32602,
1077
+ message: "Desktop history changed while paging. Reload this thread to restart history pagination.",
1078
+ },
1079
+ }));
1080
+ return true;
1081
+ }
1082
+
1083
+ function isDesktopLiveTurnStateSnapshotRequest(message) {
1084
+ if (readString(message?.method) !== "thread/turns/list"
1085
+ || readString(message?.params?.cursor)
1086
+ || message?.params?.remodexRequireCanonical === true) {
1087
+ return false;
1088
+ }
1089
+ if (message?.params?.remodexTurnStateOnly === true) {
1090
+ return true;
1091
+ }
1092
+ // Remodex iPhone 2.1 predates the explicit marker. Its running-state probe
1093
+ // has this unique request shape; actual history pages use limits 1 and 5.
1094
+ return Number(message?.params?.limit) === 8
1095
+ && normalizeToken(readString(message?.params?.sortDirection) || "desc") === "desc";
1096
+ }
1097
+
1098
+ function buildDesktopLiveTurnStateResult(turns) {
1099
+ const data = (Array.isArray(turns) ? turns : [])
1100
+ .slice()
1101
+ .reverse()
1102
+ .map((turn) => {
1103
+ const id = readString(turn?.id)
1104
+ || readString(turn?.turnId)
1105
+ || readString(turn?.turn_id);
1106
+ return {
1107
+ ...(id ? { id } : {}),
1108
+ // Unknown status must never invent an interruptible turn. Live
1109
+ // activity still carries explicit inProgress/running statuses.
1110
+ status: readString(turn?.status) || "completed",
1111
+ };
1112
+ });
1113
+ return {
1114
+ data,
1115
+ nextCursor: null,
1116
+ hasMore: false,
1117
+ remodexDesktopLiveState: true,
1118
+ };
1119
+ }
1120
+
1121
+ function hasActiveProjectedTurn(thread) {
1122
+ return (thread?.turns || []).some((turn) => turn?.status === "inProgress")
1123
+ || readString(thread?.status?.type) === "active";
1124
+ }
1125
+
1126
+ function hasNormalizedHistoryOutsideRawTurns(rawState, normalizedIndex = null) {
1127
+ if (normalizedIndex) {
1128
+ return normalizedIndex.hasHistoryOutsideRawTurns;
1129
+ }
1130
+ const turnHistory = rawState?.turnHistory ?? rawState?.turn_history;
1131
+ const history = turnHistory?.history;
1132
+ const entities = history?.entitiesByKey ?? history?.entities_by_key;
1133
+ if (!entities || typeof entities !== "object" || Array.isArray(entities)) {
1134
+ return false;
1135
+ }
1136
+ const rawTurnIds = new Set((Array.isArray(rawState?.turns) ? rawState.turns : [])
1137
+ .map((turn) => (
1138
+ readString(turn?.id)
1139
+ || readString(turn?.turnId)
1140
+ || readString(turn?.turn_id)
1141
+ ))
1142
+ .filter(Boolean));
1143
+ for (const [key, entity] of Object.entries(entities)) {
1144
+ const normalizedTurnId = key.startsWith("turn:")
1145
+ ? readString(key.slice("turn:".length))
1146
+ : readString(entity?.turnId) || readString(entity?.turn_id);
1147
+ if (normalizedTurnId && !rawTurnIds.has(normalizedTurnId)) {
1148
+ return true;
1149
+ }
1150
+ }
1151
+ return false;
1152
+ }
1153
+
1154
+ function isRawStateStaleForActiveRead(threadId) {
1155
+ const updatedAt = rawStateUpdatedAtByThreadId.get(threadId) || 0;
1156
+ return now() - updatedAt > STALE_ACTIVE_READ_MAX_AGE_MS;
1157
+ }
1158
+
1159
+ function boundedDesktopLiveStateForThread(threadId, state) {
1160
+ return boundedDesktopLiveState(
1161
+ state,
1162
+ now(),
1163
+ projectedLiveActiveTurnIdsByThreadId.get(threadId) || new Set(),
1164
+ normalizedLiveIndexesByThreadId.get(threadId) || null
1165
+ );
1166
+ }
1167
+
1168
+ function rememberDesktopLiveProjection(threadId, liveState) {
1169
+ const activeTurns = activeDesktopTurnDescriptors(liveState);
1170
+ const activeTurnIds = new Set(activeTurns.map((turn) => turn.id));
1171
+ if (activeTurnIds.size > 0) {
1172
+ projectedLiveActiveTurnIdsByThreadId.set(threadId, activeTurnIds);
1173
+ } else {
1174
+ projectedLiveActiveTurnIdsByThreadId.delete(threadId);
1175
+ }
1176
+ desktopLiveLifecycleByThreadId.set(
1177
+ threadId,
1178
+ new Map(activeTurns.map((turn) => [turn.id, turn]))
1179
+ );
1180
+ }
1181
+
1182
+ function emitDesktopSnapshotLifecycleTransition(threadId, liveState) {
1183
+ const previousById = desktopLiveLifecycleByThreadId.get(threadId) || new Map();
1184
+ const nextTurns = activeDesktopTurnDescriptors(liveState);
1185
+ const nextById = new Map(nextTurns.map((turn) => [turn.id, turn]));
1186
+ const {
1187
+ previousTurnIds: continuityPreviousTurnIds,
1188
+ nextTurnIds: continuityNextTurnIds,
1189
+ } = matchDesktopTurnIdentityContinuities(
1190
+ [...previousById.values()],
1191
+ nextTurns
1192
+ );
1193
+ for (const previous of previousById.values()) {
1194
+ if (nextById.has(previous.id)) {
1195
+ continue;
1196
+ }
1197
+ if (continuityPreviousTurnIds.has(previous.id)) {
1198
+ // Canonical ID repair is one uninterrupted run. Emitting a terminal
1199
+ // event for the synthetic alias would make iOS release its recovered
1200
+ // viewport before the continuity-tagged canonical start arrives.
1201
+ continue;
1202
+ }
1203
+ const terminalTurn = (liveState.turns || []).find((turn) => turnIdOf(turn) === previous.id);
1204
+ const terminalStatus = readString(terminalTurn?.status) || "completed";
1205
+ sendApplicationResponse(JSON.stringify(desktopLiveTurnLifecycleNotification(
1206
+ "turn/completed",
1207
+ threadId,
1208
+ { id: previous.id, status: terminalStatus }
1209
+ )));
1210
+ }
1211
+ for (const next of nextTurns) {
1212
+ if (previousById.has(next.id)) {
1213
+ continue;
1214
+ }
1215
+ const startedNotification = desktopLiveTurnLifecycleNotification(
1216
+ "turn/started",
1217
+ threadId,
1218
+ next
1219
+ );
1220
+ sendApplicationResponse(JSON.stringify(
1221
+ continuityNextTurnIds.has(next.id)
1222
+ ? notificationWithTurnIdentityContinuity(startedNotification)
1223
+ : startedNotification
1224
+ ));
1225
+ }
1226
+ }
1227
+
1228
+ function syncProjectedConversationState(threadId, nextState, { isFullSnapshot = false } = {}) {
1229
+ const resumedAfterStaleYield = staleYieldedThreadIds.delete(threadId);
1230
+ if (!canonicalHistoryThreadIds.has(threadId)
1231
+ && hasNormalizedHistoryOutsideRawTurns(
1232
+ nextState,
1233
+ normalizedLiveIndexesByThreadId.get(threadId)
1234
+ )) {
1235
+ canonicalHistoryThreadIds.add(threadId);
1236
+ }
1237
+ const liveState = boundedDesktopLiveStateForThread(threadId, nextState);
1238
+ if (canonicalHistoryThreadIds.has(threadId)
1239
+ && !canonicalHistoryReplacementSentThreadIds.has(threadId)) {
1240
+ canonicalHistoryReplacementSentThreadIds.add(threadId);
1241
+ conversationProjector.remove(threadId);
1242
+ // Seed the bounded live tail before asking the phone for canonical
1243
+ // history. Subsequent 8-50ms Desktop patches then become small deltas
1244
+ // instead of replaying hundreds of current-turn items as a baseline.
1245
+ conversationProjector.seed(threadId, liveState);
1246
+ sendApplicationResponse(JSON.stringify({
1247
+ method: "thread/replaced",
1248
+ params: {
1249
+ threadId,
1250
+ remodexDesktopMirror: true,
1251
+ remodexDesktopIpcMirror: true,
1252
+ remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
1253
+ },
1254
+ }));
1255
+ emitDesktopSnapshotLifecycleTransition(threadId, liveState);
1256
+ rememberDesktopLiveProjection(threadId, liveState);
1257
+ return;
1258
+ }
1259
+ if (isFullSnapshot && canonicalHistoryThreadIds.has(threadId)) {
1260
+ // Litter can immediately rehydrate the same normalized snapshot with a
1261
+ // completely different set of item IDs. Treat full snapshots as source
1262
+ // baselines: preserve only turn lifecycle, then seed. Item diffs resume
1263
+ // on subsequent patches and cannot replay hundreds of old rows.
1264
+ emitDesktopSnapshotLifecycleTransition(threadId, liveState);
1265
+ conversationProjector.seed(threadId, liveState);
1266
+ rememberDesktopLiveProjection(threadId, liveState);
1267
+ return;
1268
+ }
1269
+ if (resumedAfterStaleYield) {
1270
+ // Switching back from rollout/app-server history to fresh Desktop state
1271
+ // is a source epoch change. Force a baseline + thread/replaced repair.
1272
+ conversationProjector.remove(threadId);
1273
+ }
1274
+ const output = conversationProjector.project(threadId, liveState);
1275
+ if (resumedAfterStaleYield || output.type === "fullReplace" || output.type === "baseline") {
1276
+ // fullReplace: synthesized turn ids just became real, stale rows must go.
1277
+ // baseline: the projector cache was evicted, so updates that arrived while
1278
+ // unobserved were never mirrored. Both cases need the phone to rebuild the
1279
+ // thread from canonical history instead of trusting incremental rows.
1280
+ // The phone reacts to thread/replaced by re-reading canonical history;
1281
+ // it never decodes an embedded thread, and heavy threads would blow the
1282
+ // relay frame limit if we shipped one.
1283
+ sendApplicationResponse(JSON.stringify({
1284
+ method: "thread/replaced",
1285
+ params: {
1286
+ threadId,
1287
+ remodexDesktopMirror: true,
1288
+ remodexDesktopIpcMirror: true,
1289
+ remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
1290
+ },
1291
+ }));
1292
+ }
1293
+ for (const notification of output.notifications || []) {
1294
+ const preservesTurnIdentity = notification.method === "turn/started"
1295
+ && output.turnIdentityContinuityTurnIds?.includes(
1296
+ readString(notification.params?.turnId)
1297
+ );
1298
+ const projectedNotification = preservesTurnIdentity
1299
+ ? notificationWithTurnIdentityContinuity(notification)
1300
+ : notification;
1301
+ sendApplicationResponse(JSON.stringify(projectedNotification));
1302
+ }
1303
+ rememberDesktopLiveProjection(threadId, liveState);
1304
+ }
1305
+
1306
+ // Unopened chats only need run-state signals for the sidebar. Sending the
1307
+ // projector's full bootstrap here would replay every historical item from
1308
+ // every running Desktop chat onto the phone during a sidebar refresh.
1309
+ function syncBackgroundThreadLifecycle(threadId, _previousState, nextState) {
1310
+ const announcedTurn = announcedBackgroundTurnsByThreadId.get(threadId) || null;
1311
+ const previousTurnId = readString(announcedTurn?.id);
1312
+ const retainedTurnIds = previousTurnId ? new Set([previousTurnId]) : new Set();
1313
+ const liveState = boundedDesktopLiveState(
1314
+ nextState,
1315
+ now(),
1316
+ retainedTurnIds,
1317
+ normalizedLiveIndexesByThreadId.get(threadId) || null
1318
+ );
1319
+ const nextActiveTurn = latestActiveRawTurn(liveState);
1320
+ const nextTurnId = readString(nextActiveTurn?.id);
1321
+
1322
+ if (previousTurnId && previousTurnId !== nextTurnId) {
1323
+ const settledTurn = backgroundRawTurnById(liveState, previousTurnId)
1324
+ || announcedTurn;
1325
+ const settledStatus = readString(settledTurn?.status);
1326
+ settleAnnouncedBackgroundTurn(
1327
+ threadId,
1328
+ settledStatus === "failed" || settledStatus === "interrupted"
1329
+ ? settledStatus
1330
+ : "completed",
1331
+ settledTurn
1332
+ );
1333
+ }
1334
+
1335
+ if (nextTurnId && nextTurnId !== previousTurnId) {
1336
+ sendApplicationResponse(JSON.stringify(backgroundTurnLifecycleNotification(
1337
+ "turn/started",
1338
+ threadId,
1339
+ nextActiveTurn
1340
+ )));
1341
+ announcedBackgroundTurnsByThreadId.set(threadId, nextActiveTurn);
1342
+ clearBackgroundDisconnectTimer(threadId);
1343
+ }
1344
+ }
1345
+
1346
+ function settleAnnouncedBackgroundTurn(threadId, status = "interrupted", turn = null) {
1347
+ const announcedTurn = announcedBackgroundTurnsByThreadId.get(threadId);
1348
+ if (!announcedTurn) {
1349
+ clearBackgroundDisconnectTimer(threadId);
1350
+ return false;
1351
+ }
1352
+ const settledTurn = {
1353
+ ...announcedTurn,
1354
+ ...(turn && typeof turn === "object" ? turn : {}),
1355
+ id: announcedTurn.id,
1356
+ status,
1357
+ };
1358
+ sendApplicationResponse(JSON.stringify(backgroundTurnLifecycleNotification(
1359
+ "turn/completed",
1360
+ threadId,
1361
+ settledTurn
1362
+ )));
1363
+ announcedBackgroundTurnsByThreadId.delete(threadId);
1364
+ clearBackgroundDisconnectTimer(threadId);
1365
+ return true;
1366
+ }
1367
+
1368
+ function scheduleBackgroundDisconnectSettlement(threadId) {
1369
+ if (!announcedBackgroundTurnsByThreadId.has(threadId)
1370
+ || backgroundDisconnectTimersByThreadId.has(threadId)) {
1371
+ return;
1372
+ }
1373
+ const expectedTurnId = announcedBackgroundTurnsByThreadId.get(threadId)?.id;
1374
+ const timer = setTimeout(() => {
1375
+ backgroundDisconnectTimersByThreadId.delete(threadId);
1376
+ if (announcedBackgroundTurnsByThreadId.get(threadId)?.id !== expectedTurnId) {
1377
+ return;
1378
+ }
1379
+ settleAnnouncedBackgroundTurn(threadId, "interrupted");
1380
+ }, Math.max(0, backgroundDisconnectGraceMs));
1381
+ timer.unref?.();
1382
+ backgroundDisconnectTimersByThreadId.set(threadId, timer);
1383
+ }
1384
+
1385
+ function clearBackgroundDisconnectTimer(threadId) {
1386
+ const timer = backgroundDisconnectTimersByThreadId.get(threadId);
1387
+ if (!timer) {
1388
+ return;
1389
+ }
1390
+ clearTimeout(timer);
1391
+ backgroundDisconnectTimersByThreadId.delete(threadId);
1392
+ }
1393
+
1394
+ function syncThreadArchiveBroadcast(envelope) {
1395
+ const params = envelope.params || {};
1396
+ const threadId = readString(params.conversationId) || readString(params.conversation_id);
1397
+ if (!threadId) {
1398
+ return;
1399
+ }
1400
+ if (envelope.method === "thread-archived") {
1401
+ cancelPendingSnapshot(threadId);
1402
+ settleAnnouncedBackgroundTurn(threadId, "interrupted");
1403
+ if (backgroundOnlyThreadIds.delete(threadId)) {
1404
+ activeThreadIds.delete(threadId);
1405
+ }
1406
+ rawStatesByThreadId.delete(threadId);
1407
+ rawStateUpdatedAtByThreadId.delete(threadId);
1408
+ canonicalHistoryThreadIds.delete(threadId);
1409
+ canonicalHistoryReplacementSentThreadIds.delete(threadId);
1410
+ projectedLiveActiveTurnIdsByThreadId.delete(threadId);
1411
+ desktopLiveLifecycleByThreadId.delete(threadId);
1412
+ normalizedLiveIndexesByThreadId.delete(threadId);
1413
+ conversationProjector.remove(threadId);
1414
+ syncProjectedActions(threadId, []);
1415
+ }
1416
+ sendApplicationResponse(JSON.stringify({
1417
+ method: envelope.method === "thread-archived" ? "thread/archived" : "thread/unarchived",
1418
+ params: {
1419
+ threadId,
1420
+ conversationId: threadId,
1421
+ cwd: readString(params.cwd),
1422
+ remodexDesktopMirror: true,
1423
+ remodexDesktopIpcMirror: true,
1424
+ remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
1425
+ },
1426
+ }));
1427
+ }
1428
+
185
1429
  function desktopRouteForResponse(message) {
186
1430
  if (!message || typeof message !== "object" || message.method) {
187
1431
  return null;
@@ -207,13 +1451,9 @@ function createDesktopIpcActionFollower({
207
1451
  ipc.sendRequest(payload.method, payload.params)
208
1452
  .then(() => {
209
1453
  pendingRoutesByRequestId.delete(route.requestId);
210
- sendApplicationResponse(JSON.stringify({
211
- method: "serverRequest/resolved",
212
- params: {
213
- threadId: route.threadId,
214
- requestId: route.requestId,
215
- },
216
- }));
1454
+ sendApplicationResponse(JSON.stringify(
1455
+ projectedResolvedNotification(route.threadId, route.requestId)
1456
+ ));
217
1457
  })
218
1458
  .catch((error) => {
219
1459
  console.warn(`${logPrefix} desktop action reply failed for ${route.threadId}: ${error.message}`);
@@ -227,6 +1467,195 @@ function createDesktopIpcActionFollower({
227
1467
  });
228
1468
  }
229
1469
 
1470
+ function buildDesktopFollowerRoute(message) {
1471
+ const requestId = requestIdKey(message?.id);
1472
+ if (!requestId) {
1473
+ return null;
1474
+ }
1475
+ const method = readString(message?.method);
1476
+ const params = message?.params && typeof message.params === "object" && !Array.isArray(message.params)
1477
+ ? message.params
1478
+ : {};
1479
+ const threadId = readThreadId(params);
1480
+ if (!threadId) {
1481
+ return null;
1482
+ }
1483
+
1484
+ if (method === "turn/start") {
1485
+ return {
1486
+ threadId,
1487
+ method: "thread-follower-start-turn",
1488
+ params: {
1489
+ conversationId: threadId,
1490
+ senderRequestId: requestId,
1491
+ turnStartParams: params,
1492
+ },
1493
+ };
1494
+ }
1495
+ if (method === "turn/steer") {
1496
+ return {
1497
+ threadId,
1498
+ method: "thread-follower-steer-turn",
1499
+ params: {
1500
+ conversationId: threadId,
1501
+ input: Array.isArray(params.input) ? params.input : [],
1502
+ expectedTurnId: readString(params.expectedTurnId) || readString(params.expected_turn_id),
1503
+ },
1504
+ };
1505
+ }
1506
+ if (method === "turn/interrupt") {
1507
+ return {
1508
+ threadId,
1509
+ method: "thread-follower-interrupt-turn",
1510
+ params: {
1511
+ conversationId: threadId,
1512
+ turnId: readString(params.turnId) || readString(params.turn_id),
1513
+ },
1514
+ };
1515
+ }
1516
+ if (method === "thread/compact/start") {
1517
+ return {
1518
+ threadId,
1519
+ method: "thread-follower-compact-thread",
1520
+ params: {
1521
+ conversationId: threadId,
1522
+ },
1523
+ };
1524
+ }
1525
+
1526
+ return null;
1527
+ }
1528
+
1529
+ function submitDesktopFollowerRequest(route, originalMessage) {
1530
+ Promise.resolve()
1531
+ .then(() => resolveFollowerRequestParams(route))
1532
+ .then(async (resolvedParams) => {
1533
+ if (route.method === "thread-follower-start-turn") {
1534
+ await syncDesktopOwnerRuntimeSettings(route.threadId, resolvedParams.turnStartParams);
1535
+ }
1536
+ return {
1537
+ resolvedParams,
1538
+ result: await ipc.sendRequest(route.method, resolvedParams),
1539
+ };
1540
+ })
1541
+ .then(({ resolvedParams, result }) => {
1542
+ const appServerResult = appServerResultForFollowerRequest(route.method, result);
1543
+ if (route.method === "thread-follower-start-turn") {
1544
+ commitPhoneRuntimeSettings(
1545
+ route.threadId,
1546
+ resolvedParams.turnStartParams,
1547
+ readTurnIdFromAppServerResult(appServerResult)
1548
+ );
1549
+ }
1550
+ sendApplicationResponse(JSON.stringify({
1551
+ id: originalMessage.id,
1552
+ result: appServerResult,
1553
+ }));
1554
+ })
1555
+ .catch((error) => {
1556
+ console.warn(`${logPrefix} desktop follower request failed: ${error.message}`);
1557
+ // Only rerun the request locally when we know Desktop never received it.
1558
+ // Timeouts and explicit remote errors stay errors: the turn may already be
1559
+ // running on Desktop, and executing it again locally would duplicate it.
1560
+ if (typeof forwardToLocalCodex === "function" && isDeliveryFailureError(error)) {
1561
+ const threadId = readString(route.threadId) || readString(route.params?.conversationId);
1562
+ if (threadId) {
1563
+ releaseDesktopThreadState(threadId);
1564
+ }
1565
+ forwardToLocalCodex(JSON.stringify(originalMessage));
1566
+ return;
1567
+ }
1568
+ sendApplicationResponse(JSON.stringify({
1569
+ id: originalMessage.id,
1570
+ error: {
1571
+ code: -32000,
1572
+ message: "Could not continue this Codex Desktop-owned thread from the phone.",
1573
+ },
1574
+ }));
1575
+ });
1576
+ }
1577
+
1578
+ function appServerResultForFollowerRequest(method, result) {
1579
+ if (method === "thread-follower-start-turn"
1580
+ && result
1581
+ && typeof result === "object"
1582
+ && !Array.isArray(result)
1583
+ && Object.prototype.hasOwnProperty.call(result, "result")) {
1584
+ return result.result ?? null;
1585
+ }
1586
+ return result ?? null;
1587
+ }
1588
+
1589
+ function readTurnIdFromAppServerResult(result) {
1590
+ return readString(result?.turn?.id)
1591
+ || readString(result?.turnId)
1592
+ || readString(result?.turn_id)
1593
+ || "";
1594
+ }
1595
+
1596
+ // Desktop-owned threads build the actual app-server turn from the owner's
1597
+ // local composer state. Passing model/effort only inside start-turn leaves
1598
+ // that state untouched, so Desktop silently starts with its old selection.
1599
+ // Apply the phone's complete runtime choice first, then start the turn.
1600
+ async function syncDesktopOwnerRuntimeSettings(threadId, turnStartParams) {
1601
+ const params = turnStartParams && typeof turnStartParams === "object"
1602
+ ? turnStartParams
1603
+ : {};
1604
+ const collaborationMode = params.collaborationMode && typeof params.collaborationMode === "object"
1605
+ ? cloneJSON(params.collaborationMode)
1606
+ : null;
1607
+ const collaborationSettings = collaborationMode?.settings;
1608
+ const model = readString(params.model) || readString(collaborationSettings?.model);
1609
+ const effort = readString(params.effort)
1610
+ || readString(params.reasoningEffort)
1611
+ || readString(collaborationSettings?.reasoning_effort)
1612
+ || readString(collaborationSettings?.reasoningEffort);
1613
+ if (!model && !effort && !collaborationMode) {
1614
+ return;
1615
+ }
1616
+
1617
+ await ipc.sendRequest("thread-follower-update-thread-settings", {
1618
+ conversationId: threadId,
1619
+ threadSettings: {
1620
+ ...(model ? { model } : {}),
1621
+ effort: effort || null,
1622
+ // turn/start omission is the app-server representation of Normal speed.
1623
+ serviceTier: readString(params.serviceTier) || readString(params.service_tier) || null,
1624
+ ...(collaborationMode ? { collaborationMode } : {}),
1625
+ },
1626
+ });
1627
+ }
1628
+
1629
+ function commitPhoneRuntimeSettings(threadId, turnStartParams, turnId) {
1630
+ try {
1631
+ runtimeSettingsStore?.commit?.(threadId, turnStartParams, {
1632
+ source: "phone",
1633
+ turnId,
1634
+ });
1635
+ } catch (error) {
1636
+ console.warn(`${logPrefix} runtime settings persistence failed: ${error.message}`);
1637
+ }
1638
+ }
1639
+
1640
+ // Desktop-followed turn starts must apply the same param normalization as
1641
+ // requests forwarded straight to the local app-server.
1642
+ async function resolveFollowerRequestParams(route) {
1643
+ if (route.method !== "thread-follower-start-turn") {
1644
+ return route.params;
1645
+ }
1646
+
1647
+ const normalized = await Promise.resolve(
1648
+ normalizeTurnStartParams(cloneJSON(route.params.turnStartParams))
1649
+ );
1650
+ const turnStartParams = normalized && typeof normalized === "object" && !Array.isArray(normalized)
1651
+ ? normalized
1652
+ : route.params.turnStartParams;
1653
+ return {
1654
+ ...route.params,
1655
+ turnStartParams,
1656
+ };
1657
+ }
1658
+
230
1659
  function queueThreadChange(threadId, change) {
231
1660
  if (!change || typeof change !== "object") {
232
1661
  return;
@@ -234,6 +1663,11 @@ function createDesktopIpcActionFollower({
234
1663
 
235
1664
  const queuedChanges = queuedChangesByThreadId.get(threadId) || [];
236
1665
  queuedChanges.push(change);
1666
+ // Patches without a baseline are useless beyond a bound; keep the tail so
1667
+ // memory stays flat while recovery waits for the thread to materialize.
1668
+ if (queuedChanges.length > MAX_QUEUED_CHANGES_PER_THREAD) {
1669
+ queuedChanges.splice(0, queuedChanges.length - MAX_QUEUED_CHANGES_PER_THREAD);
1670
+ }
237
1671
  queuedChangesByThreadId.set(threadId, queuedChanges);
238
1672
  }
239
1673
 
@@ -242,21 +1676,41 @@ function createDesktopIpcActionFollower({
242
1676
  || rawStatesByThreadId.has(threadId)) {
243
1677
  return;
244
1678
  }
1679
+ const recoveryState = baselineRecoveryStateByThreadId.get(threadId) || {
1680
+ attempts: 0,
1681
+ nextAttemptAt: 0,
1682
+ };
1683
+ if (recoveryState.attempts >= MAX_BASELINE_RECOVERY_ATTEMPTS) {
1684
+ // Give up until a snapshot arrives; a fresh snapshot resets this state.
1685
+ return;
1686
+ }
1687
+ if (now() < recoveryState.nextAttemptAt) {
1688
+ return;
1689
+ }
1690
+ recoveryState.attempts += 1;
1691
+ recoveryState.nextAttemptAt = now() + Math.min(
1692
+ BASELINE_RECOVERY_MAX_DELAY_MS,
1693
+ BASELINE_RECOVERY_BASE_DELAY_MS * (2 ** (recoveryState.attempts - 1))
1694
+ );
1695
+ baselineRecoveryStateByThreadId.set(threadId, recoveryState);
245
1696
 
246
1697
  recoveringThreadIds.add(threadId);
247
1698
  Promise.resolve()
248
1699
  .then(() => readConversationState(threadId))
249
1700
  .then((baselineState) => {
250
1701
  if (!baselineState || typeof baselineState !== "object") {
251
- recoverThreadBaselineFromQueuedChanges(threadId, null);
252
1702
  return;
253
1703
  }
254
1704
 
1705
+ baselineRecoveryStateByThreadId.delete(threadId);
255
1706
  recoverThreadBaselineFromQueuedChanges(threadId, baselineState);
256
1707
  })
257
1708
  .catch((error) => {
258
- console.warn(`${logPrefix} desktop IPC baseline recovery failed for ${threadId}: ${error.message}`);
259
- recoverThreadBaselineFromQueuedChanges(threadId, null);
1709
+ if (recoveryState.attempts === 1
1710
+ || recoveryState.attempts === MAX_BASELINE_RECOVERY_ATTEMPTS) {
1711
+ console.warn(`${logPrefix} desktop IPC baseline recovery failed for ${threadId} (attempt ${recoveryState.attempts}/${MAX_BASELINE_RECOVERY_ATTEMPTS}): ${error.message}`);
1712
+ }
1713
+ // Keep queued changes: a later attempt or snapshot may still recover.
260
1714
  })
261
1715
  .finally(() => {
262
1716
  recoveringThreadIds.delete(threadId);
@@ -278,37 +1732,85 @@ function createDesktopIpcActionFollower({
278
1732
  }
279
1733
 
280
1734
  rawStatesByThreadId.set(threadId, nextState);
281
- syncProjectedAssistantDeltas(threadId, baselineState, nextState);
282
- syncProjectedActions(threadId, projectPendingDesktopActions(threadId, nextState));
283
- }
284
-
285
- function syncProjectedAssistantDeltas(threadId, previousState, nextState) {
286
- const previousTexts = assistantMessageTextsByThreadId.get(threadId);
287
- if (!previousTexts && !previousState) {
288
- assistantMessageTextsByThreadId.set(threadId, snapshotAssistantMessageTexts(nextState));
289
- return;
1735
+ rawStateUpdatedAtByThreadId.set(threadId, now());
1736
+ rebuildNormalizedLiveIndex(threadId, nextState);
1737
+ if (baselineState && typeof baselineState === "object"
1738
+ && !backgroundOnlyThreadIds.has(threadId)) {
1739
+ const liveState = boundedDesktopLiveState(
1740
+ baselineState,
1741
+ now(),
1742
+ projectedLiveActiveTurnIdsByThreadId.get(threadId) || new Set(),
1743
+ createNormalizedLiveIndex(baselineState)
1744
+ );
1745
+ conversationProjector.seed(threadId, liveState);
1746
+ rememberDesktopLiveProjection(threadId, liveState);
290
1747
  }
291
-
292
- const notifications = projectDesktopAssistantDeltaNotifications(
293
- threadId,
294
- previousState,
295
- nextState,
296
- previousTexts || snapshotAssistantMessageTexts(previousState)
297
- );
298
- if (notifications.length === 0) {
299
- assistantMessageTextsByThreadId.set(threadId, snapshotAssistantMessageTexts(nextState));
300
- return;
1748
+ if (backgroundOnlyThreadIds.has(threadId)) {
1749
+ syncBackgroundThreadLifecycle(threadId, baselineState, nextState);
1750
+ } else {
1751
+ syncProjectedConversationState(threadId, nextState);
301
1752
  }
302
-
303
- for (const notification of notifications) {
304
- sendApplicationResponse(JSON.stringify(notification));
305
- }
306
- assistantMessageTextsByThreadId.set(threadId, snapshotAssistantMessageTexts(nextState));
1753
+ syncProjectedActions(threadId, projectPendingDesktopActions(threadId, nextState));
1754
+ releaseHeldFollowerRequests(threadId, { toDesktop: true });
307
1755
  }
308
1756
 
309
1757
  return {
310
1758
  observeInbound,
311
1759
  stopAll,
1760
+ // True while this thread has live Desktop-owned IPC state mirrored to the
1761
+ // phone; used to keep fallback mirrors (rollout tail) silent.
1762
+ hasLiveThreadState(threadId) {
1763
+ const normalizedThreadId = readString(threadId);
1764
+ if (pendingSnapshotsByThreadId.has(normalizedThreadId)) {
1765
+ // A debounced snapshot is an in-flight authoritative Desktop update.
1766
+ // Do not briefly wake rollout between IPC patches and duplicate rows.
1767
+ return true;
1768
+ }
1769
+ const rawState = rawStatesByThreadId.get(normalizedThreadId);
1770
+ if (!rawState) {
1771
+ return false;
1772
+ }
1773
+ const liveState = boundedDesktopLiveStateForThread(normalizedThreadId, rawState);
1774
+ if (!Array.isArray(liveState.turns) || liveState.turns.length === 0) {
1775
+ return false;
1776
+ }
1777
+ const thread = projectDesktopConversationStateToThread(normalizedThreadId, liveState, { now });
1778
+ if (hasActiveProjectedTurn(thread) && isRawStateStaleForActiveRead(normalizedThreadId)) {
1779
+ staleYieldedThreadIds.add(normalizedThreadId);
1780
+ return false;
1781
+ }
1782
+ return true;
1783
+ },
1784
+ // Fallback rollout mirroring must only yield to a Desktop snapshot that
1785
+ // has actually moved recently. Keep hasLiveThreadState's broader meaning
1786
+ // for callers that need cached/idle Desktop state, but expose this explicit
1787
+ // lease check for source arbitration.
1788
+ hasFreshLiveThreadState(threadId, { fallbackActivityAt = 0 } = {}) {
1789
+ const normalizedThreadId = readString(threadId);
1790
+ if (pendingSnapshotsByThreadId.has(normalizedThreadId)) {
1791
+ return Boolean(normalizedThreadId);
1792
+ }
1793
+ const rawState = rawStatesByThreadId.get(normalizedThreadId);
1794
+ if (!rawState) {
1795
+ return false;
1796
+ }
1797
+ const liveState = boundedDesktopLiveStateForThread(normalizedThreadId, rawState);
1798
+ if (!Array.isArray(liveState.turns) || liveState.turns.length === 0) {
1799
+ return false;
1800
+ }
1801
+ const thread = projectDesktopConversationStateToThread(normalizedThreadId, liveState, { now });
1802
+ // A connected Desktop stream with an explicitly active projected turn is
1803
+ // authoritative during a genuinely quiet tool/reasoning interval. Once
1804
+ // its cached state is stale, yield only when the rollout file proves that
1805
+ // newer per-thread activity exists; connection state alone is not enough.
1806
+ if (hasActiveProjectedTurn(thread)) {
1807
+ const updatedAt = rawStateUpdatedAtByThreadId.get(normalizedThreadId) || 0;
1808
+ const hasNewerFallbackActivity = Number(fallbackActivityAt) > updatedAt;
1809
+ return ipc.isConnected()
1810
+ && (!isRawStateStaleForActiveRead(normalizedThreadId) || !hasNewerFallbackActivity);
1811
+ }
1812
+ return !isRawStateStaleForActiveRead(normalizedThreadId);
1813
+ },
312
1814
  };
313
1815
  }
314
1816
 
@@ -320,6 +1822,7 @@ function createDesktopIpcClient({
320
1822
  requestTimeoutMs,
321
1823
  logPrefix,
322
1824
  onEnvelope,
1825
+ onConnected,
323
1826
  onDisconnect,
324
1827
  }) {
325
1828
  let socket = null;
@@ -327,6 +1830,7 @@ function createDesktopIpcClient({
327
1830
  let isConnecting = false;
328
1831
  let readBuffer = Buffer.alloc(0);
329
1832
  const pendingRequests = new Map();
1833
+ const pendingDiscoveries = new Map();
330
1834
 
331
1835
  function ensureConnected() {
332
1836
  if (socket || isConnecting) {
@@ -342,6 +1846,7 @@ function createDesktopIpcClient({
342
1846
  sendRequest("initialize", { clientType: "remodex-bridge" })
343
1847
  .then((result) => {
344
1848
  clientId = readString(result?.clientId) || clientId;
1849
+ onConnected?.(clientId);
345
1850
  })
346
1851
  .catch((error) => {
347
1852
  console.warn(`${logPrefix} desktop IPC initialize failed: ${error.message}`);
@@ -360,7 +1865,7 @@ function createDesktopIpcClient({
360
1865
  function sendRequest(method, params) {
361
1866
  ensureConnected();
362
1867
  if (!socket || socket.destroyed) {
363
- return Promise.reject(new Error("Desktop IPC is not connected."));
1868
+ return Promise.reject(markDeliveryFailureError(new Error("Desktop IPC is not connected.")));
364
1869
  }
365
1870
 
366
1871
  const requestId = `remodex-${now().toString(36)}-${Math.random().toString(16).slice(2)}`;
@@ -393,7 +1898,42 @@ function createDesktopIpcClient({
393
1898
 
394
1899
  clearTimeout(timeout);
395
1900
  pendingRequests.delete(requestId);
396
- reject(error);
1901
+ reject(markDeliveryFailureError(error));
1902
+ });
1903
+ });
1904
+ }
1905
+
1906
+ // Resolves true/false from a discovery answer, or null when nobody answers in
1907
+ // time, so callers can fall back to their own timers.
1908
+ function sendDiscoveryRequest(request, timeoutMs) {
1909
+ ensureConnected();
1910
+ if (!socket || socket.destroyed) {
1911
+ return Promise.resolve(null);
1912
+ }
1913
+
1914
+ const requestId = `remodex-discovery-${now().toString(36)}-${Math.random().toString(16).slice(2)}`;
1915
+ return new Promise((resolve) => {
1916
+ const timeout = setTimeout(() => {
1917
+ pendingDiscoveries.delete(requestId);
1918
+ resolve(null);
1919
+ }, timeoutMs);
1920
+ timeout.unref?.();
1921
+
1922
+ pendingDiscoveries.set(requestId, {
1923
+ resolve,
1924
+ timeout,
1925
+ });
1926
+ writeEnvelope({
1927
+ type: "client-discovery-request",
1928
+ requestId,
1929
+ request,
1930
+ }, (error) => {
1931
+ if (!error) {
1932
+ return;
1933
+ }
1934
+ clearTimeout(timeout);
1935
+ pendingDiscoveries.delete(requestId);
1936
+ resolve(null);
397
1937
  });
398
1938
  });
399
1939
  }
@@ -431,6 +1971,17 @@ function createDesktopIpcClient({
431
1971
  return;
432
1972
  }
433
1973
 
1974
+ if (envelope.type === "client-discovery-response") {
1975
+ const requestId = requestIdKey(envelope.requestId);
1976
+ const pendingDiscovery = requestId ? pendingDiscoveries.get(requestId) : null;
1977
+ if (pendingDiscovery) {
1978
+ pendingDiscoveries.delete(requestId);
1979
+ clearTimeout(pendingDiscovery.timeout);
1980
+ pendingDiscovery.resolve(Boolean(envelope.response?.canHandle));
1981
+ }
1982
+ return;
1983
+ }
1984
+
434
1985
  if (envelope.type === "response") {
435
1986
  const requestId = requestIdKey(envelope.requestId);
436
1987
  const waiter = requestId ? pendingRequests.get(requestId) : null;
@@ -441,7 +1992,14 @@ function createDesktopIpcClient({
441
1992
  pendingRequests.delete(requestId);
442
1993
  clearTimeout(waiter.timeout);
443
1994
  if (envelope.resultType === "error") {
444
- waiter.reject(new Error(envelope.error || `Desktop IPC request failed: ${waiter.method}`));
1995
+ const error = new Error(envelope.error || `Desktop IPC request failed: ${waiter.method}`);
1996
+ // A no-handler routing error means the request never reached any client,
1997
+ // so callers may safely retry it against the local app-server. Codex
1998
+ // Desktop's router reports this case as "no-client-found".
1999
+ if (/no codex ipc client can handle|no-client-found/i.test(error.message)) {
2000
+ markDeliveryFailureError(error);
2001
+ }
2002
+ waiter.reject(error);
445
2003
  return;
446
2004
  }
447
2005
 
@@ -462,6 +2020,11 @@ function createDesktopIpcClient({
462
2020
  waiter.reject(new Error("Desktop IPC connection closed."));
463
2021
  }
464
2022
  pendingRequests.clear();
2023
+ for (const pendingDiscovery of pendingDiscoveries.values()) {
2024
+ clearTimeout(pendingDiscovery.timeout);
2025
+ pendingDiscovery.resolve(null);
2026
+ }
2027
+ pendingDiscoveries.clear();
465
2028
  onDisconnect();
466
2029
  }
467
2030
 
@@ -486,7 +2049,11 @@ function createDesktopIpcClient({
486
2049
 
487
2050
  return {
488
2051
  ensureConnected,
2052
+ isConnected() {
2053
+ return Boolean(socket && !socket.destroyed && clientId);
2054
+ },
489
2055
  sendRequest,
2056
+ sendDiscoveryRequest,
490
2057
  close,
491
2058
  };
492
2059
  }
@@ -690,6 +2257,8 @@ function projectPendingDesktopAction(threadId, request) {
690
2257
  params: {
691
2258
  ...params,
692
2259
  remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
2260
+ remodexDesktopMirror: true,
2261
+ remodexDesktopIpcMirror: true,
693
2262
  threadId: readString(params.threadId) || readString(params.thread_id) || threadId,
694
2263
  },
695
2264
  };
@@ -713,17 +2282,669 @@ function applyConversationStateChange(previousState, change) {
713
2282
  return previousState || null;
714
2283
  }
715
2284
 
716
- const nextState = cloneJSON(previousState);
2285
+ // Copy-on-write: clone only the nodes along each patch path and share the
2286
+ // rest with the previous state. Besides skipping an O(state) deep clone per
2287
+ // broadcast, preserving the identity of untouched turns lets the projector
2288
+ // reuse their cached projection instead of re-diffing them.
2289
+ let nextState = shallowCloneNode(previousState);
2290
+ const clonedNodes = new Set([nextState]);
717
2291
  for (const patch of patches) {
718
- applyImmerPatch(nextState, patch);
2292
+ if (Array.isArray(patch?.path) && patch.path.length === 0) {
2293
+ const op = readString(patch?.op).toLowerCase();
2294
+ if (op === "add" || op === "replace") {
2295
+ nextState = cloneJSON(patch.value);
2296
+ clonedNodes.clear();
2297
+ clonedNodes.add(nextState);
2298
+ continue;
2299
+ }
2300
+ return null;
2301
+ }
2302
+ if (!applyImmerPatchCopyOnWrite(nextState, patch, clonedNodes)) {
2303
+ return null;
2304
+ }
719
2305
  }
720
2306
  return nextState;
721
2307
  }
722
2308
 
2309
+ function shallowCloneNode(value) {
2310
+ return Array.isArray(value) ? value.slice() : { ...value };
2311
+ }
2312
+
723
2313
  function isPatchChange(change) {
724
2314
  return change?.type === "patches" || change?.type === "Patches";
725
2315
  }
726
2316
 
2317
+ function isSnapshotChange(change) {
2318
+ return change?.type === "snapshot" || change?.type === "Snapshot";
2319
+ }
2320
+
2321
+ function normalizedTurnStore(state) {
2322
+ const turnHistory = state?.turnHistory ?? state?.turn_history;
2323
+ const history = turnHistory?.history;
2324
+ const entities = history?.entitiesByKey ?? history?.entities_by_key;
2325
+ if (!entities || typeof entities !== "object" || Array.isArray(entities)) {
2326
+ return null;
2327
+ }
2328
+ return { history, entities };
2329
+ }
2330
+
2331
+ function hasNormalizedTurnStore(state) {
2332
+ return Boolean(normalizedTurnStore(state));
2333
+ }
2334
+
2335
+ function normalizedTurnIdForEntity(entityKey, entity) {
2336
+ const keyedTurnId = entityKey.startsWith("turn:")
2337
+ ? readString(entityKey.slice("turn:".length))
2338
+ : "";
2339
+ const entityTurnId = readString(entity?.turnId) || readString(entity?.turn_id);
2340
+ const looksLikeTurn = Boolean(keyedTurnId)
2341
+ || (Boolean(entityTurnId) && (entity?.status != null || Array.isArray(entity?.items)));
2342
+ return looksLikeTurn ? keyedTurnId || entityTurnId : "";
2343
+ }
2344
+
2345
+ function createNormalizedLiveIndex(state) {
2346
+ const store = normalizedTurnStore(state);
2347
+ if (!store) {
2348
+ return null;
2349
+ }
2350
+ const rawTurns = Array.isArray(state?.turns) ? state.turns : [];
2351
+ const rawTurnIds = new Set();
2352
+ const rawIndexByTurnId = new Map();
2353
+ for (let rawIndex = 0; rawIndex < rawTurns.length; rawIndex += 1) {
2354
+ const rawTurnId = turnIdOf(rawTurns[rawIndex]);
2355
+ if (rawTurnId) {
2356
+ rawTurnIds.add(rawTurnId);
2357
+ rawIndexByTurnId.set(rawTurnId, rawIndex);
2358
+ }
2359
+ }
2360
+ const turnIdByEntityKey = new Map();
2361
+ const normalizedTurnIds = new Set();
2362
+ for (const [entityKey, entity] of Object.entries(store.entities)) {
2363
+ if (!entity || typeof entity !== "object") {
2364
+ continue;
2365
+ }
2366
+ const turnId = normalizedTurnIdForEntity(entityKey, entity);
2367
+ if (!turnId) {
2368
+ continue;
2369
+ }
2370
+ turnIdByEntityKey.set(entityKey, turnId);
2371
+ normalizedTurnIds.add(turnId);
2372
+ }
2373
+
2374
+ const entries = [];
2375
+ const entryIndexByTurnId = new Map();
2376
+ const appendEntityKey = (key) => {
2377
+ const entityKey = readString(key);
2378
+ const turnId = entityKey ? turnIdByEntityKey.get(entityKey) : "";
2379
+ if (!turnId || entryIndexByTurnId.has(turnId)) {
2380
+ return;
2381
+ }
2382
+ entryIndexByTurnId.set(turnId, entries.length);
2383
+ entries.push({ id: turnId, entityKey, rawIndex: rawIndexByTurnId.get(turnId) ?? null });
2384
+ };
2385
+ for (const island of Array.isArray(store.history?.islands) ? store.history.islands : []) {
2386
+ for (const entry of Array.isArray(island?.entries) ? island.entries : []) {
2387
+ appendEntityKey(readString(entry?.value) || readString(entry?.key));
2388
+ }
2389
+ }
2390
+ if (entries.length === 0) {
2391
+ for (const entityKey of turnIdByEntityKey.keys()) {
2392
+ appendEntityKey(entityKey);
2393
+ }
2394
+ }
2395
+ for (let rawIndex = 0; rawIndex < rawTurns.length; rawIndex += 1) {
2396
+ const rawTurn = rawTurns[rawIndex];
2397
+ if (!rawTurn || typeof rawTurn !== "object") {
2398
+ continue;
2399
+ }
2400
+ const rawTurnId = turnIdOf(rawTurn) || `ipc-turn-${entries.length}`;
2401
+ if (entryIndexByTurnId.has(rawTurnId)) {
2402
+ continue;
2403
+ }
2404
+ entryIndexByTurnId.set(rawTurnId, entries.length);
2405
+ entries.push({
2406
+ id: rawTurnId,
2407
+ entityKey: null,
2408
+ rawIndex: rawIndexByTurnId.get(rawTurnId) ?? rawIndex,
2409
+ });
2410
+ }
2411
+
2412
+ const index = {
2413
+ entries,
2414
+ entryIndexByTurnId,
2415
+ turnIdByEntityKey,
2416
+ activeTurnIds: new Set(),
2417
+ hasHistoryOutsideRawTurns: Array.from(normalizedTurnIds).some(
2418
+ (turnId) => !rawTurnIds.has(turnId)
2419
+ ),
2420
+ };
2421
+ for (const entry of entries) {
2422
+ const turn = resolveIndexedTurn(state, entry);
2423
+ if (turn && isActiveRawTurn(turn)) {
2424
+ index.activeTurnIds.add(entry.id);
2425
+ }
2426
+ }
2427
+ return index;
2428
+ }
2429
+
2430
+ function normalizedLiveIndexNeedsRebuild(change) {
2431
+ for (const patch of Array.isArray(change?.patches) ? change.patches : []) {
2432
+ const path = Array.isArray(patch?.path) ? patch.path : [];
2433
+ if (path.length === 0) {
2434
+ return true;
2435
+ }
2436
+ if (path[0] === "turns") {
2437
+ if (path.length <= 2 || path[2] === "id" || path[2] === "turnId" || path[2] === "turn_id") {
2438
+ return true;
2439
+ }
2440
+ continue;
2441
+ }
2442
+ if (path[0] !== "turnHistory" && path[0] !== "turn_history") {
2443
+ continue;
2444
+ }
2445
+ if (path.length <= 3 || path[1] !== "history") {
2446
+ return true;
2447
+ }
2448
+ if (path[2] === "islands") {
2449
+ return true;
2450
+ }
2451
+ if (path[2] !== "entitiesByKey" && path[2] !== "entities_by_key") {
2452
+ continue;
2453
+ }
2454
+ if (path.length <= 4 || path[4] === "turnId" || path[4] === "turn_id" || path[4] === "id") {
2455
+ return true;
2456
+ }
2457
+ }
2458
+ return false;
2459
+ }
2460
+
2461
+ function refreshTouchedNormalizedActiveTurns(index, state, change) {
2462
+ const touchedTurnIds = new Set();
2463
+ for (const patch of Array.isArray(change?.patches) ? change.patches : []) {
2464
+ const path = Array.isArray(patch?.path) ? patch.path : [];
2465
+ if (path[0] === "turns" && Number.isInteger(path[1]) && path[2] === "status") {
2466
+ const rawTurn = Array.isArray(state?.turns) ? state.turns[path[1]] : null;
2467
+ const turnId = turnIdOf(rawTurn);
2468
+ if (turnId) {
2469
+ touchedTurnIds.add(turnId);
2470
+ }
2471
+ continue;
2472
+ }
2473
+ if ((path[0] === "turnHistory" || path[0] === "turn_history")
2474
+ && path[1] === "history"
2475
+ && (path[2] === "entitiesByKey" || path[2] === "entities_by_key")
2476
+ && path[4] === "status") {
2477
+ const turnId = index.turnIdByEntityKey.get(readString(path[3]));
2478
+ if (turnId) {
2479
+ touchedTurnIds.add(turnId);
2480
+ }
2481
+ }
2482
+ }
2483
+ if (touchedTurnIds.size === 0) {
2484
+ return;
2485
+ }
2486
+ for (const turnId of touchedTurnIds) {
2487
+ const entryIndex = index.entryIndexByTurnId.get(turnId);
2488
+ const entry = entryIndex == null ? null : index.entries[entryIndex];
2489
+ const turn = entry ? resolveIndexedTurn(state, entry) : null;
2490
+ if (turn && isActiveRawTurn(turn)) {
2491
+ index.activeTurnIds.add(turnId);
2492
+ } else {
2493
+ index.activeTurnIds.delete(turnId);
2494
+ }
2495
+ }
2496
+ }
2497
+
2498
+ function latestActiveRawTurn(state) {
2499
+ const turns = Array.isArray(state?.turns) ? state.turns : [];
2500
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
2501
+ const status = normalizeToken(turns[index]?.status);
2502
+ if (status === "inprogress"
2503
+ || status === "running"
2504
+ || status === "active"
2505
+ || status === "processing") {
2506
+ return backgroundRawTurn(turns[index], index);
2507
+ }
2508
+ }
2509
+ return null;
2510
+ }
2511
+
2512
+ function boundedDesktopLiveState(
2513
+ state,
2514
+ nowValue = Date.now(),
2515
+ retainedTurnIds = new Set(),
2516
+ normalizedIndex = null
2517
+ ) {
2518
+ return {
2519
+ ...(state && typeof state === "object" ? state : {}),
2520
+ turns: boundedDesktopLiveTurns(state, nowValue, retainedTurnIds, normalizedIndex),
2521
+ };
2522
+ }
2523
+
2524
+ function boundedDesktopLiveTurns(
2525
+ state,
2526
+ nowValue = Date.now(),
2527
+ retainedTurnIds = new Set(),
2528
+ normalizedIndex = null
2529
+ ) {
2530
+ if (normalizedIndex) {
2531
+ return boundedIndexedDesktopLiveTurns(
2532
+ state,
2533
+ normalizedIndex,
2534
+ nowValue,
2535
+ retainedTurnIds
2536
+ );
2537
+ }
2538
+ const orderedTurns = backgroundHistoryTurns(state);
2539
+ if (orderedTurns.length <= 1) {
2540
+ return normalizeBoundedTurnsForRuntime(
2541
+ orderedTurns.map((turn, index) => withStableProjectedTurnId(turn, index)),
2542
+ state
2543
+ );
2544
+ }
2545
+
2546
+ // History is canonical elsewhere. Live projection needs only the newest turn,
2547
+ // fresh parallel work, and turns retained for one terminal diff.
2548
+ const selectedIndexes = new Set([orderedTurns.length - 1]);
2549
+ const freshActiveIndexes = [];
2550
+ for (let index = 0; index < orderedTurns.length; index += 1) {
2551
+ const turn = orderedTurns[index];
2552
+ if (isActiveRawTurn(turn) && isRawTurnActivityFresh(turn, nowValue)) {
2553
+ freshActiveIndexes.push(index);
2554
+ }
2555
+ const turnId = turnIdOf(turn);
2556
+ if (turnId && retainedTurnIds.has(turnId)) {
2557
+ selectedIndexes.add(index);
2558
+ }
2559
+ }
2560
+ // Preserve parallel live work without letting an ancient stale inProgress
2561
+ // entity resurrect itself. Two active turns plus the canonical tail keeps
2562
+ // projection bounded even for multi-gigabyte histories.
2563
+ for (const index of freshActiveIndexes.slice(-2)) {
2564
+ selectedIndexes.add(index);
2565
+ }
2566
+ const selectedTurns = Array.from(selectedIndexes)
2567
+ .sort((left, right) => left - right)
2568
+ .map((index) => withStableProjectedTurnId(orderedTurns[index], index));
2569
+ return normalizeBoundedTurnsForRuntime(selectedTurns, state);
2570
+ }
2571
+
2572
+ function boundedIndexedDesktopLiveTurns(state, index, nowValue, retainedTurnIds) {
2573
+ if (index.entries.length === 0) {
2574
+ return [];
2575
+ }
2576
+ const selectedTurnIds = new Set();
2577
+ const tailEntry = index.entries[index.entries.length - 1];
2578
+ if (tailEntry?.id) {
2579
+ selectedTurnIds.add(tailEntry.id);
2580
+ }
2581
+ for (const turnId of retainedTurnIds) {
2582
+ if (index.entryIndexByTurnId.has(turnId)) {
2583
+ selectedTurnIds.add(turnId);
2584
+ }
2585
+ }
2586
+ const freshActiveTurnIds = [];
2587
+ for (const turnId of index.activeTurnIds) {
2588
+ const entryIndex = index.entryIndexByTurnId.get(turnId);
2589
+ if (entryIndex == null) {
2590
+ continue;
2591
+ }
2592
+ const turn = resolveIndexedTurn(state, index.entries[entryIndex]);
2593
+ if (turn && isRawTurnActivityFresh(turn, nowValue)) {
2594
+ freshActiveTurnIds.push(turnId);
2595
+ }
2596
+ }
2597
+ freshActiveTurnIds.sort((left, right) => (
2598
+ index.entryIndexByTurnId.get(left) - index.entryIndexByTurnId.get(right)
2599
+ ));
2600
+ for (const turnId of freshActiveTurnIds.slice(-2)) {
2601
+ selectedTurnIds.add(turnId);
2602
+ }
2603
+ const selectedTurns = Array.from(selectedTurnIds)
2604
+ .map((turnId) => index.entryIndexByTurnId.get(turnId))
2605
+ .filter((entryIndex) => entryIndex != null)
2606
+ .sort((left, right) => left - right)
2607
+ .map((entryIndex) => {
2608
+ const entry = index.entries[entryIndex];
2609
+ const turn = resolveIndexedTurn(state, entry);
2610
+ if (!turn) {
2611
+ return null;
2612
+ }
2613
+ return turnIdOf(turn) ? turn : { ...turn, id: entry.id };
2614
+ })
2615
+ .filter(Boolean);
2616
+ return normalizeBoundedTurnsForRuntime(selectedTurns, state);
2617
+ }
2618
+
2619
+ function resolveIndexedTurn(state, entry) {
2620
+ if (entry.rawIndex != null) {
2621
+ return Array.isArray(state?.turns) ? state.turns[entry.rawIndex] : null;
2622
+ }
2623
+ const store = normalizedTurnStore(state);
2624
+ return entry.entityKey && store ? store.entities[entry.entityKey] || null : null;
2625
+ }
2626
+
2627
+ function turnIdOf(turn) {
2628
+ return readString(turn?.id)
2629
+ || readString(turn?.turnId)
2630
+ || readString(turn?.turn_id);
2631
+ }
2632
+
2633
+ function withStableProjectedTurnId(turn, fullHistoryIndex) {
2634
+ return turnIdOf(turn)
2635
+ ? turn
2636
+ : { ...turn, id: `ipc-turn-${fullHistoryIndex}` };
2637
+ }
2638
+
2639
+ function activeDesktopTurnDescriptors(state) {
2640
+ const turns = Array.isArray(state?.turns) ? state.turns : [];
2641
+ const descriptors = [];
2642
+ for (const turn of turns) {
2643
+ if (!isActiveRawTurn(turn)) {
2644
+ continue;
2645
+ }
2646
+ const id = turnIdOf(turn);
2647
+ if (id) {
2648
+ descriptors.push({
2649
+ id,
2650
+ status: readString(turn?.status) || "inProgress",
2651
+ turn,
2652
+ });
2653
+ }
2654
+ }
2655
+ return descriptors;
2656
+ }
2657
+
2658
+ function desktopLiveTurnLifecycleNotification(method, threadId, turn) {
2659
+ const status = readString(turn?.status) || (method === "turn/started" ? "inProgress" : "completed");
2660
+ return {
2661
+ method,
2662
+ params: {
2663
+ threadId,
2664
+ turnId: turn.id,
2665
+ id: turn.id,
2666
+ status,
2667
+ turn: { id: turn.id, status },
2668
+ remodexDesktopMirror: true,
2669
+ remodexDesktopIpcMirror: true,
2670
+ // Lets a live authoritative start clear a guessed `.stopped` marker that
2671
+ // survived a prior disconnect, even when the chat is already selected.
2672
+ remodexBackgroundDiscovery: method === "turn/started",
2673
+ remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
2674
+ },
2675
+ };
2676
+ }
2677
+
2678
+ function notificationWithTurnIdentityContinuity(notification) {
2679
+ if (notification?.method !== "turn/started") {
2680
+ return notification;
2681
+ }
2682
+ return {
2683
+ ...notification,
2684
+ params: {
2685
+ ...(notification.params || {}),
2686
+ // This is the same logical run re-announced under canonical Desktop IDs,
2687
+ // not a new turn. The phone keeps its recovered-turn viewport ownership.
2688
+ remodexTurnIdentityContinuity: true,
2689
+ },
2690
+ };
2691
+ }
2692
+
2693
+ function normalizeBoundedTurnsForRuntime(turns, state) {
2694
+ if (!isExplicitlyIdleDesktopRuntime(state)) {
2695
+ return turns;
2696
+ }
2697
+ return turns.map((turn) => (
2698
+ isActiveRawTurn(turn)
2699
+ ? { ...turn, status: "completed" }
2700
+ : turn
2701
+ ));
2702
+ }
2703
+
2704
+ function desktopRuntimeStatusToken(state) {
2705
+ return normalizeToken(
2706
+ readString(state?.threadRuntimeStatus?.type)
2707
+ || readString(state?.thread_runtime_status?.type)
2708
+ || readString(state?.runtimeStatus?.type)
2709
+ || readString(state?.runtime_status?.type)
2710
+ );
2711
+ }
2712
+
2713
+ function isExplicitlyIdleDesktopRuntime(state) {
2714
+ const status = desktopRuntimeStatusToken(state);
2715
+ return status === "idle"
2716
+ || status === "inactive"
2717
+ || status === "completed"
2718
+ || status === "stopped"
2719
+ || status === "notrunning";
2720
+ }
2721
+
2722
+ function isActiveRawTurn(turn) {
2723
+ const status = normalizeToken(turn?.status);
2724
+ return status === "inprogress"
2725
+ || status === "running"
2726
+ || status === "active"
2727
+ || status === "processing";
2728
+ }
2729
+
2730
+ function isRawTurnActivityFresh(turn, nowValue) {
2731
+ const startedAt = Number(turn?.turnStartedAtMs ?? turn?.turn_started_at_ms);
2732
+ const durationMs = Number(turn?.durationMs ?? turn?.duration_ms);
2733
+ if (!Number.isFinite(startedAt) || startedAt <= 0) {
2734
+ return false;
2735
+ }
2736
+ const activityAt = Number.isFinite(durationMs) && durationMs >= 0
2737
+ ? startedAt + durationMs
2738
+ : startedAt;
2739
+ return nowValue - activityAt <= STALE_ACTIVE_READ_MAX_AGE_MS;
2740
+ }
2741
+
2742
+ function backgroundRawTurnById(state, turnId) {
2743
+ const turns = Array.isArray(state?.turns) ? state.turns : [];
2744
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
2745
+ const turn = backgroundRawTurn(turns[index], index);
2746
+ if (turn.id === turnId) {
2747
+ return turn;
2748
+ }
2749
+ }
2750
+ return null;
2751
+ }
2752
+
2753
+ function backgroundHistoryTurns(state) {
2754
+ const turns = Array.isArray(state?.turns) ? state.turns.filter(Boolean) : [];
2755
+ const turnHistory = state?.turnHistory ?? state?.turn_history;
2756
+ const history = turnHistory?.history;
2757
+ const entities = history?.entitiesByKey ?? history?.entities_by_key;
2758
+ if (!entities || typeof entities !== "object" || Array.isArray(entities)) {
2759
+ return turns;
2760
+ }
2761
+
2762
+ const rawTurnsById = new Map();
2763
+ for (const turn of turns) {
2764
+ const turnId = readString(turn?.id)
2765
+ || readString(turn?.turnId)
2766
+ || readString(turn?.turn_id);
2767
+ if (turnId) {
2768
+ rawTurnsById.set(turnId, turn);
2769
+ }
2770
+ }
2771
+ const orderedTurns = [];
2772
+ const addedTurnIds = new Set();
2773
+ const addedEntityKeys = new Set();
2774
+ const appendEntity = (key) => {
2775
+ const entityKey = readString(key);
2776
+ const entity = entities[entityKey];
2777
+ if (!entityKey || addedEntityKeys.has(entityKey) || !entity || typeof entity !== "object") {
2778
+ return;
2779
+ }
2780
+ const keyedTurnId = entityKey.startsWith("turn:")
2781
+ ? readString(entityKey.slice("turn:".length))
2782
+ : "";
2783
+ const entityTurnId = readString(entity?.turnId) || readString(entity?.turn_id);
2784
+ const turnId = keyedTurnId || entityTurnId;
2785
+ const looksLikeTurn = Boolean(keyedTurnId)
2786
+ || (Boolean(entityTurnId) && (entity?.status != null || Array.isArray(entity?.items)));
2787
+ if (!looksLikeTurn || !turnId || addedTurnIds.has(turnId)) {
2788
+ return;
2789
+ }
2790
+ addedEntityKeys.add(entityKey);
2791
+ addedTurnIds.add(turnId);
2792
+ // Prefer the fresher legacy/live object when Litter materialized this same
2793
+ // turn in both stores, but keep the canonical island position.
2794
+ orderedTurns.push(rawTurnsById.get(turnId) || entity);
2795
+ };
2796
+
2797
+ // Litter's islands preserve timeline order even though entitiesByKey is a
2798
+ // normalized object store. Prefer that order, then tolerate snapshots that
2799
+ // provide only the entity map.
2800
+ for (const island of Array.isArray(history?.islands) ? history.islands : []) {
2801
+ for (const entry of Array.isArray(island?.entries) ? island.entries : []) {
2802
+ appendEntity(readString(entry?.value) || readString(entry?.key));
2803
+ }
2804
+ }
2805
+ // When islands exist they are the canonical order. Do not append orphaned
2806
+ // normalized entities after the tail: a stale unreferenced inProgress turn
2807
+ // would otherwise look newer than the real current turn. Entity-map order is
2808
+ // only a compatibility fallback for snapshots that omit islands entirely.
2809
+ if (orderedTurns.length === 0) {
2810
+ for (const key of Object.keys(entities)) {
2811
+ appendEntity(key);
2812
+ }
2813
+ }
2814
+ for (const turn of turns) {
2815
+ const turnId = readString(turn?.id)
2816
+ || readString(turn?.turnId)
2817
+ || readString(turn?.turn_id);
2818
+ if (!turnId || !addedTurnIds.has(turnId)) {
2819
+ orderedTurns.push(turn);
2820
+ }
2821
+ }
2822
+ return orderedTurns.length > 0 ? orderedTurns : turns;
2823
+ }
2824
+
2825
+ function backgroundRawTurn(turn, index) {
2826
+ const id = readString(turn?.turnId)
2827
+ || readString(turn?.turn_id)
2828
+ || readString(turn?.id)
2829
+ // Must match desktop-ipc-conversation-projector so opening the thread
2830
+ // mid-run does not replace the active id and strand iOS running state.
2831
+ || `ipc-turn-${index}`;
2832
+ const status = normalizeToken(turn?.status);
2833
+ let normalizedStatus = "completed";
2834
+ if (status === "inprogress" || status === "running" || status === "active" || status === "processing") {
2835
+ normalizedStatus = "inProgress";
2836
+ } else if (status === "failed" || status === "error" || status === "systemerror") {
2837
+ normalizedStatus = "failed";
2838
+ } else if (status === "interrupted" || status === "cancelled" || status === "canceled" || status === "stopped") {
2839
+ normalizedStatus = "interrupted";
2840
+ }
2841
+ const identityItems = (Array.isArray(turn?.items) ? turn.items : []).flatMap((item) => {
2842
+ const itemId = readString(item?.id) || readString(item?.itemId) || readString(item?.item_id);
2843
+ const itemType = readString(item?.type);
2844
+ if (!itemType || (!itemId && normalizeToken(itemType) !== "usermessage")) {
2845
+ return [];
2846
+ }
2847
+ return [{
2848
+ ...(itemId ? { id: itemId } : {}),
2849
+ type: itemType,
2850
+ ...(normalizeToken(itemType) === "usermessage"
2851
+ ? { content: compactBackgroundPromptEntries(item?.content) }
2852
+ : {}),
2853
+ }];
2854
+ });
2855
+ const paramsInput = Array.isArray(turn?.params?.input)
2856
+ ? compactBackgroundPromptEntries(turn.params.input)
2857
+ : null;
2858
+ const startedAt = turn?.startedAt
2859
+ ?? turn?.started_at
2860
+ ?? turn?.turnStartedAtMs
2861
+ ?? turn?.turn_started_at_ms
2862
+ ?? null;
2863
+ return {
2864
+ id,
2865
+ status: normalizedStatus,
2866
+ error: turn?.error ?? null,
2867
+ ...(paramsInput ? { params: { input: paramsInput } } : {}),
2868
+ ...(identityItems.length > 0 ? { items: identityItems } : {}),
2869
+ ...(startedAt != null ? { startedAt } : {}),
2870
+ };
2871
+ }
2872
+
2873
+ // Background discovery retains only the text needed to recognize the same run
2874
+ // after id promotion; image payloads and expanded runtime context stay in the
2875
+ // canonical raw state instead of being duplicated in lifecycle bookkeeping.
2876
+ function compactBackgroundPromptEntries(entries) {
2877
+ if (!Array.isArray(entries)) {
2878
+ return [];
2879
+ }
2880
+ return entries.flatMap((entry) => {
2881
+ if (typeof entry === "string") {
2882
+ return entry ? [entry] : [];
2883
+ }
2884
+ if (!entry || typeof entry !== "object") {
2885
+ return [];
2886
+ }
2887
+ const text = readString(entry.text)
2888
+ || readString(entry.message)
2889
+ || readString(entry.content);
2890
+ return text ? [{ text }] : [];
2891
+ });
2892
+ }
2893
+
2894
+ function backgroundTurnLifecycleNotification(method, threadId, turn) {
2895
+ const turnId = readString(turn?.id);
2896
+ const params = {
2897
+ threadId,
2898
+ remodexDesktopMirror: true,
2899
+ remodexDesktopIpcMirror: true,
2900
+ remodexBackgroundDiscovery: true,
2901
+ remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
2902
+ };
2903
+ if (turnId) {
2904
+ params.turnId = turnId;
2905
+ params.id = turnId;
2906
+ }
2907
+ if (method === "turn/completed") {
2908
+ params.status = readString(turn?.status) || "completed";
2909
+ if (turn?.error != null) {
2910
+ params.error = cloneJSON(turn.error);
2911
+ }
2912
+ }
2913
+ return { method, params };
2914
+ }
2915
+
2916
+ function isRemodexLiveOwnerBroadcast(params) {
2917
+ return readString(params?.remodexOwnerSource) === REMODEX_LIVE_OWNER_SOURCE;
2918
+ }
2919
+
2920
+ // Resolutions of Desktop-owned prompts are mirror events too; the tags let iOS
2921
+ // reconcile them without treating them as local runtime work.
2922
+ function projectedResolvedNotification(threadId, requestId) {
2923
+ return {
2924
+ method: "serverRequest/resolved",
2925
+ params: {
2926
+ threadId,
2927
+ requestId,
2928
+ remodexDesktopMirror: true,
2929
+ remodexDesktopIpcMirror: true,
2930
+ remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
2931
+ },
2932
+ };
2933
+ }
2934
+
2935
+ function isPeerOwnershipSnapshot(params) {
2936
+ return !isRemodexLiveOwnerBroadcast(params) && normalizeToken(params?.change?.type) === "snapshot";
2937
+ }
2938
+
2939
+ function markDeliveryFailureError(error) {
2940
+ error.remodexDeliveryFailed = true;
2941
+ return error;
2942
+ }
2943
+
2944
+ function isDeliveryFailureError(error) {
2945
+ return error?.remodexDeliveryFailed === true;
2946
+ }
2947
+
727
2948
  function seedConversationStateFromThreadRead(response) {
728
2949
  const conversationState = response?.conversationState || response?.conversation_state;
729
2950
  if (conversationState && typeof conversationState === "object" && !Array.isArray(conversationState)) {
@@ -746,58 +2967,68 @@ function createEmptyConversationState() {
746
2967
  };
747
2968
  }
748
2969
 
749
- function applyImmerPatch(target, patch) {
2970
+ function applyImmerPatchCopyOnWrite(target, patch, clonedNodes) {
750
2971
  const patchPath = Array.isArray(patch?.path) ? patch.path : [];
751
2972
  const op = readString(patch?.op).toLowerCase();
752
2973
  if (!op || patchPath.length === 0) {
753
- return;
2974
+ return false;
754
2975
  }
755
2976
 
756
2977
  let parent = target;
757
2978
  for (let index = 0; index < patchPath.length - 1; index += 1) {
758
- parent = parent?.[patchPath[index]];
759
- if (parent == null) {
760
- return;
2979
+ const key = patchPath[index];
2980
+ const child = parent?.[key];
2981
+ if (child == null || typeof child !== "object") {
2982
+ return false;
2983
+ }
2984
+ if (clonedNodes.has(child)) {
2985
+ parent = child;
2986
+ continue;
761
2987
  }
2988
+ const clonedChild = shallowCloneNode(child);
2989
+ clonedNodes.add(clonedChild);
2990
+ parent[key] = clonedChild;
2991
+ parent = clonedChild;
762
2992
  }
763
2993
 
764
2994
  const key = patchPath[patchPath.length - 1];
765
2995
  if (op === "remove") {
766
2996
  if (Array.isArray(parent) && Number.isInteger(key)) {
2997
+ if (key < 0 || key >= parent.length) {
2998
+ return false;
2999
+ }
767
3000
  parent.splice(key, 1);
3001
+ return true;
768
3002
  } else if (parent && typeof parent === "object") {
3003
+ if (!Object.prototype.hasOwnProperty.call(parent, key)) {
3004
+ return false;
3005
+ }
769
3006
  delete parent[key];
3007
+ return true;
770
3008
  }
771
- return;
3009
+ return false;
772
3010
  }
773
3011
 
774
3012
  if (op === "add" || op === "replace") {
775
3013
  if (Array.isArray(parent) && Number.isInteger(key)) {
776
3014
  if (op === "add") {
3015
+ if (key < 0 || key > parent.length) {
3016
+ return false;
3017
+ }
777
3018
  parent.splice(key, 0, patch.value);
778
3019
  } else {
3020
+ if (key < 0 || key >= parent.length) {
3021
+ return false;
3022
+ }
779
3023
  parent[key] = patch.value;
780
3024
  }
3025
+ return true;
781
3026
  } else if (parent && typeof parent === "object") {
782
3027
  parent[key] = patch.value;
3028
+ return true;
783
3029
  }
784
3030
  }
785
- }
786
-
787
- function writeFrame(socket, payload, callback) {
788
- const body = Buffer.from(payload, "utf8");
789
- const header = Buffer.alloc(FRAME_HEADER_BYTES);
790
- header.writeUInt32LE(body.length, 0);
791
- socket.write(Buffer.concat([header, body]), callback);
792
- }
793
-
794
- function resolveDefaultIpcSocketPath() {
795
- if (process.platform === "win32") {
796
- return "\\\\.\\pipe\\codex-ipc";
797
- }
798
-
799
- const uid = typeof process.getuid === "function" ? process.getuid() : 0;
800
- return path.join(os.tmpdir(), "codex-ipc", `ipc-${uid}.sock`);
3031
+ return false;
801
3032
  }
802
3033
 
803
3034
  function readThreadId(params) {
@@ -807,40 +3038,9 @@ function readThreadId(params) {
807
3038
  || readString(params?.conversation_id);
808
3039
  }
809
3040
 
810
- function requestIdKey(value) {
811
- if (typeof value === "string" && value) {
812
- return value;
813
- }
814
- if (typeof value === "number" && Number.isFinite(value)) {
815
- return String(value);
816
- }
817
- return "";
818
- }
819
-
820
- function readString(value) {
821
- return typeof value === "string" && value.trim() ? value.trim() : "";
822
- }
823
-
824
- function normalizeToken(value) {
825
- return typeof value === "string"
826
- ? value.toLowerCase().replace(/[_-\s]+/g, "")
827
- : "";
828
- }
829
-
830
- function cloneJSON(value) {
831
- return JSON.parse(JSON.stringify(value));
832
- }
833
-
834
- function safeParseJSON(value) {
835
- try {
836
- return JSON.parse(value);
837
- } catch {
838
- return null;
839
- }
840
- }
841
-
842
3041
  module.exports = {
843
3042
  applyConversationStateChange,
3043
+ buildDesktopTurnsListResult,
844
3044
  createDesktopIpcActionFollower,
845
3045
  desktopFollowerPayloadForResponse,
846
3046
  projectDesktopAssistantDeltaNotifications,