@makerbi/remodex 2.0.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,19 +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
- const { buildApplyPatchFileChangeItem } = require("./apply-patch-changes");
11
9
 
12
- const FRAME_HEADER_BYTES = 4;
13
- 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
+
14
29
  const REQUEST_TIMEOUT_MS = 10_000;
15
- const TURN_COMPLETION_IDLE_MS = 3_500;
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;
16
42
  const DESKTOP_IPC_ACTION_SOURCE = "desktop-ipc-action-follower";
17
- 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
+ ]);
18
62
  const ACTION_METHODS = new Set([
19
63
  "item/commandExecution/requestApproval",
20
64
  "item/fileChange/requestApproval",
@@ -29,26 +73,148 @@ const REPLY_METHOD_BY_ACTION_METHOD = new Map([
29
73
  ["item/permissions/requestApproval", "thread-follower-file-approval-decision"],
30
74
  ["item/tool/requestUserInput", "thread-follower-submit-user-input"],
31
75
  ]);
32
- const METHOD_VERSION_BY_NAME = new Map([
33
- ["initialize", 1],
34
- ["thread-follower-command-approval-decision", 1],
35
- ["thread-follower-file-approval-decision", 1],
36
- ["thread-follower-submit-user-input", 1],
37
- ]);
38
76
  const APPROVAL_DECISIONS = new Set(["accept", "acceptForSession", "decline", "cancel"]);
39
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
+
40
195
  // Opens the Desktop IPC bus on demand and exposes Mac-owned pending actions as normal app-server requests.
41
196
  function createDesktopIpcActionFollower({
42
197
  sendApplicationResponse,
43
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,
44
207
  logPrefix = "[remodex]",
45
208
  socketPath = resolveDefaultIpcSocketPath(),
46
209
  netModule = net,
47
210
  now = () => Date.now(),
48
- requestTimeoutMs = REQUEST_TIMEOUT_MS,
49
- turnCompletionIdleMs = TURN_COMPLETION_IDLE_MS,
211
+ snapshotDebounceMs = 0,
50
212
  setTimeoutFn = setTimeout,
51
213
  clearTimeoutFn = clearTimeout,
214
+ onNormalizedHistoryIndexRebuilt = () => {},
215
+ requestTimeoutMs = REQUEST_TIMEOUT_MS,
216
+ ownershipProbeTimeoutMs = OWNERSHIP_PROBE_TIMEOUT_MS,
217
+ backgroundDisconnectGraceMs = BACKGROUND_DISCONNECT_GRACE_MS,
52
218
  } = {}) {
53
219
  const ipc = createDesktopIpcClient({
54
220
  socketPath,
@@ -57,20 +223,104 @@ function createDesktopIpcActionFollower({
57
223
  requestTimeoutMs,
58
224
  logPrefix,
59
225
  onEnvelope,
226
+ onConnected() {
227
+ probeHeldFollowerRequests();
228
+ },
60
229
  onDisconnect,
61
230
  });
62
231
  const rawStatesByThreadId = new Map();
63
- const assistantMessageTextsByThreadId = new Map();
64
- const mirroredActivityKeysByThreadId = new Map();
65
- const mirroredUserMessageKeysByThreadId = new Map();
66
- const activeDesktopTurnsByThreadId = 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 });
67
245
  const pendingRoutesByRequestId = new Map();
68
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
+ }
69
312
  const recoveringThreadIds = new Set();
70
313
  const queuedChangesByThreadId = new Map();
71
-
72
- function observeInbound(rawMessage) {
73
- 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);
74
324
  const responseRoute = desktopRouteForResponse(message);
75
325
  if (responseRoute) {
76
326
  submitDesktopActionResponse(responseRoute, message);
@@ -78,7 +328,97 @@ function createDesktopIpcActionFollower({
78
328
  }
79
329
 
80
330
  const method = readString(message?.method);
81
- 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)) {
82
422
  return false;
83
423
  }
84
424
 
@@ -87,33 +427,101 @@ function createDesktopIpcActionFollower({
87
427
  return false;
88
428
  }
89
429
 
90
- 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
+ }
91
436
  ipc.ensureConnected();
92
437
  return false;
93
438
  }
94
439
 
95
440
  function stopAll() {
441
+ for (const threadId of pendingSnapshotsByThreadId.keys()) {
442
+ cancelPendingSnapshot(threadId);
443
+ }
96
444
  rawStatesByThreadId.clear();
97
- assistantMessageTextsByThreadId.clear();
98
- mirroredActivityKeysByThreadId.clear();
99
- mirroredUserMessageKeysByThreadId.clear();
100
- clearAllDesktopTurnCompletionTimers();
445
+ rawStateUpdatedAtByThreadId.clear();
446
+ canonicalHistoryThreadIds.clear();
447
+ canonicalHistoryReplacementSentThreadIds.clear();
448
+ projectedLiveActiveTurnIdsByThreadId.clear();
449
+ desktopLiveLifecycleByThreadId.clear();
450
+ normalizedLiveIndexesByThreadId.clear();
451
+ conversationProjector.reset();
101
452
  pendingRoutesByRequestId.clear();
102
453
  activeThreadIds.clear();
454
+ backgroundOnlyThreadIds.clear();
455
+ announcedBackgroundTurnsByThreadId.clear();
456
+ for (const timer of backgroundDisconnectTimersByThreadId.values()) {
457
+ clearTimeout(timer);
458
+ }
459
+ backgroundDisconnectTimersByThreadId.clear();
103
460
  recoveringThreadIds.clear();
461
+ baselineRecoveryStateByThreadId.clear();
104
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();
105
473
  ipc.close();
106
474
  }
107
475
 
108
476
  // Desktop broadcasts carry the live conversation state Litter projects from.
109
477
  function onEnvelope(envelope) {
478
+ if (envelope?.type === "broadcast"
479
+ && (envelope.method === "thread-archived" || envelope.method === "thread-unarchived")) {
480
+ syncThreadArchiveBroadcast(envelope);
481
+ return;
482
+ }
110
483
  if (envelope?.type !== "broadcast" || envelope.method !== "thread-stream-state-changed") {
111
484
  return;
112
485
  }
113
486
 
114
487
  const params = envelope.params || {};
115
488
  const threadId = readString(params.conversationId) || readString(params.conversation_id);
116
- 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)) {
117
525
  return;
118
526
  }
119
527
 
@@ -122,6 +530,16 @@ function createDesktopIpcActionFollower({
122
530
  return;
123
531
  }
124
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
+
125
543
  const previousState = rawStatesByThreadId.get(threadId) || null;
126
544
  const nextState = applyConversationStateChange(previousState, params.change);
127
545
  if (!nextState) {
@@ -131,7 +549,12 @@ function createDesktopIpcActionFollower({
131
549
  const speculativeActions = projectPendingDesktopActions(threadId, speculativeState);
132
550
  if (speculativeActions.length > 0) {
133
551
  rawStatesByThreadId.set(threadId, speculativeState);
552
+ rawStateUpdatedAtByThreadId.set(threadId, now());
553
+ if (!backgroundOnlyThreadIds.has(threadId)) {
554
+ conversationProjector.seed(threadId, speculativeState);
555
+ }
134
556
  syncProjectedActions(threadId, speculativeActions);
557
+ releaseHeldFollowerRequests(threadId, { toDesktop: true });
135
558
  return;
136
559
  }
137
560
 
@@ -145,357 +568,1249 @@ function createDesktopIpcActionFollower({
145
568
  return;
146
569
  }
147
570
 
148
- rawStatesByThreadId.set(threadId, nextState);
149
- syncProjectedLiveState(threadId, previousState, nextState);
150
- syncProjectedActions(threadId, projectPendingDesktopActions(threadId, nextState));
151
- }
571
+ if (isSnapshotChange(params.change) && snapshotDebounceMs > 0) {
572
+ schedulePendingSnapshot(threadId, nextState);
573
+ return;
574
+ }
152
575
 
153
- function onDisconnect() {
154
- rawStatesByThreadId.clear();
155
- assistantMessageTextsByThreadId.clear();
156
- mirroredActivityKeysByThreadId.clear();
157
- mirroredUserMessageKeysByThreadId.clear();
158
- clearAllDesktopTurnCompletionTimers();
159
- pendingRoutesByRequestId.clear();
160
- recoveringThreadIds.clear();
161
- queuedChangesByThreadId.clear();
576
+ commitConversationState(threadId, nextState, {
577
+ isFullSnapshot: isSnapshotChange(params.change),
578
+ change: params.change,
579
+ });
162
580
  }
163
581
 
164
- function syncProjectedActions(threadId, actions) {
165
- const nextRequestIds = new Set(actions.map((action) => action.id));
166
- for (const [requestId, route] of Array.from(pendingRoutesByRequestId.entries())) {
167
- if (route.threadId !== threadId || nextRequestIds.has(requestId)) {
168
- continue;
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;
169
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
+ }
170
595
 
171
- pendingRoutesByRequestId.delete(requestId);
172
- sendApplicationResponse(JSON.stringify({
173
- method: "serverRequest/resolved",
174
- params: {
175
- threadId,
176
- requestId,
177
- },
178
- }));
596
+ function flushPendingSnapshot(threadId) {
597
+ const pending = pendingSnapshotsByThreadId.get(threadId);
598
+ if (!pending) {
599
+ return false;
179
600
  }
601
+ clearTimeoutFn(pending.timer);
602
+ pendingSnapshotsByThreadId.delete(threadId);
603
+ commitConversationState(threadId, pending.state, { isFullSnapshot: true });
604
+ return true;
605
+ }
180
606
 
181
- for (const action of actions) {
182
- if (pendingRoutesByRequestId.has(action.id)) {
183
- continue;
184
- }
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
+ }
185
616
 
186
- pendingRoutesByRequestId.set(action.id, {
187
- requestId: action.id,
188
- method: action.method,
189
- threadId,
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;
632
+ rawStatesByThreadId.set(threadId, 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,
190
645
  });
191
- sendApplicationResponse(JSON.stringify({
192
- id: action.id,
193
- method: action.method,
194
- params: action.params,
195
- }));
196
646
  }
647
+ syncProjectedActions(threadId, projectPendingDesktopActions(threadId, nextState));
648
+ releaseHeldFollowerRequests(threadId, { toDesktop: true });
649
+ return true;
197
650
  }
198
651
 
199
- function desktopRouteForResponse(message) {
200
- if (!message || typeof message !== "object" || message.method) {
201
- return null;
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);
202
659
  }
203
-
204
- const requestId = requestIdKey(message.id);
205
- return requestId ? pendingRoutesByRequestId.get(requestId) || null : null;
206
660
  }
207
661
 
208
- function submitDesktopActionResponse(route, responseMessage) {
209
- const payload = desktopFollowerPayloadForResponse(route, responseMessage);
210
- if (!payload) {
211
- sendApplicationResponse(JSON.stringify({
212
- id: responseMessage?.id ?? route.requestId,
213
- error: {
214
- code: -32602,
215
- message: "Invalid desktop action response.",
216
- },
217
- }));
662
+ function updateNormalizedLiveIndex(threadId, state, change) {
663
+ const index = normalizedLiveIndexesByThreadId.get(threadId);
664
+ if (!index) {
665
+ if (hasNormalizedTurnStore(state)) {
666
+ rebuildNormalizedLiveIndex(threadId, state);
667
+ }
218
668
  return;
219
669
  }
220
-
221
- ipc.sendRequest(payload.method, payload.params)
222
- .then(() => {
223
- pendingRoutesByRequestId.delete(route.requestId);
224
- sendApplicationResponse(JSON.stringify({
225
- method: "serverRequest/resolved",
226
- params: {
227
- threadId: route.threadId,
228
- requestId: route.requestId,
229
- },
230
- }));
231
- })
232
- .catch((error) => {
233
- console.warn(`${logPrefix} desktop action reply failed for ${route.threadId}: ${error.message}`);
234
- sendApplicationResponse(JSON.stringify({
235
- id: responseMessage.id,
236
- error: {
237
- code: -32000,
238
- message: "Could not send this action to Codex on the Mac.",
239
- },
240
- }));
241
- });
670
+ if (normalizedLiveIndexNeedsRebuild(change)) {
671
+ rebuildNormalizedLiveIndex(threadId, state);
672
+ return;
673
+ }
674
+ refreshTouchedNormalizedActiveTurns(index, state, change);
242
675
  }
243
676
 
244
- function queueThreadChange(threadId, change) {
245
- if (!change || typeof change !== "object") {
246
- return;
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);
247
683
  }
684
+ rawStatesByThreadId.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();
693
+ recoveringThreadIds.clear();
694
+ baselineRecoveryStateByThreadId.clear();
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
+ }
248
767
 
249
- const queuedChanges = queuedChangesByThreadId.get(threadId) || [];
250
- queuedChanges.push(change);
251
- queuedChangesByThreadId.set(threadId, queuedChanges);
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;
252
788
  }
253
789
 
254
- function recoverThreadBaseline(threadId) {
255
- if (recoveringThreadIds.has(threadId)
256
- || rawStatesByThreadId.has(threadId)) {
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)) {
257
795
  return;
258
796
  }
259
-
260
- recoveringThreadIds.add(threadId);
261
- Promise.resolve()
262
- .then(() => readConversationState(threadId))
263
- .then((baselineState) => {
264
- if (!baselineState || typeof baselineState !== "object") {
265
- recoverThreadBaselineFromQueuedChanges(threadId, null);
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) {
266
809
  return;
267
810
  }
268
-
269
- recoverThreadBaselineFromQueuedChanges(threadId, baselineState);
270
- })
271
- .catch((error) => {
272
- console.warn(`${logPrefix} desktop IPC baseline recovery failed for ${threadId}: ${error.message}`);
273
- recoverThreadBaselineFromQueuedChanges(threadId, null);
274
- })
275
- .finally(() => {
276
- recoveringThreadIds.delete(threadId);
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.
277
823
  });
278
824
  }
279
825
 
280
- function recoverThreadBaselineFromQueuedChanges(threadId, baselineState) {
281
- const queuedChanges = queuedChangesByThreadId.get(threadId) || [];
282
- if (queuedChanges.length === 0) {
283
- return;
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
+ }
284
838
  }
839
+ }
285
840
 
286
- queuedChangesByThreadId.delete(threadId);
287
- let nextState = baselineState && typeof baselineState === "object"
288
- ? cloneJSON(baselineState)
289
- : createEmptyConversationState();
290
- for (const change of queuedChanges) {
291
- nextState = applyConversationStateChange(nextState, change) || nextState;
292
- }
841
+ function isDesktopRoutableThread(threadId) {
842
+ return !liveOwnerThreadIds.has(threadId)
843
+ && !isLocallyOwnedThread(threadId)
844
+ && (rawStatesByThreadId.has(threadId) || desktopOwnedByProbeThreadIds.has(threadId));
845
+ }
293
846
 
294
- rawStatesByThreadId.set(threadId, nextState);
295
- syncProjectedLiveState(threadId, baselineState, nextState);
296
- syncProjectedActions(threadId, projectPendingDesktopActions(threadId, nextState));
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);
297
873
  }
298
874
 
299
- function syncProjectedLiveState(threadId, previousState, nextState) {
300
- syncProjectedAssistantDeltas(threadId, previousState, nextState);
301
- syncProjectedDesktopActivities(threadId, nextState);
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
+ }
302
886
  }
303
887
 
304
- function syncProjectedAssistantDeltas(threadId, previousState, nextState) {
305
- refreshTrackedDesktopTurnState(threadId, nextState);
306
- const previousTexts = assistantMessageTextsByThreadId.get(threadId);
307
- if (!previousTexts && !previousState) {
308
- assistantMessageTextsByThreadId.set(threadId, snapshotAssistantMessageTexts(nextState));
309
- syncProjectedDesktopTurnCompletions(threadId, nextState);
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);
310
903
  return;
311
904
  }
905
+ submitDesktopFollowerRequest(route, message);
906
+ }
312
907
 
313
- const notifications = projectDesktopAssistantDeltaNotifications(
314
- threadId,
315
- previousState,
316
- nextState,
317
- previousTexts || snapshotAssistantMessageTexts(previousState)
318
- );
319
- if (notifications.length === 0) {
320
- assistantMessageTextsByThreadId.set(threadId, snapshotAssistantMessageTexts(nextState));
321
- syncProjectedDesktopTurnCompletions(threadId, nextState);
908
+ function releaseHeldFollowerRequests(threadId, { toDesktop } = {}) {
909
+ const queue = heldFollowerRequestsByThreadId.get(threadId);
910
+ if (!queue || queue.length === 0) {
911
+ heldFollowerRequestsByThreadId.delete(threadId);
322
912
  return;
323
913
  }
324
914
 
325
- const activeDeltaTurnIds = new Set(
326
- notifications
327
- .map((notification) => readString(notification?.params?.turnId) || readString(notification?.params?.turn_id))
328
- .filter(Boolean)
329
- );
330
- const userNotifications = projectDesktopUserMessageNotifications(
331
- threadId,
332
- nextState,
333
- mirroredUserMessageKeysForThread(threadId),
334
- activeDeltaTurnIds
335
- );
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
+ }
336
936
 
337
- // Desktop IPC state can report assistant text growth before rollout replay catches
338
- // up with the user prelude. Emit the opening prompt first to avoid mobile row jumps.
339
- for (const notification of [...userNotifications, ...notifications]) {
340
- sendApplicationResponse(JSON.stringify(notification));
937
+ function rejectHeldFollowerRequests(threadId, reason) {
938
+ const queue = heldFollowerRequestsByThreadId.get(threadId);
939
+ if (!queue || queue.length === 0) {
940
+ heldFollowerRequestsByThreadId.delete(threadId);
941
+ return;
341
942
  }
342
- for (const turnId of activeDeltaTurnIds) {
343
- noteDesktopIpcTurnActivity(threadId, turnId, nextState);
943
+ heldFollowerRequestsByThreadId.delete(threadId);
944
+ for (const entry of queue) {
945
+ clearTimeout(entry.timer);
946
+ rejectHeldFollowerRequest(safeParseJSON(entry.rawMessage), reason);
344
947
  }
345
- assistantMessageTextsByThreadId.set(threadId, snapshotAssistantMessageTexts(nextState));
346
- syncProjectedDesktopTurnCompletions(threadId, nextState);
347
948
  }
348
949
 
349
- function syncProjectedDesktopActivities(threadId, nextState) {
350
- refreshTrackedDesktopTurnState(threadId, nextState);
351
- const activityKeys = mirroredActivityKeysForThread(threadId);
352
- const notifications = projectDesktopActivityNotifications(threadId, nextState, activityKeys);
353
- if (notifications.length === 0) {
354
- syncProjectedDesktopTurnCompletions(threadId, nextState);
950
+ function rejectHeldFollowerRequest(message, reason) {
951
+ if (message?.id == null) {
355
952
  return;
356
953
  }
954
+ sendApplicationResponse(JSON.stringify({
955
+ id: message.id,
956
+ error: {
957
+ code: -32000,
958
+ message: reason,
959
+ },
960
+ }));
961
+ }
357
962
 
358
- const activeActivityTurnIds = new Set(
359
- notifications
360
- .map((notification) => readString(notification?.params?.turnId) || readString(notification?.params?.turn_id))
361
- .filter(Boolean)
362
- );
363
- const userNotifications = projectDesktopUserMessageNotifications(
364
- threadId,
365
- nextState,
366
- mirroredUserMessageKeysForThread(threadId),
367
- activeActivityTurnIds
368
- );
963
+ function syncProjectedActions(threadId, actions) {
964
+ const nextRequestIds = new Set(actions.map((action) => action.id));
965
+ for (const [requestId, route] of Array.from(pendingRoutesByRequestId.entries())) {
966
+ if (route.threadId !== threadId || nextRequestIds.has(requestId)) {
967
+ continue;
968
+ }
369
969
 
370
- // Tool-call snapshots are independent from assistant text deltas. Emit them
371
- // through the same app-server event names rollout mirroring uses so iOS keeps
372
- // showing active tool rows while the Mac-owned run is still executing.
373
- for (const notification of [...userNotifications, ...notifications]) {
374
- sendApplicationResponse(JSON.stringify(notification));
970
+ pendingRoutesByRequestId.delete(requestId);
971
+ sendApplicationResponse(JSON.stringify(projectedResolvedNotification(threadId, requestId)));
375
972
  }
376
- for (const turnId of activeActivityTurnIds) {
377
- noteDesktopIpcTurnActivity(threadId, turnId, nextState);
973
+
974
+ for (const action of actions) {
975
+ if (pendingRoutesByRequestId.has(action.id)) {
976
+ continue;
977
+ }
978
+
979
+ pendingRoutesByRequestId.set(action.id, {
980
+ requestId: action.id,
981
+ method: action.method,
982
+ threadId,
983
+ });
984
+ sendApplicationResponse(JSON.stringify({
985
+ id: action.id,
986
+ method: action.method,
987
+ params: action.params,
988
+ }));
378
989
  }
379
- syncProjectedDesktopTurnCompletions(threadId, nextState);
380
990
  }
381
991
 
382
- function mirroredUserMessageKeysForThread(threadId) {
383
- let keys = mirroredUserMessageKeysByThreadId.get(threadId);
384
- if (!keys) {
385
- keys = new Set();
386
- mirroredUserMessageKeysByThreadId.set(threadId, keys);
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);
387
1029
  }
388
- return keys;
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;
389
1081
  }
390
1082
 
391
- function mirroredActivityKeysForThread(threadId) {
392
- let keys = mirroredActivityKeysByThreadId.get(threadId);
393
- if (!keys) {
394
- keys = new Set();
395
- mirroredActivityKeysByThreadId.set(threadId, keys);
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;
396
1088
  }
397
- return keys;
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
+ };
398
1119
  }
399
1120
 
400
- function noteDesktopIpcTurnActivity(threadId, turnId, latestState) {
401
- if (!turnId || turnCompletionIdleMs <= 0) {
402
- return;
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;
403
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
+ }
404
1158
 
405
- let turns = activeDesktopTurnsByThreadId.get(threadId);
406
- if (!turns) {
407
- turns = new Map();
408
- activeDesktopTurnsByThreadId.set(threadId, turns);
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);
409
1175
  }
1176
+ desktopLiveLifecycleByThreadId.set(
1177
+ threadId,
1178
+ new Map(activeTurns.map((turn) => [turn.id, turn]))
1179
+ );
1180
+ }
410
1181
 
411
- const existing = turns.get(turnId);
412
- if (existing?.timer) {
413
- clearTimeoutFn(existing.timer);
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
+ );
414
1333
  }
415
1334
 
416
- const entry = { latestState, timer: null };
417
- turns.set(turnId, entry);
418
- scheduleDesktopIpcTurnIdleCompletion(threadId, turnId, entry);
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
+ }
419
1344
  }
420
1345
 
421
- function scheduleDesktopIpcTurnIdleCompletion(threadId, turnId, entry) {
422
- entry.timer = setTimeoutFn(() => {
423
- const currentTurns = activeDesktopTurnsByThreadId.get(threadId);
424
- const currentEntry = currentTurns?.get(turnId);
425
- if (!currentEntry) {
426
- return;
427
- }
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
+ }
428
1367
 
429
- if (hasOpenDesktopRequestForTurn(currentEntry.latestState, turnId)
430
- || hasActiveDesktopActivityForTurn(currentEntry.latestState, turnId)) {
431
- scheduleDesktopIpcTurnIdleCompletion(threadId, turnId, currentEntry);
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) {
432
1377
  return;
433
1378
  }
1379
+ settleAnnouncedBackgroundTurn(threadId, "interrupted");
1380
+ }, Math.max(0, backgroundDisconnectGraceMs));
1381
+ timer.unref?.();
1382
+ backgroundDisconnectTimersByThreadId.set(threadId, timer);
1383
+ }
434
1384
 
435
- completeDesktopIpcTurn(threadId, turnId);
436
- }, turnCompletionIdleMs);
437
- entry.timer.unref?.();
1385
+ function clearBackgroundDisconnectTimer(threadId) {
1386
+ const timer = backgroundDisconnectTimersByThreadId.get(threadId);
1387
+ if (!timer) {
1388
+ return;
1389
+ }
1390
+ clearTimeout(timer);
1391
+ backgroundDisconnectTimersByThreadId.delete(threadId);
438
1392
  }
439
1393
 
440
- function refreshTrackedDesktopTurnState(threadId, latestState) {
441
- const turns = activeDesktopTurnsByThreadId.get(threadId);
442
- if (!turns) {
1394
+ function syncThreadArchiveBroadcast(envelope) {
1395
+ const params = envelope.params || {};
1396
+ const threadId = readString(params.conversationId) || readString(params.conversation_id);
1397
+ if (!threadId) {
443
1398
  return;
444
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
+ }
445
1428
 
446
- for (const entry of turns.values()) {
447
- entry.latestState = latestState;
1429
+ function desktopRouteForResponse(message) {
1430
+ if (!message || typeof message !== "object" || message.method) {
1431
+ return null;
448
1432
  }
1433
+
1434
+ const requestId = requestIdKey(message.id);
1435
+ return requestId ? pendingRoutesByRequestId.get(requestId) || null : null;
449
1436
  }
450
1437
 
451
- function syncProjectedDesktopTurnCompletions(threadId, nextState) {
452
- const turns = activeDesktopTurnsByThreadId.get(threadId);
453
- if (!turns || turns.size === 0) {
1438
+ function submitDesktopActionResponse(route, responseMessage) {
1439
+ const payload = desktopFollowerPayloadForResponse(route, responseMessage);
1440
+ if (!payload) {
1441
+ sendApplicationResponse(JSON.stringify({
1442
+ id: responseMessage?.id ?? route.requestId,
1443
+ error: {
1444
+ code: -32602,
1445
+ message: "Invalid desktop action response.",
1446
+ },
1447
+ }));
454
1448
  return;
455
1449
  }
456
1450
 
457
- const completions = projectDesktopTurnCompletedNotifications(
458
- threadId,
459
- nextState,
460
- new Set(turns.keys())
461
- );
462
- for (const notification of completions) {
463
- completeDesktopIpcTurn(threadId, notification.params.turnId, notification.params.status || "completed");
1451
+ ipc.sendRequest(payload.method, payload.params)
1452
+ .then(() => {
1453
+ pendingRoutesByRequestId.delete(route.requestId);
1454
+ sendApplicationResponse(JSON.stringify(
1455
+ projectedResolvedNotification(route.threadId, route.requestId)
1456
+ ));
1457
+ })
1458
+ .catch((error) => {
1459
+ console.warn(`${logPrefix} desktop action reply failed for ${route.threadId}: ${error.message}`);
1460
+ sendApplicationResponse(JSON.stringify({
1461
+ id: responseMessage.id,
1462
+ error: {
1463
+ code: -32000,
1464
+ message: "Could not send this action to Codex on the Mac.",
1465
+ },
1466
+ }));
1467
+ });
1468
+ }
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
+ };
464
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;
465
1527
  }
466
1528
 
467
- function completeDesktopIpcTurn(threadId, turnId, status = "completed") {
468
- const turns = activeDesktopTurnsByThreadId.get(threadId);
469
- const entry = turns?.get(turnId);
470
- if (!entry) {
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) {
471
1614
  return;
472
1615
  }
473
1616
 
474
- if (entry.timer) {
475
- clearTimeoutFn(entry.timer);
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}`);
476
1637
  }
477
- turns.delete(turnId);
478
- if (turns.size === 0) {
479
- activeDesktopTurnsByThreadId.delete(threadId);
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;
480
1645
  }
481
1646
 
482
- sendApplicationResponse(JSON.stringify(createDesktopIpcTurnCompletedNotification(threadId, turnId, status)));
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
+ };
483
1657
  }
484
1658
 
485
- function clearAllDesktopTurnCompletionTimers() {
486
- for (const turns of activeDesktopTurnsByThreadId.values()) {
487
- for (const entry of turns.values()) {
488
- if (entry.timer) {
489
- clearTimeoutFn(entry.timer);
1659
+ function queueThreadChange(threadId, change) {
1660
+ if (!change || typeof change !== "object") {
1661
+ return;
1662
+ }
1663
+
1664
+ const queuedChanges = queuedChangesByThreadId.get(threadId) || [];
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
+ }
1671
+ queuedChangesByThreadId.set(threadId, queuedChanges);
1672
+ }
1673
+
1674
+ function recoverThreadBaseline(threadId) {
1675
+ if (recoveringThreadIds.has(threadId)
1676
+ || rawStatesByThreadId.has(threadId)) {
1677
+ return;
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);
1696
+
1697
+ recoveringThreadIds.add(threadId);
1698
+ Promise.resolve()
1699
+ .then(() => readConversationState(threadId))
1700
+ .then((baselineState) => {
1701
+ if (!baselineState || typeof baselineState !== "object") {
1702
+ return;
490
1703
  }
491
- }
1704
+
1705
+ baselineRecoveryStateByThreadId.delete(threadId);
1706
+ recoverThreadBaselineFromQueuedChanges(threadId, baselineState);
1707
+ })
1708
+ .catch((error) => {
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.
1714
+ })
1715
+ .finally(() => {
1716
+ recoveringThreadIds.delete(threadId);
1717
+ });
1718
+ }
1719
+
1720
+ function recoverThreadBaselineFromQueuedChanges(threadId, baselineState) {
1721
+ const queuedChanges = queuedChangesByThreadId.get(threadId) || [];
1722
+ if (queuedChanges.length === 0) {
1723
+ return;
1724
+ }
1725
+
1726
+ queuedChangesByThreadId.delete(threadId);
1727
+ let nextState = baselineState && typeof baselineState === "object"
1728
+ ? cloneJSON(baselineState)
1729
+ : createEmptyConversationState();
1730
+ for (const change of queuedChanges) {
1731
+ nextState = applyConversationStateChange(nextState, change) || nextState;
492
1732
  }
493
- activeDesktopTurnsByThreadId.clear();
1733
+
1734
+ rawStatesByThreadId.set(threadId, nextState);
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);
1747
+ }
1748
+ if (backgroundOnlyThreadIds.has(threadId)) {
1749
+ syncBackgroundThreadLifecycle(threadId, baselineState, nextState);
1750
+ } else {
1751
+ syncProjectedConversationState(threadId, nextState);
1752
+ }
1753
+ syncProjectedActions(threadId, projectPendingDesktopActions(threadId, nextState));
1754
+ releaseHeldFollowerRequests(threadId, { toDesktop: true });
494
1755
  }
495
1756
 
496
1757
  return {
497
1758
  observeInbound,
498
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
+ },
499
1814
  };
500
1815
  }
501
1816
 
@@ -507,6 +1822,7 @@ function createDesktopIpcClient({
507
1822
  requestTimeoutMs,
508
1823
  logPrefix,
509
1824
  onEnvelope,
1825
+ onConnected,
510
1826
  onDisconnect,
511
1827
  }) {
512
1828
  let socket = null;
@@ -514,6 +1830,7 @@ function createDesktopIpcClient({
514
1830
  let isConnecting = false;
515
1831
  let readBuffer = Buffer.alloc(0);
516
1832
  const pendingRequests = new Map();
1833
+ const pendingDiscoveries = new Map();
517
1834
 
518
1835
  function ensureConnected() {
519
1836
  if (socket || isConnecting) {
@@ -529,6 +1846,7 @@ function createDesktopIpcClient({
529
1846
  sendRequest("initialize", { clientType: "remodex-bridge" })
530
1847
  .then((result) => {
531
1848
  clientId = readString(result?.clientId) || clientId;
1849
+ onConnected?.(clientId);
532
1850
  })
533
1851
  .catch((error) => {
534
1852
  console.warn(`${logPrefix} desktop IPC initialize failed: ${error.message}`);
@@ -547,7 +1865,7 @@ function createDesktopIpcClient({
547
1865
  function sendRequest(method, params) {
548
1866
  ensureConnected();
549
1867
  if (!socket || socket.destroyed) {
550
- return Promise.reject(new Error("Desktop IPC is not connected."));
1868
+ return Promise.reject(markDeliveryFailureError(new Error("Desktop IPC is not connected.")));
551
1869
  }
552
1870
 
553
1871
  const requestId = `remodex-${now().toString(36)}-${Math.random().toString(16).slice(2)}`;
@@ -560,27 +1878,62 @@ function createDesktopIpcClient({
560
1878
  params: params || {},
561
1879
  };
562
1880
 
563
- return new Promise((resolve, reject) => {
1881
+ return new Promise((resolve, reject) => {
1882
+ const timeout = setTimeout(() => {
1883
+ pendingRequests.delete(requestId);
1884
+ reject(new Error(`Desktop IPC request timed out: ${method}`));
1885
+ }, requestTimeoutMs);
1886
+ timeout.unref?.();
1887
+
1888
+ pendingRequests.set(requestId, {
1889
+ method,
1890
+ resolve,
1891
+ reject,
1892
+ timeout,
1893
+ });
1894
+ writeFrame(socket, JSON.stringify(envelope), (error) => {
1895
+ if (!error) {
1896
+ return;
1897
+ }
1898
+
1899
+ clearTimeout(timeout);
1900
+ pendingRequests.delete(requestId);
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) => {
564
1916
  const timeout = setTimeout(() => {
565
- pendingRequests.delete(requestId);
566
- reject(new Error(`Desktop IPC request timed out: ${method}`));
567
- }, requestTimeoutMs);
1917
+ pendingDiscoveries.delete(requestId);
1918
+ resolve(null);
1919
+ }, timeoutMs);
568
1920
  timeout.unref?.();
569
1921
 
570
- pendingRequests.set(requestId, {
571
- method,
1922
+ pendingDiscoveries.set(requestId, {
572
1923
  resolve,
573
- reject,
574
1924
  timeout,
575
1925
  });
576
- writeFrame(socket, JSON.stringify(envelope), (error) => {
1926
+ writeEnvelope({
1927
+ type: "client-discovery-request",
1928
+ requestId,
1929
+ request,
1930
+ }, (error) => {
577
1931
  if (!error) {
578
1932
  return;
579
1933
  }
580
-
581
1934
  clearTimeout(timeout);
582
- pendingRequests.delete(requestId);
583
- reject(error);
1935
+ pendingDiscoveries.delete(requestId);
1936
+ resolve(null);
584
1937
  });
585
1938
  });
586
1939
  }
@@ -618,6 +1971,17 @@ function createDesktopIpcClient({
618
1971
  return;
619
1972
  }
620
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
+
621
1985
  if (envelope.type === "response") {
622
1986
  const requestId = requestIdKey(envelope.requestId);
623
1987
  const waiter = requestId ? pendingRequests.get(requestId) : null;
@@ -628,7 +1992,14 @@ function createDesktopIpcClient({
628
1992
  pendingRequests.delete(requestId);
629
1993
  clearTimeout(waiter.timeout);
630
1994
  if (envelope.resultType === "error") {
631
- 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);
632
2003
  return;
633
2004
  }
634
2005
 
@@ -649,6 +2020,11 @@ function createDesktopIpcClient({
649
2020
  waiter.reject(new Error("Desktop IPC connection closed."));
650
2021
  }
651
2022
  pendingRequests.clear();
2023
+ for (const pendingDiscovery of pendingDiscoveries.values()) {
2024
+ clearTimeout(pendingDiscovery.timeout);
2025
+ pendingDiscovery.resolve(null);
2026
+ }
2027
+ pendingDiscoveries.clear();
652
2028
  onDisconnect();
653
2029
  }
654
2030
 
@@ -673,7 +2049,11 @@ function createDesktopIpcClient({
673
2049
 
674
2050
  return {
675
2051
  ensureConnected,
2052
+ isConnected() {
2053
+ return Boolean(socket && !socket.destroyed && clientId);
2054
+ },
676
2055
  sendRequest,
2056
+ sendDiscoveryRequest,
677
2057
  close,
678
2058
  };
679
2059
  }
@@ -792,7 +2172,6 @@ function projectDesktopAssistantDeltaNotifications(
792
2172
  turnId: message.turnId,
793
2173
  itemId: message.itemId,
794
2174
  delta,
795
- ...(message.phase ? { phase: message.phase } : {}),
796
2175
  },
797
2176
  });
798
2177
  }
@@ -800,695 +2179,770 @@ function projectDesktopAssistantDeltaNotifications(
800
2179
  return notifications;
801
2180
  }
802
2181
 
803
- function projectDesktopUserMessageNotifications(
804
- threadId,
805
- conversationState,
806
- mirroredKeys = new Set(),
807
- turnIdFilter = null
808
- ) {
809
- const messages = collectUserMessages(conversationState);
810
- const notifications = [];
811
-
812
- for (const message of messages) {
813
- if (turnIdFilter && turnIdFilter.size > 0 && !turnIdFilter.has(message.turnId)) {
814
- continue;
815
- }
816
- if (mirroredKeys.has(message.key)) {
817
- continue;
818
- }
819
-
820
- mirroredKeys.add(message.key);
821
- notifications.push({
822
- method: "codex/event/user_message",
823
- params: {
824
- threadId,
825
- turnId: message.turnId,
826
- message: message.text,
827
- ...(message.itemId ? { id: message.itemId } : {}),
828
- ...(message.timestamp ? { timestamp: message.timestamp } : {}),
829
- remodexDesktopMirror: true,
830
- remodexDesktopIpcMirror: true,
831
- },
832
- });
833
- }
834
-
835
- return notifications;
2182
+ function snapshotAssistantMessageTexts(conversationState) {
2183
+ return new Map(collectAssistantMessages(conversationState).map((message) => [message.key, message.text]));
836
2184
  }
837
2185
 
838
- function projectDesktopActivityNotifications(threadId, conversationState, mirroredKeys = new Set()) {
2186
+ function collectAssistantMessages(conversationState) {
839
2187
  const turns = Array.isArray(conversationState?.turns) ? conversationState.turns : [];
840
- const notifications = [];
841
-
2188
+ const messages = [];
842
2189
  for (const turn of turns) {
843
2190
  const turnId = readString(turn?.id) || readString(turn?.turnId) || readString(turn?.turn_id);
844
- if (!turnId || (
845
- !hasActiveDesktopActivityForTurn(conversationState, turnId)
846
- && !isDesktopTurnLive(turn, conversationState)
847
- && !hasMirroredActivityOutputPending(turn, mirroredKeys)
848
- )) {
849
- continue;
850
- }
851
-
852
2191
  const items = Array.isArray(turn?.items) ? turn.items : [];
853
- const callsById = new Map();
854
2192
  for (const item of items) {
855
- if (isDesktopActivityCallItem(item)) {
856
- const callId = desktopActivityCallId(item);
857
- if (callId) {
858
- callsById.set(callId, item);
859
- }
2193
+ if (!isAssistantMessageItem(item)) {
2194
+ continue;
860
2195
  }
861
- }
862
2196
 
863
- for (const item of items) {
864
- if (isDesktopActivityCallItem(item)) {
865
- notifications.push(...projectDesktopActivityBeginNotifications(threadId, turnId, item, mirroredKeys));
866
- } else if (isDesktopActivityOutputItem(item)) {
867
- const callId = desktopActivityCallId(item);
868
- notifications.push(...projectDesktopActivityOutputNotifications(
869
- threadId,
870
- turnId,
871
- item,
872
- callsById.get(callId),
873
- mirroredKeys
874
- ));
2197
+ const itemId = readString(item?.id) || readString(item?.itemId) || readString(item?.item_id);
2198
+ const text = assistantMessageText(item);
2199
+ if (!turnId || !itemId) {
2200
+ continue;
875
2201
  }
876
- }
877
- }
878
-
879
- return notifications;
880
- }
881
-
882
- function projectDesktopActivityBeginNotifications(threadId, turnId, item, mirroredKeys) {
883
- const callId = desktopActivityCallId(item);
884
- const toolName = readString(item?.name) || readString(item?.toolName) || readString(item?.tool_name);
885
- if (!callId || !toolName || !markMirroredActivityKey(mirroredKeys, turnId, callId, "begin")) {
886
- return [];
887
- }
888
2202
 
889
- if (isCommandToolName(toolName)) {
890
- const argumentsObject = parseToolArguments(item?.arguments);
891
- return [createDesktopIpcNotification("codex/event/exec_command_begin", {
892
- threadId,
893
- turnId,
894
- call_id: callId,
895
- command: resolveToolCommand(toolName, argumentsObject),
896
- cwd: resolveToolWorkingDirectory(argumentsObject, item),
897
- status: "running",
898
- })];
899
- }
900
-
901
- if (toolName === "apply_patch") {
902
- const isCompletedPatch = Boolean(terminalStatusFromObject(item));
903
- const fileChange = buildApplyPatchFileChangeItem({
904
- callId,
905
- patch: readString(item?.input) || readString(item?.arguments),
906
- status: readString(item?.status) || (isCompletedPatch ? "completed" : "inProgress"),
907
- idFallback: buildSyntheticActivityItemId("file-change", threadId, turnId, callId),
908
- cwd: readString(item?.cwd) || readString(item?.workdir),
909
- });
910
- if (fileChange) {
911
- return [createDesktopIpcNotification(
912
- isCompletedPatch ? "codex/event/patch_apply_end" : "codex/event/patch_apply_begin",
913
- {
914
- threadId,
915
- turnId,
916
- id: turnId,
917
- call_id: callId,
918
- itemId: fileChange.id,
919
- status: fileChange.status,
920
- ...(isCompletedPatch ? { success: true } : {}),
921
- changes: fileChange.changes,
922
- }
923
- )];
2203
+ messages.push({
2204
+ key: `${turnId}:${itemId}`,
2205
+ turnId,
2206
+ itemId,
2207
+ text,
2208
+ });
924
2209
  }
925
2210
  }
926
-
927
- return [createDesktopIpcNotification("codex/event/background_event", {
928
- threadId,
929
- turnId,
930
- call_id: callId,
931
- message: genericToolActivityMessage(toolName),
932
- })];
2211
+ return messages;
933
2212
  }
934
2213
 
935
- function projectDesktopActivityOutputNotifications(threadId, turnId, item, callItem, mirroredKeys) {
936
- const callId = desktopActivityCallId(item);
937
- const toolName = readString(callItem?.name) || readString(callItem?.toolName) || readString(callItem?.tool_name);
938
- if (!callId || !toolName || !mirroredKeys.has(activityMirrorKey(turnId, callId, "begin"))) {
939
- return [];
940
- }
941
- if (!markMirroredActivityKey(mirroredKeys, turnId, callId, "output")) {
942
- return [];
2214
+ function isAssistantMessageItem(item) {
2215
+ const type = normalizeToken(item?.type);
2216
+ if (type === "agentmessage" || type === "assistantmessage") {
2217
+ return true;
943
2218
  }
2219
+ return type === "message" && normalizeToken(item?.role) === "assistant";
2220
+ }
944
2221
 
945
- if (!isCommandToolName(toolName)) {
946
- return [];
2222
+ function assistantMessageText(item) {
2223
+ const directText = readString(item?.text) || readString(item?.message);
2224
+ if (directText) {
2225
+ return directText;
947
2226
  }
948
2227
 
949
- const argumentsObject = parseToolArguments(callItem?.arguments);
950
- const command = resolveToolCommand(toolName, argumentsObject);
951
- const cwd = resolveToolWorkingDirectory(argumentsObject, callItem);
952
- const output = readString(item?.output) || readString(item?.text) || readString(item?.content);
953
- const notifications = [];
954
- if (output) {
955
- notifications.push(createDesktopIpcNotification("codex/event/exec_command_output_delta", {
956
- threadId,
957
- turnId,
958
- call_id: callId,
959
- command,
960
- cwd,
961
- chunk: output,
962
- }));
963
- }
964
- notifications.push(createDesktopIpcNotification("codex/event/exec_command_end", {
965
- threadId,
966
- turnId,
967
- call_id: callId,
968
- command,
969
- cwd,
970
- status: "completed",
971
- output: output || "",
972
- }));
973
- return notifications;
2228
+ const content = Array.isArray(item?.content) ? item.content : [];
2229
+ return content
2230
+ .map((entry) => entry && typeof entry === "object" ? entry : null)
2231
+ .filter(Boolean)
2232
+ .map((entry) => readString(entry.text) || readString(entry?.data?.text))
2233
+ .filter(Boolean)
2234
+ .join("");
974
2235
  }
975
2236
 
976
- function projectDesktopTurnCompletedNotifications(
977
- threadId,
978
- conversationState,
979
- trackedTurnIds = new Set()
980
- ) {
981
- if (!trackedTurnIds || trackedTurnIds.size === 0) {
982
- return [];
2237
+ function projectPendingDesktopAction(threadId, request) {
2238
+ const requestId = requestIdKey(request.id);
2239
+ const method = readString(request.method);
2240
+ const params = request.params && typeof request.params === "object" && !Array.isArray(request.params)
2241
+ ? request.params
2242
+ : {};
2243
+ if (!requestId || !method) {
2244
+ return null;
983
2245
  }
984
2246
 
985
- const turns = Array.isArray(conversationState?.turns) ? conversationState.turns : [];
986
- const notifications = [];
987
- for (const turn of turns) {
988
- const turnId = readString(turn?.id) || readString(turn?.turnId) || readString(turn?.turn_id);
989
- if (!turnId || !trackedTurnIds.has(turnId)) {
990
- continue;
991
- }
992
- // Terminal snapshots can race active tool rows. Keep tracking the turn until
993
- // the activity itself closes, otherwise the phone may stay running forever.
994
- if (hasActiveDesktopActivityForTurn(conversationState, turnId)) {
995
- continue;
996
- }
997
- if (!isDesktopTurnTerminal(turn, conversationState)) {
998
- continue;
2247
+ if (method === "item/tool/requestUserInput") {
2248
+ const questions = Array.isArray(params.questions) ? params.questions : [];
2249
+ if (questions.length === 0) {
2250
+ return null;
999
2251
  }
1000
-
1001
- notifications.push(createDesktopIpcTurnCompletedNotification(
1002
- threadId,
1003
- turnId,
1004
- desktopTerminalStatus(turn, conversationState) || "completed"
1005
- ));
1006
2252
  }
1007
- return notifications;
1008
- }
1009
2253
 
1010
- function createDesktopIpcTurnCompletedNotification(threadId, turnId, status = "completed") {
1011
2254
  return {
1012
- method: "turn/completed",
2255
+ id: requestId,
2256
+ method,
1013
2257
  params: {
1014
- threadId,
1015
- turnId,
1016
- id: turnId,
1017
- status,
2258
+ ...params,
2259
+ remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
1018
2260
  remodexDesktopMirror: true,
1019
2261
  remodexDesktopIpcMirror: true,
2262
+ threadId: readString(params.threadId) || readString(params.thread_id) || threadId,
1020
2263
  },
1021
2264
  };
1022
2265
  }
1023
2266
 
1024
- function snapshotAssistantMessageTexts(conversationState) {
1025
- return new Map(collectAssistantMessages(conversationState).map((message) => [message.key, message.text]));
1026
- }
1027
-
1028
- function collectUserMessages(conversationState) {
1029
- const turns = Array.isArray(conversationState?.turns) ? conversationState.turns : [];
1030
- const messages = [];
1031
- for (const turn of turns) {
1032
- const turnId = readString(turn?.id) || readString(turn?.turnId) || readString(turn?.turn_id);
1033
- const items = Array.isArray(turn?.items) ? turn.items : [];
1034
- for (const item of items) {
1035
- if (!isUserMessageItem(item)) {
1036
- continue;
1037
- }
2267
+ function applyConversationStateChange(previousState, change) {
2268
+ if (!change || typeof change !== "object") {
2269
+ return null;
2270
+ }
1038
2271
 
1039
- const text = userMessageText(item);
1040
- if (!turnId || !text) {
1041
- continue;
1042
- }
2272
+ if (change.type === "snapshot" || change.type === "Snapshot") {
2273
+ return cloneJSON(change.conversationState || change.conversation_state || {});
2274
+ }
1043
2275
 
1044
- const itemId = readString(item?.id) || readString(item?.itemId) || readString(item?.item_id);
1045
- messages.push({
1046
- key: userMessageKey(turnId, itemId, text),
1047
- turnId,
1048
- itemId,
1049
- text,
1050
- timestamp: readString(item?.createdAt)
1051
- || readString(item?.created_at)
1052
- || readString(item?.timestamp)
1053
- || readString(item?.time),
1054
- });
1055
- }
2276
+ if (change.type !== "patches" && change.type !== "Patches") {
2277
+ return previousState || null;
1056
2278
  }
1057
- return messages;
1058
- }
1059
2279
 
1060
- function collectAssistantMessages(conversationState) {
1061
- const turns = Array.isArray(conversationState?.turns) ? conversationState.turns : [];
1062
- const messages = [];
1063
- for (const turn of turns) {
1064
- const turnId = readString(turn?.id) || readString(turn?.turnId) || readString(turn?.turn_id);
1065
- const items = Array.isArray(turn?.items) ? turn.items : [];
1066
- for (const item of items) {
1067
- if (!isAssistantMessageItem(item)) {
1068
- continue;
1069
- }
2280
+ const patches = Array.isArray(change.patches) ? change.patches : [];
2281
+ if (!previousState || patches.length === 0) {
2282
+ return previousState || null;
2283
+ }
1070
2284
 
1071
- const itemId = readString(item?.id) || readString(item?.itemId) || readString(item?.item_id);
1072
- const text = assistantMessageText(item);
1073
- const phase = assistantMessagePhase(item);
1074
- if (!turnId || !itemId) {
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]);
2291
+ for (const patch of patches) {
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);
1075
2298
  continue;
1076
2299
  }
1077
-
1078
- messages.push({
1079
- key: `${turnId}:${itemId}`,
1080
- turnId,
1081
- itemId,
1082
- phase,
1083
- text,
1084
- });
2300
+ return null;
2301
+ }
2302
+ if (!applyImmerPatchCopyOnWrite(nextState, patch, clonedNodes)) {
2303
+ return null;
1085
2304
  }
1086
2305
  }
1087
- return messages;
1088
- }
1089
-
1090
- function userMessageKey(turnId, itemId, text) {
1091
- if (itemId) {
1092
- return `${turnId}:${itemId}`;
1093
- }
1094
- return `${turnId}:text:${crypto
1095
- .createHash("sha256")
1096
- .update(text)
1097
- .digest("hex")
1098
- .slice(0, 16)}`;
2306
+ return nextState;
1099
2307
  }
1100
2308
 
1101
- function markMirroredActivityKey(mirroredKeys, turnId, callId, phase) {
1102
- const key = activityMirrorKey(turnId, callId, phase);
1103
- if (mirroredKeys.has(key)) {
1104
- return false;
1105
- }
1106
- mirroredKeys.add(key);
1107
- return true;
2309
+ function shallowCloneNode(value) {
2310
+ return Array.isArray(value) ? value.slice() : { ...value };
1108
2311
  }
1109
2312
 
1110
- function activityMirrorKey(turnId, callId, phase) {
1111
- return `${turnId}:${callId}:${phase}`;
2313
+ function isPatchChange(change) {
2314
+ return change?.type === "patches" || change?.type === "Patches";
1112
2315
  }
1113
2316
 
1114
- function hasMirroredActivityOutputPending(turn, mirroredKeys) {
1115
- const turnId = readString(turn?.id) || readString(turn?.turnId) || readString(turn?.turn_id);
1116
- const items = Array.isArray(turn?.items) ? turn.items : [];
1117
- return items.some((item) => {
1118
- if (!isDesktopActivityOutputItem(item)) {
1119
- return false;
1120
- }
1121
- const callId = desktopActivityCallId(item);
1122
- return callId
1123
- && mirroredKeys.has(activityMirrorKey(turnId, callId, "begin"))
1124
- && !mirroredKeys.has(activityMirrorKey(turnId, callId, "output"));
1125
- });
2317
+ function isSnapshotChange(change) {
2318
+ return change?.type === "snapshot" || change?.type === "Snapshot";
1126
2319
  }
1127
2320
 
1128
- function isDesktopTurnTerminal(turn, conversationState) {
1129
- if (desktopTerminalStatus(turn, conversationState)) {
1130
- return true;
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;
1131
2327
  }
1132
-
1133
- return hasExplicitFalseFlag(turn, ["running", "isRunning", "streaming", "isStreaming"])
1134
- || (
1135
- hasExplicitFalseFlag(conversationState, ["running", "isRunning", "streaming", "isStreaming"])
1136
- && !hasOpenDesktopRequestForTurn(conversationState, readString(turn?.id) || readString(turn?.turnId) || readString(turn?.turn_id))
1137
- );
2328
+ return { history, entities };
1138
2329
  }
1139
2330
 
1140
- function isDesktopTurnLive(turn, conversationState) {
1141
- return hasExplicitTrueFlag(turn, ["running", "isRunning", "streaming", "isStreaming"])
1142
- || hasExplicitTrueFlag(conversationState, ["running", "isRunning", "streaming", "isStreaming"])
1143
- || ACTIVE_STATUS_TOKENS.has(normalizeToken(readString(turn?.status)))
1144
- || ACTIVE_STATUS_TOKENS.has(normalizeToken(readString(turn?.state)))
1145
- || ACTIVE_STATUS_TOKENS.has(normalizeToken(readString(turn?.phase)))
1146
- || ACTIVE_STATUS_TOKENS.has(normalizeToken(readString(conversationState?.status)))
1147
- || ACTIVE_STATUS_TOKENS.has(normalizeToken(readString(conversationState?.state)))
1148
- || ACTIVE_STATUS_TOKENS.has(normalizeToken(readString(conversationState?.phase)));
2331
+ function hasNormalizedTurnStore(state) {
2332
+ return Boolean(normalizedTurnStore(state));
1149
2333
  }
1150
2334
 
1151
- function desktopTerminalStatus(turn, conversationState) {
1152
- const terminal = terminalStatusFromObject(turn)
1153
- || terminalStatusFromObject(turn?.turn)
1154
- || terminalStatusFromObject(conversationState);
1155
- return terminal || "";
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 : "";
1156
2343
  }
1157
2344
 
1158
- function terminalStatusFromObject(value) {
1159
- if (!value || typeof value !== "object" || Array.isArray(value)) {
1160
- return "";
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
+ });
1161
2410
  }
1162
2411
 
1163
- const booleanStatus = terminalStatusFromBooleans(value);
1164
- if (booleanStatus) {
1165
- return booleanStatus;
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
+ }
1166
2426
  }
2427
+ return index;
2428
+ }
1167
2429
 
1168
- const candidates = [
1169
- value.status,
1170
- value.state,
1171
- value.phase,
1172
- value.lifecycle,
1173
- value.lifecycleStatus,
1174
- value.lifecycle_status,
1175
- value.runStatus,
1176
- value.run_status,
1177
- value.turnStatus,
1178
- value.turn_status,
1179
- ];
1180
- for (const candidate of candidates) {
1181
- const token = normalizeToken(readString(candidate));
1182
- if (TERMINAL_STATUS_TOKENS.has(token)) {
1183
- return canonicalTerminalStatus(token);
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;
1184
2456
  }
1185
2457
  }
1186
- return "";
2458
+ return false;
1187
2459
  }
1188
2460
 
1189
- function terminalStatusFromBooleans(value) {
1190
- if (value.completed === true || value.complete === true || value.done === true || value.finished === true) {
1191
- return "completed";
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
+ }
1192
2482
  }
1193
- if (value.failed === true || value.error === true) {
1194
- return "failed";
2483
+ if (touchedTurnIds.size === 0) {
2484
+ return;
1195
2485
  }
1196
- if (value.cancelled === true || value.canceled === true || value.interrupted === true) {
1197
- return "canceled";
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
+ }
1198
2495
  }
1199
- return "";
1200
2496
  }
1201
2497
 
1202
- const TERMINAL_STATUS_TOKENS = new Set([
1203
- "completed",
1204
- "complete",
1205
- "finished",
1206
- "succeeded",
1207
- "success",
1208
- "failed",
1209
- "failure",
1210
- "error",
1211
- "cancelled",
1212
- "canceled",
1213
- "interrupted",
1214
- ]);
1215
-
1216
- function canonicalTerminalStatus(token) {
1217
- if (token === "failed" || token === "failure" || token === "error") {
1218
- return "failed";
1219
- }
1220
- if (token === "cancelled" || token === "canceled" || token === "interrupted") {
1221
- return "canceled";
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
+ }
1222
2508
  }
1223
- return "completed";
2509
+ return null;
1224
2510
  }
1225
2511
 
1226
- function hasExplicitFalseFlag(value, keys) {
1227
- if (!value || typeof value !== "object" || Array.isArray(value)) {
1228
- return false;
1229
- }
1230
-
1231
- return keys.some((key) => Object.prototype.hasOwnProperty.call(value, key) && value[key] === false);
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
+ };
1232
2522
  }
1233
2523
 
1234
- function hasExplicitTrueFlag(value, keys) {
1235
- if (!value || typeof value !== "object" || Array.isArray(value)) {
1236
- return false;
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
+ );
1237
2537
  }
1238
-
1239
- return keys.some((key) => Object.prototype.hasOwnProperty.call(value, key) && value[key] === true);
1240
- }
1241
-
1242
- const ACTIVE_STATUS_TOKENS = new Set([
1243
- "running",
1244
- "streaming",
1245
- "inprogress",
1246
- "inflight",
1247
- "started",
1248
- "pending",
1249
- "active",
1250
- ]);
1251
-
1252
- function hasOpenDesktopRequestForTurn(conversationState, turnId) {
1253
- if (!turnId) {
1254
- return false;
2538
+ const orderedTurns = backgroundHistoryTurns(state);
2539
+ if (orderedTurns.length <= 1) {
2540
+ return normalizeBoundedTurnsForRuntime(
2541
+ orderedTurns.map((turn, index) => withStableProjectedTurnId(turn, index)),
2542
+ state
2543
+ );
1255
2544
  }
1256
2545
 
1257
- const requests = Array.isArray(conversationState?.requests) ? conversationState.requests : [];
1258
- return requests.some((request) => {
1259
- if (!request || request.completed === true) {
1260
- return false;
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);
1261
2558
  }
1262
-
1263
- const params = request.params && typeof request.params === "object" && !Array.isArray(request.params)
1264
- ? request.params
1265
- : {};
1266
- const requestTurnId = readString(params.turnId)
1267
- || readString(params.turn_id)
1268
- || readString(request.turnId)
1269
- || readString(request.turn_id);
1270
- return requestTurnId === turnId;
1271
- });
1272
- }
1273
-
1274
- function hasActiveDesktopActivityForTurn(conversationState, turnId) {
1275
- const turn = desktopTurnById(conversationState, turnId);
1276
- const items = Array.isArray(turn?.items) ? turn.items : [];
1277
- const completedActivityIds = completedDesktopActivityIds(items);
1278
- return items.some((item) => isActiveDesktopActivityItem(item, completedActivityIds));
1279
- }
1280
-
1281
- function desktopTurnById(conversationState, turnId) {
1282
- if (!turnId) {
1283
- return null;
1284
2559
  }
1285
-
1286
- const turns = Array.isArray(conversationState?.turns) ? conversationState.turns : [];
1287
- return turns.find((turn) => {
1288
- const candidateTurnId = readString(turn?.id) || readString(turn?.turnId) || readString(turn?.turn_id);
1289
- return candidateTurnId === turnId;
1290
- }) || null;
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);
1291
2570
  }
1292
2571
 
1293
- function isActiveDesktopActivityItem(item, completedActivityIds = new Set()) {
1294
- if (!item || typeof item !== "object" || Array.isArray(item)) {
1295
- return false;
2572
+ function boundedIndexedDesktopLiveTurns(state, index, nowValue, retainedTurnIds) {
2573
+ if (index.entries.length === 0) {
2574
+ return [];
1296
2575
  }
1297
- if (!isDesktopActivityItem(item) || isDesktopActivityOutputItem(item) || terminalStatusFromObject(item)) {
1298
- return false;
2576
+ const selectedTurnIds = new Set();
2577
+ const tailEntry = index.entries[index.entries.length - 1];
2578
+ if (tailEntry?.id) {
2579
+ selectedTurnIds.add(tailEntry.id);
1299
2580
  }
1300
- if (hasExplicitTrueFlag(item, ["running", "isRunning", "streaming", "isStreaming"])
1301
- || ACTIVE_STATUS_TOKENS.has(normalizeToken(readString(item.status)))
1302
- || ACTIVE_STATUS_TOKENS.has(normalizeToken(readString(item.state)))
1303
- || ACTIVE_STATUS_TOKENS.has(normalizeToken(readString(item.phase)))
1304
- || ACTIVE_STATUS_TOKENS.has(normalizeToken(readString(item.lifecycle)))) {
1305
- return true;
2581
+ for (const turnId of retainedTurnIds) {
2582
+ if (index.entryIndexByTurnId.has(turnId)) {
2583
+ selectedTurnIds.add(turnId);
2584
+ }
1306
2585
  }
1307
-
1308
- // Codex Desktop often represents a live tool as a bare function_call/custom_tool_call
1309
- // with only call_id, then appends a separate *_output item. Treat the call as active
1310
- // until that output appears, even when no explicit "running" status is present.
1311
- const activityId = desktopActivityCallId(item);
1312
- return isDesktopActivityCallItem(item) && activityId && !completedActivityIds.has(activityId);
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);
1313
2617
  }
1314
2618
 
1315
- function isDesktopActivityItem(item) {
1316
- const type = normalizeToken(item?.type);
1317
- return type.includes("tool")
1318
- || type.includes("command")
1319
- || type.includes("exec")
1320
- || type.includes("mcp")
1321
- || type.includes("function");
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;
1322
2625
  }
1323
2626
 
1324
- function isDesktopActivityCallItem(item) {
1325
- const type = normalizeToken(item?.type);
1326
- if (isDesktopActivityOutputItem(item)) {
1327
- return false;
1328
- }
1329
- return type.endsWith("call")
1330
- || type.includes("toolcall")
1331
- || type.includes("functioncall")
1332
- || type.includes("commandexecution")
1333
- || type.includes("localshellcall");
2627
+ function turnIdOf(turn) {
2628
+ return readString(turn?.id)
2629
+ || readString(turn?.turnId)
2630
+ || readString(turn?.turn_id);
1334
2631
  }
1335
2632
 
1336
- function isDesktopActivityOutputItem(item) {
1337
- const type = normalizeToken(item?.type);
1338
- return type.includes("output")
1339
- || type.includes("result")
1340
- || type.endsWith("end")
1341
- || type.includes("completed");
2633
+ function withStableProjectedTurnId(turn, fullHistoryIndex) {
2634
+ return turnIdOf(turn)
2635
+ ? turn
2636
+ : { ...turn, id: `ipc-turn-${fullHistoryIndex}` };
1342
2637
  }
1343
2638
 
1344
- function completedDesktopActivityIds(items) {
1345
- const ids = new Set();
1346
- for (const item of items) {
1347
- if (!item || typeof item !== "object" || Array.isArray(item)) {
1348
- continue;
1349
- }
1350
- if (!isDesktopActivityOutputItem(item) && !terminalStatusFromObject(item)) {
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)) {
1351
2644
  continue;
1352
2645
  }
1353
- const id = desktopActivityCallId(item);
2646
+ const id = turnIdOf(turn);
1354
2647
  if (id) {
1355
- ids.add(id);
2648
+ descriptors.push({
2649
+ id,
2650
+ status: readString(turn?.status) || "inProgress",
2651
+ turn,
2652
+ });
1356
2653
  }
1357
2654
  }
1358
- return ids;
2655
+ return descriptors;
1359
2656
  }
1360
2657
 
1361
- function desktopActivityCallId(item) {
1362
- return readString(item?.call_id)
1363
- || readString(item?.callId)
1364
- || readString(item?.tool_call_id)
1365
- || readString(item?.toolCallId)
1366
- || readString(item?.requestId)
1367
- || readString(item?.request_id)
1368
- || readString(item?.id);
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
+ };
1369
2676
  }
1370
2677
 
1371
- function isUserMessageItem(item) {
1372
- const type = normalizeToken(item?.type);
1373
- if (type === "usermessage") {
1374
- return true;
2678
+ function notificationWithTurnIdentityContinuity(notification) {
2679
+ if (notification?.method !== "turn/started") {
2680
+ return notification;
1375
2681
  }
1376
- return type === "message" && normalizeToken(item?.role) === "user";
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
+ };
1377
2691
  }
1378
2692
 
1379
- function isAssistantMessageItem(item) {
1380
- const type = normalizeToken(item?.type);
1381
- if (type === "agentmessage" || type === "assistantmessage") {
1382
- return true;
2693
+ function normalizeBoundedTurnsForRuntime(turns, state) {
2694
+ if (!isExplicitlyIdleDesktopRuntime(state)) {
2695
+ return turns;
1383
2696
  }
1384
- return type === "message" && normalizeToken(item?.role) === "assistant";
2697
+ return turns.map((turn) => (
2698
+ isActiveRawTurn(turn)
2699
+ ? { ...turn, status: "completed" }
2700
+ : turn
2701
+ ));
1385
2702
  }
1386
2703
 
1387
- function userMessageText(item) {
1388
- const directText = readString(item?.text) || readString(item?.message);
1389
- if (directText) {
1390
- return directText;
1391
- }
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
+ }
1392
2712
 
1393
- const content = Array.isArray(item?.content) ? item.content : [];
1394
- return content
1395
- .map((entry) => entry && typeof entry === "object" ? entry : null)
1396
- .filter(Boolean)
1397
- .map((entry) => readString(entry.text) || readString(entry?.data?.text))
1398
- .filter(Boolean)
1399
- .join("");
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";
1400
2720
  }
1401
2721
 
1402
- function assistantMessagePhase(item) {
1403
- return normalizeAssistantPhase(
1404
- readString(item?.phase)
1405
- || readString(item?.assistantPhase)
1406
- || readString(item?.assistant_phase)
1407
- || readString(item?.metadata?.phase)
1408
- );
2722
+ function isActiveRawTurn(turn) {
2723
+ const status = normalizeToken(turn?.status);
2724
+ return status === "inprogress"
2725
+ || status === "running"
2726
+ || status === "active"
2727
+ || status === "processing";
1409
2728
  }
1410
2729
 
1411
- function normalizeAssistantPhase(value) {
1412
- const normalized = normalizeToken(value);
1413
- if (!normalized) {
1414
- return "";
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;
1415
2735
  }
1416
- if (normalized === "finalanswer") {
1417
- return "final_answer";
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
+ }
1418
2749
  }
1419
- return normalized;
2750
+ return null;
1420
2751
  }
1421
2752
 
1422
- function assistantMessageText(item) {
1423
- const directText = readString(item?.text) || readString(item?.message);
1424
- if (directText) {
1425
- return directText;
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;
1426
2760
  }
1427
2761
 
1428
- const content = Array.isArray(item?.content) ? item.content : [];
1429
- return content
1430
- .map((entry) => entry && typeof entry === "object" ? entry : null)
1431
- .filter(Boolean)
1432
- .map((entry) => readString(entry.text) || readString(entry?.data?.text))
1433
- .filter(Boolean)
1434
- .join("");
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
+ };
1435
2871
  }
1436
2872
 
1437
- function projectPendingDesktopAction(threadId, request) {
1438
- const requestId = requestIdKey(request.id);
1439
- const method = readString(request.method);
1440
- const params = request.params && typeof request.params === "object" && !Array.isArray(request.params)
1441
- ? request.params
1442
- : {};
1443
- if (!requestId || !method) {
1444
- return null;
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 [];
1445
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
+ }
1446
2893
 
1447
- if (method === "item/tool/requestUserInput") {
1448
- const questions = Array.isArray(params.questions) ? params.questions : [];
1449
- if (questions.length === 0) {
1450
- return null;
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);
1451
2911
  }
1452
2912
  }
2913
+ return { method, params };
2914
+ }
2915
+
2916
+ function isRemodexLiveOwnerBroadcast(params) {
2917
+ return readString(params?.remodexOwnerSource) === REMODEX_LIVE_OWNER_SOURCE;
2918
+ }
1453
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) {
1454
2923
  return {
1455
- id: requestId,
1456
- method,
2924
+ method: "serverRequest/resolved",
1457
2925
  params: {
1458
- ...params,
2926
+ threadId,
2927
+ requestId,
2928
+ remodexDesktopMirror: true,
2929
+ remodexDesktopIpcMirror: true,
1459
2930
  remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
1460
- threadId: readString(params.threadId) || readString(params.thread_id) || threadId,
1461
2931
  },
1462
2932
  };
1463
2933
  }
1464
2934
 
1465
- function applyConversationStateChange(previousState, change) {
1466
- if (!change || typeof change !== "object") {
1467
- return null;
1468
- }
1469
-
1470
- if (change.type === "snapshot" || change.type === "Snapshot") {
1471
- return cloneJSON(change.conversationState || change.conversation_state || {});
1472
- }
1473
-
1474
- if (change.type !== "patches" && change.type !== "Patches") {
1475
- return previousState || null;
1476
- }
1477
-
1478
- const patches = Array.isArray(change.patches) ? change.patches : [];
1479
- if (!previousState || patches.length === 0) {
1480
- return previousState || null;
1481
- }
2935
+ function isPeerOwnershipSnapshot(params) {
2936
+ return !isRemodexLiveOwnerBroadcast(params) && normalizeToken(params?.change?.type) === "snapshot";
2937
+ }
1482
2938
 
1483
- const nextState = cloneJSON(previousState);
1484
- for (const patch of patches) {
1485
- applyImmerPatch(nextState, patch);
1486
- }
1487
- return nextState;
2939
+ function markDeliveryFailureError(error) {
2940
+ error.remodexDeliveryFailed = true;
2941
+ return error;
1488
2942
  }
1489
2943
 
1490
- function isPatchChange(change) {
1491
- return change?.type === "patches" || change?.type === "Patches";
2944
+ function isDeliveryFailureError(error) {
2945
+ return error?.remodexDeliveryFailed === true;
1492
2946
  }
1493
2947
 
1494
2948
  function seedConversationStateFromThreadRead(response) {
@@ -1513,58 +2967,68 @@ function createEmptyConversationState() {
1513
2967
  };
1514
2968
  }
1515
2969
 
1516
- function applyImmerPatch(target, patch) {
2970
+ function applyImmerPatchCopyOnWrite(target, patch, clonedNodes) {
1517
2971
  const patchPath = Array.isArray(patch?.path) ? patch.path : [];
1518
2972
  const op = readString(patch?.op).toLowerCase();
1519
2973
  if (!op || patchPath.length === 0) {
1520
- return;
2974
+ return false;
1521
2975
  }
1522
2976
 
1523
2977
  let parent = target;
1524
2978
  for (let index = 0; index < patchPath.length - 1; index += 1) {
1525
- parent = parent?.[patchPath[index]];
1526
- if (parent == null) {
1527
- 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;
1528
2987
  }
2988
+ const clonedChild = shallowCloneNode(child);
2989
+ clonedNodes.add(clonedChild);
2990
+ parent[key] = clonedChild;
2991
+ parent = clonedChild;
1529
2992
  }
1530
2993
 
1531
2994
  const key = patchPath[patchPath.length - 1];
1532
2995
  if (op === "remove") {
1533
2996
  if (Array.isArray(parent) && Number.isInteger(key)) {
2997
+ if (key < 0 || key >= parent.length) {
2998
+ return false;
2999
+ }
1534
3000
  parent.splice(key, 1);
3001
+ return true;
1535
3002
  } else if (parent && typeof parent === "object") {
3003
+ if (!Object.prototype.hasOwnProperty.call(parent, key)) {
3004
+ return false;
3005
+ }
1536
3006
  delete parent[key];
3007
+ return true;
1537
3008
  }
1538
- return;
3009
+ return false;
1539
3010
  }
1540
3011
 
1541
3012
  if (op === "add" || op === "replace") {
1542
3013
  if (Array.isArray(parent) && Number.isInteger(key)) {
1543
3014
  if (op === "add") {
3015
+ if (key < 0 || key > parent.length) {
3016
+ return false;
3017
+ }
1544
3018
  parent.splice(key, 0, patch.value);
1545
3019
  } else {
3020
+ if (key < 0 || key >= parent.length) {
3021
+ return false;
3022
+ }
1546
3023
  parent[key] = patch.value;
1547
3024
  }
3025
+ return true;
1548
3026
  } else if (parent && typeof parent === "object") {
1549
3027
  parent[key] = patch.value;
3028
+ return true;
1550
3029
  }
1551
3030
  }
1552
- }
1553
-
1554
- function writeFrame(socket, payload, callback) {
1555
- const body = Buffer.from(payload, "utf8");
1556
- const header = Buffer.alloc(FRAME_HEADER_BYTES);
1557
- header.writeUInt32LE(body.length, 0);
1558
- socket.write(Buffer.concat([header, body]), callback);
1559
- }
1560
-
1561
- function resolveDefaultIpcSocketPath() {
1562
- if (process.platform === "win32") {
1563
- return "\\\\.\\pipe\\codex-ipc";
1564
- }
1565
-
1566
- const uid = typeof process.getuid === "function" ? process.getuid() : 0;
1567
- return path.join(os.tmpdir(), "codex-ipc", `ipc-${uid}.sock`);
3031
+ return false;
1568
3032
  }
1569
3033
 
1570
3034
  function readThreadId(params) {
@@ -1574,109 +3038,12 @@ function readThreadId(params) {
1574
3038
  || readString(params?.conversation_id);
1575
3039
  }
1576
3040
 
1577
- function requestIdKey(value) {
1578
- if (typeof value === "string" && value) {
1579
- return value;
1580
- }
1581
- if (typeof value === "number" && Number.isFinite(value)) {
1582
- return String(value);
1583
- }
1584
- return "";
1585
- }
1586
-
1587
- function parseToolArguments(rawArguments) {
1588
- const parsed = safeParseJSON(rawArguments);
1589
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1590
- }
1591
-
1592
- function resolveToolCommand(toolName, argumentsObject) {
1593
- if (!isCommandToolName(toolName)) {
1594
- return toolName;
1595
- }
1596
-
1597
- return readString(argumentsObject.cmd)
1598
- || readString(argumentsObject.command)
1599
- || readString(argumentsObject.raw_command)
1600
- || readString(argumentsObject.rawCommand)
1601
- || toolName;
1602
- }
1603
-
1604
- function resolveToolWorkingDirectory(argumentsObject, item = {}) {
1605
- return readString(argumentsObject.workdir)
1606
- || readString(argumentsObject.cwd)
1607
- || readString(argumentsObject.working_directory)
1608
- || readString(item?.cwd)
1609
- || readString(item?.workdir)
1610
- || "";
1611
- }
1612
-
1613
- function isCommandToolName(toolName) {
1614
- const normalized = readString(toolName).toLowerCase();
1615
- return normalized === "exec_command"
1616
- || normalized === "shell_command"
1617
- || normalized.endsWith(".exec_command")
1618
- || normalized.endsWith(".shell_command");
1619
- }
1620
-
1621
- function genericToolActivityMessage(toolName) {
1622
- switch (readString(toolName).toLowerCase()) {
1623
- case "apply_patch":
1624
- return "Applying patch";
1625
- case "write_stdin":
1626
- return "Writing to terminal";
1627
- case "read_thread_terminal":
1628
- return "Reading terminal output";
1629
- default:
1630
- return `Running ${toolName}`;
1631
- }
1632
- }
1633
-
1634
- function buildSyntheticActivityItemId(kind, threadId, turnId, callId) {
1635
- return `${kind}:${threadId}:${turnId}:${callId}`;
1636
- }
1637
-
1638
- function createDesktopIpcNotification(method, params = {}) {
1639
- return {
1640
- method,
1641
- params: {
1642
- remodexDesktopMirror: true,
1643
- remodexDesktopIpcMirror: true,
1644
- ...params,
1645
- },
1646
- };
1647
- }
1648
-
1649
- function readString(value) {
1650
- return typeof value === "string" && value.trim() ? value.trim() : "";
1651
- }
1652
-
1653
- function normalizeToken(value) {
1654
- return typeof value === "string"
1655
- ? value.toLowerCase().replace(/[_-\s]+/g, "")
1656
- : "";
1657
- }
1658
-
1659
- function cloneJSON(value) {
1660
- return JSON.parse(JSON.stringify(value));
1661
- }
1662
-
1663
- function safeParseJSON(value) {
1664
- try {
1665
- return JSON.parse(value);
1666
- } catch {
1667
- return null;
1668
- }
1669
- }
1670
-
1671
3041
  module.exports = {
1672
3042
  applyConversationStateChange,
3043
+ buildDesktopTurnsListResult,
1673
3044
  createDesktopIpcActionFollower,
1674
3045
  desktopFollowerPayloadForResponse,
1675
- hasActiveDesktopActivityForTurn,
1676
3046
  projectDesktopAssistantDeltaNotifications,
1677
- projectDesktopActivityNotifications,
1678
- projectDesktopTurnCompletedNotifications,
1679
- projectDesktopUserMessageNotifications,
1680
3047
  projectPendingDesktopActions,
1681
3048
  resolveDefaultIpcSocketPath,
1682
3049
  seedConversationStateFromThreadRead,